diff --git a/src/__tests__/__snapshots__/code.test.ts.snap b/src/__tests__/__snapshots__/code.test.ts.snap index 5830663..c61256e 100644 --- a/src/__tests__/__snapshots__/code.test.ts.snap +++ b/src/__tests__/__snapshots__/code.test.ts.snap @@ -6,7 +6,7 @@ exports[`registerCodegen should register codegen 1`] = ` "code": "export function Test() { return - }" +}" , "language": "TYPESCRIPT", "title": "Test - Components", @@ -19,7 +19,7 @@ echo 'import { Box } from \\'@devup-ui/react\\' export function Test() { return - }' > src/components/Test.tsx" +}' > src/components/Test.tsx" , "language": "BASH", "title": "Test - Components CLI (Bash)", @@ -33,7 +33,7 @@ import { Box } from '@devup-ui/react' export function Test() { return - } +} '@ | Out-File -FilePath src\\components\\Test.tsx -Encoding UTF8" , "language": "BASH", diff --git a/src/__tests__/code.test.ts b/src/__tests__/code.test.ts index 295421a..3702655 100644 --- a/src/__tests__/code.test.ts +++ b/src/__tests__/code.test.ts @@ -214,3 +214,423 @@ describe('extractImports', () => { expect(result).toContain('Box') }) }) + +describe('extractCustomComponentImports', () => { + it('should extract custom component imports', () => { + const result = codeModule.extractCustomComponentImports([ + ['MyComponent', ''], + ]) + expect(result).toContain('CustomButton') + expect(result).toContain('CustomInput') + expect(result).not.toContain('Box') + expect(result).not.toContain('MyComponent') + }) + + it('should not include devup-ui components', () => { + const result = codeModule.extractCustomComponentImports([ + [ + 'MyComponent', + '', + ], + ]) + expect(result).toContain('CustomCard') + expect(result).not.toContain('Box') + expect(result).not.toContain('Flex') + expect(result).not.toContain('VStack') + }) + + it('should return empty array when no custom components', () => { + const result = codeModule.extractCustomComponentImports([ + ['MyComponent', 'Hello'], + ]) + expect(result).toEqual([]) + }) + + it('should sort custom components alphabetically', () => { + const result = codeModule.extractCustomComponentImports([ + ['MyComponent', ''], + ]) + expect(result).toEqual(['Apple', 'Mango', 'Zebra']) + }) + + it('should handle multiple components with same custom component', () => { + const result = codeModule.extractCustomComponentImports([ + ['ComponentA', ''], + ['ComponentB', ''], + ]) + expect(result).toEqual(['SharedButton']) + }) + + it('should handle nested custom components', () => { + const result = codeModule.extractCustomComponentImports([ + ['Parent', ''], + ]) + expect(result).toContain('ChildA') + expect(result).toContain('ChildB') + expect(result).toContain('ChildC') + }) +}) + +describe('registerCodegen with viewport variant', () => { + type CodegenHandler = (event: { + node: SceneNode + language: string + }) => Promise + + it('should generate responsive component codes for COMPONENT_SET with viewport variant', 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: 'ResponsiveButton', + visible: true, + componentPropertyDefinitions: { + viewport: { + type: 'VARIANT', + defaultValue: 'desktop', + variantOptions: ['mobile', 'desktop'], + }, + }, + children: [ + { + type: 'COMPONENT', + name: 'viewport=mobile', + visible: true, + variantProperties: { viewport: 'mobile' }, + children: [], + layoutMode: 'VERTICAL', + width: 320, + height: 100, + }, + { + type: 'COMPONENT', + name: 'viewport=desktop', + visible: true, + variantProperties: { viewport: 'desktop' }, + children: [], + layoutMode: 'HORIZONTAL', + width: 1200, + height: 100, + }, + ], + defaultVariant: { + type: 'COMPONENT', + name: 'viewport=desktop', + visible: true, + variantProperties: { viewport: 'desktop' }, + 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.includes('Responsive'), + ) + expect(responsiveResult).toBeDefined() + }) + + it('should generate responsive component with multiple variants (viewport + size)', 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 both viewport and size variants + const componentSetNode = { + type: 'COMPONENT_SET', + name: 'ResponsiveButton', + visible: true, + componentPropertyDefinitions: { + viewport: { + type: 'VARIANT', + defaultValue: 'desktop', + variantOptions: ['mobile', 'desktop'], + }, + size: { + type: 'VARIANT', + defaultValue: 'md', + variantOptions: ['sm', 'md', 'lg'], + }, + }, + children: [ + { + type: 'COMPONENT', + name: 'viewport=mobile, size=md', + visible: true, + variantProperties: { viewport: 'mobile', size: 'md' }, + children: [], + layoutMode: 'VERTICAL', + width: 320, + height: 100, + }, + { + type: 'COMPONENT', + name: 'viewport=desktop, size=md', + visible: true, + variantProperties: { viewport: 'desktop', size: 'md' }, + children: [], + layoutMode: 'HORIZONTAL', + width: 1200, + height: 100, + }, + ], + defaultVariant: { + type: 'COMPONENT', + name: 'viewport=desktop, size=md', + visible: true, + variantProperties: { viewport: 'desktop', size: 'md' }, + 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.includes('Responsive'), + ) + expect(responsiveResult).toBeDefined() + + // The generated code should include the size variant in the interface + const resultWithCode = responsiveResult as { code: string } | undefined + if (resultWithCode?.code) { + expect(resultWithCode.code).toContain('size') + } + }) + + it('should generate responsive code for node with parent SECTION', 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') + + // Create a SECTION node with children of different widths + const sectionNode = { + type: 'SECTION', + name: 'ResponsiveSection', + visible: true, + children: [ + { + type: 'FRAME', + name: 'MobileFrame', + visible: true, + width: 375, + height: 200, + children: [], + layoutMode: 'VERTICAL', + }, + { + type: 'FRAME', + name: 'DesktopFrame', + visible: true, + width: 1200, + height: 200, + children: [], + layoutMode: 'HORIZONTAL', + }, + ], + } + + // Create a child node that has the SECTION as parent + const childNode = { + type: 'FRAME', + name: 'ChildFrame', + visible: true, + width: 375, + height: 100, + children: [], + layoutMode: 'VERTICAL', + parent: sectionNode, + } as unknown as SceneNode + + const handler = capturedHandler as CodegenHandler + const result = await handler({ + node: childNode, + language: 'devup-ui', + }) + + // Should include responsive result from parent section + const responsiveResult = result.find( + (r: unknown) => + typeof r === 'object' && + r !== null && + 'title' in r && + (r as { title: string }).title.includes('Responsive'), + ) + expect(responsiveResult).toBeDefined() + }) + + it('should generate CLI with custom component imports', 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') + + // Create a custom component that will be referenced + const customComponent = { + type: 'COMPONENT', + name: 'CustomButton', + visible: true, + children: [], + width: 100, + height: 40, + layoutMode: 'NONE', + componentPropertyDefinitions: {}, + parent: null, + } + + // Create an INSTANCE referencing the custom component + const instanceNode = { + type: 'INSTANCE', + name: 'CustomButton', + visible: true, + width: 100, + height: 40, + getMainComponentAsync: async () => customComponent, + } + + // Create a COMPONENT that contains the INSTANCE + const componentNode = { + type: 'COMPONENT', + name: 'MyComponent', + visible: true, + children: [instanceNode], + width: 200, + height: 100, + layoutMode: 'VERTICAL', + componentPropertyDefinitions: {}, + reactions: [], + parent: null, + } as unknown as SceneNode + + // Create COMPONENT_SET parent with proper children array + const componentSetNode = { + type: 'COMPONENT_SET', + name: 'MyComponentSet', + componentPropertyDefinitions: {}, + children: [componentNode], + defaultVariant: componentNode, + reactions: [], + } + + // Set parent reference + ;(componentNode as { parent: unknown }).parent = componentSetNode + + const handler = capturedHandler as CodegenHandler + const result = await handler({ + node: componentNode, + language: 'devup-ui', + }) + + // Should include CLI outputs + const bashCLI = result.find( + (r: unknown) => + typeof r === 'object' && + r !== null && + 'title' in r && + (r as { title: string }).title.includes('CLI (Bash)'), + ) + const powershellCLI = result.find( + (r: unknown) => + typeof r === 'object' && + r !== null && + 'title' in r && + (r as { title: string }).title.includes('CLI (PowerShell)'), + ) + + expect(bashCLI).toBeDefined() + expect(powershellCLI).toBeDefined() + + // Check that custom component import is included (bash escapes quotes) + 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\\'", + ) + } + if (powershellCode) { + expect(powershellCode).toContain( + "import { CustomButton } from '@/components/CustomButton'", + ) + } + }) +}) diff --git a/src/code-impl.ts b/src/code-impl.ts index e98ba11..0b54211 100644 --- a/src/code-impl.ts +++ b/src/code-impl.ts @@ -3,6 +3,17 @@ import { ResponsiveCodegen } from './codegen/responsive/ResponsiveCodegen' import { exportDevup, importDevup } from './commands/devup' import { exportAssets } from './commands/exportAssets' import { exportComponents } from './commands/exportComponents' +import { getComponentName } from './utils' + +const DEVUP_COMPONENTS = [ + 'Center', + 'VStack', + 'Flex', + 'Grid', + 'Box', + 'Text', + 'Image', +] export function extractImports( componentsCodes: ReadonlyArray, @@ -10,39 +21,67 @@ export function extractImports( const allCode = componentsCodes.map(([_, code]) => code).join('\n') const imports = new Set() - const devupComponents = [ - 'Center', - 'VStack', - 'Flex', - 'Grid', - 'Box', - 'Text', - 'Image', - ] - - for (const component of devupComponents) { + for (const component of DEVUP_COMPONENTS) { const regex = new RegExp(`<${component}[\\s/>]`, 'g') if (regex.test(allCode)) { imports.add(component) } } - // keyframes 함수 체크 - if (/keyframes\s*\(|keyframes`/.test(allCode)) { + if (/\bkeyframes\s*(\(|`)/.test(allCode)) { imports.add('keyframes') } return Array.from(imports).sort() } +export function extractCustomComponentImports( + componentsCodes: ReadonlyArray, +): string[] { + const allCode = componentsCodes.map(([_, code]) => code).join('\n') + const customImports = new Set() + + // Find all component usages in JSX: + const componentUsageRegex = /<([A-Z][a-zA-Z0-9]*)/g + const matches = allCode.matchAll(componentUsageRegex) + for (const match of matches) { + const componentName = match[1] + // Skip devup-ui components and components defined in this code + if (!DEVUP_COMPONENTS.includes(componentName)) { + customImports.add(componentName) + } + } + + return Array.from(customImports).sort() +} + +function generateImportStatements( + componentsCodes: ReadonlyArray, +): string { + const devupImports = extractImports(componentsCodes) + const customImports = extractCustomComponentImports(componentsCodes) + + const statements: string[] = [] + + if (devupImports.length > 0) { + statements.push( + `import { ${devupImports.join(', ')} } from '@devup-ui/react'`, + ) + } + + for (const componentName of customImports) { + statements.push( + `import { ${componentName} } from '@/components/${componentName}'`, + ) + } + + return statements.length > 0 ? `${statements.join('\n')}\n\n` : '' +} + function generateBashCLI( componentsCodes: ReadonlyArray, ): string { - const imports = extractImports(componentsCodes) - const importStatement = - imports.length > 0 - ? `import { ${imports.join(', ')} } from '@devup-ui/react'\n\n` - : '' + const importStatement = generateImportStatements(componentsCodes) const commands = [ 'mkdir -p src/components', @@ -60,11 +99,7 @@ function generateBashCLI( function generatePowerShellCLI( componentsCodes: ReadonlyArray, ): string { - const imports = extractImports(componentsCodes) - const importStatement = - imports.length > 0 - ? `import { ${imports.join(', ')} } from '@devup-ui/react'\n\n` - : '' + const importStatement = generateImportStatements(componentsCodes) const commands = [ 'New-Item -ItemType Directory -Force -Path src\\components | Out-Null', @@ -87,6 +122,20 @@ export function registerCodegen(ctx: typeof figma) { const codegen = new Codegen(node) await codegen.run() const componentsCodes = codegen.getComponentsCodes() + + // Generate responsive component codes with variant support + let responsiveComponentsCodes: ReadonlyArray< + readonly [string, string] + > = [] + if (node.type === 'COMPONENT_SET') { + const componentName = getComponentName(node) + responsiveComponentsCodes = + await ResponsiveCodegen.generateVariantResponsiveComponents( + node, + componentName, + ) + } + console.info(`[benchmark] devup-ui end ${Date.now() - time}ms`) const parentSection = ResponsiveCodegen.hasParentSection(node) @@ -144,6 +193,17 @@ export function registerCodegen(ctx: typeof figma) { }, ] as const) : []), + ...(responsiveComponentsCodes.length > 0 + ? [ + { + title: `${node.name} - Components Responsive`, + language: 'TYPESCRIPT' as const, + code: responsiveComponentsCodes + .map((code) => code[1]) + .join('\n\n'), + }, + ] + : []), ...responsiveResult, ] } diff --git a/src/codegen/Codegen.ts b/src/codegen/Codegen.ts index aa716ae..070ecf3 100644 --- a/src/codegen/Codegen.ts +++ b/src/codegen/Codegen.ts @@ -25,11 +25,17 @@ export class Codegen { private componentTrees: Map = new Map() constructor(private node: SceneNode) { - if (node.type === 'COMPONENT' && node.parent?.type === 'COMPONENT_SET') { - this.node = node.parent - } else { - this.node = node - } + this.node = node + // if (node.type === 'COMPONENT' && node.parent?.type === 'COMPONENT_SET') { + // this.node = node.parent + // } else { + // this.node = node + // } + // if (node.type === 'COMPONENT' && node.parent?.type === 'COMPONENT_SET') { + // this.node = node.parent + // } else { + // this.node = node + // } } getCode() { @@ -46,120 +52,31 @@ export class Codegen { ) } - async addComponent(node: ComponentNode) { - const childrenCodes = - 'children' in node - ? node.children.map(async (child) => { - if (child.type === 'INSTANCE') { - const mainComponent = await child.getMainComponentAsync() - if (mainComponent) await this.addComponent(mainComponent) - } + /** + * Run the codegen process: build tree and render to JSX string. + */ + async run(node: SceneNode = this.node, depth: number = 0): Promise { + // Build the tree first + const tree = await this.buildTree(node) - return await this.run(child, 0) - }) - : [] - const props = await getProps(node) - const selectorProps = await getSelectorProps(node) - const variants = {} + // Render the tree to JSX string + const ret = Codegen.renderTree(tree, depth) - if (selectorProps) { - Object.assign(props, selectorProps.props) - Object.assign(variants, selectorProps.variants) + if (node === this.node) { + this.code = ret + this.tree = tree } - this.components.set(node, { - code: renderNode( - getDevupComponentByProps(props), - props, - 2, - await Promise.all(childrenCodes), - ), - variants, - }) - } - - async run(node: SceneNode = this.node, dep: number = 0): Promise { - const assetNode = checkAssetNode(node) - if (assetNode) { - const props = await getProps(node) - props.src = `/${assetNode === 'svg' ? 'icons' : 'images'}/${node.name}.${assetNode}` - if (assetNode === 'svg') { - const maskColor = await checkSameColor(node) - if (maskColor) { - // support mask image icon - props.maskImage = buildCssUrl(props.src as string) - props.maskRepeat = 'no-repeat' - props.maskSize = 'contain' - props.bg = maskColor - delete props.src - } + // Sync componentTrees to components + for (const [compNode, compTree] of this.componentTrees) { + if (!this.components.has(compNode)) { + this.components.set(compNode, { + code: Codegen.renderTree(compTree.tree, 2), + variants: compTree.variants, + }) } - const ret = renderNode('src' in props ? 'Image' : 'Box', props, dep, []) - if (node === this.node) this.code = ret - return ret } - const props = await getProps(node) - if ( - (node.type === 'COMPONENT_SET' || node.type === 'COMPONENT') && - ((this.node.type === 'COMPONENT_SET' && - node === this.node.defaultVariant) || - this.node.type === 'COMPONENT') - ) { - await this.addComponent( - node.type === 'COMPONENT_SET' ? node.defaultVariant : node, - ) - } - if (node.type === 'INSTANCE') { - const mainComponent = await node.getMainComponentAsync() - if (mainComponent) await this.addComponent(mainComponent) - let ret = renderNode(getComponentName(mainComponent || node), {}, dep, []) - if (props.pos) { - ret = renderNode( - 'Box', - { - pos: props.pos, - top: props.top, - left: props.left, - right: props.right, - bottom: props.bottom, - w: - // if the node is a page root, set the width to 100% - (getPageNode(node as BaseNode & ChildrenMixin) as SceneNode) - ?.width === node.width - ? '100%' - : undefined, - }, - dep, - [ret], - ) - } - if (node === this.node) this.code = ret - return ret - } - const childrenCodes = await Promise.all( - 'children' in node - ? node.children.map(async (child) => { - if (child.type === 'INSTANCE') { - const mainComponent = await child.getMainComponentAsync() - if (mainComponent) await this.addComponent(mainComponent) - } - return await this.run(child) - }) - : [], - ) - if (node.type === 'TEXT') { - const { children, props: _props } = await renderText(node) - childrenCodes.push(...children) - Object.assign(props, _props) - } - const ret = renderNode( - getDevupComponentByNode(node, props), - props, - dep, - childrenCodes, - ) - if (node === this.node) this.code = ret return ret } @@ -344,6 +261,16 @@ export class Codegen { }) } + /** + * Check if the node is a COMPONENT_SET with viewport variant. + */ + hasViewportVariant(): boolean { + if (this.node.type !== 'COMPONENT_SET') return false + return Object.keys( + (this.node as ComponentSetNode).componentPropertyDefinitions, + ).some((key) => key.toLowerCase() === 'viewport') + } + /** * Render a NodeTree to JSX string. * Static method so it can be used independently. @@ -354,8 +281,9 @@ export class Codegen { return renderNode(tree.component, tree.props, depth, tree.textChildren) } + // Children are rendered with depth 0 because renderNode handles indentation internally const childrenCodes = tree.children.map((child) => - Codegen.renderTree(child, depth + 1), + Codegen.renderTree(child, 0), ) return renderNode(tree.component, tree.props, depth, childrenCodes) } diff --git a/src/codegen/__tests__/codegen-viewport.test.ts b/src/codegen/__tests__/codegen-viewport.test.ts new file mode 100644 index 0000000..e4ad9ea --- /dev/null +++ b/src/codegen/__tests__/codegen-viewport.test.ts @@ -0,0 +1,357 @@ +import { afterAll, describe, expect, test } from 'bun:test' +import { Codegen } from '../Codegen' +import { ResponsiveCodegen } from '../responsive/ResponsiveCodegen' + +// Mock figma global +;(globalThis as { figma?: unknown }).figma = { + mixed: Symbol('mixed'), + util: { + rgba: (color: string | RGB | RGBA): RGBA => { + if (typeof color === 'string') { + const rgbMatch = color.match(/rgb\(([^)]+)\)/) + if (rgbMatch) { + const values = rgbMatch[1].split(/[,\s/]+/).filter(Boolean) + const r = values[0]?.includes('%') + ? parseFloat(values[0]) / 100 + : parseFloat(values[0] || '0') / 255 + const g = values[1]?.includes('%') + ? parseFloat(values[1]) / 100 + : parseFloat(values[1] || '0') / 255 + const b = values[2]?.includes('%') + ? parseFloat(values[2]) / 100 + : parseFloat(values[2] || '0') / 255 + const a = values[3] ? parseFloat(values[3]) : 1 + return { r, g, b, a } + } + return { r: 0, g: 0, b: 0, a: 1 } + } + if (typeof color === 'object') { + if ('a' in color) { + return color + } + return { ...color, a: 1 } + } + return { r: 0, g: 0, b: 0, a: 1 } + }, + }, + getLocalTextStylesAsync: () => [], + getStyleByIdAsync: async () => null, + getNodeByIdAsync: async () => null, + variables: { + getVariableByIdAsync: async () => null, + }, +} as unknown as typeof figma + +afterAll(() => { + ;(globalThis as { figma?: unknown }).figma = undefined +}) + +function createComponentNode( + name: string, + variantProperties: Record, + overrides: Partial = {}, +): ComponentNode { + return { + type: 'COMPONENT', + name, + variantProperties, + children: [], + layoutMode: 'VERTICAL', + primaryAxisAlignItems: 'MIN', + counterAxisAlignItems: 'MIN', + paddingTop: 0, + paddingBottom: 0, + paddingLeft: 0, + paddingRight: 0, + itemSpacing: 0, + width: 100, + height: 100, + visible: true, + fills: [], + strokes: [], + effects: [], + cornerRadius: 0, + reactions: [], + ...overrides, + } as unknown as ComponentNode +} + +function createComponentSetNode( + name: string, + componentPropertyDefinitions: ComponentSetNode['componentPropertyDefinitions'], + children: ComponentNode[], +): ComponentSetNode { + return { + type: 'COMPONENT_SET', + name, + componentPropertyDefinitions, + children, + defaultVariant: children[0], + width: 500, + height: 500, + visible: true, + fills: [], + strokes: [], + effects: [], + reactions: [], + } as unknown as ComponentSetNode +} + +describe('Codegen viewport variant', () => { + test('hasViewportVariant returns false for non-COMPONENT_SET', () => { + const frameNode = { + type: 'FRAME', + name: 'Frame', + children: [], + } as unknown as SceneNode + + const codegen = new Codegen(frameNode) + expect(codegen.hasViewportVariant()).toBe(false) + }) + + test('hasViewportVariant returns false when no viewport variant exists', () => { + const componentSet = createComponentSetNode( + 'Button', + { + size: { + type: 'VARIANT', + defaultValue: 'md', + variantOptions: ['sm', 'md', 'lg'], + }, + }, + [createComponentNode('size=md', { size: 'md' })], + ) + + const codegen = new Codegen(componentSet) + expect(codegen.hasViewportVariant()).toBe(false) + }) + + test('hasViewportVariant returns true when viewport variant exists (lowercase)', () => { + const componentSet = createComponentSetNode( + 'Button', + { + viewport: { + type: 'VARIANT', + defaultValue: 'desktop', + variantOptions: ['mobile', 'tablet', 'desktop'], + }, + }, + [createComponentNode('viewport=desktop', { viewport: 'desktop' })], + ) + + const codegen = new Codegen(componentSet) + expect(codegen.hasViewportVariant()).toBe(true) + }) + + test('hasViewportVariant returns true when viewport variant exists (mixed case)', () => { + const componentSet = createComponentSetNode( + 'Button', + { + Viewport: { + type: 'VARIANT', + defaultValue: 'Desktop', + variantOptions: ['Mobile', 'Tablet', 'Desktop'], + }, + }, + [createComponentNode('Viewport=Desktop', { Viewport: 'Desktop' })], + ) + + const codegen = new Codegen(componentSet) + expect(codegen.hasViewportVariant()).toBe(true) + }) + + test('generateViewportResponsiveComponents generates responsive code for viewport variants', async () => { + const mobileComponent = createComponentNode( + 'viewport=mobile', + { viewport: 'mobile' }, + { + width: 320, + layoutMode: 'VERTICAL', + itemSpacing: 8, + }, + ) + + const desktopComponent = createComponentNode( + 'viewport=desktop', + { viewport: 'desktop' }, + { + width: 1200, + layoutMode: 'HORIZONTAL', + itemSpacing: 16, + }, + ) + + const componentSet = createComponentSetNode( + 'ResponsiveCard', + { + viewport: { + type: 'VARIANT', + defaultValue: 'desktop', + variantOptions: ['mobile', 'desktop'], + }, + }, + [mobileComponent, desktopComponent], + ) + + const codegen = new Codegen(componentSet) + expect(codegen.hasViewportVariant()).toBe(true) + + const codes = await ResponsiveCodegen.generateViewportResponsiveComponents( + componentSet, + 'ResponsiveCard', + ) + expect(codes.length).toBe(1) + expect(codes[0][0]).toBe('ResponsiveCard') + expect(codes[0][1]).toContain('export function ResponsiveCard') + }) + + test('generateViewportResponsiveComponents excludes viewport from variants interface', async () => { + const mobileComponent = createComponentNode('size=md, viewport=mobile', { + size: 'md', + viewport: 'mobile', + }) + + const desktopComponent = createComponentNode('size=md, viewport=desktop', { + size: 'md', + viewport: 'desktop', + }) + + const componentSet = createComponentSetNode( + 'Button', + { + size: { + type: 'VARIANT', + defaultValue: 'md', + variantOptions: ['sm', 'md', 'lg'], + }, + viewport: { + type: 'VARIANT', + defaultValue: 'desktop', + variantOptions: ['mobile', 'desktop'], + }, + }, + [mobileComponent, desktopComponent], + ) + + const codes = await ResponsiveCodegen.generateViewportResponsiveComponents( + componentSet, + 'Button', + ) + + expect(codes.length).toBe(1) + // Should have size in interface but not viewport + expect(codes[0][1]).toContain('size:') + expect(codes[0][1]).not.toContain('viewport:') + }) + + test('generateViewportResponsiveComponents generates responsive arrays for different props', async () => { + const mobileComponent = createComponentNode( + 'viewport=mobile', + { viewport: 'mobile' }, + { + width: 320, + height: 200, + layoutMode: 'VERTICAL', + itemSpacing: 8, + paddingTop: 8, + paddingBottom: 8, + paddingLeft: 8, + paddingRight: 8, + }, + ) + + const desktopComponent = createComponentNode( + 'viewport=desktop', + { viewport: 'desktop' }, + { + width: 1200, + height: 400, + layoutMode: 'HORIZONTAL', + itemSpacing: 24, + paddingTop: 16, + paddingBottom: 16, + paddingLeft: 16, + paddingRight: 16, + }, + ) + + const componentSet = createComponentSetNode( + 'ResponsiveBox', + { + viewport: { + type: 'VARIANT', + defaultValue: 'desktop', + variantOptions: ['mobile', 'desktop'], + }, + }, + [mobileComponent, desktopComponent], + ) + + const codes = await ResponsiveCodegen.generateViewportResponsiveComponents( + componentSet, + 'ResponsiveBox', + ) + + expect(codes.length).toBe(1) + const generatedCode = codes[0][1] + + // Check that responsive arrays are generated for different prop values + // padding should be responsive: p={["8px", null, null, null, "16px"]} + expect(generatedCode).toContain('p={') + expect(generatedCode).toContain('"8px"') + expect(generatedCode).toContain('"16px"') + }) + + test('groups components with same non-viewport variants together', async () => { + // Create components for different size + viewport combinations + const smMobile = createComponentNode( + 'size=sm, viewport=mobile', + { size: 'sm', viewport: 'mobile' }, + { width: 80 }, + ) + + const smDesktop = createComponentNode( + 'size=sm, viewport=desktop', + { size: 'sm', viewport: 'desktop' }, + { width: 100 }, + ) + + const lgMobile = createComponentNode( + 'size=lg, viewport=mobile', + { size: 'lg', viewport: 'mobile' }, + { width: 120 }, + ) + + const lgDesktop = createComponentNode( + 'size=lg, viewport=desktop', + { size: 'lg', viewport: 'desktop' }, + { width: 160 }, + ) + + const componentSet = createComponentSetNode( + 'Button', + { + size: { + type: 'VARIANT', + defaultValue: 'sm', + variantOptions: ['sm', 'lg'], + }, + viewport: { + type: 'VARIANT', + defaultValue: 'desktop', + variantOptions: ['mobile', 'desktop'], + }, + }, + [smMobile, smDesktop, lgMobile, lgDesktop], + ) + + const codes = await ResponsiveCodegen.generateViewportResponsiveComponents( + componentSet, + 'Button', + ) + + // Should generate 2 groups: one for size=sm and one for size=lg + // But since we're using the same component name, it will be 2 entries + expect(codes.length).toBe(2) + }) +}) diff --git a/src/codegen/__tests__/codegen.test.ts b/src/codegen/__tests__/codegen.test.ts index f24f1aa..189f733 100644 --- a/src/codegen/__tests__/codegen.test.ts +++ b/src/codegen/__tests__/codegen.test.ts @@ -569,6 +569,132 @@ describe('Codegen', () => { } as unknown as VectorNode, expected: ``, }, + { + title: 'renders nested svg asset with 3 solid fill boxes in frame', + node: { + type: 'FRAME', + name: 'NestedIcon', + children: [ + { + type: 'RECTANGLE', + name: 'Box1', + children: [], + visible: true, + isAsset: false, + fills: [ + { + type: 'SOLID', + visible: true, + color: { r: 1, g: 0, b: 0 }, + opacity: 1, + }, + ], + }, + { + type: 'RECTANGLE', + name: 'Box2', + children: [], + visible: true, + isAsset: false, + fills: [ + { + type: 'SOLID', + visible: true, + color: { r: 1, g: 0, b: 0 }, + opacity: 1, + }, + ], + }, + { + type: 'RECTANGLE', + name: 'Box3', + children: [], + visible: true, + isAsset: false, + fills: [ + { + type: 'SOLID', + visible: true, + color: { r: 1, g: 0, b: 0 }, + opacity: 1, + }, + ], + }, + ], + isAsset: false, + layoutSizingHorizontal: 'FIXED', + layoutSizingVertical: 'FIXED', + width: 24, + height: 24, + } as unknown as FrameNode, + expected: ``, + }, + { + title: 'renders nested svg asset with different colors as image', + node: { + type: 'FRAME', + name: 'NestedMultiColorIcon', + children: [ + { + type: 'RECTANGLE', + name: 'Box1', + children: [], + visible: true, + isAsset: false, + fills: [ + { + type: 'SOLID', + visible: true, + color: { r: 1, g: 0, b: 0 }, + opacity: 1, + }, + ], + }, + { + type: 'RECTANGLE', + name: 'Box2', + children: [], + visible: true, + isAsset: false, + fills: [ + { + type: 'SOLID', + visible: true, + color: { r: 0, g: 1, b: 0 }, + opacity: 1, + }, + ], + }, + { + type: 'RECTANGLE', + name: 'Box3', + children: [], + visible: true, + isAsset: false, + fills: [ + { + type: 'SOLID', + visible: true, + color: { r: 0, g: 0, b: 1 }, + opacity: 1, + }, + ], + }, + ], + isAsset: true, + layoutSizingHorizontal: 'FIXED', + layoutSizingVertical: 'FIXED', + width: 24, + height: 24, + } as unknown as FrameNode, + expected: ``, + }, { title: 'renders layout for absolute child same size as parent', node: { @@ -1054,6 +1180,25 @@ describe('Codegen', () => { w="110px" />`, }, + { + title: 'renders padding from inferredAutoLayout', + node: { + type: 'FRAME', + name: 'InferredPaddingFrame', + children: [], + layoutSizingHorizontal: 'FIXED', + layoutSizingVertical: 'FIXED', + width: 100, + height: 50, + inferredAutoLayout: { + paddingTop: 10, + paddingRight: 20, + paddingBottom: 10, + paddingLeft: 20, + }, + } as unknown as FrameNode, + expected: ``, + }, { title: 'renders frame with border radius', node: { @@ -3047,9 +3192,9 @@ describe('Codegen', () => { state: 'default' | 'hover' } -export function Button() { +export function Button({ state }: ButtonProps) { return - }`, +}`, ], ], }, @@ -3092,7 +3237,7 @@ export function Button() { 'Button', `export function Button() { return - }`, +}`, ], ], }, @@ -3158,7 +3303,7 @@ export function Button() { 'Button', `export function Button() { return - }`, +}`, ], ], }, @@ -3234,7 +3379,7 @@ export function Button() { transitionProperty="opacity" /> ) - }`, +}`, ], ], }, @@ -3290,11 +3435,19 @@ export function Button() { }, ], } as unknown as ComponentNode, - expected: ` - - -`, - expectedComponents: [], + expected: ``, + expectedComponents: [ + [ + 'Button', + `export interface ButtonProps { + state: 'default' | 'hover' +} + +export function Button({ state }: ButtonProps) { + return +}`, + ], + ], }, { title: 'renders component set with press trigger', @@ -3335,7 +3488,7 @@ export function Button() { 'Button', `export function Button() { return - }`, +}`, ], ], }, @@ -3352,7 +3505,7 @@ export function Button() { 'Icon', `export function Icon() { return - }`, +}`, ], ], }, @@ -3365,11 +3518,24 @@ export function Button() { type: 'COMPONENT_SET', name: 'Button', children: [], + componentPropertyDefinitions: {}, + defaultVariant: { + type: 'COMPONENT', + name: 'Default', + children: [], + }, }, children: [], } as unknown as ComponentNode, - expected: ``, - expectedComponents: [], + expected: ``, + expectedComponents: [ + [ + 'Button', + `export function Button() { + return +}`, + ], + ], }, ])('$title', async ({ node, expected, expectedComponents }) => { addParent(node) diff --git a/src/codegen/__tests__/render.test.ts b/src/codegen/__tests__/render.test.ts index a18450b..d7dd957 100644 --- a/src/codegen/__tests__/render.test.ts +++ b/src/codegen/__tests__/render.test.ts @@ -46,7 +46,7 @@ describe('renderComponent', () => { variants: {} as Record, expected: `export function Button() { return