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