mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +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
@@ -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, "&")
|
||||
.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<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());
|
||||
}
|
||||
Reference in New Issue
Block a user