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))
}
}