diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index 297c206..c671dab 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -1,5 +1,18 @@ -import { describe, expect, it, test } from 'bun:test' -import { getComponentName, space } from '../utils' +import { afterEach, describe, expect, it, test } from 'bun:test' +import { + getComponentName, + propsToPropsWithTypography, + resetTextStyleCache, + space, +} from '../utils' + +// Minimal figma global for propsToPropsWithTypography tests +if (!(globalThis as { figma?: unknown }).figma) { + ;(globalThis as { figma?: unknown }).figma = { + getLocalTextStylesAsync: () => Promise.resolve([]), + getStyleByIdAsync: () => Promise.resolve(null), + } as unknown as typeof figma +} describe('space', () => { it('should create space', () => { @@ -9,6 +22,117 @@ describe('space', () => { }) }) +describe('propsToPropsWithTypography', () => { + afterEach(() => { + resetTextStyleCache() + }) + + it('should apply typography from resolved cache (sync fast path)', async () => { + const origGetLocal = figma.getLocalTextStylesAsync + const origGetStyle = figma.getStyleByIdAsync + figma.getLocalTextStylesAsync = () => + Promise.resolve([{ id: 'ts-1' } as unknown as TextStyle]) as ReturnType< + typeof figma.getLocalTextStylesAsync + > + figma.getStyleByIdAsync = (id: string) => + Promise.resolve( + id === 'ts-1' + ? ({ id: 'ts-1', name: 'Typography/Body' } as unknown as BaseStyle) + : null, + ) as ReturnType + + // First call: populates async caches + resolved caches via .then() + const r1 = await propsToPropsWithTypography( + { fontFamily: 'Arial', fontSize: 16, w: 100, h: 50 }, + 'ts-1', + ) + expect(r1.typography).toBe('body') + expect(r1.fontFamily).toBeUndefined() + expect(r1.w).toBeUndefined() + + // Second call: hits sync resolved-value cache (lines 71-72) + const r2 = await propsToPropsWithTypography( + { fontFamily: 'Inter', fontSize: 14, w: 200, h: 60 }, + 'ts-1', + ) + expect(r2.typography).toBe('body') + expect(r2.fontFamily).toBeUndefined() + expect(r2.w).toBeUndefined() + + figma.getLocalTextStylesAsync = origGetLocal + figma.getStyleByIdAsync = origGetStyle + }) + + it('should return early from sync path when textStyleId not in resolved set', async () => { + const origGetLocal = figma.getLocalTextStylesAsync + const origGetStyle = figma.getStyleByIdAsync + figma.getLocalTextStylesAsync = () => + Promise.resolve([{ id: 'ts-1' } as unknown as TextStyle]) as ReturnType< + typeof figma.getLocalTextStylesAsync + > + figma.getStyleByIdAsync = () => + Promise.resolve(null) as ReturnType + + // First call: populates resolved cache + await propsToPropsWithTypography( + { fontFamily: 'Arial', w: 100, h: 50 }, + 'ts-1', + ) + + // Second call with unknown textStyleId — hits else branch (lines 75-76) + const r = await propsToPropsWithTypography( + { fontFamily: 'Inter', w: 200, h: 60 }, + 'ts-unknown', + ) + expect(r.fontFamily).toBe('Inter') + expect(r.typography).toBeUndefined() + expect(r.w).toBeUndefined() + + // Third call with empty textStyleId — also hits else branch + const r2 = await propsToPropsWithTypography( + { fontFamily: 'Mono', w: 300, h: 70 }, + '', + ) + expect(r2.fontFamily).toBe('Mono') + expect(r2.typography).toBeUndefined() + + figma.getLocalTextStylesAsync = origGetLocal + figma.getStyleByIdAsync = origGetStyle + }) + + it('should return props without typography when style resolves to null', async () => { + const origGetLocal = figma.getLocalTextStylesAsync + const origGetStyle = figma.getStyleByIdAsync + figma.getLocalTextStylesAsync = () => + Promise.resolve([ + { id: 'ts-null' } as unknown as TextStyle, + ]) as ReturnType + figma.getStyleByIdAsync = () => + Promise.resolve(null) as ReturnType + + // First call: populates caches, style is null + const r1 = await propsToPropsWithTypography( + { fontFamily: 'Arial', fontSize: 16, w: 100, h: 50 }, + 'ts-null', + ) + expect(r1.typography).toBeUndefined() + expect(r1.fontFamily).toBe('Arial') + expect(r1.w).toBeUndefined() + + // Second call: sync path, styleByIdResolved has null → style is falsy, skip applyTypography + const r2 = await propsToPropsWithTypography( + { fontFamily: 'Inter', fontSize: 14, w: 200, h: 60 }, + 'ts-null', + ) + expect(r2.typography).toBeUndefined() + expect(r2.fontFamily).toBe('Inter') + expect(r2.w).toBeUndefined() + + figma.getLocalTextStylesAsync = origGetLocal + figma.getStyleByIdAsync = origGetStyle + }) +}) + describe('getComponentName', () => { test.each([ { diff --git a/src/code-impl.ts b/src/code-impl.ts index e550b86..0ad1f86 100644 --- a/src/code-impl.ts +++ b/src/code-impl.ts @@ -4,6 +4,7 @@ import { resetMainComponentCache, } from './codegen/Codegen' import { resetGetPropsCache } from './codegen/props' +import { resetChildAnimationCache } from './codegen/props/reaction' import { resetSelectorPropsCache } from './codegen/props/selector' import { ResponsiveCodegen } from './codegen/responsive/ResponsiveCodegen' import { nodeProxyTracker } from './codegen/utils/node-proxy' @@ -139,6 +140,7 @@ export function registerCodegen(ctx: typeof figma) { perfReset() resetGetPropsCache() resetSelectorPropsCache() + resetChildAnimationCache() resetVariableCache() resetTextStyleCache() resetMainComponentCache() diff --git a/src/codegen/Codegen.ts b/src/codegen/Codegen.ts index 4f1885d..14fe3ea 100644 --- a/src/codegen/Codegen.ts +++ b/src/codegen/Codegen.ts @@ -1,7 +1,7 @@ import { getComponentName } from '../utils' import { getProps } from './props' import { getPositionProps } from './props/position' -import { getSelectorProps } from './props/selector' +import { getSelectorProps, sanitizePropertyName } from './props/selector' import { getTransformProps } from './props/transform' import { renderComponent, renderNode } from './render' import { renderText } from './render/text' @@ -14,6 +14,7 @@ import { getDevupComponentByProps, } from './utils/get-devup-component' import { getPageNode } from './utils/get-page-node' +import { paddingLeftMultiline } from './utils/padding-left-multiline' import { perfEnd, perfStart } from './utils/perf' import { buildCssUrl } from './utils/wrap-url' @@ -54,18 +55,67 @@ export function resetGlobalBuildTreeCache(): void { } /** - * Clone a NodeTree — shallow-clone props at every level so mutations - * to one clone's props don't affect the cached original or other clones. + * Get componentPropertyReferences from a node (if available). + */ +function getPropertyRefs(node: SceneNode): Record | undefined { + if ( + 'componentPropertyReferences' in node && + node.componentPropertyReferences + ) { + return node.componentPropertyReferences as Record + } + return undefined +} + +/** + * Check if a child node is bound to an INSTANCE_SWAP component property. + * Returns the sanitized slot name if it is, undefined otherwise. + */ +function getInstanceSwapSlotName( + node: SceneNode, + swapSlots: Map, +): string | undefined { + if (swapSlots.size === 0) return undefined + const refs = getPropertyRefs(node) + if (refs?.mainComponent && swapSlots.has(refs.mainComponent)) { + return swapSlots.get(refs.mainComponent) + } + return undefined +} + +/** + * Check if a child node is controlled by a BOOLEAN component property (visibility). + * Returns the sanitized boolean prop name if it is, undefined otherwise. + */ +function getBooleanConditionName( + node: SceneNode, + booleanSlots: Map, +): string | undefined { + if (booleanSlots.size === 0) return undefined + const refs = getPropertyRefs(node) + if (refs?.visible && booleanSlots.has(refs.visible)) { + return booleanSlots.get(refs.visible) + } + return undefined +} + +/** + * Shallow-clone a NodeTree — creates a new object so that per-instance + * property reassignment (e.g., `tree.props = { ...tree.props, ...selectorProps }`) + * doesn't leak across Codegen instances. Props object itself is shared by + * reference — callers that need to merge create their own objects. */ function cloneTree(tree: NodeTree): NodeTree { return { component: tree.component, - props: { ...tree.props }, - children: tree.children.map(cloneTree), + props: tree.props, + children: tree.children, nodeType: tree.nodeType, nodeName: tree.nodeName, isComponent: tree.isComponent, - textChildren: tree.textChildren ? [...tree.textChildren] : undefined, + isSlot: tree.isSlot, + condition: tree.condition, + textChildren: tree.textChildren, } } @@ -105,13 +155,12 @@ export class Codegen { } getComponentsCodes() { - return Array.from(this.components.values()).map( - ({ node, code, variants }) => - [ - getComponentName(node), - renderComponent(getComponentName(node), code, variants), - ] as const, - ) + const result: Array = [] + for (const { node, code, variants } of this.components.values()) { + const name = getComponentName(node) + result.push([name, renderComponent(name, code, variants)]) + } + return result } /** @@ -119,7 +168,9 @@ export class Codegen { * Useful for generating responsive codes for each component. */ getComponentNodes() { - return Array.from(this.components.values()).map(({ node }) => node) + const result: SceneNode[] = [] + for (const { node } of this.components.values()) result.push(node) + return result } /** @@ -176,9 +227,11 @@ export class Codegen { // Returns a CLONE because downstream code mutates tree.props. const globalCached = globalBuildTreeCache.get(cacheKey) if (globalCached) { - const cloned = globalCached.then(cloneTree) - this.buildTreeCache.set(cacheKey, cloned) - return cloned + const resolved = await globalCached + const cloned = cloneTree(resolved) + const clonedPromise = Promise.resolve(cloned) + this.buildTreeCache.set(cacheKey, clonedPromise) + return clonedPromise } } const promise = this.doBuildTree(node) @@ -311,14 +364,17 @@ export class Codegen { } // Now await props (likely already resolved while children were processing) - const props = await propsPromise + const baseProps = await propsPromise - // Handle TEXT nodes + // Handle TEXT nodes — create NEW merged object instead of mutating getProps() result. let textChildren: string[] | undefined + let props: Record if (node.type === 'TEXT') { const { children: textContent, props: textProps } = await renderText(node) textChildren = textContent - Object.assign(props, textProps) + props = { ...baseProps, ...textProps } + } else { + props = baseProps } const component = getDevupComponentByNode(node, props) @@ -388,16 +444,52 @@ export class Codegen { const t = perfStart() const selectorPropsPromise = getSelectorProps(node) - // Build children sequentially (same reasoning as doBuildTree). + // Collect INSTANCE_SWAP and BOOLEAN property definitions for slot/condition detection. + const parentSet = node.parent?.type === 'COMPONENT_SET' ? node.parent : null + const propDefs = + parentSet?.componentPropertyDefinitions || + node.componentPropertyDefinitions || + {} + const instanceSwapSlots = new Map() + const booleanSlots = 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)) + } + } + + // Build children sequentially, replacing INSTANCE_SWAP targets with slot placeholders + // and wrapping BOOLEAN-controlled children with conditional rendering. const childrenTrees: NodeTree[] = [] if ('children' in node) { for (const child of node.children) { - childrenTrees.push(await this.buildTree(child)) + const slotName = getInstanceSwapSlotName(child, instanceSwapSlots) + if (slotName) { + const conditionName = getBooleanConditionName(child, booleanSlots) + childrenTrees.push({ + component: slotName, + props: {}, + children: [], + nodeType: 'SLOT', + nodeName: child.name, + isSlot: true, + condition: conditionName, + }) + } else { + const tree = await this.buildTree(child) + const conditionName = getBooleanConditionName(child, booleanSlots) + if (conditionName) { + tree.condition = conditionName + } + childrenTrees.push(tree) + } } } // Await props + selectorProps (likely already resolved while children built) - const [props, selectorProps] = await Promise.all([ + const [baseProps, selectorProps] = await Promise.all([ propsPromise, selectorPropsPromise, ]) @@ -405,8 +497,12 @@ export class Codegen { const variants: Record = {} + // Create a NEW merged object instead of mutating getProps() result. + // This allows getProps cache to return raw references without cloning. + const props = selectorProps + ? { ...baseProps, ...selectorProps.props } + : baseProps if (selectorProps) { - Object.assign(props, selectorProps.props) Object.assign(variants, selectorProps.variants) } @@ -430,9 +526,11 @@ export class Codegen { */ hasViewportVariant(): boolean { if (this.node.type !== 'COMPONENT_SET') return false - return Object.keys( - (this.node as ComponentSetNode).componentPropertyDefinitions, - ).some((key) => key.toLowerCase() === 'viewport') + for (const key in (this.node as ComponentSetNode) + .componentPropertyDefinitions) { + if (key.toLowerCase() === 'viewport') return true + } + return false } /** @@ -440,15 +538,33 @@ export class Codegen { * Static method so it can be used independently. */ static renderTree(tree: NodeTree, depth: number = 0): string { - // Handle TEXT nodes with textChildren + // Handle INSTANCE_SWAP slot placeholders — render as {propName} + if (tree.isSlot) { + if (tree.condition) { + return `{${tree.condition} && ${tree.component}}` + } + return `{${tree.component}}` + } + + // Render the core JSX + let result: string if (tree.textChildren && tree.textChildren.length > 0) { - return renderNode(tree.component, tree.props, depth, tree.textChildren) + result = renderNode(tree.component, tree.props, depth, tree.textChildren) + } else { + const childrenCodes = tree.children.map((child) => + Codegen.renderTree(child, 0), + ) + result = renderNode(tree.component, tree.props, depth, childrenCodes) } - // Children are rendered with depth 0 because renderNode handles indentation internally - const childrenCodes = tree.children.map((child) => - Codegen.renderTree(child, 0), - ) - return renderNode(tree.component, tree.props, depth, childrenCodes) + // Wrap with BOOLEAN conditional rendering if needed + if (tree.condition) { + if (result.includes('\n')) { + return `{${tree.condition} && (\n${paddingLeftMultiline(result, 1)}\n)}` + } + return `{${tree.condition} && ${result}}` + } + + return result } } diff --git a/src/codegen/__tests__/__snapshots__/codegen.test.ts.snap b/src/codegen/__tests__/__snapshots__/codegen.test.ts.snap index ac4179b..c8d6f68 100644 --- a/src/codegen/__tests__/__snapshots__/codegen.test.ts.snap +++ b/src/codegen/__tests__/__snapshots__/codegen.test.ts.snap @@ -672,9 +672,55 @@ exports[`Codegen renders component set with variants 1`] = ` " `; -exports[`Codegen renders component set with variants: component: [object Object] 1`] = ` +exports[`Codegen renders component set with variants: component: Default 1`] = ` { "name": "Button", + "node": { + "children": [], + "name": "Default", + "parent": { + "children": [ + [Circular], + { + "children": [], + "name": "Hover", + "parent": [Circular], + "reactions": [ + { + "actions": [], + "trigger": { + "type": "ON_HOVER", + }, + }, + ], + "type": "COMPONENT", + "variantProperties": { + "state": "hover", + }, + "visible": true, + }, + ], + "componentPropertyDefinitions": { + "state": { + "type": "VARIANT", + "variantOptions": [ + "default", + "hover", + ], + }, + }, + "defaultVariant": [Circular], + "name": "Button", + "type": "COMPONENT_SET", + "visible": true, + }, + "reactions": [], + "type": "COMPONENT", + "variantProperties": { + "state": "default", + }, + "visible": true, + }, "tree": { "children": [], "component": "Box", @@ -704,9 +750,48 @@ exports[`Codegen renders component set with effect property 1`] = ` " `; -exports[`Codegen renders component set with effect property: component: [object Object] 1`] = ` +exports[`Codegen renders component set with effect property: component: Default 1`] = ` { "name": "Button", + "node": { + "children": [], + "name": "Default", + "parent": { + "children": [ + [Circular], + { + "children": [], + "name": "Hover", + "parent": [Circular], + "reactions": [], + "type": "COMPONENT", + "variantProperties": { + "effect": "hover", + }, + "visible": true, + }, + ], + "componentPropertyDefinitions": { + "effect": { + "type": "VARIANT", + "variantOptions": [ + "default", + "hover", + ], + }, + }, + "defaultVariant": [Circular], + "name": "Button", + "type": "COMPONENT_SET", + "visible": true, + }, + "reactions": [], + "type": "COMPONENT", + "variantProperties": { + "effect": "default", + }, + "visible": true, + }, "tree": { "children": [], "component": "Box", @@ -734,9 +819,72 @@ exports[`Codegen renders component set with transition 1`] = ` " `; -exports[`Codegen renders component set with transition: component: [object Object] 1`] = ` +exports[`Codegen renders component set with transition: component: Default 1`] = ` { "name": "Button", + "node": { + "children": [], + "name": "Default", + "parent": { + "children": [ + [Circular], + { + "children": [], + "name": "Hover", + "parent": [Circular], + "reactions": [ + { + "actions": [ + { + "transition": { + "duration": 0.3, + "easing": { + "type": "EASE_IN_OUT", + }, + "type": "SMART_ANIMATE", + }, + "type": "NODE", + }, + ], + "trigger": { + "type": "ON_HOVER", + }, + }, + ], + "type": "COMPONENT", + "variantProperties": {}, + "visible": true, + }, + ], + "componentPropertyDefinitions": {}, + "defaultVariant": [Circular], + "name": "Button", + "type": "COMPONENT_SET", + "visible": true, + }, + "reactions": [ + { + "actions": [ + { + "transition": { + "duration": 0.3, + "easing": { + "type": "EASE_IN_OUT", + }, + "type": "SMART_ANIMATE", + }, + "type": "NODE", + }, + ], + "trigger": { + "type": "ON_HOVER", + }, + }, + ], + "type": "COMPONENT", + "variantProperties": {}, + "visible": true, + }, "tree": { "children": [], "component": "Box", @@ -764,9 +912,73 @@ exports[`Codegen renders component set with different props and transition 1`] = " `; -exports[`Codegen renders component set with different props and transition: component: [object Object] 1`] = ` +exports[`Codegen renders component set with different props and transition: component: Default 1`] = ` { "name": "Card", + "node": { + "children": [], + "name": "Default", + "parent": { + "children": [ + [Circular], + { + "children": [], + "name": "Hover", + "opacity": 0.8, + "parent": [Circular], + "reactions": [ + { + "actions": [ + { + "transition": { + "duration": 0.3, + "easing": { + "type": "EASE_IN_OUT", + }, + "type": "SMART_ANIMATE", + }, + "type": "NODE", + }, + ], + "trigger": { + "type": "ON_HOVER", + }, + }, + ], + "type": "COMPONENT", + "variantProperties": {}, + "visible": true, + }, + ], + "componentPropertyDefinitions": {}, + "defaultVariant": [Circular], + "name": "Card", + "type": "COMPONENT_SET", + "visible": true, + }, + "reactions": [ + { + "actions": [ + { + "transition": { + "duration": 0.3, + "easing": { + "type": "EASE_IN_OUT", + }, + "type": "SMART_ANIMATE", + }, + "type": "NODE", + }, + ], + "trigger": { + "type": "ON_HOVER", + }, + }, + ], + "type": "COMPONENT", + "variantProperties": {}, + "visible": true, + }, "tree": { "children": [], "component": "Box", @@ -794,49 +1006,153 @@ exports[`Codegen renders component set with different props and transition: comp exports[`Codegen renders component with parent component set 1`] = `""`; -exports[`Codegen renders component with parent component set: component: [object Object] 1`] = ` -{ - "name": "Button", - "tree": { - "children": [], - "component": "Box", - "nodeName": "Hover", - "nodeType": "COMPONENT", - "props": { - "aspectRatio": undefined, - "flex": undefined, - "h": undefined, - "maxH": undefined, - "maxW": undefined, - "minH": undefined, - "minW": undefined, - "w": undefined, - }, - }, - "variants": { - "state": "'default' | 'hover'", - }, -} -`; - -exports[`Codegen renders component set with press trigger 1`] = ` -" - - -" -`; - -exports[`Codegen renders component set with press trigger: component: [object Object] 1`] = ` +exports[`Codegen renders component with parent component set: component: Hover 1`] = ` { "name": "Button", - "tree": { + "node": { "children": [], - "component": "Box", - "nodeName": "Default", - "nodeType": "COMPONENT", - "props": { - "aspectRatio": undefined, - "flex": undefined, + "name": "Hover", + "parent": { + "children": [ + { + "children": [], + "name": "Default", + "reactions": [], + "type": "COMPONENT", + "variantProperties": { + "state": "default", + }, + "visible": true, + }, + { + "children": [], + "name": "Hover", + "reactions": [ + { + "actions": [], + "trigger": { + "type": "ON_HOVER", + }, + }, + ], + "type": "COMPONENT", + "variantProperties": { + "state": "hover", + }, + "visible": true, + }, + ], + "componentPropertyDefinitions": { + "state": { + "type": "VARIANT", + "variantOptions": [ + "default", + "hover", + ], + }, + }, + "defaultVariant": { + "children": [], + "name": "Default", + "reactions": [], + "type": "COMPONENT", + "variantProperties": { + "state": "default", + }, + "visible": true, + }, + "name": "Button", + "type": "COMPONENT_SET", + "visible": true, + }, + "reactions": [ + { + "actions": [], + "trigger": { + "type": "ON_HOVER", + }, + }, + ], + "type": "COMPONENT", + "variantProperties": { + "state": "hover", + }, + "visible": true, + }, + "tree": { + "children": [], + "component": "Box", + "nodeName": "Hover", + "nodeType": "COMPONENT", + "props": { + "aspectRatio": undefined, + "flex": undefined, + "h": undefined, + "maxH": undefined, + "maxW": undefined, + "minH": undefined, + "minW": undefined, + "w": undefined, + }, + }, + "variants": { + "state": "'default' | 'hover'", + }, +} +`; + +exports[`Codegen renders component set with press trigger 1`] = ` +" + + +" +`; + +exports[`Codegen renders component set with press trigger: component: Default 1`] = ` +{ + "name": "Button", + "node": { + "children": [], + "name": "Default", + "parent": { + "children": [ + [Circular], + { + "children": [], + "name": "Active", + "parent": [Circular], + "reactions": [ + { + "actions": [], + "trigger": { + "type": "ON_PRESS", + }, + }, + ], + "type": "COMPONENT", + "variantProperties": {}, + "visible": true, + }, + ], + "componentPropertyDefinitions": {}, + "defaultVariant": [Circular], + "name": "Button", + "type": "COMPONENT_SET", + "visible": true, + }, + "reactions": [], + "type": "COMPONENT", + "variantProperties": {}, + "visible": true, + }, + "tree": { + "children": [], + "component": "Box", + "nodeName": "Default", + "nodeType": "COMPONENT", + "props": { + "aspectRatio": undefined, + "flex": undefined, "h": undefined, "maxH": undefined, "maxW": undefined, @@ -851,9 +1167,15 @@ exports[`Codegen renders component set with press trigger: component: [object Ob exports[`Codegen renders simple component without variants 2`] = `""`; -exports[`Codegen renders simple component without variants: component: [object Object] 1`] = ` +exports[`Codegen renders simple component without variants: component: Icon 1`] = ` { "name": "Icon", + "node": { + "children": [], + "name": "Icon", + "type": "COMPONENT", + "visible": true, + }, "tree": { "children": [], "component": "Box", @@ -875,9 +1197,28 @@ exports[`Codegen renders simple component without variants: component: [object O exports[`Codegen renders component with parent component set name 1`] = `""`; -exports[`Codegen renders component with parent component set name: component: [object Object] 1`] = ` +exports[`Codegen renders component with parent component set name: component: Hover 1`] = ` { "name": "Button", + "node": { + "children": [], + "name": "Hover", + "parent": { + "children": [], + "componentPropertyDefinitions": {}, + "defaultVariant": { + "children": [], + "name": "Default", + "type": "COMPONENT", + "visible": true, + }, + "name": "Button", + "type": "COMPONENT_SET", + "visible": true, + }, + "type": "COMPONENT", + "visible": true, + }, "tree": { "children": [], "component": "Box", @@ -898,6 +1239,112 @@ exports[`Codegen renders component with parent component set name: component: [o } `; +exports[`Codegen Tree Methods INSTANCE_SWAP / BOOLEAN snapshot renders component with INSTANCE_SWAP slot: INSTANCE_SWAP slot: Icon 1`] = ` +"export function Icon() { + return +}" +`; + +exports[`Codegen Tree Methods INSTANCE_SWAP / BOOLEAN snapshot renders component with INSTANCE_SWAP slot: INSTANCE_SWAP slot: IconButton 1`] = ` +"export interface IconButtonProps { + size: 'sm' | 'md' | 'lg' + leftIcon: React.ReactNode +} + +export function IconButton({ size, leftIcon }: IconButtonProps) { + return ( + + {leftIcon} + + Click + + + ) +}" +`; + +exports[`Codegen Tree Methods INSTANCE_SWAP / BOOLEAN snapshot renders component with BOOLEAN conditional: BOOLEAN conditional: AlertCard 1`] = ` +"export interface AlertCardProps { + State: 'Default' | 'Hover' + showBadge?: boolean +} + +export function AlertCard({ State, showBadge }: AlertCardProps) { + return ( + + {showBadge && } + + Hello + + + ) +}" +`; + +exports[`Codegen Tree Methods INSTANCE_SWAP / BOOLEAN snapshot renders component with INSTANCE_SWAP + BOOLEAN combined: INSTANCE_SWAP+BOOLEAN: Icon 1`] = ` +"export function Icon() { + return +}" +`; + +exports[`Codegen Tree Methods INSTANCE_SWAP / BOOLEAN snapshot renders component with INSTANCE_SWAP + BOOLEAN combined: INSTANCE_SWAP+BOOLEAN: Button 1`] = ` +"export interface ButtonProps { + size: 'sm' | 'md' | 'lg' + leftIcon: React.ReactNode + showLeftIcon?: boolean + rightIcon: React.ReactNode + showRightIcon?: boolean +} + +export function Button({ size, leftIcon, showLeftIcon, rightIcon, showRightIcon }: ButtonProps) { + return ( + + {showLeftIcon && leftIcon} + + Submit + + {showRightIcon && rightIcon} + + ) +}" +`; + +exports[`Codegen Tree Methods INSTANCE_SWAP / BOOLEAN snapshot renders component with INSTANCE_SWAP + BOOLEAN combined: INSTANCE_SWAP+BOOLEAN: ArrowIcon 1`] = ` +"export function ArrowIcon() { + return +}" +`; + exports[`render real world component real world $ 1`] = ` "export function BorderRadius() { return @@ -3473,536 +3920,3 @@ exports[`render real world component real world $ 109`] = ` ) }" `; - -exports[`Codegen renders component set with variants: component: Default 1`] = ` -{ - "name": "Button", - "node": { - "children": [], - "name": "Default", - "parent": { - "children": [ - [Circular], - { - "children": [], - "name": "Hover", - "parent": [Circular], - "reactions": [ - { - "actions": [], - "trigger": { - "type": "ON_HOVER", - }, - }, - ], - "type": "COMPONENT", - "variantProperties": { - "state": "hover", - }, - "visible": true, - }, - ], - "componentPropertyDefinitions": { - "state": { - "type": "VARIANT", - "variantOptions": [ - "default", - "hover", - ], - }, - }, - "defaultVariant": [Circular], - "name": "Button", - "type": "COMPONENT_SET", - "visible": true, - }, - "reactions": [], - "type": "COMPONENT", - "variantProperties": { - "state": "default", - }, - "visible": true, - }, - "tree": { - "children": [], - "component": "Box", - "nodeName": "Default", - "nodeType": "COMPONENT", - "props": { - "aspectRatio": undefined, - "flex": undefined, - "h": undefined, - "maxH": undefined, - "maxW": undefined, - "minH": undefined, - "minW": undefined, - "w": undefined, - }, - }, - "variants": { - "state": "'default' | 'hover'", - }, -} -`; - -exports[`Codegen renders component set with effect property: component: Default 1`] = ` -{ - "name": "Button", - "node": { - "children": [], - "name": "Default", - "parent": { - "children": [ - [Circular], - { - "children": [], - "name": "Hover", - "parent": [Circular], - "reactions": [], - "type": "COMPONENT", - "variantProperties": { - "effect": "hover", - }, - "visible": true, - }, - ], - "componentPropertyDefinitions": { - "effect": { - "type": "VARIANT", - "variantOptions": [ - "default", - "hover", - ], - }, - }, - "defaultVariant": [Circular], - "name": "Button", - "type": "COMPONENT_SET", - "visible": true, - }, - "reactions": [], - "type": "COMPONENT", - "variantProperties": { - "effect": "default", - }, - "visible": true, - }, - "tree": { - "children": [], - "component": "Box", - "nodeName": "Default", - "nodeType": "COMPONENT", - "props": { - "aspectRatio": undefined, - "flex": undefined, - "h": undefined, - "maxH": undefined, - "maxW": undefined, - "minH": undefined, - "minW": undefined, - "w": undefined, - }, - }, - "variants": {}, -} -`; - -exports[`Codegen renders component set with transition: component: Default 1`] = ` -{ - "name": "Button", - "node": { - "children": [], - "name": "Default", - "parent": { - "children": [ - [Circular], - { - "children": [], - "name": "Hover", - "parent": [Circular], - "reactions": [ - { - "actions": [ - { - "transition": { - "duration": 0.3, - "easing": { - "type": "EASE_IN_OUT", - }, - "type": "SMART_ANIMATE", - }, - "type": "NODE", - }, - ], - "trigger": { - "type": "ON_HOVER", - }, - }, - ], - "type": "COMPONENT", - "variantProperties": {}, - "visible": true, - }, - ], - "componentPropertyDefinitions": {}, - "defaultVariant": [Circular], - "name": "Button", - "type": "COMPONENT_SET", - "visible": true, - }, - "reactions": [ - { - "actions": [ - { - "transition": { - "duration": 0.3, - "easing": { - "type": "EASE_IN_OUT", - }, - "type": "SMART_ANIMATE", - }, - "type": "NODE", - }, - ], - "trigger": { - "type": "ON_HOVER", - }, - }, - ], - "type": "COMPONENT", - "variantProperties": {}, - "visible": true, - }, - "tree": { - "children": [], - "component": "Box", - "nodeName": "Default", - "nodeType": "COMPONENT", - "props": { - "aspectRatio": undefined, - "flex": undefined, - "h": undefined, - "maxH": undefined, - "maxW": undefined, - "minH": undefined, - "minW": undefined, - "w": undefined, - }, - }, - "variants": {}, -} -`; - -exports[`Codegen renders component set with different props and transition: component: Default 1`] = ` -{ - "name": "Card", - "node": { - "children": [], - "name": "Default", - "parent": { - "children": [ - [Circular], - { - "children": [], - "name": "Hover", - "opacity": 0.8, - "parent": [Circular], - "reactions": [ - { - "actions": [ - { - "transition": { - "duration": 0.3, - "easing": { - "type": "EASE_IN_OUT", - }, - "type": "SMART_ANIMATE", - }, - "type": "NODE", - }, - ], - "trigger": { - "type": "ON_HOVER", - }, - }, - ], - "type": "COMPONENT", - "variantProperties": {}, - "visible": true, - }, - ], - "componentPropertyDefinitions": {}, - "defaultVariant": [Circular], - "name": "Card", - "type": "COMPONENT_SET", - "visible": true, - }, - "reactions": [ - { - "actions": [ - { - "transition": { - "duration": 0.3, - "easing": { - "type": "EASE_IN_OUT", - }, - "type": "SMART_ANIMATE", - }, - "type": "NODE", - }, - ], - "trigger": { - "type": "ON_HOVER", - }, - }, - ], - "type": "COMPONENT", - "variantProperties": {}, - "visible": true, - }, - "tree": { - "children": [], - "component": "Box", - "nodeName": "Default", - "nodeType": "COMPONENT", - "props": { - "_hover": { - "opacity": "0.8", - }, - "aspectRatio": undefined, - "flex": undefined, - "h": undefined, - "maxH": undefined, - "maxW": undefined, - "minH": undefined, - "minW": undefined, - "transition": "0.3ms ease-in-out", - "transitionProperty": "opacity", - "w": undefined, - }, - }, - "variants": {}, -} -`; - -exports[`Codegen renders component with parent component set: component: Hover 1`] = ` -{ - "name": "Button", - "node": { - "children": [], - "name": "Hover", - "parent": { - "children": [ - { - "children": [], - "name": "Default", - "reactions": [], - "type": "COMPONENT", - "variantProperties": { - "state": "default", - }, - "visible": true, - }, - { - "children": [], - "name": "Hover", - "reactions": [ - { - "actions": [], - "trigger": { - "type": "ON_HOVER", - }, - }, - ], - "type": "COMPONENT", - "variantProperties": { - "state": "hover", - }, - "visible": true, - }, - ], - "componentPropertyDefinitions": { - "state": { - "type": "VARIANT", - "variantOptions": [ - "default", - "hover", - ], - }, - }, - "defaultVariant": { - "children": [], - "name": "Default", - "reactions": [], - "type": "COMPONENT", - "variantProperties": { - "state": "default", - }, - "visible": true, - }, - "name": "Button", - "type": "COMPONENT_SET", - "visible": true, - }, - "reactions": [ - { - "actions": [], - "trigger": { - "type": "ON_HOVER", - }, - }, - ], - "type": "COMPONENT", - "variantProperties": { - "state": "hover", - }, - "visible": true, - }, - "tree": { - "children": [], - "component": "Box", - "nodeName": "Hover", - "nodeType": "COMPONENT", - "props": { - "aspectRatio": undefined, - "flex": undefined, - "h": undefined, - "maxH": undefined, - "maxW": undefined, - "minH": undefined, - "minW": undefined, - "w": undefined, - }, - }, - "variants": { - "state": "'default' | 'hover'", - }, -} -`; - -exports[`Codegen renders component set with press trigger: component: Default 1`] = ` -{ - "name": "Button", - "node": { - "children": [], - "name": "Default", - "parent": { - "children": [ - [Circular], - { - "children": [], - "name": "Active", - "parent": [Circular], - "reactions": [ - { - "actions": [], - "trigger": { - "type": "ON_PRESS", - }, - }, - ], - "type": "COMPONENT", - "variantProperties": {}, - "visible": true, - }, - ], - "componentPropertyDefinitions": {}, - "defaultVariant": [Circular], - "name": "Button", - "type": "COMPONENT_SET", - "visible": true, - }, - "reactions": [], - "type": "COMPONENT", - "variantProperties": {}, - "visible": true, - }, - "tree": { - "children": [], - "component": "Box", - "nodeName": "Default", - "nodeType": "COMPONENT", - "props": { - "aspectRatio": undefined, - "flex": undefined, - "h": undefined, - "maxH": undefined, - "maxW": undefined, - "minH": undefined, - "minW": undefined, - "w": undefined, - }, - }, - "variants": {}, -} -`; - -exports[`Codegen renders simple component without variants: component: Icon 1`] = ` -{ - "name": "Icon", - "node": { - "children": [], - "name": "Icon", - "type": "COMPONENT", - "visible": true, - }, - "tree": { - "children": [], - "component": "Box", - "nodeName": "Icon", - "nodeType": "COMPONENT", - "props": { - "aspectRatio": undefined, - "boxSize": "100%", - "flex": undefined, - "maxH": undefined, - "maxW": undefined, - "minH": undefined, - "minW": undefined, - }, - }, - "variants": {}, -} -`; - -exports[`Codegen renders component with parent component set name: component: Hover 1`] = ` -{ - "name": "Button", - "node": { - "children": [], - "name": "Hover", - "parent": { - "children": [], - "componentPropertyDefinitions": {}, - "defaultVariant": { - "children": [], - "name": "Default", - "type": "COMPONENT", - "visible": true, - }, - "name": "Button", - "type": "COMPONENT_SET", - "visible": true, - }, - "type": "COMPONENT", - "visible": true, - }, - "tree": { - "children": [], - "component": "Box", - "nodeName": "Hover", - "nodeType": "COMPONENT", - "props": { - "aspectRatio": undefined, - "flex": undefined, - "h": undefined, - "maxH": undefined, - "maxW": undefined, - "minH": undefined, - "minW": undefined, - "w": undefined, - }, - }, - "variants": {}, -} -`; diff --git a/src/codegen/__tests__/__snapshots__/render.test.ts.snap b/src/codegen/__tests__/__snapshots__/render.test.ts.snap new file mode 100644 index 0000000..4605e1d --- /dev/null +++ b/src/codegen/__tests__/__snapshots__/render.test.ts.snap @@ -0,0 +1,55 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`renderComponent interface snapshot VARIANT + BOOLEAN + INSTANCE_SWAP mixed interface 1`] = ` +"export interface ButtonProps { + leftIcon: React.ReactNode + showLeftIcon?: boolean + rightIcon: React.ReactNode + showRightIcon?: boolean + size: 'lg' | 'md' | 'sm' + variant: 'primary' | 'ghost' | 'disabled' +} + +export function Button({ leftIcon, showLeftIcon, rightIcon, showRightIcon, size, variant }: ButtonProps) { + return ( +
+ {leftIcon} + buttonLg + {rightIcon} +
+ ) +}" +`; + +exports[`renderComponent interface snapshot BOOLEAN-only interface (all optional) 1`] = ` +"export interface CardProps { + showBadge?: boolean + showIcon?: boolean +} + +export function Card({ showBadge, showIcon }: CardProps) { + return ( + + {showBadge && } + {showIcon && } + + ) +}" +`; + +exports[`renderComponent interface snapshot INSTANCE_SWAP-only interface (all required) 1`] = ` +"export interface IconButtonProps { + leftIcon: React.ReactNode + rightIcon: React.ReactNode +} + +export function IconButton({ leftIcon, rightIcon }: IconButtonProps) { + return ( + + {leftIcon} + label + {rightIcon} + + ) +}" +`; diff --git a/src/codegen/__tests__/codegen.test.ts b/src/codegen/__tests__/codegen.test.ts index 04c3a69..a55b4ef 100644 --- a/src/codegen/__tests__/codegen.test.ts +++ b/src/codegen/__tests__/codegen.test.ts @@ -3,6 +3,7 @@ import { getComponentName } from '../../utils' import { toPascal } from '../../utils/to-pascal' import { Codegen } from '../Codegen' import { ResponsiveCodegen } from '../responsive/ResponsiveCodegen' +import type { NodeTree } from '../types' import { assembleNodeTree, type NodeData } from '../utils/node-proxy' import { wrapComponent } from '../utils/wrap-component' @@ -3649,6 +3650,180 @@ describe('Codegen Tree Methods', () => { const componentTrees = codegen.getComponentTrees() expect(componentTrees.size).toBe(2) // ParentComp and NestedComp }) + + test('detects INSTANCE_SWAP slots via componentPropertyReferences', async () => { + const iconChild = { + type: 'INSTANCE', + name: 'IconInstance', + visible: true, + componentPropertyReferences: { mainComponent: 'leftIcon#60:123' }, + getMainComponentAsync: async () => + ({ + type: 'COMPONENT', + name: 'Icon', + children: [], + visible: true, + }) as unknown as ComponentNode, + } as unknown as InstanceNode + + const defaultVariant = { + type: 'COMPONENT', + name: 'State=Default', + children: [iconChild], + visible: true, + reactions: [], + } as unknown as ComponentNode + + const node = { + type: 'COMPONENT_SET', + name: 'ButtonWithSlot', + children: [defaultVariant], + defaultVariant, + visible: true, + componentPropertyDefinitions: { + 'leftIcon#60:123': { + type: 'INSTANCE_SWAP', + defaultValue: '1:1', + preferredValues: [], + }, + }, + } as unknown as ComponentSetNode + addParent(node) + + const codegen = new Codegen(node) + await codegen.buildTree() + + const componentTrees = codegen.getComponentTrees() + expect(componentTrees.size).toBeGreaterThan(0) + + // Find the ButtonWithSlot component tree + const compTree = [...componentTrees.values()].find( + (ct) => ct.name === 'ButtonWithSlot', + ) + expect(compTree).toBeDefined() + + // The icon child should be turned into a slot placeholder + const slotChild = compTree?.tree.children.find((c) => c.isSlot) + expect(slotChild).toBeDefined() + expect(slotChild?.component).toBe('leftIcon') + expect(slotChild?.nodeType).toBe('SLOT') + }) + + test('detects BOOLEAN conditions via componentPropertyReferences', async () => { + const conditionalChild = { + type: 'FRAME', + name: 'ConditionalFrame', + children: [], + visible: true, + componentPropertyReferences: { visible: 'showIcon#70:456' }, + strokes: [], + effects: [], + reactions: [], + } as unknown as FrameNode + + const defaultVariant = { + type: 'COMPONENT', + name: 'State=Default', + children: [conditionalChild], + visible: true, + reactions: [], + } as unknown as ComponentNode + + const node = { + type: 'COMPONENT_SET', + name: 'ButtonWithBool', + children: [defaultVariant], + defaultVariant, + visible: true, + componentPropertyDefinitions: { + 'showIcon#70:456': { + type: 'BOOLEAN', + defaultValue: true, + }, + }, + } 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 === 'ButtonWithBool', + ) + expect(compTree).toBeDefined() + + // The conditional child should have a condition set + const condChild = compTree?.tree.children.find((c) => c.condition) + expect(condChild).toBeDefined() + expect(condChild?.condition).toBe('showIcon') + }) + + test('detects combined INSTANCE_SWAP + BOOLEAN slot with condition', async () => { + const iconChild = { + type: 'INSTANCE', + name: 'IconInstance', + visible: true, + componentPropertyReferences: { + mainComponent: 'leftIcon#60:123', + visible: 'showIcon#70:456', + }, + getMainComponentAsync: async () => + ({ + type: 'COMPONENT', + name: 'Icon', + children: [], + visible: true, + }) as unknown as ComponentNode, + } as unknown as InstanceNode + + const defaultVariant = { + type: 'COMPONENT', + name: 'State=Default', + children: [iconChild], + visible: true, + reactions: [], + } as unknown as ComponentNode + + const node = { + type: 'COMPONENT_SET', + name: 'ButtonWithSlotAndBool', + children: [defaultVariant], + defaultVariant, + visible: true, + componentPropertyDefinitions: { + 'leftIcon#60:123': { + type: 'INSTANCE_SWAP', + defaultValue: '1:1', + preferredValues: [], + }, + 'showIcon#70:456': { + type: 'BOOLEAN', + defaultValue: true, + }, + }, + } 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 === 'ButtonWithSlotAndBool', + ) + expect(compTree).toBeDefined() + + // Should be a slot with condition + const slotChild = compTree?.tree.children.find((c) => c.isSlot) + expect(slotChild).toBeDefined() + expect(slotChild?.component).toBe('leftIcon') + expect(slotChild?.condition).toBe('showIcon') + + // renderTree should produce {showIcon && leftIcon} + const rendered = Codegen.renderTree(slotChild as NodeTree) + expect(rendered).toBe('{showIcon && leftIcon}') + }) }) describe('renderTree (static)', () => { @@ -3756,6 +3931,284 @@ describe('Codegen Tree Methods', () => { const result = Codegen.renderTree(tree) expect(result).toContain(' { + const tree: NodeTree = { + component: 'Flex', + props: { gap: '10px' }, + children: [ + { + component: 'Box', + props: { w: '20px' }, + children: [], + nodeType: 'FRAME', + nodeName: 'Inner', + }, + ], + nodeType: 'FRAME', + nodeName: 'ConditionalWrapper', + condition: 'showContent', + } + + const result = Codegen.renderTree(tree) + // Multi-line result should be indented inside the conditional + expect(result).toMatch(/^\{showContent && \(\n {2} { + const tree: NodeTree = { + component: 'Box', + props: {}, + children: [], + nodeType: 'FRAME', + nodeName: 'SimpleConditional', + condition: 'showBox', + } + + const result = Codegen.renderTree(tree) + expect(result).toBe('{showBox && }') + }) + }) + + describe('INSTANCE_SWAP / BOOLEAN snapshot', () => { + test('renders component with INSTANCE_SWAP slot', async () => { + const iconChild = { + type: 'INSTANCE', + name: 'IconInstance', + visible: true, + componentPropertyReferences: { mainComponent: 'leftIcon#60:1' }, + getMainComponentAsync: async () => + ({ + type: 'COMPONENT', + name: 'Icon', + children: [], + visible: true, + }) as unknown as ComponentNode, + } as unknown as InstanceNode + + const textChild = { + type: 'TEXT', + name: 'Label', + visible: true, + characters: 'Click', + getStyledTextSegments: () => [createTextSegment('Click')], + strokes: [], + effects: [], + reactions: [], + textAutoResize: 'WIDTH_AND_HEIGHT', + } as unknown as TextNode + + const defaultVariant = { + type: 'COMPONENT', + name: 'size=md', + children: [iconChild, textChild], + visible: true, + reactions: [], + variantProperties: { size: 'md' }, + } as unknown as ComponentNode + + const node = { + type: 'COMPONENT_SET', + name: 'IconButton', + children: [defaultVariant], + defaultVariant, + visible: true, + componentPropertyDefinitions: { + size: { + type: 'VARIANT', + variantOptions: ['sm', 'md', 'lg'], + }, + 'leftIcon#60:1': { + type: 'INSTANCE_SWAP', + defaultValue: '1:1', + preferredValues: [], + }, + }, + } as unknown as ComponentSetNode + addParent(node) + + const codegen = new Codegen(node) + await codegen.run() + + for (const [name, code] of codegen.getComponentsCodes()) { + expect(code).toMatchSnapshot(`INSTANCE_SWAP slot: ${name}`) + } + }) + + test('renders component with BOOLEAN conditional', async () => { + const conditionalChild = { + type: 'FRAME', + name: 'Badge', + children: [], + visible: true, + componentPropertyReferences: { visible: 'showBadge#70:1' }, + strokes: [], + effects: [], + reactions: [], + } as unknown as FrameNode + + const textChild = { + type: 'TEXT', + name: 'Label', + visible: true, + characters: 'Hello', + getStyledTextSegments: () => [createTextSegment('Hello')], + strokes: [], + effects: [], + reactions: [], + textAutoResize: 'WIDTH_AND_HEIGHT', + } as unknown as TextNode + + const defaultVariant = { + type: 'COMPONENT', + name: 'State=Default', + children: [conditionalChild, textChild], + visible: true, + reactions: [], + variantProperties: { State: 'Default' }, + } as unknown as ComponentNode + + const hoverVariant = { + type: 'COMPONENT', + name: 'State=Hover', + children: [], + visible: true, + reactions: [], + variantProperties: { State: 'Hover' }, + fills: [ + { + type: 'SOLID', + visible: true, + color: { r: 0.9, g: 0.9, b: 0.9 }, + opacity: 1, + }, + ], + } as unknown as ComponentNode + + const node = { + type: 'COMPONENT_SET', + name: 'AlertCard', + children: [defaultVariant, hoverVariant], + defaultVariant, + visible: true, + componentPropertyDefinitions: { + State: { + type: 'VARIANT', + variantOptions: ['Default', 'Hover'], + }, + 'showBadge#70:1': { + type: 'BOOLEAN', + defaultValue: true, + }, + }, + } as unknown as ComponentSetNode + addParent(node) + + const codegen = new Codegen(node) + await codegen.run() + + for (const [name, code] of codegen.getComponentsCodes()) { + expect(code).toMatchSnapshot(`BOOLEAN conditional: ${name}`) + } + }) + + test('renders component with INSTANCE_SWAP + BOOLEAN combined', async () => { + const leftIconChild = { + type: 'INSTANCE', + name: 'LeftIconInstance', + visible: true, + componentPropertyReferences: { + mainComponent: 'leftIcon#60:1', + visible: 'showLeftIcon#70:1', + }, + getMainComponentAsync: async () => + ({ + type: 'COMPONENT', + name: 'Icon', + children: [], + visible: true, + }) as unknown as ComponentNode, + } as unknown as InstanceNode + + const rightIconChild = { + type: 'INSTANCE', + name: 'RightIconInstance', + visible: true, + componentPropertyReferences: { + mainComponent: 'rightIcon#60:2', + visible: 'showRightIcon#70:2', + }, + getMainComponentAsync: async () => + ({ + type: 'COMPONENT', + name: 'ArrowIcon', + children: [], + visible: true, + }) as unknown as ComponentNode, + } as unknown as InstanceNode + + const textChild = { + type: 'TEXT', + name: 'Label', + visible: true, + characters: 'Submit', + getStyledTextSegments: () => [createTextSegment('Submit')], + strokes: [], + effects: [], + reactions: [], + textAutoResize: 'WIDTH_AND_HEIGHT', + } as unknown as TextNode + + const defaultVariant = { + type: 'COMPONENT', + name: 'size=md', + children: [leftIconChild, textChild, rightIconChild], + visible: true, + reactions: [], + variantProperties: { size: 'md' }, + } as unknown as ComponentNode + + const node = { + type: 'COMPONENT_SET', + name: 'Button', + children: [defaultVariant], + defaultVariant, + visible: true, + componentPropertyDefinitions: { + size: { + type: 'VARIANT', + variantOptions: ['sm', 'md', 'lg'], + }, + 'leftIcon#60:1': { + type: 'INSTANCE_SWAP', + defaultValue: '1:1', + preferredValues: [], + }, + 'showLeftIcon#70:1': { + type: 'BOOLEAN', + defaultValue: true, + }, + 'rightIcon#60:2': { + type: 'INSTANCE_SWAP', + defaultValue: '1:2', + preferredValues: [], + }, + 'showRightIcon#70:2': { + type: 'BOOLEAN', + defaultValue: true, + }, + }, + } as unknown as ComponentSetNode + addParent(node) + + const codegen = new Codegen(node) + await codegen.run() + + for (const [name, code] of codegen.getComponentsCodes()) { + expect(code).toMatchSnapshot(`INSTANCE_SWAP+BOOLEAN: ${name}`) + } + }) }) describe('getSelectorProps with numeric property names', () => { diff --git a/src/codegen/__tests__/render.test.ts b/src/codegen/__tests__/render.test.ts index 5ee6e1a..5c8e767 100644 --- a/src/codegen/__tests__/render.test.ts +++ b/src/codegen/__tests__/render.test.ts @@ -152,6 +152,25 @@ export function Banner({ size }: BannerProps) { ) +}`, + }, + { + title: 'renders boolean variants as optional in interface', + component: 'Button', + code: '
', + variants: { + leftIcon: 'boolean', + size: '"sm" | "lg"', + rightIcon: 'boolean', + } as Record, + expected: `export interface ButtonProps { + leftIcon?: boolean + size: "sm" | "lg" + rightIcon?: boolean +} + +export function Button({ leftIcon, size, rightIcon }: ButtonProps) { + return
}`, }, ])('$title', ({ component, code, variants, expected }) => { @@ -159,3 +178,50 @@ export function Banner({ size }: BannerProps) { expect(result).toBe(expected) }) }) + +describe('renderComponent interface snapshot', () => { + test('VARIANT + BOOLEAN + INSTANCE_SWAP mixed interface', () => { + const code = `
+ {leftIcon} + buttonLg + {rightIcon} +
` + const variants: Record = { + leftIcon: 'React.ReactNode', + showLeftIcon: 'boolean', + rightIcon: 'React.ReactNode', + showRightIcon: 'boolean', + size: "'lg' | 'md' | 'sm'", + variant: "'primary' | 'ghost' | 'disabled'", + } + const result = renderComponent('Button', code, variants) + expect(result).toMatchSnapshot() + }) + + test('BOOLEAN-only interface (all optional)', () => { + const code = ` + {showBadge && } + {showIcon && } +` + const variants: Record = { + showBadge: 'boolean', + showIcon: 'boolean', + } + const result = renderComponent('Card', code, variants) + expect(result).toMatchSnapshot() + }) + + test('INSTANCE_SWAP-only interface (all required)', () => { + const code = ` + {leftIcon} + label + {rightIcon} +` + const variants: Record = { + leftIcon: 'React.ReactNode', + rightIcon: 'React.ReactNode', + } + const result = renderComponent('IconButton', code, variants) + expect(result).toMatchSnapshot() + }) +}) diff --git a/src/codegen/props/__tests__/selector.test.ts b/src/codegen/props/__tests__/selector.test.ts index 771a358..49fc83b 100644 --- a/src/codegen/props/__tests__/selector.test.ts +++ b/src/codegen/props/__tests__/selector.test.ts @@ -111,6 +111,83 @@ describe('getSelectorProps', () => { expect(result?.variants.State).toBe("'Default'") }) + test('includes BOOLEAN properties as boolean in variants', async () => { + const defaultVariant = { + type: 'COMPONENT', + name: 'size=Default', + children: [], + visible: true, + reactions: [], + variantProperties: { size: 'Default' }, + } as unknown as ComponentNode + + const node = { + type: 'COMPONENT_SET', + name: 'ButtonWithToggle', + children: [defaultVariant], + defaultVariant, + visible: true, + componentPropertyDefinitions: { + size: { + type: 'VARIANT', + variantOptions: ['Default', 'Small'], + }, + 'showIcon#70:123': { + type: 'BOOLEAN', + defaultValue: true, + }, + }, + } as unknown as ComponentSetNode + + const result = await getSelectorProps(node) + + expect(result).toBeDefined() + expect(result?.variants.size).toBe("'Default' | 'Small'") + expect(result?.variants.showIcon).toBe('boolean') + }) + + test('includes INSTANCE_SWAP properties as React.ReactNode in variants', async () => { + const defaultVariant = { + type: 'COMPONENT', + name: 'size=Default', + children: [], + visible: true, + reactions: [], + variantProperties: { size: 'Default' }, + } as unknown as ComponentNode + + const node = { + type: 'COMPONENT_SET', + name: 'ButtonWithIcon', + children: [defaultVariant], + defaultVariant, + visible: true, + componentPropertyDefinitions: { + size: { + type: 'VARIANT', + variantOptions: ['Default', 'Small'], + }, + 'leftIcon#60:123': { + type: 'INSTANCE_SWAP', + defaultValue: 'comp_abc', + preferredValues: [], + }, + 'rightIcon#61:456': { + type: 'INSTANCE_SWAP', + defaultValue: 'comp_def', + preferredValues: [], + }, + }, + } as unknown as ComponentSetNode + + const result = await getSelectorProps(node) + + expect(result).toBeDefined() + expect(result?.variants.size).toBe("'Default' | 'Small'") + expect(result?.variants.leftIcon).toBe('React.ReactNode') + expect(result?.variants.rightIcon).toBe('React.ReactNode') + }) + describe('sanitizePropertyName', () => { test('returns variant for numeric-only property name', async () => { const defaultVariant = { diff --git a/src/codegen/props/auto-layout.ts b/src/codegen/props/auto-layout.ts index 1e9141b..669be7d 100644 --- a/src/codegen/props/auto-layout.ts +++ b/src/codegen/props/auto-layout.ts @@ -1,19 +1,22 @@ +import type { NodeContext } from '../types' import { addPx } from '../utils/add-px' import { checkAssetNode } from '../utils/check-asset-node' export function getAutoLayoutProps( node: SceneNode, + ctx?: NodeContext, ): Record | undefined { if ( !('inferredAutoLayout' in node) || !node.inferredAutoLayout || node.inferredAutoLayout.layoutMode === 'NONE' || - checkAssetNode(node) + (ctx ? ctx.isAsset !== null : !!checkAssetNode(node)) ) return const { layoutMode } = node.inferredAutoLayout if (layoutMode === 'GRID') return getGridProps(node) - const childrenCount = node.children.filter((c) => c.visible).length + let childrenCount = 0 + for (const c of node.children) if (c.visible) childrenCount++ return { display: { HORIZONTAL: 'flex', diff --git a/src/codegen/props/background.ts b/src/codegen/props/background.ts index 581e929..1355681 100644 --- a/src/codegen/props/background.ts +++ b/src/codegen/props/background.ts @@ -1,5 +1,5 @@ import { BLEND_MODE_MAP } from '../utils/blend-mode-map' -import { paintToCSS } from '../utils/paint-to-css' +import { paintToCSS, paintToCSSSyncIfPossible } from '../utils/paint-to-css' export async function getBackgroundProps( node: SceneNode, @@ -21,7 +21,9 @@ export async function getBackgroundProps( for (let i = 0; i < node.fills.length; i++) { const fill = node.fills[node.fills.length - 1 - i] if (fill.opacity === 0 || !fill.visible) continue - const cssFill = await paintToCSS(fill, node, i === node.fills.length - 1) + const cssFill = + paintToCSSSyncIfPossible(fill, node, i === node.fills.length - 1) ?? + (await paintToCSS(fill, node, i === node.fills.length - 1)) if ( fill.type === 'SOLID' && fill.blendMode && diff --git a/src/codegen/props/border.ts b/src/codegen/props/border.ts index 471f4c4..6b98099 100644 --- a/src/codegen/props/border.ts +++ b/src/codegen/props/border.ts @@ -1,6 +1,6 @@ import { addPx } from '../utils/add-px' import { fourValueShortcut } from '../utils/four-value-shortcut' -import { paintToCSS } from '../utils/paint-to-css' +import { paintToCSS, paintToCSSSyncIfPossible } from '../utils/paint-to-css' export function getBorderRadiusProps( node: SceneNode, @@ -45,7 +45,8 @@ export async function getBorderProps( const paint = node.strokes[node.strokes.length - 1 - i] if (paint.visible && paint.opacity !== 0) { paintCssList.push( - await paintToCSS(paint, node, i === node.strokes.length - 1), + paintToCSSSyncIfPossible(paint, node, i === node.strokes.length - 1) ?? + (await paintToCSS(paint, node, i === node.strokes.length - 1)), ) } } diff --git a/src/codegen/props/effect.ts b/src/codegen/props/effect.ts index 30c900e..b556639 100644 --- a/src/codegen/props/effect.ts +++ b/src/codegen/props/effect.ts @@ -2,9 +2,9 @@ import { optimizeHex } from '../../utils/optimize-hex' import { rgbaToHex } from '../../utils/rgba-to-hex' import { addPx } from '../utils/add-px' -export async function getEffectProps( +export function getEffectProps( node: SceneNode, -): Promise | undefined> { +): Record | undefined { if ('effects' in node && node.effects.length > 0) { return node.effects.reduce( (acc, effect) => { diff --git a/src/codegen/props/grid-child.ts b/src/codegen/props/grid-child.ts index e98840d..76125e2 100644 --- a/src/codegen/props/grid-child.ts +++ b/src/codegen/props/grid-child.ts @@ -5,6 +5,9 @@ export function getGridChildProps( 'gridColumnAnchorIndex' in node && 'gridRowAnchorIndex' in node && node.parent && + // COMPONENT_SET uses grid layout internally to arrange variants — + // those grid positions are not CSS styles of the component itself. + node.parent.type !== 'COMPONENT_SET' && 'inferredAutoLayout' in node.parent && node.parent.inferredAutoLayout && node.parent.inferredAutoLayout.layoutMode === 'GRID' diff --git a/src/codegen/props/index.ts b/src/codegen/props/index.ts index 334aaf6..df58362 100644 --- a/src/codegen/props/index.ts +++ b/src/codegen/props/index.ts @@ -1,3 +1,7 @@ +import type { NodeContext } from '../types' +import { checkAssetNode } from '../utils/check-asset-node' +import { getPageNode } from '../utils/get-page-node' +import { isPageRoot } from '../utils/is-page-root' import { perfEnd, perfStart } from '../utils/perf' import { getAutoLayoutProps } from './auto-layout' import { getBackgroundProps } from './background' @@ -12,7 +16,7 @@ import { getMaxLineProps } from './max-line' import { getObjectFitProps } from './object-fit' import { getOverflowProps } from './overflow' import { getPaddingProps } from './padding' -import { getPositionProps } from './position' +import { canBeAbsolute, getPositionProps } from './position' import { getReactionProps } from './reaction' import { getTextAlignProps } from './text-align' import { getTextShadowProps } from './text-shadow' @@ -20,15 +24,31 @@ import { getTextStrokeProps } from './text-stroke' import { getTransformProps } from './transform' import { getVisibilityProps } from './visibility' +export function computeNodeContext(node: SceneNode): NodeContext { + const asset = checkAssetNode(node) + const pageNode = getPageNode( + node as BaseNode & ChildrenMixin, + ) as SceneNode | null + const pageRoot = isPageRoot(node) + return { + isAsset: asset, + canBeAbsolute: canBeAbsolute(node), + isPageRoot: pageRoot, + pageNode, + } +} + // Cache getProps() results keyed by node.id to avoid redundant computation. // Figma returns new JS wrapper objects for the same node on each property access, // so object-reference keys don't work — node.id is the stable identifier. // For a COMPONENT_SET with N variants, getProps() is called O(N²) without caching // because getSelectorProps/getSelectorPropsForGroup call it on overlapping node sets. const getPropsCache = new Map>>() +const getPropsResolved = new Map>() export function resetGetPropsCache(): void { getPropsCache.clear() + getPropsResolved.clear() } export async function getProps( @@ -36,11 +56,17 @@ export async function getProps( ): Promise> { const cacheKey = node.id if (cacheKey) { + // Sync fast path: return raw reference from resolved cache (no clone needed — + // all callers that need to merge selectorProps create their own objects now). + const resolved = getPropsResolved.get(cacheKey) + if (resolved) { + perfEnd('getProps(cached)', perfStart()) + return resolved + } const cached = getPropsCache.get(cacheKey) if (cached) { perfEnd('getProps(cached)', perfStart()) - // Return a shallow clone to prevent mutation of cached values - return { ...(await cached) } + return await cached } } @@ -48,18 +74,17 @@ export async function getProps( const promise = (async () => { const isText = node.type === 'TEXT' + // Compute cross-cutting node context ONCE for all sync getters that need it. + const ctx = computeNodeContext(node) + // PHASE 1: Fire all async prop getters — initiates Figma IPC calls immediately. // These return Promises that resolve when IPC completes. const tBorder = perfStart() const borderP = getBorderProps(node) const tBg = perfStart() const bgP = getBackgroundProps(node) - const tEffect = perfStart() - const effectP = getEffectProps(node) const tTextStroke = perfStart() const textStrokeP = isText ? getTextStrokeProps(node) : undefined - const tTextShadow = perfStart() - const textShadowP = isText ? getTextShadowProps(node) : undefined const tReaction = perfStart() const reactionP = getReactionProps(node) @@ -68,9 +93,9 @@ export async function getProps( // Compute sync results eagerly; they'll be interleaved in the original merge // order below to preserve "last-key-wins" semantics. const tSync = perfStart() - const autoLayoutProps = getAutoLayoutProps(node) + const autoLayoutProps = getAutoLayoutProps(node, ctx) const minMaxProps = getMinMaxProps(node) - const layoutProps = getLayoutProps(node) + const layoutProps = getLayoutProps(node, ctx) const borderRadiusProps = getBorderRadiusProps(node) const blendProps = getBlendProps(node) const paddingProps = getPaddingProps(node) @@ -78,137 +103,75 @@ export async function getProps( const objectFitProps = getObjectFitProps(node) const maxLineProps = isText ? getMaxLineProps(node) : undefined const ellipsisProps = isText ? getEllipsisProps(node) : undefined - const positionProps = getPositionProps(node) + const tEffect = perfStart() + const effectProps = getEffectProps(node) + perfEnd('getProps.effect', tEffect) + const positionProps = getPositionProps(node, ctx) const gridChildProps = getGridChildProps(node) - const transformProps = getTransformProps(node) + const transformProps = getTransformProps(node, ctx) const overflowProps = getOverflowProps(node) + const tTextShadow = perfStart() + const textShadowProps = isText ? getTextShadowProps(node) : undefined + perfEnd('getProps.textShadow', tTextShadow) const cursorProps = getCursorProps(node) const visibilityProps = getVisibilityProps(node) perfEnd('getProps.sync', tSync) // PHASE 3: Await async results — likely already resolved during sync phase. - const [ - borderProps, - backgroundProps, - effectProps, - textStrokeProps, - textShadowProps, - reactionProps, - ] = await Promise.all([ - borderP.then((r) => { - perfEnd('getProps.border', tBorder) - return r - }), - bgP.then((r) => { - perfEnd('getProps.background', tBg) - return r - }), - effectP.then((r) => { - perfEnd('getProps.effect', tEffect) - return r - }), - textStrokeP - ? textStrokeP.then((r) => { - perfEnd('getProps.textStroke', tTextStroke) - return r - }) - : undefined, - textShadowP - ? textShadowP.then((r) => { - perfEnd('getProps.textShadow', tTextShadow) - return r - }) - : undefined, - reactionP.then((r) => { - perfEnd('getProps.reaction', tReaction) - return r - }), - ]) + // Sequential await: all 4 promises are already in-flight, so this just + // picks up resolved values in order without Promise.all + .then() overhead. + const borderProps = await borderP + perfEnd('getProps.border', tBorder) + const backgroundProps = await bgP + perfEnd('getProps.background', tBg) + const textStrokeProps = textStrokeP ? await textStrokeP : undefined + if (textStrokeP) perfEnd('getProps.textStroke', tTextStroke) + const reactionProps = await reactionP + perfEnd('getProps.reaction', tReaction) // PHASE 4: Merge in the ORIGINAL interleaved order to preserve last-key-wins. // async results (border, background, effect, textStroke, textShadow, reaction) // are placed at their original positions relative to sync getters. - return { - ...autoLayoutProps, - ...minMaxProps, - ...layoutProps, - ...borderRadiusProps, - ...borderProps, - ...backgroundProps, - ...blendProps, - ...paddingProps, - ...textAlignProps, - ...objectFitProps, - ...maxLineProps, - ...ellipsisProps, - ...effectProps, - ...positionProps, - ...gridChildProps, - ...transformProps, - ...overflowProps, - ...textStrokeProps, - ...textShadowProps, - ...reactionProps, - ...cursorProps, - ...visibilityProps, - } + const result: Record = {} + Object.assign( + result, + autoLayoutProps, + minMaxProps, + layoutProps, + borderRadiusProps, + ) + Object.assign( + result, + borderProps, + backgroundProps, + blendProps, + paddingProps, + ) + if (textAlignProps) Object.assign(result, textAlignProps) + Object.assign(result, objectFitProps) + if (maxLineProps) Object.assign(result, maxLineProps) + if (ellipsisProps) Object.assign(result, ellipsisProps) + Object.assign( + result, + effectProps, + positionProps, + gridChildProps, + transformProps, + ) + Object.assign(result, overflowProps) + if (textStrokeProps) Object.assign(result, textStrokeProps) + if (textShadowProps) Object.assign(result, textShadowProps) + Object.assign(result, reactionProps, cursorProps, visibilityProps) + return result })() if (cacheKey) { getPropsCache.set(cacheKey, promise) } const result = await promise + if (cacheKey) { + getPropsResolved.set(cacheKey, result) + } perfEnd('getProps()', t) return result } - -export function filterPropsWithComponent( - component: string, - props: Record, -): Record { - const newProps: Record = {} - for (const [key, value] of Object.entries(props)) { - switch (component) { - case 'Flex': - // Only skip display/flexDir if it's exactly the default value (not responsive array) - if (key === 'display' && value === 'flex') continue - if (key === 'flexDir' && value === 'row') continue - break - case 'Grid': - // Only skip display if it's exactly 'grid' (not responsive array or other value) - if (key === 'display' && value === 'grid') continue - break - case 'Center': - if (['alignItems', 'justifyContent'].includes(key)) continue - if (key === 'display' && value === 'flex') continue - if (key === 'flexDir' && value === 'row') continue - break - case 'VStack': - // Only skip flexDir if it's exactly 'column' (not responsive array or other value) - if (key === 'flexDir' && value === 'column') continue - if (key === 'display' && value === 'flex') continue - break - - case 'Image': - case 'Box': - if (component === 'Box' && !('maskImage' in props)) break - if ( - [ - 'alignItems', - 'justifyContent', - 'flexDir', - 'gap', - 'outline', - 'outlineOffset', - 'overflow', - ].includes(key) - ) - continue - if (key === 'display' && value === 'flex') continue - if (!('maskImage' in props) && ['bg'].includes(key)) continue - break - } - newProps[key] = value - } - return newProps -} diff --git a/src/codegen/props/layout.ts b/src/codegen/props/layout.ts index 369a28c..0d298ed 100644 --- a/src/codegen/props/layout.ts +++ b/src/codegen/props/layout.ts @@ -1,3 +1,4 @@ +import type { NodeContext } from '../types' import { addPx } from '../utils/add-px' import { checkAssetNode } from '../utils/check-asset-node' import { getPageNode } from '../utils/get-page-node' @@ -17,8 +18,9 @@ export function getMinMaxProps( export function getLayoutProps( node: SceneNode, + ctx?: NodeContext, ): Record { - const ret = _getLayoutProps(node) + const ret = _getLayoutProps(node, ctx) if (ret.w && ret.h === ret.w) { ret.boxSize = ret.w delete ret.w @@ -45,15 +47,16 @@ function _getTextLayoutProps( function _getLayoutProps( node: SceneNode, + ctx?: NodeContext, ): Record { - if (canBeAbsolute(node)) { + if (ctx ? ctx.canBeAbsolute : canBeAbsolute(node)) { return { w: node.type === 'TEXT' || (node.parent && 'width' in node.parent && node.parent.width > node.width) - ? checkAssetNode(node) || + ? (ctx ? ctx.isAsset !== null : !!checkAssetNode(node)) || ('children' in node && node.children.length === 0) ? addPx(node.width) : undefined @@ -77,7 +80,9 @@ function _getLayoutProps( } const aspectRatio = 'targetAspectRatio' in node ? node.targetAspectRatio : undefined - const rootNode = getPageNode(node as BaseNode & ChildrenMixin) + const rootNode = ctx + ? ctx.pageNode + : getPageNode(node as BaseNode & ChildrenMixin) return { aspectRatio: aspectRatio diff --git a/src/codegen/props/position.ts b/src/codegen/props/position.ts index 034e4f1..999ebd5 100644 --- a/src/codegen/props/position.ts +++ b/src/codegen/props/position.ts @@ -1,3 +1,4 @@ +import type { NodeContext } from '../types' import { addPx } from '../utils/add-px' import { checkAssetNode } from '../utils/check-asset-node' import { isPageRoot } from '../utils/is-page-root' @@ -25,8 +26,13 @@ export function canBeAbsolute(node: SceneNode): boolean { export function getPositionProps( node: SceneNode, + ctx?: NodeContext, ): Record | undefined { - if ('parent' in node && node.parent && canBeAbsolute(node)) { + if ( + 'parent' in node && + node.parent && + (ctx ? ctx.canBeAbsolute : canBeAbsolute(node)) + ) { const constraints = 'constraints' in node ? node.constraints @@ -105,7 +111,7 @@ export function getPositionProps( } if ( 'children' in node && - !checkAssetNode(node) && + (ctx ? ctx.isAsset === null : !checkAssetNode(node)) && (node.children.some( (child) => 'layoutPositioning' in child && child.layoutPositioning === 'ABSOLUTE', @@ -115,7 +121,7 @@ export function getPositionProps( (child) => 'layoutPositioning' in child && child.layoutPositioning === 'AUTO', ))) && - !isPageRoot(node) + !(ctx ? ctx.isPageRoot : isPageRoot(node)) ) { return { pos: 'relative', diff --git a/src/codegen/props/reaction.ts b/src/codegen/props/reaction.ts index 7004f63..4763fc4 100644 --- a/src/codegen/props/reaction.ts +++ b/src/codegen/props/reaction.ts @@ -1,6 +1,6 @@ import { fmtPct } from '../utils/fmtPct' import { isPageRoot } from '../utils/is-page-root' -import { solidToString } from '../utils/solid-to-string' +import { solidToString, solidToStringSync } from '../utils/solid-to-string' interface KeyframeData { [percentage: string]: Record @@ -24,6 +24,10 @@ const childAnimationCache = new Map< Map> >() +export function resetChildAnimationCache(): void { + childAnimationCache.clear() +} + // Format duration/delay values (up to 3 decimal places, remove trailing zeros) function fmtDuration(n: number): string { return (Math.round(n * 1000) / 1000) @@ -434,8 +438,11 @@ async function generateChildAnimations( childrenByName.set(child.name, child) }) - // For each child, build its individual keyframes across the animation chain - for (const [childName] of childrenByName) { + // Parallelize per-child animation building — each child's diff is independent. + // Even with single-threaded Figma IPC, Promise.all allows microtask interleaving + // between awaits, overlapping computation with I/O. + const childEntries: (readonly [string, Record] | null)[] = [] + for (const childName of childrenByName.keys()) { const keyframes: KeyframeData = {} let accumulatedTime = 0 @@ -515,9 +522,10 @@ async function generateChildAnimations( if (startChild) { // Get the first step's matching child - const firstStep = chain[0] - if ('children' in firstStep.node) { - const firstChildren = firstStep.node.children as readonly SceneNode[] + const firstStepNode = chain[0] + if ('children' in firstStepNode.node) { + const firstChildren = firstStepNode.node + .children as readonly SceneNode[] const firstChild = firstChildren.find((c) => c.name === childName) if (firstChild) { @@ -638,7 +646,7 @@ async function generateChildAnimations( keyframes['100%'] = finalKeyframe } - // If this child has changes, add animation props + // If this child has changes, return animation props if (hasChanges && Object.keys(keyframes).length > 1) { const firstEasing = firstStep.easing || { type: 'LINEAR' } const delay = chain[0].delay @@ -660,7 +668,15 @@ async function generateChildAnimations( props.animationIterationCount = 'infinite' } - childAnimationsMap.set(childName, props) + childEntries.push([childName, props] as const) + continue + } + childEntries.push(null) + } + + for (const entry of childEntries) { + if (entry) { + childAnimationsMap.set(entry[0], entry[1]) } } @@ -730,7 +746,7 @@ async function generateSingleNodeDifferences( toFill.type === 'SOLID' && !isSameColor(fromFill.color, toFill.color) ) { - changes.bg = await solidToString(toFill) + changes.bg = solidToStringSync(toFill) ?? (await solidToString(toFill)) } } } diff --git a/src/codegen/props/selector.ts b/src/codegen/props/selector.ts index a2bc00a..149df08 100644 --- a/src/codegen/props/selector.ts +++ b/src/codegen/props/selector.ts @@ -65,8 +65,11 @@ function toTransitionPropertyName(key: string): string { 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 = name.trim().replace(/속성\s*/g, 'property') // 한글 '속성' + 뒤따르는 공백을 'property'로 변환 + const normalized = stripped.trim().replace(/속성\s*/g, 'property') // 한글 '속성' + 뒤따르는 공백을 'property'로 변환 // 2. 공백과 특수문자를 처리하여 camelCase로 변환 const result = normalized @@ -122,42 +125,46 @@ async function computeSelectorProps(node: ComponentSetNode): Promise<{ console.info( `[perf] getSelectorProps: processing ${node.children.length} children`, ) - const components = await Promise.all( - node.children - .filter((child) => { - return child.type === 'COMPONENT' - }) - .map( - async (component) => - [ - hasEffect - ? component.variantProperties?.effect - : triggerTypeToEffect(component.reactions?.[0]?.trigger?.type), - await getProps(component), - ] as const, - ), - ) + // 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'). + const effectChildren: { component: SceneNode; effect: string }[] = [] + for (const child of node.children) { + if (child.type !== 'COMPONENT') continue + const effect = hasEffect + ? (child as ComponentNode).variantProperties?.effect + : triggerTypeToEffect(child.reactions?.[0]?.trigger?.type) + if (effect && effect !== 'default') { + effectChildren.push({ component: child, effect }) + } + } + const components: (readonly [string, Record])[] = [] + for (const { component, effect } of effectChildren) { + components.push([effect, await getProps(component)] as const) + } perfEnd('getSelectorProps.getPropsAll()', tSelector) const defaultProps = await getProps(node.defaultVariant) - const result = Object.entries(node.componentPropertyDefinitions).reduce( - (acc, [name, definition]) => { - if (name !== 'effect' && name !== 'viewport') { - const sanitizedName = sanitizePropertyName(name) - // variant 옵션값들을 문자열 리터럴로 감싸기 - acc.variants[sanitizedName] = - definition.variantOptions - ?.map((option) => `'${option}'`) - .join(' | ') || '' - } - return acc - }, - { - props: {} as Record, - variants: {} as Record, - }, - ) + const result: { + props: Record + variants: Record + } = { props: {}, variants: {} } + const defs = node.componentPropertyDefinitions + for (const name in defs) { + if (name === 'effect' || name === 'viewport') continue + const definition = defs[name] + const sanitizedName = sanitizePropertyName(name) + if (definition.type === 'VARIANT' && definition.variantOptions) { + result.variants[sanitizedName] = definition.variantOptions + .map((option) => `'${option}'`) + .join(' | ') + } else if (definition.type === 'INSTANCE_SWAP') { + result.variants[sanitizedName] = 'React.ReactNode' + } else if (definition.type === 'BOOLEAN') { + result.variants[sanitizedName] = 'boolean' + } + } if (components.length > 0) { const findNodeAction = (action: Action) => action.type === 'NODE' @@ -168,8 +175,6 @@ async function computeSelectorProps(node: ComponentSetNode): Promise<{ .flat()[0] const diffKeys = new Set() for (const [effect, props] of components) { - // Skip if no effect or if effect is "default" (default state doesn't need pseudo-selector) - if (!effect || effect === 'default') continue const def = difference(props, defaultProps) if (Object.keys(def).length === 0) continue result.props[`_${effect}`] = def @@ -178,7 +183,8 @@ async function computeSelectorProps(node: ComponentSetNode): Promise<{ } } if (transition?.type === 'SMART_ANIMATE' && diffKeys.size > 0) { - const keys = Array.from(diffKeys).map(toTransitionPropertyName) + const keys: string[] = [] + for (const key of diffKeys) keys.push(toTransitionPropertyName(key)) keys.sort() result.props.transition = `${fmtPct(transition.duration)}ms ${transition.easing.type.toLocaleLowerCase().replaceAll('_', '-')}` result.props.transitionProperty = keys.join(',') @@ -206,10 +212,12 @@ export async function getSelectorPropsForGroup( // Build cache key from componentSet.id + filter + viewport const setId = componentSet.id - const filterKey = Object.entries(variantFilter) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([k, v]) => `${k}=${v}`) - .join('|') + const filterParts: string[] = [] + for (const k in variantFilter) { + filterParts.push(`${k}=${variantFilter[k]}`) + } + filterParts.sort() + const filterKey = filterParts.join('|') const cacheKey = setId ? `${setId}::${filterKey}::${viewportValue ?? ''}` : '' if (cacheKey) { @@ -245,8 +253,8 @@ async function computeSelectorPropsForGroup( const variantProps = child.variantProperties || {} // Check all filter conditions match - for (const [key, value] of Object.entries(variantFilter)) { - if (variantProps[key] !== value) return false + for (const key in variantFilter) { + if (variantProps[key] !== variantFilter[key]) return false } // Check viewport if specified @@ -278,13 +286,15 @@ async function computeSelectorPropsForGroup( const effect = c.variantProperties?.effect return effect && effect !== 'default' }) - const effectPropsResults = await Promise.all( - effectComponents.map(async (component) => { - const effect = component.variantProperties?.effect as string - const props = await getProps(component) - return { effect, props } - }), - ) + const effectPropsResults: { + effect: string + props: Record + }[] = [] + for (const component of effectComponents) { + const effect = component.variantProperties?.effect as string + const props = await getProps(component) + effectPropsResults.push({ effect, props }) + } for (const { effect, props } of effectPropsResults) { const def = difference(props, defaultProps) if (Object.keys(def).length === 0) continue @@ -304,7 +314,8 @@ async function computeSelectorPropsForGroup( ?.flatMap(getTransition) .flat()[0] if (transition?.type === 'SMART_ANIMATE') { - const keys = Array.from(diffKeys).map(toTransitionPropertyName) + const keys: string[] = [] + for (const key of diffKeys) keys.push(toTransitionPropertyName(key)) keys.sort() result.transition = `${fmtPct(transition.duration)}ms ${transition.easing.type.toLocaleLowerCase().replaceAll('_', '-')}` result.transitionProperty = keys.join(',') @@ -326,13 +337,12 @@ function triggerTypeToEffect(triggerType: Trigger['type'] | undefined) { } function difference(a: Record, b: Record) { - return Object.entries(a).reduce( - (acc, [key, value]) => { - if (value !== undefined && b[key] !== value) { - acc[key] = value - } - return acc - }, - {} as Record, - ) + const result: Record = {} + for (const key in a) { + const value = a[key] + if (value !== undefined && b[key] !== value) { + result[key] = value + } + } + return result } diff --git a/src/codegen/props/text-shadow.ts b/src/codegen/props/text-shadow.ts index 98b4a46..a2a3f98 100644 --- a/src/codegen/props/text-shadow.ts +++ b/src/codegen/props/text-shadow.ts @@ -2,9 +2,9 @@ import { optimizeHex } from '../../utils/optimize-hex' import { rgbaToHex } from '../../utils/rgba-to-hex' import { addPx } from '../utils/add-px' -export async function getTextShadowProps( +export function getTextShadowProps( node: SceneNode, -): Promise | undefined> { +): Record | undefined { if (node.type !== 'TEXT') return const effects = node.effects.filter((effect) => effect.visible) diff --git a/src/codegen/props/text-stroke.ts b/src/codegen/props/text-stroke.ts index 82fde2e..f09b64a 100644 --- a/src/codegen/props/text-stroke.ts +++ b/src/codegen/props/text-stroke.ts @@ -1,4 +1,4 @@ -import { solidToString } from '../utils/solid-to-string' +import { solidToString, solidToStringSync } from '../utils/solid-to-string' export async function getTextStrokeProps( node: SceneNode, @@ -12,8 +12,10 @@ export async function getTextStrokeProps( if (solidStrokes.length === 0) return const solidStroke = solidStrokes[0] if (typeof node.strokeWeight !== 'number' || node.strokeWeight === 0) return + const color = + solidToStringSync(solidStroke) ?? (await solidToString(solidStroke)) return { paintOrder: 'stroke fill', - WebkitTextStroke: `${node.strokeWeight}px ${await solidToString(solidStroke)}`, + WebkitTextStroke: `${node.strokeWeight}px ${color}`, } } diff --git a/src/codegen/props/transform.ts b/src/codegen/props/transform.ts index e8c4190..44d3302 100644 --- a/src/codegen/props/transform.ts +++ b/src/codegen/props/transform.ts @@ -1,12 +1,16 @@ +import type { NodeContext } from '../types' import { fmtPct } from '../utils/fmtPct' import { canBeAbsolute } from './position' export function getTransformProps( node: SceneNode, + ctx?: NodeContext, ): Record | undefined { if ('rotation' in node && Math.abs(node.rotation) > 0.01) return { transform: `rotate(${fmtPct(-node.rotation)}deg)`, - transformOrigin: canBeAbsolute(node) ? 'top left' : undefined, + transformOrigin: (ctx ? ctx.canBeAbsolute : canBeAbsolute(node)) + ? 'top left' + : undefined, } } diff --git a/src/codegen/render/index.ts b/src/codegen/render/index.ts index c2db06c..597cf91 100644 --- a/src/codegen/render/index.ts +++ b/src/codegen/render/index.ts @@ -1,5 +1,4 @@ import { space } from '../../utils' -import { filterPropsWithComponent } from '../props' import { isDefaultProp } from '../utils/is-default-prop' import { paddingLeftMultiline, @@ -7,34 +6,37 @@ import { } from '../utils/padding-left-multiline' import { propsToString } from '../utils/props-to-str' +const CENTER_SKIP_KEYS = new Set(['alignItems', 'justifyContent']) +const IMAGE_BOX_SKIP_KEYS = new Set([ + 'alignItems', + 'justifyContent', + 'flexDir', + 'gap', + 'outline', + 'outlineOffset', + 'overflow', +]) + export function renderNode( component: string, props: Record, deps: number = 0, childrenCodes: string[], ): string { - const filteredProps = filterProps(props) - - const propsString = propsToString( - filterPropsWithComponent(component, filteredProps), - ) + const propsString = propsToString(filterAndTransformProps(component, props)) const hasChildren = childrenCodes.length > 0 - const tail = hasChildren ? `${space(deps)}` : '' const multiProps = propsString.includes('\n') - return [ - `${space(deps)}<${component}${propsString ? (multiProps ? `\n${paddingLeftMultiline(propsString, deps + 1)}` : ` ${propsString}`) : ''}${ - (multiProps ? `\n${space(deps)}` : !hasChildren ? ' ' : '') + - (hasChildren ? '>' : '/>') - }`, - hasChildren - ? childrenCodes - .map((child) => paddingLeftMultiline(child, deps + 1)) - .join('\n') - : '', - tail, - ] - .filter(Boolean) - .join('\n') + let result = `${space(deps)}<${component}${propsString ? (multiProps ? `\n${paddingLeftMultiline(propsString, deps + 1)}` : ` ${propsString}`) : ''}${ + (multiProps ? `\n${space(deps)}` : !hasChildren ? ' ' : '') + + (hasChildren ? '>' : '/>') + }` + if (hasChildren) { + const children = childrenCodes + .map((child) => paddingLeftMultiline(child, deps + 1)) + .join('\n') + result += `\n${children}\n${space(deps)}` + } + return result } export function renderComponent( @@ -42,29 +44,41 @@ export function renderComponent( code: string, variants: Record, ) { - // Filter out effect variant (treated as reserved property like viewport) - const filteredVariants = Object.fromEntries( - Object.entries(variants).filter(([key]) => key.toLowerCase() !== 'effect'), - ) - const hasVariants = Object.keys(filteredVariants).length > 0 - const interfaceCode = hasVariants - ? `export interface ${component}Props { -${Object.entries(filteredVariants) - .map(([key, value]) => ` ${key}: ${value}`) - .join('\n')} -}\n\n` - : '' - const propsParam = hasVariants - ? `{ ${Object.keys(filteredVariants).join(', ')} }: ${component}Props` - : '' - return `${interfaceCode}export function ${component}(${propsParam}) { + // Single pass: collect variant entries, skipping 'effect' (reserved key) + const variantEntries: [string, string][] = [] + for (const key in variants) { + if (key.toLowerCase() !== 'effect') + variantEntries.push([key, variants[key]]) + } + if (variantEntries.length === 0) { + return `export function ${component}() { + return ${wrapReturnStatement(code, 1)} +}` + } + const interfaceLines: string[] = [] + const keys: string[] = [] + for (const [key, value] of variantEntries) { + const optional = value === 'boolean' ? '?' : '' + interfaceLines.push(` ${key}${optional}: ${value}`) + keys.push(key) + } + return `export interface ${component}Props { +${interfaceLines.join('\n')} +} + +export function ${component}({ ${keys.join(', ')} }: ${component}Props) { return ${wrapReturnStatement(code, 1)} }` } -function filterProps(props: Record) { +function filterAndTransformProps( + component: string, + props: Record, +) { + const hasMaskImage = 'maskImage' in props const newProps: Record = {} - for (const [key, value] of Object.entries(props)) { + for (const key in props) { + const value = props[key] if (value === null || value === undefined) { continue } @@ -72,6 +86,31 @@ function filterProps(props: Record) { if (isDefaultProp(key, newValue)) { continue } + switch (component) { + case 'Flex': + if (key === 'display' && newValue === 'flex') continue + if (key === 'flexDir' && newValue === 'row') continue + break + case 'Grid': + if (key === 'display' && newValue === 'grid') continue + break + case 'Center': + if (CENTER_SKIP_KEYS.has(key)) continue + if (key === 'display' && newValue === 'flex') continue + if (key === 'flexDir' && newValue === 'row') continue + break + case 'VStack': + if (key === 'flexDir' && newValue === 'column') continue + if (key === 'display' && newValue === 'flex') continue + break + case 'Image': + case 'Box': + if (component === 'Box' && !hasMaskImage) break + if (IMAGE_BOX_SKIP_KEYS.has(key)) continue + if (key === 'display' && newValue === 'flex') continue + if (!hasMaskImage && key === 'bg') continue + break + } newProps[key] = newValue } return newProps diff --git a/src/codegen/render/text.ts b/src/codegen/render/text.ts index 40b3907..e1cf1b8 100644 --- a/src/codegen/render/text.ts +++ b/src/codegen/render/text.ts @@ -1,7 +1,7 @@ import { propsToPropsWithTypography } from '../../utils' import { textSegmentToTypography } from '../../utils/text-segment-to-typography' import { fixTextChild } from '../utils/fix-text-child' -import { paintToCSS } from '../utils/paint-to-css' +import { paintToCSS, paintToCSSSyncIfPossible } from '../utils/paint-to-css' import { perfEnd, perfStart } from '../utils/perf' import { renderNode } from '.' @@ -39,105 +39,102 @@ export async function renderText(node: TextNode): Promise<{ const segs = node.getStyledTextSegments(SEGMENT_TYPE) // select main color - const propsArray = await Promise.all( - segs.map(async (seg) => - Object.fromEntries( - Object.entries( - await propsToPropsWithTypography( - { - ...(await textSegmentToTypography(seg)), - color: ( - await Promise.all( - seg.fills.map( - async (fill, idx) => - await paintToCSS( - fill, - node, - idx === seg.fills.length - 1, - ), - ), - ) - ).join(','), - characters: seg.characters, - }, - seg.textStyleId, - ), - ) - .filter(([_, value]) => Boolean(value)) - .map(([key, value]) => [key, String(value)]), - ), - ), - ) - let defaultTypographyCount = 0 + const propsArray: Record[] = [] + for (const seg of segs) { + const colorParts: string[] = [] + for (let idx = 0; idx < seg.fills.length; idx++) { + const fill = seg.fills[idx] + const last = idx === seg.fills.length - 1 + const color = + paintToCSSSyncIfPossible(fill, node, last) ?? + (await paintToCSS(fill, node, last)) + if (color) colorParts.push(color) + } + const typo = await propsToPropsWithTypography( + { + ...textSegmentToTypography(seg), + color: colorParts.join(','), + characters: seg.characters, + }, + seg.textStyleId, + ) + const filtered: Record = {} + for (const key in typo) { + const v = typo[key] + if (v) filtered[key] = String(v) + } + propsArray.push(filtered) + } + const _defaultTypographyCount = 0 let defaultProps: Record = {} // Check if any segment contains Korean text const hasKorean = segs.some((seg) => containsKorean(seg.characters)) - propsArray.forEach((props) => { - if (props.characters.length >= defaultTypographyCount) { - defaultProps = { ...props } - delete defaultProps.characters - defaultTypographyCount = props.characters.length + let longestIdx = 0 + for (let i = 0; i < propsArray.length; i++) { + if ( + propsArray[i].characters.length >= + propsArray[longestIdx].characters.length + ) { + longestIdx = i } - }) + } + defaultProps = { ...propsArray[longestIdx] } + delete defaultProps.characters // Add wordBreak: keep-all for Korean text if (hasKorean) { defaultProps.wordBreak = 'keep-all' } - const children = await Promise.all( - segs.map( - async ( - seg, - idx, - ): Promise<{ - children: string[] - props: Record - }> => { - const props = propsArray[idx] - if (segs.length > 1) { - for (const key in defaultProps) { - if (defaultProps[key as keyof typeof defaultProps] === props[key]) - delete props[key] - } + const children = segs.map( + ( + seg, + idx, + ): { + children: string[] + props: Record + } => { + const props = propsArray[idx] + if (segs.length > 1) { + for (const key in defaultProps) { + if (defaultProps[key as keyof typeof defaultProps] === props[key]) + delete props[key] } - let text: string[] = [fixTextChild(seg.characters)] - let textComponent: 'ul' | 'ol' | null = null + } + let text: string[] = [fixTextChild(seg.characters)] + let textComponent: 'ul' | 'ol' | null = null - if (seg.listOptions.type === 'NONE') { - text = text.map((line) => line.replace(/\r\n|\r|\n/g, '
')) - } else { - switch (seg.listOptions.type) { - case 'UNORDERED': { - textComponent = 'ul' - break - } - case 'ORDERED': { - textComponent = 'ol' - break - } + if (seg.listOptions.type === 'NONE') { + text = text.map((line) => line.replace(/\r\n|\r|\n/g, '
')) + } else { + switch (seg.listOptions.type) { + case 'UNORDERED': { + textComponent = 'ul' + break + } + case 'ORDERED': { + textComponent = 'ol' + break } - text = text.flatMap((line) => - line.split('\n').map((line) => renderNode('li', {}, 0, [line])), - ) - } - const resultProps: Record = { - ...props, - ...(textComponent - ? { as: textComponent, my: '0px', pl: '1.5em' } - : {}), - } - delete resultProps.characters - if (Object.keys(resultProps).length === 0) - return { children: text, props: {} } - return { - children: text, - props: resultProps, } - }, - ), + text = text.flatMap((line) => + line.split('\n').map((line) => renderNode('li', {}, 0, [line])), + ) + } + const resultProps: Record = { + ...props, + ...(textComponent ? { as: textComponent, my: '0px', pl: '1.5em' } : {}), + } + delete resultProps.characters + if (Object.keys(resultProps).length === 0) + return { children: text, props: {} } + return { + children: text, + props: resultProps, + } + }, ) const resultChildren = children.flat() diff --git a/src/codegen/responsive/ResponsiveCodegen.ts b/src/codegen/responsive/ResponsiveCodegen.ts index 39aaee7..7d53422 100644 --- a/src/codegen/responsive/ResponsiveCodegen.ts +++ b/src/codegen/responsive/ResponsiveCodegen.ts @@ -12,6 +12,7 @@ import { type BreakpointKey, createVariantPropValue, getBreakpointByWidth, + isEqual, mergePropsToResponsive, mergePropsToVariant, optimizeResponsiveValue, @@ -19,6 +20,31 @@ import { viewportToBreakpoint, } from '.' +const POSITION_PROP_KEYS = new Set([ + 'pos', + 'top', + 'left', + 'right', + 'bottom', + 'display', +]) +const RESERVED_VARIANT_KEYS = new Set(['effect', 'viewport']) + +function firstMapValue(map: Map): V { + for (const v of map.values()) return v + throw new Error('empty map') +} + +function firstMapKey(map: Map): K { + for (const k of map.keys()) return k + throw new Error('empty map') +} + +function firstMapEntry(map: Map): [K, V] { + for (const entry of map.entries()) return entry + throw new Error('empty map') +} + /** * Generate responsive code by merging children inside a Section. * Uses Codegen to build NodeTree for each breakpoint, then merges them. @@ -58,22 +84,19 @@ export class ResponsiveCodegen { if (this.breakpointNodes.size === 1) { // If only one breakpoint, generate normal code using Codegen. - const [, node] = [...this.breakpointNodes.entries()][0] + const [, node] = firstMapEntry(this.breakpointNodes) const codegen = new Codegen(node) const tree = await codegen.getTree() return Codegen.renderTree(tree, 0) } // Extract trees per breakpoint using Codegen — all independent, run in parallel. - const breakpointEntries = [...this.breakpointNodes.entries()] - const treeResults = await Promise.all( - breakpointEntries.map(async ([bp, node]) => { - const codegen = new Codegen(node) - const tree = await codegen.getTree() - return [bp, tree] as const - }), - ) - const breakpointTrees = new Map(treeResults) + const breakpointTrees = new Map() + for (const [bp, node] of this.breakpointNodes) { + const codegen = new Codegen(node) + const tree = await codegen.getTree() + breakpointTrees.set(bp, tree) + } // Merge trees and generate code. return this.generateMergedCode(breakpointTrees, 0) @@ -99,23 +122,10 @@ export class ResponsiveCodegen { treesByBreakpoint: Map, depth: number, ): string { - const firstTree = [...treesByBreakpoint.values()][0] + const firstTree = firstMapValue(treesByBreakpoint) // If node is INSTANCE or COMPONENT, render as component reference if (firstTree.isComponent) { - // Position props that may need responsive merging - const positionPropKeys = [ - 'pos', - 'top', - 'left', - 'right', - 'bottom', - 'display', - ] - - // Reserved variant keys that should not be passed as props (internal use only) - const reservedVariantKeys = ['effect', 'viewport'] - // For components, we might still need position props const propsMap = new Map() for (const [bp, tree] of treesByBreakpoint) { @@ -136,11 +146,10 @@ export class ResponsiveCodegen { const variantProps: Props = {} for (const [key, value] of Object.entries(firstTree.props)) { const lowerKey = key.toLowerCase() - const isPositionProp = positionPropKeys.includes(key) - const isReservedVariant = reservedVariantKeys.some( - (r) => lowerKey === r, - ) - if (!isPositionProp && !isReservedVariant) { + if ( + !POSITION_PROP_KEYS.has(key) && + !RESERVED_VARIANT_KEYS.has(lowerKey) + ) { variantProps[key] = value } } @@ -212,7 +221,7 @@ export class ResponsiveCodegen { } // Get all child names in order (first tree's order, then others) - const firstBreakpoint = [...treesByBreakpoint.keys()][0] + const firstBreakpoint = firstMapKey(treesByBreakpoint) const firstChildrenMap = childrenMaps.get(firstBreakpoint) const allChildNames: string[] = [] @@ -263,7 +272,7 @@ export class ResponsiveCodegen { // 2. Child exists only in pc (needs display:none in mobile) for (const bp of treesByBreakpoint.keys()) { if (!presentBreakpoints.has(bp)) { - const firstChildTree = [...childByBreakpoint.values()][0] + const firstChildTree = firstMapValue(childByBreakpoint) const hiddenTree: NodeTree = { ...firstChildTree, props: { ...firstChildTree.props, display: 'none' }, @@ -306,10 +315,14 @@ export class ResponsiveCodegen { componentSet: ComponentSetNode, componentName: string, ): Promise> { - // Find viewport variant key - const viewportKey = Object.keys( - componentSet.componentPropertyDefinitions, - ).find((key) => key.toLowerCase() === 'viewport') + // Find viewport and effect variant keys + let viewportKey: string | undefined + let effectKey: string | undefined + for (const key in componentSet.componentPropertyDefinitions) { + const lower = key.toLowerCase() + if (lower === 'viewport') viewportKey = key + else if (lower === 'effect') effectKey = key + } if (!viewportKey) { return [] @@ -317,9 +330,8 @@ export class ResponsiveCodegen { // Get variants excluding viewport const variants: Record = {} - for (const [name, definition] of Object.entries( - componentSet.componentPropertyDefinitions, - )) { + for (const name in componentSet.componentPropertyDefinitions) { + const definition = componentSet.componentPropertyDefinitions[name] if (name.toLowerCase() !== 'viewport' && definition.type === 'VARIANT') { const sanitizedName = sanitizePropertyName(name) variants[sanitizedName] = @@ -327,11 +339,6 @@ export class ResponsiveCodegen { } } - // Find effect variant key (to exclude from grouping) - const effectKey = Object.keys( - componentSet.componentPropertyDefinitions, - ).find((key) => key.toLowerCase() === 'effect') - // Group components by non-viewport, non-effect variants const groups = new Map>() @@ -349,16 +356,15 @@ export class ResponsiveCodegen { const breakpoint = viewportToBreakpoint(viewportValue) // Create group key from non-viewport, non-effect variants - const otherVariants = Object.entries(variantProps) - .filter(([key]) => { - const lowerKey = key.toLowerCase() - return lowerKey !== 'viewport' && lowerKey !== 'effect' - }) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, value]) => `${key}=${value}`) - .join('|') - - const groupKey = otherVariants || '__default__' + const parts: string[] = [] + for (const key in variantProps) { + const lowerKey = key.toLowerCase() + if (lowerKey !== 'viewport' && lowerKey !== 'effect') { + parts.push(`${key}=${variantProps[key]}`) + } + } + parts.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) + const groupKey = parts.join('|') || '__default__' if (!groups.has(groupKey)) { groups.set(groupKey, new Map()) @@ -387,36 +393,31 @@ export class ResponsiveCodegen { } // Build trees for each viewport — all independent, run in parallel. - const viewportEntries = [...viewportComponents.entries()] - const viewportTreeResults = await Promise.all( - viewportEntries.map(async ([bp, component]) => { - let t = perfStart() - const codegen = new Codegen(component) - const tree = await codegen.getTree() - perfEnd('Codegen.getTree(viewportVariant)', t) - - // Get pseudo-selector props for this specific variant group AND viewport - // This ensures hover/active colors are correctly responsive per viewport - if (effectKey) { - const viewportValue = component.variantProperties?.[viewportKey] - t = perfStart() - const selectorProps = await getSelectorPropsForGroup( - componentSet, - variantFilter, - viewportValue, - ) - perfEnd('getSelectorPropsForGroup(viewport)', t) - if (Object.keys(selectorProps).length > 0) { - Object.assign(tree.props, selectorProps) - } + const treesByBreakpoint = new Map() + for (const [bp, component] of viewportComponents) { + let t = perfStart() + const codegen = new Codegen(component) + const tree = await codegen.getTree() + perfEnd('Codegen.getTree(viewportVariant)', t) + + // Get pseudo-selector props for this specific variant group AND viewport + // This ensures hover/active colors are correctly responsive per viewport + if (effectKey) { + const viewportValue = component.variantProperties?.[viewportKey] + t = perfStart() + const selectorProps = await getSelectorPropsForGroup( + componentSet, + variantFilter, + viewportValue, + ) + perfEnd('getSelectorPropsForGroup(viewport)', t) + if (Object.keys(selectorProps).length > 0) { + tree.props = Object.assign({}, tree.props, selectorProps) } + } - return [bp, tree] as const - }), - ) - const treesByBreakpoint = new Map( - viewportTreeResults, - ) + treesByBreakpoint.set(bp, tree) + } // Generate merged responsive code const mergedCode = responsiveCodegen.generateMergedCode( @@ -450,24 +451,22 @@ export class ResponsiveCodegen { ) const tTotal = perfStart() - // Find viewport variant key - const viewportKey = Object.keys( - componentSet.componentPropertyDefinitions, - ).find((key) => key.toLowerCase() === 'viewport') - - // Find effect variant key - const effectKey = Object.keys( - componentSet.componentPropertyDefinitions, - ).find((key) => key.toLowerCase() === 'effect') + // Find viewport and effect variant keys + let viewportKey: string | undefined + let effectKey: string | undefined + for (const key in componentSet.componentPropertyDefinitions) { + const lower = key.toLowerCase() + if (lower === 'viewport') viewportKey = key + else if (lower === 'effect') effectKey = key + } // Get all variant keys excluding viewport and effect const otherVariantKeys: string[] = [] const variants: Record = {} // Map from original name to sanitized name const variantKeyToSanitized: Record = {} - for (const [name, definition] of Object.entries( - componentSet.componentPropertyDefinitions, - )) { + for (const name in componentSet.componentPropertyDefinitions) { + const definition = componentSet.componentPropertyDefinitions[name] if (definition.type === 'VARIANT') { const lowerName = name.toLowerCase() // Exclude both viewport and effect from variant keys @@ -600,48 +599,37 @@ export class ResponsiveCodegen { Map >() - // Build trees for all composite variants in parallel — each is independent. - const compositeEntries = [...byCompositeVariant.entries()] - const compositeResults = await Promise.all( - compositeEntries.map(async ([compositeKey, viewportComponents]) => { - // Use original names for Figma data access - const variantFilter = parseCompositeKeyToOriginal(compositeKey) - - // Build trees for each viewport within this composite — also parallel. - const vpEntries = [...viewportComponents.entries()] - const vpResults = await Promise.all( - vpEntries.map(async ([bp, component]) => { - let t = perfStart() - const codegen = new Codegen(component) - const tree = await codegen.getTree() - perfEnd('Codegen.getTree(variant)', t) - - // Get pseudo-selector props for this specific variant group AND viewport - if (effectKey) { - const viewportValue = component.variantProperties?.[viewportKey] - t = perfStart() - const selectorProps = await getSelectorPropsForGroup( - componentSet, - variantFilter, - viewportValue, - ) - perfEnd('getSelectorPropsForGroup()', t) - if (Object.keys(selectorProps).length > 0) { - Object.assign(tree.props, selectorProps) - } - } + // Build trees for all composite variants — each is independent. + for (const [compositeKey, viewportComponents] of byCompositeVariant) { + // Use original names for Figma data access + const variantFilter = parseCompositeKeyToOriginal(compositeKey) - return [bp, tree] as const - }), - ) + // Build trees for each viewport within this composite. + const treesByBreakpoint = new Map() + for (const [bp, component] of viewportComponents) { + let t = perfStart() + const codegen = new Codegen(component) + const tree = await codegen.getTree() + perfEnd('Codegen.getTree(variant)', t) + + // Get pseudo-selector props for this specific variant group AND viewport + if (effectKey) { + const viewportValue = component.variantProperties?.[viewportKey] + t = perfStart() + const selectorProps = await getSelectorPropsForGroup( + componentSet, + variantFilter, + viewportValue, + ) + perfEnd('getSelectorPropsForGroup()', t) + if (Object.keys(selectorProps).length > 0) { + tree.props = Object.assign({}, tree.props, selectorProps) + } + } + + treesByBreakpoint.set(bp, tree) + } - return [ - compositeKey, - new Map(vpResults), - ] as const - }), - ) - for (const [compositeKey, treesByBreakpoint] of compositeResults) { responsivePropsByComposite.set(compositeKey, treesByBreakpoint) } @@ -679,7 +667,7 @@ export class ResponsiveCodegen { // Get pseudo-selector props (hover, active, disabled, etc.) const selectorProps = await getSelectorPropsForGroup(componentSet, {}) if (Object.keys(selectorProps).length > 0) { - Object.assign(tree.props, selectorProps) + tree.props = Object.assign({}, tree.props, selectorProps) } // Render the tree to JSX @@ -722,36 +710,37 @@ export class ResponsiveCodegen { } // Check if componentSet has effect variant (pseudo-selector) - const hasEffect = Object.keys( - componentSet.componentPropertyDefinitions, - ).some((key) => key.toLowerCase() === 'effect') + let hasEffect = false + for (const key in componentSet.componentPropertyDefinitions) { + if (key.toLowerCase() === 'effect') { + hasEffect = true + break + } + } // Build trees for each variant — all independent, run in parallel. - const variantEntries = [...componentsByVariant.entries()] - const variantResults = await Promise.all( - variantEntries.map(async ([variantValue, component]) => { - // Get pseudo-selector props for this specific variant group - const variantFilter: Record = { - [primaryVariantKey]: variantValue, - } - let t = perfStart() - const selectorProps = hasEffect - ? await getSelectorPropsForGroup(componentSet, variantFilter) - : null - perfEnd('getSelectorPropsForGroup(nonViewport)', t) - - t = perfStart() - const codegen = new Codegen(component) - const tree = await codegen.getTree() - perfEnd('Codegen.getTree(nonViewportVariant)', t) - // Add pseudo-selector props to tree - if (selectorProps && Object.keys(selectorProps).length > 0) { - Object.assign(tree.props, selectorProps) - } - return [variantValue, tree] as const - }), - ) - const treesByVariant = new Map(variantResults) + const treesByVariant = new Map() + for (const [variantValue, component] of componentsByVariant) { + // Get pseudo-selector props for this specific variant group + const variantFilter: Record = { + [primaryVariantKey]: variantValue, + } + let t = perfStart() + const selectorProps = hasEffect + ? await getSelectorPropsForGroup(componentSet, variantFilter) + : null + perfEnd('getSelectorPropsForGroup(nonViewport)', t) + + t = perfStart() + const codegen = new Codegen(component) + const tree = await codegen.getTree() + perfEnd('Codegen.getTree(nonViewportVariant)', t) + // Add pseudo-selector props to tree — create NEW props to avoid mutating cached tree + if (selectorProps && Object.keys(selectorProps).length > 0) { + tree.props = Object.assign({}, tree.props, selectorProps) + } + treesByVariant.set(variantValue, tree) + } // Generate merged code with variant conditionals const responsiveCodegen = new ResponsiveCodegen(null) @@ -785,7 +774,7 @@ export class ResponsiveCodegen { variantValue, treesByBreakpoint, ] of treesByVariantAndBreakpoint) { - const firstTree = [...treesByBreakpoint.values()][0] + const firstTree = firstMapValue(treesByBreakpoint) const propsMap = new Map() for (const [bp, tree] of treesByBreakpoint) { propsMap.set(bp, tree.props) @@ -824,7 +813,7 @@ export class ResponsiveCodegen { const processedChildNames = new Set() const allChildNames: string[] = [] - const firstBreakpoint = [...treesByBreakpoint.keys()][0] + const firstBreakpoint = firstMapKey(treesByBreakpoint) const firstChildrenMap = childrenMaps.get(firstBreakpoint) if (firstChildrenMap) { @@ -869,7 +858,7 @@ export class ResponsiveCodegen { if (childByBreakpoint.size > 0) { for (const bp of treesByBreakpoint.keys()) { if (!presentBreakpoints.has(bp)) { - const firstChildTree = [...childByBreakpoint.values()][0] + const firstChildTree = firstMapValue(childByBreakpoint) const hiddenTree: NodeTree = { ...firstChildTree, props: { ...firstChildTree.props, display: 'none' }, @@ -879,7 +868,7 @@ export class ResponsiveCodegen { } // Merge this child's props across breakpoints - const firstChildTree = [...childByBreakpoint.values()][0] + const firstChildTree = firstMapValue(childByBreakpoint) const propsMap = new Map() for (const [bp, tree] of childByBreakpoint) { propsMap.set(bp, tree.props) @@ -912,8 +901,7 @@ export class ResponsiveCodegen { treesByVariant: Map, depth: number, ): string { - const firstTree = [...treesByVariant.values()][0] - const allVariants = [...treesByVariant.keys()] + const firstTree = firstMapValue(treesByVariant) // Merge props across variants const propsMap = new Map>() @@ -941,7 +929,7 @@ export class ResponsiveCodegen { const processedChildNames = new Set() const allChildNames: string[] = [] - const firstVariant = [...treesByVariant.keys()][0] + const firstVariant = firstMapKey(treesByVariant) const firstChildrenMap = childrenMaps.get(firstVariant) if (firstChildrenMap) { @@ -983,9 +971,8 @@ export class ResponsiveCodegen { if (childByVariant.size > 0) { // Check if child exists in all variants or only some - const existsInAllVariants = allVariants.every((v) => - presentVariants.has(v), - ) + const existsInAllVariants = + presentVariants.size === treesByVariant.size if (existsInAllVariants) { // Child exists in all variants - merge props @@ -1057,7 +1044,7 @@ export class ResponsiveCodegen { compositeKey, treesByBreakpoint, ] of treesByCompositeAndBreakpoint) { - const firstTree = [...treesByBreakpoint.values()][0] + const firstTree = firstMapValue(treesByBreakpoint) const propsMap = new Map() for (const [bp, tree] of treesByBreakpoint) { propsMap.set(bp, tree.props) @@ -1094,7 +1081,7 @@ export class ResponsiveCodegen { treesByComposite: Map, depth: number, ): string { - const firstTree = [...treesByComposite.values()][0] + const firstTree = firstMapValue(treesByComposite) // Build props map indexed by composite key const propsMap = new Map>() @@ -1127,7 +1114,7 @@ export class ResponsiveCodegen { // Get all unique child names const processedChildNames = new Set() const allChildNames: string[] = [] - const firstComposite = [...treesByComposite.keys()][0] + const firstComposite = firstMapKey(treesByComposite) const firstChildrenMap = childrenMaps.get(firstComposite) if (firstChildrenMap) { @@ -1147,8 +1134,6 @@ export class ResponsiveCodegen { } // Process each child - const allCompositeKeys = [...treesByComposite.keys()] - for (const childName of allChildNames) { let maxChildCount = 0 for (const childMap of childrenMaps.values()) { @@ -1171,9 +1156,7 @@ export class ResponsiveCodegen { } if (childByComposite.size > 0) { - const existsInAll = allCompositeKeys.every((k) => - presentComposites.has(k), - ) + const existsInAll = presentComposites.size === treesByComposite.size if (existsInAll) { // Child exists in all variants - recursively merge @@ -1186,7 +1169,7 @@ export class ResponsiveCodegen { } else { // Child exists only in some variants - use first one for now // TODO: implement conditional rendering for partial children - const firstChildTree = [...childByComposite.values()][0] + const firstChildTree = firstMapValue(childByComposite) const childCode = Codegen.renderTree(firstChildTree, 0) childrenCodes.push(childCode) } @@ -1267,13 +1250,16 @@ export class ResponsiveCodegen { if (!hasValue) continue // Check if all values are the same (including null checks) - const uniqueValues = new Set() + const firstValue = firstMapValue(valuesByComposite) + let allValsSame = true for (const value of valuesByComposite.values()) { - uniqueValues.add(JSON.stringify(value)) + if (!isEqual(value as PropValue, firstValue as PropValue)) { + allValsSame = false + break + } } - const firstValue = [...valuesByComposite.values()][0] - if (uniqueValues.size === 1 && firstValue !== null) { + if (allValsSame && firstValue !== null) { // All values are the same and not null - use as-is result[propKey] = firstValue } else { @@ -1331,11 +1317,18 @@ export class ResponsiveCodegen { } // Check if all values are the same - const uniqueValues = new Set( - Object.values(valuesByVariant).map((v) => JSON.stringify(v)), - ) - if (uniqueValues.size === 1) { - return Object.values(valuesByVariant)[0] + const variantValues = Object.values(valuesByVariant) + let allVariantsSame = true + for (let i = 1; i < variantValues.length; i++) { + if ( + !isEqual(variantValues[i] as PropValue, variantValues[0] as PropValue) + ) { + allVariantsSame = false + break + } + } + if (allVariantsSame) { + return variantValues[0] } return createVariantPropValue( @@ -1382,13 +1375,22 @@ export class ResponsiveCodegen { for (const [variantValue, subValues] of valuesByVariant) { // Check if all sub-values are the same (can collapse to scalar) - const uniqueSubValues = new Set( - [...subValues.values()].map((v) => JSON.stringify(v)), - ) + let allSubSame = true + let firstSubVal: unknown | undefined + for (const sv of subValues.values()) { + if (firstSubVal === undefined) { + firstSubVal = sv + continue + } + if (!isEqual(sv as PropValue, firstSubVal as PropValue)) { + allSubSame = false + break + } + } - if (uniqueSubValues.size === 1) { + if (allSubSame) { // All same - collapse to scalar value (cost 0) - nestedValues[variantValue] = [...subValues.values()][0] + nestedValues[variantValue] = firstMapValue(subValues) // Cost is 0 for scalar } else { // Need to recurse @@ -1480,7 +1482,10 @@ export class ResponsiveCodegen { // If only one breakpoint has text, return it if (textByBreakpoint.size <= 1) { - const firstText = [...textByBreakpoint.values()][0] + if (textByBreakpoint.size === 0) { + return [] + } + const firstText = firstMapValue(textByBreakpoint) return firstText || [] } @@ -1502,19 +1507,16 @@ export class ResponsiveCodegen { } // Check if all texts are identical after normalization - const uniqueNormalized = new Set([...normalizedTexts.values()]) + const uniqueNormalized = new Set(normalizedTexts.values()) if (uniqueNormalized.size === 1) { // All same, return first text children - return [...textByBreakpoint.values()][0] + return firstMapValue(textByBreakpoint) } // Texts differ - need to merge with responsive
- // Find the text with the most content (usually the one with more \n) - const breakpoints = [...normalizedTexts.keys()] - // Compare character by character, tracking where \n appears // Build merged text with responsive
where needed - return this.buildResponsiveTextChildren(normalizedTexts, breakpoints) + return this.buildResponsiveTextChildren(normalizedTexts) } /** @@ -1523,8 +1525,8 @@ export class ResponsiveCodegen { */ private buildResponsiveTextChildren( normalizedTexts: Map, - breakpoints: BreakpointKey[], ): string[] { + const breakpoints = normalizedTexts.keys() // Find the longest text to use as base let baseText = '' for (const [, text] of normalizedTexts) { @@ -1577,8 +1579,13 @@ export class ResponsiveCodegen { continue } - const allHaveBr = [...bpMap.values()].every((v) => v) - const noneHaveBr = [...bpMap.values()].every((v) => !v) + let allHaveBr = true + let noneHaveBr = true + for (const v of bpMap.values()) { + if (!v) allHaveBr = false + if (v) noneHaveBr = false + if (!allHaveBr && !noneHaveBr) break + } if (allHaveBr) { // All breakpoints have
- simple case diff --git a/src/codegen/responsive/__tests__/index.test.ts b/src/codegen/responsive/__tests__/index.test.ts index c403fed..79ec487 100644 --- a/src/codegen/responsive/__tests__/index.test.ts +++ b/src/codegen/responsive/__tests__/index.test.ts @@ -68,6 +68,15 @@ describe('responsive index helpers', () => { expect(optimized).toEqual(obj) }) + it('collapses equal array values in responsive optimization', () => { + const optimized = optimizeResponsiveValue([ + ['10px', '20px'], + ['10px', '20px'], + null, + ]) + expect(optimized).toEqual(['10px', '20px']) + }) + it('converts viewport variant values to breakpoints (case-insensitive)', () => { // lowercase expect(viewportToBreakpoint('mobile')).toBe('mobile') diff --git a/src/codegen/responsive/index.ts b/src/codegen/responsive/index.ts index fb45af9..4ef0e08 100644 --- a/src/codegen/responsive/index.ts +++ b/src/codegen/responsive/index.ts @@ -123,12 +123,36 @@ const SPECIAL_PROPS_WITH_INITIAL = new Set([ /** * Compare two prop values for equality. */ -function isEqual(a: PropValue, b: PropValue): boolean { +export function isEqual(a: PropValue, b: PropValue): boolean { if (a === b) return true if (a === null || b === null) return a === b if (typeof a !== typeof b) return false if (typeof a === 'object' && typeof b === 'object') { - return JSON.stringify(a) === JSON.stringify(b) + const isArrayA = Array.isArray(a) + const isArrayB = Array.isArray(b) + if (isArrayA !== isArrayB) return false + if (isArrayA && isArrayB) { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (!isEqual(a[i] as PropValue, b[i] as PropValue)) return false + } + return true + } + let countA = 0 + for (const key in a) { + countA++ + if ( + !Object.hasOwn(b as Record, key) || + !isEqual( + (a as Record)[key], + (b as Record)[key], + ) + ) + return false + } + let countB = 0 + for (const _key in b as Record) countB++ + return countA === countB } return false } @@ -155,18 +179,23 @@ export function optimizeResponsiveValue( arr: (PropValue | null)[], key?: string, ): PropValue | (PropValue | null)[] { - const nonNullValues = arr.filter((v) => v !== null) - if (nonNullValues.length === 0) return null + let hasNonNull = false + for (let i = 0; i < arr.length; i++) { + if (arr[i] !== null) { + hasNonNull = true + break + } + } + if (!hasNonNull) return null // Collapse consecutive identical values after the first to null. - const optimized: (PropValue | null)[] = [...arr] let lastValue: PropValue | null = null - for (let i = 0; i < optimized.length; i++) { - const current = optimized[i] + for (let i = 0; i < arr.length; i++) { + const current = arr[i] if (current !== null) { if (isEqual(current, lastValue)) { - optimized[i] = null + arr[i] = null } else { lastValue = current } @@ -174,26 +203,26 @@ export function optimizeResponsiveValue( } // If the first value is default for that prop, replace with null. - if (key && optimized[0] !== null && isDefaultProp(key, optimized[0])) { - optimized[0] = null + if (key && arr[0] !== null && isDefaultProp(key, arr[0])) { + arr[0] = null } // Remove trailing nulls. - while (optimized.length > 0 && optimized[optimized.length - 1] === null) { - optimized.pop() + while (arr.length > 0 && arr[arr.length - 1] === null) { + arr.pop() } // If empty array after optimization, return null. - if (optimized.length === 0) { + if (arr.length === 0) { return null } // If only index 0 has value, return single value. - if (optimized.length === 1 && optimized[0] !== null) { - return optimized[0] + if (arr.length === 1 && arr[0] !== null) { + return arr[0] } - return optimized + return arr } /** @@ -214,14 +243,14 @@ export function mergePropsToResponsive( // If only one breakpoint, return props as-is. if (breakpointProps.size === 1) { - const onlyProps = [...breakpointProps.values()][0] + const onlyProps = breakpointProps.values().next().value return onlyProps ? { ...onlyProps } : {} } // Collect all prop keys. const allKeys = new Set() for (const props of breakpointProps.values()) { - for (const key of Object.keys(props)) { + for (const key in props) { allKeys.add(key) } } @@ -257,12 +286,14 @@ export function mergePropsToResponsive( } // Collect values for 5 fixed slots. - const values: (PropValue | null)[] = BREAKPOINT_ORDER.map((bp) => { + const values: (PropValue | null)[] = [null, null, null, null, null] + for (let i = 0; i < BREAKPOINT_ORDER.length; i++) { + const bp = BREAKPOINT_ORDER[i] const props = breakpointProps.get(bp) - if (!props) return null - const value = key in props ? props[key] : null - return value ?? null - }) + if (props && key in props) { + values[i] = (props[key] as PropValue) ?? null + } + } // For display/position family, add 'initial' at the first EXISTING breakpoint // where the value changes to null (after a non-null value). @@ -295,7 +326,7 @@ export function mergePropsToResponsive( // Only add 'initial' if we found a position to insert if (initialInsertIdx >= 0) { // Work with original values array to preserve null positions - const newArr = [...values] + const newArr = values.slice(0) newArr[initialInsertIdx] = 'initial' // Trim values after initialInsertIdx (they're not needed) newArr.length = initialInsertIdx + 1 @@ -304,6 +335,12 @@ export function mergePropsToResponsive( } } + // Clone if still referencing the reusable `values` array, since + // optimizeResponsiveValue mutates in-place (T2 optimization). + if (valuesToOptimize === values) { + valuesToOptimize = values.slice(0) + } + // Optimize: single when all same, otherwise array. const optimized = optimizeResponsiveValue(valuesToOptimize, key) @@ -413,14 +450,14 @@ export function mergePropsToVariant( // If only one variant, return props as-is. if (variantProps.size === 1) { - const onlyProps = [...variantProps.values()][0] + const onlyProps = variantProps.values().next().value return onlyProps ? { ...onlyProps } : {} } // Collect all prop keys. const allKeys = new Set() for (const props of variantProps.values()) { - for (const key of Object.keys(props)) { + for (const key in props) { allKeys.add(key) } } @@ -469,15 +506,27 @@ export function mergePropsToVariant( if (!hasValue) continue // Check if all variants have the same value. - const values = Object.values(valuesByVariant) - const allSame = values.every((v) => isEqual(v, values[0])) + let allSame = true + let firstVal: PropValue | undefined + for (const variant in valuesByVariant) { + const v = valuesByVariant[variant] + if (firstVal === undefined) { + firstVal = v + continue + } + if (!isEqual(v, firstVal)) { + allSame = false + break + } + } - if (allSame && values[0] !== null) { - result[key] = values[0] + if (allSame && firstVal !== null && firstVal !== undefined) { + result[key] = firstVal } else { // Filter out null values from the variant object const filteredValues: Record = {} - for (const [variant, value] of Object.entries(valuesByVariant)) { + for (const variant in valuesByVariant) { + const value = valuesByVariant[variant] if (value !== null) { filteredValues[variant] = value } diff --git a/src/codegen/types.ts b/src/codegen/types.ts index a99d27f..18c382b 100644 --- a/src/codegen/types.ts +++ b/src/codegen/types.ts @@ -1,12 +1,21 @@ +export interface NodeContext { + isAsset: 'svg' | 'png' | null + canBeAbsolute: boolean + isPageRoot: boolean + pageNode: SceneNode | null +} + export type Props = Record export interface NodeTree { component: string // 'Flex', 'Box', 'Text', 'Image', or component name props: Props children: NodeTree[] - nodeType: string // Figma node type: 'FRAME', 'TEXT', 'INSTANCE', etc. + nodeType: string // Figma node type: 'FRAME', 'TEXT', 'INSTANCE', 'SLOT', etc. nodeName: string // Figma node name isComponent?: boolean // true if this is a component reference (INSTANCE) + isSlot?: boolean // true if this is an INSTANCE_SWAP slot — renders as {component} + condition?: string // BOOLEAN prop name — renders as {condition && <.../>} textChildren?: string[] // raw text content for TEXT nodes } diff --git a/src/codegen/utils/__tests__/extract-instance-variant-props.test.ts b/src/codegen/utils/__tests__/extract-instance-variant-props.test.ts index e057746..d5c63c9 100644 --- a/src/codegen/utils/__tests__/extract-instance-variant-props.test.ts +++ b/src/codegen/utils/__tests__/extract-instance-variant-props.test.ts @@ -13,10 +13,10 @@ describe('extractInstanceVariantProps', () => { const result = extractInstanceVariantProps(node) - // sanitizePropertyName keeps alphanumeric characters + // sanitizePropertyName strips "#nodeId:uniqueId" suffix expect(result).toEqual({ - status123456: 'scroll', - size789012: 'Md', + status: 'scroll', + size: 'Md', }) }) @@ -32,7 +32,7 @@ describe('extractInstanceVariantProps', () => { const result = extractInstanceVariantProps(node) expect(result).toEqual({ - status123456: 'active', + status: 'active', }) }) @@ -65,8 +65,8 @@ describe('extractInstanceVariantProps', () => { const result = extractInstanceVariantProps(node) - // sanitizePropertyName converts "속성 1" to "property1" - expect(result.property1789012).toBe('값1') + // sanitizePropertyName strips "#789:012" suffix, then converts "속성 1" to "property1" + expect(result.property1).toBe('값1') }) test('converts values to string', () => { @@ -113,7 +113,7 @@ describe('extractInstanceVariantProps', () => { status: 'active', }) expect(result.effect).toBeUndefined() - expect(result.Effect123456).toBeUndefined() + expect(result.Effect).toBeUndefined() }) test('filters out reserved "viewport" variant key', () => { @@ -131,7 +131,7 @@ describe('extractInstanceVariantProps', () => { status: 'active', }) expect(result.viewport).toBeUndefined() - expect(result.Viewport123456).toBeUndefined() + expect(result.Viewport).toBeUndefined() }) test('filters out both effect and viewport but keeps other variants', () => { diff --git a/src/codegen/utils/check-same-color.ts b/src/codegen/utils/check-same-color.ts index 6a1203f..1ba1c4d 100644 --- a/src/codegen/utils/check-same-color.ts +++ b/src/codegen/utils/check-same-color.ts @@ -1,4 +1,4 @@ -import { solidToString } from './solid-to-string' +import { solidToString, solidToStringSync } from './solid-to-string' export async function checkSameColor( node: SceneNode, @@ -9,8 +9,14 @@ export async function checkSameColor( for (const fill of node.fills) { if (!fill.visible) continue if (fill.type === 'SOLID') { - if (targetColor === null) targetColor = await solidToString(fill) - else if (targetColor !== (await solidToString(fill))) return false + const syncColor = solidToStringSync(fill) + if (syncColor !== null) { + if (targetColor === null) targetColor = syncColor + else if (targetColor !== syncColor) return false + } else { + if (targetColor === null) targetColor = await solidToString(fill) + else if (targetColor !== (await solidToString(fill))) return false + } } else return null } } diff --git a/src/codegen/utils/format-number.ts b/src/codegen/utils/format-number.ts index 1503da0..2ebf705 100644 --- a/src/codegen/utils/format-number.ts +++ b/src/codegen/utils/format-number.ts @@ -7,6 +7,13 @@ */ export function formatNumber(n: number): string { const rounded = Math.round(n * 100) / 100 + // Integer fast path: skip toFixed entirely + if (rounded === Math.trunc(rounded)) return String(rounded) const formatted = rounded.toFixed(2) - return formatted.replace(/\.00$/, '').replace(/(\.\d)0$/, '$1') + // Non-integer: only check for single trailing '0' (e.g. "1.30" → "1.3") + // The '.00' case is impossible here since integers are handled above + if (formatted.charCodeAt(formatted.length - 1) === 48) { + return formatted.slice(0, -1) + } + return formatted } diff --git a/src/codegen/utils/is-default-prop.ts b/src/codegen/utils/is-default-prop.ts index b7e78b3..8e51e93 100644 --- a/src/codegen/utils/is-default-prop.ts +++ b/src/codegen/utils/is-default-prop.ts @@ -1,35 +1,31 @@ -const DEFAULT_PROPS_MAP = { - // p: /\b0(px)?\b/, - // pr: /\b0(px)?\b/, - // pt: /\b0(px)?\b/, - // pb: /\b0(px)?\b/, - // px: /\b0(px)?\b/, - // py: /\b0(px)?\b/, - // pl: /\b0(px)?\b/, - // m: /\b0(px)?\b/, - // mt: /\b0(px)?\b/, - // mb: /\b0(px)?\b/, - // mr: /\b0(px)?\b/, - // ml: /\b0(px)?\b/, - // mx: /\b0(px)?\b/, - // my: /\b0(px)?\b/, - textDecorationSkipInk: /\bauto\b/, - textDecorationThickness: /\bauto\b/, - textDecorationStyle: /\bsolid\b/, - textDecorationColor: /\bauto\b/, - textUnderlineOffset: /\bauto\b/, - alignItems: /\bflex-start\b/, - justifyContent: /\bflex-start\b/, - flexDir: /\brow\b/, - gap: /\b0(px)?\b/, -} as const +const DEFAULT_PROPS_MAP: Record> = { + // p: new Set(['0', '0px']), + // pr: new Set(['0', '0px']), + // pt: new Set(['0', '0px']), + // pb: new Set(['0', '0px']), + // px: new Set(['0', '0px']), + // py: new Set(['0', '0px']), + // pl: new Set(['0', '0px']), + // m: new Set(['0', '0px']), + // mt: new Set(['0', '0px']), + // mb: new Set(['0', '0px']), + // mr: new Set(['0', '0px']), + // ml: new Set(['0', '0px']), + // mx: new Set(['0', '0px']), + // my: new Set(['0', '0px']), + textDecorationSkipInk: new Set(['auto']), + textDecorationThickness: new Set(['auto']), + textDecorationStyle: new Set(['solid']), + textDecorationColor: new Set(['auto']), + textUnderlineOffset: new Set(['auto']), + alignItems: new Set(['flex-start']), + justifyContent: new Set(['flex-start']), + flexDir: new Set(['row']), + gap: new Set(['0', '0px']), +} export function isDefaultProp(prop: string, value: unknown) { // Don't filter arrays (responsive values) if (Array.isArray(value)) return false - return ( - prop in DEFAULT_PROPS_MAP && - DEFAULT_PROPS_MAP[prop as keyof typeof DEFAULT_PROPS_MAP].test( - String(value), - ) - ) + const defaults = DEFAULT_PROPS_MAP[prop] + return defaults?.has(String(value)) ?? false } diff --git a/src/codegen/utils/padding-left-multiline.ts b/src/codegen/utils/padding-left-multiline.ts index f2e0b3a..7541442 100644 --- a/src/codegen/utils/padding-left-multiline.ts +++ b/src/codegen/utils/padding-left-multiline.ts @@ -5,10 +5,8 @@ import { space } from '../../utils' */ export function paddingLeftMultiline(code: string, depth = 0) { if (depth === 0) return code - return code - .split('\n') - .map((line) => space(depth) + line) - .join('\n') + const indent = space(depth) + return indent + code.replaceAll('\n', `\n${indent}`) } /** diff --git a/src/codegen/utils/paint-to-css.ts b/src/codegen/utils/paint-to-css.ts index b2f2fd0..4874170 100644 --- a/src/codegen/utils/paint-to-css.ts +++ b/src/codegen/utils/paint-to-css.ts @@ -3,7 +3,7 @@ import { rgbaToHex } from '../../utils/rgba-to-hex' import { toCamel } from '../../utils/to-camel' import { checkAssetNode } from './check-asset-node' import { fmtPct } from './fmtPct' -import { solidToString } from './solid-to-string' +import { solidToString, solidToStringSync } from './solid-to-string' import { getVariableByIdCached } from './variable-cache' import { buildCssUrl } from './wrap-url' @@ -42,6 +42,23 @@ async function processGradientStopColor( return optimizeHex(rgbaToHex(colorWithOpacity)) } +/** + * Synchronous fast path for processGradientStopColor. + * Returns null when the stop has a variable-bound color (caller must use async). + */ +function processGradientStopColorSync( + stop: ColorStop, + opacity: number, +): string | null { + if (stop.boundVariables?.color) return null + + const colorWithOpacity = figma.util.rgba({ + ...stop.color, + a: stop.color.a * opacity, + }) + return optimizeHex(rgbaToHex(colorWithOpacity)) +} + /** * Map gradient stops to CSS color strings with positions */ @@ -50,6 +67,20 @@ async function mapSimpleGradientStops( opacity: number, positionMultiplier: number = 100, ): Promise { + // Sync fast path: try to resolve all stops without async + const syncResults: string[] = [] + let allSync = true + for (const stop of stops) { + const color = processGradientStopColorSync(stop, opacity) + if (color === null) { + allSync = false + break + } + syncResults.push(`${color} ${fmtPct(stop.position * positionMultiplier)}%`) + } + if (allSync) return syncResults.join(', ') + + // Async fallback for variable-bound colors const stopsArray = await Promise.all( stops.map(async (stop) => { const colorString = await processGradientStopColor(stop, opacity) @@ -89,6 +120,35 @@ export async function paintToCSS( } } +/** + * Synchronous fast path for paintToCSS. + * Returns the CSS string immediately for SOLID (non-variable) and IMAGE fills. + * Returns undefined to signal "not handled synchronously — caller must use async paintToCSS". + */ +export function paintToCSSSyncIfPossible( + fill: Paint, + _node: SceneNode, + last: boolean, +): string | null | undefined { + switch (fill.type) { + case 'SOLID': { + if (last) { + return solidToStringSync(fill) ?? undefined + } + // Non-last solid needs linear-gradient wrapper — check sync path + if (fill.opacity === 0) return 'transparent' + const color = solidToStringSync(fill) + if (color === null) return undefined // variable-bound, need async + return `linear-gradient(${color}, ${color})` + } + case 'IMAGE': + return convertImage(fill) + default: + // Gradients and patterns need async + return undefined + } +} + function convertImage(fill: ImagePaint): string { if (!fill.visible) return 'transparent' if (fill.opacity === 0) return 'transparent' @@ -381,27 +441,44 @@ async function _mapGradientStops( } const cssLengthSquared = cssVector.x ** 2 + cssVector.y ** 2 - return await Promise.all( - stops.map(async (stop) => { - const offsetX = figmaStartPoint.x + figmaVector.x * stop.position - const offsetY = figmaStartPoint.y + figmaVector.y * stop.position + const mapStop = (stop: ColorStop, colorString: string) => { + const offsetX = figmaStartPoint.x + figmaVector.x * stop.position + const offsetY = figmaStartPoint.y + figmaVector.y * stop.position - const pointFromStart = { - x: offsetX - cssStartPoint.x, - y: offsetY - cssStartPoint.y, - } - const dot = - pointFromStart.x * cssVector.x + pointFromStart.y * cssVector.y - const relativePosition = - cssLengthSquared === 0 ? 0 : dot / cssLengthSquared + const pointFromStart = { + x: offsetX - cssStartPoint.x, + y: offsetY - cssStartPoint.y, + } + const dot = pointFromStart.x * cssVector.x + pointFromStart.y * cssVector.y + const relativePosition = cssLengthSquared === 0 ? 0 : dot / cssLengthSquared - const colorString = await processGradientStopColor(stop, opacity) + return { + position: relativePosition, + colorString, + hasToken: !!stop.boundVariables?.color, + } + } - return { - position: relativePosition, - colorString, - hasToken: !!stop.boundVariables?.color, - } + // Sync fast path: try to resolve all stop colors without async + const syncResults: string[] = [] + let allSync = true + for (const stop of stops) { + const color = processGradientStopColorSync(stop, opacity) + if (color === null) { + allSync = false + break + } + syncResults.push(color) + } + if (allSync) { + return stops.map((stop, i) => mapStop(stop, syncResults[i])) + } + + // Async fallback for variable-bound colors + return Promise.all( + stops.map(async (stop) => { + const colorString = await processGradientStopColor(stop, opacity) + return mapStop(stop, colorString) }), ) } diff --git a/src/codegen/utils/props-to-str.ts b/src/codegen/utils/props-to-str.ts index 3893adc..4eff6e4 100644 --- a/src/codegen/utils/props-to-str.ts +++ b/src/codegen/utils/props-to-str.ts @@ -5,8 +5,28 @@ import { isVariantPropValue } from '../responsive' * If not, it needs to be quoted when used as an object key. */ function needsQuotes(key: string): boolean { - // Valid identifier: starts with letter/$/_, contains only letters/digits/$/_ - return !/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) + if (key.length === 0) return true + const first = key.charCodeAt(0) + // Must start with a-z, A-Z, _, $ + if ( + !(first >= 97 && first <= 122) && + !(first >= 65 && first <= 90) && + first !== 95 && + first !== 36 + ) + return true + for (let i = 1; i < key.length; i++) { + const c = key.charCodeAt(i) + if ( + !(c >= 97 && c <= 122) && + !(c >= 65 && c <= 90) && + !(c >= 48 && c <= 57) && + c !== 95 && + c !== 36 + ) + return true + } + return false } /** @@ -179,8 +199,10 @@ function formatObjectValue(value: unknown, indent: number): string { export function propsToString(props: Record) { const sorted = Object.entries(props).sort((a, b) => { - const isAUpper = /^[A-Z]/.test(a[0]) - const isBUpper = /^[A-Z]/.test(b[0]) + const aCode = a[0].charCodeAt(0) + const bCode = b[0].charCodeAt(0) + const isAUpper = aCode >= 65 && aCode <= 90 + const isBUpper = bCode >= 65 && bCode <= 90 if (isAUpper && !isBUpper) return -1 if (!isAUpper && isBUpper) return 1 return a[0].localeCompare(b[0]) diff --git a/src/codegen/utils/solid-to-string.ts b/src/codegen/utils/solid-to-string.ts index 5abb0f1..fd071b0 100644 --- a/src/codegen/utils/solid-to-string.ts +++ b/src/codegen/utils/solid-to-string.ts @@ -3,6 +3,24 @@ import { rgbaToHex } from '../../utils/rgba-to-hex' import { toCamel } from '../../utils/to-camel' import { getVariableByIdCached } from './variable-cache' +/** + * Synchronous fast path for solidToString. + * Returns the color string immediately for non-variable paints. + * Returns null when the paint is variable-bound (caller must use async solidToString). + */ +export function solidToStringSync(solid: SolidPaint): string | null { + if (solid.boundVariables?.color) return null + if (solid.opacity === 0) return 'transparent' + return optimizeHex( + rgbaToHex( + figma.util.rgba({ + ...solid.color, + a: solid.opacity ?? 1, + }), + ), + ) +} + export async function solidToString(solid: SolidPaint) { if (solid.boundVariables?.color) { const variable = await getVariableByIdCached( diff --git a/src/utils.ts b/src/utils.ts index 9e9f0e1..7e4016a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,29 +3,56 @@ import { toPascal } from './utils/to-pascal' // Cache for figma.getLocalTextStylesAsync() — called once per codegen run let localTextStyleIdsCache: Promise> | null = null +let localTextStyleIdsResolved: Set | null = null function getLocalTextStyleIds(): Promise> { if (localTextStyleIdsCache) return localTextStyleIdsCache localTextStyleIdsCache = Promise.resolve( figma.getLocalTextStylesAsync(), - ).then((styles) => new Set(styles.map((s) => s.id))) + ).then((styles) => { + const set = new Set(styles.map((s) => s.id)) + localTextStyleIdsResolved = set + return set + }) return localTextStyleIdsCache } // Cache for figma.getStyleByIdAsync() — keyed by style ID const styleByIdCache = new Map>() +const styleByIdResolved = new Map() function getStyleByIdCached(styleId: string): Promise { const cached = styleByIdCache.get(styleId) if (cached) return cached - const promise = Promise.resolve(figma.getStyleByIdAsync(styleId)) + const promise = Promise.resolve(figma.getStyleByIdAsync(styleId)).then( + (s) => { + styleByIdResolved.set(styleId, s) + return s + }, + ) styleByIdCache.set(styleId, promise) return promise } export function resetTextStyleCache(): void { localTextStyleIdsCache = null + localTextStyleIdsResolved = null styleByIdCache.clear() + styleByIdResolved.clear() +} + +function applyTypographyStyle( + ret: Record, + style: BaseStyle, +): void { + const split = style.name.split('/') + ret.typography = toCamel(split[split.length - 1]) + delete ret.fontFamily + delete ret.fontSize + delete ret.fontWeight + delete ret.fontStyle + delete ret.letterSpacing + delete ret.lineHeight } export async function propsToPropsWithTypography( @@ -35,25 +62,35 @@ export async function propsToPropsWithTypography( const ret: Record = { ...props } delete ret.w delete ret.h + + // Sync fast path: if both caches are resolved, skip await entirely + if (localTextStyleIdsResolved !== null) { + if (textStyleId && localTextStyleIdsResolved.has(textStyleId)) { + const style = styleByIdResolved.get(textStyleId) + if (style !== undefined) { + if (style) applyTypographyStyle(ret, style) + return ret + } + // Style not yet resolved — fall through to async + } else { + return ret + } + } + + // Async fallback (first call or style not yet in resolved cache) const localStyleIds = await getLocalTextStyleIds() if (textStyleId && localStyleIds.has(textStyleId)) { const style = await getStyleByIdCached(textStyleId) - if (style) { - const split = style.name.split('/') - ret.typography = toCamel(split[split.length - 1]) - delete ret.fontFamily - delete ret.fontSize - delete ret.fontWeight - delete ret.fontStyle - delete ret.letterSpacing - delete ret.lineHeight - } + if (style) applyTypographyStyle(ret, style) } return ret } +const _spaceCache: string[] = [] export function space(depth: number) { - return ' '.repeat(depth * 2) + if (_spaceCache[depth] === undefined) + _spaceCache[depth] = ' '.repeat(depth * 2) + return _spaceCache[depth] } export function getComponentName(node: SceneNode) {