From 624c928ba9ca3189bef22bc2e14236be7d1f2805 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 14 Mar 2026 20:20:26 +0900 Subject: [PATCH] Optimize export --- src/codegen/Codegen.ts | 24 +++++--- src/codegen/render/index.ts | 8 ++- .../__tests__/mergePropsToResponsive.test.ts | 35 ++++++++++++ src/codegen/responsive/index.ts | 22 +++++--- .../__tests__/exportComponents.test.ts | 41 +++++++++++--- .../exportPagesAndComponents.test.ts | 10 ++++ .../devup/__tests__/import-devup.test.ts | 4 +- src/commands/devup/import-devup.ts | 32 ++++++++--- src/commands/exportComponents.ts | 27 +++++---- src/commands/exportPagesAndComponents.ts | 56 ++++++++++++------- 10 files changed, 191 insertions(+), 68 deletions(-) diff --git a/src/codegen/Codegen.ts b/src/codegen/Codegen.ts index 3285d9b..073ba48 100644 --- a/src/codegen/Codegen.ts +++ b/src/codegen/Codegen.ts @@ -457,17 +457,24 @@ export class Codegen { // Multiple SLOTs → render each as a named JSX prop (renders as } content={} />) 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 } @@ -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) } diff --git a/src/codegen/render/index.ts b/src/codegen/render/index.ts index ed95ae5..b3d8b8a 100644 --- a/src/codegen/render/index.ts +++ b/src/codegen/render/index.ts @@ -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)}` } return result diff --git a/src/codegen/responsive/__tests__/mergePropsToResponsive.test.ts b/src/codegen/responsive/__tests__/mergePropsToResponsive.test.ts index aaaffc7..ad64276 100644 --- a/src/codegen/responsive/__tests__/mergePropsToResponsive.test.ts +++ b/src/codegen/responsive/__tests__/mergePropsToResponsive.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'bun:test' import { type BreakpointKey, + groupChildrenByBreakpoint, + groupNodesByName, mergePropsToResponsive, type Props, } from '../index' @@ -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([ + [ + '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) + }) +}) diff --git a/src/codegen/responsive/index.ts b/src/codegen/responsive/index.ts index 4ef0e08..8285b92 100644 --- a/src/codegen/responsive/index.ts +++ b/src/codegen/responsive/index.ts @@ -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]) + } } } @@ -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: {} }]) + } } } @@ -525,13 +531,15 @@ export function mergePropsToVariant( } else { // Filter out null values from the variant object const filteredValues: Record = {} + 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) } } diff --git a/src/commands/__tests__/exportComponents.test.ts b/src/commands/__tests__/exportComponents.test.ts index 5f955c3..40a6340 100644 --- a/src/commands/__tests__/exportComponents.test.ts +++ b/src/commands/__tests__/exportComponents.test.ts @@ -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 = {} - 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 => [], +) mock.module('../codegen/Codegen', () => ({ Codegen: class { @@ -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 () => { @@ -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') }) @@ -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() }) @@ -192,14 +204,25 @@ describe('exportComponents', () => { (globalThis as { figma?: { currentPage?: { selection?: SceneNode[] } } }) .figma?.currentPage as { selection: SceneNode[] } ).selection = [node] - getComponentsCodesMock.mockReturnValueOnce({ - Component: [['Component.tsx', '']], - }) + getComponentsCodesMock.mockReturnValueOnce([ + ['Component.tsx', ''], + ]) 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), diff --git a/src/commands/__tests__/exportPagesAndComponents.test.ts b/src/commands/__tests__/exportPagesAndComponents.test.ts index 69af737..e06046a 100644 --- a/src/commands/__tests__/exportPagesAndComponents.test.ts +++ b/src/commands/__tests__/exportPagesAndComponents.test.ts @@ -347,4 +347,14 @@ describe('generateImportStatements', () => { const result = generateImportStatements([['Test', '']]) expect(result.endsWith('\n\n')).toBe(true) }) + + test('should return the same imports across repeated calls', () => { + const components = [ + ['Test', ''], + ] as const + const first = generateImportStatements(components) + const second = generateImportStatements(components) + + expect(second).toBe(first) + }) }) diff --git a/src/commands/devup/__tests__/import-devup.test.ts b/src/commands/devup/__tests__/import-devup.test.ts index e9877c8..c0d98d7 100644 --- a/src/commands/devup/__tests__/import-devup.test.ts +++ b/src/commands/devup/__tests__/import-devup.test.ts @@ -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, @@ -60,7 +61,7 @@ describe('import-devup (standalone file)', () => { util: { rgba: (v: unknown) => v }, variables: { getLocalVariableCollectionsAsync: async () => [], - getLocalVariablesAsync: async () => [], + getLocalVariablesAsync, createVariableCollection: () => collection, createVariable, }, @@ -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() diff --git a/src/commands/devup/import-devup.ts b/src/commands/devup/import-devup.ts index b715ea0..6916d97 100644 --- a/src/commands/devup/import-devup.ts +++ b/src/commands/devup/import-devup.ts @@ -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() + 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() const colorNames = new Set() 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) @@ -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() } } diff --git a/src/commands/exportComponents.ts b/src/commands/exportComponents.ts index d7376d4..f849ead 100644 --- a/src/commands/exportComponents.ts +++ b/src/commands/exportComponents.ts @@ -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, ) @@ -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, diff --git a/src/commands/exportPagesAndComponents.ts b/src/commands/exportPagesAndComponents.ts index 6841d4d..30c333e 100644 --- a/src/commands/exportPagesAndComponents.ts +++ b/src/commands/exportPagesAndComponents.ts @@ -23,6 +23,14 @@ const NOTIFY_TIMEOUT = 3000 // SVG/asset exports are lightweight and scale better with higher concurrency. const SCREENSHOT_BATCH_SIZE = 4 const ASSET_BATCH_SIZE = 8 +const ZIP_TEXT_FILE_OPTIONS = { compression: 'DEFLATE' as const } +const ZIP_BINARY_FILE_OPTIONS = { binary: true, compression: 'STORE' as const } +const ZIP_GENERATE_OPTIONS = { + type: 'uint8array' as const, + compression: 'DEFLATE' as const, + compressionOptions: { level: 1 }, + streamFiles: true, +} export const DEVUP_COMPONENTS = [ 'Center', @@ -33,15 +41,30 @@ export const DEVUP_COMPONENTS = [ 'Text', 'Image', ] +const DEVUP_COMPONENT_SET = new Set(DEVUP_COMPONENTS) +const DEVUP_COMPONENT_PATTERNS = DEVUP_COMPONENTS.map( + (component) => [component, new RegExp(`<${component}[\\s/>]`)] as const, +) +const CUSTOM_COMPONENT_USAGE_REGEX = /<([A-Z][a-zA-Z0-9]*)/g + +function getCombinedCode( + componentsCodes: ReadonlyArray, +): string { + let allCode = '' + for (let i = 0; i < componentsCodes.length; i++) { + if (i > 0) allCode += '\n' + allCode += componentsCodes[i][1] + } + return allCode +} export function extractImports( componentsCodes: ReadonlyArray, ): string[] { - const allCode = componentsCodes.map(([_, code]) => code).join('\n') + const allCode = getCombinedCode(componentsCodes) const imports = new Set() - for (const component of DEVUP_COMPONENTS) { - const regex = new RegExp(`<${component}[\\s/>]`, 'g') + for (const [component, regex] of DEVUP_COMPONENT_PATTERNS) { if (regex.test(allCode)) { imports.add(component) } @@ -57,14 +80,13 @@ export function extractImports( export function extractCustomComponentImports( componentsCodes: ReadonlyArray, ): string[] { - const allCode = componentsCodes.map(([_, code]) => code).join('\n') + const allCode = getCombinedCode(componentsCodes) const customImports = new Set() - const componentUsageRegex = /<([A-Z][a-zA-Z0-9]*)/g - const matches = allCode.matchAll(componentUsageRegex) - for (const match of matches) { + CUSTOM_COMPONENT_USAGE_REGEX.lastIndex = 0 + for (const match of allCode.matchAll(CUSTOM_COMPONENT_USAGE_REGEX)) { const componentName = match[1] - if (!DEVUP_COMPONENTS.includes(componentName)) { + if (!DEVUP_COMPONENT_SET.has(componentName)) { customImports.add(componentName) } } @@ -207,7 +229,7 @@ export async function exportPagesAndComponents() { for (const [name, code] of responsiveCodes) { const importStatement = generateImportStatements([[name, code]]) const fullCode = importStatement + code - componentsFolder?.file(`${name}.tsx`, fullCode) + componentsFolder?.file(`${name}.tsx`, fullCode, ZIP_TEXT_FILE_OPTIONS) componentCount++ } perfEnd('writeComponentFiles', t) @@ -255,7 +277,7 @@ export async function exportPagesAndComponents() { for (const [name, code] of componentsCodes) { const importStatement = generateImportStatements([[name, code]]) const fullCode = importStatement + code - componentsFolder?.file(`${name}.tsx`, fullCode) + componentsFolder?.file(`${name}.tsx`, fullCode, ZIP_TEXT_FILE_OPTIONS) componentCount++ } perfEnd('writeComponentFiles', t) @@ -295,7 +317,7 @@ export async function exportPagesAndComponents() { const importStatement = generateImportStatements(pageCodeEntry) const fullCode = importStatement + wrappedCode - pagesFolder?.file(`${pageName}.tsx`, fullCode) + pagesFolder?.file(`${pageName}.tsx`, fullCode, ZIP_TEXT_FILE_OPTIONS) perfEnd(`responsivePage(${pageName})`, t) // Defer screenshot capture @@ -352,7 +374,7 @@ export async function exportPagesAndComponents() { format: 'PNG', constraint: { type: 'SCALE', value: 1 }, }) - folder.file(fileName, imageData) + folder.file(fileName, imageData, ZIP_BINARY_FILE_OPTIONS) perfEnd('exportAsync(screenshot)', t) completedExports++ } catch (e) { @@ -378,13 +400,13 @@ export async function exportPagesAndComponents() { const t = perfStart() if (type === 'svg') { const svgData = await node.exportAsync({ format: 'SVG' }) - iconsFolder?.file(fileName, svgData) + iconsFolder?.file(fileName, svgData, ZIP_BINARY_FILE_OPTIONS) } else { const pngData = await node.exportAsync({ format: 'PNG', constraint: { type: 'SCALE', value: 2 }, }) - imagesFolder?.file(fileName, pngData) + imagesFolder?.file(fileName, pngData, ZIP_BINARY_FILE_OPTIONS) } perfEnd(`exportAsync(${type})`, t) assetCount++ @@ -407,11 +429,7 @@ export async function exportPagesAndComponents() { const tZip = perfStart() await downloadFile( `${figma.currentPage.name}-export.zip`, - await zip.generateAsync({ - type: 'uint8array', - compression: 'DEFLATE', - compressionOptions: { level: 1 }, - }), + await zip.generateAsync(ZIP_GENERATE_OPTIONS), ) perfEnd('phase3.zip', tZip)