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
54 changes: 43 additions & 11 deletions src/code-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,10 +418,25 @@ export function registerCodegen(ctx: typeof figma) {
}
}

const allComponentsCodes = [
...componentsResponsiveCodes,
...responsiveComponentsCodes,
]
// Merge component codes: responsive/variant versions override simple ones.
const responsiveOverrides = new Map<
string,
readonly [string, string]
>()
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]
> =
componentsCodes.length > 0 && responsiveOverrides.size > 0
? componentsCodes.map(
([name, code]) =>
responsiveOverrides.get(name) ?? ([name, code] as const),
)
: componentsCodes

// For INSTANCE nodes, include the referenced component definition(s)
// alongside Usage so developers see both how to use AND the implementation.
Expand All @@ -430,14 +445,16 @@ export function registerCodegen(ctx: typeof figma) {
language: 'TYPESCRIPT' | 'BASH'
code: string
}[] = []
if (node.type === 'INSTANCE' && componentsCodes.length > 0) {
const importStatement = generateImportStatements(componentsCodes)
const combinedCode = componentsCodes
if (node.type === 'INSTANCE' && mergedComponentsCodes.length > 0) {
const importStatement = generateImportStatements(
mergedComponentsCodes,
)
const combinedCode = mergedComponentsCodes
.map(([, code]) => code)
.join('\n\n')
const label =
componentsCodes.length === 1
? componentsCodes[0][0]
mergedComponentsCodes.length === 1
? mergedComponentsCodes[0][0]
: `${node.name} - Components`
componentDefinitionResults.push(
{
Expand All @@ -448,16 +465,31 @@ export function registerCodegen(ctx: typeof figma) {
{
title: `${label} - CLI (Bash)`,
language: 'BASH',
code: generateBashCLI(componentsCodes),
code: generateBashCLI(mergedComponentsCodes),
},
{
title: `${label} - CLI (PowerShell)`,
language: 'BASH',
code: generatePowerShellCLI(componentsCodes),
code: generatePowerShellCLI(mergedComponentsCodes),
},
)
}

// Collect remaining responsive codes NOT already merged into component definitions.
// Only filter for INSTANCE nodes — other node types don't produce componentDefinitionResults.
const mergedNames =
node.type === 'INSTANCE'
? new Set(mergedComponentsCodes.map(([name]) => name))
: new Set<string>()
const allComponentsCodes = [
...componentsResponsiveCodes.filter(
([name]) => !mergedNames.has(name),
),
...responsiveComponentsCodes.filter(
([name]) => !mergedNames.has(name),
),
]

// For COMPONENT nodes, show both the single-variant code AND Usage.
// For COMPONENT_SET and INSTANCE, show only Usage.
// For all other types, show the main code.
Expand Down
51 changes: 26 additions & 25 deletions src/codegen/Codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,6 @@ export class Codegen {
private buildTreeCache: Map<string, Promise<NodeTree>> = new Map()
// Collect fire-and-forget addComponentTree promises so we can await them
// before rendering component codes (decouples INSTANCE buildTree from addComponentTree)
private pendingComponentTrees: Promise<void>[] = []

constructor(private node: SceneNode) {
this.node = node
Expand Down Expand Up @@ -356,10 +355,12 @@ export class Codegen {
this.tree = tree
}

// Await all fire-and-forget addComponentTree calls before rendering
if (this.pendingComponentTrees.length > 0) {
await Promise.all(this.pendingComponentTrees)
this.pendingComponentTrees = []
// Drain all addComponentTree promises, including nested ones added during execution.
// Uses addComponentTreePromises Map which stably tracks every fired promise.
let _prevSize = 0
while (this.addComponentTreePromises.size > _prevSize) {
_prevSize = this.addComponentTreePromises.size
await Promise.all(this.addComponentTreePromises.values())
}

// Sync componentTrees to components
Expand Down Expand Up @@ -409,13 +410,6 @@ export class Codegen {
globalBuildTreeCache.set(cacheKey, promise)
}
const result = await promise
// When called as the root-level buildTree (node === this.node),
// drain any fire-and-forget addComponentTree promises so that
// getComponentTrees() is populated before the caller inspects it.
if (node === this.node && this.pendingComponentTrees.length > 0) {
await Promise.all(this.pendingComponentTrees)
this.pendingComponentTrees = []
}
return result
}

Expand All @@ -431,10 +425,9 @@ export class Codegen {
node === this.node.defaultVariant) ||
this.node.type === 'COMPONENT')
) {
this.pendingComponentTrees.push(
this.addComponentTree(
node.type === 'COMPONENT_SET' ? node.defaultVariant : node,
),
// Fire-and-forget — errors collected via addComponentTreePromises in run().
this.addComponentTree(
node.type === 'COMPONENT_SET' ? node.defaultVariant : node,
)
}

Expand Down Expand Up @@ -462,7 +455,7 @@ export class Codegen {
// Fire addComponentTree without awaiting — it runs in the background.
// All pending promises are collected and awaited in run() before rendering.
if (mainComponent) {
this.pendingComponentTrees.push(this.addComponentTree(mainComponent))
this.addComponentTree(mainComponent)
}

const componentName = getComponentName(mainComponent || node)
Expand Down Expand Up @@ -570,7 +563,10 @@ export class Codegen {
if (!globalAssetNodes.has(assetKey)) {
globalAssetNodes.set(assetKey, { node, type: assetNode })
}
const props = await getProps(node)
const baseProps = await getProps(node)
// Clone to avoid mutating the shared getProps cache — subsequent
// codegen runs (e.g. ResponsiveCodegen) reuse the cached reference.
const props: Record<string, unknown> = { ...baseProps }
props.src = `/${assetNode === 'svg' ? 'icons' : 'images'}/${node.name}.${assetNode}`
if (assetNode === 'svg') {
const maskColor = await checkSameColor(node)
Expand Down Expand Up @@ -679,10 +675,11 @@ export class Codegen {
async getTree(): Promise<NodeTree> {
if (!this.tree) {
this.tree = await this.buildTree(this.node)
// Await any fire-and-forget addComponentTree calls launched during buildTree
if (this.pendingComponentTrees.length > 0) {
await Promise.all(this.pendingComponentTrees)
this.pendingComponentTrees = []
// Drain all addComponentTree promises (including nested ones)
let _prevSize = 0
while (this.addComponentTreePromises.size > _prevSize) {
_prevSize = this.addComponentTreePromises.size
await Promise.all(this.addComponentTreePromises.values())
}
}
return this.tree
Expand All @@ -702,15 +699,19 @@ export class Codegen {
// when multiple INSTANCE nodes reference the same component
private addComponentTreePromises: Map<string, Promise<void>> = new Map()

private async addComponentTree(node: ComponentNode): Promise<void> {
private addComponentTree(node: ComponentNode): Promise<void> {
const nodeId = node.id || node.name
if (this.componentTrees.has(nodeId)) return
if (this.componentTrees.has(nodeId)) return Promise.resolve()

// If already in-flight, await the same promise
// If already in-flight, return the same promise
const inflight = this.addComponentTreePromises.get(nodeId)
if (inflight) return inflight

// Store the raw promise (may reject) for drain in run().
// Attach a no-op .catch so fire-and-forget callers don't
// trigger unhandled rejection warnings.
const promise = this.doAddComponentTree(node, nodeId)
promise.catch(() => {})
this.addComponentTreePromises.set(nodeId, promise)
return promise
}
Expand Down
24 changes: 19 additions & 5 deletions src/commands/devup/__tests__/import-devup.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { describe, expect, mock, spyOn, test } from 'bun:test'
import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test'
import * as uploadFileModule from '../../../utils/upload-file'
import { importDevup } from '../import-devup'
import * as uploadXlsxModule from '../utils/upload-devup-xlsx'

describe('import-devup (standalone file)', () => {
const spies: ReturnType<typeof spyOn>[] = []
afterEach(() => {
for (const s of spies) s.mockRestore()
spies.length = 0
;(globalThis as { figma?: unknown }).figma = undefined
})
test('returns early when theme is missing', async () => {
const uploadFile = mock(() => Promise.resolve('{}'))
spyOn(uploadFileModule, 'uploadFile').mockImplementation(uploadFile)
spies.push(
spyOn(uploadFileModule, 'uploadFile').mockImplementation(uploadFile),
)
await importDevup('json')
expect(uploadFile).toHaveBeenCalledWith('.json')
})
Expand All @@ -30,7 +38,9 @@ describe('import-devup (standalone file)', () => {
},
}),
)
spyOn(uploadXlsxModule, 'uploadDevupXlsx').mockImplementation(uploadXlsx)
spies.push(
spyOn(uploadXlsxModule, 'uploadDevupXlsx').mockImplementation(uploadXlsx),
)

const setValueForMode = mock(() => {})
const createVariable = mock(
Expand Down Expand Up @@ -89,7 +99,9 @@ describe('import-devup (standalone file)', () => {
},
}),
)
spyOn(uploadXlsxModule, 'uploadDevupXlsx').mockImplementation(uploadXlsx)
spies.push(
spyOn(uploadXlsxModule, 'uploadDevupXlsx').mockImplementation(uploadXlsx),
)

const removeDevupVariable = mock(() => {})
const removeOtherVariable = mock(() => {})
Expand Down Expand Up @@ -167,7 +179,9 @@ describe('import-devup (standalone file)', () => {
},
}),
)
spyOn(uploadXlsxModule, 'uploadDevupXlsx').mockImplementation(uploadXlsx)
spies.push(
spyOn(uploadXlsxModule, 'uploadDevupXlsx').mockImplementation(uploadXlsx),
)

const createTextStyle = mock(
() =>
Expand Down
52 changes: 33 additions & 19 deletions src/commands/devup/utils/__tests__/download-devup-xlsx.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import { describe, expect, mock, test } from 'bun:test'
import { downloadDevupXlsx } from '../download-devup-xlsx'

describe('downloadDevupXlsx', () => {
let showUIMock: ReturnType<typeof mock>
let postMessageMock: ReturnType<typeof mock>
let onmessageHandler: ((message: unknown) => void) | null = null

beforeEach(() => {
showUIMock = mock(() => {})
postMessageMock = mock(() => {})
onmessageHandler = null
function createMockFigma() {
const showUIMock = mock(() => {})
const postMessageMock = mock(() => {})
let onmessageHandler: ((message: unknown) => void) | null = null

const uiObj: {
onmessage?: (message: unknown) => void
Expand All @@ -26,18 +22,26 @@ describe('downloadDevupXlsx', () => {

uiObj.postMessage = postMessageMock

;(globalThis as { figma?: unknown }).figma = {
const ctx = {
showUI: showUIMock,
ui: uiObj,
} as unknown as typeof figma
})

afterEach(() => {
;(globalThis as { figma?: unknown }).figma = undefined
})
return {
ctx,
showUIMock,
postMessageMock,
getHandler: () => onmessageHandler,
}
}

test('should call showUI with correct HTML string and visible false', () => {
downloadDevupXlsx('test.xlsx', '{"theme":{"colors":{},"typography":{}}}')
const { ctx, showUIMock } = createMockFigma()
downloadDevupXlsx(
'test.xlsx',
'{"theme":{"colors":{},"typography":{}}}',
ctx,
)
expect(showUIMock).toHaveBeenCalledWith(
expect.stringContaining('xlsx-0.20.3'),
{ visible: false },
Expand All @@ -49,8 +53,13 @@ describe('downloadDevupXlsx', () => {
})

test('should set onmessage handler and post message', () => {
downloadDevupXlsx('test.xlsx', '{"theme":{"colors":{},"typography":{}}}')
expect(onmessageHandler).not.toBeNull()
const { ctx, getHandler, postMessageMock } = createMockFigma()
downloadDevupXlsx(
'test.xlsx',
'{"theme":{"colors":{},"typography":{}}}',
ctx,
)
expect(getHandler()).not.toBeNull()
expect(postMessageMock).toHaveBeenCalledWith({
type: 'download',
fileName: 'test.xlsx',
Expand All @@ -59,26 +68,31 @@ describe('downloadDevupXlsx', () => {
})

test('should return a promise that resolves when onmessage is called', async () => {
const { ctx, getHandler, postMessageMock } = createMockFigma()
const promise = downloadDevupXlsx(
'test.xlsx',
'{"theme":{"colors":{},"typography":{}}}',
ctx,
)

// Simulate message from UI
if (onmessageHandler) {
onmessageHandler(undefined)
const handler = getHandler()
if (handler) {
handler(undefined)
}

await promise
expect(postMessageMock).toHaveBeenCalled()
})

test('should handle different file names and data', () => {
const { ctx, postMessageMock } = createMockFigma()
downloadDevupXlsx(
'devup.xlsx',
JSON.stringify({
theme: { colors: { light: { primary: '#000' } }, typography: {} },
}),
ctx,
)
expect(postMessageMock).toHaveBeenCalledWith({
type: 'download',
Expand Down
Loading