Skip to content

Commit 361be3e

Browse files
authored
Merge pull request #40 from dev-five-git/fix-asset-issue
Fix asset issue
2 parents 3557cfc + e112dc9 commit 361be3e

File tree

6 files changed

+208
-70
lines changed

6 files changed

+208
-70
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ coverage
1111

1212
.claude
1313
.sisyphus
14+
.omc

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
@AGENTS.md

src/codegen/Codegen.ts

Lines changed: 165 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getTransformProps } from './props/transform'
77
import { renderComponent, renderNode } from './render'
88
import { renderText } from './render/text'
99
import type { ComponentTree, NodeTree } from './types'
10+
import { addPx } from './utils/add-px'
1011
import { checkAssetNode } from './utils/check-asset-node'
1112
import { checkSameColor } from './utils/check-same-color'
1213
import { 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,

src/codegen/__tests__/__snapshots__/codegen.test.ts.snap

Lines changed: 23 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1971,16 +1971,7 @@ exports[`render real world component real world $ 35`] = `
19711971

19721972
exports[`render real world component real world $ 36`] = `
19731973
"export function Border() {
1974-
return (
1975-
<Box
1976-
bg="#FFF"
1977-
borderTop="solid 1px #000"
1978-
maskImage="url(/icons/border.svg)"
1979-
maskPos="center"
1980-
maskRepeat="no-repeat"
1981-
maskSize="contain"
1982-
/>
1983-
)
1974+
return <Image borderTop="solid 1px #000" src="/icons/border.svg" />
19841975
}"
19851976
`;
19861977

@@ -2190,10 +2181,6 @@ exports[`render real world component real world $ 47`] = `
21902181
maskPos="center"
21912182
maskRepeat="no-repeat"
21922183
maskSize="contain"
2193-
pb="2.96px"
2194-
pl="3.49px"
2195-
pr="2.89px"
2196-
pt="2.02px"
21972184
/>
21982185
)
21992186
}"
@@ -2208,8 +2195,6 @@ exports[`render real world component real world $ 48`] = `
22082195
maskPos="center"
22092196
maskRepeat="no-repeat"
22102197
maskSize="contain"
2211-
px="3px"
2212-
py="2px"
22132198
/>
22142199
)
22152200
}"
@@ -2224,16 +2209,14 @@ exports[`render real world component real world $ 49`] = `
22242209
maskPos="center"
22252210
maskRepeat="no-repeat"
22262211
maskSize="contain"
2227-
px="3px"
2228-
py="2px"
22292212
/>
22302213
)
22312214
}"
22322215
`;
22332216

22342217
exports[`render real world component real world $ 50`] = `
22352218
"export function Svg() {
2236-
return <Image px="3px" py="2px" src="/icons/recommend.svg" />
2219+
return <Image src="/icons/recommend.svg" />
22372220
}"
22382221
`;
22392222

@@ -3178,7 +3161,15 @@ exports[`render real world component real world $ 87`] = `
31783161

31793162
exports[`render real world component real world $ 88`] = `
31803163
"export function 다양한도형() {
3181-
return <Image src="/icons/Vector4.svg" />
3164+
return (
3165+
<Box
3166+
bg="#000"
3167+
maskImage="url(/icons/Vector4.svg)"
3168+
maskPos="center"
3169+
maskRepeat="no-repeat"
3170+
maskSize="contain"
3171+
/>
3172+
)
31823173
}"
31833174
`;
31843175

@@ -3206,9 +3197,6 @@ exports[`render real world component real world $ 91`] = `
32063197
maskPos="center"
32073198
maskRepeat="no-repeat"
32083199
maskSize="contain"
3209-
pl="9px"
3210-
pr="7px"
3211-
py="5px"
32123200
transform="rotate(180deg)"
32133201
/>
32143202
</Center>
@@ -3228,7 +3216,6 @@ exports[`render real world component real world $ 92`] = `
32283216
maskPos="center"
32293217
maskRepeat="no-repeat"
32303218
maskSize="contain"
3231-
p="2.29px"
32323219
/>
32333220
</Center>
32343221
)
@@ -3257,8 +3244,6 @@ exports[`render real world component real world $ 94`] = `
32573244
maskPos="center"
32583245
maskRepeat="no-repeat"
32593246
maskSize="contain"
3260-
px="2.65px"
3261-
py="2.33px"
32623247
/>
32633248
</Center>
32643249
)
@@ -3287,8 +3272,6 @@ exports[`render real world component real world $ 96`] = `
32873272
maskPos="center"
32883273
maskRepeat="no-repeat"
32893274
maskSize="contain"
3290-
px="2.65px"
3291-
py="2.33px"
32923275
/>
32933276
</Center>
32943277
)
@@ -3546,11 +3529,15 @@ exports[`render real world component real world $ 106`] = `
35463529
"export function UnderConstruction() {
35473530
return (
35483531
<Center bg="$background" overflow="hidden" px="40px" py="120px">
3549-
<Image
3532+
<Box
3533+
bg="#C8A46B"
35503534
left="-196.23px"
3535+
maskImage="url(/icons/backgroundImage.svg)"
3536+
maskPos="center"
3537+
maskRepeat="no-repeat"
3538+
maskSize="contain"
35513539
opacity="0.2"
35523540
pos="absolute"
3553-
src="/icons/backgroundImage.svg"
35543541
top="-908px"
35553542
transform="rotate(11.24deg)"
35563543
transformOrigin="top left"
@@ -3679,11 +3666,15 @@ exports[`render real world component real world $ 107`] = `
36793666
px="20px"
36803667
py="60px"
36813668
>
3682-
<Image
3669+
<Box
3670+
bg="#C8A46B"
36833671
left="-183.93px"
3672+
maskImage="url(/icons/backgroundImage.svg)"
3673+
maskPos="center"
3674+
maskRepeat="no-repeat"
3675+
maskSize="contain"
36843676
opacity="0.2"
36853677
pos="absolute"
3686-
src="/icons/backgroundImage.svg"
36873678
top="-255px"
36883679
transform="rotate(11.24deg)"
36893680
transformOrigin="top left"

0 commit comments

Comments
 (0)