Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ten-meals-happen.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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, '');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(['[]']);
});
});
});
Original file line number Diff line number Diff line change
@@ -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];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 16 additions & 4 deletions packages/tokens-studio-for-figma/src/plugin/setValuesOnVariable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading