Skip to content

Commit 4c783d7

Browse files
authored
Merge pull request #34 from dev-five-git/optimize-export
Optimize export
2 parents 82d0858 + 624c928 commit 4c783d7

File tree

10 files changed

+191
-68
lines changed

10 files changed

+191
-68
lines changed

src/codegen/Codegen.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -457,17 +457,24 @@ export class Codegen {
457457
// Multiple SLOTs → render each as a named JSX prop (renders as <Comp header={<X/>} content={<Y/>} />)
458458
let slotChildren: NodeTree[] = []
459459
if (slotsByName.size === 1) {
460-
slotChildren = [...slotsByName.values()][0]
460+
const firstSlot = slotsByName.values().next().value
461+
if (firstSlot) {
462+
slotChildren = firstSlot
463+
}
461464
} else if (slotsByName.size > 1) {
462465
for (const [slotName, content] of slotsByName) {
463466
let jsx: string
464467
if (content.length === 1) {
465468
jsx = Codegen.renderTree(content[0], 0)
466469
} else {
467-
const children = content.map((c) => Codegen.renderTree(c, 0))
468-
const childrenStr = children
469-
.map((c) => paddingLeftMultiline(c, 1))
470-
.join('\n')
470+
let childrenStr = ''
471+
for (let i = 0; i < content.length; i++) {
472+
if (i > 0) childrenStr += '\n'
473+
childrenStr += paddingLeftMultiline(
474+
Codegen.renderTree(content[i], 0),
475+
1,
476+
)
477+
}
471478
jsx = `<>\n${childrenStr}\n</>`
472479
}
473480
variantProps[slotName] = { __jsxSlot: true, jsx }
@@ -776,9 +783,10 @@ export class Codegen {
776783
if (tree.textChildren && tree.textChildren.length > 0) {
777784
result = renderNode(tree.component, tree.props, depth, tree.textChildren)
778785
} else {
779-
const childrenCodes = tree.children.map((child) =>
780-
Codegen.renderTree(child, 0),
781-
)
786+
const childrenCodes: string[] = []
787+
for (const child of tree.children) {
788+
childrenCodes.push(Codegen.renderTree(child, 0))
789+
}
782790
result = renderNode(tree.component, tree.props, depth, childrenCodes)
783791
}
784792

src/codegen/render/index.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@ export function renderNode(
3131
(hasChildren ? '>' : '/>')
3232
}`
3333
if (hasChildren) {
34-
const children = childrenCodes
35-
.map((child) => paddingLeftMultiline(child, deps + 1))
36-
.join('\n')
34+
let children = ''
35+
for (let i = 0; i < childrenCodes.length; i++) {
36+
if (i > 0) children += '\n'
37+
children += paddingLeftMultiline(childrenCodes[i], deps + 1)
38+
}
3739
result += `\n${children}\n${space(deps)}</${component}>`
3840
}
3941
return result

src/codegen/responsive/__tests__/mergePropsToResponsive.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { describe, expect, it } from 'bun:test'
22
import {
33
type BreakpointKey,
4+
groupChildrenByBreakpoint,
5+
groupNodesByName,
46
mergePropsToResponsive,
57
type Props,
68
} from '../index'
@@ -233,3 +235,36 @@ describe('mergePropsToResponsive', () => {
233235
})
234236
})
235237
})
238+
239+
describe('responsive grouping helpers', () => {
240+
it('groups children by breakpoint without reallocating existing buckets', () => {
241+
const children = [
242+
{ width: 320, name: 'mobile-a' },
243+
{ width: 360, name: 'mobile-b' },
244+
{ width: 1200, name: 'desktop-a' },
245+
] as unknown as SceneNode[]
246+
247+
const groups = groupChildrenByBreakpoint(children)
248+
249+
expect(groups.get('mobile')?.map((child) => child.name)).toEqual([
250+
'mobile-a',
251+
'mobile-b',
252+
])
253+
expect(groups.get('lg')?.map((child) => child.name)).toEqual(['desktop-a'])
254+
})
255+
256+
it('groups nodes by name across breakpoints', () => {
257+
const breakpointNodes = new Map<BreakpointKey, SceneNode[]>([
258+
[
259+
'mobile',
260+
[{ name: 'Card' } as SceneNode, { name: 'Badge' } as SceneNode],
261+
],
262+
['pc', [{ name: 'Card' } as SceneNode]],
263+
])
264+
265+
const groups = groupNodesByName(breakpointNodes)
266+
267+
expect(groups.get('Card')).toHaveLength(2)
268+
expect(groups.get('Badge')).toHaveLength(1)
269+
})
270+
})

src/codegen/responsive/index.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,12 @@ export function groupChildrenByBreakpoint(
5454
for (const child of children) {
5555
if ('width' in child) {
5656
const breakpoint = getBreakpointByWidth(child.width)
57-
const group = groups.get(breakpoint) || []
58-
group.push(child)
59-
groups.set(breakpoint, group)
57+
const group = groups.get(breakpoint)
58+
if (group) {
59+
group.push(child)
60+
} else {
61+
groups.set(breakpoint, [child])
62+
}
6063
}
6164
}
6265

@@ -368,9 +371,12 @@ export function groupNodesByName(
368371
for (const [breakpoint, nodes] of breakpointNodes) {
369372
for (const node of nodes) {
370373
const name = node.name
371-
const group = result.get(name) || []
372-
group.push({ breakpoint, node, props: {} })
373-
result.set(name, group)
374+
const group = result.get(name)
375+
if (group) {
376+
group.push({ breakpoint, node, props: {} })
377+
} else {
378+
result.set(name, [{ breakpoint, node, props: {} }])
379+
}
374380
}
375381
}
376382

@@ -525,13 +531,15 @@ export function mergePropsToVariant(
525531
} else {
526532
// Filter out null values from the variant object
527533
const filteredValues: Record<string, PropValue> = {}
534+
let filteredCount = 0
528535
for (const variant in valuesByVariant) {
529536
const value = valuesByVariant[variant]
530537
if (value !== null) {
531538
filteredValues[variant] = value
539+
filteredCount++
532540
}
533541
}
534-
if (Object.keys(filteredValues).length > 0) {
542+
if (filteredCount > 0) {
535543
result[key] = createVariantPropValue(variantKey, filteredValues)
536544
}
537545
}

src/commands/__tests__/exportComponents.test.ts

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,31 @@ import {
1111
import * as downloadFileModule from '../../utils/download-file'
1212
import { exportComponents } from '../exportComponents'
1313

14+
const zipFileMock = mock(
15+
(_name: string, _data: unknown, _options?: unknown) => {},
16+
)
17+
const zipGenerateAsyncMock = mock((_options?: unknown) =>
18+
Promise.resolve(new Uint8Array([1, 2, 3])),
19+
)
20+
1421
// mock jszip
1522
mock.module('jszip', () => ({
1623
default: class JSZipMock {
1724
files: Record<string, unknown> = {}
18-
file(name: string, data: unknown) {
25+
file(name: string, data: unknown, options?: unknown) {
26+
zipFileMock(name, data, options)
1927
this.files[name] = data
2028
}
21-
async generateAsync() {
22-
return new Uint8Array([1, 2, 3])
29+
async generateAsync(options?: unknown) {
30+
return zipGenerateAsyncMock(options)
2331
}
2432
},
2533
}))
2634

2735
const runMock = mock(() => Promise.resolve())
28-
const getComponentsCodesMock = mock(() => ({}))
36+
const getComponentsCodesMock = mock(
37+
(): ReadonlyArray<readonly [string, string]> => [],
38+
)
2939

3040
mock.module('../codegen/Codegen', () => ({
3141
Codegen: class {
@@ -127,6 +137,8 @@ describe('exportComponents', () => {
127137
downloadFileMock.mockClear()
128138
runMock.mockClear()
129139
getComponentsCodesMock.mockClear()
140+
zipFileMock.mockClear()
141+
zipGenerateAsyncMock.mockClear()
130142
})
131143

132144
test('should notify and return if no components found', async () => {
@@ -137,7 +149,7 @@ describe('exportComponents', () => {
137149
(globalThis as { figma?: { currentPage?: { selection?: SceneNode[] } } })
138150
.figma?.currentPage as { selection: SceneNode[] }
139151
).selection = [node]
140-
getComponentsCodesMock.mockReturnValueOnce({})
152+
getComponentsCodesMock.mockReturnValueOnce([])
141153
await exportComponents()
142154
expect(notifyMock).toHaveBeenCalledWith('No components found')
143155
})
@@ -156,7 +168,7 @@ describe('exportComponents', () => {
156168
(globalThis as { figma?: { currentPage?: { selection?: SceneNode[] } } })
157169
.figma?.currentPage as { selection: SceneNode[] }
158170
).selection = [node]
159-
getComponentsCodesMock.mockReturnValueOnce({})
171+
getComponentsCodesMock.mockReturnValueOnce([])
160172
await exportComponents()
161173
expect(downloadFileMock).not.toHaveBeenCalled()
162174
})
@@ -192,14 +204,25 @@ describe('exportComponents', () => {
192204
(globalThis as { figma?: { currentPage?: { selection?: SceneNode[] } } })
193205
.figma?.currentPage as { selection: SceneNode[] }
194206
).selection = [node]
195-
getComponentsCodesMock.mockReturnValueOnce({
196-
Component: [['Component.tsx', '<Component />']],
197-
})
207+
getComponentsCodesMock.mockReturnValueOnce([
208+
['Component.tsx', '<Component />'],
209+
])
198210
await exportComponents()
199211
expect(downloadFileMock).toHaveBeenCalledWith(
200212
'TestPage.zip',
201213
expect.any(Uint8Array),
202214
)
215+
expect(zipFileMock).toHaveBeenCalledWith(
216+
expect.any(String),
217+
expect.any(String),
218+
{ compression: 'DEFLATE' },
219+
)
220+
expect(zipGenerateAsyncMock).toHaveBeenCalledWith({
221+
type: 'uint8array',
222+
compression: 'DEFLATE',
223+
compressionOptions: { level: 1 },
224+
streamFiles: true,
225+
})
203226
expect(notifyMock).toHaveBeenCalledWith(
204227
'Components exported',
205228
expect.any(Object),

src/commands/__tests__/exportPagesAndComponents.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,4 +347,14 @@ describe('generateImportStatements', () => {
347347
const result = generateImportStatements([['Test', '<Box />']])
348348
expect(result.endsWith('\n\n')).toBe(true)
349349
})
350+
351+
test('should return the same imports across repeated calls', () => {
352+
const components = [
353+
['Test', '<Box><CustomButton /><Flex /></Box>'],
354+
] as const
355+
const first = generateImportStatements(components)
356+
const second = generateImportStatements(components)
357+
358+
expect(second).toBe(first)
359+
})
350360
})

src/commands/devup/__tests__/import-devup.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ describe('import-devup (standalone file)', () => {
4242
}) as unknown as Variable,
4343
)
4444
const addMode = mock((name: string) => `${name}-id`)
45+
const getLocalVariablesAsync = mock(() => Promise.resolve([] as Variable[]))
4546
const collection = {
4647
modes: [] as { modeId: string; name: string }[],
4748
addMode,
@@ -60,7 +61,7 @@ describe('import-devup (standalone file)', () => {
6061
util: { rgba: (v: unknown) => v },
6162
variables: {
6263
getLocalVariableCollectionsAsync: async () => [],
63-
getLocalVariablesAsync: async () => [],
64+
getLocalVariablesAsync,
6465
createVariableCollection: () => collection,
6566
createVariable,
6667
},
@@ -72,6 +73,7 @@ describe('import-devup (standalone file)', () => {
7273
await importDevup('excel')
7374

7475
expect(addMode).toHaveBeenCalledWith('Light')
76+
expect(getLocalVariablesAsync).toHaveBeenCalledTimes(1)
7577
expect(setValueForMode).toHaveBeenCalledWith('Light-id', '#111111')
7678
expect(createTextStyle).toHaveBeenCalled()
7779
expect(loadFontAsync).toHaveBeenCalled()

src/commands/devup/import-devup.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,34 @@ async function importColors(devup: Devup) {
2424
const collection =
2525
(await getDevupColorCollection()) ??
2626
(await figma.variables.createVariableCollection('Devup Colors'))
27+
const variables = await figma.variables.getLocalVariablesAsync()
28+
const variablesByName = new Map<string, Variable>()
29+
for (const variable of variables) {
30+
if (!variablesByName.has(variable.name)) {
31+
variablesByName.set(variable.name, variable)
32+
}
33+
}
34+
const modeIdsByName = new Map(
35+
collection.modes.map((mode) => [mode.name, mode.modeId] as const),
36+
)
2737

2838
const themes = new Set<string>()
2939
const colorNames = new Set<string>()
3040

3141
for (const [theme, value] of Object.entries(colors)) {
32-
const modeId =
33-
collection.modes.find((mode) => mode.name === theme)?.modeId ??
34-
collection.addMode(theme)
42+
let modeId = modeIdsByName.get(theme)
43+
if (!modeId) {
44+
modeId = collection.addMode(theme)
45+
modeIdsByName.set(theme, modeId)
46+
}
3547

36-
const variables = await figma.variables.getLocalVariablesAsync()
3748
for (const [colorKey, colorValue] of Object.entries(value)) {
38-
const variable =
39-
variables.find((variable) => variable.name === colorKey) ??
40-
figma.variables.createVariable(colorKey, collection, 'COLOR')
49+
let variable = variablesByName.get(colorKey)
50+
if (!variable) {
51+
variable = figma.variables.createVariable(colorKey, collection, 'COLOR')
52+
variablesByName.set(colorKey, variable)
53+
variables.push(variable)
54+
}
4155

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

54-
const variables = await figma.variables.getLocalVariablesAsync()
55-
for (const variable of variables.filter((v) => !colorNames.has(v.name))) {
68+
for (const variable of variables) {
69+
if (colorNames.has(variable.name)) continue
5670
variable.remove()
5771
}
5872
}

src/commands/exportComponents.ts

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,26 @@ import { Codegen } from '../codegen/Codegen'
44
import { downloadFile } from '../utils/download-file'
55

66
const NOTIFY_TIMEOUT = 3000
7+
const ZIP_TEXT_FILE_OPTIONS = { compression: 'DEFLATE' as const }
8+
const ZIP_GENERATE_OPTIONS = {
9+
type: 'uint8array' as const,
10+
compression: 'DEFLATE' as const,
11+
compressionOptions: { level: 1 },
12+
streamFiles: true,
13+
}
714

815
export async function exportComponents() {
916
try {
1017
figma.notify('Exporting components...')
11-
const elements = await Promise.all(
12-
figma.currentPage.selection.map(async (node) => new Codegen(node)),
18+
const elements = figma.currentPage.selection.map(
19+
(node) => new Codegen(node),
1320
)
1421
await Promise.all(elements.map((element) => element.run()))
1522

16-
const components = await Promise.all(
17-
elements.map((element) => element.getComponentsCodes()),
18-
)
23+
const components = elements.map((element) => element.getComponentsCodes())
1924

2025
const componentCount = components.reduce(
21-
(acc, component) => acc + Object.keys(component).length,
26+
(acc, component) => acc + component.length,
2227
0,
2328
)
2429

@@ -31,17 +36,15 @@ export async function exportComponents() {
3136
timeout: NOTIFY_TIMEOUT,
3237
})
3338
const zip = new JSZip()
34-
for (const component of components) {
35-
for (const [_, codeList] of Object.entries(component)) {
36-
for (const [name, code] of codeList) {
37-
zip.file(name, code)
38-
}
39+
for (const codeList of components) {
40+
for (const [name, code] of codeList) {
41+
zip.file(name, code, ZIP_TEXT_FILE_OPTIONS)
3942
}
4043
}
4144

4245
await downloadFile(
4346
`${figma.currentPage.name}.zip`,
44-
await zip.generateAsync({ type: 'uint8array' }),
47+
await zip.generateAsync(ZIP_GENERATE_OPTIONS),
4548
)
4649
figma.notify('Components exported', {
4750
timeout: NOTIFY_TIMEOUT,

0 commit comments

Comments
 (0)