diff --git a/.changeset/fix-composite-tokens-export.md b/.changeset/fix-composite-tokens-export.md new file mode 100644 index 0000000000..e314a6d112 --- /dev/null +++ b/.changeset/fix-composite-tokens-export.md @@ -0,0 +1,5 @@ +--- +"@tokens-studio/figma-plugin": patch +--- + +Fix composite tokens being skipped during variable export. Typography, border, boxShadow, and composition tokens are now expanded into individual property variables (e.g., typography.fontSize, typography.fontWeight) when exporting as variables. diff --git a/packages/tokens-studio-for-figma/src/plugin/generateTokensToCreate.ts b/packages/tokens-studio-for-figma/src/plugin/generateTokensToCreate.ts index 10041bac0b..ede7ad6402 100644 --- a/packages/tokens-studio-for-figma/src/plugin/generateTokensToCreate.ts +++ b/packages/tokens-studio-for-figma/src/plugin/generateTokensToCreate.ts @@ -4,6 +4,7 @@ import { ThemeObject, UsedTokenSetsMap } from '@/types'; import { AnyTokenList } from '@/types/tokens'; import { defaultTokenResolver } from '@/utils/TokenResolver'; import { mergeTokenGroups } from '@/utils/tokenHelpers'; +import { expandCompositeTokensForVariables } from '@/utils/expandCompositeTokensForVariables'; export function generateTokensToCreate({ theme, @@ -21,7 +22,11 @@ export function generateTokensToCreate({ .filter(([name, status]) => status === TokenSetStatus.ENABLED && (!filterByTokenSet || name === filterByTokenSet)) .map(([tokenSet]) => tokenSet); const resolved = defaultTokenResolver.setTokens(mergeTokenGroups(tokens, theme.selectedTokenSets, overallConfig)); - return resolved.filter( + + // Expand composite tokens (typography, border, boxShadow, composition) into individual property tokens + const expandedTokens = expandCompositeTokensForVariables(resolved); + + return expandedTokens.filter( (token) => ((!token.internal__Parent || enabledTokenSets.includes(token.internal__Parent)) && tokenTypesToCreateVariable.includes(token.type)), // filter out SOURCE tokens ); } diff --git a/packages/tokens-studio-for-figma/src/plugin/updateVariables.test.ts b/packages/tokens-studio-for-figma/src/plugin/updateVariables.test.ts index 5356a36c25..fd74a98065 100644 --- a/packages/tokens-studio-for-figma/src/plugin/updateVariables.test.ts +++ b/packages/tokens-studio-for-figma/src/plugin/updateVariables.test.ts @@ -149,4 +149,50 @@ describe('updateVariables', () => { }); expect(result.removedVariables).toEqual(['VariableID:1:toremove']); }); + + it('should expand composite tokens (typography, border, boxShadow) into individual variables', async () => { + const tokensWithComposite = { + core: [ + { + name: 'heading.large', + value: { + fontFamily: 'Inter', + fontSize: '24px', + fontWeight: '700', + lineHeight: '1.5', + }, + type: TokenTypes.TYPOGRAPHY, + }, + { + name: 'border.primary', + value: { + color: '#ff0000', + width: '2px', + style: 'solid', + }, + type: TokenTypes.BORDER, + }, + ], + }; + + const result = await updateVariables({ + collection, + mode: '1:2', + theme, + tokens: tokensWithComposite, + settings, + overallConfig: { core: TokenSetStatus.ENABLED }, + }); + + // Should create variables for expanded properties + expect(result.variableIds['heading.large.fontFamily']).toBeDefined(); + expect(result.variableIds['heading.large.fontSize']).toBeDefined(); + expect(result.variableIds['heading.large.fontWeight']).toBeDefined(); + expect(result.variableIds['heading.large.lineHeight']).toBeDefined(); + expect(result.variableIds['border.primary.color']).toBeDefined(); + expect(result.variableIds['border.primary.width']).toBeDefined(); + + // Should have 6 total expanded variables + expect(Object.keys(result.variableIds).length).toBe(6); + }); }); diff --git a/packages/tokens-studio-for-figma/src/plugin/updateVariables.ts b/packages/tokens-studio-for-figma/src/plugin/updateVariables.ts index 3fd16d3a69..1c150691e6 100644 --- a/packages/tokens-studio-for-figma/src/plugin/updateVariables.ts +++ b/packages/tokens-studio-for-figma/src/plugin/updateVariables.ts @@ -27,6 +27,7 @@ export default async function updateVariables({ filterByTokenSet, overallConfig, }: CreateVariableTypes) { + // Tokens are already expanded in generateTokensToCreate const tokensToCreate = generateTokensToCreate({ theme, tokens, diff --git a/packages/tokens-studio-for-figma/src/utils/expandCompositeTokensForVariables.test.ts b/packages/tokens-studio-for-figma/src/utils/expandCompositeTokensForVariables.test.ts new file mode 100644 index 0000000000..8809e2a418 --- /dev/null +++ b/packages/tokens-studio-for-figma/src/utils/expandCompositeTokensForVariables.test.ts @@ -0,0 +1,248 @@ +import { TokenTypes } from '@/constants/TokenTypes'; +import { ResolveTokenValuesResult } from './tokenHelpers'; +import { expandCompositeTokensForVariables } from './expandCompositeTokensForVariables'; + +describe('expandCompositeTokensForVariables', () => { + it('should expand typography tokens into individual property tokens', () => { + const tokens: ResolveTokenValuesResult[] = [ + { + name: 'heading.large', + type: TokenTypes.TYPOGRAPHY, + value: { + fontFamily: 'Inter', + fontSize: '24px', + fontWeight: '700', + lineHeight: '1.5', + }, + } as ResolveTokenValuesResult, + ]; + + const result = expandCompositeTokensForVariables(tokens); + + expect(result).toHaveLength(4); + expect(result).toEqual([ + expect.objectContaining({ + name: 'heading.large.fontFamily', + type: TokenTypes.FONT_FAMILIES, + value: 'Inter', + }), + expect.objectContaining({ + name: 'heading.large.fontSize', + type: TokenTypes.FONT_SIZES, + value: '24px', + }), + expect.objectContaining({ + name: 'heading.large.fontWeight', + type: TokenTypes.FONT_WEIGHTS, + value: '700', + }), + expect.objectContaining({ + name: 'heading.large.lineHeight', + type: TokenTypes.LINE_HEIGHTS, + value: '1.5', + }), + ]); + }); + + it('should expand border tokens into individual property tokens', () => { + const tokens: ResolveTokenValuesResult[] = [ + { + name: 'border.primary', + type: TokenTypes.BORDER, + value: { + color: '#FF0000', + width: '2px', + style: 'solid', + }, + } as ResolveTokenValuesResult, + ]; + + const result = expandCompositeTokensForVariables(tokens); + + expect(result).toHaveLength(3); + expect(result).toEqual([ + expect.objectContaining({ + name: 'border.primary.color', + type: TokenTypes.COLOR, + value: '#FF0000', + }), + expect.objectContaining({ + name: 'border.primary.width', + type: TokenTypes.BORDER_WIDTH, + value: '2px', + }), + expect.objectContaining({ + name: 'border.primary.style', + value: 'solid', + }), + ]); + }); + + it('should expand boxShadow tokens into individual property tokens', () => { + const tokens: ResolveTokenValuesResult[] = [ + { + name: 'shadow.elevated', + type: TokenTypes.BOX_SHADOW, + value: { + color: '#000000', + x: '0', + y: '4px', + blur: '8px', + spread: '0', + }, + } as ResolveTokenValuesResult, + ]; + + const result = expandCompositeTokensForVariables(tokens); + + expect(result).toHaveLength(5); + expect(result).toEqual([ + expect.objectContaining({ + name: 'shadow.elevated.color', + type: TokenTypes.COLOR, + value: '#000000', + }), + expect.objectContaining({ + name: 'shadow.elevated.x', + value: '0', + }), + expect.objectContaining({ + name: 'shadow.elevated.y', + value: '4px', + }), + expect.objectContaining({ + name: 'shadow.elevated.blur', + value: '8px', + }), + expect.objectContaining({ + name: 'shadow.elevated.spread', + value: '0', + }), + ]); + }); + + it('should keep non-composite tokens unchanged', () => { + const tokens: ResolveTokenValuesResult[] = [ + { + name: 'color.primary', + type: TokenTypes.COLOR, + value: '#FF0000', + } as ResolveTokenValuesResult, + { + name: 'spacing.small', + type: TokenTypes.SPACING, + value: '8px', + } as ResolveTokenValuesResult, + ]; + + const result = expandCompositeTokensForVariables(tokens); + + expect(result).toHaveLength(2); + expect(result).toEqual(tokens); + }); + + it('should handle composite tokens with string references without expanding', () => { + const tokens: ResolveTokenValuesResult[] = [ + { + name: 'heading.reference', + type: TokenTypes.TYPOGRAPHY, + value: '{typography.base}', + } as ResolveTokenValuesResult, + ]; + + const result = expandCompositeTokensForVariables(tokens); + + expect(result).toHaveLength(1); + expect(result).toEqual(tokens); + }); + + it('should handle mixed composite and non-composite tokens', () => { + const tokens: ResolveTokenValuesResult[] = [ + { + name: 'color.primary', + type: TokenTypes.COLOR, + value: '#FF0000', + } as ResolveTokenValuesResult, + { + name: 'heading.small', + type: TokenTypes.TYPOGRAPHY, + value: { + fontFamily: 'Inter', + fontSize: '16px', + }, + } as ResolveTokenValuesResult, + { + name: 'spacing.medium', + type: TokenTypes.SPACING, + value: '16px', + } as ResolveTokenValuesResult, + ]; + + const result = expandCompositeTokensForVariables(tokens); + + expect(result).toHaveLength(4); + expect(result).toEqual([ + expect.objectContaining({ + name: 'color.primary', + type: TokenTypes.COLOR, + value: '#FF0000', + }), + expect.objectContaining({ + name: 'heading.small.fontFamily', + type: TokenTypes.FONT_FAMILIES, + value: 'Inter', + }), + expect.objectContaining({ + name: 'heading.small.fontSize', + type: TokenTypes.FONT_SIZES, + value: '16px', + }), + expect.objectContaining({ + name: 'spacing.medium', + type: TokenTypes.SPACING, + value: '16px', + }), + ]); + }); + + it('should expand typography tokens with paragraphIndent and paragraphSpacing', () => { + const tokens: ResolveTokenValuesResult[] = [ + { + name: 'paragraph.style', + type: TokenTypes.TYPOGRAPHY, + value: { + fontFamily: 'Arial', + fontSize: '14px', + paragraphSpacing: '12px', + paragraphIndent: '20px', + }, + } as ResolveTokenValuesResult, + ]; + + const result = expandCompositeTokensForVariables(tokens); + + expect(result).toHaveLength(4); + expect(result).toEqual([ + expect.objectContaining({ + name: 'paragraph.style.fontFamily', + type: TokenTypes.FONT_FAMILIES, + value: 'Arial', + }), + expect.objectContaining({ + name: 'paragraph.style.fontSize', + type: TokenTypes.FONT_SIZES, + value: '14px', + }), + expect.objectContaining({ + name: 'paragraph.style.paragraphSpacing', + type: TokenTypes.PARAGRAPH_SPACING, + value: '12px', + }), + expect.objectContaining({ + name: 'paragraph.style.paragraphIndent', + type: TokenTypes.PARAGRAPH_INDENT, + value: '20px', + }), + ]); + }); +}); diff --git a/packages/tokens-studio-for-figma/src/utils/expandCompositeTokensForVariables.ts b/packages/tokens-studio-for-figma/src/utils/expandCompositeTokensForVariables.ts new file mode 100644 index 0000000000..69e09f7353 --- /dev/null +++ b/packages/tokens-studio-for-figma/src/utils/expandCompositeTokensForVariables.ts @@ -0,0 +1,97 @@ +import { TokenTypes } from '@/constants/TokenTypes'; +import { ResolveTokenValuesResult } from './tokenHelpers'; +import { convertToDefaultProperty } from './convertToDefaultProperty'; + +/** + * Maps property strings to their corresponding TokenTypes enum values. + * The convertToDefaultProperty function returns string values that should map to TokenTypes. + */ +function mapPropertyToTokenType(propertyString: string): TokenTypes { + // Direct mappings from property strings to TokenTypes enum values + const propertyToTypeMap: Record = { + color: TokenTypes.COLOR, + borderWidth: TokenTypes.BORDER_WIDTH, + fontFamilies: TokenTypes.FONT_FAMILIES, + fontSizes: TokenTypes.FONT_SIZES, + fontWeights: TokenTypes.FONT_WEIGHTS, + lineHeights: TokenTypes.LINE_HEIGHTS, + letterSpacing: TokenTypes.LETTER_SPACING, + paragraphSpacing: TokenTypes.PARAGRAPH_SPACING, + paragraphIndent: TokenTypes.PARAGRAPH_INDENT, + dimension: TokenTypes.DIMENSION, + sizing: TokenTypes.SIZING, + spacing: TokenTypes.SPACING, + borderRadius: TokenTypes.BORDER_RADIUS, + opacity: TokenTypes.OPACITY, + textCase: TokenTypes.TEXT_CASE, + textDecoration: TokenTypes.TEXT_DECORATION, + }; + + return propertyToTypeMap[propertyString] || (propertyString as TokenTypes); +} + +/** + * Maps property keys to their appropriate token types based on the parent composite token type + */ +function getPropertyType(parentType: TokenTypes, propertyKey: string): TokenTypes { + // Handle border-specific properties + if (parentType === TokenTypes.BORDER) { + if (propertyKey === 'width') { + return TokenTypes.BORDER_WIDTH; + } + if (propertyKey === 'color') { + return TokenTypes.COLOR; + } + } + + // Handle boxShadow-specific properties + if (parentType === TokenTypes.BOX_SHADOW) { + if (propertyKey === 'color') { + return TokenTypes.COLOR; + } + } + + // For other composite types, use the default conversion and map to TokenTypes + const propertyString = convertToDefaultProperty(propertyKey); + return mapPropertyToTokenType(propertyString); +} + +/** + * Expands composite tokens (typography, border, boxShadow, composition) into individual property tokens + * that can be exported as variables + */ +export function expandCompositeTokensForVariables( + tokens: ResolveTokenValuesResult[], +): ResolveTokenValuesResult[] { + const expandedTokens: ResolveTokenValuesResult[] = []; + + tokens.forEach((token) => { + const isCompositeToken = [ + TokenTypes.TYPOGRAPHY, + TokenTypes.BORDER, + TokenTypes.BOX_SHADOW, + TokenTypes.COMPOSITION, + ].includes(token.type); + + if (isCompositeToken && typeof token.value === 'object' && token.value !== null) { + // Expand composite token into individual properties + Object.entries(token.value).forEach(([propertyKey, propertyValue]) => { + if (typeof propertyValue === 'string' || typeof propertyValue === 'number') { + const propertyType = getPropertyType(token.type, propertyKey); + + expandedTokens.push({ + ...token, + name: `${token.name}.${propertyKey}`, + type: propertyType, + value: propertyValue, + } as ResolveTokenValuesResult); + } + }); + } else { + // Keep non-composite tokens as-is + expandedTokens.push(token); + } + }); + + return expandedTokens; +}