diff --git a/packages/cli/src/commands/figma/component.test.ts b/packages/cli/src/commands/figma/component.test.ts index cc47031ec6..0f8a7ad5a5 100644 --- a/packages/cli/src/commands/figma/component.test.ts +++ b/packages/cli/src/commands/figma/component.test.ts @@ -74,7 +74,7 @@ describe("runComponentImport", () => { 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).toContain("background-color: #0066FF"); expect(html).not.toContain("var("); expect(out.unresolved).toHaveLength(1); expect(out.unresolved[0]?.figmaId).toBe("VariableID:1:1"); @@ -153,4 +153,17 @@ describe("runComponentImport", () => { }), ).rejects.toThrow(/rate limit/); }); + + it("honors a name override so variant frames don't slug-collide", async () => { + const out = await runComponentImport("FILE:1-1", { + projectDir: dir, + client: client(), + download: () => Promise.resolve(SVG), + name: "Hero Actions", + }); + expect(out.name).toBe("hero-actions"); + expect(out.htmlPath).toContain("hero-actions"); + const html = readFileSync(join(dir, out.htmlPath), "utf8"); + expect(html).toMatch(/^
/g, ">") .replace(/"/g, """); } -import { runAssetImport } from "./asset.js"; +import { runAssetImport, type AssetImportResult } from "./asset.js"; import { downloadRender } from "./download.js"; import { withFigmaErrors } from "./cliError.js"; @@ -35,6 +36,9 @@ export interface ComponentImportDeps { projectDir: string; client: FigmaClient; download: (url: string) => Promise; + /** override the component name — figma variant frames are often all named + * "Platform=Desktop", which would slug-collide across imports */ + name?: string; } export interface ComponentImportResult { @@ -55,9 +59,9 @@ export async function runComponentImport( const tree = await deps.client.nodeTree(ref); const bindings = resolveBindings(tree, readBindings(deps.projectDir)); - const mapped = nodeToHtml(tree, bindings); + const mapped = nodeToHtml(tree, bindings, { rootName: deps.name }); - const name = slugify(tree.name); + const name = slugify(deps.name ?? tree.name); const componentDir = join(deps.projectDir, "compositions", "components", name); if (existsSync(componentDir)) console.warn( @@ -65,52 +69,12 @@ export async function runComponentImport( ); 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; - const frozenAssets: string[] = []; - const failedRasterize: string[] = []; - for (const req of mapped.rasterize) { - // figma sometimes refuses to render a node (nested instances commonly - // fail as svg) — retry once as png, then skip THIS node and keep the - // import: one unrenderable node must not abort the whole component. The - // placeholder keeps its data-figma-rasterize marker (no src) so the gap - // is visible and hand-fixable. - let asset = null; - for (const format of ["svg", "png"] as const) { - try { - asset = await runAssetImport( - `${ref.fileKey}:${req.nodeId}`, - { format, description: req.name }, - { projectDir: deps.projectDir, client: deps.client, download: deps.download }, - ); - break; - } catch (err) { - if (!(err instanceof FigmaClientError) || err.code !== "RENDER_FAILED") throw err; - } - } - 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( - "\\", - "/", - ); - const emittedId = escapeAttr(req.nodeId); - html = html.replaceAll( - `data-figma-rasterize="${emittedId}" `, - `data-figma-rasterize="${emittedId}" src="${escapeAttr(srcRel)}" `, - ); - } + const { html, frozenAssets, failedRasterize } = await rasterizeFallback( + mapped, + ref.fileKey, + componentDir, + deps, + ); const htmlFile = join(componentDir, `${name}.html`); writeFileSync(htmlFile, html + "\n"); @@ -148,10 +112,83 @@ export async function runComponentImport( }; } +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 { + 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 { + 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 }) { @@ -162,6 +199,7 @@ export default defineCommand({ projectDir: args.dir, client, download: downloadRender, + name: args.name, }); console.log(`imported component "${result.name}" → ${result.htmlPath}`); if (result.rasterized.length > 0) { diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index 3458675e1f..b7c540f9d4 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -1,3 +1,7 @@ +import { markFlattenedInnerRoot } from "../runtime/flattenedRoot"; +export { FLATTENED_INNER_ROOT_STRIP_ATTRS } from "../runtime/flattenedRoot"; +import { parseHostVariableValues } from "../runtime/getVariables"; +import { cssVariableName } from "../tokenSlug"; import { readFileSync, existsSync } from "fs"; import { join, resolve, relative, dirname, isAbsolute, sep } from "path"; import { CSS_URL_RE, isNonRelativeUrl } from "./assetPaths.js"; @@ -392,43 +396,9 @@ function assignBundledRuntimeCompositionIds( return identities; } -function parseHostVariableValues(host: Element): Record { - const raw = host.getAttribute("data-variable-values"); - if (!raw) return {}; - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return {}; - } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; - return parsed as Record; -} - -export const FLATTENED_INNER_ROOT_STRIP_ATTRS = [ - "data-composition-id", - "data-composition-file", - "data-start", - "data-duration", - "data-end", - "data-track-index", - "data-track", - "data-composition-src", - "data-hf-authored-duration", - "data-hf-authored-end", -]; - export function prepareFlattenedInnerRoot(innerRoot: Element): Element { const prepared = innerRoot.cloneNode(true) as Element; - const authoredRootId = prepared.getAttribute("id")?.trim(); - for (const attrName of FLATTENED_INNER_ROOT_STRIP_ATTRS) { - prepared.removeAttribute(attrName); - } - if (authoredRootId) { - prepared.removeAttribute("id"); - prepared.setAttribute("data-hf-authored-id", authoredRootId); - } - prepared.setAttribute("data-hf-inner-root", "true"); + markFlattenedInnerRoot(prepared); const w = prepared.getAttribute("data-width"); const h = prepared.getAttribute("data-height"); const widthVal = w ? `${w}px` : "100%"; @@ -890,6 +860,13 @@ export async function bundleToSingleHtml( if (runtimeCompId && Object.keys(mergedVariables).length > 0) { compVariablesByComp[runtimeCompId] = mergedVariables; } + pushSubCompVariableStyles( + innerDoc, + innerRoot, + mergedVariables, + runtimeScope, + compStyleChunks, + ); if (innerRoot) { // Hoist styles into the collected style chunks @@ -973,6 +950,8 @@ export async function bundleToSingleHtml( document.body.appendChild(compScript); } + emitRootCompositionVariableStyles(document, compVariablesByComp); + enforceCompositionPixelSizing(document); autoHealMissingCompositionIds(document); coalesceHeadStylesAndBodyScripts(document); @@ -1014,3 +993,159 @@ export async function bundleToSingleHtml( return document.toString(); } + +/** One stylesheet rule defining primitive composition variables under `selector`. */ +function compositionVariablesCssBlock( + variables: Record, + selector: string, +): string | null { + const lines: string[] = []; + for (const [id, value] of Object.entries(variables)) { + if ((typeof value === "string" && value !== "") || typeof value === "number") { + lines.push(` ${cssVariableName(id)}: ${String(value)};`); + } + } + if (lines.length === 0) return null; + return `${selector} {\n${lines.join("\n")}\n}`; +} + +/** + * Compile-time counterpart of the runtime's injectCompositionCssVariables: + * every element declaring data-composition-variables gets a scoped stylesheet + * rule so var(--slug, literal) references resolve during body parse. The + * runtime injection remains define-if-absent, so it won't double-apply. + * + * `variablesByComp` (host-merged sub-composition values, keyed by runtime + * composition id) adds one rule per scope — the flattened inner root loses + * its data-composition-id, so the host selector is the only stable anchor. + * Exported for the producer's render compiler, which inlines sub-compositions + * through the shared module rather than this bundler. + * Returns whether a style element was appended. + */ +export function emitRootCompositionVariableStyles( + document: Document, + variablesByComp: Record> = {}, + overrides: Record = {}, +): boolean { + const layerFor = makeVariableLayer(document, overrides); + const rules = [ + ...hostScopedVariableRules(variablesByComp, overrides), + ...rootDeclaredVariableRules(document, layerFor), + ...declarerVariableRules(document, layerFor), + ]; + if (rules.length === 0) return false; + const style = document.createElement("style"); + style.setAttribute("data-hf-composition-variables", ""); + style.textContent = rules.join("\n\n"); + document.head.appendChild(style); + return true; +} + +type VariableLayer = ( + declared: Record, + hostValues: Record, +) => Record; + +/** + * Layering for one declarer: authored stylesheet definitions win over + * declared defaults (the runtime's define-if-absent, applied statically) — + * a var already defined in any authored