@@ -15,6 +15,7 @@ import {
1515 slugify ,
1616 type BindingSite ,
1717 type FigmaClient ,
18+ type NodeToHtmlResult ,
1819 type RasterizeRequest ,
1920} from "@hyperframes/core/figma" ;
2021import { existsSync , mkdirSync , writeFileSync } from "node:fs" ;
@@ -27,14 +28,17 @@ function escapeAttr(value: string): string {
2728 . replace ( / > / g, ">" )
2829 . replace ( / " / g, """ ) ;
2930}
30- import { runAssetImport } from "./asset.js" ;
31+ import { runAssetImport , type AssetImportResult } from "./asset.js" ;
3132import { downloadRender } from "./download.js" ;
3233import { withFigmaErrors } from "./cliError.js" ;
3334
3435export 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
4044export 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+
151184export 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