Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 40 additions & 36 deletions bun.lock

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@
"author": "",
"license": "",
"devDependencies": {
"@figma/plugin-typings": "^1.131",
"@rspack/cli": "^2.1.4",
"@rspack/core": "^2.1.4",
"@figma/plugin-typings": "^1.137",
"@rspack/cli": "^2.2.2",
"@rspack/core": "^2.2.2",

"husky": "^9.1",
"typescript": "^7.0",
"@biomejs/biome": "^2.5",
"@types/bun": "^1.3"
"@types/bun": "^1.4"
},
"dependencies": {
"jszip": "^3.10"
Expand Down
54 changes: 54 additions & 0 deletions src/__tests__/code.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2052,3 +2052,57 @@ describe('registerCodegen with usage output', () => {
expect(usageCode).toBe('<MyButton variant="primary" size="md" />')
})
})

describe('registerCodegen error reporting', () => {
type CodegenHandler = (event: {
node: SceneNode
language: string
}) => Promise<unknown[]>

it('returns a copyable diagnostic report instead of rejecting', async () => {
let capturedHandler: CodegenHandler | null = null
const figmaMock = {
editorType: 'dev',
mode: 'codegen',
command: 'noop',
codegen: {
on: mock((_event: string, handler: CodegenHandler) => {
capturedHandler = handler
}),
},
closePlugin: mock(() => {}),
} as unknown as typeof figma

const consoleError = spyOn(console, 'error').mockImplementation(() => {})

codeModule.registerCodegen(figmaMock)
expect(capturedHandler).not.toBeNull()
if (capturedHandler === null) throw new Error('Handler not captured')

// A TEXT node without getStyledTextSegments makes renderText throw the same
// way the minified bundle does in Figma.
const brokenText = {
id: '1:99',
type: 'TEXT',
name: 'Broken',
visible: true,
characters: 'hello',
} as unknown as SceneNode

const handler = capturedHandler as CodegenHandler
const result = await handler({ node: brokenText, language: 'devup-ui' })

expect(result).toHaveLength(1)
const [entry] = result as [
{ title: string; language: string; code: string },
]
expect(entry.title).toBe('Devup UI - Codegen Error')
expect(entry.language).toBe('PLAINTEXT')
expect(entry.code).toContain('DEVUP UI CODEGEN ERROR')
expect(entry.code).toContain('language : devup-ui')
expect(entry.code).toContain('selected : TEXT "Broken" (1:99)')
expect(entry.code).toContain('--- codegen path (innermost first) ---')
expect(entry.code).toContain('buildTree :: TEXT "Broken" (1:99)')
expect(consoleError).toHaveBeenCalled()
})
})
62 changes: 32 additions & 30 deletions src/code-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,20 @@ import {
Codegen,
DEFAULT_CODEGEN_OPTIONS,
resetGlobalBuildTreeCache,
resetMainComponentCache,
} from './codegen/Codegen'
import { resetGetPropsCache } from './codegen/props'
import { resetChildAnimationCache } from './codegen/props/reaction'
import {
resetSelectorPropsCache,
sanitizePropertyName,
} from './codegen/props/selector'
import { sanitizePropertyName } from './codegen/props/selector'
import { resetCodegenCaches } from './codegen/reset-caches'
import { ResponsiveCodegen } from './codegen/responsive/ResponsiveCodegen'
import {
coerceBooleanVariantValue,
isBooleanVariantOptions,
} from './codegen/utils/boolean-variant'
import { resetCheckAssetNodeCache } from './codegen/utils/check-asset-node'
import { resetCheckSameColorCache } from './codegen/utils/check-same-color'
import type { ImportMetadata } from './codegen/utils/collect-import-metadata'
import { formatCodegenErrorReport } from './codegen/utils/diagnostics'
import { isReservedVariantKey } from './codegen/utils/extract-instance-variant-props'
import {
getComponentPropertyDefinitions,
resetComponentPropertyDefinitionsCache,
} from './codegen/utils/get-component-property-definitions'
import { resetGetPageNodeCache } from './codegen/utils/get-page-node'
import { getComponentPropertyDefinitions } from './codegen/utils/get-component-property-definitions'
import { nodeProxyTracker } from './codegen/utils/node-proxy'
import { resetPaintToCssCache } from './codegen/utils/paint-to-css'
import { perfEnd, perfReport, perfReset, perfStart } from './codegen/utils/perf'
import { resetVariableCache } from './codegen/utils/variable-cache'
import { wrapComponent } from './codegen/utils/wrap-component'
import { exportDevup, importDevup } from './commands/devup'
import { exportAssets } from './commands/exportAssets'
Expand All @@ -40,7 +28,7 @@ import {

export { extractCustomComponentImports, extractImports }

import { getComponentName, resetTextStyleCache } from './utils'
import { getComponentName } from './utils'
import { toPascal } from './utils/to-pascal'

type GeneratedCodeEntry = readonly [string, string, ImportMetadata?]
Expand Down Expand Up @@ -241,26 +229,18 @@ const debug = true

export function registerCodegen(ctx: typeof figma) {
if (ctx.editorType === 'dev' && ctx.mode === 'codegen') {
ctx.codegen.on('generate', async ({ node: n, language }) => {
const handler = async ({
node: n,
language,
}: CodegenEvent): Promise<CodegenResult[]> => {
// Use the raw node for codegen (no Proxy overhead).
// Debug tracking happens AFTER codegen completes via separate walk.
const node = n
switch (language) {
case 'devup-ui': {
const time = Date.now()
perfReset()
resetGetPropsCache()
resetSelectorPropsCache()
resetChildAnimationCache()
resetVariableCache()
resetCheckAssetNodeCache()
resetCheckSameColorCache()
resetPaintToCssCache()
resetGetPageNodeCache()
resetComponentPropertyDefinitionsCache()
resetTextStyleCache()
resetMainComponentCache()
resetGlobalBuildTreeCache()
resetCodegenCaches()

let t = perfStart()
const codegen = new Codegen(node, DEFAULT_CODEGEN_OPTIONS)
Expand Down Expand Up @@ -594,6 +574,28 @@ export function registerCodegen(ctx: typeof figma) {
}
}
return []
}

ctx.codegen.on('generate', async (event) => {
try {
return await handler(event)
} catch (error) {
// The bundle is minified, so Figma's own stack trace is unreadable and
// the throw surfaces only as "unhandled promise rejection". Surface a
// structured report in the codegen panel instead, so it can be copied.
const report = formatCodegenErrorReport(error, {
node: event.node,
language: event.language,
})
console.error(report)
return [
{
title: 'Devup UI - Codegen Error',
language: 'PLAINTEXT' as const,
code: report,
},
]
}
})
}
}
Expand Down
24 changes: 22 additions & 2 deletions src/codegen/Codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type ImportMetadata,
mergeImportMetadata,
} from './utils/collect-import-metadata'
import { traceAsync, traceSync } from './utils/diagnostics'
import { extractInstanceVariantProps } from './utils/extract-instance-variant-props'
import { getComponentPropertyDefinitions } from './utils/get-component-property-definitions'
import {
Expand Down Expand Up @@ -500,7 +501,11 @@ export class Codegen {
return result
}

private async doBuildTree(node: SceneNode): Promise<NodeTree> {
private doBuildTree(node: SceneNode): Promise<NodeTree> {
return traceAsync('buildTree', node, () => this.doBuildTreeInner(node))
}

private async doBuildTreeInner(node: SceneNode): Promise<NodeTree> {
const tBuild = perfStart()
let pendingAddComponentTreeNode: ComponentNode | null = null

Expand Down Expand Up @@ -912,7 +917,16 @@ export class Codegen {
return promise
}

private async doAddComponentTree(
private doAddComponentTree(
node: ComponentNode,
nodeId: string,
): Promise<void> {
return traceAsync('addComponentTree', node, () =>
this.doAddComponentTreeInner(node, nodeId),
)
}

private async doAddComponentTreeInner(
node: ComponentNode,
nodeId: string,
): Promise<void> {
Expand Down Expand Up @@ -1102,6 +1116,12 @@ export class Codegen {
* Static method so it can be used independently.
*/
static renderTree(tree: NodeTree, depth: number = 0): string {
return traceSync('renderTree', tree, () =>
Codegen.renderTreeInner(tree, depth),
)
}

private static renderTreeInner(tree: NodeTree, depth: number): string {
// Handle INSTANCE_SWAP slot placeholders — render as {propName}
if (tree.isSlot) {
if (tree.condition) {
Expand Down
17 changes: 6 additions & 11 deletions src/codegen/__tests__/codegen-viewport.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import { afterAll, beforeEach, describe, expect, test } from 'bun:test'
import { resetTextStyleCache } from '../../utils'
import {
Codegen,
getGlobalAssetNodes,
resetGlobalAssetNodes,
resetGlobalBuildTreeCache,
} from '../Codegen'
import { resetGetPropsCache } from '../props'
import { resetSelectorPropsCache } from '../props/selector'
import { resetCodegenCaches } from '../reset-caches'
import { ResponsiveCodegen } from '../responsive/ResponsiveCodegen'

// Mock figma global
Expand Down Expand Up @@ -50,20 +48,17 @@ import { ResponsiveCodegen } from '../responsive/ResponsiveCodegen'
},
} as unknown as typeof figma

// Every codegen cache is keyed by node.id, and other test files reuse ids such
// as `status-error`. Reset all of them — exactly like a real codegen run does —
// so results never depend on which test file ran first.
beforeEach(() => {
resetGlobalBuildTreeCache()
resetCodegenCaches()
resetGlobalAssetNodes()
resetGetPropsCache()
resetSelectorPropsCache()
resetTextStyleCache()
})

afterAll(() => {
resetGlobalBuildTreeCache()
resetCodegenCaches()
resetGlobalAssetNodes()
resetGetPropsCache()
resetSelectorPropsCache()
resetTextStyleCache()
;(globalThis as { figma?: unknown }).figma = undefined
})

Expand Down
28 changes: 28 additions & 0 deletions src/codegen/__tests__/codegen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterAll, describe, expect, it, test } from 'bun:test'
import { getComponentName } from '../../utils'
import { toPascal } from '../../utils/to-pascal'
import { Codegen, DEFAULT_CODEGEN_OPTIONS } from '../Codegen'
import { renderText } from '../render/text'
import { ResponsiveCodegen } from '../responsive/ResponsiveCodegen'
import type { NodeTree } from '../types'
import { collectComponentProps } from '../utils/collect-component-props'
Expand Down Expand Up @@ -61237,3 +61238,30 @@ describe('render real world component', () => {
}
})
})

describe('renderText with segments missing listOptions', () => {
it('treats a segment without listOptions as a plain (non-list) segment', async () => {
// Figma returns `listOptions: undefined` — not `{ type: 'NONE' }` — for the
// non-list part of a TEXT node that also contains a bulleted list.
const plain = createTextSegment('Heading\n')
;(plain as unknown as { listOptions?: unknown }).listOptions = undefined
const bullet = createTextSegment('list item')
;(bullet as unknown as { listOptions?: unknown }).listOptions = {
type: 'UNORDERED',
}

const node = {
id: '1:1',
type: 'TEXT',
name: 'Mixed',
getStyledTextSegments: () => [plain, bullet],
} as unknown as TextNode

const { children } = await renderText(node)
const joined = children.join('')
expect(joined).toContain('Heading')
expect(joined).toContain('<Text as="ul"')
expect(joined).toContain('<li>')
expect(joined).toContain('list item')
})
})
30 changes: 30 additions & 0 deletions src/codegen/props/__tests__/bound-variables.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,36 @@ describe('length bound variables (padding / gap / size / radius)', () => {
})
})

test.each([
['SPACE_EVENLY', 'space-evenly'],
['SPACE_AROUND', 'space-around'],
] as const)(
'getAutoLayoutProps maps %s distribution to %s',
async (primaryAxisAlignItems, justifyContent) => {
setupFigmaMocks()

const node = {
type: 'FRAME',
inferredAutoLayout: {
layoutMode: 'HORIZONTAL',
itemSpacing: 8,
},
primaryAxisAlignItems,
counterAxisAlignItems: 'CENTER',
children: [{ visible: true }, { visible: true }],
boundVariables: {},
} as unknown as SceneNode

expect(await getAutoLayoutProps(node)).toEqual({
display: 'flex',
flexDir: 'row',
gap: '8px',
justifyContent,
alignItems: 'center',
})
},
)

test('getLayoutProps resolves width/height variables on absolute nodes', async () => {
setupFigmaMocks({
variableNamesById: {
Expand Down
2 changes: 2 additions & 0 deletions src/codegen/props/auto-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ function getJustifyContent(
MAX: 'flex-end',
CENTER: 'center',
SPACE_BETWEEN: 'space-between',
SPACE_AROUND: 'space-around',
SPACE_EVENLY: 'space-evenly',
}[node.primaryAxisAlignItems]
}

Expand Down
Loading