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
24 changes: 16 additions & 8 deletions src/codegen/Codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,17 +457,24 @@ export class Codegen {
// Multiple SLOTs → render each as a named JSX prop (renders as <Comp header={<X/>} content={<Y/>} />)
let slotChildren: NodeTree[] = []
if (slotsByName.size === 1) {
slotChildren = [...slotsByName.values()][0]
const firstSlot = slotsByName.values().next().value
if (firstSlot) {
slotChildren = firstSlot
}
} else if (slotsByName.size > 1) {
for (const [slotName, content] of slotsByName) {
let jsx: string
if (content.length === 1) {
jsx = Codegen.renderTree(content[0], 0)
} else {
const children = content.map((c) => Codegen.renderTree(c, 0))
const childrenStr = children
.map((c) => paddingLeftMultiline(c, 1))
.join('\n')
let childrenStr = ''
for (let i = 0; i < content.length; i++) {
if (i > 0) childrenStr += '\n'
childrenStr += paddingLeftMultiline(
Codegen.renderTree(content[i], 0),
1,
)
}
jsx = `<>\n${childrenStr}\n</>`
}
variantProps[slotName] = { __jsxSlot: true, jsx }
Expand Down Expand Up @@ -776,9 +783,10 @@ export class Codegen {
if (tree.textChildren && tree.textChildren.length > 0) {
result = renderNode(tree.component, tree.props, depth, tree.textChildren)
} else {
const childrenCodes = tree.children.map((child) =>
Codegen.renderTree(child, 0),
)
const childrenCodes: string[] = []
for (const child of tree.children) {
childrenCodes.push(Codegen.renderTree(child, 0))
}
result = renderNode(tree.component, tree.props, depth, childrenCodes)
}

Expand Down
8 changes: 5 additions & 3 deletions src/codegen/render/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ export function renderNode(
(hasChildren ? '>' : '/>')
}`
if (hasChildren) {
const children = childrenCodes
.map((child) => paddingLeftMultiline(child, deps + 1))
.join('\n')
let children = ''
for (let i = 0; i < childrenCodes.length; i++) {
if (i > 0) children += '\n'
children += paddingLeftMultiline(childrenCodes[i], deps + 1)
}
result += `\n${children}\n${space(deps)}</${component}>`
}
return result
Expand Down
35 changes: 35 additions & 0 deletions src/codegen/responsive/__tests__/mergePropsToResponsive.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'bun:test'
import {
type BreakpointKey,
groupChildrenByBreakpoint,
groupNodesByName,
mergePropsToResponsive,
type Props,
} from '../index'
Expand Down Expand Up @@ -233,3 +235,36 @@ describe('mergePropsToResponsive', () => {
})
})
})

describe('responsive grouping helpers', () => {
it('groups children by breakpoint without reallocating existing buckets', () => {
const children = [
{ width: 320, name: 'mobile-a' },
{ width: 360, name: 'mobile-b' },
{ width: 1200, name: 'desktop-a' },
] as unknown as SceneNode[]

const groups = groupChildrenByBreakpoint(children)

expect(groups.get('mobile')?.map((child) => child.name)).toEqual([
'mobile-a',
'mobile-b',
])
expect(groups.get('lg')?.map((child) => child.name)).toEqual(['desktop-a'])
})

it('groups nodes by name across breakpoints', () => {
const breakpointNodes = new Map<BreakpointKey, SceneNode[]>([
[
'mobile',
[{ name: 'Card' } as SceneNode, { name: 'Badge' } as SceneNode],
],
['pc', [{ name: 'Card' } as SceneNode]],
])

const groups = groupNodesByName(breakpointNodes)

expect(groups.get('Card')).toHaveLength(2)
expect(groups.get('Badge')).toHaveLength(1)
})
})
22 changes: 15 additions & 7 deletions src/codegen/responsive/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,12 @@ export function groupChildrenByBreakpoint(
for (const child of children) {
if ('width' in child) {
const breakpoint = getBreakpointByWidth(child.width)
const group = groups.get(breakpoint) || []
group.push(child)
groups.set(breakpoint, group)
const group = groups.get(breakpoint)
if (group) {
group.push(child)
} else {
groups.set(breakpoint, [child])
}
}
}

Expand Down Expand Up @@ -368,9 +371,12 @@ export function groupNodesByName(
for (const [breakpoint, nodes] of breakpointNodes) {
for (const node of nodes) {
const name = node.name
const group = result.get(name) || []
group.push({ breakpoint, node, props: {} })
result.set(name, group)
const group = result.get(name)
if (group) {
group.push({ breakpoint, node, props: {} })
} else {
result.set(name, [{ breakpoint, node, props: {} }])
}
}
}

Expand Down Expand Up @@ -525,13 +531,15 @@ export function mergePropsToVariant(
} else {
// Filter out null values from the variant object
const filteredValues: Record<string, PropValue> = {}
let filteredCount = 0
for (const variant in valuesByVariant) {
const value = valuesByVariant[variant]
if (value !== null) {
filteredValues[variant] = value
filteredCount++
}
}
if (Object.keys(filteredValues).length > 0) {
if (filteredCount > 0) {
result[key] = createVariantPropValue(variantKey, filteredValues)
}
}
Expand Down
41 changes: 32 additions & 9 deletions src/commands/__tests__/exportComponents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,31 @@ import {
import * as downloadFileModule from '../../utils/download-file'
import { exportComponents } from '../exportComponents'

const zipFileMock = mock(
(_name: string, _data: unknown, _options?: unknown) => {},
)
const zipGenerateAsyncMock = mock((_options?: unknown) =>
Promise.resolve(new Uint8Array([1, 2, 3])),
)

// mock jszip
mock.module('jszip', () => ({
default: class JSZipMock {
files: Record<string, unknown> = {}
file(name: string, data: unknown) {
file(name: string, data: unknown, options?: unknown) {
zipFileMock(name, data, options)
this.files[name] = data
}
async generateAsync() {
return new Uint8Array([1, 2, 3])
async generateAsync(options?: unknown) {
return zipGenerateAsyncMock(options)
}
},
}))

const runMock = mock(() => Promise.resolve())
const getComponentsCodesMock = mock(() => ({}))
const getComponentsCodesMock = mock(
(): ReadonlyArray<readonly [string, string]> => [],
)

mock.module('../codegen/Codegen', () => ({
Codegen: class {
Expand Down Expand Up @@ -127,6 +137,8 @@ describe('exportComponents', () => {
downloadFileMock.mockClear()
runMock.mockClear()
getComponentsCodesMock.mockClear()
zipFileMock.mockClear()
zipGenerateAsyncMock.mockClear()
})

test('should notify and return if no components found', async () => {
Expand All @@ -137,7 +149,7 @@ describe('exportComponents', () => {
(globalThis as { figma?: { currentPage?: { selection?: SceneNode[] } } })
.figma?.currentPage as { selection: SceneNode[] }
).selection = [node]
getComponentsCodesMock.mockReturnValueOnce({})
getComponentsCodesMock.mockReturnValueOnce([])
await exportComponents()
expect(notifyMock).toHaveBeenCalledWith('No components found')
})
Expand All @@ -156,7 +168,7 @@ describe('exportComponents', () => {
(globalThis as { figma?: { currentPage?: { selection?: SceneNode[] } } })
.figma?.currentPage as { selection: SceneNode[] }
).selection = [node]
getComponentsCodesMock.mockReturnValueOnce({})
getComponentsCodesMock.mockReturnValueOnce([])
await exportComponents()
expect(downloadFileMock).not.toHaveBeenCalled()
})
Expand Down Expand Up @@ -192,14 +204,25 @@ describe('exportComponents', () => {
(globalThis as { figma?: { currentPage?: { selection?: SceneNode[] } } })
.figma?.currentPage as { selection: SceneNode[] }
).selection = [node]
getComponentsCodesMock.mockReturnValueOnce({
Component: [['Component.tsx', '<Component />']],
})
getComponentsCodesMock.mockReturnValueOnce([
['Component.tsx', '<Component />'],
])
await exportComponents()
expect(downloadFileMock).toHaveBeenCalledWith(
'TestPage.zip',
expect.any(Uint8Array),
)
expect(zipFileMock).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
{ compression: 'DEFLATE' },
)
expect(zipGenerateAsyncMock).toHaveBeenCalledWith({
type: 'uint8array',
compression: 'DEFLATE',
compressionOptions: { level: 1 },
streamFiles: true,
})
expect(notifyMock).toHaveBeenCalledWith(
'Components exported',
expect.any(Object),
Expand Down
10 changes: 10 additions & 0 deletions src/commands/__tests__/exportPagesAndComponents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,4 +347,14 @@ describe('generateImportStatements', () => {
const result = generateImportStatements([['Test', '<Box />']])
expect(result.endsWith('\n\n')).toBe(true)
})

test('should return the same imports across repeated calls', () => {
const components = [
['Test', '<Box><CustomButton /><Flex /></Box>'],
] as const
const first = generateImportStatements(components)
const second = generateImportStatements(components)

expect(second).toBe(first)
})
})
4 changes: 3 additions & 1 deletion src/commands/devup/__tests__/import-devup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ describe('import-devup (standalone file)', () => {
}) as unknown as Variable,
)
const addMode = mock((name: string) => `${name}-id`)
const getLocalVariablesAsync = mock(() => Promise.resolve([] as Variable[]))
const collection = {
modes: [] as { modeId: string; name: string }[],
addMode,
Expand All @@ -60,7 +61,7 @@ describe('import-devup (standalone file)', () => {
util: { rgba: (v: unknown) => v },
variables: {
getLocalVariableCollectionsAsync: async () => [],
getLocalVariablesAsync: async () => [],
getLocalVariablesAsync,
createVariableCollection: () => collection,
createVariable,
},
Expand All @@ -72,6 +73,7 @@ describe('import-devup (standalone file)', () => {
await importDevup('excel')

expect(addMode).toHaveBeenCalledWith('Light')
expect(getLocalVariablesAsync).toHaveBeenCalledTimes(1)
expect(setValueForMode).toHaveBeenCalledWith('Light-id', '#111111')
expect(createTextStyle).toHaveBeenCalled()
expect(loadFontAsync).toHaveBeenCalled()
Expand Down
32 changes: 23 additions & 9 deletions src/commands/devup/import-devup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,34 @@ async function importColors(devup: Devup) {
const collection =
(await getDevupColorCollection()) ??
(await figma.variables.createVariableCollection('Devup Colors'))
const variables = await figma.variables.getLocalVariablesAsync()
const variablesByName = new Map<string, Variable>()
for (const variable of variables) {
if (!variablesByName.has(variable.name)) {
variablesByName.set(variable.name, variable)
}
}
const modeIdsByName = new Map(
collection.modes.map((mode) => [mode.name, mode.modeId] as const),
)

const themes = new Set<string>()
const colorNames = new Set<string>()

for (const [theme, value] of Object.entries(colors)) {
const modeId =
collection.modes.find((mode) => mode.name === theme)?.modeId ??
collection.addMode(theme)
let modeId = modeIdsByName.get(theme)
if (!modeId) {
modeId = collection.addMode(theme)
modeIdsByName.set(theme, modeId)
}

const variables = await figma.variables.getLocalVariablesAsync()
for (const [colorKey, colorValue] of Object.entries(value)) {
const variable =
variables.find((variable) => variable.name === colorKey) ??
figma.variables.createVariable(colorKey, collection, 'COLOR')
let variable = variablesByName.get(colorKey)
if (!variable) {
variable = figma.variables.createVariable(colorKey, collection, 'COLOR')
variablesByName.set(colorKey, variable)
variables.push(variable)
}

variable.setValueForMode(modeId, figma.util.rgba(colorValue))
colorNames.add(colorKey)
Expand All @@ -51,8 +65,8 @@ async function importColors(devup: Devup) {
collection.removeMode(theme.modeId)
}

const variables = await figma.variables.getLocalVariablesAsync()
for (const variable of variables.filter((v) => !colorNames.has(v.name))) {
for (const variable of variables) {
if (colorNames.has(variable.name)) continue
variable.remove()
}
}
Expand Down
27 changes: 15 additions & 12 deletions src/commands/exportComponents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,26 @@ import { Codegen } from '../codegen/Codegen'
import { downloadFile } from '../utils/download-file'

const NOTIFY_TIMEOUT = 3000
const ZIP_TEXT_FILE_OPTIONS = { compression: 'DEFLATE' as const }
const ZIP_GENERATE_OPTIONS = {
type: 'uint8array' as const,
compression: 'DEFLATE' as const,
compressionOptions: { level: 1 },
streamFiles: true,
}

export async function exportComponents() {
try {
figma.notify('Exporting components...')
const elements = await Promise.all(
figma.currentPage.selection.map(async (node) => new Codegen(node)),
const elements = figma.currentPage.selection.map(
(node) => new Codegen(node),
)
await Promise.all(elements.map((element) => element.run()))

const components = await Promise.all(
elements.map((element) => element.getComponentsCodes()),
)
const components = elements.map((element) => element.getComponentsCodes())

const componentCount = components.reduce(
(acc, component) => acc + Object.keys(component).length,
(acc, component) => acc + component.length,
0,
)

Expand All @@ -31,17 +36,15 @@ export async function exportComponents() {
timeout: NOTIFY_TIMEOUT,
})
const zip = new JSZip()
for (const component of components) {
for (const [_, codeList] of Object.entries(component)) {
for (const [name, code] of codeList) {
zip.file(name, code)
}
for (const codeList of components) {
for (const [name, code] of codeList) {
zip.file(name, code, ZIP_TEXT_FILE_OPTIONS)
}
}

await downloadFile(
`${figma.currentPage.name}.zip`,
await zip.generateAsync({ type: 'uint8array' }),
await zip.generateAsync(ZIP_GENERATE_OPTIONS),
)
figma.notify('Components exported', {
timeout: NOTIFY_TIMEOUT,
Expand Down
Loading