diff --git a/src/__tests__/__snapshots__/code.test.ts.snap b/src/__tests__/__snapshots__/code.test.ts.snap index 1232b6d..8a10f6b 100644 --- a/src/__tests__/__snapshots__/code.test.ts.snap +++ b/src/__tests__/__snapshots__/code.test.ts.snap @@ -2,21 +2,65 @@ exports[`registerCodegen should register codegen 1`] = ` [ + { + "code": "", + "language": "TYPESCRIPT", + "title": "Pure Code", + }, { "code": "", "language": "TYPESCRIPT", "title": "Usage", }, { - "code": "", + "code": +"import { Box } from '@devup-ui/react' + +export function Test() { + return +}" +, "language": "TYPESCRIPT", "title": "Test", }, + { + "code": +"mkdir -p src/components + +echo 'import { Box } from \\'@devup-ui/react\\' + +export function Test() { + return +}' > src/components/Test.tsx" +, + "language": "BASH", + "title": "Test - CLI (Bash)", + }, + { + "code": +"New-Item -ItemType Directory -Force -Path src\\components | Out-Null + +@' +import { Box } from '@devup-ui/react' + +export function Test() { + return +} +'@ | Out-File -FilePath src\\components\\Test.tsx -Encoding UTF8" +, + "language": "BASH", + "title": "Test - CLI (PowerShell)", + }, ] `; exports[`registerCodegen should register codegen 2`] = ` [ + { + "code": "", + "language": "TYPESCRIPT", + "title": "Pure Code", + }, { "code": "", "language": "TYPESCRIPT", @@ -35,6 +79,16 @@ exports[`registerCodegen should generate responsive code when root node is SECTI " +, + "language": "TYPESCRIPT", + "title": "Pure Code", + }, + { + "code": +" + + +" , "language": "TYPESCRIPT", "title": "ResponsiveSection", diff --git a/src/__tests__/code-responsive.test.ts b/src/__tests__/code-responsive.test.ts index 47310a8..0f75811 100644 --- a/src/__tests__/code-responsive.test.ts +++ b/src/__tests__/code-responsive.test.ts @@ -74,7 +74,14 @@ describe('registerCodegen responsive error handling', () => { expect(consoleErrorMock).toHaveBeenCalled() expect(runMock).toHaveBeenCalled() + // Pure Code is generated via a separate Codegen instance whose run()/getCode() + // hit the same prototype mocks, so its code is also 'base-code'. expect(result).toEqual([ + { + title: 'Pure Code', + language: 'TYPESCRIPT', + code: 'base-code', + }, { title: 'Main', language: 'TYPESCRIPT', @@ -83,3 +90,73 @@ describe('registerCodegen responsive error handling', () => { ]) }) }) + +describe('registerCodegen pure code error handling', () => { + // Throws ONLY for the Pure Code Codegen instance (inlineAllInstances=true), + // letting the main codegen run normally so we can isolate the catch branch. + const pureCodeRunMock = mock(async function (this: { + options?: { inlineAllInstances?: boolean } + }) { + if (this.options?.inlineAllInstances) { + throw new Error('pure-code-boom') + } + }) + + beforeEach(() => { + Codegen.prototype.run = + pureCodeRunMock as unknown as typeof Codegen.prototype.run + Codegen.prototype.getComponentsCodes = getComponentsCodesMock + Codegen.prototype.getCode = getCodeMock + + console.error = consoleErrorMock as typeof console.error + resetFigma() + }) + + afterEach(() => { + Codegen.prototype.run = originalRun + Codegen.prototype.getComponentsCodes = originalGetComponentsCodes + Codegen.prototype.getCode = originalGetCode + + console.error = originalError + resetFigma() + mock.restore() + }) + + test('swallows pure code errors and omits the Pure Code entry', async () => { + const handlerCalls: ((event: CodegenEvent) => Promise)[] = + [] + const ctx = { + editorType: 'dev', + mode: 'codegen', + command: 'noop', + codegen: { + on: mock((_event, handler) => { + handlerCalls.push(handler) + }), + }, + } as unknown as typeof figma + + const node = { + type: 'FRAME', + name: 'PureFail', + } as unknown as SceneNode + + registerCodegen(ctx) + + const generate = handlerCalls[0] + const result = await generate({ node, language: 'devup-ui' }) + + // Pure Code generation threw → console.error captured the failure. + expect(consoleErrorMock).toHaveBeenCalled() + + // Pure Code entry must be ABSENT in the result. + expect( + result.find((r) => (r as { title?: string }).title === 'Pure Code'), + ).toBeUndefined() + + // Main code remains present (FRAME → showMainCode true). + expect( + result.find((r) => (r as { title?: string }).title === 'PureFail'), + ).toBeDefined() + }) +}) diff --git a/src/__tests__/code.test.ts b/src/__tests__/code.test.ts index bb01905..f08eae7 100644 --- a/src/__tests__/code.test.ts +++ b/src/__tests__/code.test.ts @@ -1688,6 +1688,292 @@ describe('registerCodegen with usage output', () => { const usageCode = (usageResult as { code: string }).code expect(usageCode).toBe('') + + // Regression guard: COMPONENT root must emit a wrapped function + // definition (export function ...) — never raw JSX from getCode(). + const definitionResult = result.find( + (r: unknown) => + typeof r === 'object' && + r !== null && + 'title' in r && + (r as { title: string }).title === 'MyButton', + ) + expect(definitionResult).toBeDefined() + const definitionCode = (definitionResult as { code: string }).code + expect(definitionCode).toContain('export function MyButton') + expect(definitionCode).not.toMatch(/^<[A-Z]/) + }) + + it('should emit wrapped definition + import + CLI for standalone COMPONENT (regression)', async () => { + let capturedHandler: CodegenHandler | null = null + + const figmaMock = { + editorType: 'dev', + mode: 'codegen', + command: 'noop', + codegen: { + on: (_event: string, handler: CodegenHandler) => { + capturedHandler = handler + }, + }, + closePlugin: mock(() => {}), + } as unknown as typeof figma + + codeModule.registerCodegen(figmaMock) + + expect(capturedHandler).not.toBeNull() + if (capturedHandler === null) throw new Error('Handler not captured') + + // Standalone COMPONENT (no parent COMPONENT_SET, no variants) — mirrors + // the user-reported BottomSheet case where the function wrapper was missing. + const componentNode = { + type: 'COMPONENT', + name: 'BasicCard', + visible: true, + variantProperties: null, + children: [], + layoutMode: 'VERTICAL', + width: 100, + height: 100, + componentPropertyDefinitions: {}, + reactions: [], + parent: null, + } as unknown as SceneNode + + const handler = capturedHandler as CodegenHandler + const result = await handler({ + node: componentNode, + language: 'devup-ui', + }) + + // Usage must remain + const usageResult = result.find( + (r: unknown) => + typeof r === 'object' && + r !== null && + 'title' in r && + (r as { title: string }).title === 'Usage', + ) + expect(usageResult).toBeDefined() + expect((usageResult as { code: string }).code).toBe('') + + // Definition must contain wrapped function + devup-ui import. + // Title for a single-component definition equals the component name. + const definitionResult = result.find( + (r: unknown) => + typeof r === 'object' && + r !== null && + 'title' in r && + (r as { title: string }).title === 'BasicCard', + ) + expect(definitionResult).toBeDefined() + const definitionCode = (definitionResult as { code: string }).code + expect(definitionCode).toContain('export function BasicCard') + expect(definitionCode).toContain("from '@devup-ui/react'") + + // Regression: definition must NOT start with a raw JSX tag (which would + // be the unwrapped output of codegen.getCode()). + expect(definitionCode).not.toMatch(/^<[A-Z]/) + + // Bash + PowerShell CLI entries must exist for the COMPONENT. + const bashCLI = result.find( + (r: unknown) => + typeof r === 'object' && + r !== null && + 'title' in r && + (r as { title: string }).title === 'BasicCard - CLI (Bash)', + ) + expect(bashCLI).toBeDefined() + expect((bashCLI as { code: string }).code).toContain( + 'src/components/BasicCard.tsx', + ) + + const powershellCLI = result.find( + (r: unknown) => + typeof r === 'object' && + r !== null && + 'title' in r && + (r as { title: string }).title === 'BasicCard - CLI (PowerShell)', + ) + expect(powershellCLI).toBeDefined() + expect((powershellCLI as { code: string }).code).toContain( + 'src\\components\\BasicCard.tsx', + ) + + // Regression: there must be NO entry titled `${node.name}` that contains + // an un-wrapped main code (i.e., the old codegen.getCode() output that + // caused the bug). The only entry titled 'BasicCard' is the wrapped + // definition checked above — already asserted to start with non-` + typeof r === 'object' && + r !== null && + 'title' in r && + (r as { title: string }).title === 'Pure Code', + ) + expect(pureCodeResult).toBeDefined() + expect(result[0]).toBe(pureCodeResult) + const pureCode = (pureCodeResult as { code: string }).code + expect(pureCode).not.toContain('export function') + expect(pureCode).not.toContain("from '@devup-ui/react'") + }) + + it('should emit Pure Code section as first entry for every node type (regression)', async () => { + let capturedHandler: CodegenHandler | null = null + + const figmaMock = { + editorType: 'dev', + mode: 'codegen', + command: 'noop', + codegen: { + on: (_event: string, handler: CodegenHandler) => { + capturedHandler = handler + }, + }, + closePlugin: mock(() => {}), + } as unknown as typeof figma + + codeModule.registerCodegen(figmaMock) + + expect(capturedHandler).not.toBeNull() + if (capturedHandler === null) throw new Error('Handler not captured') + + const handler = capturedHandler as CodegenHandler + + // 1. Plain FRAME — Pure Code must exist as first entry. + const frameNode = { + type: 'FRAME', + name: 'PlainFrame', + visible: true, + children: [], + width: 100, + height: 100, + layoutMode: 'VERTICAL', + } as unknown as SceneNode + + const frameResult = await handler({ + node: frameNode, + language: 'devup-ui', + }) + expect((frameResult[0] as { title: string } | undefined)?.title).toBe( + 'Pure Code', + ) + + // 2. Standalone COMPONENT — Pure Code must exist as first entry. + const componentNode = { + type: 'COMPONENT', + name: 'PlainComponent', + visible: true, + variantProperties: null, + children: [], + width: 100, + height: 100, + layoutMode: 'VERTICAL', + componentPropertyDefinitions: {}, + reactions: [], + parent: null, + } as unknown as SceneNode + + const componentResult = await handler({ + node: componentNode, + language: 'devup-ui', + }) + expect((componentResult[0] as { title: string } | undefined)?.title).toBe( + 'Pure Code', + ) + }) + + it('should inline-expand INSTANCE in Pure Code (no component references)', async () => { + let capturedHandler: CodegenHandler | null = null + + const figmaMock = { + editorType: 'dev', + mode: 'codegen', + command: 'noop', + codegen: { + on: (_event: string, handler: CodegenHandler) => { + capturedHandler = handler + }, + }, + closePlugin: mock(() => {}), + } as unknown as typeof figma + + codeModule.registerCodegen(figmaMock) + + expect(capturedHandler).not.toBeNull() + if (capturedHandler === null) throw new Error('Handler not captured') + + // Build a COMPONENT with an inner Box (so inline-expansion produces JSX). + const innerBox = { + type: 'FRAME', + name: 'InnerBox', + visible: true, + children: [], + width: 50, + height: 50, + layoutMode: 'NONE', + } + + const mainComponent = { + type: 'COMPONENT', + name: 'CustomChip', + visible: true, + variantProperties: null, + children: [innerBox], + width: 100, + height: 50, + layoutMode: 'HORIZONTAL', + componentPropertyDefinitions: {}, + reactions: [], + parent: null, + } as unknown as ComponentNode + + const instanceNode = { + type: 'INSTANCE', + name: 'CustomChip', + visible: true, + width: 100, + height: 50, + componentProperties: {}, + getMainComponentAsync: async () => mainComponent, + } + + const frameNode = { + type: 'FRAME', + name: 'WrapperFrame', + visible: true, + children: [instanceNode], + width: 200, + height: 200, + layoutMode: 'VERTICAL', + } as unknown as SceneNode + + const handler = capturedHandler as CodegenHandler + const result = await handler({ + node: frameNode, + language: 'devup-ui', + }) + + // Pure Code must be the first entry. + const pureCodeResult = result.find( + (r: unknown) => + typeof r === 'object' && + r !== null && + 'title' in r && + (r as { title: string }).title === 'Pure Code', + ) + expect(pureCodeResult).toBeDefined() + expect(result[0]).toBe(pureCodeResult) + + const pureCode = (pureCodeResult as { code: string }).code + // INSTANCE reference must NOT appear in Pure Code — it should be inlined. + expect(pureCode).not.toContain(' { diff --git a/src/code-impl.ts b/src/code-impl.ts index 09dcb8f..4f8e60a 100644 --- a/src/code-impl.ts +++ b/src/code-impl.ts @@ -457,14 +457,19 @@ export function registerCodegen(ctx: typeof figma) { ) : componentsCodes - // For INSTANCE nodes, include the referenced component definition(s) - // alongside Usage so developers see both how to use AND the implementation. + // For INSTANCE and COMPONENT nodes, include the referenced component + // definition(s) alongside Usage so developers see both how to use AND + // the implementation. For COMPONENT, the self-definition is in + // mergedComponentsCodes because addComponentTree() registers it. const componentDefinitionResults: { title: string language: 'TYPESCRIPT' | 'BASH' code: string }[] = [] - if (node.type === 'INSTANCE' && mergedComponentsCodes.length > 0) { + if ( + (node.type === 'INSTANCE' || node.type === 'COMPONENT') && + mergedComponentsCodes.length > 0 + ) { const importStatement = generateImportStatements( mergedComponentsCodes, ) @@ -495,9 +500,10 @@ export function registerCodegen(ctx: typeof figma) { } // Collect remaining responsive codes NOT already merged into component definitions. - // Only filter for INSTANCE nodes — other node types don't produce componentDefinitionResults. + // Filter for INSTANCE and COMPONENT — both produce componentDefinitionResults + // and need to deduplicate self/child entries from allComponentsCodes. const mergedNames = - node.type === 'INSTANCE' + node.type === 'INSTANCE' || node.type === 'COMPONENT' ? new Set(mergedComponentsCodes.map(([name]) => name)) : new Set() const allComponentsCodes = [ @@ -509,12 +515,48 @@ export function registerCodegen(ctx: typeof figma) { ), ] - // For COMPONENT nodes, show both the single-variant code AND Usage. - // For COMPONENT_SET and INSTANCE, show only Usage. + // For COMPONENT/COMPONENT_SET/INSTANCE, the wrapped definition is emitted + // via componentDefinitionResults (or Usage), so suppress the raw main code + // (codegen.getCode() returns un-wrapped JSX without the function signature). // For all other types, show the main code. - const showMainCode = !isComponentType || node.type === 'COMPONENT' + const showMainCode = !isComponentType + + // Generate "Pure Code" — raw JSX of devup-ui primitives only, with + // every INSTANCE inline-expanded and every component reference + // (SLOT/BOOLEAN-conditional) flattened. Always emitted as the + // FIRST entry so users see a copy-paste-ready primitives version. + let pureCodeResult: { + title: 'Pure Code' + language: 'TYPESCRIPT' + code: string + } | null = null + try { + // Pure Code is generated AFTER the main codegen, which leaves the + // shared globalBuildTreeCache populated with mutated trees + // (BOOLEAN conditions / SLOT placeholders). Reset before reuse. + resetGlobalBuildTreeCache() + const pureSourceNode = + node.type === 'COMPONENT_SET' + ? (node as ComponentSetNode).defaultVariant + : node + if (pureSourceNode) { + const pureCodegen = new Codegen(pureSourceNode, { + ...DEFAULT_CODEGEN_OPTIONS, + inlineAllInstances: true, + }) + await pureCodegen.run() + pureCodeResult = { + title: 'Pure Code', + language: 'TYPESCRIPT', + code: pureCodegen.getCode(), + } + } + } catch (e) { + console.error('[pure-code] Error generating pure code:', e) + } return [ + ...(pureCodeResult ? [{ ...pureCodeResult } as const] : []), ...usageResults, ...componentDefinitionResults, ...(showMainCode diff --git a/src/codegen/Codegen.ts b/src/codegen/Codegen.ts index 31f652c..3325c95 100644 --- a/src/codegen/Codegen.ts +++ b/src/codegen/Codegen.ts @@ -30,6 +30,16 @@ import { buildCssUrl } from './utils/wrap-url' export interface CodegenOptions { assetComponentInstanceMode?: 'reference' | 'inline' + /** + * "Pure Code" mode — produces raw JSX of primitives only. + * - Bypasses INSTANCE component-reference rendering (INSTANCE nodes are + * walked like ordinary FRAMEs so their children are emitted inline). + * - Skips addComponentTree() so no SLOT placeholders / BOOLEAN conditions + * are injected into the tree. + * - Drops native SLOT children and BOOLEAN-default-false children at build + * time so the output reflects the default variant's actual JSX. + */ + inlineAllInstances?: boolean } export const DEFAULT_CODEGEN_OPTIONS: CodegenOptions = { @@ -497,7 +507,10 @@ export class Codegen { // Handle COMPONENT_SET or COMPONENT — fire addComponentTree BEFORE any early returns // (e.g., asset detection) so that BOOLEAN conditions and INSTANCE_SWAP slots are always // detected on children, even when the COMPONENT itself is classified as an asset. + // Skipped entirely in Pure Code mode (inlineAllInstances) — no component + // extraction, no condition/slot injection. if ( + !this.options.inlineAllInstances && (node.type === 'COMPONENT_SET' || node.type === 'COMPONENT') && ((this.node.type === 'COMPONENT_SET' && node === this.node.defaultVariant) || @@ -526,7 +539,9 @@ export class Codegen { // skipping the expensive full getProps() with 6 async Figma API calls. // INSTANCE nodes must be checked before asset detection because icon-like // instances (containing only vectors) would otherwise be misclassified as SVG assets. - if (node.type === 'INSTANCE') { + // In Pure Code mode (inlineAllInstances), INSTANCE is walked like an ordinary + // node so its children render inline as raw primitives. + if (node.type === 'INSTANCE' && !this.options.inlineAllInstances) { const mainComponent = await getMainComponentCached(node) const variantProps = extractInstanceVariantProps(node) @@ -744,12 +759,53 @@ export class Codegen { // Fire getProps early for non-INSTANCE nodes — it runs while we process children. const propsPromise = getProps(node) + // In Pure Code mode, pre-compute BOOLEAN-default-false slots so we can + // drop children whose visibility is bound to a false-default boolean prop + // (matches the default variant's actual rendered JSX). + let pureModeHiddenBooleans: Set | null = null + if ( + this.options.inlineAllInstances && + (node.type === 'COMPONENT' || node.type === 'COMPONENT_SET') + ) { + const parentSet = + node.type === 'COMPONENT' && node.parent?.type === 'COMPONENT_SET' + ? (node.parent as ComponentSetNode) + : null + const propDefs = parentSet + ? getComponentPropertyDefinitions(parentSet) + : getComponentPropertyDefinitions( + node as ComponentNode | ComponentSetNode, + ) + for (const [key, def] of Object.entries(propDefs)) { + if (def.type === 'BOOLEAN' && !def.defaultValue) { + pureModeHiddenBooleans ??= new Set() + pureModeHiddenBooleans.add(key) + } + } + } + // Build children sequentially — Figma's single-threaded IPC means // concurrent subtree builds add overhead without improving throughput, // and sequential order maximizes cache hits for shared nodes. const children: NodeTree[] = [] if ('children' in node) { for (const child of node.children) { + if (this.options.inlineAllInstances) { + // Drop native SLOT children — they're component-definition concepts + // (render as {slotName}); raw JSX should not contain them. + if ((child.type as string) === 'SLOT') continue + // Drop BOOLEAN-default-false controlled children. + if (pureModeHiddenBooleans) { + const refs = ( + child as { + componentPropertyReferences?: Record + } + ).componentPropertyReferences + if (refs?.visible && pureModeHiddenBooleans.has(refs.visible)) { + continue + } + } + } children.push(await this.buildTree(child)) } }