diff --git a/src/__tests__/__snapshots__/code.test.ts.snap b/src/__tests__/__snapshots__/code.test.ts.snap index 4cc369d..1232b6d 100644 --- a/src/__tests__/__snapshots__/code.test.ts.snap +++ b/src/__tests__/__snapshots__/code.test.ts.snap @@ -3,41 +3,14 @@ exports[`registerCodegen should register codegen 1`] = ` [ { - "code": -"export function Test() { - return -}" -, + "code": "", "language": "TYPESCRIPT", - "title": "Test - Components", - }, - { - "code": -"mkdir -p src/components - -echo 'import { Box } from \\'@devup-ui/react\\' - -export function Test() { - return -}' > src/components/Test.tsx" -, - "language": "BASH", - "title": "Test - Components CLI (Bash)", + "title": "Usage", }, { - "code": -"New-Item -ItemType Directory -Force -Path src\\components | Out-Null - -@' -import { Box } from '@devup-ui/react' - -export function Test() { - return -} -'@ | Out-File -FilePath src\\components\\Test.tsx -Encoding UTF8" -, - "language": "BASH", - "title": "Test - Components CLI (PowerShell)", + "code": "", + "language": "TYPESCRIPT", + "title": "Test", }, ] `; diff --git a/src/__tests__/code.test.ts b/src/__tests__/code.test.ts index 27f8f68..a3458b8 100644 --- a/src/__tests__/code.test.ts +++ b/src/__tests__/code.test.ts @@ -409,7 +409,7 @@ describe('registerCodegen with viewport variant', () => { typeof r === 'object' && r !== null && 'title' in r && - (r as { title: string }).title.includes('Responsive'), + (r as { title: string }).title.endsWith('- Components'), ) expect(responsiveResult).toBeDefined() }) @@ -494,7 +494,7 @@ describe('registerCodegen with viewport variant', () => { typeof r === 'object' && r !== null && 'title' in r && - (r as { title: string }).title.includes('Responsive'), + (r as { title: string }).title.endsWith('- Components'), ) expect(responsiveResult).toBeDefined() @@ -505,6 +505,118 @@ describe('registerCodegen with viewport variant', () => { } }) + it('should generate responsive component with multiple non-viewport variants (size + varient)', async () => { + let capturedHandler: CodegenHandler | null = null + + const figmaMock = { + editorType: 'dev', + mode: 'codegen', + command: 'noop', + codegen: { + on: (_event: string, handler: CodegenHandler) => { + capturedHandler = handler + }, + }, + closePlugin: mock(() => {}), + } as unknown as typeof figma + + codeModule.registerCodegen(figmaMock) + + expect(capturedHandler).not.toBeNull() + if (capturedHandler === null) throw new Error('Handler not captured') + + // COMPONENT_SET with two non-viewport variants: size and varient + const componentSetNode = { + type: 'COMPONENT_SET', + name: 'MyButton', + visible: true, + componentPropertyDefinitions: { + size: { + type: 'VARIANT', + defaultValue: 'md', + variantOptions: ['sm', 'md'], + }, + varient: { + type: 'VARIANT', + defaultValue: 'primary', + variantOptions: ['primary', 'white'], + }, + }, + children: [ + { + type: 'COMPONENT', + name: 'size=sm, varient=primary', + visible: true, + variantProperties: { size: 'sm', varient: 'primary' }, + children: [], + layoutMode: 'HORIZONTAL', + width: 100, + height: 40, + }, + { + type: 'COMPONENT', + name: 'size=md, varient=primary', + visible: true, + variantProperties: { size: 'md', varient: 'primary' }, + children: [], + layoutMode: 'HORIZONTAL', + width: 200, + height: 50, + }, + { + type: 'COMPONENT', + name: 'size=sm, varient=white', + visible: true, + variantProperties: { size: 'sm', varient: 'white' }, + children: [], + layoutMode: 'HORIZONTAL', + width: 100, + height: 40, + }, + { + type: 'COMPONENT', + name: 'size=md, varient=white', + visible: true, + variantProperties: { size: 'md', varient: 'white' }, + children: [], + layoutMode: 'HORIZONTAL', + width: 200, + height: 50, + }, + ], + defaultVariant: { + type: 'COMPONENT', + name: 'size=md, varient=primary', + visible: true, + variantProperties: { size: 'md', varient: 'primary' }, + children: [], + }, + } as unknown as SceneNode + + const handler = capturedHandler as CodegenHandler + const result = await handler({ + node: componentSetNode, + language: 'devup-ui', + }) + + // Should include responsive components result + const responsiveResult = result.find( + (r: unknown) => + typeof r === 'object' && + r !== null && + 'title' in r && + (r as { title: string }).title.endsWith('- Components'), + ) + expect(responsiveResult).toBeDefined() + + // The generated code should include BOTH variant keys in the interface + const resultWithCode = responsiveResult as { code: string } | undefined + if (resultWithCode?.code) { + expect(resultWithCode.code).toContain('size') + expect(resultWithCode.code).toContain('varient') + } + }) + it('should generate responsive code for node with parent SECTION', async () => { let capturedHandler: CodegenHandler | null = null @@ -576,7 +688,7 @@ describe('registerCodegen with viewport variant', () => { typeof r === 'object' && r !== null && 'title' in r && - (r as { title: string }).title.includes('Responsive'), + (r as { title: string }).title.endsWith('- Responsive'), ) expect(responsiveResult).toBeDefined() }) @@ -601,6 +713,33 @@ describe('registerCodegen with viewport variant', () => { expect(capturedHandler).not.toBeNull() if (capturedHandler === null) throw new Error('Handler not captured') + // Create a nested custom component (NestedIcon) that CustomButton references. + // When CustomButton's code renders , generateImportStatements + // will extract it as a custom import — covering the customImports loop. + const nestedIconComponent = { + type: 'COMPONENT', + name: 'NestedIcon', + visible: true, + children: [], + width: 16, + height: 16, + layoutMode: 'NONE', + componentPropertyDefinitions: {}, + variantProperties: {} as Record, + reactions: [], + parent: null, + } + + // INSTANCE of NestedIcon, placed inside CustomButton variants + const nestedIconInstance = { + type: 'INSTANCE', + name: 'NestedIcon', + visible: true, + width: 16, + height: 16, + getMainComponentAsync: async () => nestedIconComponent, + } + // Create a custom component that will be referenced const customComponent = { type: 'COMPONENT', @@ -611,6 +750,7 @@ describe('registerCodegen with viewport variant', () => { height: 40, layoutMode: 'NONE', componentPropertyDefinitions: {}, + variantProperties: {} as Record, parent: null, } @@ -624,36 +764,93 @@ describe('registerCodegen with viewport variant', () => { getMainComponentAsync: async () => customComponent, } - // Create a COMPONENT that contains the INSTANCE - const componentNode = { + // Create COMPONENT variants that the instance references. + // Each variant contains a NestedIcon INSTANCE child — this causes + // the generated component code to include . + const componentVariant1 = { type: 'COMPONENT', - name: 'MyComponent', + name: 'CustomButton', visible: true, - children: [instanceNode], - width: 200, - height: 100, - layoutMode: 'VERTICAL', + children: [ + { + ...nestedIconInstance, + name: 'NestedIcon', + parent: null as unknown, + }, + ], + width: 100, + height: 40, + layoutMode: 'HORIZONTAL', componentPropertyDefinitions: {}, reactions: [], + variantProperties: { size: 'md' }, parent: null, - } as unknown as SceneNode + } - // Create COMPONENT_SET parent with proper children array + const componentVariant2 = { + type: 'COMPONENT', + name: 'CustomButton', + visible: true, + children: [ + { + ...nestedIconInstance, + name: 'NestedIcon', + parent: null as unknown, + }, + ], + width: 100, + height: 40, + layoutMode: 'HORIZONTAL', + componentPropertyDefinitions: {}, + reactions: [], + variantProperties: { size: 'lg' }, + parent: null, + } + + // Create COMPONENT_SET parent with a variant key so Components tab is generated const componentSetNode = { type: 'COMPONENT_SET', - name: 'MyComponentSet', - componentPropertyDefinitions: {}, - children: [componentNode], - defaultVariant: componentNode, + name: 'CustomButton', + componentPropertyDefinitions: { + size: { + type: 'VARIANT', + variantOptions: ['md', 'lg'], + }, + }, + children: [componentVariant1, componentVariant2], + defaultVariant: componentVariant1, reactions: [], } - // Set parent reference - ;(componentNode as { parent: unknown }).parent = componentSetNode + // Set parent references + ;(componentVariant1 as { parent: unknown }).parent = componentSetNode + ;(componentVariant2 as { parent: unknown }).parent = componentSetNode + for (const variant of [componentVariant1, componentVariant2]) { + for (const child of variant.children) { + ;(child as { parent: unknown }).parent = variant + } + } + ;(customComponent as { parent: unknown }).parent = componentSetNode + ;(customComponent as { variantProperties: unknown }).variantProperties = { + size: 'md', + } + + // Create a FRAME that contains the INSTANCE (not a COMPONENT) + const frameNode = { + type: 'FRAME', + name: 'MyFrame', + visible: true, + children: [instanceNode], + width: 200, + height: 100, + layoutMode: 'VERTICAL', + reactions: [], + parent: null, + } as unknown as SceneNode const handler = capturedHandler as CodegenHandler const result = await handler({ - node: componentNode, + node: frameNode, language: 'devup-ui', }) @@ -676,19 +873,17 @@ describe('registerCodegen with viewport variant', () => { expect(bashCLI).toBeDefined() expect(powershellCLI).toBeDefined() - // Check that custom component import is included (bash escapes quotes) + // Check that custom component file is included in CLI output const bashCode = (bashCLI as { code: string } | undefined)?.code const powershellCode = (powershellCLI as { code: string } | undefined)?.code if (bashCode) { - expect(bashCode).toContain( - "import { CustomButton } from \\'@/components/CustomButton\\'", - ) + expect(bashCode).toContain('CustomButton') + expect(bashCode).toContain('src/components/CustomButton.tsx') } if (powershellCode) { - expect(powershellCode).toContain( - "import { CustomButton } from '@/components/CustomButton'", - ) + expect(powershellCode).toContain('CustomButton') + expect(powershellCode).toContain('src\\components\\CustomButton.tsx') } }) @@ -793,18 +988,17 @@ describe('registerCodegen with viewport variant', () => { typeof r === 'object' && r !== null && 'title' in r && - (r as { title: string }).title === 'MyFrame - Components Responsive', + (r as { title: string }).title === 'MyFrame - Components', ) expect(responsiveResult).toBeDefined() - // Should also include CLI results for Components Responsive + // Should also include CLI results for Components const bashCLI = result.find( (r: unknown) => typeof r === 'object' && r !== null && 'title' in r && - (r as { title: string }).title === - 'MyFrame - Components Responsive CLI (Bash)', + (r as { title: string }).title === 'MyFrame - Components CLI (Bash)', ) expect(bashCLI).toBeDefined() @@ -814,8 +1008,472 @@ describe('registerCodegen with viewport variant', () => { r !== null && 'title' in r && (r as { title: string }).title === - 'MyFrame - Components Responsive CLI (PowerShell)', + 'MyFrame - Components CLI (PowerShell)', ) expect(powershellCLI).toBeDefined() }) }) + +describe('generateComponentUsage', () => { + it('should generate usage for COMPONENT without variant props', () => { + const node = { + type: 'COMPONENT', + name: 'MyButton', + variantProperties: null, + } as unknown as SceneNode + + const result = codeModule.generateComponentUsage(node) + expect(result).toBe('') + }) + + it('should generate usage for COMPONENT with variant props', () => { + const node = { + type: 'COMPONENT', + name: 'MyButton', + variantProperties: { variant: 'primary', size: 'lg' }, + } as unknown as SceneNode + + const result = codeModule.generateComponentUsage(node) + expect(result).toBe('') + }) + + it('should filter reserved variant keys for COMPONENT', () => { + const node = { + type: 'COMPONENT', + name: 'MyButton', + variantProperties: { + variant: 'primary', + viewport: 'mobile', + effect: 'hover', + }, + } as unknown as SceneNode + + const result = codeModule.generateComponentUsage(node) + expect(result).toBe('') + }) + + it('should generate usage for COMPONENT in COMPONENT_SET', () => { + const componentSet = { + type: 'COMPONENT_SET', + name: 'ButtonSet', + } + const node = { + type: 'COMPONENT', + name: 'variant=primary, size=lg', + variantProperties: { variant: 'primary', size: 'lg' }, + parent: componentSet, + } as unknown as SceneNode + + const result = codeModule.generateComponentUsage(node) + expect(result).toBe('') + }) + + it('should generate usage for COMPONENT_SET with defaults', () => { + const node = { + type: 'COMPONENT_SET', + name: 'MyButton', + componentPropertyDefinitions: { + variant: { + type: 'VARIANT', + defaultValue: 'primary', + variantOptions: ['primary', 'secondary'], + }, + size: { + type: 'VARIANT', + defaultValue: 'md', + variantOptions: ['sm', 'md', 'lg'], + }, + }, + } as unknown as SceneNode + + const result = codeModule.generateComponentUsage(node) + expect(result).toBe('') + }) + + it('should filter reserved variant keys for COMPONENT_SET', () => { + const node = { + type: 'COMPONENT_SET', + name: 'MyButton', + componentPropertyDefinitions: { + variant: { + type: 'VARIANT', + defaultValue: 'primary', + variantOptions: ['primary', 'secondary'], + }, + viewport: { + type: 'VARIANT', + defaultValue: 'desktop', + variantOptions: ['mobile', 'desktop'], + }, + }, + } as unknown as SceneNode + + const result = codeModule.generateComponentUsage(node) + expect(result).toBe('') + }) + + it('should sanitize COMPONENT_SET property names with hash suffixes', () => { + const node = { + type: 'COMPONENT_SET', + name: 'MyButton', + componentPropertyDefinitions: { + 'variant#123:456': { + type: 'VARIANT', + defaultValue: 'primary', + variantOptions: ['primary', 'secondary'], + }, + }, + } as unknown as SceneNode + + const result = codeModule.generateComponentUsage(node) + expect(result).toBe('') + }) + + it('should skip non-VARIANT properties for COMPONENT_SET', () => { + const node = { + type: 'COMPONENT_SET', + name: 'MyButton', + componentPropertyDefinitions: { + variant: { + type: 'VARIANT', + defaultValue: 'primary', + variantOptions: ['primary', 'secondary'], + }, + hasIcon: { + type: 'BOOLEAN', + defaultValue: true, + }, + icon: { + type: 'INSTANCE_SWAP', + defaultValue: 'some-id', + }, + }, + } as unknown as SceneNode + + const result = codeModule.generateComponentUsage(node) + expect(result).toBe('') + }) + + it('should generate usage for COMPONENT_SET without componentPropertyDefinitions', () => { + const node = { + type: 'COMPONENT_SET', + name: 'MyButton', + componentPropertyDefinitions: undefined, + } as unknown as SceneNode + + const result = codeModule.generateComponentUsage(node) + expect(result).toBe('') + }) + + it('should return null for non-component nodes', () => { + const node = { + type: 'FRAME', + name: 'MyFrame', + } as unknown as SceneNode + + const result = codeModule.generateComponentUsage(node) + expect(result).toBeNull() + }) + + it('should generate usage with no props when COMPONENT_SET has only reserved variants', () => { + const node = { + type: 'COMPONENT_SET', + name: 'MyButton', + componentPropertyDefinitions: { + viewport: { + type: 'VARIANT', + defaultValue: 'desktop', + variantOptions: ['mobile', 'desktop'], + }, + }, + } as unknown as SceneNode + + const result = codeModule.generateComponentUsage(node) + expect(result).toBe('') + }) +}) + +describe('registerCodegen with usage output', () => { + type CodegenHandler = (event: { + node: SceneNode + language: string + }) => Promise + + it('should generate usage for INSTANCE node', async () => { + let capturedHandler: CodegenHandler | null = null + + const figmaMock = { + editorType: 'dev', + mode: 'codegen', + command: 'noop', + codegen: { + on: (_event: string, handler: CodegenHandler) => { + capturedHandler = handler + }, + }, + closePlugin: mock(() => {}), + } as unknown as typeof figma + + codeModule.registerCodegen(figmaMock) + + expect(capturedHandler).not.toBeNull() + if (capturedHandler === null) throw new Error('Handler not captured') + + const mainComponent = { + type: 'COMPONENT', + name: 'PrimaryButton', + children: [], + visible: true, + } as unknown as ComponentNode + + const instanceNode = { + type: 'INSTANCE', + name: 'PrimaryButton', + visible: true, + componentProperties: { + 'variant#123:456': { type: 'VARIANT', value: 'primary' }, + 'size#789:012': { type: 'VARIANT', value: 'lg' }, + }, + getMainComponentAsync: async () => mainComponent, + } as unknown as SceneNode + + const handler = capturedHandler as CodegenHandler + const result = await handler({ + node: instanceNode, + language: 'devup-ui', + }) + + const usageResult = result.find( + (r: unknown) => + typeof r === 'object' && + r !== null && + 'title' in r && + (r as { title: string }).title === 'Usage', + ) + expect(usageResult).toBeDefined() + + const usageCode = (usageResult as { code: string }).code + expect(usageCode).toContain(' { + let capturedHandler: CodegenHandler | null = null + + const figmaMock = { + editorType: 'dev', + mode: 'codegen', + command: 'noop', + codegen: { + on: (_event: string, handler: CodegenHandler) => { + capturedHandler = handler + }, + }, + closePlugin: mock(() => {}), + } as unknown as typeof figma + + codeModule.registerCodegen(figmaMock) + + expect(capturedHandler).not.toBeNull() + if (capturedHandler === null) throw new Error('Handler not captured') + + const mainComponent = { + type: 'COMPONENT', + name: 'AbsButton', + children: [], + visible: true, + } as unknown as ComponentNode + + const parent = { + type: 'FRAME', + name: 'Parent', + children: [] as unknown[], + visible: true, + width: 500, + } + + const instanceNode = { + type: 'INSTANCE', + name: 'AbsButton', + visible: true, + width: 100, + height: 50, + x: 10, + y: 20, + layoutPositioning: 'ABSOLUTE', + constraints: { + horizontal: 'MIN', + vertical: 'MIN', + }, + componentProperties: { + 'variant#1:2': { type: 'VARIANT', value: 'secondary' }, + }, + getMainComponentAsync: async () => mainComponent, + parent, + } as unknown as SceneNode + + parent.children = [instanceNode] + + const handler = capturedHandler as CodegenHandler + const result = await handler({ + node: instanceNode, + language: 'devup-ui', + }) + + const usageResult = result.find( + (r: unknown) => + typeof r === 'object' && + r !== null && + 'title' in r && + (r as { title: string }).title === 'Usage', + ) + expect(usageResult).toBeDefined() + + const usageCode = (usageResult as { code: string }).code + // Should show clean component usage without position wrapper + expect(usageCode).toContain(' { + let capturedHandler: CodegenHandler | null = null + + const figmaMock = { + editorType: 'dev', + mode: 'codegen', + command: 'noop', + codegen: { + on: (_event: string, handler: CodegenHandler) => { + capturedHandler = handler + }, + }, + closePlugin: mock(() => {}), + } as unknown as typeof figma + + codeModule.registerCodegen(figmaMock) + + expect(capturedHandler).not.toBeNull() + if (capturedHandler === null) throw new Error('Handler not captured') + + const componentSetNode = { + type: 'COMPONENT_SET', + name: 'MyButton', + componentPropertyDefinitions: {}, + children: [] as unknown[], + defaultVariant: null as unknown, + } + + const componentNode = { + type: 'COMPONENT', + name: 'variant=primary', + visible: true, + variantProperties: { variant: 'primary' }, + children: [], + width: 100, + height: 40, + layoutMode: 'NONE', + componentPropertyDefinitions: {}, + parent: componentSetNode, + reactions: [], + } as unknown as SceneNode + + componentSetNode.children = [componentNode] + componentSetNode.defaultVariant = componentNode + + const handler = capturedHandler as CodegenHandler + const result = await handler({ + node: componentNode, + language: 'devup-ui', + }) + + const usageResult = result.find( + (r: unknown) => + typeof r === 'object' && + r !== null && + 'title' in r && + (r as { title: string }).title === 'Usage', + ) + expect(usageResult).toBeDefined() + + const usageCode = (usageResult as { code: string }).code + expect(usageCode).toBe('') + }) + + it('should generate usage for COMPONENT_SET node', async () => { + let capturedHandler: CodegenHandler | null = null + + const figmaMock = { + editorType: 'dev', + mode: 'codegen', + command: 'noop', + codegen: { + on: (_event: string, handler: CodegenHandler) => { + capturedHandler = handler + }, + }, + closePlugin: mock(() => {}), + } as unknown as typeof figma + + codeModule.registerCodegen(figmaMock) + + expect(capturedHandler).not.toBeNull() + if (capturedHandler === null) throw new Error('Handler not captured') + + const componentSetNode = { + type: 'COMPONENT_SET', + name: 'MyButton', + visible: true, + componentPropertyDefinitions: { + variant: { + type: 'VARIANT', + defaultValue: 'primary', + variantOptions: ['primary', 'secondary'], + }, + size: { + type: 'VARIANT', + defaultValue: 'md', + variantOptions: ['sm', 'md', 'lg'], + }, + }, + children: [ + { + type: 'COMPONENT', + name: 'variant=primary, size=md', + visible: true, + variantProperties: { variant: 'primary', size: 'md' }, + children: [], + layoutMode: 'VERTICAL', + width: 100, + height: 40, + }, + ], + defaultVariant: { + type: 'COMPONENT', + name: 'variant=primary, size=md', + visible: true, + variantProperties: { variant: 'primary', size: 'md' }, + children: [], + }, + } as unknown as SceneNode + + const handler = capturedHandler as CodegenHandler + const result = await handler({ + node: componentSetNode, + language: 'devup-ui', + }) + + const usageResult = result.find( + (r: unknown) => + typeof r === 'object' && + r !== null && + 'title' in r && + (r as { title: string }).title === 'Usage', + ) + expect(usageResult).toBeDefined() + + const usageCode = (usageResult as { code: string }).code + expect(usageCode).toBe('') + }) +}) diff --git a/src/code-impl.ts b/src/code-impl.ts index 0ad1f86..db0b896 100644 --- a/src/code-impl.ts +++ b/src/code-impl.ts @@ -5,8 +5,12 @@ import { } from './codegen/Codegen' import { resetGetPropsCache } from './codegen/props' import { resetChildAnimationCache } from './codegen/props/reaction' -import { resetSelectorPropsCache } from './codegen/props/selector' +import { + resetSelectorPropsCache, + sanitizePropertyName, +} from './codegen/props/selector' import { ResponsiveCodegen } from './codegen/responsive/ResponsiveCodegen' +import { isReservedVariantKey } from './codegen/utils/extract-instance-variant-props' import { nodeProxyTracker } from './codegen/utils/node-proxy' import { perfEnd, perfReport, perfReset, perfStart } from './codegen/utils/perf' import { resetVariableCache } from './codegen/utils/variable-cache' @@ -126,6 +130,44 @@ function generatePowerShellCLI( return commands.join('\n') } +export function generateComponentUsage(node: SceneNode): string | null { + const componentName = getComponentName(node) + + if (node.type === 'COMPONENT') { + const variantProps = (node as ComponentNode).variantProperties + if (!variantProps) return `<${componentName} />` + + const entries: [string, string][] = [] + for (const [key, value] of Object.entries(variantProps)) { + if (!isReservedVariantKey(key)) { + entries.push([key, value]) + } + } + + if (entries.length === 0) return `<${componentName} />` + const propsStr = entries.map(([k, v]) => `${k}="${v}"`).join(' ') + return `<${componentName} ${propsStr} />` + } + + if (node.type === 'COMPONENT_SET') { + const defs = (node as ComponentSetNode).componentPropertyDefinitions + if (!defs) return `<${componentName} />` + + const entries: [string, string][] = [] + for (const [key, def] of Object.entries(defs)) { + if (def.type === 'VARIANT' && !isReservedVariantKey(key)) { + entries.push([sanitizePropertyName(key), String(def.defaultValue)]) + } + } + + if (entries.length === 0) return `<${componentName} />` + const propsStr = entries.map(([k, v]) => `${k}="${v}"`).join(' ') + return `<${componentName} ${propsStr} />` + } + + return null +} + const debug = true export function registerCodegen(ctx: typeof figma) { @@ -161,6 +203,12 @@ export function registerCodegen(ctx: typeof figma) { > = [] if (node.type === 'COMPONENT_SET') { const componentName = getComponentName(node) + // Reset the global build tree cache so that each variant's Codegen + // instance runs doBuildTree fresh — this ensures addComponentTree fires + // and BOOLEAN condition fields are populated on children. + // Without this, cached trees from codegen.run() above would cause + // buildTree to return early, skipping addComponentTree entirely. + resetGlobalBuildTreeCache() t = perfStart() responsiveComponentsCodes = await ResponsiveCodegen.generateVariantResponsiveComponents( @@ -171,10 +219,18 @@ export function registerCodegen(ctx: typeof figma) { } // Generate responsive codes for components extracted from the page + // Skip when the selected node itself is a COMPONENT or COMPONENT_SET, + // because the self-referencing componentTree would trigger the parent + // COMPONENT_SET to be fully expanded — producing ComponentSet-level output + // when the user only wants to see their selected variant. let componentsResponsiveCodes: ReadonlyArray< readonly [string, string] > = [] - if (componentsCodes.length > 0) { + if ( + componentsCodes.length > 0 && + node.type !== 'COMPONENT' && + node.type !== 'COMPONENT_SET' + ) { const componentNodes = codegen.getComponentNodes() const processedComponentSets = new Set() const responsiveResults: Array = [] @@ -190,6 +246,8 @@ export function registerCodegen(ctx: typeof figma) { if (parentSet && !processedComponentSets.has(parentSet.id)) { processedComponentSets.add(parentSet.id) const componentName = getComponentName(parentSet) + // Reset global cache so addComponentTree fires for BOOLEAN conditions + resetGlobalBuildTreeCache() t = perfStart() const responsiveCodes = await ResponsiveCodegen.generateVariantResponsiveComponents( @@ -273,76 +331,83 @@ export function registerCodegen(ctx: typeof figma) { ) } - return [ - ...(node.type === 'COMPONENT' || + // Generate usage snippet for component-type nodes + const isComponentType = + node.type === 'COMPONENT' || node.type === 'COMPONENT_SET' || node.type === 'INSTANCE' - ? [] - : [ + const usageResults: { + title: string + language: 'TYPESCRIPT' + code: string + }[] = [] + if (node.type === 'INSTANCE') { + // For INSTANCE: extract clean component usage from the tree + // (tree already has the correct component name from getMainComponentAsync) + const tree = await codegen.getTree() + const componentTree = tree.isComponent + ? tree + : tree.children.find((c) => c.isComponent) + if (componentTree) { + usageResults.push({ + title: 'Usage', + language: 'TYPESCRIPT', + code: Codegen.renderTree(componentTree, 0), + }) + } + } else if ( + node.type === 'COMPONENT' || + node.type === 'COMPONENT_SET' + ) { + const usage = generateComponentUsage(node) + if (usage) { + usageResults.push({ + title: 'Usage', + language: 'TYPESCRIPT', + code: usage, + }) + } + } + + const allComponentsCodes = [ + ...componentsResponsiveCodes, + ...responsiveComponentsCodes, + ] + + // For COMPONENT nodes, show both the single-variant code AND Usage. + // For COMPONENT_SET and INSTANCE, show only Usage. + // For all other types, show the main code. + const showMainCode = !isComponentType || node.type === 'COMPONENT' + + return [ + ...usageResults, + ...(showMainCode + ? [ { title: node.name, language: 'TYPESCRIPT', code: codegen.getCode(), } as const, - ]), - ...(componentsCodes.length > 0 - ? ([ - { - title: `${node.name} - Components`, - language: 'TYPESCRIPT', - code: componentsCodes.map((code) => code[1]).join('\n\n'), - }, - { - title: `${node.name} - Components CLI (Bash)`, - language: 'BASH', - code: generateBashCLI(componentsCodes), - }, - { - title: `${node.name} - Components CLI (PowerShell)`, - language: 'BASH', - code: generatePowerShellCLI(componentsCodes), - }, - ] as const) - : []), - ...(componentsResponsiveCodes.length > 0 - ? [ - { - title: `${node.name} - Components Responsive`, - language: 'TYPESCRIPT' as const, - code: componentsResponsiveCodes - .map((code) => code[1]) - .join('\n\n'), - }, - { - title: `${node.name} - Components Responsive CLI (Bash)`, - language: 'BASH' as const, - code: generateBashCLI(componentsResponsiveCodes), - }, - { - title: `${node.name} - Components Responsive CLI (PowerShell)`, - language: 'BASH' as const, - code: generatePowerShellCLI(componentsResponsiveCodes), - }, ] : []), - ...(responsiveComponentsCodes.length > 0 + ...(allComponentsCodes.length > 0 ? [ { - title: `${node.name} - Components Responsive`, + title: `${node.name} - Components`, language: 'TYPESCRIPT' as const, - code: responsiveComponentsCodes + code: allComponentsCodes .map((code) => code[1]) .join('\n\n'), }, { - title: `${node.name} - Components Responsive CLI (Bash)`, + title: `${node.name} - Components CLI (Bash)`, language: 'BASH' as const, - code: generateBashCLI(responsiveComponentsCodes), + code: generateBashCLI(allComponentsCodes), }, { - title: `${node.name} - Components Responsive CLI (PowerShell)`, + title: `${node.name} - Components CLI (PowerShell)`, language: 'BASH' as const, - code: generatePowerShellCLI(responsiveComponentsCodes), + code: generatePowerShellCLI(allComponentsCodes), }, ] : []), diff --git a/src/codegen/Codegen.ts b/src/codegen/Codegen.ts index 14fe3ea..b8d91e5 100644 --- a/src/codegen/Codegen.ts +++ b/src/codegen/Codegen.ts @@ -154,6 +154,18 @@ export class Codegen { return this.code } + /** + * Get the component tree built by addComponentTree for this node. + * Unlike getTree(), which skips invisible children in buildTree(), + * this tree includes ALL children with BOOLEAN conditions and + * INSTANCE_SWAP slot placeholders preserved. + * Returns undefined if no component tree was built (non-COMPONENT nodes). + */ + getComponentTree(): ComponentTree | undefined { + const nodeId = this.node.id || this.node.name + return this.componentTrees.get(nodeId) + } + getComponentsCodes() { const result: Array = [] for (const { node, code, variants } of this.components.values()) { @@ -252,6 +264,23 @@ export class Codegen { private async doBuildTree(node: SceneNode): Promise { const tBuild = perfStart() + + // Handle COMPONENT_SET or COMPONENT — fire addComponentTree BEFORE any early returns + // (e.g., asset detection) so that BOOLEAN conditions and INSTANCE_SWAP slots are always + // detected on children, even when the COMPONENT itself is classified as an asset. + if ( + (node.type === 'COMPONENT_SET' || node.type === 'COMPONENT') && + ((this.node.type === 'COMPONENT_SET' && + node === this.node.defaultVariant) || + this.node.type === 'COMPONENT') + ) { + this.pendingComponentTrees.push( + this.addComponentTree( + node.type === 'COMPONENT_SET' ? node.defaultVariant : node, + ), + ) + } + // Handle asset nodes (images/SVGs) const assetNode = checkAssetNode(node) if (assetNode) { @@ -263,6 +292,7 @@ export class Codegen { props.maskImage = buildCssUrl(props.src as string) props.maskRepeat = 'no-repeat' props.maskSize = 'contain' + props.maskPos = 'center' props.bg = maskColor delete props.src } @@ -339,20 +369,6 @@ export class Codegen { // Fire getProps early for non-INSTANCE nodes — it runs while we process children. const propsPromise = getProps(node) - // Handle COMPONENT_SET or COMPONENT - add to componentTrees (fire-and-forget) - if ( - (node.type === 'COMPONENT_SET' || node.type === 'COMPONENT') && - ((this.node.type === 'COMPONENT_SET' && - node === this.node.defaultVariant) || - this.node.type === 'COMPONENT') - ) { - this.pendingComponentTrees.push( - this.addComponentTree( - node.type === 'COMPONENT_SET' ? node.defaultVariant : node, - ), - ) - } - // Build children sequentially — Figma's single-threaded IPC means // concurrent subtree builds add overhead without improving throughput, // and sequential order maximizes cache hits for shared nodes. diff --git a/src/codegen/__tests__/__snapshots__/codegen.test.ts.snap b/src/codegen/__tests__/__snapshots__/codegen.test.ts.snap index c8d6f68..f0230c1 100644 --- a/src/codegen/__tests__/__snapshots__/codegen.test.ts.snap +++ b/src/codegen/__tests__/__snapshots__/codegen.test.ts.snap @@ -35,6 +35,7 @@ exports[`Codegen renders svg asset with same color mask 1`] = ` bg="#F00" boxSize="24px" maskImage="url(/icons/MaskIcon.svg)" + maskPos="center" maskRepeat="no-repeat" maskSize="contain" />" @@ -45,6 +46,7 @@ exports[`Codegen renders svg asset with children same color 1`] = ` bg="#F00" boxSize="24px" maskImage="url(/icons/GroupIcon.svg)" + maskPos="center" maskRepeat="no-repeat" maskSize="contain" />" @@ -61,6 +63,7 @@ exports[`Codegen renders nested svg asset with 3 solid fill boxes in frame 1`] = bg="#F00" boxSize="24px" maskImage="url(/icons/NestedIcon.svg)" + maskPos="center" maskRepeat="no-repeat" maskSize="contain" />" @@ -1658,6 +1661,7 @@ exports[`render real world component real world $ 19`] = ` bg="#FFF" boxSize="20px" maskImage="url(/icons/arrow.svg)" + maskPos="center" maskRepeat="no-repeat" maskSize="contain" /> @@ -1895,7 +1899,15 @@ exports[`render real world component real world $ 32`] = ` exports[`render real world component real world $ 33`] = ` "export function Circle() { - return + return ( + + ) }" `; @@ -1934,6 +1946,7 @@ exports[`render real world component real world $ 36`] = ` bg="#FFF" borderTop="solid 1px #000" maskImage="url(/icons/border.svg)" + maskPos="center" maskRepeat="no-repeat" maskSize="contain" /> @@ -2144,6 +2157,7 @@ exports[`render real world component real world $ 47`] = ` aspectRatio="1" bg="$caption" maskImage="url(/icons/recommend.svg)" + maskPos="center" maskRepeat="no-repeat" maskSize="contain" pb="2.96px" @@ -2161,6 +2175,7 @@ exports[`render real world component real world $ 48`] = ` @@ -2981,6 +2998,7 @@ exports[`render real world component real world $ 78`] = ` bg="$gray300" boxSize="24px" maskImage="url(/icons/ic:round-arrow-left.svg)" + maskPos="center" maskRepeat="no-repeat" maskSize="contain" transform="rotate(180deg)" @@ -3009,6 +3027,7 @@ exports[`render real world component real world $ 78`] = ` bg="$gray300" boxSize="24px" maskImage="url(/icons/ic:round-arrow-left.svg)" + maskPos="center" maskRepeat="no-repeat" maskSize="contain" transform="rotate(180deg)" @@ -3037,6 +3056,7 @@ exports[`render real world component real world $ 78`] = ` bg="$gray300" boxSize="24px" maskImage="url(/icons/ic:round-arrow-left.svg)" + maskPos="center" maskRepeat="no-repeat" maskSize="contain" transform="rotate(180deg)" @@ -3050,19 +3070,43 @@ exports[`render real world component real world $ 78`] = ` exports[`render real world component real world $ 79`] = ` "export function 다양한도형() { - return + return ( + + ) }" `; exports[`render real world component real world $ 80`] = ` "export function 다양한도형() { - return + return ( + + ) }" `; exports[`render real world component real world $ 81`] = ` "export function 다양한도형() { - return + return ( + + ) }" `; @@ -3129,6 +3173,7 @@ exports[`render real world component real world $ 91`] = ` bg="$textLight" boxSize="24px" maskImage="url(/icons/back.svg)" + maskPos="center" maskRepeat="no-repeat" maskSize="contain" pb="5px" @@ -3151,6 +3196,7 @@ exports[`render real world component real world $ 92`] = ` bg="$primary" boxSize="20px" maskImage="url(/icons/plus.svg)" + maskPos="center" maskRepeat="no-repeat" maskSize="contain" pb="2.29px" @@ -3182,6 +3228,7 @@ exports[`render real world component real world $ 94`] = ` bg="#000" boxSize="28px" maskImage="url(/icons/cog.svg)" + maskPos="center" maskRepeat="no-repeat" maskSize="contain" pb="2.33px" @@ -3213,6 +3260,7 @@ exports[`render real world component real world $ 96`] = ` bg="#000" boxSize="28px" maskImage="url(/icons/cog.svg)" + maskPos="center" maskRepeat="no-repeat" maskSize="contain" pb="2.33px" @@ -3495,6 +3543,7 @@ exports[`render real world component real world $ 106`] = ` bg="#B1874F" h="104px" maskImage="url(/icons/path1208.svg)" + maskPos="center" maskRepeat="no-repeat" maskSize="contain" w="200px" @@ -3627,6 +3676,7 @@ exports[`render real world component real world $ 107`] = ` bg="#B1874F" h="52px" maskImage="url(/icons/path1208.svg)" + maskPos="center" maskRepeat="no-repeat" maskSize="contain" w="100px" @@ -3781,6 +3831,7 @@ exports[`render real world component real world $ 108`] = ` bg="$gold" h="64px" maskImage="url('/icons/Group 27.svg')" + maskPos="center" maskRepeat="no-repeat" maskSize="contain" w="102.17px" diff --git a/src/codegen/__tests__/codegen-viewport.test.ts b/src/codegen/__tests__/codegen-viewport.test.ts index d62400d..321a38e 100644 --- a/src/codegen/__tests__/codegen-viewport.test.ts +++ b/src/codegen/__tests__/codegen-viewport.test.ts @@ -1,5 +1,8 @@ -import { afterAll, describe, expect, test } from 'bun:test' -import { Codegen } from '../Codegen' +import { afterAll, beforeEach, describe, expect, test } from 'bun:test' +import { resetTextStyleCache } from '../../utils' +import { Codegen, resetGlobalBuildTreeCache } from '../Codegen' +import { resetGetPropsCache } from '../props' +import { resetSelectorPropsCache } from '../props/selector' import { ResponsiveCodegen } from '../responsive/ResponsiveCodegen' // Mock figma global @@ -42,7 +45,18 @@ import { ResponsiveCodegen } from '../responsive/ResponsiveCodegen' }, } as unknown as typeof figma +beforeEach(() => { + resetGlobalBuildTreeCache() + resetGetPropsCache() + resetSelectorPropsCache() + resetTextStyleCache() +}) + afterAll(() => { + resetGlobalBuildTreeCache() + resetGetPropsCache() + resetSelectorPropsCache() + resetTextStyleCache() ;(globalThis as { figma?: unknown }).figma = undefined }) @@ -697,4 +711,839 @@ describe('Codegen effect-only COMPONENT_SET', () => { // Should have _hover props expect(generatedCode).toContain('_hover') }) + + test('generates BOOLEAN conditions on children in multi-variant ComponentSet', async () => { + // Simulates a ComponentSet with: + // - size: lg, tag (VARIANT) + // - varient: primary, white (VARIANT) + // - leftIcon: boolean (BOOLEAN) — controls Icon child visibility + // + // Icon child exists in lg+primary and lg+white (with leftIcon condition) + // but NOT in tag variants. This is a BOOLEAN-controlled partial child. + + function createFrameChild( + id: string, + name: string, + overrides: Record = {}, + ): SceneNode { + return { + type: 'FRAME', + id, + name, + visible: true, + children: [], + fills: [], + strokes: [], + effects: [], + reactions: [], + width: 20, + height: 20, + layoutMode: 'NONE', + layoutSizingHorizontal: 'FIXED', + layoutSizingVertical: 'FIXED', + ...overrides, + } as unknown as SceneNode + } + + // lg+primary (has Icon child with BOOLEAN condition + Label child) + const lgPrimary = createComponentNode( + 'size=lg, varient=primary', + { size: 'lg', varient: 'primary' }, + { + id: 'lg-primary', + children: [ + createFrameChild('icon-lg-p', 'Icon', { + componentPropertyReferences: { visible: 'leftIcon#70:100' }, + }), + createFrameChild('label-lg-p', 'Label'), + ] as unknown as readonly SceneNode[], + layoutMode: 'HORIZONTAL', + itemSpacing: 10, + paddingLeft: 24, + paddingRight: 24, + paddingTop: 10, + paddingBottom: 10, + }, + ) + + // lg+white (has Icon child with BOOLEAN condition + Label child) + const lgWhite = createComponentNode( + 'size=lg, varient=white', + { size: 'lg', varient: 'white' }, + { + id: 'lg-white', + children: [ + createFrameChild('icon-lg-w', 'Icon', { + componentPropertyReferences: { visible: 'leftIcon#70:100' }, + }), + createFrameChild('label-lg-w', 'Label'), + ] as unknown as readonly SceneNode[], + layoutMode: 'HORIZONTAL', + itemSpacing: 10, + paddingLeft: 24, + paddingRight: 24, + paddingTop: 10, + paddingBottom: 10, + }, + ) + + // tag+primary (NO Icon, just Label) + const tagPrimary = createComponentNode( + 'size=tag, varient=primary', + { size: 'tag', varient: 'primary' }, + { + id: 'tag-primary', + children: [ + createFrameChild('label-tag-p', 'Label'), + ] as unknown as readonly SceneNode[], + layoutMode: 'HORIZONTAL', + itemSpacing: 0, + paddingLeft: 10, + paddingRight: 10, + paddingTop: 6, + paddingBottom: 6, + }, + ) + + // tag+white (NO Icon, just Label) + const tagWhite = createComponentNode( + 'size=tag, varient=white', + { size: 'tag', varient: 'white' }, + { + id: 'tag-white', + children: [ + createFrameChild('label-tag-w', 'Label'), + ] as unknown as readonly SceneNode[], + layoutMode: 'HORIZONTAL', + itemSpacing: 0, + paddingLeft: 10, + paddingRight: 10, + paddingTop: 6, + paddingBottom: 6, + }, + ) + + const allChildren = [lgPrimary, lgWhite, tagPrimary, tagWhite] + + const componentSet = createComponentSetNode( + 'Button', + { + size: { + type: 'VARIANT', + defaultValue: 'lg', + variantOptions: ['lg', 'tag'], + }, + varient: { + type: 'VARIANT', + defaultValue: 'primary', + variantOptions: ['primary', 'white'], + }, + 'leftIcon#70:100': { + type: 'BOOLEAN', + defaultValue: true, + }, + }, + allChildren, + ) + + // Set parent references (critical for addComponentTree to find BOOLEAN properties) + for (const child of allChildren) { + ;(child as unknown as { parent: ComponentSetNode }).parent = componentSet + if ('children' in child && child.children) { + for (const grandchild of child.children) { + ;(grandchild as unknown as { parent: SceneNode }).parent = child + } + } + } + + const codes = await ResponsiveCodegen.generateVariantResponsiveComponents( + componentSet, + 'Button', + ) + + expect(codes.length).toBe(1) + const [, generatedCode] = codes[0] + + // Should have leftIcon as a boolean prop + expect(generatedCode).toContain('leftIcon?: boolean') + + // Icon child should have BOOLEAN condition prepended before variant condition + // The icon exists in lg+primary and lg+white but NOT tag variants + // So it should render as: {leftIcon && size === "lg" && } + expect(generatedCode).toMatch(/leftIcon\s*&&/) + }) + + test('generates BOOLEAN conditions on INSTANCE asset children in multi-variant ComponentSet', async () => { + // Mirrors the real Figma Button structure where: + // - size: lg, md, sm, tag (VARIANT) + // - varient: primary, white (VARIANT) + // - leftIcon: boolean (BOOLEAN) — controls MypageIcon child visibility + // - rightIcon: boolean (BOOLEAN) — controls Arrow child visibility + // + // MypageIcon and Arrow are INSTANCE children classified as SVG assets. + // They exist in lg, md, sm variants but NOT tag variants. + + function createInstanceChild( + id: string, + name: string, + overrides: Record = {}, + ): SceneNode { + return { + type: 'INSTANCE', + id, + name, + visible: true, + children: [ + { + type: 'VECTOR', + id: `${id}-vec`, + name: `${name}Vector`, + visible: true, + isAsset: true, + fills: [ + { + type: 'SOLID', + visible: true, + color: { r: 1, g: 1, b: 1 }, + opacity: 1, + }, + ], + strokes: [], + effects: [], + reactions: [], + }, + ], + fills: [], + strokes: [], + effects: [], + reactions: [], + isAsset: true, + width: 20, + height: 20, + ...overrides, + } as unknown as SceneNode + } + + function createTextChild( + id: string, + name: string, + text: string, + overrides: Record = {}, + ): SceneNode { + return { + type: 'TEXT', + id, + name, + visible: true, + characters: text, + fills: [ + { + type: 'SOLID', + visible: true, + color: { r: 1, g: 1, b: 1 }, + opacity: 1, + }, + ], + strokes: [], + effects: [], + reactions: [], + width: 50, + height: 20, + fontSize: 16, + fontName: { family: 'Pretendard', style: 'Bold' }, + fontWeight: 600, + textAlignHorizontal: 'LEFT', + textAlignVertical: 'CENTER', + letterSpacing: { value: -2, unit: 'PERCENT' }, + lineHeight: { unit: 'AUTO' }, + textAutoResize: 'WIDTH_AND_HEIGHT', + textStyleId: '', + getStyledTextSegments: () => [ + { + characters: text, + start: 0, + end: text.length, + fontSize: 16, + fontName: { family: 'Pretendard', style: 'Bold' }, + fontWeight: 600, + fills: [ + { + type: 'SOLID', + visible: true, + color: { r: 1, g: 1, b: 1 }, + opacity: 1, + }, + ], + textDecoration: 'NONE', + textCase: 'ORIGINAL', + lineHeight: { unit: 'AUTO' }, + letterSpacing: { value: -2, unit: 'PERCENT' }, + textStyleId: '', + fillStyleId: '', + listOptions: { type: 'NONE' }, + indentation: 0, + hyperlink: null, + }, + ], + ...overrides, + } as unknown as SceneNode + } + + // Helper to build a variant COMPONENT with MypageIcon + Text + Arrow children + function createVariantWithIcons( + size: string, + varient: string, + opts: { hasIcons: boolean; px: number; gap: number }, + ) { + const id = `${size}-${varient}` + const children: SceneNode[] = [] + if (opts.hasIcons) { + children.push( + createInstanceChild(`icon-${id}`, 'MypageIcon', { + componentPropertyReferences: { + visible: 'leftIcon#70:100', + }, + }), + ) + } + children.push(createTextChild(`text-${id}`, 'Label', `btn${size}`)) + if (opts.hasIcons) { + children.push( + createInstanceChild(`arrow-${id}`, 'Arrow', { + componentPropertyReferences: { + visible: 'rightIcon#71:101', + }, + }), + ) + } + + return createComponentNode( + `size=${size}, varient=${varient}`, + { size, varient }, + { + id, + children: children as unknown as readonly SceneNode[], + layoutMode: 'HORIZONTAL', + itemSpacing: opts.gap, + paddingLeft: opts.px, + paddingRight: opts.px, + paddingTop: 10, + paddingBottom: 10, + }, + ) + } + + const sizes = ['lg', 'md', 'sm', 'tag'] + const varients = ['primary', 'white'] + const allChildren: ComponentNode[] = [] + + for (const size of sizes) { + for (const varient of varients) { + const hasIcons = size !== 'tag' + allChildren.push( + createVariantWithIcons(size, varient, { + hasIcons, + px: size === 'tag' ? 10 : 24, + gap: 10, + }), + ) + } + } + + const componentSet = createComponentSetNode( + 'Button', + { + size: { + type: 'VARIANT', + defaultValue: 'lg', + variantOptions: sizes, + }, + varient: { + type: 'VARIANT', + defaultValue: 'primary', + variantOptions: varients, + }, + 'leftIcon#70:100': { + type: 'BOOLEAN', + defaultValue: true, + }, + 'rightIcon#71:101': { + type: 'BOOLEAN', + defaultValue: true, + }, + }, + allChildren, + ) + + // Set parent references + for (const child of allChildren) { + ;(child as unknown as { parent: ComponentSetNode }).parent = componentSet + if ('children' in child && child.children) { + for (const grandchild of child.children) { + ;(grandchild as unknown as { parent: SceneNode }).parent = child + } + } + } + + const codes = await ResponsiveCodegen.generateVariantResponsiveComponents( + componentSet, + 'Button', + ) + + expect(codes.length).toBe(1) + const [, generatedCode] = codes[0] + + // Should have leftIcon and rightIcon as boolean props + expect(generatedCode).toContain('leftIcon?: boolean') + expect(generatedCode).toContain('rightIcon?: boolean') + + // MypageIcon should have leftIcon condition + // Arrow should have rightIcon condition + expect(generatedCode).toMatch(/leftIcon\s*&&/) + expect(generatedCode).toMatch(/rightIcon\s*&&/) + + // MypageIcon and Arrow only exist in lg, md, sm — not tag + // So there should also be a size condition + expect(generatedCode).toMatch( + /size\s*===\s*"lg"\s*\|\|\s*size\s*===\s*"md"\s*\|\|\s*size\s*===\s*"sm"/, + ) + }) + + test('BOOLEAN conditions survive after codegen.run() populates globalBuildTreeCache', async () => { + // Regression test: In real Figma, codegen.run(componentSet) is called first, + // populating globalBuildTreeCache. Then generateVariantResponsiveComponents is called. + // Without resetting the cache, buildTree returns cached trees without firing + // addComponentTree, so BOOLEAN conditions are never set on children. + + function createInstanceChild3( + id: string, + name: string, + overrides: Record = {}, + ): SceneNode { + return { + type: 'INSTANCE', + id, + name, + visible: true, + children: [ + { + type: 'VECTOR', + id: `${id}-vec`, + name: `${name}Vector`, + visible: true, + isAsset: true, + fills: [ + { + type: 'SOLID', + visible: true, + color: { r: 1, g: 1, b: 1 }, + opacity: 1, + }, + ], + strokes: [], + effects: [], + reactions: [], + }, + ], + fills: [], + strokes: [], + effects: [], + reactions: [], + isAsset: true, + width: 20, + height: 20, + ...overrides, + } as unknown as SceneNode + } + + function createTextChild3( + id: string, + name: string, + text: string, + ): SceneNode { + return { + type: 'TEXT', + id, + name, + visible: true, + characters: text, + fills: [ + { + type: 'SOLID', + visible: true, + color: { r: 1, g: 1, b: 1 }, + opacity: 1, + }, + ], + strokes: [], + effects: [], + reactions: [], + width: 50, + height: 20, + fontSize: 16, + fontName: { family: 'Pretendard', style: 'Bold' }, + fontWeight: 600, + textAlignHorizontal: 'LEFT', + textAlignVertical: 'CENTER', + letterSpacing: { value: -2, unit: 'PERCENT' }, + lineHeight: { unit: 'AUTO' }, + textAutoResize: 'WIDTH_AND_HEIGHT', + textStyleId: '', + getStyledTextSegments: () => [ + { + characters: text, + start: 0, + end: text.length, + fontSize: 16, + fontName: { family: 'Pretendard', style: 'Bold' }, + fontWeight: 600, + fills: [ + { + type: 'SOLID', + visible: true, + color: { r: 1, g: 1, b: 1 }, + opacity: 1, + }, + ], + textDecoration: 'NONE', + textCase: 'ORIGINAL', + lineHeight: { unit: 'AUTO' }, + letterSpacing: { value: -2, unit: 'PERCENT' }, + textStyleId: '', + fillStyleId: '', + listOptions: { type: 'NONE' }, + indentation: 0, + hyperlink: null, + }, + ], + } as unknown as SceneNode + } + + const sizes = ['lg', 'tag'] + const varients = ['primary', 'white'] + const allChildren: ComponentNode[] = [] + + for (const size of sizes) { + for (const varient of varients) { + const hasIcons = size !== 'tag' + const id = `cache-${size}-${varient}` + const children: SceneNode[] = [] + if (hasIcons) { + children.push( + createInstanceChild3(`icon-${id}`, 'MypageIcon', { + componentPropertyReferences: { + visible: 'leftIcon#70:100', + }, + }), + ) + } + children.push(createTextChild3(`text-${id}`, 'Label', `btn${size}`)) + allChildren.push( + createComponentNode( + `size=${size}, varient=${varient}`, + { size, varient }, + { + id, + children: children as unknown as readonly SceneNode[], + layoutMode: 'HORIZONTAL', + itemSpacing: 10, + paddingLeft: size === 'tag' ? 10 : 24, + paddingRight: size === 'tag' ? 10 : 24, + paddingTop: 10, + paddingBottom: 10, + }, + ), + ) + } + } + + const componentSet = createComponentSetNode( + 'CacheButton', + { + size: { + type: 'VARIANT', + defaultValue: 'lg', + variantOptions: sizes, + }, + varient: { + type: 'VARIANT', + defaultValue: 'primary', + variantOptions: varients, + }, + 'leftIcon#70:100': { + type: 'BOOLEAN', + defaultValue: true, + }, + }, + allChildren, + ) + + // Set parent references + for (const child of allChildren) { + ;(child as unknown as { parent: ComponentSetNode }).parent = componentSet + if ('children' in child && child.children) { + for (const grandchild of child.children) { + ;(grandchild as unknown as { parent: SceneNode }).parent = child + } + } + } + + // Step 1: Run codegen.run() first — this populates globalBuildTreeCache + // (simulates what happens in code-impl.ts before generateVariantResponsiveComponents) + const codegen = new Codegen(componentSet as unknown as SceneNode) + await codegen.run() + + // Step 2: Reset cache (this is the fix in code-impl.ts) + resetGlobalBuildTreeCache() + + // Step 3: Now generate responsive components + const codes = await ResponsiveCodegen.generateVariantResponsiveComponents( + componentSet, + 'CacheButton', + ) + + expect(codes.length).toBe(1) + const [, generatedCode] = codes[0] + + // Should have leftIcon as a boolean prop + expect(generatedCode).toContain('leftIcon?: boolean') + + // MypageIcon should have leftIcon condition — THIS WOULD FAIL without the cache reset + expect(generatedCode).toMatch(/leftIcon\s*&&/) + }) + + test('generates BOOLEAN conditions with effect variants (real Button scenario)', async () => { + // Mirrors the REAL Figma Button with effect variants: + // - size: lg, md, sm, tag (VARIANT) + // - varient: primary, white, ghost (VARIANT) + // - effect: default, hover (VARIANT) — becomes pseudo-selectors + // - leftIcon: boolean (BOOLEAN) — controls MypageIcon visibility + // + // The effect variant is filtered to only keep 'default' children. + // This ensures the BOOLEAN condition still propagates through the + // effect-filtering + multi-variant merge pipeline. + + function createInstanceChild2( + id: string, + name: string, + overrides: Record = {}, + ): SceneNode { + return { + type: 'INSTANCE', + id, + name, + visible: true, + children: [ + { + type: 'VECTOR', + id: `${id}-vec`, + name: `${name}Vector`, + visible: true, + isAsset: true, + fills: [ + { + type: 'SOLID', + visible: true, + color: { r: 1, g: 1, b: 1 }, + opacity: 1, + }, + ], + strokes: [], + effects: [], + reactions: [], + }, + ], + fills: [], + strokes: [], + effects: [], + reactions: [], + isAsset: true, + width: 20, + height: 20, + ...overrides, + } as unknown as SceneNode + } + + function createTextChild2( + id: string, + name: string, + text: string, + ): SceneNode { + return { + type: 'TEXT', + id, + name, + visible: true, + characters: text, + fills: [ + { + type: 'SOLID', + visible: true, + color: { r: 1, g: 1, b: 1 }, + opacity: 1, + }, + ], + strokes: [], + effects: [], + reactions: [], + width: 50, + height: 20, + fontSize: 16, + fontName: { family: 'Pretendard', style: 'Bold' }, + fontWeight: 600, + textAlignHorizontal: 'LEFT', + textAlignVertical: 'CENTER', + letterSpacing: { value: -2, unit: 'PERCENT' }, + lineHeight: { unit: 'AUTO' }, + textAutoResize: 'WIDTH_AND_HEIGHT', + textStyleId: '', + getStyledTextSegments: () => [ + { + characters: text, + start: 0, + end: text.length, + fontSize: 16, + fontName: { family: 'Pretendard', style: 'Bold' }, + fontWeight: 600, + fills: [ + { + type: 'SOLID', + visible: true, + color: { r: 1, g: 1, b: 1 }, + opacity: 1, + }, + ], + textDecoration: 'NONE', + textCase: 'ORIGINAL', + lineHeight: { unit: 'AUTO' }, + letterSpacing: { value: -2, unit: 'PERCENT' }, + textStyleId: '', + fillStyleId: '', + listOptions: { type: 'NONE' }, + indentation: 0, + hyperlink: null, + }, + ], + } as unknown as SceneNode + } + + const sizes = ['lg', 'md', 'sm', 'tag'] + const varients = ['primary', 'white', 'ghost'] + const effects = ['default', 'hover'] + const allChildren: ComponentNode[] = [] + + for (const size of sizes) { + for (const varient of varients) { + for (const effect of effects) { + const hasIcons = size !== 'tag' + const id = `${size}-${varient}-${effect}` + const children: SceneNode[] = [] + if (hasIcons) { + children.push( + createInstanceChild2(`icon-${id}`, 'MypageIcon', { + componentPropertyReferences: { + visible: 'leftIcon#70:100', + }, + }), + ) + } + children.push(createTextChild2(`text-${id}`, 'Label', `btn${size}`)) + if (hasIcons) { + children.push( + createInstanceChild2(`arrow-${id}`, 'Arrow', { + componentPropertyReferences: { + visible: 'rightIcon#71:101', + }, + }), + ) + } + const bgColor = + effect === 'hover' + ? { r: 0.3, g: 0.5, b: 0.9 } + : { r: 0.2, g: 0.4, b: 0.8 } + allChildren.push( + createComponentNode( + `size=${size}, varient=${varient}, effect=${effect}`, + { size, varient, effect }, + { + id, + children: children as unknown as readonly SceneNode[], + layoutMode: 'HORIZONTAL', + itemSpacing: 10, + paddingLeft: size === 'tag' ? 10 : 24, + paddingRight: size === 'tag' ? 10 : 24, + paddingTop: 10, + paddingBottom: 10, + fills: [ + { + type: 'SOLID', + visible: true, + color: bgColor, + opacity: 1, + } as unknown as Paint, + ], + }, + ), + ) + } + } + } + + const componentSet = createComponentSetNode( + 'Button', + { + size: { + type: 'VARIANT', + defaultValue: 'lg', + variantOptions: sizes, + }, + varient: { + type: 'VARIANT', + defaultValue: 'primary', + variantOptions: varients, + }, + effect: { + type: 'VARIANT', + defaultValue: 'default', + variantOptions: effects, + }, + 'leftIcon#70:100': { + type: 'BOOLEAN', + defaultValue: true, + }, + 'rightIcon#71:101': { + type: 'BOOLEAN', + defaultValue: true, + }, + }, + allChildren, + ) + + // Set parent references + for (const child of allChildren) { + ;(child as unknown as { parent: ComponentSetNode }).parent = componentSet + if ('children' in child && child.children) { + for (const grandchild of child.children) { + ;(grandchild as unknown as { parent: SceneNode }).parent = child + } + } + } + + const codes = await ResponsiveCodegen.generateVariantResponsiveComponents( + componentSet, + 'Button', + ) + + expect(codes.length).toBe(1) + const [, generatedCode] = codes[0] + + // Should have leftIcon and rightIcon as boolean props + expect(generatedCode).toContain('leftIcon?: boolean') + expect(generatedCode).toContain('rightIcon?: boolean') + + // MypageIcon should have leftIcon condition + expect(generatedCode).toMatch(/leftIcon\s*&&/) + // Arrow should have rightIcon condition + expect(generatedCode).toMatch(/rightIcon\s*&&/) + }) }) diff --git a/src/codegen/props/__tests__/selector.test.ts b/src/codegen/props/__tests__/selector.test.ts index 49fc83b..dcafd9d 100644 --- a/src/codegen/props/__tests__/selector.test.ts +++ b/src/codegen/props/__tests__/selector.test.ts @@ -146,6 +146,41 @@ describe('getSelectorProps', () => { expect(result?.variants.showIcon).toBe('boolean') }) + test('includes TEXT properties as string in variants', async () => { + const defaultVariant = { + type: 'COMPONENT', + name: 'size=Default', + children: [], + visible: true, + reactions: [], + variantProperties: { size: 'Default' }, + } as unknown as ComponentNode + + const node = { + type: 'COMPONENT_SET', + name: 'ButtonWithLabel', + children: [defaultVariant], + defaultVariant, + visible: true, + componentPropertyDefinitions: { + size: { + type: 'VARIANT', + variantOptions: ['Default', 'Small'], + }, + 'label#80:456': { + type: 'TEXT', + defaultValue: 'Click me', + }, + }, + } as unknown as ComponentSetNode + + const result = await getSelectorProps(node) + + expect(result).toBeDefined() + expect(result?.variants.size).toBe("'Default' | 'Small'") + expect(result?.variants.label).toBe('string') + }) + test('includes INSTANCE_SWAP properties as React.ReactNode in variants', async () => { const defaultVariant = { type: 'COMPONENT', diff --git a/src/codegen/props/selector.ts b/src/codegen/props/selector.ts index 149df08..830acec 100644 --- a/src/codegen/props/selector.ts +++ b/src/codegen/props/selector.ts @@ -163,6 +163,8 @@ async function computeSelectorProps(node: ComponentSetNode): Promise<{ result.variants[sanitizedName] = 'React.ReactNode' } else if (definition.type === 'BOOLEAN') { result.variants[sanitizedName] = 'boolean' + } else if (definition.type === 'TEXT') { + result.variants[sanitizedName] = 'string' } } diff --git a/src/codegen/responsive/ResponsiveCodegen.ts b/src/codegen/responsive/ResponsiveCodegen.ts index 7d53422..6f3ed30 100644 --- a/src/codegen/responsive/ResponsiveCodegen.ts +++ b/src/codegen/responsive/ResponsiveCodegen.ts @@ -35,16 +35,118 @@ function firstMapValue(map: Map): V { throw new Error('empty map') } -function firstMapKey(map: Map): K { - for (const k of map.keys()) return k - throw new Error('empty map') -} - function firstMapEntry(map: Map): [K, V] { for (const entry of map.entries()) return entry throw new Error('empty map') } +/** + * Build a stable merged order of child names across multiple variants/breakpoints. + * Uses topological sort on a DAG of ordering constraints from all variants, + * with average-position tie-breaking for deterministic output. + * + * Example: variant A has [Icon, TextA, Arrow], variant B has [Icon, TextB, Arrow] + * → edges: Icon→TextA, TextA→Arrow, Icon→TextB, TextB→Arrow + * → topo sort: [Icon, TextA, TextB, Arrow] (Arrow stays last) + */ +function mergeChildNameOrder( + childrenMaps: Map>, +): string[] { + // Collect distinct child name sequences from each variant + const sequences: string[][] = [] + for (const childMap of childrenMaps.values()) { + const seq: string[] = [] + for (const name of childMap.keys()) { + seq.push(name) + } + sequences.push(seq) + } + + // Collect all unique names + const allNames = new Set() + for (const seq of sequences) { + for (const name of seq) { + allNames.add(name) + } + } + + if (allNames.size === 0) return [] + if (allNames.size === 1) return [...allNames] + + // Build DAG: for each variant, add edge from consecutive distinct names + const edges = new Map>() + const inDegree = new Map() + for (const name of allNames) { + edges.set(name, new Set()) + inDegree.set(name, 0) + } + + for (const seq of sequences) { + for (let i = 0; i < seq.length - 1; i++) { + const from = seq[i] + const to = seq[i + 1] + const fromEdges = edges.get(from) + if (fromEdges && !fromEdges.has(to)) { + fromEdges.add(to) + inDegree.set(to, (inDegree.get(to) || 0) + 1) + } + } + } + + // Compute average normalized position for tie-breaking + const avgPosition = new Map() + for (const name of allNames) { + let totalPos = 0 + let count = 0 + for (const seq of sequences) { + const idx = seq.indexOf(name) + if (idx >= 0) { + // Normalize to 0..1 range + totalPos += seq.length > 1 ? idx / (seq.length - 1) : 0.5 + count++ + } + } + avgPosition.set(name, count > 0 ? totalPos / count : 0.5) + } + + // Kahn's algorithm with priority-based tie-breaking + const queue: string[] = [] + for (const [name, deg] of inDegree) { + if (deg === 0) queue.push(name) + } + // Sort initial queue by average position (stable) + queue.sort((a, b) => (avgPosition.get(a) || 0) - (avgPosition.get(b) || 0)) + + const result: string[] = [] + while (queue.length > 0) { + const node = queue.shift() + if (!node) break + result.push(node) + for (const neighbor of edges.get(node) || []) { + const newDeg = (inDegree.get(neighbor) || 1) - 1 + inDegree.set(neighbor, newDeg) + if (newDeg === 0) { + queue.push(neighbor) + // Re-sort to maintain priority order + queue.sort( + (a, b) => (avgPosition.get(a) || 0) - (avgPosition.get(b) || 0), + ) + } + } + } + + // Cycle fallback: append any remaining nodes (shouldn't happen with consistent data) + if (result.length < allNames.size) { + for (const name of allNames) { + if (!result.includes(name)) { + result.push(name) + } + } + } + + return result +} + /** * Generate responsive code by merging children inside a Section. * Uses Codegen to build NodeTree for each breakpoint, then merges them. @@ -212,7 +314,6 @@ export class ResponsiveCodegen { // Merge children by name const childrenCodes: string[] = [] - const processedChildNames = new Set() // Convert all trees' children to maps const childrenMaps = new Map>() @@ -220,27 +321,8 @@ export class ResponsiveCodegen { childrenMaps.set(bp, this.treeChildrenToMap(tree)) } - // Get all child names in order (first tree's order, then others) - const firstBreakpoint = firstMapKey(treesByBreakpoint) - const firstChildrenMap = childrenMaps.get(firstBreakpoint) - const allChildNames: string[] = [] - - if (firstChildrenMap) { - for (const name of firstChildrenMap.keys()) { - allChildNames.push(name) - processedChildNames.add(name) - } - } - - // Add children that exist only in other breakpoints - for (const childMap of childrenMaps.values()) { - for (const name of childMap.keys()) { - if (!processedChildNames.has(name)) { - allChildNames.push(name) - processedChildNames.add(name) - } - } - } + // Get all child names in stable merged order across all breakpoints + const allChildNames = mergeChildNameOrder(childrenMaps) for (const childName of allChildNames) { // Find the maximum number of children with this name across all breakpoints @@ -332,10 +414,20 @@ export class ResponsiveCodegen { const variants: Record = {} for (const name in componentSet.componentPropertyDefinitions) { const definition = componentSet.componentPropertyDefinitions[name] - if (name.toLowerCase() !== 'viewport' && definition.type === 'VARIANT') { + const lowerName = name.toLowerCase() + if (lowerName !== 'viewport' && lowerName !== 'effect') { const sanitizedName = sanitizePropertyName(name) - variants[sanitizedName] = - definition.variantOptions?.map((opt) => `'${opt}'`).join(' | ') || '' + if (definition.type === 'VARIANT') { + variants[sanitizedName] = + definition.variantOptions?.map((opt) => `'${opt}'`).join(' | ') || + '' + } else if (definition.type === 'INSTANCE_SWAP') { + variants[sanitizedName] = 'React.ReactNode' + } else if (definition.type === 'BOOLEAN') { + variants[sanitizedName] = 'boolean' + } else if (definition.type === 'TEXT') { + variants[sanitizedName] = 'string' + } } } @@ -480,6 +572,15 @@ export class ResponsiveCodegen { definition.variantOptions?.map((opt) => `'${opt}'`).join(' | ') || '' } + } else if (definition.type === 'INSTANCE_SWAP') { + const sanitizedName = sanitizePropertyName(name) + variants[sanitizedName] = 'React.ReactNode' + } else if (definition.type === 'BOOLEAN') { + const sanitizedName = sanitizePropertyName(name) + variants[sanitizedName] = 'boolean' + } else if (definition.type === 'TEXT') { + const sanitizedName = sanitizePropertyName(name) + variants[sanitizedName] = 'string' } } @@ -673,9 +774,22 @@ export class ResponsiveCodegen { // Render the tree to JSX const code = Codegen.renderTree(tree, 0) - // No variant props needed since effect is handled via pseudo-selectors + // Collect BOOLEAN and INSTANCE_SWAP props for the interface + // (effect is handled via pseudo-selectors, VARIANT keys don't exist in effect-only path) + const variants: Record = {} + for (const name in componentSet.componentPropertyDefinitions) { + const definition = componentSet.componentPropertyDefinitions[name] + if (definition.type === 'INSTANCE_SWAP') { + variants[sanitizePropertyName(name)] = 'React.ReactNode' + } else if (definition.type === 'BOOLEAN') { + variants[sanitizePropertyName(name)] = 'boolean' + } else if (definition.type === 'TEXT') { + variants[sanitizePropertyName(name)] = 'string' + } + } + const result: Array = [ - [componentName, renderComponent(componentName, code, {})], + [componentName, renderComponent(componentName, code, variants)], ] return result } @@ -693,8 +807,160 @@ export class ResponsiveCodegen { return [] } + // Check if componentSet has effect variant (pseudo-selector) + let hasEffect = false + for (const key in componentSet.componentPropertyDefinitions) { + if (key.toLowerCase() === 'effect') { + hasEffect = true + break + } + } + + // Map from original name to sanitized name + const variantKeyToSanitized: Record = {} + for (const key of variantKeys) { + variantKeyToSanitized[key] = sanitizePropertyName(key) + } + const sanitizedVariantKeys = variantKeys.map( + (key) => variantKeyToSanitized[key], + ) + + // Single variant key: use simpler single-dimension merge + if (variantKeys.length === 1) { + return ResponsiveCodegen.generateSingleVariantComponents( + componentSet, + componentName, + variantKeys[0], + sanitizedVariantKeys[0], + variants, + hasEffect, + ) + } + + // Multiple variant keys: build trees for ALL combinations, use multi-dimensional merge + // Build composite key for each component (e.g., "size=lg|varient=primary") + const buildCompositeKey = ( + variantProps: Record, + ): string => { + return variantKeys + .map((key) => { + const sanitizedKey = variantKeyToSanitized[key] + return `${sanitizedKey}=${variantProps[key] || '__default__'}` + }) + .join('|') + } + + // Reverse mapping from sanitized to original names (for getSelectorPropsForGroup) + const sanitizedToOriginal: Record = {} + for (const [original, sanitized] of Object.entries(variantKeyToSanitized)) { + sanitizedToOriginal[sanitized] = original + } + + const parseCompositeKeyToOriginal = ( + compositeKey: string, + ): Record => { + const result: Record = {} + for (const part of compositeKey.split('|')) { + const [sanitizedKey, value] = part.split('=') + const originalKey = sanitizedToOriginal[sanitizedKey] + if (originalKey) { + result[originalKey] = value + } + } + return result + } + + // Group components by composite variant key (all variant values combined) + const componentsByComposite = new Map() + for (const child of componentSet.children) { + if (child.type !== 'COMPONENT') continue + + const component = child as ComponentNode + const variantProps = component.variantProperties || {} + + // Skip effect variants (they become pseudo-selectors) + if (hasEffect) { + const effectValue = + variantProps[ + Object.keys(componentSet.componentPropertyDefinitions).find( + (k) => k.toLowerCase() === 'effect', + ) || '' + ] + if (effectValue && effectValue !== 'default') continue + } + + const compositeKey = buildCompositeKey(variantProps) + if (!componentsByComposite.has(compositeKey)) { + componentsByComposite.set(compositeKey, component) + } + } + + // Build trees for each combination + const treesByComposite = new Map() + for (const [compositeKey, component] of componentsByComposite) { + const variantFilter = parseCompositeKeyToOriginal(compositeKey) + let t = perfStart() + const selectorProps = hasEffect + ? await getSelectorPropsForGroup(componentSet, variantFilter) + : null + perfEnd('getSelectorPropsForGroup(nonViewport)', t) + + t = perfStart() + const codegen = new Codegen(component) + const tree = await codegen.getTree() + perfEnd('Codegen.getTree(nonViewportVariant)', t) + + // Use the component tree from addComponentTree if available — it includes + // ALL children (even invisible BOOLEAN-controlled ones) with condition fields + // and INSTANCE_SWAP slot placeholders, which buildTree() skips. + const componentTree = codegen.getComponentTree() + if (componentTree) { + tree.children = componentTree.tree.children + } + + if (selectorProps && Object.keys(selectorProps).length > 0) { + tree.props = Object.assign({}, tree.props, selectorProps) + } + treesByComposite.set(compositeKey, tree) + } + + // Use multi-dimensional merge (same as viewport+variant path but without viewport) + // Wrap each tree in a single-breakpoint map so generateMultiVariantMergedCode works + const treesByCompositeAndBreakpoint = new Map< + string, + Map + >() + for (const [compositeKey, tree] of treesByComposite) { + const singleBreakpointMap = new Map() + singleBreakpointMap.set('pc', tree) + treesByCompositeAndBreakpoint.set(compositeKey, singleBreakpointMap) + } + + const responsiveCodegen = new ResponsiveCodegen(null) + const mergedCode = responsiveCodegen.generateMultiVariantMergedCode( + sanitizedVariantKeys, + treesByCompositeAndBreakpoint, + 0, + ) + + const result: Array = [ + [componentName, renderComponent(componentName, mergedCode, variants)], + ] + return result + } + + /** + * Generate component code for single variant key (original simple path). + */ + private static async generateSingleVariantComponents( + componentSet: ComponentSetNode, + componentName: string, + variantKey: string, + sanitizedVariantKey: string, + variants: Record, + hasEffect: boolean, + ): Promise> { // Group components by variant value - const primaryVariantKey = variantKeys[0] const componentsByVariant = new Map() for (const child of componentSet.children) { @@ -702,28 +968,18 @@ export class ResponsiveCodegen { const component = child as ComponentNode const variantProps = component.variantProperties || {} - const variantValue = variantProps[primaryVariantKey] || '__default__' + const variantValue = variantProps[variantKey] || '__default__' if (!componentsByVariant.has(variantValue)) { componentsByVariant.set(variantValue, component) } } - // Check if componentSet has effect variant (pseudo-selector) - let hasEffect = false - for (const key in componentSet.componentPropertyDefinitions) { - if (key.toLowerCase() === 'effect') { - hasEffect = true - break - } - } - - // Build trees for each variant — all independent, run in parallel. + // Build trees for each variant const treesByVariant = new Map() for (const [variantValue, component] of componentsByVariant) { - // Get pseudo-selector props for this specific variant group const variantFilter: Record = { - [primaryVariantKey]: variantValue, + [variantKey]: variantValue, } let t = perfStart() const selectorProps = hasEffect @@ -735,7 +991,15 @@ export class ResponsiveCodegen { const codegen = new Codegen(component) const tree = await codegen.getTree() perfEnd('Codegen.getTree(nonViewportVariant)', t) - // Add pseudo-selector props to tree — create NEW props to avoid mutating cached tree + + // Use the component tree from addComponentTree if available — it includes + // ALL children (even invisible BOOLEAN-controlled ones) with condition fields + // and INSTANCE_SWAP slot placeholders, which buildTree() skips. + const componentTree = codegen.getComponentTree() + if (componentTree) { + tree.children = componentTree.tree.children + } + if (selectorProps && Object.keys(selectorProps).length > 0) { tree.props = Object.assign({}, tree.props, selectorProps) } @@ -744,10 +1008,8 @@ export class ResponsiveCodegen { // Generate merged code with variant conditionals const responsiveCodegen = new ResponsiveCodegen(null) - // Use sanitized variant key for code generation (e.g., "속성 1" -> "property1") - const sanitizedPrimaryVariantKey = sanitizePropertyName(primaryVariantKey) const mergedCode = responsiveCodegen.generateVariantOnlyMergedCode( - sanitizedPrimaryVariantKey, + sanitizedVariantKey, treesByVariant, 0, ) @@ -811,26 +1073,8 @@ export class ResponsiveCodegen { childrenMaps.set(bp, this.treeChildrenToMap(tree)) } - const processedChildNames = new Set() - const allChildNames: string[] = [] - const firstBreakpoint = firstMapKey(treesByBreakpoint) - const firstChildrenMap = childrenMaps.get(firstBreakpoint) - - if (firstChildrenMap) { - for (const name of firstChildrenMap.keys()) { - allChildNames.push(name) - processedChildNames.add(name) - } - } - - for (const childMap of childrenMaps.values()) { - for (const name of childMap.keys()) { - if (!processedChildNames.has(name)) { - allChildNames.push(name) - processedChildNames.add(name) - } - } - } + // Get all child names in stable merged order across all breakpoints + const allChildNames = mergeChildNameOrder(childrenMaps) const mergedChildren: NodeTree[] = [] @@ -912,11 +1156,15 @@ export class ResponsiveCodegen { // Handle TEXT nodes if (firstTree.textChildren && firstTree.textChildren.length > 0) { + const mergedTextChildren = this.mergeTextChildrenAcrossVariants( + variantKey, + treesByVariant, + ) return renderNode( firstTree.component, mergedProps, depth, - firstTree.textChildren, + mergedTextChildren, ) } @@ -927,26 +1175,8 @@ export class ResponsiveCodegen { childrenMaps.set(variant, this.treeChildrenToMap(tree)) } - const processedChildNames = new Set() - const allChildNames: string[] = [] - const firstVariant = firstMapKey(treesByVariant) - const firstChildrenMap = childrenMaps.get(firstVariant) - - if (firstChildrenMap) { - for (const name of firstChildrenMap.keys()) { - allChildNames.push(name) - processedChildNames.add(name) - } - } - - for (const childMap of childrenMaps.values()) { - for (const name of childMap.keys()) { - if (!processedChildNames.has(name)) { - allChildNames.push(name) - processedChildNames.add(name) - } - } - } + // Get all child names in stable merged order across all variants + const allChildNames = mergeChildNameOrder(childrenMaps) for (const childName of allChildNames) { let maxChildCount = 0 @@ -981,38 +1211,77 @@ export class ResponsiveCodegen { childByVariant, 0, ) + + // Check if all variants share the same BOOLEAN condition + const firstChild = firstMapValue(childByVariant) + const condition = firstChild.condition + if (condition) { + let allSameCondition = true + for (const child of childByVariant.values()) { + if (child.condition !== condition) { + allSameCondition = false + break + } + } + if (allSameCondition) { + if (childCode.includes('\n')) { + childrenCodes.push( + `{${condition} && (\n${paddingLeftMultiline(childCode, 1)}\n)}`, + ) + } else { + childrenCodes.push(`{${condition} && ${childCode}}`) + } + continue + } + } + + // Handle INSTANCE_SWAP slot placeholders + if (firstChild.isSlot) { + childrenCodes.push(`{${firstChild.component}}`) + continue + } + childrenCodes.push(childCode) } else { // Child exists only in some variants - use conditional rendering const presentVariantsList = [...presentVariants] + // Check if present children share a common BOOLEAN condition + const sharedCondition = this.getSharedCondition(childByVariant) + if (presentVariantsList.length === 1) { // Only one variant has this child: {status === "scroll" && } const onlyVariant = presentVariantsList[0] const childTree = childByVariant.get(onlyVariant) if (!childTree) continue const childCode = Codegen.renderTree(childTree, 0) + const variantCondition = `${variantKey} === "${onlyVariant}"` + const fullCondition = sharedCondition + ? `${sharedCondition} && ${variantCondition}` + : variantCondition const formattedChildCode = childCode.includes('\n') ? `(\n${paddingLeftMultiline(childCode, 1)}\n)` : childCode - childrenCodes.push( - `{${variantKey} === "${onlyVariant}" && ${formattedChildCode}}`, - ) + childrenCodes.push(`{${fullCondition} && ${formattedChildCode}}`) } else { // Multiple (but not all) variants have this child // Use conditional rendering with OR const conditions = presentVariantsList .map((v) => `${variantKey} === "${v}"`) .join(' || ') + const variantCondition = `(${conditions})` + const fullCondition = sharedCondition + ? `${sharedCondition} && ${variantCondition}` + : variantCondition const childCode = this.generateVariantOnlyMergedCode( variantKey, childByVariant, 0, ) const formattedChildCode = childCode.includes('\n') - ? `2(\n${paddingLeftMultiline(childCode, 1)}\n)` + ? `(\n${paddingLeftMultiline(childCode, 1)}\n)` : childCode - childrenCodes.push(`{(${conditions}) && ${formattedChildCode}}`) + childrenCodes.push(`{${fullCondition} && ${formattedChildCode}}`) } } } @@ -1094,11 +1363,15 @@ export class ResponsiveCodegen { // Handle TEXT nodes if (firstTree.textChildren && firstTree.textChildren.length > 0) { + const mergedTextChildren = this.mergeTextChildrenAcrossComposites( + variantKeys, + treesByComposite, + ) return renderNode( firstTree.component, mergedProps, depth, - firstTree.textChildren, + mergedTextChildren, ) } @@ -1111,27 +1384,8 @@ export class ResponsiveCodegen { childrenMaps.set(compositeKey, this.treeChildrenToMap(tree)) } - // Get all unique child names - const processedChildNames = new Set() - const allChildNames: string[] = [] - const firstComposite = firstMapKey(treesByComposite) - const firstChildrenMap = childrenMaps.get(firstComposite) - - if (firstChildrenMap) { - for (const name of firstChildrenMap.keys()) { - allChildNames.push(name) - processedChildNames.add(name) - } - } - - for (const childMap of childrenMaps.values()) { - for (const name of childMap.keys()) { - if (!processedChildNames.has(name)) { - allChildNames.push(name) - processedChildNames.add(name) - } - } - } + // Get all unique child names in stable merged order across all composites + const allChildNames = mergeChildNameOrder(childrenMaps) // Process each child for (const childName of allChildNames) { @@ -1165,13 +1419,150 @@ export class ResponsiveCodegen { childByComposite, 0, ) + + // Check if all composites share the same BOOLEAN condition + const firstChild = firstMapValue(childByComposite) + const condition = firstChild.condition + if (condition) { + // Check all composites have the same condition + let allSameCondition = true + for (const child of childByComposite.values()) { + if (child.condition !== condition) { + allSameCondition = false + break + } + } + if (allSameCondition) { + // Wrap with BOOLEAN conditional + if (childCode.includes('\n')) { + childrenCodes.push( + `{${condition} && (\n${paddingLeftMultiline(childCode, 1)}\n)}`, + ) + } else { + childrenCodes.push(`{${condition} && ${childCode}}`) + } + continue + } + } + + // Handle INSTANCE_SWAP slot placeholders + if (firstChild.isSlot) { + childrenCodes.push(`{${firstChild.component}}`) + continue + } + childrenCodes.push(childCode) } else { - // Child exists only in some variants - use first one for now - // TODO: implement conditional rendering for partial children - const firstChildTree = firstMapValue(childByComposite) - const childCode = Codegen.renderTree(firstChildTree, 0) - childrenCodes.push(childCode) + // Child exists only in some variants - conditional rendering + // Find which variant key(s) control this child's presence + const presentKeys = [...presentComposites] + const absentKeys = [...treesByComposite.keys()].filter( + (k) => !presentComposites.has(k), + ) + + // Parse all composite keys to find the controlling variant dimension + const parsedPresent = presentKeys.map((k) => + this.parseCompositeKey(k), + ) + const parsedAbsent = absentKeys.map((k) => + this.parseCompositeKey(k), + ) + + // For each variant key, check if it fully explains presence/absence + let controllingKey: string | undefined + let presentValues: string[] = [] + + for (const vk of variantKeys) { + const presentVals = new Set(parsedPresent.map((p) => p[vk])) + const absentVals = new Set(parsedAbsent.map((p) => p[vk])) + + // Check if there's no overlap — this key fully explains presence + let hasOverlap = false + for (const v of presentVals) { + if (absentVals.has(v)) { + hasOverlap = true + break + } + } + + if (!hasOverlap) { + controllingKey = vk + presentValues = [...presentVals] + break + } + } + + // Check if present children share a common BOOLEAN condition + const sharedCondition = this.getSharedCondition(childByComposite) + + if (controllingKey && presentValues.length > 0) { + // Single controlling key — generate condition on that key + // Recursively merge the child across the present composites + const childCode = + childByComposite.size === 1 + ? Codegen.renderTree(firstMapValue(childByComposite), 0) + : this.generateNestedVariantMergedCode( + variantKeys, + childByComposite, + 0, + ) + + if (presentValues.length === 1) { + // {key === "value" && } + const variantCondition = `${controllingKey} === "${presentValues[0]}"` + const fullCondition = sharedCondition + ? `${sharedCondition} && ${variantCondition}` + : variantCondition + const formattedChildCode = childCode.includes('\n') + ? `(\n${paddingLeftMultiline(childCode, 1)}\n)` + : childCode + childrenCodes.push( + `{${fullCondition} && ${formattedChildCode}}`, + ) + } else { + // {(key === "a" || key === "b") && } + const conditions = presentValues + .map((v) => `${controllingKey} === "${v}"`) + .join(' || ') + const variantCondition = `(${conditions})` + const fullCondition = sharedCondition + ? `${sharedCondition} && ${variantCondition}` + : variantCondition + const formattedChildCode = childCode.includes('\n') + ? `(\n${paddingLeftMultiline(childCode, 1)}\n)` + : childCode + childrenCodes.push( + `{${fullCondition} && ${formattedChildCode}}`, + ) + } + } else { + // Multiple keys control presence — build combined condition + // Collect unique composite value combinations that have this child + const conditionParts: string[] = [] + for (const compositeKey of presentKeys) { + const parsed = this.parseCompositeKey(compositeKey) + const parts = variantKeys.map( + (vk) => `${vk} === "${parsed[vk]}"`, + ) + conditionParts.push(`(${parts.join(' && ')})`) + } + const variantCondition = `(${conditionParts.join(' || ')})` + const fullCondition = sharedCondition + ? `${sharedCondition} && ${variantCondition}` + : variantCondition + const childCode = + childByComposite.size === 1 + ? Codegen.renderTree(firstMapValue(childByComposite), 0) + : this.generateNestedVariantMergedCode( + variantKeys, + childByComposite, + 0, + ) + const formattedChildCode = childCode.includes('\n') + ? `(\n${paddingLeftMultiline(childCode, 1)}\n)` + : childCode + childrenCodes.push(`{${fullCondition} && ${formattedChildCode}}`) + } } } } @@ -1180,6 +1571,40 @@ export class ResponsiveCodegen { return renderNode(firstTree.component, mergedProps, depth, childrenCodes) } + /** + * Parse a composite key like "size=lg|varient=primary" into { size: "lg", varient: "primary" }. + */ + private parseCompositeKey(compositeKey: string): Record { + const parsed: Record = {} + for (const part of compositeKey.split('|')) { + const [key, value] = part.split('=') + parsed[key] = value + } + return parsed + } + + /** + * Check if all children in a map share the same BOOLEAN condition. + * Returns the shared condition string, or undefined if not all share one. + */ + private getSharedCondition( + childMap: Map, + ): string | undefined { + let sharedCondition: string | undefined + let first = true + for (const child of childMap.values()) { + if (first) { + sharedCondition = child.condition + first = false + continue + } + if (child.condition !== sharedCondition) { + return undefined + } + } + return sharedCondition + } + /** * Merge props across composite variant keys. * Creates nested variant conditionals for props that differ. @@ -1225,24 +1650,28 @@ export class ResponsiveCodegen { } } if (hasPseudoSelector) { - result[propKey] = this.mergePropsAcrossComposites( + const merged = this.mergePropsAcrossComposites( variantKeys, pseudoPropsMap, ) + // Only include pseudo-selector if it has at least one prop after merging + if (Object.keys(merged).length > 0) { + result[propKey] = merged + } } continue } // Collect values for this prop across all composites - // For composites that don't have this prop, use null + // For composites that don't have this prop (or value is undefined), use null const valuesByComposite = new Map() let hasValue = false for (const [compositeKey, props] of propsMap) { - if (propKey in props) { + if (propKey in props && props[propKey] !== undefined) { valuesByComposite.set(compositeKey, props[propKey]) hasValue = true } else { - // Composite doesn't have this prop, use null + // Composite doesn't have this prop (or value is undefined), use null valuesByComposite.set(compositeKey, null) } } @@ -1455,6 +1884,127 @@ export class ResponsiveCodegen { return 0 } + /** + * Merge text children across variant values (single variant key). + * If all variants have the same text, return it directly. + * If texts differ, create variant-mapped text: {{ lg: "buttonLg", md: "button" }[size]} + */ + private mergeTextChildrenAcrossVariants( + variantKey: string, + treesByVariant: Map, + ): string[] { + // Collect joined text from each variant + const textByVariant = new Map() + for (const [variant, tree] of treesByVariant) { + if (tree.textChildren && tree.textChildren.length > 0) { + textByVariant.set(variant, tree.textChildren.join('')) + } + } + + // If no text, return first tree's text + if (textByVariant.size === 0) { + const firstTree = firstMapValue(treesByVariant) + return firstTree.textChildren || [] + } + + // Check if all texts are the same + let allSame = true + let firstText: string | undefined + for (const text of textByVariant.values()) { + if (firstText === undefined) { + firstText = text + continue + } + if (text !== firstText) { + allSame = false + break + } + } + + if (allSame) { + return firstMapValue(treesByVariant).textChildren || [] + } + + // Texts differ — create variant-mapped text + const entries = [...textByVariant.entries()] + .map(([variant, text]) => ` ${variant}: "${text}"`) + .join(',\n') + return [`{{\n${entries}\n}[${variantKey}]}`] + } + + /** + * Merge text children across composite variant keys (multiple variant dimensions). + * If all composites have the same text, return it directly. + * If texts differ, find the controlling variant key and create variant-mapped text. + */ + private mergeTextChildrenAcrossComposites( + variantKeys: string[], + treesByComposite: Map, + ): string[] { + // Collect joined text from each composite + const textByComposite = new Map() + for (const [compositeKey, tree] of treesByComposite) { + if (tree.textChildren && tree.textChildren.length > 0) { + textByComposite.set(compositeKey, tree.textChildren.join('')) + } + } + + // If no text, return first tree's text + if (textByComposite.size === 0) { + const firstTree = firstMapValue(treesByComposite) + return firstTree.textChildren || [] + } + + // Check if all texts are the same + let allSame = true + let firstText: string | undefined + for (const text of textByComposite.values()) { + if (firstText === undefined) { + firstText = text + continue + } + if (text !== firstText) { + allSame = false + break + } + } + + if (allSame) { + return firstMapValue(treesByComposite).textChildren || [] + } + + // Texts differ — find which variant key controls the text difference + // Group text values by each variant key to find the simplest mapping + for (const vk of variantKeys) { + const textByVariantValue = new Map() + let isConsistent = true + + for (const [compositeKey, text] of textByComposite) { + const parsed = this.parseCompositeKey(compositeKey) + const variantValue = parsed[vk] + const existing = textByVariantValue.get(variantValue) + + if (existing !== undefined && existing !== text) { + // Same variant value maps to different texts — this key doesn't fully control + isConsistent = false + break + } + textByVariantValue.set(variantValue, text) + } + + if (isConsistent) { + // This variant key fully controls text — create mapping on this key + const entries = [...textByVariantValue.entries()] + .map(([variant, text]) => ` ${variant}: "${text}"`) + .join(',\n') + return [`{{\n${entries}\n}[${vk}]}`] + } + } + + // No single key controls — use first tree's text as fallback + return firstMapValue(treesByComposite).textChildren || [] + } + /** * Merge text children across breakpoints. * Compares text content and handles \n differences with responsive
display. diff --git a/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts b/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts index 1783fb1..5d73e03 100644 --- a/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts +++ b/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts @@ -46,6 +46,7 @@ const mockRenderTree = mock((tree: NodeTree, depth: number) => const MockCodegen = class { getTree = mockGetTree + getComponentTree = mock(() => undefined) static renderTree = mockRenderTree } @@ -570,6 +571,325 @@ describe('ResponsiveCodegen', () => { expect(result).toContain('&&') expect(result).toMatchSnapshot() }) + + it('prepends BOOLEAN condition on partial children (single variant, single value)', () => { + const generator = new ResponsiveCodegen(null) + + // MypageIcon exists only in "lg" variant with condition: 'leftIcon' + const iconChild: NodeTree = { + component: 'Box', + props: { maskImage: 'url(/icons/MypageIcon.svg)' }, + children: [], + nodeType: 'FRAME', + nodeName: 'MypageIcon', + condition: 'leftIcon', + } + + const treesByVariant = new Map([ + [ + 'lg', + { + component: 'Flex', + props: {}, + children: [iconChild], + nodeType: 'FRAME', + nodeName: 'Root', + }, + ], + [ + 'tag', + { + component: 'Flex', + props: {}, + children: [], + nodeType: 'FRAME', + nodeName: 'Root', + }, + ], + ]) + + const result = generator.generateVariantOnlyMergedCode( + 'size', + treesByVariant, + 0, + ) + + // Should prepend BOOLEAN condition before variant condition + expect(result).toContain('leftIcon && size === "lg"') + }) + + it('prepends BOOLEAN condition on partial children (single variant, multiple values)', () => { + const generator = new ResponsiveCodegen(null) + + // MypageIcon exists in lg, md, sm but NOT tag — all with condition 'leftIcon' + const iconChild: NodeTree = { + component: 'Box', + props: { maskImage: 'url(/icons/MypageIcon.svg)' }, + children: [], + nodeType: 'FRAME', + nodeName: 'MypageIcon', + condition: 'leftIcon', + } + + const treesByVariant = new Map([ + [ + 'lg', + { + component: 'Flex', + props: {}, + children: [{ ...iconChild }], + nodeType: 'FRAME', + nodeName: 'Root', + }, + ], + [ + 'md', + { + component: 'Flex', + props: {}, + children: [{ ...iconChild }], + nodeType: 'FRAME', + nodeName: 'Root', + }, + ], + [ + 'sm', + { + component: 'Flex', + props: {}, + children: [{ ...iconChild }], + nodeType: 'FRAME', + nodeName: 'Root', + }, + ], + [ + 'tag', + { + component: 'Flex', + props: {}, + children: [], + nodeType: 'FRAME', + nodeName: 'Root', + }, + ], + ]) + + const result = generator.generateVariantOnlyMergedCode( + 'size', + treesByVariant, + 0, + ) + + // Should prepend BOOLEAN condition before OR variant condition + expect(result).toContain( + 'leftIcon && (size === "lg" || size === "md" || size === "sm")', + ) + }) + + it('prepends BOOLEAN condition on partial children (multi-variant, single controlling key)', () => { + const generator = new ResponsiveCodegen(null) + + // MypageIcon exists in lg|primary and lg|white but NOT tag|primary and tag|white + // Controlling key = size (lg vs tag), shared condition = 'leftIcon' + const iconChild: NodeTree = { + component: 'Box', + props: { maskImage: 'url(/icons/MypageIcon.svg)' }, + children: [], + nodeType: 'FRAME', + nodeName: 'MypageIcon', + condition: 'leftIcon', + } + + // Use generateMultiVariantMergedCode (public), which wraps each tree in a single-breakpoint map + const treesByCompositeAndBreakpoint = new Map< + string, + Map + >([ + [ + 'size=lg|varient=primary', + new Map([ + [ + 'pc' as import('../index').BreakpointKey, + { + component: 'Flex', + props: {}, + children: [{ ...iconChild }], + nodeType: 'FRAME', + nodeName: 'Root', + }, + ], + ]), + ], + [ + 'size=lg|varient=white', + new Map([ + [ + 'pc' as import('../index').BreakpointKey, + { + component: 'Flex', + props: {}, + children: [{ ...iconChild }], + nodeType: 'FRAME', + nodeName: 'Root', + }, + ], + ]), + ], + [ + 'size=tag|varient=primary', + new Map([ + [ + 'pc' as import('../index').BreakpointKey, + { + component: 'Flex', + props: {}, + children: [], + nodeType: 'FRAME', + nodeName: 'Root', + }, + ], + ]), + ], + [ + 'size=tag|varient=white', + new Map([ + [ + 'pc' as import('../index').BreakpointKey, + { + component: 'Flex', + props: {}, + children: [], + nodeType: 'FRAME', + nodeName: 'Root', + }, + ], + ]), + ], + ]) + + const result = generator.generateMultiVariantMergedCode( + ['size', 'varient'], + treesByCompositeAndBreakpoint, + 0, + ) + + // Should prepend BOOLEAN condition before variant condition + expect(result).toContain('leftIcon && size === "lg"') + }) + + it('does not prepend condition when partial children have different conditions', () => { + const generator = new ResponsiveCodegen(null) + + // Child in "lg" has condition 'leftIcon', child in "md" has condition 'rightIcon' + const treesByVariant = new Map([ + [ + 'lg', + { + component: 'Flex', + props: {}, + children: [ + { + component: 'Box', + props: {}, + children: [], + nodeType: 'FRAME', + nodeName: 'Icon', + condition: 'leftIcon', + }, + ], + nodeType: 'FRAME', + nodeName: 'Root', + }, + ], + [ + 'md', + { + component: 'Flex', + props: {}, + children: [ + { + component: 'Box', + props: {}, + children: [], + nodeType: 'FRAME', + nodeName: 'Icon', + condition: 'rightIcon', + }, + ], + nodeType: 'FRAME', + nodeName: 'Root', + }, + ], + [ + 'tag', + { + component: 'Flex', + props: {}, + children: [], + nodeType: 'FRAME', + nodeName: 'Root', + }, + ], + ]) + + const result = generator.generateVariantOnlyMergedCode( + 'size', + treesByVariant, + 0, + ) + + // Should NOT contain leftIcon or rightIcon since conditions don't match + expect(result).not.toContain('leftIcon') + expect(result).not.toContain('rightIcon') + // Should still have variant condition + expect(result).toContain('size === "lg" || size === "md"') + }) + + it('does not prepend condition when partial children have no condition', () => { + const generator = new ResponsiveCodegen(null) + + // Partial child without condition + const treesByVariant = new Map([ + [ + 'lg', + { + component: 'Flex', + props: {}, + children: [ + { + component: 'Box', + props: { id: 'icon' }, + children: [], + nodeType: 'FRAME', + nodeName: 'Icon', + }, + ], + nodeType: 'FRAME', + nodeName: 'Root', + }, + ], + [ + 'tag', + { + component: 'Flex', + props: {}, + children: [], + nodeType: 'FRAME', + nodeName: 'Root', + }, + ], + ]) + + const result = generator.generateVariantOnlyMergedCode( + 'size', + treesByVariant, + 0, + ) + + // Should have variant condition but no boolean prefix + expect(result).toContain('size === "lg"') + expect(result).not.toContain('leftIcon') + }) }) describe('createNestedVariantProp optimization', () => { @@ -919,33 +1239,187 @@ describe('ResponsiveCodegen', () => { expect(result[0][1]).toContain('status') }) - it('delegates to generateViewportResponsiveComponents when only viewport exists', async () => { + it('handles component set with multiple non-viewport variants', async () => { const componentSet = { type: 'COMPONENT_SET', - name: 'ViewportOnly', + name: 'MultiVariant', componentPropertyDefinitions: { - viewport: { + size: { type: 'VARIANT', - variantOptions: ['mobile', 'desktop'], + variantOptions: ['sm', 'lg'], + }, + color: { + type: 'VARIANT', + variantOptions: ['red', 'blue'], }, }, children: [ { type: 'COMPONENT', - name: 'viewport=mobile', - variantProperties: { viewport: 'mobile' }, + name: 'size=sm, color=red', + variantProperties: { size: 'sm', color: 'red' }, children: [], layoutMode: 'VERTICAL', - width: 320, - height: 100, + width: 100, + height: 50, }, { type: 'COMPONENT', - name: 'viewport=desktop', - variantProperties: { viewport: 'desktop' }, + name: 'size=sm, color=blue', + variantProperties: { size: 'sm', color: 'blue' }, children: [], - layoutMode: 'HORIZONTAL', - width: 1200, + layoutMode: 'VERTICAL', + width: 100, + height: 50, + }, + { + type: 'COMPONENT', + name: 'size=lg, color=red', + variantProperties: { size: 'lg', color: 'red' }, + children: [], + layoutMode: 'VERTICAL', + width: 200, + height: 100, + }, + { + type: 'COMPONENT', + name: 'size=lg, color=blue', + variantProperties: { size: 'lg', color: 'blue' }, + children: [], + layoutMode: 'VERTICAL', + width: 200, + height: 100, + }, + ], + } as unknown as ComponentSetNode + + const result = + await ResponsiveCodegen.generateVariantResponsiveComponents( + componentSet, + 'MultiVariant', + ) + + expect(result.length).toBe(1) + expect(result[0][0]).toBe('MultiVariant') + // Should contain both variant keys + expect(result[0][1]).toContain('size') + expect(result[0][1]).toContain('color') + }) + + it('handles component set with multiple non-viewport variants and effect', async () => { + const componentSet = { + type: 'COMPONENT_SET', + name: 'MultiVariantEffect', + componentPropertyDefinitions: { + size: { + type: 'VARIANT', + variantOptions: ['sm', 'lg'], + }, + color: { + type: 'VARIANT', + variantOptions: ['red', 'blue'], + }, + effect: { + type: 'VARIANT', + variantOptions: ['default', 'hover'], + }, + }, + children: [ + { + type: 'COMPONENT', + name: 'size=sm, color=red, effect=default', + variantProperties: { + size: 'sm', + color: 'red', + effect: 'default', + }, + children: [], + layoutMode: 'VERTICAL', + width: 100, + height: 50, + }, + { + type: 'COMPONENT', + name: 'size=sm, color=red, effect=hover', + variantProperties: { + size: 'sm', + color: 'red', + effect: 'hover', + }, + children: [], + layoutMode: 'VERTICAL', + width: 100, + height: 50, + }, + { + type: 'COMPONENT', + name: 'size=lg, color=blue, effect=default', + variantProperties: { + size: 'lg', + color: 'blue', + effect: 'default', + }, + children: [], + layoutMode: 'VERTICAL', + width: 200, + height: 100, + }, + { + type: 'COMPONENT', + name: 'size=lg, color=blue, effect=hover', + variantProperties: { + size: 'lg', + color: 'blue', + effect: 'hover', + }, + children: [], + layoutMode: 'VERTICAL', + width: 200, + height: 100, + }, + ], + } as unknown as ComponentSetNode + + const result = + await ResponsiveCodegen.generateVariantResponsiveComponents( + componentSet, + 'MultiVariantEffect', + ) + + expect(result.length).toBe(1) + expect(result[0][0]).toBe('MultiVariantEffect') + // Should contain both variant keys (not effect) + expect(result[0][1]).toContain('size') + expect(result[0][1]).toContain('color') + }) + + it('delegates to generateViewportResponsiveComponents when only viewport exists', async () => { + const componentSet = { + type: 'COMPONENT_SET', + name: 'ViewportOnly', + componentPropertyDefinitions: { + viewport: { + type: 'VARIANT', + variantOptions: ['mobile', 'desktop'], + }, + }, + children: [ + { + type: 'COMPONENT', + name: 'viewport=mobile', + variantProperties: { viewport: 'mobile' }, + children: [], + layoutMode: 'VERTICAL', + width: 320, + height: 100, + }, + { + type: 'COMPONENT', + name: 'viewport=desktop', + variantProperties: { viewport: 'desktop' }, + children: [], + layoutMode: 'HORIZONTAL', + width: 1200, height: 100, }, ], @@ -2094,4 +2568,490 @@ describe('ResponsiveCodegen', () => { expect(result).toContain('Box') }) }) + + describe('generateNestedVariantMergedCode partial children with multiple presentValues', () => { + it('generates OR condition when child exists in multiple values of controlling key', () => { + const generator = new ResponsiveCodegen(null) + + // Child "Badge" exists in variant=primary and variant=white, but NOT variant=outline + // Controlling key = varient, presentValues = ["primary", "white"] (length 2 → line 1498 branch) + const badgeChild: NodeTree = { + component: 'Box', + props: { bg: 'red' }, + children: [], + nodeType: 'FRAME', + nodeName: 'Badge', + } + + const treesByCompositeAndBreakpoint = new Map< + string, + Map + >([ + [ + 'size=lg|varient=primary', + new Map([ + [ + 'pc' as import('../index').BreakpointKey, + { + component: 'Flex', + props: {}, + children: [{ ...badgeChild }], + nodeType: 'FRAME', + nodeName: 'Root', + }, + ], + ]), + ], + [ + 'size=lg|varient=white', + new Map([ + [ + 'pc' as import('../index').BreakpointKey, + { + component: 'Flex', + props: {}, + children: [{ ...badgeChild }], + nodeType: 'FRAME', + nodeName: 'Root', + }, + ], + ]), + ], + [ + 'size=lg|varient=outline', + new Map([ + [ + 'pc' as import('../index').BreakpointKey, + { + component: 'Flex', + props: {}, + children: [], + nodeType: 'FRAME', + nodeName: 'Root', + }, + ], + ]), + ], + ]) + + const result = generator.generateMultiVariantMergedCode( + ['size', 'varient'], + treesByCompositeAndBreakpoint, + 0, + ) + + // Should generate OR condition: (varient === "primary" || varient === "white") + expect(result).toContain('varient === "primary"') + expect(result).toContain('varient === "white"') + expect(result).toContain('||') + }) + }) + + describe('generateNestedVariantMergedCode with no single controlling key', () => { + it('generates combined AND/OR condition when multiple keys control presence', () => { + const generator = new ResponsiveCodegen(null) + + // Child "Icon" exists in size=lg|varient=primary and size=sm|varient=white + // but NOT in size=lg|varient=white and size=sm|varient=primary + // Neither key alone separates present from absent → line 1518 branch + const iconChild: NodeTree = { + component: 'Box', + props: { maskImage: 'url(/icon.svg)' }, + children: [], + nodeType: 'FRAME', + nodeName: 'Icon', + } + + const treesByCompositeAndBreakpoint = new Map< + string, + Map + >([ + [ + 'size=lg|varient=primary', + new Map([ + [ + 'pc' as import('../index').BreakpointKey, + { + component: 'Flex', + props: {}, + children: [{ ...iconChild }], + nodeType: 'FRAME', + nodeName: 'Root', + }, + ], + ]), + ], + [ + 'size=lg|varient=white', + new Map([ + [ + 'pc' as import('../index').BreakpointKey, + { + component: 'Flex', + props: {}, + children: [], + nodeType: 'FRAME', + nodeName: 'Root', + }, + ], + ]), + ], + [ + 'size=sm|varient=primary', + new Map([ + [ + 'pc' as import('../index').BreakpointKey, + { + component: 'Flex', + props: {}, + children: [], + nodeType: 'FRAME', + nodeName: 'Root', + }, + ], + ]), + ], + [ + 'size=sm|varient=white', + new Map([ + [ + 'pc' as import('../index').BreakpointKey, + { + component: 'Flex', + props: {}, + children: [{ ...iconChild }], + nodeType: 'FRAME', + nodeName: 'Root', + }, + ], + ]), + ], + ]) + + const result = generator.generateMultiVariantMergedCode( + ['size', 'varient'], + treesByCompositeAndBreakpoint, + 0, + ) + + // Should generate combined AND conditions joined with OR: + // (size === "lg" && varient === "primary") || (size === "sm" && varient === "white") + expect(result).toContain('size === "lg" && varient === "primary"') + expect(result).toContain('size === "sm" && varient === "white"') + expect(result).toContain('||') + }) + }) + + describe('generateVariantOnlyMergedCode with differing textChildren', () => { + it('creates variant-mapped text when texts differ across variants', () => { + const generator = new ResponsiveCodegen(null) + + const treesByVariant = new Map([ + [ + 'scroll', + { + component: 'Text', + props: { fontSize: '14px' }, + children: [], + nodeType: 'TEXT', + nodeName: 'Label', + textChildren: ['ScrollLabel'], + }, + ], + [ + 'default', + { + component: 'Text', + props: { fontSize: '16px' }, + children: [], + nodeType: 'TEXT', + nodeName: 'Label', + textChildren: ['DefaultLabel'], + }, + ], + ]) + + const result = generator.generateVariantOnlyMergedCode( + 'status', + treesByVariant, + 0, + ) + + // Texts differ → variant-mapped text with entries like: scroll: "ScrollLabel" + expect(result).toContain('scroll: "ScrollLabel"') + expect(result).toContain('default: "DefaultLabel"') + expect(result).toContain('[status]') + }) + }) + + describe('generateNestedVariantMergedCode with differing textChildren controlled by one key', () => { + it('creates variant-mapped text when texts differ and one key controls the text', () => { + const generator = new ResponsiveCodegen(null) + + // size=Md → "Medium", size=Sm → "Small", variant doesn't matter + // size key consistently controls text + const treesByComposite = new Map([ + [ + 'size=Md|variant=primary', + { + component: 'Text', + props: { fontSize: '14px' }, + children: [], + nodeType: 'TEXT', + nodeName: 'Label', + textChildren: ['Medium'], + }, + ], + [ + 'size=Md|variant=white', + { + component: 'Text', + props: { fontSize: '14px' }, + children: [], + nodeType: 'TEXT', + nodeName: 'Label', + textChildren: ['Medium'], + }, + ], + [ + 'size=Sm|variant=primary', + { + component: 'Text', + props: { fontSize: '12px' }, + children: [], + nodeType: 'TEXT', + nodeName: 'Label', + textChildren: ['Small'], + }, + ], + [ + 'size=Sm|variant=white', + { + component: 'Text', + props: { fontSize: '12px' }, + children: [], + nodeType: 'TEXT', + nodeName: 'Label', + textChildren: ['Small'], + }, + ], + ]) + + const result = ( + generator as unknown as { + generateNestedVariantMergedCode: ( + variantKeys: string[], + trees: Map, + depth: number, + ) => string + } + ).generateNestedVariantMergedCode( + ['size', 'variant'], + treesByComposite, + 0, + ) + + // size controls text: Md → "Medium", Sm → "Small" + expect(result).toContain('Md: "Medium"') + expect(result).toContain('Sm: "Small"') + expect(result).toContain('[size]') + }) + }) + + describe('child ordering across variants', () => { + it('preserves relative child order when merging variants with different children', () => { + const generator = new ResponsiveCodegen(null) + + // Simulates a Button component: lg has [LeftIcon, TextLg, RightIcon] + // md has [LeftIcon, TextMd, RightIcon], tag has [TextTag] + // RightIcon must appear AFTER all text nodes in merged output + const leftIcon: NodeTree = { + component: 'Box', + props: { maskImage: 'url(/icons/left.svg)' }, + children: [], + nodeType: 'FRAME', + nodeName: 'LeftIcon', + condition: 'leftIcon', + } + const rightIcon: NodeTree = { + component: 'Box', + props: { maskImage: 'url(/icons/right.svg)' }, + children: [], + nodeType: 'FRAME', + nodeName: 'RightIcon', + condition: 'rightIcon', + } + + const treesByVariant = new Map([ + [ + 'lg', + { + component: 'Flex', + props: {}, + children: [ + { ...leftIcon }, + { + component: 'Text', + props: { fontSize: '16px' }, + children: [], + nodeType: 'TEXT', + nodeName: 'TextLg', + textChildren: ['buttonLg'], + }, + { ...rightIcon }, + ], + nodeType: 'FRAME', + nodeName: 'Root', + }, + ], + [ + 'md', + { + component: 'Flex', + props: {}, + children: [ + { ...leftIcon }, + { + component: 'Text', + props: { fontSize: '14px' }, + children: [], + nodeType: 'TEXT', + nodeName: 'TextMd', + textChildren: ['button'], + }, + { ...rightIcon }, + ], + nodeType: 'FRAME', + nodeName: 'Root', + }, + ], + [ + 'tag', + { + component: 'Flex', + props: {}, + children: [ + { + component: 'Text', + props: { fontSize: '12px' }, + children: [], + nodeType: 'TEXT', + nodeName: 'TextTag', + textChildren: ['Tag'], + }, + ], + nodeType: 'FRAME', + nodeName: 'Root', + }, + ], + ]) + + const result = generator.generateVariantOnlyMergedCode( + 'size', + treesByVariant, + 0, + ) + + // Mock output format: render:Component:depth=N:props|children + // Use unique prop values to identify each child's position: + // LeftIcon → url(/icons/left.svg), RightIcon → url(/icons/right.svg) + // TextLg → buttonLg, TextMd → button, TextTag → Tag + const leftIconPos = result.indexOf('url(/icons/left.svg)') + const textLgPos = result.indexOf('buttonLg') + const textTagPos = result.indexOf('Tag') + const rightIconPos = result.indexOf('url(/icons/right.svg)') + + // LeftIcon should appear + expect(leftIconPos).toBeGreaterThanOrEqual(0) + // RightIcon should appear after LeftIcon and all texts + expect(rightIconPos).toBeGreaterThan(leftIconPos) + expect(rightIconPos).toBeGreaterThan(textLgPos) + // Tag text should come before RightIcon + if (textTagPos >= 0) { + expect(rightIconPos).toBeGreaterThan(textTagPos) + } + }) + + it('preserves order in nested variant merge with multiple child types', () => { + const generator = new ResponsiveCodegen(null) + + // 4 composites: size × varient, with [IconComp, Text, ArrowComp] order + // Use unique component names to make assertions easy + // ArrowComp must always be last in merged output + const makeTree = ( + textName: string, + textContent: string, + fontSize: string, + ): NodeTree => ({ + component: 'Flex', + props: {}, + children: [ + { + component: 'IconComp', + props: { id: 'icon' }, + children: [], + nodeType: 'FRAME', + nodeName: 'Icon', + }, + { + component: 'Text', + props: { fontSize }, + children: [], + nodeType: 'TEXT', + nodeName: textName, + textChildren: [textContent], + }, + { + component: 'ArrowComp', + props: { id: 'arrow' }, + children: [], + nodeType: 'FRAME', + nodeName: 'Arrow', + }, + ], + nodeType: 'FRAME', + nodeName: 'Root', + }) + + const bp = 'pc' as import('../index').BreakpointKey + const treesByCompositeAndBreakpoint = new Map< + string, + Map + >([ + [ + 'size=lg|varient=primary', + new Map([[bp, makeTree('LgText', 'Large', '16px')]]), + ], + [ + 'size=sm|varient=primary', + new Map([[bp, makeTree('SmText', 'Small', '12px')]]), + ], + [ + 'size=lg|varient=white', + new Map([[bp, makeTree('LgText', 'Large', '16px')]]), + ], + [ + 'size=sm|varient=white', + new Map([[bp, makeTree('SmText', 'Small', '12px')]]), + ], + ]) + + const result = generator.generateMultiVariantMergedCode( + ['size', 'varient'], + treesByCompositeAndBreakpoint, + 0, + ) + + // ArrowComp must appear after all text-related content + const iconPos = result.indexOf('render:IconComp') + const arrowPos = result.indexOf('render:ArrowComp') + const lgTextPos = result.indexOf('Large') + const smTextPos = result.indexOf('Small') + + expect(iconPos).toBeGreaterThanOrEqual(0) + expect(arrowPos).toBeGreaterThan(iconPos) + expect(arrowPos).toBeGreaterThan(lgTextPos) + expect(arrowPos).toBeGreaterThan(smTextPos) + }) + }) }) diff --git a/src/codegen/utils/extract-instance-variant-props.ts b/src/codegen/utils/extract-instance-variant-props.ts index 91aee50..f0c7128 100644 --- a/src/codegen/utils/extract-instance-variant-props.ts +++ b/src/codegen/utils/extract-instance-variant-props.ts @@ -9,7 +9,7 @@ const RESERVED_VARIANT_KEYS = ['effect', 'viewport'] /** * Check if a key is a reserved variant key (case-insensitive). */ -function isReservedVariantKey(key: string): boolean { +export function isReservedVariantKey(key: string): boolean { const lowerKey = key.toLowerCase() return RESERVED_VARIANT_KEYS.some( (reserved) => lowerKey === reserved || lowerKey.startsWith(`${reserved}#`),