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}${componentName}>`
+ const propsStr = filteredEntries
+ .map((e) => {
+ if (e.type === 'BOOLEAN') return e.key
+ return `${e.key}="${e.value}"`
+ })
+ .join(' ')
+ return `<${componentName} ${propsStr}>${textEntry.value}${componentName}>`
+ }
+
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}${componentName}>`
+ const propsStr = filteredEntries
+ .map((e) => {
+ if (e.type === 'BOOLEAN') return e.key
+ return `${e.key}="${e.value}"`
+ })
+ .join(' ')
+ return `<${componentName} ${propsStr}>${textEntry.value}${componentName}>`
+ }
+
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
-
-
-
-
-