diff --git a/src/commands/devup/__tests__/index.test.ts b/src/commands/devup/__tests__/index.test.ts index 6e53430..39aa172 100644 --- a/src/commands/devup/__tests__/index.test.ts +++ b/src/commands/devup/__tests__/index.test.ts @@ -235,6 +235,253 @@ 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 treeshake true stops within current page subtree and skips later pages', 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 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', + children: [firstSection, secondSection], + } as unknown as PageNode + const otherPage = { + id: 'page-other', + children: [], + 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: 'heading/2' } as unknown as TextStyle, + ], + root: { + children: [otherPage, currentPage], + }, + mixed: Symbol('mixed'), + variables: { getVariableByIdAsync: async () => null }, + } as unknown as typeof figma + + await exportDevup('json', true) + + 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 62c6004..8e9341e 100644 --- a/src/commands/devup/export-devup.ts +++ b/src/commands/devup/export-devup.ts @@ -1,9 +1,14 @@ +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' 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' @@ -11,108 +16,196 @@ 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, ) { + perfReset() + const t = perfStart() const devup: Devup = {} + const tColors = perfStart() const collection = await getDevupColorCollection() if (collection) { - 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) - 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)), - ) - } - }), - ) - } + // Pre-fetch all variables once — reuse across modes + const variables = await Promise.all( + collection.variableIds.map((varId) => + 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 ??= {} + 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) - await figma.loadAllPagesAsync() - + const tLoad = perfStart() const textStyles = await figma.getLocalTextStylesAsync() - const ids = new Set() - const styles: Record = {} + perfEnd('exportDevup.load', tLoad) + + const typographyByKey: Record = {} + const styleMetaById: Record = + Object.create(null) as Record + let allTypographyKeyCount = 0 for (const style of textStyles) { - ids.add(style.id) - styles[style.name] = style + const meta = styleNameToTypography(style.name) + 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() 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 - } - } - }), - ) - } 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 + // Skip hidden instance children — can make findAllWithCriteria dramatically faster + const prevSkip = figma.skipInvisibleInstanceChildren + figma.skipInvisibleInstanceChildren = true + + 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 (usedTypographyKeyCount >= allTypographyKeyCount) return + const { textStyleId } = text + if (typeof textStyleId === 'string' && textStyleId) { + markTypographyKeyUsed(styleMetaById[textStyleId]) + return + } + if (textStyleId !== mixedTextStyleId) return - 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) + 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) + + 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 > 0) { + const currentPageId = figma.currentPage.id + const orderedPages: PageNode[] = [ + figma.currentPage, + ...rootPages.filter((page) => page.id !== currentPageId), + ] + for (const page of orderedPages) { + 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 + } + } + } 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 (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 [key, values] of Object.entries(typographyByKey)) { + typography[key] = [...values] } } + perfEnd('exportDevup.typography', tTypo) if (Object.keys(typography).length > 0) { devup.theme ??= {} @@ -143,6 +236,9 @@ export async function exportDevup( ) } + perfEnd('exportDevup()', t) + console.info(perfReport()) + switch (output) { case 'json': return downloadFile('devup.json', JSON.stringify(devup)) 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 }