Skip to content

Commit e52bfc2

Browse files
authored
Merge pull request #2053 from heygen-com/vi/figma-loop-fixes
fix(core,cli,lint): close the figma brand-token loop — runtime CSS variables, --name, snippet lint
2 parents 4d3cdc3 + d9368ec commit e52bfc2

20 files changed

Lines changed: 778 additions & 185 deletions

File tree

packages/cli/src/commands/figma/component.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ describe("runComponentImport", () => {
7474

7575
it("bakes literals and reports unresolved bindings when the index is empty", async () => {
7676
const { out, html } = await importHero();
77-
expect(html).toContain("background: #0066FF");
77+
expect(html).toContain("background-color: #0066FF");
7878
expect(html).not.toContain("var(");
7979
expect(out.unresolved).toHaveLength(1);
8080
expect(out.unresolved[0]?.figmaId).toBe("VariableID:1:1");
@@ -153,4 +153,17 @@ describe("runComponentImport", () => {
153153
}),
154154
).rejects.toThrow(/rate limit/);
155155
});
156+
157+
it("honors a name override so variant frames don't slug-collide", async () => {
158+
const out = await runComponentImport("FILE:1-1", {
159+
projectDir: dir,
160+
client: client(),
161+
download: () => Promise.resolve(SVG),
162+
name: "Hero Actions",
163+
});
164+
expect(out.name).toBe("hero-actions");
165+
expect(out.htmlPath).toContain("hero-actions");
166+
const html = readFileSync(join(dir, out.htmlPath), "utf8");
167+
expect(html).toMatch(/^<div id="hero-actions"/);
168+
});
156169
});

packages/cli/src/commands/figma/component.ts

Lines changed: 87 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
slugify,
1616
type BindingSite,
1717
type FigmaClient,
18+
type NodeToHtmlResult,
1819
type RasterizeRequest,
1920
} from "@hyperframes/core/figma";
2021
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
@@ -27,14 +28,17 @@ function escapeAttr(value: string): string {
2728
.replace(/>/g, "&gt;")
2829
.replace(/"/g, "&quot;");
2930
}
30-
import { runAssetImport } from "./asset.js";
31+
import { runAssetImport, type AssetImportResult } from "./asset.js";
3132
import { downloadRender } from "./download.js";
3233
import { withFigmaErrors } from "./cliError.js";
3334

3435
export interface ComponentImportDeps {
3536
projectDir: string;
3637
client: FigmaClient;
3738
download: (url: string) => Promise<Uint8Array>;
39+
/** override the component name — figma variant frames are often all named
40+
* "Platform=Desktop", which would slug-collide across imports */
41+
name?: string;
3842
}
3943

4044
export interface ComponentImportResult {
@@ -55,62 +59,22 @@ export async function runComponentImport(
5559

5660
const tree = await deps.client.nodeTree(ref);
5761
const bindings = resolveBindings(tree, readBindings(deps.projectDir));
58-
const mapped = nodeToHtml(tree, bindings);
62+
const mapped = nodeToHtml(tree, bindings, { rootName: deps.name });
5963

60-
const name = slugify(tree.name);
64+
const name = slugify(deps.name ?? tree.name);
6165
const componentDir = join(deps.projectDir, "compositions", "components", name);
6266
if (existsSync(componentDir))
6367
console.warn(
6468
`component dir compositions/components/${name} already exists — overwriting (rename the figma frame for a separate import)`,
6569
);
6670
mkdirSync(componentDir, { recursive: true });
6771

68-
// Rasterize fallback: export each unmappable node via Phase 1 and point
69-
// the placeholder img at the frozen file (path relative to the component).
70-
// The search key must match the EMITTED (html-escaped) node id, and
71-
// replaceAll covers the same node appearing twice in the tree.
72-
let html = mapped.html;
73-
const frozenAssets: string[] = [];
74-
const failedRasterize: string[] = [];
75-
for (const req of mapped.rasterize) {
76-
// figma sometimes refuses to render a node (nested instances commonly
77-
// fail as svg) — retry once as png, then skip THIS node and keep the
78-
// import: one unrenderable node must not abort the whole component. The
79-
// placeholder keeps its data-figma-rasterize marker (no src) so the gap
80-
// is visible and hand-fixable.
81-
let asset = null;
82-
for (const format of ["svg", "png"] as const) {
83-
try {
84-
asset = await runAssetImport(
85-
`${ref.fileKey}:${req.nodeId}`,
86-
{ format, description: req.name },
87-
{ projectDir: deps.projectDir, client: deps.client, download: deps.download },
88-
);
89-
break;
90-
} catch (err) {
91-
if (!(err instanceof FigmaClientError) || err.code !== "RENDER_FAILED") throw err;
92-
}
93-
}
94-
if (asset === null) {
95-
failedRasterize.push(req.nodeId);
96-
console.warn(
97-
`could not render node ${req.nodeId} ("${req.name}") as svg or png — leaving its placeholder without src`,
98-
);
99-
continue;
100-
}
101-
frozenAssets.push(asset.record.path);
102-
// src is a URL — always forward slashes, even when relative() yields
103-
// windows separators.
104-
const srcRel = relative(componentDir, join(deps.projectDir, asset.record.path)).replaceAll(
105-
"\\",
106-
"/",
107-
);
108-
const emittedId = escapeAttr(req.nodeId);
109-
html = html.replaceAll(
110-
`data-figma-rasterize="${emittedId}" `,
111-
`data-figma-rasterize="${emittedId}" src="${escapeAttr(srcRel)}" `,
112-
);
113-
}
72+
const { html, frozenAssets, failedRasterize } = await rasterizeFallback(
73+
mapped,
74+
ref.fileKey,
75+
componentDir,
76+
deps,
77+
);
11478

11579
const htmlFile = join(componentDir, `${name}.html`);
11680
writeFileSync(htmlFile, html + "\n");
@@ -148,10 +112,83 @@ export async function runComponentImport(
148112
};
149113
}
150114

115+
interface RasterizeOutcome {
116+
html: string;
117+
frozenAssets: string[];
118+
failedRasterize: string[];
119+
}
120+
121+
/**
122+
* Rasterize fallback: export each unmappable node via Phase 1 and point the
123+
* placeholder img at the frozen file (path relative to the component). figma
124+
* sometimes refuses to render a node (nested instances commonly fail as svg)
125+
* — retry once as png, then skip THAT node and keep the import: one
126+
* unrenderable node must not abort the whole component. Skipped placeholders
127+
* keep their data-figma-rasterize marker (no src) so the gap is visible.
128+
*/
129+
async function rasterizeFallback(
130+
mapped: NodeToHtmlResult,
131+
fileKey: string,
132+
componentDir: string,
133+
deps: ComponentImportDeps,
134+
): Promise<RasterizeOutcome> {
135+
let html = mapped.html;
136+
const frozenAssets: string[] = [];
137+
const failedRasterize: string[] = [];
138+
for (const req of mapped.rasterize) {
139+
const asset = await renderWithPngRetry(fileKey, req, deps);
140+
if (asset === null) {
141+
failedRasterize.push(req.nodeId);
142+
console.warn(
143+
`could not render node ${req.nodeId} ("${req.name}") as svg or png — leaving its placeholder without src`,
144+
);
145+
continue;
146+
}
147+
frozenAssets.push(asset.record.path);
148+
// src is a URL — always forward slashes, even when relative() yields
149+
// windows separators.
150+
const srcRel = relative(componentDir, join(deps.projectDir, asset.record.path)).replaceAll(
151+
"\\",
152+
"/",
153+
);
154+
// The search key must match the EMITTED (html-escaped) node id, and
155+
// replaceAll covers the same node appearing twice in the tree.
156+
const emittedId = escapeAttr(req.nodeId);
157+
html = html.replaceAll(
158+
`data-figma-rasterize="${emittedId}" `,
159+
`data-figma-rasterize="${emittedId}" src="${escapeAttr(srcRel)}" `,
160+
);
161+
}
162+
return { html, frozenAssets, failedRasterize };
163+
}
164+
165+
async function renderWithPngRetry(
166+
fileKey: string,
167+
req: RasterizeRequest,
168+
deps: ComponentImportDeps,
169+
): Promise<AssetImportResult | null> {
170+
for (const format of ["svg", "png"] as const) {
171+
try {
172+
return await runAssetImport(
173+
`${fileKey}:${req.nodeId}`,
174+
{ format, description: req.name },
175+
{ projectDir: deps.projectDir, client: deps.client, download: deps.download },
176+
);
177+
} catch (err) {
178+
if (!(err instanceof FigmaClientError) || err.code !== "RENDER_FAILED") throw err;
179+
}
180+
}
181+
return null;
182+
}
183+
151184
export default defineCommand({
152185
meta: { name: "component", description: "Import a figma frame as an editable HTML component" },
153186
args: {
154187
ref: { type: "positional", description: "figma URL or fileKey:nodeId", required: true },
188+
name: {
189+
type: "string",
190+
description: "component name override (variant frames often share a name and would collide)",
191+
},
155192
dir: { type: "string", description: "project directory", default: "." },
156193
},
157194
async run({ args }) {
@@ -162,6 +199,7 @@ export default defineCommand({
162199
projectDir: args.dir,
163200
client,
164201
download: downloadRender,
202+
name: args.name,
165203
});
166204
console.log(`imported component "${result.name}" → ${result.htmlPath}`);
167205
if (result.rasterized.length > 0) {

0 commit comments

Comments
 (0)