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:
Vance Ingalls
2026-07-03 18:19:01 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 4d60792adc
commit ee7a96147a
12 changed files with 1042 additions and 24 deletions
+2 -7
View File
@@ -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<Uint8Array> {
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}`);
@@ -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("<svg/>");
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");
});
});
@@ -0,0 +1,138 @@
/**
* `hyperframes figma component <ref>` — 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
import { runAssetImport } from "./asset.js";
import { downloadRender } from "./download.js";
export interface ComponentImportDeps {
projectDir: string;
client: FigmaClient;
download: (url: string) => Promise<Uint8Array>;
}
export interface ComponentImportResult {
name: string;
htmlPath: string;
unresolved: BindingSite[];
rasterized: RasterizeRequest[];
}
export async function runComponentImport(
refInput: string,
deps: ComponentImportDeps,
): Promise<ComponentImportResult> {
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.`,
);
}
},
});
@@ -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<Uint8Array> {
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());
}