diff --git a/src/__tests__/code.test.ts b/src/__tests__/code.test.ts index a3458b8..c3f0312 100644 --- a/src/__tests__/code.test.ts +++ b/src/__tests__/code.test.ts @@ -1068,6 +1068,59 @@ describe('generateComponentUsage', () => { expect(result).toBe('') }) + it('should include BOOLEAN and TEXT from parent COMPONENT_SET for COMPONENT', () => { + const componentSet = { + type: 'COMPONENT_SET', + name: 'Menu', + componentPropertyDefinitions: { + effect: { + type: 'VARIANT', + defaultValue: 'default', + variantOptions: ['default', 'hover'], + }, + 'link#70:456': { + type: 'BOOLEAN', + defaultValue: true, + }, + 'text#80:456': { + type: 'TEXT', + defaultValue: '텍스트', + }, + }, + } + const node = { + type: 'COMPONENT', + name: 'effect=default', + variantProperties: { effect: 'default' }, + parent: componentSet, + } as unknown as SceneNode + + const result = codeModule.generateComponentUsage(node) + expect(result).toBe('텍스트') + }) + + it('should skip BOOLEAN with false default from parent COMPONENT_SET for COMPONENT', () => { + const componentSet = { + type: 'COMPONENT_SET', + name: 'Menu', + componentPropertyDefinitions: { + 'link#70:456': { + type: 'BOOLEAN', + defaultValue: false, + }, + }, + } + const node = { + type: 'COMPONENT', + name: 'effect=default', + variantProperties: { effect: 'default' }, + 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', @@ -1129,7 +1182,7 @@ describe('generateComponentUsage', () => { expect(result).toBe('') }) - it('should skip non-VARIANT properties for COMPONENT_SET', () => { + it('should include BOOLEAN and TEXT properties for COMPONENT_SET', () => { const node = { type: 'COMPONENT_SET', name: 'MyButton', @@ -1150,10 +1203,199 @@ describe('generateComponentUsage', () => { }, } as unknown as SceneNode + const result = codeModule.generateComponentUsage(node) + expect(result).toBe('') + }) + + it('should skip BOOLEAN with false defaultValue for COMPONENT_SET', () => { + const node = { + type: 'COMPONENT_SET', + name: 'MyButton', + componentPropertyDefinitions: { + variant: { + type: 'VARIANT', + defaultValue: 'primary', + variantOptions: ['primary', 'secondary'], + }, + hasIcon: { + type: 'BOOLEAN', + defaultValue: false, + }, + }, + } as unknown as SceneNode + const result = codeModule.generateComponentUsage(node) expect(result).toBe('') }) + it('should include TEXT properties for COMPONENT_SET', () => { + const node = { + type: 'COMPONENT_SET', + name: 'MyButton', + componentPropertyDefinitions: { + variant: { + type: 'VARIANT', + defaultValue: 'primary', + variantOptions: ['primary', 'secondary'], + }, + 'label#80:456': { + type: 'TEXT', + defaultValue: 'Click me', + }, + }, + } as unknown as SceneNode + + const result = codeModule.generateComponentUsage(node) + expect(result).toBe('Click me') + }) + + it('should use children syntax for COMPONENT with VARIANT and single TEXT', () => { + const componentSet = { + type: 'COMPONENT_SET', + name: 'Menu', + componentPropertyDefinitions: { + size: { + type: 'VARIANT', + defaultValue: 'md', + variantOptions: ['sm', 'md'], + }, + 'text#80:456': { + type: 'TEXT', + defaultValue: '텍스트', + }, + }, + } + const node = { + type: 'COMPONENT', + name: 'size=md', + variantProperties: { size: 'md' }, + parent: componentSet, + } as unknown as SceneNode + + const result = codeModule.generateComponentUsage(node) + expect(result).toBe('텍스트') + }) + + it('should use children syntax for COMPONENT with single TEXT-only prop (no other props)', () => { + const componentSet = { + type: 'COMPONENT_SET', + name: 'Label', + componentPropertyDefinitions: { + 'text#80:456': { + type: 'TEXT', + defaultValue: 'Hello', + }, + }, + } + const node = { + type: 'COMPONENT', + name: 'default', + variantProperties: {}, + parent: componentSet, + } as unknown as SceneNode + + const result = codeModule.generateComponentUsage(node) + expect(result).toBe('') + }) + + it('should use children syntax for COMPONENT_SET with BOOLEAN and single TEXT', () => { + const node = { + type: 'COMPONENT_SET', + name: 'Menu', + componentPropertyDefinitions: { + 'link#70:456': { + type: 'BOOLEAN', + defaultValue: true, + }, + 'text#80:456': { + type: 'TEXT', + defaultValue: '텍스트', + }, + }, + } as unknown as SceneNode + + const result = codeModule.generateComponentUsage(node) + expect(result).toBe('텍스트') + }) + + it('should use children syntax for COMPONENT_SET with single TEXT-only prop (no other props)', () => { + const node = { + type: 'COMPONENT_SET', + name: 'Label', + componentPropertyDefinitions: { + 'text#80:456': { + type: 'TEXT', + defaultValue: 'Hello', + }, + }, + } as unknown as SceneNode + + const result = codeModule.generateComponentUsage(node) + expect(result).toBe('') + }) + + it('should keep prop syntax for COMPONENT_SET with 2+ TEXT properties', () => { + const node = { + type: 'COMPONENT_SET', + name: 'Card', + componentPropertyDefinitions: { + 'title#80:111': { + type: 'TEXT', + defaultValue: 'Hello', + }, + 'description#80:222': { + type: 'TEXT', + defaultValue: 'World', + }, + }, + } as unknown as SceneNode + + const result = codeModule.generateComponentUsage(node) + expect(result).toBe('') + }) + + it('should keep prop syntax for COMPONENT with 2+ TEXT properties from parent', () => { + const componentSet = { + type: 'COMPONENT_SET', + name: 'Card', + componentPropertyDefinitions: { + 'title#80:111': { + type: 'TEXT', + defaultValue: 'Hello', + }, + 'description#80:222': { + type: 'TEXT', + defaultValue: 'World', + }, + }, + } + const node = { + type: 'COMPONENT', + name: 'default', + variantProperties: {}, + parent: componentSet, + } as unknown as SceneNode + + const result = codeModule.generateComponentUsage(node) + expect(result).toBe('') + }) + + it('should skip INSTANCE_SWAP properties for COMPONENT_SET', () => { + const node = { + type: 'COMPONENT_SET', + name: 'MyButton', + componentPropertyDefinitions: { + 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', diff --git a/src/code-impl.ts b/src/code-impl.ts index db0b896..cc387ab 100644 --- a/src/code-impl.ts +++ b/src/code-impl.ts @@ -18,60 +18,16 @@ import { wrapComponent } from './codegen/utils/wrap-component' import { exportDevup, importDevup } from './commands/devup' import { exportAssets } from './commands/exportAssets' import { exportComponents } from './commands/exportComponents' -import { exportPagesAndComponents } from './commands/exportPagesAndComponents' +import { + exportPagesAndComponents, + extractCustomComponentImports, + extractImports, +} from './commands/exportPagesAndComponents' +export { extractCustomComponentImports, extractImports } + import { getComponentName, resetTextStyleCache } from './utils' import { toPascal } from './utils/to-pascal' -const DEVUP_COMPONENTS = [ - 'Center', - 'VStack', - 'Flex', - 'Grid', - 'Box', - 'Text', - 'Image', -] - -export function extractImports( - componentsCodes: ReadonlyArray, -): string[] { - const allCode = componentsCodes.map(([_, code]) => code).join('\n') - const imports = new Set() - - for (const component of DEVUP_COMPONENTS) { - const regex = new RegExp(`<${component}[\\s/>]`, 'g') - if (regex.test(allCode)) { - imports.add(component) - } - } - - 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 { @@ -135,17 +91,72 @@ export function generateComponentUsage(node: SceneNode): string | null { 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]) + const entries: { key: string; value: string; type: string }[] = [] + if (variantProps) { + for (const [key, value] of Object.entries(variantProps)) { + if (!isReservedVariantKey(key)) { + entries.push({ + key: sanitizePropertyName(key), + value, + type: 'VARIANT', + }) + } + } + } + + // Also include BOOLEAN/TEXT properties from parent COMPONENT_SET + const parentSet = + (node as ComponentNode).parent?.type === 'COMPONENT_SET' + ? ((node as ComponentNode).parent as ComponentSetNode) + : null + const defs = parentSet?.componentPropertyDefinitions + let textEntry: { key: string; value: string } | null = null + let textCount = 0 + if (defs) { + for (const [key, def] of Object.entries(defs)) { + if (isReservedVariantKey(key)) continue + if (def.type === 'BOOLEAN' && def.defaultValue) { + entries.push({ + key: sanitizePropertyName(key), + value: 'true', + type: 'BOOLEAN', + }) + } else if (def.type === 'TEXT') { + textCount++ + textEntry = { + key: sanitizePropertyName(key), + value: String(def.defaultValue), + } + entries.push({ + key: sanitizePropertyName(key), + value: String(def.defaultValue), + type: 'TEXT', + }) + } } } + if (textCount === 1 && textEntry) { + const filteredEntries = entries.filter((e) => e.type !== 'TEXT') + if (filteredEntries.length === 0) + return `<${componentName}>${textEntry.value}` + const propsStr = filteredEntries + .map((e) => { + if (e.type === 'BOOLEAN') return e.key + return `${e.key}="${e.value}"` + }) + .join(' ') + return `<${componentName} ${propsStr}>${textEntry.value}` + } + if (entries.length === 0) return `<${componentName} />` - const propsStr = entries.map(([k, v]) => `${k}="${v}"`).join(' ') + const propsStr = entries + .map((e) => { + if (e.type === 'BOOLEAN') return e.key + return `${e.key}="${e.value}"` + }) + .join(' ') return `<${componentName} ${propsStr} />` } @@ -153,15 +164,53 @@ export function generateComponentUsage(node: SceneNode): string | null { const defs = (node as ComponentSetNode).componentPropertyDefinitions if (!defs) return `<${componentName} />` - const entries: [string, string][] = [] + const entries: { key: string; value: string; type: string }[] = [] + let textEntry: { key: string; value: string } | null = null + let textCount = 0 for (const [key, def] of Object.entries(defs)) { - if (def.type === 'VARIANT' && !isReservedVariantKey(key)) { - entries.push([sanitizePropertyName(key), String(def.defaultValue)]) + if (isReservedVariantKey(key)) continue + const sanitizedKey = sanitizePropertyName(key) + if (def.type === 'VARIANT') { + entries.push({ + key: sanitizedKey, + value: String(def.defaultValue), + type: 'VARIANT', + }) + } else if (def.type === 'BOOLEAN') { + if (def.defaultValue) { + entries.push({ key: sanitizedKey, value: 'true', type: 'BOOLEAN' }) + } + } else if (def.type === 'TEXT') { + textCount++ + textEntry = { key: sanitizedKey, value: String(def.defaultValue) } + entries.push({ + key: sanitizedKey, + value: String(def.defaultValue), + type: 'TEXT', + }) } } + if (textCount === 1 && textEntry) { + const filteredEntries = entries.filter((e) => e.type !== 'TEXT') + if (filteredEntries.length === 0) + return `<${componentName}>${textEntry.value}` + const propsStr = filteredEntries + .map((e) => { + if (e.type === 'BOOLEAN') return e.key + return `${e.key}="${e.value}"` + }) + .join(' ') + return `<${componentName} ${propsStr}>${textEntry.value}` + } + if (entries.length === 0) return `<${componentName} />` - const propsStr = entries.map(([k, v]) => `${k}="${v}"`).join(' ') + const propsStr = entries + .map((e) => { + if (e.type === 'BOOLEAN') return e.key + return `${e.key}="${e.value}"` + }) + .join(' ') return `<${componentName} ${propsStr} />` } @@ -264,9 +313,6 @@ export function registerCodegen(ctx: typeof figma) { componentsResponsiveCodes = responsiveResults } - console.info(`[benchmark] devup-ui end ${Date.now() - time}ms`) - console.info(perfReport()) - // Check if node itself is SECTION or has a parent SECTION const isNodeSection = ResponsiveCodegen.canGenerateResponsive(node) const parentSection = ResponsiveCodegen.hasParentSection(node) @@ -323,6 +369,8 @@ export function registerCodegen(ctx: typeof figma) { } } if (debug) { + console.info(`[benchmark] devup-ui end ${Date.now() - time}ms`) + console.info(perfReport()) // Track AFTER codegen — collects all node properties for test case // generation without Proxy overhead during the hot codegen path. nodeProxyTracker.trackTree(node) diff --git a/src/codegen/Codegen.ts b/src/codegen/Codegen.ts index b8d91e5..f679836 100644 --- a/src/codegen/Codegen.ts +++ b/src/codegen/Codegen.ts @@ -99,6 +99,22 @@ function getBooleanConditionName( return undefined } +/** + * Check if a child node's text characters are bound to a TEXT component property. + * Returns the sanitized text prop name if it is, undefined otherwise. + */ +function getTextPropName( + node: SceneNode, + textSlots: Map, +): string | undefined { + if (textSlots.size === 0) return undefined + const refs = getPropertyRefs(node) + if (refs?.characters && textSlots.has(refs.characters)) { + return textSlots.get(refs.characters) + } + return undefined +} + /** * Shallow-clone a NodeTree — creates a new object so that per-instance * property reassignment (e.g., `tree.props = { ...tree.props, ...selectorProps }`) @@ -122,7 +138,12 @@ function cloneTree(tree: NodeTree): NodeTree { export class Codegen { components: Map< string, - { node: SceneNode; code: string; variants: Record } + { + node: SceneNode + code: string + variants: Record + variantComments?: Record + } > = new Map() code: string = '' @@ -168,9 +189,17 @@ export class Codegen { getComponentsCodes() { const result: Array = [] - for (const { node, code, variants } of this.components.values()) { + for (const { + node, + code, + variants, + variantComments, + } of this.components.values()) { const name = getComponentName(node) - result.push([name, renderComponent(name, code, variants)]) + result.push([ + name, + renderComponent(name, code, variants, variantComments), + ]) } return result } @@ -213,6 +242,7 @@ export class Codegen { node: compTree.node, code: Codegen.renderTree(compTree.tree, 0), variants: compTree.variants, + variantComments: compTree.variantComments, }) } } @@ -468,13 +498,20 @@ export class Codegen { {} const instanceSwapSlots = new Map() const booleanSlots = new Map() + const textSlots = new Map() for (const [key, def] of Object.entries(propDefs)) { if (def.type === 'INSTANCE_SWAP') { instanceSwapSlots.set(key, sanitizePropertyName(key)) } else if (def.type === 'BOOLEAN') { booleanSlots.set(key, sanitizePropertyName(key)) + } else if (def.type === 'TEXT') { + textSlots.set(key, sanitizePropertyName(key)) } } + if (textSlots.size === 1) { + const [key] = [...textSlots.entries()][0] + textSlots.set(key, 'children') + } // Build children sequentially, replacing INSTANCE_SWAP targets with slot placeholders // and wrapping BOOLEAN-controlled children with conditional rendering. @@ -499,6 +536,10 @@ export class Codegen { if (conditionName) { tree.condition = conditionName } + const textPropName = getTextPropName(child, textSlots) + if (textPropName && tree.textChildren) { + tree.textChildren = [`{${textPropName}}`] + } childrenTrees.push(tree) } } @@ -521,6 +562,7 @@ export class Codegen { if (selectorProps) { Object.assign(variants, selectorProps.variants) } + const variantComments = selectorProps?.variantComments || {} this.componentTrees.set(nodeId, { name: getComponentName(node), @@ -533,6 +575,7 @@ export class Codegen { nodeName: node.name, }, variants, + variantComments, }) perfEnd('addComponentTree()', tAdd) } diff --git a/src/codegen/__tests__/__snapshots__/codegen.test.ts.snap b/src/codegen/__tests__/__snapshots__/codegen.test.ts.snap index f0230c1..da60cdb 100644 --- a/src/codegen/__tests__/__snapshots__/codegen.test.ts.snap +++ b/src/codegen/__tests__/__snapshots__/codegen.test.ts.snap @@ -740,6 +740,7 @@ exports[`Codegen renders component set with variants: component: Default 1`] = ` "w": undefined, }, }, + "variantComments": {}, "variants": { "state": "'default' | 'hover'", }, @@ -811,6 +812,7 @@ exports[`Codegen renders component set with effect property: component: Default "w": undefined, }, }, + "variantComments": {}, "variants": {}, } `; @@ -904,6 +906,7 @@ exports[`Codegen renders component set with transition: component: Default 1`] = "w": undefined, }, }, + "variantComments": {}, "variants": {}, } `; @@ -1003,6 +1006,7 @@ exports[`Codegen renders component set with different props and transition: comp "w": undefined, }, }, + "variantComments": {}, "variants": {}, } `; @@ -1098,6 +1102,7 @@ exports[`Codegen renders component with parent component set: component: Hover 1 "w": undefined, }, }, + "variantComments": {}, "variants": { "state": "'default' | 'hover'", }, @@ -1164,6 +1169,7 @@ exports[`Codegen renders component set with press trigger: component: Default 1` "w": undefined, }, }, + "variantComments": {}, "variants": {}, } `; @@ -1194,6 +1200,7 @@ exports[`Codegen renders simple component without variants: component: Icon 1`] "minW": undefined, }, }, + "variantComments": {}, "variants": {}, } `; @@ -1238,6 +1245,7 @@ exports[`Codegen renders component with parent component set name: component: Ho "w": undefined, }, }, + "variantComments": {}, "variants": {}, } `; @@ -3176,10 +3184,9 @@ exports[`render real world component real world $ 91`] = ` maskPos="center" maskRepeat="no-repeat" maskSize="contain" - pb="5px" pl="9px" pr="7px" - pt="5px" + py="5px" transform="rotate(180deg)" /> @@ -3199,10 +3206,7 @@ exports[`render real world component real world $ 92`] = ` maskPos="center" maskRepeat="no-repeat" maskSize="contain" - pb="2.29px" - pl="2.29px" - pr="2.29px" - pt="2.29px" + p="2.29px" /> ) @@ -3231,10 +3235,8 @@ exports[`render real world component real world $ 94`] = ` maskPos="center" maskRepeat="no-repeat" maskSize="contain" - pb="2.33px" - pl="2.65px" - pr="2.65px" - pt="2.33px" + px="2.65px" + py="2.33px" /> ) @@ -3263,10 +3265,8 @@ exports[`render real world component real world $ 96`] = ` maskPos="center" maskRepeat="no-repeat" maskSize="contain" - pb="2.33px" - pl="2.65px" - pr="2.65px" - pt="2.33px" + px="2.65px" + py="2.33px" /> ) diff --git a/src/codegen/__tests__/codegen.test.ts b/src/codegen/__tests__/codegen.test.ts index a55b4ef..abdcfb3 100644 --- a/src/codegen/__tests__/codegen.test.ts +++ b/src/codegen/__tests__/codegen.test.ts @@ -3824,6 +3824,89 @@ describe('Codegen Tree Methods', () => { const rendered = Codegen.renderTree(slotChild as NodeTree) expect(rendered).toBe('{showIcon && leftIcon}') }) + + test('detects TEXT property binding via componentPropertyReferences', async () => { + const styledSegments = [ + { + characters: 'Click me', + start: 0, + end: 8, + fontSize: 14, + fontName: { family: 'Inter', style: 'Regular' }, + fontWeight: 400, + textDecoration: 'NONE', + textCase: 'ORIGINAL', + lineHeight: { unit: 'AUTO' }, + letterSpacing: { unit: 'PIXELS', value: 0 }, + fills: [ + { + type: 'SOLID', + color: { r: 0, g: 0, b: 0 }, + opacity: 1, + visible: true, + }, + ], + listOptions: { type: 'NONE' }, + indentation: 0, + hyperlink: null, + }, + ] + + const textChild = { + type: 'TEXT', + name: 'Label', + visible: true, + characters: 'Click me', + componentPropertyReferences: { characters: 'label#80:456' }, + textAutoResize: 'WIDTH_AND_HEIGHT', + textAlignHorizontal: 'LEFT', + textAlignVertical: 'TOP', + getStyledTextSegments: () => styledSegments, + styledTextSegments: styledSegments, + strokes: [], + effects: [], + reactions: [], + } as unknown as TextNode + + const defaultVariant = { + type: 'COMPONENT', + name: 'State=Default', + children: [textChild], + visible: true, + reactions: [], + } as unknown as ComponentNode + + const node = { + type: 'COMPONENT_SET', + name: 'ButtonWithText', + children: [defaultVariant], + defaultVariant, + visible: true, + componentPropertyDefinitions: { + 'label#80:456': { + type: 'TEXT', + defaultValue: 'Click me', + }, + }, + } as unknown as ComponentSetNode + addParent(node) + + const codegen = new Codegen(node) + await codegen.buildTree() + + const componentTrees = codegen.getComponentTrees() + const compTree = [...componentTrees.values()].find( + (ct) => ct.name === 'ButtonWithText', + ) + expect(compTree).toBeDefined() + + // The text child should have its textChildren replaced with interpolated prop name + const textTree = compTree?.tree.children.find( + (c) => c.nodeType === 'TEXT', + ) + expect(textTree).toBeDefined() + expect(textTree?.textChildren).toEqual(['{children}']) + }) }) describe('renderTree (static)', () => { diff --git a/src/codegen/__tests__/render.test.ts b/src/codegen/__tests__/render.test.ts index 5c8e767..051a6d6 100644 --- a/src/codegen/__tests__/render.test.ts +++ b/src/codegen/__tests__/render.test.ts @@ -179,6 +179,55 @@ export function Button({ leftIcon, size, rightIcon }: ButtonProps) { }) }) +describe('renderComponent with comments', () => { + test('prepends JSDoc comment for variant with comment', () => { + const result = renderComponent( + 'Button', + '
', + { children: 'React.ReactNode', size: '"sm" | "lg"' }, + { children: 'label' }, + ) + expect(result).toBe(`export interface ButtonProps { + /** label */ + children: React.ReactNode + size: "sm" | "lg" +} + +export function Button({ children, size }: ButtonProps) { + return
+}`) + }) + + test('does not add comment when comments map is empty', () => { + const result = renderComponent( + 'Button', + '
', + { children: 'React.ReactNode' }, + {}, + ) + expect(result).toBe(`export interface ButtonProps { + children: React.ReactNode +} + +export function Button({ children }: ButtonProps) { + return
+}`) + }) + + test('does not add comment when comments is undefined', () => { + const result = renderComponent('Button', '
', { + children: 'React.ReactNode', + }) + expect(result).toBe(`export interface ButtonProps { + children: React.ReactNode +} + +export function Button({ children }: ButtonProps) { + return
+}`) + }) +}) + describe('renderComponent interface snapshot', () => { test('VARIANT + BOOLEAN + INSTANCE_SWAP mixed interface', () => { const code = `
diff --git a/src/codegen/props/__tests__/selector.test.ts b/src/codegen/props/__tests__/selector.test.ts index dcafd9d..55d28b7 100644 --- a/src/codegen/props/__tests__/selector.test.ts +++ b/src/codegen/props/__tests__/selector.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { getSelectorProps } from '../selector' +import { applyTextChildrenTransform, getSelectorProps } from '../selector' // Mock figma global ;(globalThis as { figma?: unknown }).figma = { @@ -146,7 +146,7 @@ describe('getSelectorProps', () => { expect(result?.variants.showIcon).toBe('boolean') }) - test('includes TEXT properties as string in variants', async () => { + test('includes single TEXT property as children: React.ReactNode in variants', async () => { const defaultVariant = { type: 'COMPONENT', name: 'size=Default', @@ -178,7 +178,9 @@ describe('getSelectorProps', () => { expect(result).toBeDefined() expect(result?.variants.size).toBe("'Default' | 'Small'") - expect(result?.variants.label).toBe('string') + expect(result?.variants.label).toBeUndefined() + expect(result?.variants.children).toBe('React.ReactNode') + expect(result?.variantComments).toEqual({ children: 'label' }) }) test('includes INSTANCE_SWAP properties as React.ReactNode in variants', async () => { @@ -417,6 +419,36 @@ describe('getSelectorProps', () => { }) }) + describe('applyTextChildrenTransform', () => { + test('returns unchanged variants when no TEXT props', () => { + const variants = { size: "'sm' | 'lg'", icon: 'React.ReactNode' } + const result = applyTextChildrenTransform(variants) + expect(result.variants).toEqual(variants) + expect(result.variantComments).toEqual({}) + }) + + test('transforms single TEXT prop to children', () => { + const variants = { size: "'sm' | 'lg'", label: 'string' } + const result = applyTextChildrenTransform(variants) + expect(result.variants).toEqual({ + size: "'sm' | 'lg'", + children: 'React.ReactNode', + }) + expect(result.variantComments).toEqual({ children: 'label' }) + }) + + test('returns unchanged variants when 2+ TEXT props', () => { + const variants = { + title: 'string', + description: 'string', + size: "'sm' | 'lg'", + } + const result = applyTextChildrenTransform(variants) + expect(result.variants).toEqual(variants) + expect(result.variantComments).toEqual({}) + }) + }) + describe('triggerTypeToEffect', () => { test('handles ON_HOVER trigger type', async () => { const defaultVariant = { diff --git a/src/codegen/props/auto-layout.ts b/src/codegen/props/auto-layout.ts index 669be7d..b0a53be 100644 --- a/src/codegen/props/auto-layout.ts +++ b/src/codegen/props/auto-layout.ts @@ -59,7 +59,10 @@ function getAlignItems(node: SceneNode & BaseFrameMixin): string | undefined { function getGridProps( node: GridLayoutMixin, ): Record { - const sameGap = node.gridRowGap === node.gridColumnGap + // Round to 2 decimal places to handle Figma floating-point imprecision + const sameGap = + Math.round(node.gridRowGap * 100) / 100 === + Math.round(node.gridColumnGap * 100) / 100 return { display: 'grid', gridTemplateColumns: `repeat(${node.gridColumnCount}, 1fr)`, diff --git a/src/codegen/props/border.ts b/src/codegen/props/border.ts index 6b98099..45a13a5 100644 --- a/src/codegen/props/border.ts +++ b/src/codegen/props/border.ts @@ -43,7 +43,7 @@ export async function getBorderProps( const paintCssList = [] for (let i = 0; i < node.strokes.length; i++) { const paint = node.strokes[node.strokes.length - 1 - i] - if (paint.visible && paint.opacity !== 0) { + if (paint.visible !== false && paint.opacity !== 0) { paintCssList.push( paintToCSSSyncIfPossible(paint, node, i === node.strokes.length - 1) ?? (await paintToCSS(paint, node, i === node.strokes.length - 1)), diff --git a/src/codegen/props/selector.ts b/src/codegen/props/selector.ts index 830acec..91845ab 100644 --- a/src/codegen/props/selector.ts +++ b/src/codegen/props/selector.ts @@ -9,6 +9,7 @@ const selectorPropsCache = new Map< Promise<{ props: Record variants: Record + variantComments: Record }> >() @@ -61,27 +62,27 @@ function toTransitionPropertyName(key: string): string { return toKebabCase(mapped) } -// 속성 이름을 유효한 TypeScript 식별자로 변환 +// Convert property names to valid TypeScript identifiers const toUpperCase = (_: string, chr: string) => chr.toUpperCase() export function sanitizePropertyName(name: string): string { // 0. Strip Figma's internal "#nodeId:uniqueId" suffix (e.g., "leftIcon#60:123" → "leftIcon") const stripped = name.replace(/#\d+:\d+$/, '') - // 1. 한글 '속성'을 'property'로 변환 (공백 포함 처리: "속성1" → "property1") - const normalized = stripped.trim().replace(/속성\s*/g, 'property') // 한글 '속성' + 뒤따르는 공백을 'property'로 변환 + // 1. Replace Korean word for "property" with English equivalent (e.g., "속성1" → "property1") + const normalized = stripped.trim().replace(/속성\s*/g, 'property') - // 2. 공백과 특수문자를 처리하여 camelCase로 변환 + // 2. Convert spaces and special characters to camelCase const result = normalized - // 공백이나 특수문자 뒤의 문자를 대문자로 (camelCase 변환) + // Capitalize the character after spaces/hyphens/underscores (camelCase) .replace(/[\s\-_]+(.)/g, toUpperCase) - // 숫자로 시작하면 앞에 _ 추가 + // Prefix leading digits with underscore .replace(/^(\d)/, '_$1') - // 3. 유효하지 않은 문자 제거 (한글, 특수문자 등) + // 3. Remove invalid characters (Korean, special chars, etc.) const cleaned = result.replace(/[^\w$]/g, '') - // 4. 완전히 비어있거나 숫자로만 구성된 경우 기본값 사용 + // 4. Fall back to default name if empty or digits-only if (!cleaned || /^\d+$/.test(cleaned)) { return 'variant' } @@ -89,12 +90,31 @@ export function sanitizePropertyName(name: string): string { return cleaned } +/** + * If exactly 1 TEXT-type variant (type === 'string'), rename it to 'children' + * with 'React.ReactNode' type. Returns variantComments mapping 'children' to original name. + */ +export function applyTextChildrenTransform(variants: Record): { + variants: Record + variantComments: Record +} { + const textEntries = Object.entries(variants).filter(([, v]) => v === 'string') + if (textEntries.length !== 1) return { variants, variantComments: {} } + + const [originalKey] = textEntries[0] + const newVariants = { ...variants } + delete newVariants[originalKey] + newVariants.children = 'React.ReactNode' + return { variants: newVariants, variantComments: { children: originalKey } } +} + export async function getSelectorProps( node: ComponentSetNode | ComponentNode, ): Promise< | { props: Record variants: Record + variantComments: Record } | undefined > { @@ -119,12 +139,10 @@ export async function getSelectorProps( async function computeSelectorProps(node: ComponentSetNode): Promise<{ props: Record variants: Record + variantComments: Record }> { const hasEffect = !!node.componentPropertyDefinitions.effect const tSelector = perfStart() - console.info( - `[perf] getSelectorProps: processing ${node.children.length} children`, - ) // Pre-filter: only call expensive getProps() on children with non-default effects. // The effect/trigger check is a cheap property read — skip children that would be // discarded later anyway (effect === undefined or effect === 'default'). @@ -149,7 +167,8 @@ async function computeSelectorProps(node: ComponentSetNode): Promise<{ const result: { props: Record variants: Record - } = { props: {}, variants: {} } + variantComments: Record + } = { props: {}, variants: {}, variantComments: {} } const defs = node.componentPropertyDefinitions for (const name in defs) { if (name === 'effect' || name === 'viewport') continue @@ -168,6 +187,11 @@ async function computeSelectorProps(node: ComponentSetNode): Promise<{ } } + const { variants: transformedVariants, variantComments } = + applyTextChildrenTransform(result.variants) + result.variants = transformedVariants + result.variantComments = variantComments + if (components.length > 0) { const findNodeAction = (action: Action) => action.type === 'NODE' const getTransition = (reaction: Reaction) => @@ -276,9 +300,6 @@ async function computeSelectorPropsForGroup( if (!defaultComponent) return {} const tGroup = perfStart() - console.info( - `[perf] getSelectorPropsForGroup: processing ${matchingComponents.length} matching components`, - ) const defaultProps = await getProps(defaultComponent) const result: Record = {} const diffKeys = new Set() diff --git a/src/codegen/props/text-shadow.ts b/src/codegen/props/text-shadow.ts index a2a3f98..6576bc0 100644 --- a/src/codegen/props/text-shadow.ts +++ b/src/codegen/props/text-shadow.ts @@ -7,7 +7,7 @@ export function getTextShadowProps( ): Record | undefined { if (node.type !== 'TEXT') return - const effects = node.effects.filter((effect) => effect.visible) + const effects = node.effects.filter((effect) => effect.visible !== false) if (effects.length === 0) return const dropShadows = effects.filter((effect) => effect.type === 'DROP_SHADOW') if (dropShadows.length === 0) return diff --git a/src/codegen/props/text-stroke.ts b/src/codegen/props/text-stroke.ts index f09b64a..2f0d750 100644 --- a/src/codegen/props/text-stroke.ts +++ b/src/codegen/props/text-stroke.ts @@ -5,7 +5,7 @@ export async function getTextStrokeProps( ): Promise | undefined> { if (node.type !== 'TEXT') return - const strokes = node.strokes.filter((stroke) => stroke.visible) + const strokes = node.strokes.filter((stroke) => stroke.visible !== false) if (strokes.length === 0) return const solidStrokes = strokes.filter((stroke) => stroke.type === 'SOLID') // @todo support gradient stroke diff --git a/src/codegen/render/index.ts b/src/codegen/render/index.ts index 597cf91..ed95ae5 100644 --- a/src/codegen/render/index.ts +++ b/src/codegen/render/index.ts @@ -43,6 +43,7 @@ export function renderComponent( component: string, code: string, variants: Record, + comments?: Record, ) { // Single pass: collect variant entries, skipping 'effect' (reserved key) const variantEntries: [string, string][] = [] @@ -59,6 +60,9 @@ export function renderComponent( const keys: string[] = [] for (const [key, value] of variantEntries) { const optional = value === 'boolean' ? '?' : '' + if (comments?.[key]) { + interfaceLines.push(` /** ${comments[key]} */`) + } interfaceLines.push(` ${key}${optional}: ${value}`) keys.push(key) } diff --git a/src/codegen/responsive/ResponsiveCodegen.ts b/src/codegen/responsive/ResponsiveCodegen.ts index 6f3ed30..03eb456 100644 --- a/src/codegen/responsive/ResponsiveCodegen.ts +++ b/src/codegen/responsive/ResponsiveCodegen.ts @@ -1,5 +1,6 @@ import { Codegen } from '../Codegen' import { + applyTextChildrenTransform, getSelectorPropsForGroup, sanitizePropertyName, } from '../props/selector' @@ -431,6 +432,9 @@ export class ResponsiveCodegen { } } + const { variants: finalVariants, variantComments } = + applyTextChildrenTransform(variants) + // Group components by non-viewport, non-effect variants const groups = new Map>() @@ -519,7 +523,12 @@ export class ResponsiveCodegen { results.push([ componentName, - renderComponent(componentName, mergedCode, variants), + renderComponent( + componentName, + mergedCode, + finalVariants, + variantComments, + ), ] as const) } @@ -538,9 +547,6 @@ export class ResponsiveCodegen { componentSet: ComponentSetNode, componentName: string, ): Promise> { - console.info( - `[perf] generateVariantResponsiveComponents: ${componentName}, ${componentSet.children.length} children, ${Object.keys(componentSet.componentPropertyDefinitions).length} variant keys`, - ) const tTotal = perfStart() // Find viewport and effect variant keys @@ -584,6 +590,9 @@ export class ResponsiveCodegen { } } + const { variants: finalVariants, variantComments } = + applyTextChildrenTransform(variants) + // If effect variant only, generate code from defaultVariant with pseudo-selectors if (effectKey && !viewportKey && otherVariantKeys.length === 0) { const r = await ResponsiveCodegen.generateEffectOnlyComponents( @@ -600,7 +609,7 @@ export class ResponsiveCodegen { componentSet, componentName, otherVariantKeys, - variants, + finalVariants, ) perfEnd('generateVariantResponsiveComponents(total)', tTotal) return r @@ -742,7 +751,15 @@ export class ResponsiveCodegen { ) const result: Array = [ - [componentName, renderComponent(componentName, mergedCode, variants)], + [ + componentName, + renderComponent( + componentName, + mergedCode, + finalVariants, + variantComments, + ), + ], ] return result } @@ -788,8 +805,14 @@ export class ResponsiveCodegen { } } + const { variants: finalVariants, variantComments } = + applyTextChildrenTransform(variants) + const result: Array = [ - [componentName, renderComponent(componentName, code, variants)], + [ + componentName, + renderComponent(componentName, code, finalVariants, variantComments), + ], ] return result } diff --git a/src/codegen/types.ts b/src/codegen/types.ts index 18c382b..bb9088f 100644 --- a/src/codegen/types.ts +++ b/src/codegen/types.ts @@ -24,4 +24,5 @@ export interface ComponentTree { node: SceneNode tree: NodeTree variants: Record + variantComments?: Record } diff --git a/src/codegen/utils/check-asset-node.ts b/src/codegen/utils/check-asset-node.ts index 00e2941..8f0f3e9 100644 --- a/src/codegen/utils/check-asset-node.ts +++ b/src/codegen/utils/check-asset-node.ts @@ -45,7 +45,7 @@ export function checkAssetNode( // if node has tile, it is not an Image, it just has a tile background node.fills.find( (fill: Paint) => - fill.visible && + fill.visible !== false && (fill.type === 'PATTERN' || (fill.type === 'IMAGE' && fill.scaleMode === 'TILE')), ) @@ -55,7 +55,7 @@ export function checkAssetNode( ? 'fills' in node && Array.isArray(node.fills) ? node.fills.some( (fill: Paint) => - fill.visible && + fill.visible !== false && fill.type === 'IMAGE' && fill.scaleMode !== 'TILE', ) @@ -73,7 +73,13 @@ export function checkAssetNode( : nested && 'fills' in node && Array.isArray(node.fills) && - node.fills.every((fill) => fill.visible && fill.type === 'SOLID') + !node.fills.some( + (fill: Paint) => + fill.visible !== false && + (fill.type === 'IMAGE' || + fill.type === 'VIDEO' || + fill.type === 'PATTERN'), + ) ? 'svg' : null } @@ -87,18 +93,15 @@ export function checkAssetNode( node.paddingBottom > 0)) || ('fills' in node && (Array.isArray(node.fills) - ? node.fills.find((fill) => fill.visible) + ? node.fills.find((fill) => fill.visible !== false) : true)) ) return null return checkAssetNode(children[0], true) } - const fillterdChildren = children.filter((child) => child.visible) + const filteredChildren = children.filter((child) => child.visible) - // return children.every((child) => child.visible && checkAssetNode(child)) - // ? 'svg' - // : null - return fillterdChildren.every((child) => { + return filteredChildren.every((child) => { const result = checkAssetNode(child, true) if (result === null) return false return result === 'svg' diff --git a/src/codegen/utils/four-value-shortcut.ts b/src/codegen/utils/four-value-shortcut.ts index fba3301..51a9d7d 100644 --- a/src/codegen/utils/four-value-shortcut.ts +++ b/src/codegen/utils/four-value-shortcut.ts @@ -1,11 +1,17 @@ import { addPx } from './add-px' export function fourValueShortcut( - first: number, - second: number, - third: number, - fourth: number, + _first: number, + _second: number, + _third: number, + _fourth: number, ): string { + // Round to 2 decimal places to handle Figma floating-point imprecision + const first = Math.round(_first * 100) / 100 + const second = Math.round(_second * 100) / 100 + const third = Math.round(_third * 100) / 100 + const fourth = Math.round(_fourth * 100) / 100 + if (first === second && second === third && third === fourth) return addPx(first, '0') if (first === third && second === fourth) diff --git a/src/codegen/utils/get-component-name.ts b/src/codegen/utils/get-component-name.ts deleted file mode 100644 index 300024b..0000000 --- a/src/codegen/utils/get-component-name.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { toPascal } from '../../utils/to-pascal' - -export function getComponentName(node: SceneNode) { - if (node.type === 'COMPONENT_SET') return toPascal(node.name) - if (node.type === 'COMPONENT') - return toPascal( - node.parent?.type === 'COMPONENT_SET' ? node.parent.name : node.name, - ) - return toPascal(node.name) -} diff --git a/src/codegen/utils/optimize-space.ts b/src/codegen/utils/optimize-space.ts index 70c8b45..6d20b33 100644 --- a/src/codegen/utils/optimize-space.ts +++ b/src/codegen/utils/optimize-space.ts @@ -7,6 +7,13 @@ export function optimizeSpace( b: number, l: number, ): Record { + // Round to 2 decimal places to handle Figma floating-point imprecision + // (e.g. 1.5 vs 1.5000009536743164). Matches formatNumber's rounding. + t = Math.round(t * 100) / 100 + r = Math.round(r * 100) / 100 + b = Math.round(b * 100) / 100 + l = Math.round(l * 100) / 100 + if (t === r && r === b && b === l) { return { [type]: addPx(t) } } diff --git a/src/commands/exportComponents.ts b/src/commands/exportComponents.ts index d751ff3..d7376d4 100644 --- a/src/commands/exportComponents.ts +++ b/src/commands/exportComponents.ts @@ -3,6 +3,8 @@ import JSZip from 'jszip' import { Codegen } from '../codegen/Codegen' import { downloadFile } from '../utils/download-file' +const NOTIFY_TIMEOUT = 3000 + export async function exportComponents() { try { figma.notify('Exporting components...') @@ -26,7 +28,7 @@ export async function exportComponents() { } figma.notify(`Components exported ${componentCount} components`, { - timeout: 3000, + timeout: NOTIFY_TIMEOUT, }) const zip = new JSZip() for (const component of components) { @@ -42,12 +44,12 @@ export async function exportComponents() { await zip.generateAsync({ type: 'uint8array' }), ) figma.notify('Components exported', { - timeout: 3000, + timeout: NOTIFY_TIMEOUT, }) } catch (error) { console.error(error) figma.notify('Error exporting components', { - timeout: 3000, + timeout: NOTIFY_TIMEOUT, error: true, }) } diff --git a/src/commands/exportPagesAndComponents.ts b/src/commands/exportPagesAndComponents.ts index a7a7437..b3075eb 100644 --- a/src/commands/exportPagesAndComponents.ts +++ b/src/commands/exportPagesAndComponents.ts @@ -7,6 +7,8 @@ import { getComponentName } from '../utils' import { downloadFile } from '../utils/download-file' import { toPascal } from '../utils/to-pascal' +const NOTIFY_TIMEOUT = 3000 + export const DEVUP_COMPONENTS = [ 'Center', 'VStack', @@ -251,13 +253,13 @@ export async function exportPagesAndComponents() { notificationHandler.cancel() figma.notify( `Exported ${componentCount} components and ${pageCount} pages`, - { timeout: 3000 }, + { timeout: NOTIFY_TIMEOUT }, ) } catch (error) { console.error(error) notificationHandler.cancel() figma.notify('Error exporting pages and components', { - timeout: 3000, + timeout: NOTIFY_TIMEOUT, error: true, }) } diff --git a/ui.html b/ui.html deleted file mode 100644 index e7a4caf..0000000 --- a/ui.html +++ /dev/null @@ -1,36 +0,0 @@ -

Devup Design System Creator

- - - - -