diff --git a/.changeset/fix-group-description-handling.md b/.changeset/fix-group-description-handling.md new file mode 100644 index 0000000000..ef227ed6ee --- /dev/null +++ b/.changeset/fix-group-description-handling.md @@ -0,0 +1,5 @@ +--- +"@tokens-studio/figma-plugin": patch +--- + +Fix handling of $description at group and root level to comply with DTCG specification. Group and root level descriptions are no longer incorrectly converted to tokens with type "other". They are now properly preserved as metadata when provided through the API. diff --git a/packages/tokens-studio-for-figma/src/types/tokens/TokenSetMetadata.ts b/packages/tokens-studio-for-figma/src/types/tokens/TokenSetMetadata.ts new file mode 100644 index 0000000000..9521eabf24 --- /dev/null +++ b/packages/tokens-studio-for-figma/src/types/tokens/TokenSetMetadata.ts @@ -0,0 +1,12 @@ +// Metadata for a token set, including group-level descriptions +export type TokenSetMetadata = { + // Group path to its metadata (e.g., "colors.primary" -> { $description: "..." }) + groups?: Record; + // Root-level metadata + root?: GroupMetadata; +}; + +export type GroupMetadata = { + $description?: string; + $extensions?: Record; +}; diff --git a/packages/tokens-studio-for-figma/src/types/tokens/TokensStore.ts b/packages/tokens-studio-for-figma/src/types/tokens/TokensStore.ts index 92c3d2c67e..7a1e151dd3 100644 --- a/packages/tokens-studio-for-figma/src/types/tokens/TokensStore.ts +++ b/packages/tokens-studio-for-figma/src/types/tokens/TokensStore.ts @@ -2,12 +2,15 @@ import { StorageType } from '../StorageType'; import { ThemeObjectsList } from '../ThemeObjectsList'; import { UsedTokenSetsMap } from '../UsedTokenSetsMap'; import { AnyTokenList } from './AnyTokenList'; +import { TokenSetMetadata } from './TokenSetMetadata'; export type TokenStore = { version: string; updatedAt: string; // @README these could be different themes or sets of tokens values: Record; + // Metadata for token sets (group-level descriptions, etc.) + metadata?: Record; usedTokenSet?: UsedTokenSetsMap | null; checkForChanges?: boolean | null; activeTheme: Record; diff --git a/packages/tokens-studio-for-figma/src/utils/convertTokens.roundtrip.test.ts b/packages/tokens-studio-for-figma/src/utils/convertTokens.roundtrip.test.ts new file mode 100644 index 0000000000..e8fd9c0c4f --- /dev/null +++ b/packages/tokens-studio-for-figma/src/utils/convertTokens.roundtrip.test.ts @@ -0,0 +1,169 @@ +import { TokenFormatOptions, TokenFormat } from '@/plugin/TokenFormatStoreClass'; +import convertToTokenArray from './convertTokens'; +import convertTokensToObject from './convertTokensToObject'; + +describe('Round-trip conversion with group descriptions', () => { + beforeEach(() => { + TokenFormat.setFormat(TokenFormatOptions.DTCG); + }); + + it('should preserve group and root descriptions through full round-trip', () => { + // Input JSON with group and root descriptions + const inputJSON = { + $description: 'Fluent Blue color palettes', + primary: { + $description: 'Primary brand colors', + 10: { + $value: '#061724', + $type: 'color', + $description: 'Darkest primary color', + }, + 20: { + $value: '#0a2540', + $type: 'color', + }, + }, + secondary: { + $description: 'Secondary colors', + light: { + $value: '#f0f0f0', + $type: 'color', + }, + dark: { + $value: '#333333', + $type: 'color', + }, + }, + }; + + // Step 1: Convert JSON to token array (what happens when loading) + const { tokens: tokenArray, metadata } = convertToTokenArray({ tokens: inputJSON }); + + // Verify tokens are extracted correctly + expect(tokenArray).toHaveLength(4); + expect(tokenArray[0].name).toBe('primary.10'); + expect(tokenArray[0].value).toBe('#061724'); + expect(tokenArray[0].description).toBe('Darkest primary color'); + + // Verify metadata is captured + expect(metadata.root?.$description).toBe('Fluent Blue color palettes'); + expect(metadata.groups?.['primary']?.$description).toBe('Primary brand colors'); + expect(metadata.groups?.['secondary']?.$description).toBe('Secondary colors'); + + // Step 2: Convert back to nested object (what happens when exporting) + const reconstructed = convertTokensToObject( + { base: tokenArray }, + true, + { base: metadata }, + ); + + // Verify structure is preserved + expect(reconstructed.base.$description).toBe('Fluent Blue color palettes'); + expect(reconstructed.base.primary.$description).toBe('Primary brand colors'); + expect(reconstructed.base.primary['10'].$value).toBe('#061724'); + expect(reconstructed.base.primary['10'].$description).toBe('Darkest primary color'); + expect(reconstructed.base.primary['20'].$value).toBe('#0a2540'); + expect(reconstructed.base.secondary.$description).toBe('Secondary colors'); + expect(reconstructed.base.secondary.light.$value).toBe('#f0f0f0'); + expect(reconstructed.base.secondary.dark.$value).toBe('#333333'); + }); + + it('should handle nested groups with descriptions', () => { + const inputJSON = { + $description: 'Root description', + colors: { + $description: 'All colors', + brand: { + $description: 'Brand colors', + primary: { + $description: 'Primary brand', + base: { + $value: '#0000ff', + $type: 'color', + }, + }, + }, + }, + }; + + const { tokens: tokenArray, metadata } = convertToTokenArray({ tokens: inputJSON }); + + expect(tokenArray).toHaveLength(1); + expect(tokenArray[0].name).toBe('colors.brand.primary.base'); + + expect(metadata.root?.$description).toBe('Root description'); + expect(metadata.groups?.['colors']?.$description).toBe('All colors'); + expect(metadata.groups?.['colors.brand']?.$description).toBe('Brand colors'); + expect(metadata.groups?.['colors.brand.primary']?.$description).toBe('Primary brand'); + + // Reconstruct + const reconstructed = convertTokensToObject( + { base: tokenArray }, + true, + { base: metadata }, + ); + + expect(reconstructed.base.$description).toBe('Root description'); + expect(reconstructed.base.colors.$description).toBe('All colors'); + expect(reconstructed.base.colors.brand.$description).toBe('Brand colors'); + expect(reconstructed.base.colors.brand.primary.$description).toBe('Primary brand'); + expect(reconstructed.base.colors.brand.primary.base.$value).toBe('#0000ff'); + }); + + it('should handle $extensions metadata', () => { + const inputJSON = { + $description: 'Root description', + $extensions: { + 'com.example': { + version: '1.0', + }, + }, + primary: { + $description: 'Primary colors', + $extensions: { + 'com.example': { + category: 'brand', + }, + }, + base: { + $value: '#ff0000', + $type: 'color', + }, + }, + }; + + const { tokens: tokenArray, metadata } = convertToTokenArray({ tokens: inputJSON }); + + expect(metadata.root?.$description).toBe('Root description'); + expect(metadata.root?.$extensions).toEqual({ + 'com.example': { + version: '1.0', + }, + }); + expect(metadata.groups?.['primary']?.$extensions).toEqual({ + 'com.example': { + category: 'brand', + }, + }); + + // Reconstruct + const reconstructed = convertTokensToObject( + { base: tokenArray }, + true, + { base: metadata }, + ); + + expect(reconstructed.base.$description).toBe('Root description'); + expect(reconstructed.base.$extensions).toEqual({ + 'com.example': { + version: '1.0', + }, + }); + expect(reconstructed.base.primary.$description).toBe('Primary colors'); + expect(reconstructed.base.primary.$extensions).toEqual({ + 'com.example': { + category: 'brand', + }, + }); + }); +}); diff --git a/packages/tokens-studio-for-figma/src/utils/convertTokens.test.ts b/packages/tokens-studio-for-figma/src/utils/convertTokens.test.ts index 50e9f5a0c6..d67f004a8f 100644 --- a/packages/tokens-studio-for-figma/src/utils/convertTokens.test.ts +++ b/packages/tokens-studio-for-figma/src/utils/convertTokens.test.ts @@ -90,7 +90,7 @@ describe('convertToTokenArray', () => { }, }; - expect(convertToTokenArray({ tokens: basicTokens })).toEqual([ + expect(convertToTokenArray({ tokens: basicTokens }).tokens).toEqual([ { name: 'global.withValue', value: 'bar', type: 'other' }, { name: 'global.basic', value: '#ff0000', type: 'other' }, { ...typographyTokens.withValue.output, name: 'global.typography.heading.h2' }, @@ -118,7 +118,7 @@ describe('convertToTokenArray', () => { expandShadow: true, expandComposition: true, expandBorder: true, - }), + }).tokens, ).toEqual([ { name: 'global.withValue', value: 'bar', type: 'other' }, { name: 'global.basic', value: '#ff0000', type: 'other' }, @@ -141,4 +141,60 @@ describe('convertToTokenArray', () => { { name: 'global.nestGroupWithType.font.big', value: '24px', type: 'dimension' }, ]); }); + + it('ignores group-level and root-level $description metadata', () => { + const tokensWithGroupDescriptions = { + $description: 'Root level description', + primary: { + $description: 'Primary brand colors', + 10: { + $value: '#061724', + $type: 'color', + $description: 'Token level description', + }, + 20: { + $value: '#0a2540', + $type: 'color', + }, + }, + secondary: { + $description: 'Secondary colors', + light: { + $value: '#f0f0f0', + $type: 'color', + }, + }, + }; + + const result = convertToTokenArray({ tokens: tokensWithGroupDescriptions }); + + // Should only include actual tokens, not $description metadata + expect(result.tokens).toEqual([ + { + name: 'primary.10', + value: '#061724', + type: 'color', + description: 'Token level description', + }, + { + name: 'primary.20', + value: '#0a2540', + type: 'color', + }, + { + name: 'secondary.light', + value: '#f0f0f0', + type: 'color', + }, + ]); + + // Verify that $description is not treated as a token + const hasDescriptionToken = result.tokens.some((token) => token.name.includes('$description') || token.name.includes('description')); + expect(hasDescriptionToken).toBe(false); + + // Verify metadata is captured correctly + expect(result.metadata.root?.$description).toBe('Root level description'); + expect(result.metadata.groups?.['primary']?.$description).toBe('Primary brand colors'); + expect(result.metadata.groups?.['secondary']?.$description).toBe('Secondary colors'); + }); }); diff --git a/packages/tokens-studio-for-figma/src/utils/convertTokens.tsx b/packages/tokens-studio-for-figma/src/utils/convertTokens.tsx index 61354dfbe7..f8e55bcfa3 100644 --- a/packages/tokens-studio-for-figma/src/utils/convertTokens.tsx +++ b/packages/tokens-studio-for-figma/src/utils/convertTokens.tsx @@ -1,5 +1,6 @@ import { TokenTypes } from '@/constants/TokenTypes'; import { SingleToken } from '@/types/tokens'; +import { TokenSetMetadata, GroupMetadata } from '@/types/tokens/TokenSetMetadata'; import { isSingleBorderToken, isSingleBoxShadowToken, @@ -45,6 +46,13 @@ export type Tokens = $description?: string; }; +// Metadata keys that should not be treated as tokens +const METADATA_KEYS = ['$description', '$extensions', 'description', 'extensions']; + +function isMetadataKey(key: string): boolean { + return METADATA_KEYS.includes(key); +} + // @TODO fix typings function checkForTokens({ obj, @@ -58,6 +66,7 @@ function checkForTokens({ inheritType, groupLevel = 0, currentTypeLevel = 0, + metadata = {}, }: { obj: SingleToken[]; token: Tokens | TokenGroupInJSON; @@ -70,6 +79,7 @@ function checkForTokens({ inheritType?: string; groupLevel?: number; currentTypeLevel?: number; + metadata?: Record; }): [(SingleToken & SingleToken & OptionalDTCGKeys)[], SingleToken & OptionalDTCGKeys | undefined] { let returnValue: | Pick, 'name' | 'value' | 'type' | 'description' | 'inheritTypeLevel'> @@ -118,9 +128,24 @@ function checkForTokens({ } } else if (typeof token === 'object') { // We dont have a single token value key yet, so it's likely a group which we need to iterate over - // This would be where we push a `group` entity to the array, once we do want to tackle group descriptions or group metadata + // Capture group-level metadata before processing tokens let tokenToCheck = token; groupLevel += 1; + + // Collect metadata for this group + const groupMetadata: GroupMetadata = {}; + if (TokenFormat.tokenDescriptionKey in token && typeof token[TokenFormat.tokenDescriptionKey] === 'string') { + groupMetadata.$description = token[TokenFormat.tokenDescriptionKey] as string; + } + if ('$extensions' in token && typeof token.$extensions === 'object') { + groupMetadata.$extensions = token.$extensions; + } + + // Store metadata if this is a group (not a token) and has metadata + if (root && Object.keys(groupMetadata).length > 0) { + metadata[root] = groupMetadata; + } + // When token groups are typed, we need to inherit the type to their children if (isTokenGroupWithType(token)) { const { [TokenFormat.tokenTypeKey]: groupType, ...tokenValues } = token; @@ -131,6 +156,10 @@ function checkForTokens({ if (typeof tokenToCheck !== 'undefined' || tokenToCheck !== null) { Object.entries(tokenToCheck).forEach(([key, value]) => { + // Skip metadata keys like $description and $extensions at group level + if (isMetadataKey(key)) { + return; + } const [, result] = checkForTokens({ obj, token: value as TokenGroupInJSON, @@ -143,6 +172,7 @@ function checkForTokens({ inheritType, groupLevel, currentTypeLevel, + metadata, }); if (root && result) { obj.push({ ...result, name: [root, key].join('.') }); @@ -166,18 +196,40 @@ function checkForTokens({ return [obj, returnValue as SingleToken | undefined]; } -export default function convertToTokenArray({ tokens }: { tokens: Tokens }) { +export default function convertToTokenArray({ tokens }: { tokens: Tokens }): { + tokens: SingleToken[]; + metadata: TokenSetMetadata; +} { + const metadata: Record = {}; const [result] = checkForTokens({ obj: [], root: null, token: tokens, + metadata, }); + // Capture root-level metadata + const rootMetadata: GroupMetadata = {}; + if (TokenFormat.tokenDescriptionKey in tokens && typeof tokens[TokenFormat.tokenDescriptionKey] === 'string') { + rootMetadata.$description = tokens[TokenFormat.tokenDescriptionKey] as string; + } + if ('$extensions' in tokens && typeof tokens.$extensions === 'object') { + rootMetadata.$extensions = tokens.$extensions; + } + // Internally we dont care about $value or value, we always use value, so remove it - return Object.values(result).map((token) => { + const processedTokens = Object.values(result).map((token) => { if ('$value' in token) delete token.$value; if ('$description' in token) delete token.$description; if ('$type' in token) delete token.$type; return token; }); + + return { + tokens: processedTokens, + metadata: { + root: Object.keys(rootMetadata).length > 0 ? rootMetadata : undefined, + groups: Object.keys(metadata).length > 0 ? metadata : undefined, + }, + }; } diff --git a/packages/tokens-studio-for-figma/src/utils/convertTokensToObject.test.ts b/packages/tokens-studio-for-figma/src/utils/convertTokensToObject.test.ts index 3463bfc277..5da3f3aeac 100644 --- a/packages/tokens-studio-for-figma/src/utils/convertTokensToObject.test.ts +++ b/packages/tokens-studio-for-figma/src/utils/convertTokensToObject.test.ts @@ -202,4 +202,47 @@ describe('convertTokensToObject', () => { TokenFormat.setFormat(TokenFormatOptions.Legacy); expect(convertTokensToObject(output as unknown as Record, true)).toEqual(input); }); + + it('should preserve group-level and root-level descriptions when metadata is provided', () => { + TokenFormat.setFormat(TokenFormatOptions.DTCG); + + const tokensWithMetadata: Record = { + base: [ + { + name: 'colors.primary.10', + type: 'color', + value: '#061724', + }, + { + name: 'colors.primary.20', + type: 'color', + value: '#0a2540', + }, + ], + }; + + const metadata = { + base: { + root: { + $description: 'Root level description', + }, + groups: { + colors: { + $description: 'All color tokens', + }, + 'colors.primary': { + $description: 'Primary brand colors', + }, + }, + }, + }; + + const result = convertTokensToObject(tokensWithMetadata, true, metadata); + + expect(result.base.$description).toBe('Root level description'); + expect(result.base.colors.$description).toBe('All color tokens'); + expect(result.base.colors.primary.$description).toBe('Primary brand colors'); + expect(result.base.colors.primary['10'].$value).toBe('#061724'); + expect(result.base.colors.primary['20'].$value).toBe('#0a2540'); + }); }); diff --git a/packages/tokens-studio-for-figma/src/utils/convertTokensToObject.ts b/packages/tokens-studio-for-figma/src/utils/convertTokensToObject.ts index 3c54397fbe..7501bfd20b 100644 --- a/packages/tokens-studio-for-figma/src/utils/convertTokensToObject.ts +++ b/packages/tokens-studio-for-figma/src/utils/convertTokensToObject.ts @@ -1,13 +1,30 @@ import set from 'set-value'; import { appendTypeToToken } from '@/app/components/createTokenObj'; import { AnyTokenList, AnyTokenSet } from '@/types/tokens'; +import { TokenSetMetadata } from '@/types/tokens/TokenSetMetadata'; import { getGroupTypeName } from './stringifyTokens'; import removeTokenId from './removeTokenId'; import { setTokenKey, FormatSensitiveTokenKeys } from './setTokenKey'; +import { TokenFormat } from '@/plugin/TokenFormatStoreClass'; -export default function convertTokensToObject(tokens: Record, storeTokenIdInJsonEditor: boolean) { +export default function convertTokensToObject( + tokens: Record, + storeTokenIdInJsonEditor: boolean, + metadata?: Record, +) { const tokenObj = Object.entries(tokens).reduce>>((acc, [key, val]) => { const tokenGroupObj: AnyTokenSet = {}; + + // Add root-level metadata if available + if (metadata?.[key]?.root) { + if (metadata[key].root?.$description) { + tokenGroupObj[TokenFormat.tokenDescriptionKey] = metadata[key].root.$description; + } + if (metadata[key].root?.$extensions) { + tokenGroupObj.$extensions = metadata[key].root.$extensions; + } + } + val.forEach((token) => { const tokenWithType = appendTypeToToken(token); const tokenWithoutId = removeTokenId(tokenWithType, !storeTokenIdInJsonEditor); @@ -41,6 +58,19 @@ export default function convertTokensToObject(tokens: Record { + if (groupMetadata.$description) { + set(tokenGroupObj, `${groupPath}.${TokenFormat.tokenDescriptionKey}`, groupMetadata.$description); + } + if (groupMetadata.$extensions) { + set(tokenGroupObj, `${groupPath}.$extensions`, groupMetadata.$extensions); + } + }); + } + acc[key] = tokenGroupObj; return acc; }, {}); diff --git a/packages/tokens-studio-for-figma/src/utils/parseTokenValues.ts b/packages/tokens-studio-for-figma/src/utils/parseTokenValues.ts index ba85134eff..010fc54cc9 100644 --- a/packages/tokens-studio-for-figma/src/utils/parseTokenValues.ts +++ b/packages/tokens-studio-for-figma/src/utils/parseTokenValues.ts @@ -26,7 +26,7 @@ export default function parseTokenValues(tokens: SetTokenDataPayload['values']): if (typeof parsedGroup === 'object') { detectFormat(parsedGroup, true); - const convertedToArray = convertToTokenArray({ tokens: parsedGroup }); + const { tokens: convertedToArray } = convertToTokenArray({ tokens: parsedGroup }); prev.push([group[0], convertedToArray]); return prev; } diff --git a/packages/tokens-studio-for-figma/src/utils/stringifyTokens.ts b/packages/tokens-studio-for-figma/src/utils/stringifyTokens.ts index 80b510f2fe..08c2c3e2f6 100644 --- a/packages/tokens-studio-for-figma/src/utils/stringifyTokens.ts +++ b/packages/tokens-studio-for-figma/src/utils/stringifyTokens.ts @@ -1,6 +1,7 @@ import set from 'set-value'; import { appendTypeToToken } from '@/app/components/createTokenObj'; import { AnyTokenList } from '@/types/tokens'; +import { TokenSetMetadata } from '@/types/tokens/TokenSetMetadata'; import removeTokenId from './removeTokenId'; import { TokenFormat, TokenFormatOptions } from '@/plugin/TokenFormatStoreClass'; import { TokenInJSON } from './convertTokens'; @@ -26,8 +27,20 @@ export default function stringifyTokens( tokens: Record, activeTokenSet: string, storeTokenIdInJsonEditor?: boolean, + metadata?: Record, ): string { - const tokenObj = {}; + const tokenObj: any = {}; + + // Add root-level metadata if available + if (metadata?.[activeTokenSet]?.root) { + if (metadata[activeTokenSet].root?.$description) { + tokenObj[TokenFormat.tokenDescriptionKey] = metadata[activeTokenSet].root.$description; + } + if (metadata[activeTokenSet].root?.$extensions) { + tokenObj.$extensions = metadata[activeTokenSet].root.$extensions; + } + } + tokens[activeTokenSet]?.forEach((token) => { const tokenWithType = appendTypeToToken(token); const { name, ...tokenWithoutName } = removeTokenId(tokenWithType, !storeTokenIdInJsonEditor); @@ -59,5 +72,17 @@ export default function stringifyTokens( } }); + // Add group-level metadata after tokens are set + if (metadata?.[activeTokenSet]?.groups) { + Object.entries(metadata[activeTokenSet].groups!).forEach(([groupPath, groupMetadata]) => { + if (groupMetadata.$description) { + set(tokenObj, `${groupPath}.${TokenFormat.tokenDescriptionKey}`, groupMetadata.$description); + } + if (groupMetadata.$extensions) { + set(tokenObj, `${groupPath}.$extensions`, groupMetadata.$extensions); + } + }); + } + return JSON.stringify(tokenObj, null, 2); }