mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
Brand-loop live test (SDS duplicate, plans/figma/brand-loop-test-plan.md) proved the recolor chain end-to-end and surfaced three gaps: - runtime now defines every declared composition variable as a CSS custom property (document root at init + scoped sub-comp hosts in the loader), so imported var(--slug, literal) fills resolve live — without this the frozen literal always won and variable-driven rebranding could not propagate. Slug kept byte-compatible with the figma importer (parity test). render --variables overrides win. - figma component --name: variant frames are often all named 'Platform=Desktop' and slug-collided across imports. - imported fragments carry data-hf-snippet and the project linter skips composition-root rules for them. - /figma skill documents the field-tested non-Enterprise tokens path (MCP get_variable_defs joined with REST boundVariables ids). Shared-helper extractions (injectScopedStyles, flattenedRoot module, parseHostVariableValues, rasterizeFallback, shapeCss) satisfy the dedup/complexity audit the runtime changes tripped. Validated live: brand-loop renders purple from the attribute alone (no manual :root); 118 figma + 662 runtime/compiler + 331 lint tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
229 lines
7.7 KiB
TypeScript
229 lines
7.7 KiB
TypeScript
/**
|
|
* `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,
|
|
FigmaClientError,
|
|
nodeToHtml,
|
|
parseFigmaRef,
|
|
readBindings,
|
|
resolveBindings,
|
|
slugify,
|
|
type BindingSite,
|
|
type FigmaClient,
|
|
type NodeToHtmlResult,
|
|
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, type AssetImportResult } from "./asset.js";
|
|
import { downloadRender } from "./download.js";
|
|
import { withFigmaErrors } from "./cliError.js";
|
|
|
|
export interface ComponentImportDeps {
|
|
projectDir: string;
|
|
client: FigmaClient;
|
|
download: (url: string) => Promise<Uint8Array>;
|
|
/** override the component name — figma variant frames are often all named
|
|
* "Platform=Desktop", which would slug-collide across imports */
|
|
name?: string;
|
|
}
|
|
|
|
export interface ComponentImportResult {
|
|
name: string;
|
|
htmlPath: string;
|
|
unresolved: BindingSite[];
|
|
rasterized: RasterizeRequest[];
|
|
/** node ids figma refused to render as svg AND png — placeholders shipped without src */
|
|
failedRasterize: string[];
|
|
}
|
|
|
|
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, { rootName: deps.name });
|
|
|
|
const name = slugify(deps.name ?? 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 });
|
|
|
|
const { html, frozenAssets, failedRasterize } = await rasterizeFallback(
|
|
mapped,
|
|
ref.fileKey,
|
|
componentDir,
|
|
deps,
|
|
);
|
|
|
|
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",
|
|
},
|
|
// The paths the frozen files ACTUALLY landed at (image_NNN.svg), which
|
|
// is also what the emitted HTML references — not the slug names.
|
|
...frozenAssets.map((p) => ({
|
|
path: p.split("/").pop() ?? p,
|
|
target: p.replaceAll("\\", "/"),
|
|
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,
|
|
failedRasterize,
|
|
};
|
|
}
|
|
|
|
interface RasterizeOutcome {
|
|
html: string;
|
|
frozenAssets: string[];
|
|
failedRasterize: string[];
|
|
}
|
|
|
|
/**
|
|
* Rasterize fallback: export each unmappable node via Phase 1 and point the
|
|
* placeholder img at the frozen file (path relative to the component). figma
|
|
* sometimes refuses to render a node (nested instances commonly fail as svg)
|
|
* — retry once as png, then skip THAT node and keep the import: one
|
|
* unrenderable node must not abort the whole component. Skipped placeholders
|
|
* keep their data-figma-rasterize marker (no src) so the gap is visible.
|
|
*/
|
|
async function rasterizeFallback(
|
|
mapped: NodeToHtmlResult,
|
|
fileKey: string,
|
|
componentDir: string,
|
|
deps: ComponentImportDeps,
|
|
): Promise<RasterizeOutcome> {
|
|
let html = mapped.html;
|
|
const frozenAssets: string[] = [];
|
|
const failedRasterize: string[] = [];
|
|
for (const req of mapped.rasterize) {
|
|
const asset = await renderWithPngRetry(fileKey, req, deps);
|
|
if (asset === null) {
|
|
failedRasterize.push(req.nodeId);
|
|
console.warn(
|
|
`could not render node ${req.nodeId} ("${req.name}") as svg or png — leaving its placeholder without src`,
|
|
);
|
|
continue;
|
|
}
|
|
frozenAssets.push(asset.record.path);
|
|
// src is a URL — always forward slashes, even when relative() yields
|
|
// windows separators.
|
|
const srcRel = relative(componentDir, join(deps.projectDir, asset.record.path)).replaceAll(
|
|
"\\",
|
|
"/",
|
|
);
|
|
// The search key must match the EMITTED (html-escaped) node id, and
|
|
// replaceAll covers the same node appearing twice in the tree.
|
|
const emittedId = escapeAttr(req.nodeId);
|
|
html = html.replaceAll(
|
|
`data-figma-rasterize="${emittedId}" `,
|
|
`data-figma-rasterize="${emittedId}" src="${escapeAttr(srcRel)}" `,
|
|
);
|
|
}
|
|
return { html, frozenAssets, failedRasterize };
|
|
}
|
|
|
|
async function renderWithPngRetry(
|
|
fileKey: string,
|
|
req: RasterizeRequest,
|
|
deps: ComponentImportDeps,
|
|
): Promise<AssetImportResult | null> {
|
|
for (const format of ["svg", "png"] as const) {
|
|
try {
|
|
return await runAssetImport(
|
|
`${fileKey}:${req.nodeId}`,
|
|
{ format, description: req.name },
|
|
{ projectDir: deps.projectDir, client: deps.client, download: deps.download },
|
|
);
|
|
} catch (err) {
|
|
if (!(err instanceof FigmaClientError) || err.code !== "RENDER_FAILED") throw err;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
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 },
|
|
name: {
|
|
type: "string",
|
|
description: "component name override (variant frames often share a name and would collide)",
|
|
},
|
|
dir: { type: "string", description: "project directory", default: "." },
|
|
},
|
|
async run({ args }) {
|
|
await withFigmaErrors("figma:component", async () => {
|
|
const t0 = Date.now();
|
|
const client = createFigmaClient({ token: process.env.FIGMA_TOKEN ?? "" });
|
|
const result = await runComponentImport(args.ref, {
|
|
projectDir: args.dir,
|
|
client,
|
|
download: downloadRender,
|
|
name: args.name,
|
|
});
|
|
console.log(`imported component "${result.name}" → ${result.htmlPath}`);
|
|
if (result.rasterized.length > 0) {
|
|
const ok = result.rasterized.length - result.failedRasterize.length;
|
|
console.log(
|
|
result.failedRasterize.length > 0
|
|
? `rasterized ${ok}/${result.rasterized.length} node(s) via asset export — ${result.failedRasterize.length} skipped (unrenderable; placeholders have no src)`
|
|
: `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.`,
|
|
);
|
|
}
|
|
const { trackFigmaImport } = await import("../../telemetry/index.js");
|
|
trackFigmaImport({
|
|
phase: "component",
|
|
unresolvedBindings: result.unresolved.length,
|
|
rasterizedNodes: result.rasterized.length,
|
|
rasterizeFailures: result.failedRasterize.length,
|
|
durationMs: Date.now() - t0,
|
|
});
|
|
});
|
|
},
|
|
});
|