From 5c455293cb886f6646c2b8fcecb5808640f6978c Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 12 Mar 2026 16:35:33 +0900 Subject: [PATCH 1/6] Optimize export devup --- src/commands/devup/export-devup.ts | 112 +++++++++++++++++------------ 1 file changed, 68 insertions(+), 44 deletions(-) diff --git a/src/commands/devup/export-devup.ts b/src/commands/devup/export-devup.ts index 62c6004..8dddc0b 100644 --- a/src/commands/devup/export-devup.ts +++ b/src/commands/devup/export-devup.ts @@ -1,3 +1,9 @@ +import { + perfEnd, + perfReport, + perfReset, + perfStart, +} from '../../codegen/utils/perf' import { downloadFile } from '../../utils/download-file' import { isVariableAlias } from '../../utils/is-variable-alias' import { optimizeHex } from '../../utils/optimize-hex' @@ -15,18 +21,26 @@ export async function exportDevup( output: 'json' | 'excel', treeshaking: boolean = true, ) { + perfReset() + const t = perfStart() const devup: Devup = {} + const tColors = perfStart() const collection = await getDevupColorCollection() if (collection) { + // Pre-fetch all variables once — reuse across modes + const variables = await Promise.all( + collection.variableIds.map((varId) => + figma.variables.getVariableByIdAsync(varId), + ), + ) + devup.theme ??= {} + devup.theme.colors ??= {} for (const mode of collection.modes) { - devup.theme ??= {} - devup.theme.colors ??= {} const colors: Record = {} devup.theme.colors[mode.name.toLowerCase()] = colors await Promise.all( - collection.variableIds.map(async (varId) => { - const variable = await figma.variables.getVariableByIdAsync(varId) + variables.map(async (variable) => { if (variable === null) return const value = variable.valuesByMode[mode.modeId] if (typeof value === 'boolean' || typeof value === 'number') return @@ -47,56 +61,62 @@ export async function exportDevup( ) } } + perfEnd('exportDevup.colors', tColors) - await figma.loadAllPagesAsync() + // Parallel: load pages (only if treeshaking) + fetch text styles + const tLoad = perfStart() + const [, textStyles] = await Promise.all([ + treeshaking ? figma.loadAllPagesAsync() : Promise.resolve(), + figma.getLocalTextStylesAsync(), + ]) + perfEnd('exportDevup.load', tLoad) - const textStyles = await figma.getLocalTextStylesAsync() - const ids = new Set() + // Build both ID-keyed and name-keyed maps from a single fetch + const stylesById = new Map() const styles: Record = {} for (const style of textStyles) { - ids.add(style.id) + stylesById.set(style.id, style) styles[style.name] = style } + const tTypo = perfStart() const typography: Record = {} if (treeshaking) { const texts = figma.root.findAllWithCriteria({ types: ['TEXT'] }) - await Promise.all( - texts - .filter( - (text) => - (typeof text.textStyleId === 'string' && text.textStyleId) || - text.textStyleId === figma.mixed, - ) - .map(async (text) => { - for (const seg of text.getStyledTextSegments([ - 'fontName', - 'fontWeight', - 'fontSize', - 'textDecoration', - 'textCase', - 'lineHeight', - 'letterSpacing', - 'fills', - 'textStyleId', - 'fillStyleId', - 'listOptions', - 'indentation', - 'hyperlink', - ])) { - if (seg?.textStyleId) { - const style = await figma.getStyleByIdAsync(seg.textStyleId) - - if (!(style && ids.has(style.id))) continue - const { level, name } = styleNameToTypography(style.name) - const typo = textSegmentToTypography(seg) - if (typography[name]?.[level]) continue - typography[name] ??= [null, null, null, null, null, null] - typography[name][level] = typo - } - } - }), - ) + for (const text of texts) { + if ( + !(typeof text.textStyleId === 'string' && text.textStyleId) && + text.textStyleId !== figma.mixed + ) + continue + // Short-circuit: single-style nodes whose style is not local + if ( + typeof text.textStyleId === 'string' && + !stylesById.has(text.textStyleId) + ) + continue + for (const seg of text.getStyledTextSegments([ + 'fontName', + 'fontWeight', + 'fontSize', + 'textDecoration', + 'textCase', + 'lineHeight', + 'letterSpacing', + 'textStyleId', + ])) { + if (seg?.textStyleId) { + // Sync lookup — no async IPC per segment + const style = stylesById.get(seg.textStyleId) + if (!style) continue + const { level, name } = styleNameToTypography(style.name) + const typo = textSegmentToTypography(seg) + if (typography[name]?.[level]) continue + typography[name] ??= [null, null, null, null, null, null] + typography[name][level] = typo + } + } + } } else { for (const [styleName, style] of Object.entries(styles)) { const { level, name } = styleNameToTypography(styleName) @@ -106,6 +126,7 @@ export async function exportDevup( typography[name][level] = typo } } + perfEnd('exportDevup.typography', tTypo) for (const [name, style] of Object.entries(styles)) { const { level, name: styleName } = styleNameToTypography(name) @@ -143,6 +164,9 @@ export async function exportDevup( ) } + perfEnd('exportDevup()', t) + console.info(perfReport()) + switch (output) { case 'json': return downloadFile('devup.json', JSON.stringify(devup)) From 19fe0e17affd4e2702de76c82ae7b4ac47039e2e Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 12 Mar 2026 16:46:18 +0900 Subject: [PATCH 2/6] Optimize export devup --- src/commands/devup/export-devup.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/commands/devup/export-devup.ts b/src/commands/devup/export-devup.ts index 8dddc0b..bc70658 100644 --- a/src/commands/devup/export-devup.ts +++ b/src/commands/devup/export-devup.ts @@ -82,8 +82,19 @@ export async function exportDevup( const tTypo = perfStart() const typography: Record = {} if (treeshaking) { + // Skip hidden instance children — can make findAllWithCriteria dramatically faster + const prevSkip = figma.skipInvisibleInstanceChildren + figma.skipInvisibleInstanceChildren = true + + const tFind = perfStart() const texts = figma.root.findAllWithCriteria({ types: ['TEXT'] }) + perfEnd('exportDevup.typography.find', tFind) + + const tScan = perfStart() + const foundStyleIds = new Set() for (const text of texts) { + // Early exit: all local styles discovered + if (foundStyleIds.size >= stylesById.size) break if ( !(typeof text.textStyleId === 'string' && text.textStyleId) && text.textStyleId !== figma.mixed @@ -95,6 +106,12 @@ export async function exportDevup( !stylesById.has(text.textStyleId) ) continue + // Skip single-style nodes whose style is already found + if ( + typeof text.textStyleId === 'string' && + foundStyleIds.has(text.textStyleId) + ) + continue for (const seg of text.getStyledTextSegments([ 'fontName', 'fontWeight', @@ -106,9 +123,11 @@ export async function exportDevup( 'textStyleId', ])) { if (seg?.textStyleId) { + if (foundStyleIds.has(seg.textStyleId)) continue // Sync lookup — no async IPC per segment const style = stylesById.get(seg.textStyleId) if (!style) continue + foundStyleIds.add(seg.textStyleId) const { level, name } = styleNameToTypography(style.name) const typo = textSegmentToTypography(seg) if (typography[name]?.[level]) continue @@ -117,6 +136,9 @@ export async function exportDevup( } } } + perfEnd('exportDevup.typography.scan', tScan) + + figma.skipInvisibleInstanceChildren = prevSkip } else { for (const [styleName, style] of Object.entries(styles)) { const { level, name } = styleNameToTypography(styleName) From b5376f1f529b68c2028c4ae74145ea9114853361 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 12 Mar 2026 17:28:07 +0900 Subject: [PATCH 3/6] Optimize export devup --- src/commands/devup/__tests__/index.test.ts | 43 ++++++++ src/commands/devup/export-devup.ts | 112 ++++++++++----------- 2 files changed, 98 insertions(+), 57 deletions(-) diff --git a/src/commands/devup/__tests__/index.test.ts b/src/commands/devup/__tests__/index.test.ts index 6e53430..aa34528 100644 --- a/src/commands/devup/__tests__/index.test.ts +++ b/src/commands/devup/__tests__/index.test.ts @@ -235,6 +235,49 @@ describe('devup commands', () => { ) }) + test('exportDevup treeshake true handles mixed-style text nodes', async () => { + getColorCollectionSpy = spyOn( + getColorCollectionModule, + 'getDevupColorCollection', + ).mockResolvedValue(null) + styleNameToTypographySpy = spyOn( + styleNameToTypographyModule, + 'styleNameToTypography', + ).mockReturnValue({ level: 0, name: 'heading' }) + textStyleToTypographySpy = spyOn( + textStyleToTypographyModule, + 'textStyleToTypography', + ).mockReturnValue({ fontFamily: 'Inter' } as unknown as DevupTypography) + + const mixedSymbol = Symbol('mixed') + const mixedTextNode = { + type: 'TEXT', + textStyleId: mixedSymbol, + getStyledTextSegments: () => [ + { textStyleId: 'style1' }, + { textStyleId: 'style2' }, + ], + } as unknown as TextNode + + ;(globalThis as { figma?: unknown }).figma = { + util: { rgba: (v: unknown) => v }, + loadAllPagesAsync: async () => {}, + getLocalTextStylesAsync: async () => [ + { id: 'style1', name: 'heading/1' } as unknown as TextStyle, + ], + root: { findAllWithCriteria: () => [mixedTextNode] }, + mixed: mixedSymbol, + variables: { getVariableByIdAsync: async () => null }, + } as unknown as typeof figma + + await exportDevup('json', true) + + expect(downloadFileMock).toHaveBeenCalledWith( + 'devup.json', + expect.stringContaining('"typography"'), + ) + }) + test('exportDevup fills missing typography levels from styles map', async () => { getColorCollectionSpy = spyOn( getColorCollectionModule, diff --git a/src/commands/devup/export-devup.ts b/src/commands/devup/export-devup.ts index bc70658..8e40a2a 100644 --- a/src/commands/devup/export-devup.ts +++ b/src/commands/devup/export-devup.ts @@ -9,7 +9,6 @@ import { isVariableAlias } from '../../utils/is-variable-alias' import { optimizeHex } from '../../utils/optimize-hex' import { rgbaToHex } from '../../utils/rgba-to-hex' import { styleNameToTypography } from '../../utils/style-name-to-typography' -import { textSegmentToTypography } from '../../utils/text-segment-to-typography' import { textStyleToTypography } from '../../utils/text-style-to-typography' import { toCamel } from '../../utils/to-camel' import { variableAliasToValue } from '../../utils/variable-alias-to-value' @@ -34,32 +33,41 @@ export async function exportDevup( figma.variables.getVariableByIdAsync(varId), ), ) + // Pre-compute camelCase names once (not per variable per mode) + const camelNames = variables.map((v) => (v ? toCamel(v.name) : '')) devup.theme ??= {} devup.theme.colors ??= {} - for (const mode of collection.modes) { - const colors: Record = {} - devup.theme.colors[mode.name.toLowerCase()] = colors - await Promise.all( - variables.map(async (variable) => { - if (variable === null) return - const value = variable.valuesByMode[mode.modeId] - if (typeof value === 'boolean' || typeof value === 'number') return - if (isVariableAlias(value)) { - const nextValue = await variableAliasToValue(value, mode.modeId) - if (nextValue === null) return - if (typeof nextValue === 'boolean' || typeof nextValue === 'number') - return - colors[toCamel(variable.name)] = optimizeHex( - rgbaToHex(figma.util.rgba(nextValue)), - ) - } else { - colors[toCamel(variable.name)] = optimizeHex( - rgbaToHex(figma.util.rgba(value)), - ) - } - }), - ) - } + const themeColors = devup.theme.colors + // Process all modes in parallel + await Promise.all( + collection.modes.map(async (mode) => { + const colors: Record = {} + themeColors[mode.name.toLowerCase()] = colors + await Promise.all( + variables.map(async (variable, i) => { + if (variable === null) return + const value = variable.valuesByMode[mode.modeId] + if (typeof value === 'boolean' || typeof value === 'number') return + if (isVariableAlias(value)) { + const nextValue = await variableAliasToValue(value, mode.modeId) + if (nextValue === null) return + if ( + typeof nextValue === 'boolean' || + typeof nextValue === 'number' + ) + return + colors[camelNames[i]] = optimizeHex( + rgbaToHex(figma.util.rgba(nextValue)), + ) + } else { + colors[camelNames[i]] = optimizeHex( + rgbaToHex(figma.util.rgba(value)), + ) + } + }), + ) + }), + ) } perfEnd('exportDevup.colors', tColors) @@ -100,40 +108,30 @@ export async function exportDevup( text.textStyleId !== figma.mixed ) continue - // Short-circuit: single-style nodes whose style is not local - if ( - typeof text.textStyleId === 'string' && - !stylesById.has(text.textStyleId) - ) - continue - // Skip single-style nodes whose style is already found - if ( - typeof text.textStyleId === 'string' && - foundStyleIds.has(text.textStyleId) - ) - continue - for (const seg of text.getStyledTextSegments([ - 'fontName', - 'fontWeight', - 'fontSize', - 'textDecoration', - 'textCase', - 'lineHeight', - 'letterSpacing', - 'textStyleId', - ])) { - if (seg?.textStyleId) { - if (foundStyleIds.has(seg.textStyleId)) continue - // Sync lookup — no async IPC per segment - const style = stylesById.get(seg.textStyleId) - if (!style) continue - foundStyleIds.add(seg.textStyleId) - const { level, name } = styleNameToTypography(style.name) - const typo = textSegmentToTypography(seg) - if (typography[name]?.[level]) continue + + if (typeof text.textStyleId === 'string') { + // Single-style node — skip getStyledTextSegments entirely + const style = stylesById.get(text.textStyleId) + if (!style || foundStyleIds.has(text.textStyleId)) continue + foundStyleIds.add(text.textStyleId) + const { level, name } = styleNameToTypography(style.name) + if (!typography[name]?.[level]) { typography[name] ??= [null, null, null, null, null, null] - typography[name][level] = typo + typography[name][level] = textStyleToTypography(style) } + continue + } + + // Mixed-style node — only request textStyleId (1 field vs 8) + for (const seg of text.getStyledTextSegments(['textStyleId'])) { + if (!seg?.textStyleId || foundStyleIds.has(seg.textStyleId)) continue + const style = stylesById.get(seg.textStyleId) + if (!style) continue + foundStyleIds.add(seg.textStyleId) + const { level, name } = styleNameToTypography(style.name) + if (typography[name]?.[level]) continue + typography[name] ??= [null, null, null, null, null, null] + typography[name][level] = textStyleToTypography(style) } } perfEnd('exportDevup.typography.scan', tScan) From 912a2bf2aa05f8e83408cc82a8c7e09b02730f9f Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 12 Mar 2026 17:54:36 +0900 Subject: [PATCH 4/6] Optimize export devup --- src/commands/devup/__tests__/index.test.ts | 62 ++++++++++++++++++ src/commands/devup/export-devup.ts | 76 ++++++++++++++++------ 2 files changed, 117 insertions(+), 21 deletions(-) diff --git a/src/commands/devup/__tests__/index.test.ts b/src/commands/devup/__tests__/index.test.ts index aa34528..387de4f 100644 --- a/src/commands/devup/__tests__/index.test.ts +++ b/src/commands/devup/__tests__/index.test.ts @@ -278,6 +278,68 @@ describe('devup commands', () => { ) }) + test('exportDevup treeshake true stops early once a typography key is found', async () => { + getColorCollectionSpy = spyOn( + getColorCollectionModule, + 'getDevupColorCollection', + ).mockResolvedValue(null) + styleNameToTypographySpy = spyOn( + styleNameToTypographyModule, + 'styleNameToTypography', + ).mockImplementation((name: string) => + name.includes('2') + ? ({ level: 1, name: 'heading' } as const) + : ({ level: 0, name: 'heading' } as const), + ) + textStyleToTypographySpy = spyOn( + textStyleToTypographyModule, + 'textStyleToTypography', + ).mockReturnValue({ fontFamily: 'Inter' } as unknown as DevupTypography) + + const currentTextNode = { + type: 'TEXT', + textStyleId: 'style1', + getStyledTextSegments: () => [{ textStyleId: 'style1' }], + } as unknown as TextNode + const currentPageFindAllWithCriteria = mock(() => [currentTextNode]) + const otherPageFindAllWithCriteria = mock(() => []) + const rootFindAllWithCriteria = mock(() => []) + const currentPage = { + id: 'page-current', + findAllWithCriteria: currentPageFindAllWithCriteria, + } as unknown as PageNode + const otherPage = { + id: 'page-other', + findAllWithCriteria: otherPageFindAllWithCriteria, + } as unknown as PageNode + + ;(globalThis as { figma?: unknown }).figma = { + util: { rgba: (v: unknown) => v }, + currentPage, + loadAllPagesAsync: async () => {}, + getLocalTextStylesAsync: async () => [ + { id: 'style1', name: 'heading/1' } as unknown as TextStyle, + { id: 'style2', name: 'heading/2' } as unknown as TextStyle, + ], + root: { + children: [otherPage, currentPage], + findAllWithCriteria: rootFindAllWithCriteria, + }, + mixed: Symbol('mixed'), + variables: { getVariableByIdAsync: async () => null }, + } as unknown as typeof figma + + await exportDevup('json', true) + + expect(currentPageFindAllWithCriteria).toHaveBeenCalledTimes(1) + expect(otherPageFindAllWithCriteria).not.toHaveBeenCalled() + expect(rootFindAllWithCriteria).not.toHaveBeenCalled() + expect(downloadFileMock).toHaveBeenCalledWith( + 'devup.json', + expect.stringContaining('"typography"'), + ) + }) + test('exportDevup fills missing typography levels from styles map', async () => { getColorCollectionSpy = spyOn( getColorCollectionModule, diff --git a/src/commands/devup/export-devup.ts b/src/commands/devup/export-devup.ts index 8e40a2a..560c81c 100644 --- a/src/commands/devup/export-devup.ts +++ b/src/commands/devup/export-devup.ts @@ -82,8 +82,13 @@ export async function exportDevup( // Build both ID-keyed and name-keyed maps from a single fetch const stylesById = new Map() const styles: Record = {} + const styleMetaById = new Map() + const allTypographyKeys = new Set() for (const style of textStyles) { + const meta = styleNameToTypography(style.name) stylesById.set(style.id, style) + styleMetaById.set(style.id, meta) + allTypographyKeys.add(meta.name) styles[style.name] = style } @@ -94,47 +99,76 @@ export async function exportDevup( const prevSkip = figma.skipInvisibleInstanceChildren figma.skipInvisibleInstanceChildren = true - const tFind = perfStart() - const texts = figma.root.findAllWithCriteria({ types: ['TEXT'] }) - perfEnd('exportDevup.typography.find', tFind) - - const tScan = perfStart() - const foundStyleIds = new Set() - for (const text of texts) { - // Early exit: all local styles discovered - if (foundStyleIds.size >= stylesById.size) break + const usedTypographyKeys = new Set() + const processText = (text: TextNode) => { + if (usedTypographyKeys.size >= allTypographyKeys.size) return if ( !(typeof text.textStyleId === 'string' && text.textStyleId) && text.textStyleId !== figma.mixed ) - continue + return if (typeof text.textStyleId === 'string') { - // Single-style node — skip getStyledTextSegments entirely const style = stylesById.get(text.textStyleId) - if (!style || foundStyleIds.has(text.textStyleId)) continue - foundStyleIds.add(text.textStyleId) - const { level, name } = styleNameToTypography(style.name) + const meta = styleMetaById.get(text.textStyleId) + if (!style || !meta || usedTypographyKeys.has(meta.name)) return + usedTypographyKeys.add(meta.name) + const { level, name } = meta if (!typography[name]?.[level]) { typography[name] ??= [null, null, null, null, null, null] typography[name][level] = textStyleToTypography(style) } - continue + return } - // Mixed-style node — only request textStyleId (1 field vs 8) for (const seg of text.getStyledTextSegments(['textStyleId'])) { - if (!seg?.textStyleId || foundStyleIds.has(seg.textStyleId)) continue + if (usedTypographyKeys.size >= allTypographyKeys.size) return + if (!seg?.textStyleId) continue const style = stylesById.get(seg.textStyleId) - if (!style) continue - foundStyleIds.add(seg.textStyleId) - const { level, name } = styleNameToTypography(style.name) + const meta = styleMetaById.get(seg.textStyleId) + if (!style || !meta || usedTypographyKeys.has(meta.name)) continue + usedTypographyKeys.add(meta.name) + const { level, name } = meta if (typography[name]?.[level]) continue typography[name] ??= [null, null, null, null, null, null] typography[name][level] = textStyleToTypography(style) } } - perfEnd('exportDevup.typography.scan', tScan) + + const rootPages = Array.isArray(figma.root.children) + ? figma.root.children + : [] + if (rootPages.length > 1) { + const currentPageId = figma.currentPage.id + const orderedPages = [ + figma.currentPage, + ...rootPages.filter((page) => page.id !== currentPageId), + ] + for (const page of orderedPages) { + if (usedTypographyKeys.size >= allTypographyKeys.size) break + const tFind = perfStart() + const texts = page.findAllWithCriteria({ types: ['TEXT'] }) + perfEnd('exportDevup.typography.find', tFind) + + const tScan = perfStart() + for (const text of texts) { + processText(text) + if (usedTypographyKeys.size >= allTypographyKeys.size) break + } + perfEnd('exportDevup.typography.scan', tScan) + } + } else { + const tFind = perfStart() + const texts = figma.root.findAllWithCriteria({ types: ['TEXT'] }) + perfEnd('exportDevup.typography.find', tFind) + + const tScan = perfStart() + for (const text of texts) { + processText(text) + if (usedTypographyKeys.size >= allTypographyKeys.size) break + } + perfEnd('exportDevup.typography.scan', tScan) + } figma.skipInvisibleInstanceChildren = prevSkip } else { From fb0c97f8977f29e3d7bfe6f4f4867f0dd1f6b8b5 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 12 Mar 2026 18:58:18 +0900 Subject: [PATCH 5/6] Optimize export devup --- src/commands/devup/__tests__/index.test.ts | 164 +++++++++++++++++++-- src/commands/devup/export-devup.ts | 152 ++++++++++--------- 2 files changed, 238 insertions(+), 78 deletions(-) diff --git a/src/commands/devup/__tests__/index.test.ts b/src/commands/devup/__tests__/index.test.ts index 387de4f..39aa172 100644 --- a/src/commands/devup/__tests__/index.test.ts +++ b/src/commands/devup/__tests__/index.test.ts @@ -278,7 +278,7 @@ describe('devup commands', () => { ) }) - test('exportDevup treeshake true stops early once a typography key is found', async () => { + test('exportDevup treeshake true stops within current page subtree and skips later pages', async () => { getColorCollectionSpy = spyOn( getColorCollectionModule, 'getDevupColorCollection', @@ -301,29 +301,36 @@ describe('devup commands', () => { textStyleId: 'style1', getStyledTextSegments: () => [{ textStyleId: 'style1' }], } as unknown as TextNode - const currentPageFindAllWithCriteria = mock(() => [currentTextNode]) - const otherPageFindAllWithCriteria = mock(() => []) - const rootFindAllWithCriteria = mock(() => []) + const firstSectionFindAllWithCriteria = mock(() => [currentTextNode]) + const secondSectionFindAllWithCriteria = mock(() => []) + const otherPageLoadAsync = mock(async () => {}) + const firstSection = { + type: 'SECTION', + findAllWithCriteria: firstSectionFindAllWithCriteria, + } as unknown as SectionNode + const secondSection = { + type: 'SECTION', + findAllWithCriteria: secondSectionFindAllWithCriteria, + } as unknown as SectionNode const currentPage = { id: 'page-current', - findAllWithCriteria: currentPageFindAllWithCriteria, + children: [firstSection, secondSection], } as unknown as PageNode const otherPage = { id: 'page-other', - findAllWithCriteria: otherPageFindAllWithCriteria, + children: [], + loadAsync: otherPageLoadAsync, } as unknown as PageNode ;(globalThis as { figma?: unknown }).figma = { util: { rgba: (v: unknown) => v }, currentPage, - loadAllPagesAsync: async () => {}, getLocalTextStylesAsync: async () => [ { id: 'style1', name: 'heading/1' } as unknown as TextStyle, { id: 'style2', name: 'heading/2' } as unknown as TextStyle, ], root: { children: [otherPage, currentPage], - findAllWithCriteria: rootFindAllWithCriteria, }, mixed: Symbol('mixed'), variables: { getVariableByIdAsync: async () => null }, @@ -331,15 +338,150 @@ describe('devup commands', () => { await exportDevup('json', true) - expect(currentPageFindAllWithCriteria).toHaveBeenCalledTimes(1) - expect(otherPageFindAllWithCriteria).not.toHaveBeenCalled() - expect(rootFindAllWithCriteria).not.toHaveBeenCalled() + expect(firstSectionFindAllWithCriteria).toHaveBeenCalledTimes(1) + expect(secondSectionFindAllWithCriteria).not.toHaveBeenCalled() + expect(otherPageLoadAsync).not.toHaveBeenCalled() + expect(downloadFileMock).toHaveBeenCalledWith( + 'devup.json', + expect.stringContaining('"typography"'), + ) + }) + + test('exportDevup treeshake true lazily loads later pages when needed', async () => { + getColorCollectionSpy = spyOn( + getColorCollectionModule, + 'getDevupColorCollection', + ).mockResolvedValue(null) + styleNameToTypographySpy = spyOn( + styleNameToTypographyModule, + 'styleNameToTypography', + ).mockImplementation((name: string) => + name.includes('2') + ? ({ level: 1, name: 'body' } as const) + : ({ level: 0, name: 'heading' } as const), + ) + textStyleToTypographySpy = spyOn( + textStyleToTypographyModule, + 'textStyleToTypography', + ).mockReturnValue({ fontFamily: 'Inter' } as unknown as DevupTypography) + + const currentSectionFindAllWithCriteria = mock(() => []) + const otherTextNode = { + type: 'TEXT', + textStyleId: 'style2', + getStyledTextSegments: () => [{ textStyleId: 'style2' }], + } as unknown as TextNode + const otherSectionFindAllWithCriteria = mock(() => [otherTextNode]) + const otherPageLoadAsync = mock(async () => {}) + const currentPage = { + id: 'page-current', + children: [ + { + type: 'SECTION', + findAllWithCriteria: currentSectionFindAllWithCriteria, + } as unknown as SectionNode, + ], + } as unknown as PageNode + const otherPage = { + id: 'page-other', + children: [ + { + type: 'SECTION', + findAllWithCriteria: otherSectionFindAllWithCriteria, + } as unknown as SectionNode, + ], + loadAsync: otherPageLoadAsync, + } as unknown as PageNode + + ;(globalThis as { figma?: unknown }).figma = { + util: { rgba: (v: unknown) => v }, + currentPage, + getLocalTextStylesAsync: async () => [ + { id: 'style1', name: 'heading/1' } as unknown as TextStyle, + { id: 'style2', name: 'body/2' } as unknown as TextStyle, + ], + root: { + children: [currentPage, otherPage], + }, + mixed: Symbol('mixed'), + variables: { getVariableByIdAsync: async () => null }, + } as unknown as typeof figma + + await exportDevup('json', true) + + expect(currentSectionFindAllWithCriteria).toHaveBeenCalledTimes(1) + expect(otherPageLoadAsync).toHaveBeenCalledTimes(1) + expect(otherSectionFindAllWithCriteria).toHaveBeenCalledTimes(1) expect(downloadFileMock).toHaveBeenCalledWith( 'devup.json', expect.stringContaining('"typography"'), ) }) + test('exportDevup treeshake true handles direct text children and recursive fallback nodes', async () => { + getColorCollectionSpy = spyOn( + getColorCollectionModule, + 'getDevupColorCollection', + ).mockResolvedValue(null) + styleNameToTypographySpy = spyOn( + styleNameToTypographyModule, + 'styleNameToTypography', + ).mockImplementation((name: string) => + name.includes('2') + ? ({ level: 1, name: 'body' } as const) + : ({ level: 0, name: 'heading' } as const), + ) + textStyleToTypographySpy = spyOn( + textStyleToTypographyModule, + 'textStyleToTypography', + ).mockImplementation( + (style: TextStyle) => ({ id: style.id }) as unknown as DevupTypography, + ) + + const directTextNode = { + type: 'TEXT', + textStyleId: 'style1', + getStyledTextSegments: () => [{ textStyleId: 'style1' }], + } as unknown as TextNode + const nestedTextNode = { + type: 'TEXT', + textStyleId: 'style2', + getStyledTextSegments: () => [{ textStyleId: 'style2' }], + } as unknown as TextNode + const recursiveNode = { + type: 'GROUP', + children: [nestedTextNode], + } as unknown as GroupNode + const currentPage = { + id: 'page-current', + children: [directTextNode, recursiveNode], + } as unknown as PageNode + + ;(globalThis as { figma?: unknown }).figma = { + util: { rgba: (v: unknown) => v }, + currentPage, + getLocalTextStylesAsync: async () => [ + { id: 'style1', name: 'heading/1' } as unknown as TextStyle, + { id: 'style2', name: 'body/2' } as unknown as TextStyle, + ], + root: { + children: [currentPage], + }, + mixed: Symbol('mixed'), + variables: { getVariableByIdAsync: async () => null }, + } as unknown as typeof figma + + await exportDevup('json', true) + + const firstCall = downloadFileMock.mock.calls[0] as unknown[] | undefined + const data = (firstCall?.[1] as string) ?? '{}' + const parsed = JSON.parse(data) as { + theme?: { typography?: Record } + } + expect(parsed.theme?.typography?.heading).toBeDefined() + expect(parsed.theme?.typography?.body).toBeDefined() + }) + test('exportDevup fills missing typography levels from styles map', async () => { getColorCollectionSpy = spyOn( getColorCollectionModule, diff --git a/src/commands/devup/export-devup.ts b/src/commands/devup/export-devup.ts index 560c81c..8e9341e 100644 --- a/src/commands/devup/export-devup.ts +++ b/src/commands/devup/export-devup.ts @@ -16,6 +16,14 @@ import type { Devup, DevupTypography } from './types' import { downloadDevupXlsx } from './utils/download-devup-xlsx' import { getDevupColorCollection } from './utils/get-devup-color-collection' +type TextSearchNode = SceneNode & { + findAllWithCriteria(criteria: { types: ['TEXT'] }): TextNode[] +} + +function isTextSearchNode(node: SceneNode): node is TextSearchNode { + return 'findAllWithCriteria' in node +} + export async function exportDevup( output: 'json' | 'excel', treeshaking: boolean = true, @@ -71,25 +79,26 @@ export async function exportDevup( } perfEnd('exportDevup.colors', tColors) - // Parallel: load pages (only if treeshaking) + fetch text styles const tLoad = perfStart() - const [, textStyles] = await Promise.all([ - treeshaking ? figma.loadAllPagesAsync() : Promise.resolve(), - figma.getLocalTextStylesAsync(), - ]) + const textStyles = await figma.getLocalTextStylesAsync() perfEnd('exportDevup.load', tLoad) - // Build both ID-keyed and name-keyed maps from a single fetch - const stylesById = new Map() - const styles: Record = {} - const styleMetaById = new Map() - const allTypographyKeys = new Set() + const typographyByKey: Record = {} + const styleMetaById: Record = + Object.create(null) as Record + let allTypographyKeyCount = 0 for (const style of textStyles) { const meta = styleNameToTypography(style.name) - stylesById.set(style.id, style) - styleMetaById.set(style.id, meta) - allTypographyKeys.add(meta.name) - styles[style.name] = style + let typographyValues = typographyByKey[meta.name] + if (!typographyValues) { + typographyValues = [null, null, null, null, null, null] + typographyByKey[meta.name] = typographyValues + allTypographyKeyCount += 1 + } + if (!typographyValues[meta.level]) { + typographyValues[meta.level] = textStyleToTypography(style) + } + styleMetaById[style.id] = meta } const tTypo = perfStart() @@ -99,63 +108,79 @@ export async function exportDevup( const prevSkip = figma.skipInvisibleInstanceChildren figma.skipInvisibleInstanceChildren = true - const usedTypographyKeys = new Set() + const usedTypographyKeys: Record = Object.create( + null, + ) as Record + let usedTypographyKeyCount = 0 + const markTypographyKeyUsed = (meta?: { level: number; name: string }) => { + if (!meta || usedTypographyKeys[meta.name]) return false + usedTypographyKeys[meta.name] = true + usedTypographyKeyCount += 1 + return true + } + const mixedTextStyleId = figma.mixed const processText = (text: TextNode) => { - if (usedTypographyKeys.size >= allTypographyKeys.size) return - if ( - !(typeof text.textStyleId === 'string' && text.textStyleId) && - text.textStyleId !== figma.mixed - ) + if (usedTypographyKeyCount >= allTypographyKeyCount) return + const { textStyleId } = text + if (typeof textStyleId === 'string' && textStyleId) { + markTypographyKeyUsed(styleMetaById[textStyleId]) return + } + if (textStyleId !== mixedTextStyleId) return - if (typeof text.textStyleId === 'string') { - const style = stylesById.get(text.textStyleId) - const meta = styleMetaById.get(text.textStyleId) - if (!style || !meta || usedTypographyKeys.has(meta.name)) return - usedTypographyKeys.add(meta.name) - const { level, name } = meta - if (!typography[name]?.[level]) { - typography[name] ??= [null, null, null, null, null, null] - typography[name][level] = textStyleToTypography(style) - } + for (const seg of text.getStyledTextSegments(['textStyleId'])) { + if (usedTypographyKeyCount >= allTypographyKeyCount) return + const segTextStyleId = seg?.textStyleId + if (!segTextStyleId) continue + markTypographyKeyUsed(styleMetaById[segTextStyleId]) + } + } + const processSubtree = (node: SceneNode) => { + if (usedTypographyKeyCount >= allTypographyKeyCount) return + if (node.type === 'TEXT') { + processText(node) return } + if (isTextSearchNode(node)) { + const tFind = perfStart() + const texts = node.findAllWithCriteria({ types: ['TEXT'] }) + perfEnd('exportDevup.typography.find', tFind) - for (const seg of text.getStyledTextSegments(['textStyleId'])) { - if (usedTypographyKeys.size >= allTypographyKeys.size) return - if (!seg?.textStyleId) continue - const style = stylesById.get(seg.textStyleId) - const meta = styleMetaById.get(seg.textStyleId) - if (!style || !meta || usedTypographyKeys.has(meta.name)) continue - usedTypographyKeys.add(meta.name) - const { level, name } = meta - if (typography[name]?.[level]) continue - typography[name] ??= [null, null, null, null, null, null] - typography[name][level] = textStyleToTypography(style) + const tScan = perfStart() + for (const text of texts) { + processText(text) + if (usedTypographyKeyCount >= allTypographyKeyCount) break + } + perfEnd('exportDevup.typography.scan', tScan) + return + } + if (!('children' in node)) return + for (const child of node.children) { + processSubtree(child) + if (usedTypographyKeyCount >= allTypographyKeyCount) break } } const rootPages = Array.isArray(figma.root.children) ? figma.root.children : [] - if (rootPages.length > 1) { + if (rootPages.length > 0) { const currentPageId = figma.currentPage.id - const orderedPages = [ + const orderedPages: PageNode[] = [ figma.currentPage, ...rootPages.filter((page) => page.id !== currentPageId), ] for (const page of orderedPages) { - if (usedTypographyKeys.size >= allTypographyKeys.size) break - const tFind = perfStart() - const texts = page.findAllWithCriteria({ types: ['TEXT'] }) - perfEnd('exportDevup.typography.find', tFind) - - const tScan = perfStart() - for (const text of texts) { - processText(text) - if (usedTypographyKeys.size >= allTypographyKeys.size) break + if (usedTypographyKeyCount >= allTypographyKeyCount) break + if (page.id !== currentPageId) { + const tPageLoad = perfStart() + await page.loadAsync() + perfEnd('exportDevup.load', tPageLoad) + } + for (const child of page.children) { + processSubtree(child) + if (usedTypographyKeyCount >= allTypographyKeyCount) break } - perfEnd('exportDevup.typography.scan', tScan) } } else { const tFind = perfStart() @@ -165,30 +190,23 @@ export async function exportDevup( const tScan = perfStart() for (const text of texts) { processText(text) - if (usedTypographyKeys.size >= allTypographyKeys.size) break + if (usedTypographyKeyCount >= allTypographyKeyCount) break } perfEnd('exportDevup.typography.scan', tScan) } figma.skipInvisibleInstanceChildren = prevSkip + + for (const key of Object.keys(usedTypographyKeys)) { + typography[key] = [...(typographyByKey[key] ?? [])] + } } else { - for (const [styleName, style] of Object.entries(styles)) { - const { level, name } = styleNameToTypography(styleName) - const typo = textStyleToTypography(style) - if (typography[name]?.[level]) continue - typography[name] ??= [null, null, null, null, null, null] - typography[name][level] = typo + for (const [key, values] of Object.entries(typographyByKey)) { + typography[key] = [...values] } } perfEnd('exportDevup.typography', tTypo) - for (const [name, style] of Object.entries(styles)) { - const { level, name: styleName } = styleNameToTypography(name) - if (typography[styleName] && !typography[styleName][level]) { - typography[styleName][level] = textStyleToTypography(style) - } - } - if (Object.keys(typography).length > 0) { devup.theme ??= {} devup.theme.typography = Object.entries(typography).reduce( From 4e0de8a42c805272859162b722345318e78add83 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 12 Mar 2026 19:07:24 +0900 Subject: [PATCH 6/6] Support single typo issue --- src/utils/__tests__/text-style-to-typography.test.ts | 2 ++ src/utils/text-style-to-typography.ts | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/src/utils/__tests__/text-style-to-typography.test.ts b/src/utils/__tests__/text-style-to-typography.test.ts index 580cda4..bca3dc0 100644 --- a/src/utils/__tests__/text-style-to-typography.test.ts +++ b/src/utils/__tests__/text-style-to-typography.test.ts @@ -27,6 +27,8 @@ describe('textStyleToTypography', () => { ['Thin', 100], ['Extra Light', 200], ['Light', 300], + ['4', 400], + ['4 Normal', 400], ['Regular', 400], ['normal', 400], ['Medium', 500], diff --git a/src/utils/text-style-to-typography.ts b/src/utils/text-style-to-typography.ts index 3508b6f..2853736 100644 --- a/src/utils/text-style-to-typography.ts +++ b/src/utils/text-style-to-typography.ts @@ -42,5 +42,12 @@ function getFontWeight(weight: string): number { } const weightNumber = Number.parseInt(weight, 10) + if ( + Number.isInteger(weightNumber) && + weightNumber >= 1 && + weightNumber <= 9 + ) { + return weightNumber * 100 + } return Number.isNaN(weightNumber) ? 400 : weightNumber }