mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
co-authored by
Claude Fable 5
parent
337d0b51bc
commit
4c8064d4d3
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user