Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ coverage

.claude
.sisyphus
.omc
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
196 changes: 165 additions & 31 deletions src/codegen/Codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getTransformProps } from './props/transform'
import { renderComponent, renderNode } from './render'
import { renderText } from './render/text'
import type { ComponentTree, NodeTree } from './types'
import { addPx } from './utils/add-px'
import { checkAssetNode } from './utils/check-asset-node'
import { checkSameColor } from './utils/check-same-color'
import { extractInstanceVariantProps } from './utils/extract-instance-variant-props'
Expand Down Expand Up @@ -75,6 +76,68 @@ export function getGlobalAssetNodes(): ReadonlyMap<
return globalAssetNodes
}

/** Props that are purely layout/padding — safe to discard when collapsing a single-asset wrapper. */
const LAYOUT_ONLY_PROPS = new Set([
'display',
'flexDir',
'gap',
'justifyContent',
'alignItems',
'p',
'px',
'py',
'pt',
'pr',
'pb',
'pl',
'w',
'h',
'boxSize',
'overflow',
'maxW',
'maxH',
'minW',
'minH',
'aspectRatio',
'flex',
])

/** Returns true if props contain visual styles (bg, border, position, etc.) beyond layout. */
function hasVisualProps(props: Record<string, unknown>): boolean {
for (const key of Object.keys(props)) {
if (props[key] != null && !LAYOUT_ONLY_PROPS.has(key)) return true
}
return false
}

/**
* Recursively traverse a single-child chain to find a lone SVG asset leaf.
* Matches both <Image src="...svg"> and mask-based <Box maskImage="url(...)">.
* Returns the leaf NodeTree if every node in the chain has no visual props,
* or null if the chain contains visual styling, branches, or is not an SVG.
*/
function findSingleSvgImageLeaf(tree: NodeTree): NodeTree | null {
if (tree.children.length === 0) {
// Match <Image src="*.svg">
if (
tree.component === 'Image' &&
typeof tree.props.src === 'string' &&
tree.props.src.endsWith('.svg')
) {
return tree
}
// Match mask-based <Box maskImage="url('*.svg')">
if (tree.component === 'Box' && typeof tree.props.maskImage === 'string') {
return tree
}
return null
}
if (tree.children.length === 1 && !hasVisualProps(tree.props)) {
return findSingleSvgImageLeaf(tree.children[0])
}
return null
}

/**
* Get componentPropertyReferences from a node (if available).
*/
Expand Down Expand Up @@ -390,39 +453,10 @@ export class Codegen {
}
}

// Handle asset nodes (images/SVGs)
const assetNode = checkAssetNode(node)
if (assetNode) {
// Register in global asset registry for export commands
const assetKey = `${assetNode}/${node.name}`
if (!globalAssetNodes.has(assetKey)) {
globalAssetNodes.set(assetKey, { node, type: assetNode })
}
const props = await getProps(node)
props.src = `/${assetNode === 'svg' ? 'icons' : 'images'}/${node.name}.${assetNode}`
if (assetNode === 'svg') {
const maskColor = await checkSameColor(node)
if (maskColor) {
props.maskImage = buildCssUrl(props.src as string)
props.maskRepeat = 'no-repeat'
props.maskSize = 'contain'
props.maskPos = 'center'
props.bg = maskColor
delete props.src
}
}
perfEnd('buildTree()', tBuild)
return {
component: 'src' in props ? 'Image' : 'Box',
props,
children: [],
nodeType: node.type,
nodeName: node.name,
}
}

// Handle INSTANCE nodes first — they only need position props (all sync),
// skipping the expensive full getProps() with 6 async Figma API calls.
// INSTANCE nodes must be checked before asset detection because icon-like
// instances (containing only vectors) would otherwise be misclassified as SVG assets.
if (node.type === 'INSTANCE') {
const mainComponent = await getMainComponentCached(node)
// Fire addComponentTree without awaiting — it runs in the background.
Expand Down Expand Up @@ -528,6 +562,53 @@ export class Codegen {
}
}

// Handle asset nodes (images/SVGs)
const assetNode = checkAssetNode(node)
if (assetNode) {
// Register in global asset registry for export commands
const assetKey = `${assetNode}/${node.name}`
if (!globalAssetNodes.has(assetKey)) {
globalAssetNodes.set(assetKey, { node, type: assetNode })
}
const props = await getProps(node)
props.src = `/${assetNode === 'svg' ? 'icons' : 'images'}/${node.name}.${assetNode}`
if (assetNode === 'svg') {
const maskColor = await checkSameColor(node)
if (maskColor) {
props.maskImage = buildCssUrl(props.src as string)
props.maskRepeat = 'no-repeat'
props.maskSize = 'contain'
props.maskPos = 'center'
props.bg = maskColor
delete props.src
}
}
// Strip padding props from asset nodes — padding from inferredAutoLayout
// is meaningless on asset elements (Image or mask-based Box).
for (const key of Object.keys(props)) {
if (
key === 'p' ||
key === 'px' ||
key === 'py' ||
key === 'pt' ||
key === 'pr' ||
key === 'pb' ||
key === 'pl'
) {
delete props[key]
}
}
const assetComponent = 'src' in props ? 'Image' : 'Box'
perfEnd('buildTree()', tBuild)
return {
component: assetComponent,
props,
children: [],
nodeType: node.type,
nodeName: node.name,
}
}

// Fire getProps early for non-INSTANCE nodes — it runs while we process children.
const propsPromise = getProps(node)

Expand Down Expand Up @@ -555,6 +636,29 @@ export class Codegen {
props = baseProps
}

// When an icon-like node (isAsset) wraps a chain of single-child
// layout-only wrappers ending in a single Image, collapse into
// a direct Image using the node's outer dimensions.
if (children.length === 1 && !hasVisualProps(baseProps)) {
const imageLeaf = findSingleSvgImageLeaf(children[0])
if (imageLeaf) {
if (node.width === node.height) {
imageLeaf.props.boxSize = addPx(node.width)
delete imageLeaf.props.w
delete imageLeaf.props.h
} else {
imageLeaf.props.w = addPx(node.width)
imageLeaf.props.h = addPx(node.height)
}
perfEnd('buildTree()', tBuild)
return {
...imageLeaf,
nodeType: node.type,
nodeName: node.name,
}
}
}

const component = getDevupComponentByNode(node, props)

perfEnd('buildTree()', tBuild)
Expand Down Expand Up @@ -737,6 +841,36 @@ export class Codegen {
}
}

// When an icon-like component (isAsset) wraps a chain of single-child
// layout-only wrappers ending in a single Image, collapse everything
// into a direct Image using the component's outer dimensions.
if (childrenTrees.length === 1 && !hasVisualProps(props)) {
const imageLeaf = findSingleSvgImageLeaf(childrenTrees[0])
if (imageLeaf) {
if (node.width === node.height) {
imageLeaf.props.boxSize = addPx(node.width)
delete imageLeaf.props.w
delete imageLeaf.props.h
} else {
imageLeaf.props.w = addPx(node.width)
imageLeaf.props.h = addPx(node.height)
}
this.componentTrees.set(nodeId, {
name: getComponentName(node),
node,
tree: {
...imageLeaf,
nodeType: node.type,
nodeName: node.name,
},
variants,
variantComments,
})
perfEnd('addComponentTree()', tAdd)
return
}
}

this.componentTrees.set(nodeId, {
name: getComponentName(node),
node,
Expand Down
55 changes: 23 additions & 32 deletions src/codegen/__tests__/__snapshots__/codegen.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -1971,16 +1971,7 @@ exports[`render real world component real world $ 35`] = `

exports[`render real world component real world $ 36`] = `
"export function Border() {
return (
<Box
bg="#FFF"
borderTop="solid 1px #000"
maskImage="url(/icons/border.svg)"
maskPos="center"
maskRepeat="no-repeat"
maskSize="contain"
/>
)
return <Image borderTop="solid 1px #000" src="/icons/border.svg" />
}"
`;

Expand Down Expand Up @@ -2190,10 +2181,6 @@ exports[`render real world component real world $ 47`] = `
maskPos="center"
maskRepeat="no-repeat"
maskSize="contain"
pb="2.96px"
pl="3.49px"
pr="2.89px"
pt="2.02px"
/>
)
}"
Expand All @@ -2208,8 +2195,6 @@ exports[`render real world component real world $ 48`] = `
maskPos="center"
maskRepeat="no-repeat"
maskSize="contain"
px="3px"
py="2px"
/>
)
}"
Expand All @@ -2224,16 +2209,14 @@ exports[`render real world component real world $ 49`] = `
maskPos="center"
maskRepeat="no-repeat"
maskSize="contain"
px="3px"
py="2px"
/>
)
}"
`;

exports[`render real world component real world $ 50`] = `
"export function Svg() {
return <Image px="3px" py="2px" src="/icons/recommend.svg" />
return <Image src="/icons/recommend.svg" />
}"
`;

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

exports[`render real world component real world $ 88`] = `
"export function 다양한도형() {
return <Image src="/icons/Vector4.svg" />
return (
<Box
bg="#000"
maskImage="url(/icons/Vector4.svg)"
maskPos="center"
maskRepeat="no-repeat"
maskSize="contain"
/>
)
}"
`;

Expand Down Expand Up @@ -3206,9 +3197,6 @@ exports[`render real world component real world $ 91`] = `
maskPos="center"
maskRepeat="no-repeat"
maskSize="contain"
pl="9px"
pr="7px"
py="5px"
transform="rotate(180deg)"
/>
</Center>
Expand All @@ -3228,7 +3216,6 @@ exports[`render real world component real world $ 92`] = `
maskPos="center"
maskRepeat="no-repeat"
maskSize="contain"
p="2.29px"
/>
</Center>
)
Expand Down Expand Up @@ -3257,8 +3244,6 @@ exports[`render real world component real world $ 94`] = `
maskPos="center"
maskRepeat="no-repeat"
maskSize="contain"
px="2.65px"
py="2.33px"
/>
</Center>
)
Expand Down Expand Up @@ -3287,8 +3272,6 @@ exports[`render real world component real world $ 96`] = `
maskPos="center"
maskRepeat="no-repeat"
maskSize="contain"
px="2.65px"
py="2.33px"
/>
</Center>
)
Expand Down Expand Up @@ -3546,11 +3529,15 @@ exports[`render real world component real world $ 106`] = `
"export function UnderConstruction() {
return (
<Center bg="$background" overflow="hidden" px="40px" py="120px">
<Image
<Box
bg="#C8A46B"
left="-196.23px"
maskImage="url(/icons/backgroundImage.svg)"
maskPos="center"
maskRepeat="no-repeat"
maskSize="contain"
opacity="0.2"
pos="absolute"
src="/icons/backgroundImage.svg"
top="-908px"
transform="rotate(11.24deg)"
transformOrigin="top left"
Expand Down Expand Up @@ -3679,11 +3666,15 @@ exports[`render real world component real world $ 107`] = `
px="20px"
py="60px"
>
<Image
<Box
bg="#C8A46B"
left="-183.93px"
maskImage="url(/icons/backgroundImage.svg)"
maskPos="center"
maskRepeat="no-repeat"
maskSize="contain"
opacity="0.2"
pos="absolute"
src="/icons/backgroundImage.svg"
top="-255px"
transform="rotate(11.24deg)"
transformOrigin="top left"
Expand Down
Loading