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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ Figma plugin that generates React/TypeScript components using `@devup-ui/react`

- **Never treat responsive arrays as default values** — Arrays bypass `isDefaultProp` filtering (`is-default-prop.ts:27`)
- **Never pass `effect` or `viewport` as component props** — Reserved internal variant keys, handled via pseudo-selectors/responsive arrays
- **Never infer props, structure, or semantics from generated code strings** — Use `NodeTree`, variant metadata, slot metadata, or other structured intermediate data instead of regex/string scans over JSX/TS output
- **Never append rotation transforms** — Always replace entire value (`reaction.ts`)
- **Animation targets are not assets** — Nodes with `SMART_ANIMATE` reactions must not be exported as images (`check-asset-node.ts:35`)
- **Tile-mode fills are not images** — `PATTERN`/`TILE` fills are backgrounds, not exportable assets
Expand Down
536 changes: 33 additions & 503 deletions bun.lock

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
"license": "",
"devDependencies": {
"@figma/plugin-typings": "^1.124",
"@rspack/cli": "^1.7.11",
"@rspack/core": "^1.7.11",
"@rspack/cli": "^2.0.0",
"@rspack/core": "^2.0.0",

"husky": "^9.1",
"typescript": "^6.0",
Expand Down
14 changes: 7 additions & 7 deletions src/__tests__/code-responsive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { ResponsiveCodegen } from '../codegen/responsive/ResponsiveCodegen'
const runMock = mock(async () => {})
const getComponentsCodesMock = mock(() => [])
const getCodeMock = mock(() => 'base-code')
const generateResponsiveCodeMock = mock(() => {
const generateResponsiveResultMock = mock(() => {
throw new Error('boom')
})

Expand All @@ -20,16 +20,16 @@ const resetFigma = () => {
const originalRun = Codegen.prototype.run
const originalGetComponentsCodes = Codegen.prototype.getComponentsCodes
const originalGetCode = Codegen.prototype.getCode
const originalGenerateResponsiveCode =
ResponsiveCodegen.prototype.generateResponsiveCode
const originalGenerateResponsiveResult =
ResponsiveCodegen.prototype.generateResponsiveResult

describe('registerCodegen responsive error handling', () => {
beforeEach(() => {
Codegen.prototype.run = runMock as unknown as typeof Codegen.prototype.run
Codegen.prototype.getComponentsCodes = getComponentsCodesMock
Codegen.prototype.getCode = getCodeMock
ResponsiveCodegen.prototype.generateResponsiveCode =
generateResponsiveCodeMock
ResponsiveCodegen.prototype.generateResponsiveResult =
generateResponsiveResultMock

console.error = consoleErrorMock as typeof console.error
resetFigma()
Expand All @@ -39,8 +39,8 @@ describe('registerCodegen responsive error handling', () => {
Codegen.prototype.run = originalRun
Codegen.prototype.getComponentsCodes = originalGetComponentsCodes
Codegen.prototype.getCode = originalGetCode
ResponsiveCodegen.prototype.generateResponsiveCode =
originalGenerateResponsiveCode
ResponsiveCodegen.prototype.generateResponsiveResult =
originalGenerateResponsiveResult

console.error = originalError
resetFigma()
Expand Down
67 changes: 51 additions & 16 deletions src/__tests__/code.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,24 @@ afterEach(() => {
mock.restore()
})

const componentCode = (
name: string,
metadata: {
devupImports?: string[]
customImports?: string[]
usesKeyframes?: boolean
},
) =>
[
name,
'',
{
devupImports: metadata.devupImports ?? [],
customImports: metadata.customImports ?? [],
usesKeyframes: metadata.usesKeyframes ?? false,
},
] as const

describe('runCommand', () => {
it.each([
['export-devup', ['json'], 'exportDevup'],
Expand Down Expand Up @@ -247,26 +265,29 @@ it('auto-runs on module load when figma is present', async () => {
describe('extractImports', () => {
it('should extract keyframes import when code contains keyframes(', () => {
const result = codeModule.extractImports([
[
'AnimatedBox',
'<Box animationName={keyframes({ "0%": { opacity: 0 } })} />',
],
componentCode('AnimatedBox', {
devupImports: ['Box'],
usesKeyframes: true,
}),
])
expect(result).toContain('keyframes')
expect(result).toContain('Box')
})

it('should extract keyframes import when code contains keyframes`', () => {
const result = codeModule.extractImports([
['AnimatedBox', '<Box animationName={keyframes`from { opacity: 0 }`} />'],
componentCode('AnimatedBox', {
usesKeyframes: true,
devupImports: ['Box'],
}),
])
expect(result).toContain('keyframes')
expect(result).toContain('Box')
})

it('should not extract keyframes when not present', () => {
const result = codeModule.extractImports([
['SimpleBox', '<Box w="100px" />'],
componentCode('SimpleBox', { devupImports: ['Box'] }),
])
expect(result).not.toContain('keyframes')
expect(result).toContain('Box')
Expand All @@ -276,7 +297,10 @@ describe('extractImports', () => {
describe('extractCustomComponentImports', () => {
it('should extract custom component imports', () => {
const result = codeModule.extractCustomComponentImports([
['MyComponent', '<Box><CustomButton /><CustomInput /></Box>'],
componentCode('MyComponent', {
devupImports: ['Box'],
customImports: ['CustomButton', 'CustomInput'],
}),
])
expect(result).toContain('CustomButton')
expect(result).toContain('CustomInput')
Expand All @@ -286,10 +310,10 @@ describe('extractCustomComponentImports', () => {

it('should not include devup-ui components', () => {
const result = codeModule.extractCustomComponentImports([
[
'MyComponent',
'<Box><Flex><VStack><CustomCard /></VStack></Flex></Box>',
],
componentCode('MyComponent', {
devupImports: ['Box', 'Flex', 'VStack'],
customImports: ['CustomCard'],
}),
])
expect(result).toContain('CustomCard')
expect(result).not.toContain('Box')
Expand All @@ -299,29 +323,40 @@ describe('extractCustomComponentImports', () => {

it('should return empty array when no custom components', () => {
const result = codeModule.extractCustomComponentImports([
['MyComponent', '<Box><Flex><Text>Hello</Text></Flex></Box>'],
componentCode('MyComponent', { devupImports: ['Box', 'Flex', 'Text'] }),
])
expect(result).toEqual([])
})

it('should sort custom components alphabetically', () => {
const result = codeModule.extractCustomComponentImports([
['MyComponent', '<Box><Zebra /><Apple /><Mango /></Box>'],
componentCode('MyComponent', {
customImports: ['Zebra', 'Apple', 'Mango'],
}),
])
expect(result).toEqual(['Apple', 'Mango', 'Zebra'])
})

it('should handle multiple components with same custom component', () => {
const result = codeModule.extractCustomComponentImports([
['ComponentA', '<Box><SharedButton /></Box>'],
['ComponentB', '<Flex><SharedButton /></Flex>'],
componentCode('ComponentA', {
devupImports: ['Box'],
customImports: ['SharedButton'],
}),
componentCode('ComponentB', {
devupImports: ['Flex'],
customImports: ['SharedButton'],
}),
])
expect(result).toEqual(['SharedButton'])
})

it('should handle nested custom components', () => {
const result = codeModule.extractCustomComponentImports([
['Parent', '<Box><ChildA><ChildB><ChildC /></ChildB></ChildA></Box>'],
componentCode('Parent', {
devupImports: ['Box'],
customImports: ['ChildA', 'ChildB', 'ChildC'],
}),
])
expect(result).toContain('ChildA')
expect(result).toContain('ChildB')
Expand Down
77 changes: 48 additions & 29 deletions src/code-impl.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
Codegen,
DEFAULT_CODEGEN_OPTIONS,
resetGlobalBuildTreeCache,
resetMainComponentCache,
} from './codegen/Codegen'
Expand All @@ -10,9 +11,21 @@ import {
sanitizePropertyName,
} from './codegen/props/selector'
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 { isReservedVariantKey } from './codegen/utils/extract-instance-variant-props'
import { getComponentPropertyDefinitions } from './codegen/utils/get-component-property-definitions'
import {
getComponentPropertyDefinitions,
resetComponentPropertyDefinitionsCache,
} from './codegen/utils/get-component-property-definitions'
import { resetGetPageNodeCache } from './codegen/utils/get-page-node'
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'
Expand All @@ -30,8 +43,10 @@ export { extractCustomComponentImports, extractImports }
import { getComponentName, resetTextStyleCache } from './utils'
import { toPascal } from './utils/to-pascal'

type GeneratedCodeEntry = readonly [string, string, ImportMetadata?]

function generateImportStatements(
componentsCodes: ReadonlyArray<readonly [string, string]>,
componentsCodes: ReadonlyArray<GeneratedCodeEntry>,
): string {
const devupImports = extractImports(componentsCodes)
const customImports = extractCustomComponentImports(componentsCodes)
Expand All @@ -54,7 +69,7 @@ function generateImportStatements(
}

function generateBashCLI(
componentsCodes: ReadonlyArray<readonly [string, string]>,
componentsCodes: ReadonlyArray<GeneratedCodeEntry>,
): string {
const importStatement = generateImportStatements(componentsCodes)

Expand All @@ -72,7 +87,7 @@ function generateBashCLI(
}

function generatePowerShellCLI(
componentsCodes: ReadonlyArray<readonly [string, string]>,
componentsCodes: ReadonlyArray<GeneratedCodeEntry>,
): string {
const importStatement = generateImportStatements(componentsCodes)

Expand Down Expand Up @@ -172,10 +187,14 @@ export function generateComponentUsage(node: SceneNode): string | null {
if (isReservedVariantKey(key)) continue
const sanitizedKey = sanitizePropertyName(key)
if (def.type === 'VARIANT') {
const defaultValue = String(def.defaultValue)
const isBooleanVariant = isBooleanVariantOptions(
def.variantOptions || [],
)
entries.push({
key: sanitizedKey,
value: String(def.defaultValue),
type: 'VARIANT',
value: String(coerceBooleanVariantValue(defaultValue)),
type: isBooleanVariant ? 'BOOLEAN' : 'VARIANT',
})
} else if (def.type === 'BOOLEAN') {
if (def.defaultValue) {
Expand Down Expand Up @@ -234,12 +253,17 @@ export function registerCodegen(ctx: typeof figma) {
resetSelectorPropsCache()
resetChildAnimationCache()
resetVariableCache()
resetCheckAssetNodeCache()
resetCheckSameColorCache()
resetPaintToCssCache()
resetGetPageNodeCache()
resetComponentPropertyDefinitionsCache()
resetTextStyleCache()
resetMainComponentCache()
resetGlobalBuildTreeCache()

let t = perfStart()
const codegen = new Codegen(node)
const codegen = new Codegen(node, DEFAULT_CODEGEN_OPTIONS)
await codegen.run()
perfEnd('Codegen.run()', t)

Expand All @@ -248,9 +272,7 @@ export function registerCodegen(ctx: typeof figma) {
perfEnd('getComponentsCodes()', t)

// Generate responsive component codes with variant support
let responsiveComponentsCodes: ReadonlyArray<
readonly [string, string]
> = []
let responsiveComponentsCodes: ReadonlyArray<GeneratedCodeEntry> = []
if (node.type === 'COMPONENT_SET') {
const componentName = getComponentName(node)
// Reset the global build tree cache so that each variant's Codegen
Expand All @@ -264,6 +286,7 @@ export function registerCodegen(ctx: typeof figma) {
await ResponsiveCodegen.generateVariantResponsiveComponents(
node,
componentName,
DEFAULT_CODEGEN_OPTIONS,
)
perfEnd('generateVariantResponsiveComponents(COMPONENT_SET)', t)
}
Expand All @@ -273,17 +296,15 @@ export function registerCodegen(ctx: typeof figma) {
// because the self-referencing componentTree would trigger the parent
// COMPONENT_SET to be fully expanded — producing ComponentSet-level output
// when the user only wants to see their selected variant.
let componentsResponsiveCodes: ReadonlyArray<
readonly [string, string]
> = []
let componentsResponsiveCodes: ReadonlyArray<GeneratedCodeEntry> = []
if (
componentsCodes.length > 0 &&
node.type !== 'COMPONENT' &&
node.type !== 'COMPONENT_SET'
) {
const componentNodes = codegen.getComponentNodes()
const processedComponentSets = new Set<string>()
const responsiveResults: Array<readonly [string, string]> = []
const responsiveResults: Array<GeneratedCodeEntry> = []

for (const componentNode of componentNodes) {
// Check if the component belongs to a COMPONENT_SET
Expand All @@ -303,6 +324,7 @@ export function registerCodegen(ctx: typeof figma) {
await ResponsiveCodegen.generateVariantResponsiveComponents(
parentSet,
componentName,
DEFAULT_CODEGEN_OPTIONS,
)
perfEnd(
`generateVariantResponsiveComponents(${componentName})`,
Expand Down Expand Up @@ -330,9 +352,12 @@ export function registerCodegen(ctx: typeof figma) {

if (sectionNode) {
try {
const responsiveCodegen = new ResponsiveCodegen(sectionNode)
const responsiveCode =
await responsiveCodegen.generateResponsiveCode()
const responsiveCodegen = new ResponsiveCodegen(
sectionNode,
DEFAULT_CODEGEN_OPTIONS,
)
const { code: responsiveCode, imports } =
await responsiveCodegen.generateResponsiveResult()
const baseName = toPascal(sectionNode.name)
const sectionComponentName = isParentSection
? `${baseName}Page`
Expand All @@ -342,9 +367,9 @@ export function registerCodegen(ctx: typeof figma) {
responsiveCode,
{ exportDefault: isParentSection },
)
const sectionCodes: ReadonlyArray<readonly [string, string]> = [
[sectionComponentName, wrappedCode],
]
const sectionCodes: ReadonlyArray<
readonly [string, string, typeof imports]
> = [[sectionComponentName, wrappedCode, imports]]
const importStatement = generateImportStatements(sectionCodes)
const fullCode = importStatement + wrappedCode

Expand Down Expand Up @@ -419,22 +444,16 @@ export function registerCodegen(ctx: typeof figma) {
}

// Merge component codes: responsive/variant versions override simple ones.
const responsiveOverrides = new Map<
string,
readonly [string, string]
>()
const responsiveOverrides = new Map<string, GeneratedCodeEntry>()
for (const entry of componentsResponsiveCodes)
responsiveOverrides.set(entry[0], entry)
for (const entry of responsiveComponentsCodes)
responsiveOverrides.set(entry[0], entry)

const mergedComponentsCodes: ReadonlyArray<
readonly [string, string]
> =
const mergedComponentsCodes: ReadonlyArray<GeneratedCodeEntry> =
componentsCodes.length > 0 && responsiveOverrides.size > 0
? componentsCodes.map(
([name, code]) =>
responsiveOverrides.get(name) ?? ([name, code] as const),
(entry) => responsiveOverrides.get(entry[0]) ?? entry,
)
: componentsCodes

Expand Down
Loading