From da98eb3f35902ab0de080d0cbc8627a7fc2a3d35 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 7 Apr 2026 11:17:51 +0900 Subject: [PATCH] Support variable in typography --- src/commands/devup/__tests__/index.test.ts | 21 +++--- src/commands/devup/export-devup.ts | 2 +- src/commands/devup/types.ts | 2 +- .../resolve-text-style-bound-variable.test.ts | 62 ++++++++++++++++ .../text-style-to-typography.test.ts | 71 +++++++++++++++++-- .../resolve-text-style-bound-variable.ts | 26 +++++++ src/utils/text-style-to-typography.ts | 35 ++++++++- 7 files changed, 202 insertions(+), 17 deletions(-) create mode 100644 src/utils/__tests__/resolve-text-style-bound-variable.test.ts create mode 100644 src/utils/resolve-text-style-bound-variable.ts diff --git a/src/commands/devup/__tests__/index.test.ts b/src/commands/devup/__tests__/index.test.ts index 09bf460..f50e333 100644 --- a/src/commands/devup/__tests__/index.test.ts +++ b/src/commands/devup/__tests__/index.test.ts @@ -147,7 +147,7 @@ describe('devup commands', () => { textStyleToTypographySpy = spyOn( textStyleToTypographyModule, 'textStyleToTypography', - ).mockReturnValue({ + ).mockResolvedValue({ fontFamily: 'Inter', } as unknown as DevupTypography) @@ -201,7 +201,7 @@ describe('devup commands', () => { textStyleToTypographySpy = spyOn( textStyleToTypographyModule, 'textStyleToTypography', - ).mockReturnValue(typoSeg as unknown as DevupTypography) + ).mockResolvedValue(typoSeg as unknown as DevupTypography) textSegmentToTypographySpy = spyOn( textSegmentToTypographyModule, 'textSegmentToTypography', @@ -263,7 +263,7 @@ describe('devup commands', () => { textStyleToTypographySpy = spyOn( textStyleToTypographyModule, 'textStyleToTypography', - ).mockReturnValue({ fontFamily: 'Inter' } as unknown as DevupTypography) + ).mockResolvedValue({ fontFamily: 'Inter' } as unknown as DevupTypography) const mixedSymbol = Symbol('mixed') const mixedTextNode = { @@ -314,7 +314,7 @@ describe('devup commands', () => { textStyleToTypographySpy = spyOn( textStyleToTypographyModule, 'textStyleToTypography', - ).mockReturnValue({ fontFamily: 'Inter' } as unknown as DevupTypography) + ).mockResolvedValue({ fontFamily: 'Inter' } as unknown as DevupTypography) const currentTextNode = { type: 'TEXT', @@ -387,7 +387,7 @@ describe('devup commands', () => { textStyleToTypographySpy = spyOn( textStyleToTypographyModule, 'textStyleToTypography', - ).mockReturnValue({ fontFamily: 'Inter' } as unknown as DevupTypography) + ).mockResolvedValue({ fontFamily: 'Inter' } as unknown as DevupTypography) const currentSectionFindAllWithCriteria = mock(() => []) const otherTextNode = { @@ -463,7 +463,8 @@ describe('devup commands', () => { textStyleToTypographyModule, 'textStyleToTypography', ).mockImplementation( - (style: TextStyle) => ({ id: style.id }) as unknown as DevupTypography, + async (style: TextStyle) => + ({ id: style.id }) as unknown as DevupTypography, ) const directTextNode = { @@ -535,7 +536,7 @@ describe('devup commands', () => { textStyleToTypographySpy = spyOn( textStyleToTypographyModule, 'textStyleToTypography', - ).mockReturnValue(typoSeg as unknown as DevupTypography) + ).mockResolvedValue(typoSeg as unknown as DevupTypography) const textNode = { type: 'TEXT', @@ -591,7 +592,8 @@ describe('devup commands', () => { textStyleToTypographyModule, 'textStyleToTypography', ).mockImplementation( - (style: TextStyle) => ({ id: style.id }) as unknown as DevupTypography, + async (style: TextStyle) => + ({ id: style.id }) as unknown as DevupTypography, ) ;(globalThis as { figma?: unknown }).figma = { @@ -637,7 +639,8 @@ describe('devup commands', () => { textStyleToTypographyModule, 'textStyleToTypography', ).mockImplementation( - (style: TextStyle) => ({ id: style.id }) as unknown as DevupTypography, + async (style: TextStyle) => + ({ id: style.id }) as unknown as DevupTypography, ) ;(globalThis as { figma?: unknown }).figma = { diff --git a/src/commands/devup/export-devup.ts b/src/commands/devup/export-devup.ts index 805895c..74113c1 100644 --- a/src/commands/devup/export-devup.ts +++ b/src/commands/devup/export-devup.ts @@ -215,7 +215,7 @@ export async function buildDevupConfig( allTypographyKeyCount += 1 } if (!typographyValues[meta.level]) { - typographyValues[meta.level] = textStyleToTypography(style) + typographyValues[meta.level] = await textStyleToTypography(style) } styleMetaById[style.id] = meta } diff --git a/src/commands/devup/types.ts b/src/commands/devup/types.ts index d941650..680609a 100644 --- a/src/commands/devup/types.ts +++ b/src/commands/devup/types.ts @@ -2,7 +2,7 @@ export interface DevupTypography { fontFamily?: string fontStyle?: string fontSize?: string - fontWeight?: number + fontWeight?: number | string lineHeight?: number | string letterSpacing?: string textDecoration?: string diff --git a/src/utils/__tests__/resolve-text-style-bound-variable.test.ts b/src/utils/__tests__/resolve-text-style-bound-variable.test.ts new file mode 100644 index 0000000..cb3f5e4 --- /dev/null +++ b/src/utils/__tests__/resolve-text-style-bound-variable.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { resolveTextStyleBoundVariable } from '../resolve-text-style-bound-variable' + +describe('resolveTextStyleBoundVariable', () => { + afterEach(() => { + ;(globalThis as any).figma = undefined + }) + + test('returns null when boundVariables is undefined', async () => { + expect( + await resolveTextStyleBoundVariable(undefined, 'fontSize'), + ).toBeNull() + }) + + test('returns null when field is not bound', async () => { + expect( + await resolveTextStyleBoundVariable({} as any, 'fontSize'), + ).toBeNull() + }) + + test('returns null when variable is not found', async () => { + ;(globalThis as any).figma = { + variables: { + getVariableByIdAsync: async () => null, + }, + } + expect( + await resolveTextStyleBoundVariable( + { fontSize: { type: 'VARIABLE_ALIAS', id: 'var1' } } as any, + 'fontSize', + ), + ).toBeNull() + }) + + test('returns $camelName when variable is found', async () => { + ;(globalThis as any).figma = { + variables: { + getVariableByIdAsync: async () => ({ name: 'heading/font-size' }), + }, + } + expect( + await resolveTextStyleBoundVariable( + { fontSize: { type: 'VARIABLE_ALIAS', id: 'var1' } } as any, + 'fontSize', + ), + ).toBe('$headingFontSize') + }) + + test('returns null when variable has no name', async () => { + ;(globalThis as any).figma = { + variables: { + getVariableByIdAsync: async () => ({ name: '' }), + }, + } + expect( + await resolveTextStyleBoundVariable( + { fontSize: { type: 'VARIABLE_ALIAS', id: 'var1' } } as any, + 'fontSize', + ), + ).toBeNull() + }) +}) diff --git a/src/utils/__tests__/text-style-to-typography.test.ts b/src/utils/__tests__/text-style-to-typography.test.ts index bca3dc0..e6486c8 100644 --- a/src/utils/__tests__/text-style-to-typography.test.ts +++ b/src/utils/__tests__/text-style-to-typography.test.ts @@ -1,7 +1,10 @@ -import { describe, expect, test } from 'bun:test' +import { afterEach, describe, expect, test } from 'bun:test' import { textStyleToTypography } from '../text-style-to-typography' -function makeStyle(styleName: string): TextStyle { +function makeStyle( + styleName: string, + boundVariables?: Record, +): TextStyle { return { id: 'style', name: 'style', @@ -19,10 +22,15 @@ function makeStyle(styleName: string): TextStyle { textAlignVertical: 'TOP', lineHeight: { unit: 'AUTO' }, letterSpacing: { unit: 'PIXELS', value: 0 }, + boundVariables, } as unknown as TextStyle } describe('textStyleToTypography', () => { + afterEach(() => { + ;(globalThis as any).figma = undefined + }) + test.each([ ['Thin', 100], ['Extra Light', 200], @@ -40,8 +48,63 @@ describe('textStyleToTypography', () => { ['Heavy', 900], ['750', 750], ['UnknownWeight', 400], - ])('maps %s to fontWeight %d', (styleName, expected) => { - const result = textStyleToTypography(makeStyle(styleName)) + ])('maps %s to fontWeight %d', async (styleName, expected) => { + const result = await textStyleToTypography(makeStyle(styleName)) expect(result.fontWeight).toBe(expected) }) + + test('returns base typography when no boundVariables', async () => { + const result = await textStyleToTypography(makeStyle('Regular')) + expect(result.fontFamily).toBe('Pretendard') + expect(result.fontSize).toBe('16px') + }) + + test('overrides fields with bound variable references', async () => { + ;(globalThis as any).figma = { + variables: { + getVariableByIdAsync: async (id: string) => { + const vars: Record = { + v1: { name: 'heading/size' }, + v2: { name: 'heading/line-height' }, + v3: { name: 'heading/spacing' }, + v4: { name: 'heading/weight' }, + v5: { name: 'heading/family' }, + v6: { name: 'heading/style' }, + } + return vars[id] ?? null + }, + }, + } + const result = await textStyleToTypography( + makeStyle('Regular', { + fontSize: { type: 'VARIABLE_ALIAS', id: 'v1' }, + lineHeight: { type: 'VARIABLE_ALIAS', id: 'v2' }, + letterSpacing: { type: 'VARIABLE_ALIAS', id: 'v3' }, + fontWeight: { type: 'VARIABLE_ALIAS', id: 'v4' }, + fontFamily: { type: 'VARIABLE_ALIAS', id: 'v5' }, + fontStyle: { type: 'VARIABLE_ALIAS', id: 'v6' }, + }), + ) + expect(result.fontSize).toBe('$headingSize') + expect(result.lineHeight).toBe('$headingLineHeight') + expect(result.letterSpacing).toBe('$headingSpacing') + expect(result.fontWeight).toBe('$headingWeight') + expect(result.fontFamily).toBe('$headingFamily') + expect(result.fontStyle).toBe('$headingStyle') + }) + + test('keeps base value when variable not found', async () => { + ;(globalThis as any).figma = { + variables: { + getVariableByIdAsync: async () => null, + }, + } + const result = await textStyleToTypography( + makeStyle('Bold', { + fontSize: { type: 'VARIABLE_ALIAS', id: 'missing' }, + }), + ) + expect(result.fontSize).toBe('16px') + expect(result.fontWeight).toBe(700) + }) }) diff --git a/src/utils/resolve-text-style-bound-variable.ts b/src/utils/resolve-text-style-bound-variable.ts new file mode 100644 index 0000000..057957e --- /dev/null +++ b/src/utils/resolve-text-style-bound-variable.ts @@ -0,0 +1,26 @@ +import { toCamel } from './to-camel' + +type VariableBindableTextField = + | 'fontFamily' + | 'fontSize' + | 'fontStyle' + | 'fontWeight' + | 'letterSpacing' + | 'lineHeight' + | 'paragraphSpacing' + | 'paragraphIndent' + +type TextStyleBoundVariables = { + [field in VariableBindableTextField]?: VariableAlias +} + +export async function resolveTextStyleBoundVariable( + boundVariables: TextStyleBoundVariables | undefined, + field: VariableBindableTextField, +): Promise { + const binding = boundVariables?.[field] + if (!binding) return null + const variable = await figma.variables.getVariableByIdAsync(binding.id) + if (variable?.name) return `$${toCamel(variable.name)}` + return null +} diff --git a/src/utils/text-style-to-typography.ts b/src/utils/text-style-to-typography.ts index 2853736..ddef333 100644 --- a/src/utils/text-style-to-typography.ts +++ b/src/utils/text-style-to-typography.ts @@ -1,9 +1,12 @@ import type { DevupTypography } from '../commands/devup/types' +import { resolveTextStyleBoundVariable } from './resolve-text-style-bound-variable' import { textSegmentToTypography } from './text-segment-to-typography' import { toCamel } from './to-camel' -export function textStyleToTypography(style: TextStyle): DevupTypography { - return textSegmentToTypography({ +export async function textStyleToTypography( + style: TextStyle, +): Promise { + const base = textSegmentToTypography({ fontName: style.fontName, fontWeight: getFontWeight(style.fontName.style), fontSize: style.fontSize, @@ -12,6 +15,34 @@ export function textStyleToTypography(style: TextStyle): DevupTypography { lineHeight: style.lineHeight, letterSpacing: style.letterSpacing, }) + + const boundVars = style.boundVariables + if (!boundVars) return base + + const [ + fontFamily, + fontSize, + fontStyle, + fontWeight, + letterSpacing, + lineHeight, + ] = await Promise.all([ + resolveTextStyleBoundVariable(boundVars, 'fontFamily'), + resolveTextStyleBoundVariable(boundVars, 'fontSize'), + resolveTextStyleBoundVariable(boundVars, 'fontStyle'), + resolveTextStyleBoundVariable(boundVars, 'fontWeight'), + resolveTextStyleBoundVariable(boundVars, 'letterSpacing'), + resolveTextStyleBoundVariable(boundVars, 'lineHeight'), + ]) + + if (fontFamily) base.fontFamily = fontFamily + if (fontSize) base.fontSize = fontSize + if (fontStyle) base.fontStyle = fontStyle + if (fontWeight) base.fontWeight = fontWeight + if (letterSpacing) base.letterSpacing = letterSpacing + if (lineHeight) base.lineHeight = lineHeight + + return base } function getFontWeight(weight: string): number {