fix(cli): figma component import survives unrenderable nodes (#2022)

Live testing against a real community file (Ratings) found a nested
instance node figma refuses to render as svg — which aborted the entire
component import. The rasterize loop now retries the node as png, and
only if both formats fail warns and skips THAT node (placeholder keeps
its data-figma-rasterize marker, no src) instead of failing the import.
On the file that surfaced this, the png retry recovers the node — 31/31
placeholders get assets.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-07 01:59:48 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 337d0b51bc
commit 4c8064d4d3
3 changed files with 103 additions and 8 deletions
@@ -4,7 +4,7 @@ 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";
import { FigmaClientError, appendBinding, type FigmaClient } from "@hyperframes/core/figma";
let dir = "";
beforeEach(() => {
@@ -94,4 +94,63 @@ describe("runComponentImport", () => {
expect(item.files.some((f) => f.type === "hyperframes:snippet")).toBe(true);
expect(out.name).toBe("hero-card");
});
it("skips nodes figma refuses to render instead of aborting the import", async () => {
const failing: FigmaClient = {
...client(),
renderNode: () =>
Promise.reject(
new FigmaClientError("RENDER_FAILED", "figma could not render node 1:3 as svg"),
),
};
const out = await runComponentImport("FILE:1-1", {
projectDir: dir,
client: failing,
download: () => Promise.resolve(SVG),
});
const html = readFileSync(join(dir, out.htmlPath), "utf8");
expect(html).toContain("data-figma-rasterize=");
expect(html).not.toContain("src=");
const registry = JSON.parse(
readFileSync(join(dir, "compositions", "components", out.name, "registry-item.json"), "utf8"),
);
expect(registry.files).toHaveLength(1);
});
it("recovers a node via png when svg render fails", async () => {
let calls = 0;
const svgFailsPngWorks: FigmaClient = {
...client(),
renderNode: (_ref, opts) => {
calls++;
if (opts.format === "svg")
return Promise.reject(new FigmaClientError("RENDER_FAILED", "no svg for you"));
return Promise.resolve({ url: "https://cdn/x.png", ext: "png" });
},
};
const out = await runComponentImport("FILE:1-1", {
projectDir: dir,
client: svgFailsPngWorks,
download: () => Promise.resolve(SVG),
});
expect(out.failedRasterize).toHaveLength(0);
const html = readFileSync(join(dir, out.htmlPath), "utf8");
expect(html).toContain('src="');
expect(calls).toBeGreaterThanOrEqual(2);
});
it("propagates non-RENDER_FAILED errors instead of skipping", async () => {
const rateLimited: FigmaClient = {
...client(),
renderNode: () =>
Promise.reject(new FigmaClientError("RATE_LIMITED", "figma rate limit hit (429)", 429)),
};
await expect(
runComponentImport("FILE:1-1", {
projectDir: dir,
client: rateLimited,
download: () => Promise.resolve(SVG),
}),
).rejects.toThrow(/rate limit/);
});
});
+39 -7
View File
@@ -7,6 +7,7 @@
import { defineCommand } from "citty";
import {
createFigmaClient,
FigmaClientError,
nodeToHtml,
parseFigmaRef,
readBindings,
@@ -41,6 +42,8 @@ export interface ComponentImportResult {
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(
@@ -68,12 +71,33 @@ export async function runComponentImport(
// 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) {
const asset = await runAssetImport(
`${ref.fileKey}:${req.nodeId}`,
{ format: "svg", description: req.name },
{ projectDir: deps.projectDir, client: deps.client, download: deps.download },
);
// 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.
@@ -120,6 +144,7 @@ export async function runComponentImport(
htmlPath: relative(deps.projectDir, htmlFile),
unresolved: bindings.unresolved,
rasterized: mapped.rasterize,
failedRasterize,
};
}
@@ -139,8 +164,14 @@ export default defineCommand({
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.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.`,
@@ -151,6 +182,7 @@ export default defineCommand({
phase: "component",
unresolvedBindings: result.unresolved.length,
rasterizedNodes: result.rasterized.length,
rasterizeFailures: result.failedRasterize.length,
durationMs: Date.now() - t0,
});
});
+4
View File
@@ -443,6 +443,7 @@ export function trackFigmaImport(props: {
entryCount?: number;
unresolvedBindings?: number;
rasterizedNodes?: number;
rasterizeFailures?: number;
}): void {
trackEvent("figma_import", {
phase: props.phase,
@@ -454,6 +455,9 @@ export function trackFigmaImport(props: {
? { unresolved_bindings: props.unresolvedBindings }
: {}),
...(props.rasterizedNodes !== undefined ? { rasterized_nodes: props.rasterizedNodes } : {}),
...(props.rasterizeFailures !== undefined
? { rasterize_failures: props.rasterizeFailures }
: {}),
});
}