diff --git a/.changeset/ten-meals-happen.md b/.changeset/ten-meals-happen.md new file mode 100644 index 0000000000..a2a82393d4 --- /dev/null +++ b/.changeset/ten-meals-happen.md @@ -0,0 +1,5 @@ +--- +"@tokens-studio/figma-plugin": patch +--- + +Fix Export to Variables writing malformed font values (e.g. `[Arial`) for font-family and font-weight tokens whose resolved value is an array-shaped string. The bracket-shaped form (`["Arial","Helvetica"]`) is now normalized to the first entry, with JSON parsing so quoted family names containing commas survive intact. diff --git a/packages/tokens-studio-for-figma/src/plugin/figmaTransforms/convertFontFamilyToFigma.test.ts b/packages/tokens-studio-for-figma/src/plugin/figmaTransforms/convertFontFamilyToFigma.test.ts index 0f6b04b1a7..2d68f4c469 100644 --- a/packages/tokens-studio-for-figma/src/plugin/figmaTransforms/convertFontFamilyToFigma.test.ts +++ b/packages/tokens-studio-for-figma/src/plugin/figmaTransforms/convertFontFamilyToFigma.test.ts @@ -36,4 +36,34 @@ describe('convertFontFamilyToFigma', () => { const result = convertFontFamilyToFigma(value, true); expect(result).toBe('Arial'); }); + + it('strips brackets and returns the first family for a JSON-array-shaped string', () => { + const value = '["Arial","Helvetica","sans-serif"]'; + const result = convertFontFamilyToFigma(value, true); + expect(result).toBe('Arial'); + }); + + it('strips brackets and returns the first family for an unquoted bracket list', () => { + const value = '[Arial, Helvetica, sans-serif]'; + const result = convertFontFamilyToFigma(value, true); + expect(result).toBe('Arial'); + }); + + it('strips brackets and returns the first family for a single-quoted bracket list', () => { + const value = "['Arial', 'Helvetica']"; + const result = convertFontFamilyToFigma(value, true); + expect(result).toBe('Arial'); + }); + + it('preserves brackets when shouldOutputForVariables is false', () => { + const value = '["Arial","Helvetica"]'; + const result = convertFontFamilyToFigma(value, false); + expect(result).toBe(value); + }); + + it('preserves a quoted family name containing a comma', () => { + const value = '["Font, Name","Arial"]'; + const result = convertFontFamilyToFigma(value, true); + expect(result).toBe('Font, Name'); + }); }); diff --git a/packages/tokens-studio-for-figma/src/plugin/figmaTransforms/convertFontFamilyToFigma.ts b/packages/tokens-studio-for-figma/src/plugin/figmaTransforms/convertFontFamilyToFigma.ts index ce51d95bbc..50b234857e 100644 --- a/packages/tokens-studio-for-figma/src/plugin/figmaTransforms/convertFontFamilyToFigma.ts +++ b/packages/tokens-studio-for-figma/src/plugin/figmaTransforms/convertFontFamilyToFigma.ts @@ -2,6 +2,26 @@ export function convertFontFamilyToFigma(value: string, shouldOutputForVariables const stringValue = value.toString(); try { if (shouldOutputForVariables) { + // Studio's server resolver returns fontFamilies as an array-shaped string + // (e.g. '["Arial","Helvetica"]'). Try JSON.parse first so quoted family names + // containing commas (e.g. '["Font, Name","Arial"]') survive intact; fall back + // to a comma split for single- or unquoted forms like "['Arial']" / "[Arial]". + const trimmed = stringValue.trim(); + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + if (trimmed.includes('"')) { + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed) && typeof parsed[0] === 'string') { + return parsed[0].trim(); + } + } catch { + // fall through to the split-based fallback + } + } + const inner = trimmed.slice(1, -1); + const first = inner.split(',')[0]?.trim().replace(/^['"]|['"]$/g, '').trim() ?? ''; + return first.length > 0 ? first : stringValue; + } const fontFamilies = stringValue.split(','); return fontFamilies[0].trim().replace(/['"]/g, ''); } diff --git a/packages/tokens-studio-for-figma/src/plugin/figmaTransforms/fontWeight.test.ts b/packages/tokens-studio-for-figma/src/plugin/figmaTransforms/fontWeight.test.ts index 92ec42582e..078cc2d7cf 100644 --- a/packages/tokens-studio-for-figma/src/plugin/figmaTransforms/fontWeight.test.ts +++ b/packages/tokens-studio-for-figma/src/plugin/figmaTransforms/fontWeight.test.ts @@ -48,4 +48,26 @@ describe('fontWeight', () => { expect(convertFontWeightToFigma(fontWeight.input)).toEqual(fontWeight.output); }); }); + + describe('shouldOutputForVariables', () => { + it('wraps a plain value in an array', () => { + expect(convertFontWeightToFigma('Bold', true)).toEqual(['Bold']); + }); + + it('strips brackets and returns the first weight for a JSON-array string', () => { + expect(convertFontWeightToFigma('["Bold","Regular"]', true)).toEqual(['Bold']); + }); + + it('strips brackets for an unquoted bracket list', () => { + expect(convertFontWeightToFigma('[Bold, Regular]', true)).toEqual(['Bold']); + }); + + it('strips brackets for a single-quoted bracket list', () => { + expect(convertFontWeightToFigma("['Bold', 'Regular']", true)).toEqual(['Bold']); + }); + + it('falls back to the raw value when the bracket list is empty', () => { + expect(convertFontWeightToFigma('[]', true)).toEqual(['[]']); + }); + }); }); diff --git a/packages/tokens-studio-for-figma/src/plugin/figmaTransforms/fontWeight.ts b/packages/tokens-studio-for-figma/src/plugin/figmaTransforms/fontWeight.ts index 1f57d69d51..3bb59e4da7 100644 --- a/packages/tokens-studio-for-figma/src/plugin/figmaTransforms/fontWeight.ts +++ b/packages/tokens-studio-for-figma/src/plugin/figmaTransforms/fontWeight.ts @@ -1,5 +1,14 @@ export function convertFontWeightToFigma(value: string, shouldOutputForVariables = false): string[] { if (shouldOutputForVariables) { + // Studio's server resolver returns fontWeights as an array-shaped string + // (e.g. '["Bold","Regular"]'). Strip surrounding brackets and take the first + // entry so we don't end up writing the raw JSON literal to the variable. + const trimmed = value.trim(); + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + const inner = trimmed.slice(1, -1); + const first = inner.split(',')[0]?.trim().replace(/^['"]|['"]$/g, '').trim() ?? ''; + return [first.length > 0 ? first : value]; + } return [value]; } diff --git a/packages/tokens-studio-for-figma/src/plugin/setValuesOnVariable.test.ts b/packages/tokens-studio-for-figma/src/plugin/setValuesOnVariable.test.ts index a571775a0c..98e70e393c 100644 --- a/packages/tokens-studio-for-figma/src/plugin/setValuesOnVariable.test.ts +++ b/packages/tokens-studio-for-figma/src/plugin/setValuesOnVariable.test.ts @@ -265,6 +265,51 @@ describe('SetValuesOnVariable', () => { expect(mockSetValueForModeLocal).toHaveBeenCalledWith(mode, 'Bold'); }); + describe('array-shaped fontFamilies / fontWeights values', () => { + const buildStringVariable = (name: string, setValueForMode: jest.Mock) => ({ + id: `VariableID:${name}:1`, + key: `${name}-key-1`, + name, + resolvedType: 'STRING', + description: '', + variableCollectionId: collection.id, + valuesByMode: { [mode]: 'Placeholder' }, + scopes: ['ALL_SCOPES'] as VariableScope[], + setValueForMode, + setVariableCodeSyntax: jest.fn(), + remove: jest.fn(), + } as unknown as Variable); + + // Note: JSON-array-shaped STRING values are normalized upstream by + // convertFontFamilyToFigma / convertFontWeightToFigma (see their own tests), + // so setValuesOnVariable only ever sees plain strings or actual arrays here. + const cases: { label: string; type: TokenTypes; value: unknown; expected: string }[] = [ + { + label: 'FONT_FAMILIES real array', type: TokenTypes.FONT_FAMILIES, value: ['Arial', 'Helvetica', 'sans-serif'], expected: 'Arial', + }, + { + label: 'FONT_WEIGHTS real array', type: TokenTypes.FONT_WEIGHTS, value: ['Bold', 'Regular'], expected: 'Bold', + }, + ]; + + it.each(cases)('writes the first entry for $label', async ({ type, value, expected }) => { + const setValueForMode = jest.fn(); + const variable = buildStringVariable('global/font', setValueForMode); + + const tokens = [{ + name: 'global.font', + path: 'global/font', + value, + rawValue: value, + type, + variableId: variable.key, + }]; + + await setValuesOnVariable([variable], tokens as any, collection, mode, baseFontSize); + expect(setValueForMode).toHaveBeenCalledWith(mode, expected); + }); + }); + describe('Variable Scopes and Code Syntax Updates', () => { const mockSetVariableCodeSyntax = jest.fn(); let testVariable: Variable; diff --git a/packages/tokens-studio-for-figma/src/plugin/setValuesOnVariable.ts b/packages/tokens-studio-for-figma/src/plugin/setValuesOnVariable.ts index 444efdae91..66c95b1df2 100644 --- a/packages/tokens-studio-for-figma/src/plugin/setValuesOnVariable.ts +++ b/packages/tokens-studio-for-figma/src/plugin/setValuesOnVariable.ts @@ -223,15 +223,27 @@ export default async function setValuesOnVariable( } break; } - case 'STRING': + case 'STRING': { + const isFontMultiValueType = token.type === TokenTypes.FONT_WEIGHTS || token.type === TokenTypes.FONT_FAMILIES; + let stringValue: string | undefined; + if (typeof token.value === 'string' && !token.value.includes('{')) { - setStringValuesOnVariable(variable, mode, token.value, hasMetadataChanged); + stringValue = token.value; // Given we cannot determine the combined family of a variable, we cannot use fallback weights from our estimates. // This is not an issue because users can set numerical font weights with variables, so we opt-out of the guesswork and just apply the numerical weight. - } else if (token.type === TokenTypes.FONT_WEIGHTS && Array.isArray(token.value)) { - setStringValuesOnVariable(variable, mode, token.value[0], hasMetadataChanged); + } else if (isFontMultiValueType && Array.isArray(token.value) && token.value[0] !== undefined) { + // Multi-value fonts arrive here as an actual array (either resolved locally, or + // wrapped by transformValue → convertFontFamilyToFigma / convertFontWeightToFigma). + // Figma variables hold a single string, so take the first entry and coerce + // (numeric font weights on a STRING variable were previously written as-is). + stringValue = String(token.value[0]); + } + + if (stringValue !== undefined) { + setStringValuesOnVariable(variable, mode, stringValue, hasMetadataChanged); } break; + } default: break; }