mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(core,cli): figma component import with binding-aware node-to-html mapper (M3) (#1872)
resolveBindings: scan the full tree (boundVariables + style ids, alias chains, children) and partition exact-ID-only against the binding index before any CSS is emitted per spec 7.1 - never value matching. nodeToHtml: absolute geometry at figma bounds inside a fixed-size root, solid/linear-gradient fills, corner radius, opacity, drop shadow, blur, text styles; resolved bindings emit var(--slug, literal), unresolved bake literals with data-figma-unresolved; visible:false respected; vectors/boolean ops route to a rasterize list. hyperframes figma component: tree -> bindings -> html, rasterize fallback via Phase-1 asset export with src backfill, registry-item packaging, unresolved-binding guidance in output. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4d60792adc
commit
ee7a96147a
@@ -0,0 +1,21 @@
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function toHexByte(channel: number): string {
|
||||
return Math.round(channel * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0")
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
/** figma {r,g,b,a?} floats (0..1) → #RRGGBB, or rgba() when translucent. */
|
||||
export function figmaColorToCss(value: unknown, extraOpacity = 1): string | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const { r, g, b, a } = value;
|
||||
if (typeof r !== "number" || typeof g !== "number" || typeof b !== "number") return null;
|
||||
const alpha = (typeof a === "number" ? a : 1) * extraOpacity;
|
||||
if (alpha >= 1) return `#${toHexByte(r)}${toHexByte(g)}${toHexByte(b)}`;
|
||||
const c = (n: number) => Math.round(n * 255);
|
||||
return `rgba(${c(r)}, ${c(g)}, ${c(b)}, ${Number(alpha.toFixed(4))})`;
|
||||
}
|
||||
@@ -42,6 +42,11 @@ export {
|
||||
recordLibraryFile,
|
||||
} from "./bindings";
|
||||
export type { FigmaBindingRecord } from "./bindings";
|
||||
export { figmaColorToCss } from "./color";
|
||||
export { resolveBindings } from "./resolveBindings";
|
||||
export type { BindingSite, ResolvedBindingSite, ResolveBindingsResult } from "./resolveBindings";
|
||||
export { nodeToHtml, slugify } from "./nodeToHtml";
|
||||
export type { NodeToHtmlResult, RasterizeRequest } from "./nodeToHtml";
|
||||
export { tokensToVariables } from "./tokensToVariables";
|
||||
export type {
|
||||
CompositionVariableEntry,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { FigmaNodeDocument } from "./client";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
/** Narrow an unknown child entry to a node document, or null. */
|
||||
function asNodeDocument(value: unknown): FigmaNodeDocument | null {
|
||||
if (
|
||||
isRecord(value) &&
|
||||
typeof value.id === "string" &&
|
||||
typeof value.name === "string" &&
|
||||
typeof value.type === "string"
|
||||
) {
|
||||
return { ...value, id: value.id, name: value.name, type: value.type };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Typed children of a node document (unknown-shaped entries skipped). */
|
||||
export function childDocuments(node: FigmaNodeDocument): FigmaNodeDocument[] {
|
||||
if (!Array.isArray(node.children)) return [];
|
||||
const out: FigmaNodeDocument[] = [];
|
||||
for (const child of node.children) {
|
||||
const doc = asNodeDocument(child);
|
||||
if (doc) out.push(doc);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { nodeToHtml } from "./nodeToHtml";
|
||||
import type { FigmaNodeDocument } from "./client";
|
||||
|
||||
const BOX = (x: number, y: number, width: number, height: number) => ({ x, y, width, height });
|
||||
|
||||
const SOLID_BLUE = { type: "SOLID", color: { r: 0, g: 0.4, b: 1, a: 1 } };
|
||||
|
||||
function frame(children: FigmaNodeDocument[]): FigmaNodeDocument {
|
||||
return {
|
||||
id: "1:1",
|
||||
name: "Hero Card",
|
||||
type: "FRAME",
|
||||
absoluteBoundingBox: BOX(100, 200, 800, 600),
|
||||
fills: [{ type: "SOLID", color: { r: 1, g: 1, b: 1, a: 1 } }],
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
describe("nodeToHtml", () => {
|
||||
it("renders the root frame at exact size with a stable id", () => {
|
||||
const out = nodeToHtml(frame([]), { resolved: [], unresolved: [] });
|
||||
expect(out.html).toContain('id="hero-card"');
|
||||
expect(out.html).toContain('data-figma-id="1:1"');
|
||||
expect(out.html).toContain("width: 800px");
|
||||
expect(out.html).toContain("height: 600px");
|
||||
expect(out.html).toContain("position: relative");
|
||||
expect(out.html).toContain("background: #FFFFFF");
|
||||
});
|
||||
|
||||
it("absolutely positions children relative to the root frame", () => {
|
||||
const out = nodeToHtml(
|
||||
frame([
|
||||
{
|
||||
id: "1:2",
|
||||
name: "Badge",
|
||||
type: "RECTANGLE",
|
||||
absoluteBoundingBox: BOX(140, 260, 120, 40),
|
||||
fills: [SOLID_BLUE],
|
||||
cornerRadius: 8,
|
||||
opacity: 0.9,
|
||||
},
|
||||
]),
|
||||
{ resolved: [], unresolved: [] },
|
||||
);
|
||||
expect(out.html).toContain("left: 40px");
|
||||
expect(out.html).toContain("top: 60px");
|
||||
expect(out.html).toContain("width: 120px");
|
||||
expect(out.html).toContain("border-radius: 8px");
|
||||
expect(out.html).toContain("opacity: 0.9");
|
||||
expect(out.html).toContain("background: #0066FF");
|
||||
});
|
||||
|
||||
it("emits var() with literal fallback for resolved bindings", () => {
|
||||
const out = nodeToHtml(
|
||||
frame([
|
||||
{
|
||||
id: "1:2",
|
||||
name: "Badge",
|
||||
type: "RECTANGLE",
|
||||
absoluteBoundingBox: BOX(100, 200, 10, 10),
|
||||
fills: [SOLID_BLUE],
|
||||
},
|
||||
]),
|
||||
{
|
||||
resolved: [
|
||||
{
|
||||
nodeId: "1:2",
|
||||
property: "fills",
|
||||
figmaId: "VariableID:1:1",
|
||||
compositionVariableId: "figma:Blue/500",
|
||||
},
|
||||
],
|
||||
unresolved: [],
|
||||
},
|
||||
);
|
||||
expect(out.html).toContain("background: var(--figma-blue-500, #0066FF)");
|
||||
});
|
||||
|
||||
it("bakes literals and flags unresolved bindings — never a dangling var()", () => {
|
||||
const out = nodeToHtml(
|
||||
frame([
|
||||
{
|
||||
id: "1:2",
|
||||
name: "Badge",
|
||||
type: "RECTANGLE",
|
||||
absoluteBoundingBox: BOX(100, 200, 10, 10),
|
||||
fills: [SOLID_BLUE],
|
||||
},
|
||||
]),
|
||||
{
|
||||
resolved: [],
|
||||
unresolved: [{ nodeId: "1:2", property: "fills", figmaId: "VariableID:9:9" }],
|
||||
},
|
||||
);
|
||||
expect(out.html).toContain("background: #0066FF");
|
||||
expect(out.html).not.toContain("var(");
|
||||
expect(out.html).toContain('data-figma-unresolved="fills"');
|
||||
});
|
||||
|
||||
it("renders text with font styles and escaped content", () => {
|
||||
const out = nodeToHtml(
|
||||
frame([
|
||||
{
|
||||
id: "1:3",
|
||||
name: "Title",
|
||||
type: "TEXT",
|
||||
absoluteBoundingBox: BOX(100, 200, 300, 50),
|
||||
characters: "Ship <fast> & true",
|
||||
style: {
|
||||
fontFamily: "Inter",
|
||||
fontWeight: 700,
|
||||
fontSize: 32,
|
||||
lineHeightPx: 40,
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
fills: [{ type: "SOLID", color: { r: 0, g: 0, b: 0, a: 1 } }],
|
||||
},
|
||||
]),
|
||||
{ resolved: [], unresolved: [] },
|
||||
);
|
||||
expect(out.html).toContain("Ship <fast> & true");
|
||||
expect(out.html).toContain("font-family: 'Inter'");
|
||||
expect(out.html).toContain("font-weight: 700");
|
||||
expect(out.html).toContain("font-size: 32px");
|
||||
expect(out.html).toContain("line-height: 40px");
|
||||
expect(out.html).toContain("color: #000000");
|
||||
});
|
||||
|
||||
it("routes vectors to the rasterize list with an img placeholder", () => {
|
||||
const out = nodeToHtml(
|
||||
frame([
|
||||
{
|
||||
id: "1:4",
|
||||
name: "Logo Mark",
|
||||
type: "VECTOR",
|
||||
absoluteBoundingBox: BOX(120, 220, 64, 64),
|
||||
},
|
||||
]),
|
||||
{ resolved: [], unresolved: [] },
|
||||
);
|
||||
expect(out.rasterize).toEqual([{ nodeId: "1:4", name: "Logo Mark", slug: "logo-mark" }]);
|
||||
expect(out.html).toContain('data-figma-rasterize="1:4"');
|
||||
expect(out.html).toContain("<img");
|
||||
});
|
||||
|
||||
it("skips invisible nodes and invisible fills (respects visible:false)", () => {
|
||||
const out = nodeToHtml(
|
||||
frame([
|
||||
{
|
||||
id: "1:5",
|
||||
name: "Hidden",
|
||||
type: "RECTANGLE",
|
||||
visible: false,
|
||||
absoluteBoundingBox: BOX(0, 0, 5, 5),
|
||||
fills: [SOLID_BLUE],
|
||||
},
|
||||
{
|
||||
id: "1:6",
|
||||
name: "NoFill",
|
||||
type: "RECTANGLE",
|
||||
absoluteBoundingBox: BOX(100, 200, 5, 5),
|
||||
fills: [{ ...SOLID_BLUE, visible: false }],
|
||||
},
|
||||
]),
|
||||
{ resolved: [], unresolved: [] },
|
||||
);
|
||||
expect(out.html).not.toContain("1:5");
|
||||
expect(out.html).toContain('data-figma-id="1:6"');
|
||||
expect(out.html).not.toContain("background: #0066FF");
|
||||
});
|
||||
|
||||
it("maps linear gradients and drop shadows", () => {
|
||||
const out = nodeToHtml(
|
||||
frame([
|
||||
{
|
||||
id: "1:7",
|
||||
name: "Grad",
|
||||
type: "RECTANGLE",
|
||||
absoluteBoundingBox: BOX(100, 200, 10, 10),
|
||||
fills: [
|
||||
{
|
||||
type: "GRADIENT_LINEAR",
|
||||
gradientStops: [
|
||||
{ color: { r: 1, g: 0, b: 0, a: 1 }, position: 0 },
|
||||
{ color: { r: 0, g: 0, b: 1, a: 1 }, position: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
effects: [
|
||||
{
|
||||
type: "DROP_SHADOW",
|
||||
color: { r: 0, g: 0, b: 0, a: 0.25 },
|
||||
offset: { x: 0, y: 4 },
|
||||
radius: 12,
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
{ resolved: [], unresolved: [] },
|
||||
);
|
||||
expect(out.html).toContain("linear-gradient(180deg, #FF0000 0%, #0000FF 100%)");
|
||||
expect(out.html).toContain("box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.25)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("nodeToHtml review fixes", () => {
|
||||
it("routes TEXT color through the binding-aware path (var() when resolved)", () => {
|
||||
const text: FigmaNodeDocument = {
|
||||
id: "2:1",
|
||||
name: "Title",
|
||||
type: "TEXT",
|
||||
absoluteBoundingBox: BOX(0, 0, 100, 20),
|
||||
fills: [SOLID_BLUE],
|
||||
characters: "Hi",
|
||||
};
|
||||
const { html } = nodeToHtml(frame([text]), {
|
||||
resolved: [
|
||||
{ nodeId: "2:1", property: "fills", figmaId: "V:1", compositionVariableId: "figma:Ink" },
|
||||
],
|
||||
unresolved: [],
|
||||
});
|
||||
expect(html).toContain("color: var(--figma-ink,");
|
||||
});
|
||||
|
||||
it("renders ELLIPSE with border-radius 50%", () => {
|
||||
const ellipse: FigmaNodeDocument = {
|
||||
id: "2:2",
|
||||
name: "Dot",
|
||||
type: "ELLIPSE",
|
||||
absoluteBoundingBox: BOX(0, 0, 10, 10),
|
||||
fills: [SOLID_BLUE],
|
||||
};
|
||||
const { html } = nodeToHtml(frame([ellipse]), { resolved: [], unresolved: [] });
|
||||
expect(html).toContain("border-radius: 50%");
|
||||
});
|
||||
|
||||
it("emits overflow hidden for clipsContent frames", () => {
|
||||
const clipped = { ...frame([]), clipsContent: true };
|
||||
const { html } = nodeToHtml(clipped, { resolved: [], unresolved: [] });
|
||||
expect(html).toContain("overflow: hidden");
|
||||
});
|
||||
|
||||
it("neutralizes a hostile fontFamily instead of breaking out of the style attribute", () => {
|
||||
const text: FigmaNodeDocument = {
|
||||
id: "2:3",
|
||||
name: "Evil",
|
||||
type: "TEXT",
|
||||
absoluteBoundingBox: BOX(0, 0, 100, 20),
|
||||
style: { fontFamily: `Mal"; onerror="alert(1)` },
|
||||
characters: "x",
|
||||
};
|
||||
const { html } = nodeToHtml(frame([text]), { resolved: [], unresolved: [] });
|
||||
expect(html).not.toContain('onerror="');
|
||||
expect(html).not.toMatch(/style="[^"]*" onerror/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Phase 3 mapper: figma node tree → editable HTML with inline styles
|
||||
* (design spec §7, hybrid fidelity routing).
|
||||
*
|
||||
* - Geometry is exact for free: absolute positioning at figma bounds inside
|
||||
* a fixed-size root — no responsive reflow, no drift.
|
||||
* - CSS where CSS is faithful: solid/linear-gradient fills, corner radius,
|
||||
* opacity, drop shadow, blur, text styles.
|
||||
* - Everything CSS can't match faithfully (vectors, boolean ops, exotic
|
||||
* paint) routes to the rasterize list — the caller exports those nodes as
|
||||
* images (Phase 1) and fills in the placeholder src.
|
||||
* - Bindings (§7.1): resolved sites emit var(--slug, literal) so a brand
|
||||
* refresh propagates; unresolved sites bake the literal and carry a
|
||||
* data-figma-unresolved flag. Never a dangling var().
|
||||
* - visible:false nodes and fills are skipped — figma's own renderer
|
||||
* semantics, not ours.
|
||||
*/
|
||||
|
||||
import type { FigmaNodeDocument } from "./client";
|
||||
import { figmaColorToCss } from "./color";
|
||||
import { childDocuments } from "./nodeDocument";
|
||||
import type { ResolveBindingsResult } from "./resolveBindings";
|
||||
|
||||
export interface RasterizeRequest {
|
||||
nodeId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface NodeToHtmlResult {
|
||||
html: string;
|
||||
rasterize: RasterizeRequest[];
|
||||
}
|
||||
|
||||
const RASTERIZE_TYPES = new Set([
|
||||
"VECTOR",
|
||||
"BOOLEAN_OPERATION",
|
||||
"STAR",
|
||||
"LINE",
|
||||
"POLYGON",
|
||||
"REGULAR_POLYGON",
|
||||
]);
|
||||
|
||||
interface Box {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function boxOf(node: FigmaNodeDocument): Box | null {
|
||||
const b = node.absoluteBoundingBox;
|
||||
if (
|
||||
isRecord(b) &&
|
||||
typeof b.x === "number" &&
|
||||
typeof b.y === "number" &&
|
||||
typeof b.width === "number" &&
|
||||
typeof b.height === "number"
|
||||
)
|
||||
return { x: b.x, y: b.y, width: b.width, height: b.height };
|
||||
return null;
|
||||
}
|
||||
|
||||
export function slugify(name: string): string {
|
||||
const slug = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return slug.length > 0 ? slug : "node";
|
||||
}
|
||||
|
||||
function cssVarName(compositionVariableId: string): string {
|
||||
return `--${slugify(compositionVariableId)}`;
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function round(n: number): number {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
function firstVisibleFill(node: FigmaNodeDocument): Record<string, unknown> | null {
|
||||
const fills = node.fills;
|
||||
if (!Array.isArray(fills)) return null;
|
||||
for (const fill of fills) {
|
||||
if (isRecord(fill) && fill.visible !== false) return fill;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function gradientCss(fill: Record<string, unknown>): string | null {
|
||||
const stops = fill.gradientStops;
|
||||
if (!Array.isArray(stops)) return null;
|
||||
const parts: string[] = [];
|
||||
for (const stop of stops) {
|
||||
if (!isRecord(stop) || typeof stop.position !== "number") return null;
|
||||
const color = figmaColorToCss(stop.color);
|
||||
if (color === null) return null;
|
||||
parts.push(`${color} ${round(stop.position * 100)}%`);
|
||||
}
|
||||
if (parts.length === 0) return null;
|
||||
// ponytail: fixed 180deg; exact angle from gradientHandlePositions when the
|
||||
// probe confirms the handle-space math (spec §7.1 probe list).
|
||||
return `linear-gradient(180deg, ${parts.join(", ")})`;
|
||||
}
|
||||
|
||||
function fillCss(node: FigmaNodeDocument): string | null {
|
||||
const fill = firstVisibleFill(node);
|
||||
if (fill === null) return null;
|
||||
if (fill.type === "SOLID") return figmaColorToCss(fill.color);
|
||||
if (fill.type === "GRADIENT_LINEAR") return gradientCss(fill);
|
||||
return null;
|
||||
}
|
||||
|
||||
function dropShadowCss(effect: Record<string, unknown>): string | null {
|
||||
if (!isRecord(effect.offset)) return null;
|
||||
const color = figmaColorToCss(effect.color);
|
||||
const { x, y } = effect.offset;
|
||||
if (color === null || typeof x !== "number" || typeof y !== "number") return null;
|
||||
const radius = typeof effect.radius === "number" ? effect.radius : 0;
|
||||
return `box-shadow: ${round(x)}px ${round(y)}px ${round(radius)}px ${color}`;
|
||||
}
|
||||
|
||||
function effectCss(effect: unknown): string | null {
|
||||
if (!isRecord(effect) || effect.visible === false) return null;
|
||||
if (effect.type === "DROP_SHADOW") return dropShadowCss(effect);
|
||||
if (effect.type === "LAYER_BLUR" && typeof effect.radius === "number")
|
||||
return `filter: blur(${round(effect.radius)}px)`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function effectsCss(node: FigmaNodeDocument, styles: string[]): void {
|
||||
const effects = node.effects;
|
||||
if (!Array.isArray(effects)) return;
|
||||
for (const effect of effects) {
|
||||
const css = effectCss(effect);
|
||||
if (css !== null) styles.push(css);
|
||||
}
|
||||
}
|
||||
|
||||
function textCss(node: FigmaNodeDocument, styles: string[]): void {
|
||||
const s = node.style;
|
||||
if (!isRecord(s)) return;
|
||||
// fontFamily is the one content-controlled string that reaches CSS — strip
|
||||
// quote/escape chars so it can't break out of the font-family value (the
|
||||
// whole style attribute is additionally HTML-escaped at emission).
|
||||
if (typeof s.fontFamily === "string")
|
||||
styles.push(`font-family: '${s.fontFamily.replace(/['"\\;]/g, "")}'`);
|
||||
if (typeof s.fontWeight === "number") styles.push(`font-weight: ${s.fontWeight}`);
|
||||
if (typeof s.fontSize === "number") styles.push(`font-size: ${round(s.fontSize)}px`);
|
||||
if (typeof s.lineHeightPx === "number") styles.push(`line-height: ${round(s.lineHeightPx)}px`);
|
||||
if (typeof s.letterSpacing === "number" && s.letterSpacing !== 0)
|
||||
styles.push(`letter-spacing: ${round(s.letterSpacing)}px`);
|
||||
}
|
||||
|
||||
interface RenderContext {
|
||||
origin: Box;
|
||||
bindings: ResolveBindingsResult;
|
||||
rasterize: RasterizeRequest[];
|
||||
usedSlugs: Set<string>;
|
||||
}
|
||||
|
||||
function uniqueSlug(ctx: RenderContext, name: string): string {
|
||||
const base = slugify(name);
|
||||
let slug = base;
|
||||
let n = 2;
|
||||
while (ctx.usedSlugs.has(slug)) slug = `${base}-${n++}`;
|
||||
ctx.usedSlugs.add(slug);
|
||||
return slug;
|
||||
}
|
||||
|
||||
function backgroundValue(node: FigmaNodeDocument, ctx: RenderContext): string | null {
|
||||
const literal = fillCss(node);
|
||||
if (literal === null) return null;
|
||||
const resolved = ctx.bindings.resolved.find(
|
||||
(r) => r.nodeId === node.id && r.property === "fills",
|
||||
);
|
||||
if (resolved) return `var(${cssVarName(resolved.compositionVariableId)}, ${literal})`;
|
||||
return literal;
|
||||
}
|
||||
|
||||
function unresolvedAttr(node: FigmaNodeDocument, ctx: RenderContext): string {
|
||||
const props = ctx.bindings.unresolved.filter((u) => u.nodeId === node.id).map((u) => u.property);
|
||||
if (props.length === 0) return "";
|
||||
return ` data-figma-unresolved="${escapeHtml(props.join(" "))}"`;
|
||||
}
|
||||
|
||||
function geometryCss(node: FigmaNodeDocument, ctx: RenderContext, isRoot: boolean): string[] {
|
||||
const box = boxOf(node);
|
||||
const styles: string[] = [];
|
||||
if (!box) return styles;
|
||||
if (isRoot) {
|
||||
styles.push(
|
||||
"position: relative",
|
||||
`width: ${round(box.width)}px`,
|
||||
`height: ${round(box.height)}px`,
|
||||
);
|
||||
} else {
|
||||
styles.push(
|
||||
"position: absolute",
|
||||
`left: ${round(box.x - ctx.origin.x)}px`,
|
||||
`top: ${round(box.y - ctx.origin.y)}px`,
|
||||
`width: ${round(box.width)}px`,
|
||||
`height: ${round(box.height)}px`,
|
||||
);
|
||||
}
|
||||
return styles;
|
||||
}
|
||||
|
||||
function decorationCss(node: FigmaNodeDocument, ctx: RenderContext): string[] {
|
||||
const styles: string[] = [];
|
||||
// backgroundValue is the binding-aware path (var(--slug, literal)) — TEXT
|
||||
// color goes through it too, so a token-bound text fill keeps its link.
|
||||
const bg = backgroundValue(node, ctx);
|
||||
if (node.type === "TEXT") {
|
||||
if (bg !== null) styles.push(`color: ${bg}`);
|
||||
textCss(node, styles);
|
||||
} else if (bg !== null) {
|
||||
styles.push(`background: ${bg}`);
|
||||
}
|
||||
if (node.type === "ELLIPSE") {
|
||||
styles.push("border-radius: 50%");
|
||||
} else if (typeof node.cornerRadius === "number" && node.cornerRadius > 0) {
|
||||
styles.push(`border-radius: ${round(node.cornerRadius)}px`);
|
||||
}
|
||||
if (node.clipsContent === true) styles.push("overflow: hidden");
|
||||
if (typeof node.opacity === "number" && node.opacity < 1)
|
||||
styles.push(`opacity: ${round(node.opacity)}`);
|
||||
effectsCss(node, styles);
|
||||
return styles;
|
||||
}
|
||||
|
||||
// ponytail: depth cap so a runaway auto-generated tree degrades to a skip
|
||||
// instead of a RangeError; real figma frames are nowhere near this deep.
|
||||
const MAX_DEPTH = 500;
|
||||
|
||||
function renderChildren(node: FigmaNodeDocument, ctx: RenderContext, depth: number): string {
|
||||
const childHtml: string[] = [];
|
||||
for (const child of childDocuments(node)) {
|
||||
const rendered = renderNodeHtml(child, ctx, false, depth + 1);
|
||||
if (rendered.length > 0) childHtml.push(rendered);
|
||||
}
|
||||
return childHtml.length > 0 ? `\n${childHtml.join("\n")}\n` : "";
|
||||
}
|
||||
|
||||
function renderNodeHtml(
|
||||
node: FigmaNodeDocument,
|
||||
ctx: RenderContext,
|
||||
isRoot: boolean,
|
||||
depth = 0,
|
||||
): string {
|
||||
if (node.visible === false || depth > MAX_DEPTH) return "";
|
||||
const slug = uniqueSlug(ctx, node.name);
|
||||
const style = escapeHtml(
|
||||
[...geometryCss(node, ctx, isRoot), ...decorationCss(node, ctx)].join("; "),
|
||||
);
|
||||
const idAttrs = `id="${slug}" data-figma-id="${escapeHtml(node.id)}"${unresolvedAttr(node, ctx)}`;
|
||||
|
||||
if (RASTERIZE_TYPES.has(node.type)) {
|
||||
ctx.rasterize.push({ nodeId: node.id, name: node.name, slug });
|
||||
return `<img ${idAttrs} data-figma-rasterize="${escapeHtml(node.id)}" alt="${escapeHtml(node.name)}" style="${style}" />`;
|
||||
}
|
||||
|
||||
if (node.type === "TEXT") {
|
||||
const text = typeof node.characters === "string" ? escapeHtml(node.characters) : "";
|
||||
return `<div ${idAttrs} style="${style}">${text}</div>`;
|
||||
}
|
||||
|
||||
return `<div ${idAttrs} style="${style}">${renderChildren(node, ctx, depth)}</div>`;
|
||||
}
|
||||
|
||||
export function nodeToHtml(
|
||||
root: FigmaNodeDocument,
|
||||
bindings: ResolveBindingsResult,
|
||||
): NodeToHtmlResult {
|
||||
const origin = boxOf(root) ?? { x: 0, y: 0, width: 0, height: 0 };
|
||||
const ctx: RenderContext = { origin, bindings, rasterize: [], usedSlugs: new Set() };
|
||||
const html = renderNodeHtml(root, ctx, true);
|
||||
return { html, rasterize: ctx.rasterize };
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveBindings } from "./resolveBindings";
|
||||
import type { FigmaBindingRecord } from "./bindings";
|
||||
import type { FigmaNodeDocument } from "./client";
|
||||
|
||||
const INDEX: FigmaBindingRecord[] = [
|
||||
{
|
||||
kind: "binding",
|
||||
figmaId: "VariableID:1:1",
|
||||
key: "kblue",
|
||||
sourceFileKey: "FILE",
|
||||
compositionVariableId: "figma:Blue/500",
|
||||
version: "7",
|
||||
},
|
||||
{
|
||||
kind: "binding",
|
||||
figmaId: "VariableID:1:2",
|
||||
key: "kbtn",
|
||||
sourceFileKey: "FILE",
|
||||
aliasChain: ["VariableID:1:2", "VariableID:1:1"],
|
||||
compositionVariableId: "figma:button/bg",
|
||||
version: "7",
|
||||
},
|
||||
];
|
||||
|
||||
function node(overrides: Partial<FigmaNodeDocument>): FigmaNodeDocument {
|
||||
return { id: "9:9", name: "n", type: "RECTANGLE", ...overrides };
|
||||
}
|
||||
|
||||
describe("resolveBindings", () => {
|
||||
it("resolves a fill bound to an indexed variable", () => {
|
||||
const doc = node({
|
||||
boundVariables: { fills: [{ type: "VARIABLE_ALIAS", id: "VariableID:1:1" }] },
|
||||
});
|
||||
const out = resolveBindings(doc, INDEX);
|
||||
expect(out.resolved).toEqual([
|
||||
{
|
||||
nodeId: "9:9",
|
||||
property: "fills",
|
||||
figmaId: "VariableID:1:1",
|
||||
compositionVariableId: "figma:Blue/500",
|
||||
},
|
||||
]);
|
||||
expect(out.unresolved).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves via alias chain membership (semantic id indexed under chain)", () => {
|
||||
const doc = node({
|
||||
boundVariables: { fills: [{ type: "VARIABLE_ALIAS", id: "VariableID:1:2" }] },
|
||||
});
|
||||
const out = resolveBindings(doc, INDEX);
|
||||
expect(out.resolved[0]?.compositionVariableId).toBe("figma:button/bg");
|
||||
});
|
||||
|
||||
it("partitions unknown ids as unresolved — exact-ID only, no value matching", () => {
|
||||
const doc = node({
|
||||
boundVariables: { fills: [{ type: "VARIABLE_ALIAS", id: "VariableID:99:1" }] },
|
||||
});
|
||||
const out = resolveBindings(doc, INDEX);
|
||||
expect(out.resolved).toEqual([]);
|
||||
expect(out.unresolved).toEqual([
|
||||
{ nodeId: "9:9", property: "fills", figmaId: "VariableID:99:1" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("walks children and collects style-id sites too", () => {
|
||||
const doc = node({
|
||||
type: "FRAME",
|
||||
children: [
|
||||
node({
|
||||
id: "9:10",
|
||||
type: "TEXT",
|
||||
styles: { text: "S:styleKey1" },
|
||||
}),
|
||||
],
|
||||
});
|
||||
const out = resolveBindings(doc, INDEX);
|
||||
expect(out.unresolved).toEqual([
|
||||
{ nodeId: "9:10", property: "style:text", figmaId: "S:styleKey1" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty partitions for an unbound tree", () => {
|
||||
const out = resolveBindings(node({}), INDEX);
|
||||
expect(out).toEqual({ resolved: [], unresolved: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Binding resolution pass (design spec §7.1): scan a node tree's complete
|
||||
* binding set — boundVariables and style ids, alias chains honored — and
|
||||
* partition against the binding index BEFORE any CSS is emitted.
|
||||
*
|
||||
* Exact-ID matching only. A missed link bakes a visually-correct literal;
|
||||
* a wrong link silently changes color at the next brand refresh. Never
|
||||
* match by value or name.
|
||||
*
|
||||
* NOTE: the consumer-side boundVariables shape is probe-flagged in the spec
|
||||
* (§7.1 pre-build probes). Extraction here is tolerant: single alias objects
|
||||
* and arrays of alias objects both count; unknown shapes are ignored rather
|
||||
* than guessed at.
|
||||
*/
|
||||
|
||||
import type { FigmaBindingRecord } from "./bindings";
|
||||
import type { FigmaNodeDocument } from "./client";
|
||||
import { childDocuments } from "./nodeDocument";
|
||||
|
||||
export interface BindingSite {
|
||||
nodeId: string;
|
||||
/** boundVariables property ("fills") or style slot prefixed "style:" ("style:text") */
|
||||
property: string;
|
||||
figmaId: string;
|
||||
}
|
||||
|
||||
export interface ResolvedBindingSite extends BindingSite {
|
||||
compositionVariableId: string;
|
||||
}
|
||||
|
||||
export interface ResolveBindingsResult {
|
||||
resolved: ResolvedBindingSite[];
|
||||
unresolved: BindingSite[];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function aliasId(value: unknown): string | null {
|
||||
if (isRecord(value) && value.type === "VARIABLE_ALIAS" && typeof value.id === "string")
|
||||
return value.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
function boundVariableSites(node: FigmaNodeDocument, out: BindingSite[]): void {
|
||||
const bound = node.boundVariables;
|
||||
if (!isRecord(bound)) return;
|
||||
for (const [property, value] of Object.entries(bound)) {
|
||||
const candidates = Array.isArray(value) ? value : [value];
|
||||
for (const item of candidates) {
|
||||
const id = aliasId(item);
|
||||
if (id) out.push({ nodeId: node.id, property, figmaId: id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function styleSites(node: FigmaNodeDocument, out: BindingSite[]): void {
|
||||
const styles = node.styles;
|
||||
if (!isRecord(styles)) return;
|
||||
for (const [slot, styleId] of Object.entries(styles)) {
|
||||
if (typeof styleId === "string" && styleId.length > 0)
|
||||
out.push({ nodeId: node.id, property: `style:${slot}`, figmaId: styleId });
|
||||
}
|
||||
}
|
||||
|
||||
function collectSites(node: FigmaNodeDocument, out: BindingSite[]): void {
|
||||
boundVariableSites(node, out);
|
||||
styleSites(node, out);
|
||||
for (const child of childDocuments(node)) collectSites(child, out);
|
||||
}
|
||||
|
||||
function findInIndex(index: FigmaBindingRecord[], figmaId: string): FigmaBindingRecord | null {
|
||||
for (const b of index) {
|
||||
if (b.figmaId === figmaId) return b;
|
||||
if (b.aliasChain?.includes(figmaId)) return b;
|
||||
if (b.key !== undefined && b.key === figmaId) return b;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveBindings(
|
||||
node: FigmaNodeDocument,
|
||||
index: FigmaBindingRecord[],
|
||||
): ResolveBindingsResult {
|
||||
const sites: BindingSite[] = [];
|
||||
collectSites(node, sites);
|
||||
|
||||
const resolved: ResolvedBindingSite[] = [];
|
||||
const unresolved: BindingSite[] = [];
|
||||
for (const site of sites) {
|
||||
const match = findInIndex(index, site.figmaId);
|
||||
if (match) resolved.push({ ...site, compositionVariableId: match.compositionVariableId });
|
||||
else unresolved.push(site);
|
||||
}
|
||||
return { resolved, unresolved };
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import type { FigmaVariablePayload, FigmaVariablesResult } from "./client";
|
||||
import type { FigmaBindingRecord } from "./bindings";
|
||||
import { figmaColorToCss } from "./color";
|
||||
|
||||
/** data-composition-variables entry (runtime getVariables contract). */
|
||||
export interface CompositionVariableEntry {
|
||||
@@ -54,22 +55,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function toHexByte(channel: number): string {
|
||||
return Math.round(channel * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0")
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
function colorToCss(value: Record<string, unknown>): string | null {
|
||||
const { r, g, b, a } = value;
|
||||
if (typeof r !== "number" || typeof g !== "number" || typeof b !== "number") return null;
|
||||
const alpha = typeof a === "number" ? a : 1;
|
||||
if (alpha >= 1) return `#${toHexByte(r)}${toHexByte(g)}${toHexByte(b)}`;
|
||||
const c = (n: number) => Math.round(n * 255);
|
||||
return `rgba(${c(r)}, ${c(g)}, ${c(b)}, ${alpha})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The collection's defaultModeId is the authority for "which mode is the
|
||||
* base value" (Light vs Dark). Falls back to first-inserted mode when the
|
||||
@@ -126,7 +111,7 @@ function toEntryValue(
|
||||
resolvedType: string | undefined,
|
||||
raw: unknown,
|
||||
): string | number | boolean | null {
|
||||
if (resolvedType === "COLOR") return isRecord(raw) ? colorToCss(raw) : null;
|
||||
if (resolvedType === "COLOR") return figmaColorToCss(raw);
|
||||
if (resolvedType === "FLOAT") return typeof raw === "number" ? raw : null;
|
||||
if (resolvedType === "BOOLEAN") return typeof raw === "boolean" ? raw : null;
|
||||
if (resolvedType === "STRING") return typeof raw === "string" ? raw : null;
|
||||
|
||||
Reference in New Issue
Block a user