From e112dc9e60f703ce9ae7bf9cc2ce340367fc8fb2 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 23 Mar 2026 17:02:27 +0900 Subject: [PATCH] Fix asset issue --- .gitignore | 1 + CLAUDE.md | 1 + src/codegen/Codegen.ts | 196 +++++++++++++++--- .../__snapshots__/codegen.test.ts.snap | 55 ++--- .../__tests__/codegen-viewport.test.ts | 3 + src/codegen/utils/check-same-color.ts | 22 +- 6 files changed, 208 insertions(+), 70 deletions(-) create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index ed5a8f3..0f2df8c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ coverage .claude .sisyphus +.omc diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..eef4bd2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md \ No newline at end of file diff --git a/src/codegen/Codegen.ts b/src/codegen/Codegen.ts index 8ae800c..06c1d6a 100644 --- a/src/codegen/Codegen.ts +++ b/src/codegen/Codegen.ts @@ -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' @@ -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): 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 and mask-based . + * 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 + if ( + tree.component === 'Image' && + typeof tree.props.src === 'string' && + tree.props.src.endsWith('.svg') + ) { + return tree + } + // Match mask-based + 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). */ @@ -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. @@ -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) @@ -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) @@ -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, diff --git a/src/codegen/__tests__/__snapshots__/codegen.test.ts.snap b/src/codegen/__tests__/__snapshots__/codegen.test.ts.snap index c2b342b..4ba3f7d 100644 --- a/src/codegen/__tests__/__snapshots__/codegen.test.ts.snap +++ b/src/codegen/__tests__/__snapshots__/codegen.test.ts.snap @@ -1971,16 +1971,7 @@ exports[`render real world component real world $ 35`] = ` exports[`render real world component real world $ 36`] = ` "export function Border() { - return ( - - ) + return }" `; @@ -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" /> ) }" @@ -2208,8 +2195,6 @@ exports[`render real world component real world $ 48`] = ` maskPos="center" maskRepeat="no-repeat" maskSize="contain" - px="3px" - py="2px" /> ) }" @@ -2224,8 +2209,6 @@ exports[`render real world component real world $ 49`] = ` maskPos="center" maskRepeat="no-repeat" maskSize="contain" - px="3px" - py="2px" /> ) }" @@ -2233,7 +2216,7 @@ exports[`render real world component real world $ 49`] = ` exports[`render real world component real world $ 50`] = ` "export function Svg() { - return + return }" `; @@ -3178,7 +3161,15 @@ exports[`render real world component real world $ 87`] = ` exports[`render real world component real world $ 88`] = ` "export function 다양한도형() { - return + return ( + + ) }" `; @@ -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)" /> @@ -3228,7 +3216,6 @@ exports[`render real world component real world $ 92`] = ` maskPos="center" maskRepeat="no-repeat" maskSize="contain" - p="2.29px" /> ) @@ -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" /> ) @@ -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" /> ) @@ -3546,11 +3529,15 @@ exports[`render real world component real world $ 106`] = ` "export function UnderConstruction() { return (
- - { isAsset: true, width: 20, height: 20, + getMainComponentAsync: () => Promise.resolve(null), ...overrides, } as unknown as SceneNode } @@ -1174,6 +1175,7 @@ describe('Codegen effect-only COMPONENT_SET', () => { isAsset: true, width: 20, height: 20, + getMainComponentAsync: () => Promise.resolve(null), ...overrides, } as unknown as SceneNode } @@ -1382,6 +1384,7 @@ describe('Codegen effect-only COMPONENT_SET', () => { isAsset: true, width: 20, height: 20, + getMainComponentAsync: () => Promise.resolve(null), ...overrides, } as unknown as SceneNode } diff --git a/src/codegen/utils/check-same-color.ts b/src/codegen/utils/check-same-color.ts index 1ba1c4d..bca234d 100644 --- a/src/codegen/utils/check-same-color.ts +++ b/src/codegen/utils/check-same-color.ts @@ -5,21 +5,29 @@ export async function checkSameColor( color: string | null = null, ): Promise { let targetColor: string | null = color - if ('fills' in node && Array.isArray(node.fills)) { - for (const fill of node.fills) { - if (!fill.visible) continue - if (fill.type === 'SOLID') { - const syncColor = solidToStringSync(fill) + + // Check both fills and strokes for solid colors + const paintArrays: Paint[][] = [] + if ('fills' in node && Array.isArray(node.fills)) paintArrays.push(node.fills) + if ('strokes' in node && Array.isArray(node.strokes)) + paintArrays.push(node.strokes) + + for (const paints of paintArrays) { + for (const paint of paints) { + if (!paint.visible) continue + if (paint.type === 'SOLID') { + const syncColor = solidToStringSync(paint) if (syncColor !== null) { if (targetColor === null) targetColor = syncColor else if (targetColor !== syncColor) return false } else { - if (targetColor === null) targetColor = await solidToString(fill) - else if (targetColor !== (await solidToString(fill))) return false + if (targetColor === null) targetColor = await solidToString(paint) + else if (targetColor !== (await solidToString(paint))) return false } } else return null } } + if ('children' in node) { for (const child of node.children) { if (!child.visible) continue