@@ -7,6 +7,7 @@ import { getTransformProps } from './props/transform'
77import { renderComponent , renderNode } from './render'
88import { renderText } from './render/text'
99import type { ComponentTree , NodeTree } from './types'
10+ import { addPx } from './utils/add-px'
1011import { checkAssetNode } from './utils/check-asset-node'
1112import { checkSameColor } from './utils/check-same-color'
1213import { extractInstanceVariantProps } from './utils/extract-instance-variant-props'
@@ -75,6 +76,68 @@ export function getGlobalAssetNodes(): ReadonlyMap<
7576 return globalAssetNodes
7677}
7778
79+ /** Props that are purely layout/padding — safe to discard when collapsing a single-asset wrapper. */
80+ const LAYOUT_ONLY_PROPS = new Set ( [
81+ 'display' ,
82+ 'flexDir' ,
83+ 'gap' ,
84+ 'justifyContent' ,
85+ 'alignItems' ,
86+ 'p' ,
87+ 'px' ,
88+ 'py' ,
89+ 'pt' ,
90+ 'pr' ,
91+ 'pb' ,
92+ 'pl' ,
93+ 'w' ,
94+ 'h' ,
95+ 'boxSize' ,
96+ 'overflow' ,
97+ 'maxW' ,
98+ 'maxH' ,
99+ 'minW' ,
100+ 'minH' ,
101+ 'aspectRatio' ,
102+ 'flex' ,
103+ ] )
104+
105+ /** Returns true if props contain visual styles (bg, border, position, etc.) beyond layout. */
106+ function hasVisualProps ( props : Record < string , unknown > ) : boolean {
107+ for ( const key of Object . keys ( props ) ) {
108+ if ( props [ key ] != null && ! LAYOUT_ONLY_PROPS . has ( key ) ) return true
109+ }
110+ return false
111+ }
112+
113+ /**
114+ * Recursively traverse a single-child chain to find a lone SVG asset leaf.
115+ * Matches both <Image src="...svg"> and mask-based <Box maskImage="url(...)">.
116+ * Returns the leaf NodeTree if every node in the chain has no visual props,
117+ * or null if the chain contains visual styling, branches, or is not an SVG.
118+ */
119+ function findSingleSvgImageLeaf ( tree : NodeTree ) : NodeTree | null {
120+ if ( tree . children . length === 0 ) {
121+ // Match <Image src="*.svg">
122+ if (
123+ tree . component === 'Image' &&
124+ typeof tree . props . src === 'string' &&
125+ tree . props . src . endsWith ( '.svg' )
126+ ) {
127+ return tree
128+ }
129+ // Match mask-based <Box maskImage="url('*.svg')">
130+ if ( tree . component === 'Box' && typeof tree . props . maskImage === 'string' ) {
131+ return tree
132+ }
133+ return null
134+ }
135+ if ( tree . children . length === 1 && ! hasVisualProps ( tree . props ) ) {
136+ return findSingleSvgImageLeaf ( tree . children [ 0 ] )
137+ }
138+ return null
139+ }
140+
78141/**
79142 * Get componentPropertyReferences from a node (if available).
80143 */
@@ -390,39 +453,10 @@ export class Codegen {
390453 }
391454 }
392455
393- // Handle asset nodes (images/SVGs)
394- const assetNode = checkAssetNode ( node )
395- if ( assetNode ) {
396- // Register in global asset registry for export commands
397- const assetKey = `${ assetNode } /${ node . name } `
398- if ( ! globalAssetNodes . has ( assetKey ) ) {
399- globalAssetNodes . set ( assetKey , { node, type : assetNode } )
400- }
401- const props = await getProps ( node )
402- props . src = `/${ assetNode === 'svg' ? 'icons' : 'images' } /${ node . name } .${ assetNode } `
403- if ( assetNode === 'svg' ) {
404- const maskColor = await checkSameColor ( node )
405- if ( maskColor ) {
406- props . maskImage = buildCssUrl ( props . src as string )
407- props . maskRepeat = 'no-repeat'
408- props . maskSize = 'contain'
409- props . maskPos = 'center'
410- props . bg = maskColor
411- delete props . src
412- }
413- }
414- perfEnd ( 'buildTree()' , tBuild )
415- return {
416- component : 'src' in props ? 'Image' : 'Box' ,
417- props,
418- children : [ ] ,
419- nodeType : node . type ,
420- nodeName : node . name ,
421- }
422- }
423-
424456 // Handle INSTANCE nodes first — they only need position props (all sync),
425457 // skipping the expensive full getProps() with 6 async Figma API calls.
458+ // INSTANCE nodes must be checked before asset detection because icon-like
459+ // instances (containing only vectors) would otherwise be misclassified as SVG assets.
426460 if ( node . type === 'INSTANCE' ) {
427461 const mainComponent = await getMainComponentCached ( node )
428462 // Fire addComponentTree without awaiting — it runs in the background.
@@ -528,6 +562,53 @@ export class Codegen {
528562 }
529563 }
530564
565+ // Handle asset nodes (images/SVGs)
566+ const assetNode = checkAssetNode ( node )
567+ if ( assetNode ) {
568+ // Register in global asset registry for export commands
569+ const assetKey = `${ assetNode } /${ node . name } `
570+ if ( ! globalAssetNodes . has ( assetKey ) ) {
571+ globalAssetNodes . set ( assetKey , { node, type : assetNode } )
572+ }
573+ const props = await getProps ( node )
574+ props . src = `/${ assetNode === 'svg' ? 'icons' : 'images' } /${ node . name } .${ assetNode } `
575+ if ( assetNode === 'svg' ) {
576+ const maskColor = await checkSameColor ( node )
577+ if ( maskColor ) {
578+ props . maskImage = buildCssUrl ( props . src as string )
579+ props . maskRepeat = 'no-repeat'
580+ props . maskSize = 'contain'
581+ props . maskPos = 'center'
582+ props . bg = maskColor
583+ delete props . src
584+ }
585+ }
586+ // Strip padding props from asset nodes — padding from inferredAutoLayout
587+ // is meaningless on asset elements (Image or mask-based Box).
588+ for ( const key of Object . keys ( props ) ) {
589+ if (
590+ key === 'p' ||
591+ key === 'px' ||
592+ key === 'py' ||
593+ key === 'pt' ||
594+ key === 'pr' ||
595+ key === 'pb' ||
596+ key === 'pl'
597+ ) {
598+ delete props [ key ]
599+ }
600+ }
601+ const assetComponent = 'src' in props ? 'Image' : 'Box'
602+ perfEnd ( 'buildTree()' , tBuild )
603+ return {
604+ component : assetComponent ,
605+ props,
606+ children : [ ] ,
607+ nodeType : node . type ,
608+ nodeName : node . name ,
609+ }
610+ }
611+
531612 // Fire getProps early for non-INSTANCE nodes — it runs while we process children.
532613 const propsPromise = getProps ( node )
533614
@@ -555,6 +636,29 @@ export class Codegen {
555636 props = baseProps
556637 }
557638
639+ // When an icon-like node (isAsset) wraps a chain of single-child
640+ // layout-only wrappers ending in a single Image, collapse into
641+ // a direct Image using the node's outer dimensions.
642+ if ( children . length === 1 && ! hasVisualProps ( baseProps ) ) {
643+ const imageLeaf = findSingleSvgImageLeaf ( children [ 0 ] )
644+ if ( imageLeaf ) {
645+ if ( node . width === node . height ) {
646+ imageLeaf . props . boxSize = addPx ( node . width )
647+ delete imageLeaf . props . w
648+ delete imageLeaf . props . h
649+ } else {
650+ imageLeaf . props . w = addPx ( node . width )
651+ imageLeaf . props . h = addPx ( node . height )
652+ }
653+ perfEnd ( 'buildTree()' , tBuild )
654+ return {
655+ ...imageLeaf ,
656+ nodeType : node . type ,
657+ nodeName : node . name ,
658+ }
659+ }
660+ }
661+
558662 const component = getDevupComponentByNode ( node , props )
559663
560664 perfEnd ( 'buildTree()' , tBuild )
@@ -737,6 +841,36 @@ export class Codegen {
737841 }
738842 }
739843
844+ // When an icon-like component (isAsset) wraps a chain of single-child
845+ // layout-only wrappers ending in a single Image, collapse everything
846+ // into a direct Image using the component's outer dimensions.
847+ if ( childrenTrees . length === 1 && ! hasVisualProps ( props ) ) {
848+ const imageLeaf = findSingleSvgImageLeaf ( childrenTrees [ 0 ] )
849+ if ( imageLeaf ) {
850+ if ( node . width === node . height ) {
851+ imageLeaf . props . boxSize = addPx ( node . width )
852+ delete imageLeaf . props . w
853+ delete imageLeaf . props . h
854+ } else {
855+ imageLeaf . props . w = addPx ( node . width )
856+ imageLeaf . props . h = addPx ( node . height )
857+ }
858+ this . componentTrees . set ( nodeId , {
859+ name : getComponentName ( node ) ,
860+ node,
861+ tree : {
862+ ...imageLeaf ,
863+ nodeType : node . type ,
864+ nodeName : node . name ,
865+ } ,
866+ variants,
867+ variantComments,
868+ } )
869+ perfEnd ( 'addComponentTree()' , tAdd )
870+ return
871+ }
872+ }
873+
740874 this . componentTrees . set ( nodeId , {
741875 name : getComponentName ( node ) ,
742876 node,
0 commit comments