diff --git a/packages/cli/src/commands/figma/asset.ts b/packages/cli/src/commands/figma/asset.ts index 08685506d4..1e95176707 100644 --- a/packages/cli/src/commands/figma/asset.ts +++ b/packages/cli/src/commands/figma/asset.ts @@ -22,6 +22,7 @@ import { } from "@hyperframes/core/figma"; import { existsSync } from "node:fs"; import { join, relative } from "node:path"; +import { downloadRender } from "./download.js"; export interface AssetImportOptions { format: FigmaAssetFormat; @@ -41,12 +42,6 @@ export interface AssetImportResult { reused: boolean; } -async function defaultDownload(url: string): Promise { - const res = await fetch(url); - if (!res.ok) throw new Error(`figma render download failed: HTTP ${res.status}`); - return new Uint8Array(await res.arrayBuffer()); -} - export async function runAssetImport( refInput: string, opts: AssetImportOptions, @@ -137,7 +132,7 @@ export default defineCommand({ format: parseFormat(args.format), scale: args.scale !== undefined ? Number(args.scale) : undefined, }, - { projectDir: args.dir, client, download: defaultDownload }, + { projectDir: args.dir, client, download: downloadRender }, ); const verb = result.reused ? "reused" : "imported"; console.log(`${verb} ${result.record.id} → ${result.record.path}`); diff --git a/packages/cli/src/commands/figma/component.test.ts b/packages/cli/src/commands/figma/component.test.ts new file mode 100644 index 0000000000..a5d81b3ede --- /dev/null +++ b/packages/cli/src/commands/figma/component.test.ts @@ -0,0 +1,97 @@ +// @vitest-environment node +import { describe, expect, it, afterEach, beforeEach } from "vitest"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runComponentImport } from "./component.js"; +import { appendBinding, type FigmaClient } from "@hyperframes/core/figma"; + +let dir = ""; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "hf-figma-component-")); +}); +afterEach(() => rmSync(dir, { recursive: true, force: true })); + +const TREE = { + id: "1:1", + name: "Hero Card", + type: "FRAME", + absoluteBoundingBox: { x: 0, y: 0, width: 400, height: 300 }, + fills: [{ type: "SOLID", color: { r: 1, g: 1, b: 1, a: 1 } }], + children: [ + { + id: "1:2", + name: "Badge", + type: "RECTANGLE", + absoluteBoundingBox: { x: 20, y: 20, width: 100, height: 40 }, + fills: [{ type: "SOLID", color: { r: 0, g: 0.4, b: 1, a: 1 } }], + boundVariables: { fills: [{ type: "VARIABLE_ALIAS", id: "VariableID:1:1" }] }, + }, + { + id: "1:3", + name: "Mark", + type: "VECTOR", + absoluteBoundingBox: { x: 40, y: 100, width: 64, height: 64 }, + }, + ], +}; + +const SVG = new TextEncoder().encode(""); + +function client(): FigmaClient { + return { + renderNode: () => Promise.resolve({ url: "https://cdn/x", ext: "svg" }), + imageFills: () => Promise.resolve(new Map()), + variables: () => Promise.resolve({ variables: {}, variableCollections: {} }), + styles: () => Promise.resolve([]), + nodeTree: () => Promise.resolve(TREE), + fileVersion: () => Promise.resolve({ version: "7", lastModified: "2026-07-01" }), + }; +} + +async function importHero() { + const out = await runComponentImport("FILE:1-1", { + projectDir: dir, + client: client(), + download: () => Promise.resolve(SVG), + }); + return { out, html: readFileSync(join(dir, out.htmlPath), "utf8") }; +} + +describe("runComponentImport", () => { + it("writes component html with resolved var() when the binding index knows the id", async () => { + appendBinding(dir, { + kind: "binding", + figmaId: "VariableID:1:1", + sourceFileKey: "FILE", + compositionVariableId: "figma:Blue/500", + version: "7", + }); + const { out, html } = await importHero(); + expect(html).toContain("var(--figma-blue-500, #0066FF)"); + expect(out.unresolved).toEqual([]); + }); + + it("bakes literals and reports unresolved bindings when the index is empty", async () => { + const { out, html } = await importHero(); + expect(html).toContain("background: #0066FF"); + expect(html).not.toContain("var("); + expect(out.unresolved).toHaveLength(1); + expect(out.unresolved[0]?.figmaId).toBe("VariableID:1:1"); + }); + + it("rasterizes vectors into frozen assets, fills img src, writes the registry item", async () => { + const { out, html } = await importHero(); + expect(html).toContain('src="../../../.media/images/image_001.svg"'); + expect(out.rasterized).toHaveLength(1); + const item = JSON.parse( + readFileSync( + join(dir, "compositions", "components", "hero-card", "registry-item.json"), + "utf8", + ), + ) as { type: string; files: Array<{ type: string }> }; + expect(item.type).toBe("hyperframes:component"); + expect(item.files.some((f) => f.type === "hyperframes:snippet")).toBe(true); + expect(out.name).toBe("hero-card"); + }); +}); diff --git a/packages/cli/src/commands/figma/component.ts b/packages/cli/src/commands/figma/component.ts new file mode 100644 index 0000000000..beb1ec0918 --- /dev/null +++ b/packages/cli/src/commands/figma/component.ts @@ -0,0 +1,138 @@ +/** + * `hyperframes figma component ` — Phase 3: node tree → editable HTML + * component with the §7.1 binding pass, per-node rasterize fallback via + * Phase-1 asset import, packaged as a registry item. + */ + +import { defineCommand } from "citty"; +import { + createFigmaClient, + nodeToHtml, + parseFigmaRef, + readBindings, + resolveBindings, + slugify, + type BindingSite, + type FigmaClient, + type RasterizeRequest, +} from "@hyperframes/core/figma"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; + +function escapeAttr(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} +import { runAssetImport } from "./asset.js"; +import { downloadRender } from "./download.js"; + +export interface ComponentImportDeps { + projectDir: string; + client: FigmaClient; + download: (url: string) => Promise; +} + +export interface ComponentImportResult { + name: string; + htmlPath: string; + unresolved: BindingSite[]; + rasterized: RasterizeRequest[]; +} + +export async function runComponentImport( + refInput: string, + deps: ComponentImportDeps, +): Promise { + const ref = parseFigmaRef(refInput); + if (!ref.nodeId) throw new Error(`ref "${refInput}" has no node id`); + + const tree = await deps.client.nodeTree(ref); + const bindings = resolveBindings(tree, readBindings(deps.projectDir)); + const mapped = nodeToHtml(tree, bindings); + + const name = slugify(tree.name); + const componentDir = join(deps.projectDir, "compositions", "components", name); + if (existsSync(componentDir)) + console.warn( + `component dir compositions/components/${name} already exists — overwriting (rename the figma frame for a separate import)`, + ); + mkdirSync(componentDir, { recursive: true }); + + // Rasterize fallback: export each unmappable node via Phase 1 and point + // the placeholder img at the frozen file (path relative to the component). + // The search key must match the EMITTED (html-escaped) node id, and + // replaceAll covers the same node appearing twice in the tree. + let html = mapped.html; + for (const req of mapped.rasterize) { + const asset = await runAssetImport( + `${ref.fileKey}:${req.nodeId}`, + { format: "svg" }, + { projectDir: deps.projectDir, client: deps.client, download: deps.download }, + ); + const srcRel = relative(componentDir, join(deps.projectDir, asset.record.path)); + const emittedId = escapeAttr(req.nodeId); + html = html.replaceAll( + `data-figma-rasterize="${emittedId}" `, + `data-figma-rasterize="${emittedId}" src="${escapeAttr(srcRel)}" `, + ); + } + + const htmlFile = join(componentDir, `${name}.html`); + writeFileSync(htmlFile, html + "\n"); + + const registryItem = { + name, + type: "hyperframes:component", + description: `Imported from figma ${ref.fileKey}/${ref.nodeId}`, + files: [ + { + path: `${name}.html`, + target: `compositions/components/${name}/${name}.html`, + type: "hyperframes:snippet", + }, + ...mapped.rasterize.map((r) => ({ + path: `${r.slug}.svg`, + target: `.media/images/${r.slug}.svg`, + type: "hyperframes:asset", + })), + ], + }; + writeFileSync( + join(componentDir, "registry-item.json"), + JSON.stringify(registryItem, null, 2) + "\n", + ); + + return { + name, + htmlPath: relative(deps.projectDir, htmlFile), + unresolved: bindings.unresolved, + rasterized: mapped.rasterize, + }; +} + +export default defineCommand({ + meta: { name: "component", description: "Import a figma frame as an editable HTML component" }, + args: { + ref: { type: "positional", description: "figma URL or fileKey:nodeId", required: true }, + dir: { type: "string", description: "project directory", default: "." }, + }, + async run({ args }) { + const client = createFigmaClient({ token: process.env.FIGMA_TOKEN ?? "" }); + const result = await runComponentImport(args.ref, { + projectDir: args.dir, + client, + download: downloadRender, + }); + console.log(`imported component "${result.name}" → ${result.htmlPath}`); + if (result.rasterized.length > 0) + console.log(`rasterized ${result.rasterized.length} node(s) via asset export`); + if (result.unresolved.length > 0) { + console.log( + `${result.unresolved.length} binding(s) reference tokens not yet imported — colors baked as literals (flagged data-figma-unresolved). Run \`hyperframes figma tokens\` on the source/library file, then re-import to link them.`, + ); + } + }, +}); diff --git a/packages/cli/src/commands/figma/download.ts b/packages/cli/src/commands/figma/download.ts new file mode 100644 index 0000000000..fdd2162d36 --- /dev/null +++ b/packages/cli/src/commands/figma/download.ts @@ -0,0 +1,15 @@ +import { exceedsFreezeCap, MAX_FREEZE_BYTES } from "@hyperframes/core/figma"; + +/** Fetch a short-lived figma CDN render url into bytes. */ +export async function downloadRender(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`figma render download failed: HTTP ${res.status}`); + // Reject oversized responses before buffering the body — the freeze cap + // alone only fires after the full allocation. + const declared = Number(res.headers.get("content-length") ?? 0); + if (exceedsFreezeCap(declared)) + throw new Error( + `figma render download failed: content-length ${declared} exceeds ${MAX_FREEZE_BYTES} cap`, + ); + return new Uint8Array(await res.arrayBuffer()); +} diff --git a/packages/core/src/figma/color.ts b/packages/core/src/figma/color.ts new file mode 100644 index 0000000000..593eefc4d5 --- /dev/null +++ b/packages/core/src/figma/color.ts @@ -0,0 +1,21 @@ +function isRecord(value: unknown): value is Record { + 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))})`; +} diff --git a/packages/core/src/figma/index.ts b/packages/core/src/figma/index.ts index 0c90a3245d..369687ab2c 100644 --- a/packages/core/src/figma/index.ts +++ b/packages/core/src/figma/index.ts @@ -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, diff --git a/packages/core/src/figma/nodeDocument.ts b/packages/core/src/figma/nodeDocument.ts new file mode 100644 index 0000000000..024c7c584d --- /dev/null +++ b/packages/core/src/figma/nodeDocument.ts @@ -0,0 +1,29 @@ +import type { FigmaNodeDocument } from "./client"; + +function isRecord(value: unknown): value is Record { + 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; +} diff --git a/packages/core/src/figma/nodeToHtml.test.ts b/packages/core/src/figma/nodeToHtml.test.ts new file mode 100644 index 0000000000..1c584af17a --- /dev/null +++ b/packages/core/src/figma/nodeToHtml.test.ts @@ -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 & 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(" { + 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/); + }); +}); diff --git a/packages/core/src/figma/nodeToHtml.ts b/packages/core/src/figma/nodeToHtml.ts new file mode 100644 index 0000000000..39640525df --- /dev/null +++ b/packages/core/src/figma/nodeToHtml.ts @@ -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 { + 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, """); +} + +function round(n: number): number { + return Math.round(n * 100) / 100; +} + +function firstVisibleFill(node: FigmaNodeDocument): Record | 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 | 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 | 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; +} + +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 `${escapeHtml(node.name)}`; + } + + if (node.type === "TEXT") { + const text = typeof node.characters === "string" ? escapeHtml(node.characters) : ""; + return `
${text}
`; + } + + return `
${renderChildren(node, ctx, depth)}
`; +} + +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 }; +} diff --git a/packages/core/src/figma/resolveBindings.test.ts b/packages/core/src/figma/resolveBindings.test.ts new file mode 100644 index 0000000000..1c799e8e2f --- /dev/null +++ b/packages/core/src/figma/resolveBindings.test.ts @@ -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 { + 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: [] }); + }); +}); diff --git a/packages/core/src/figma/resolveBindings.ts b/packages/core/src/figma/resolveBindings.ts new file mode 100644 index 0000000000..3a11532325 --- /dev/null +++ b/packages/core/src/figma/resolveBindings.ts @@ -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 { + 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 }; +} diff --git a/packages/core/src/figma/tokensToVariables.ts b/packages/core/src/figma/tokensToVariables.ts index 13f88a58d6..b6948c7afa 100644 --- a/packages/core/src/figma/tokensToVariables.ts +++ b/packages/core/src/figma/tokensToVariables.ts @@ -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 { 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 | 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;