diff --git a/.changeset/lemon-singers-search.md b/.changeset/lemon-singers-search.md new file mode 100644 index 0000000..077b8f0 --- /dev/null +++ b/.changeset/lemon-singers-search.md @@ -0,0 +1,5 @@ +--- +'@tokens-studio/sd-transforms': minor +--- + +Add [W3C Design Token Community Group draft spec](https://design-tokens.github.io/community-group/format/) forward compatibility. diff --git a/package-lock.json b/package-lock.json index 8d2c408..ee75544 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,22 +1,22 @@ { "name": "@tokens-studio/sd-transforms", - "version": "0.13.3", + "version": "0.13.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tokens-studio/sd-transforms", - "version": "0.13.3", + "version": "0.13.4", "license": "MIT", "dependencies": { - "@tokens-studio/types": "^0.2.4", + "@tokens-studio/types": "^0.4.0", "color2k": "^2.0.1", "colorjs.io": "^0.4.3", "deepmerge": "^4.3.1", "expr-eval-fork": "^2.0.2", "is-mergeable-object": "^1.1.1", "postcss-calc-ast-parser": "^0.1.4", - "style-dictionary": "4.0.0-prerelease.9" + "style-dictionary": "4.0.0-prerelease.10" }, "devDependencies": { "@changesets/cli": "^2.26.0", @@ -1361,9 +1361,9 @@ } }, "node_modules/@tokens-studio/types": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@tokens-studio/types/-/types-0.2.4.tgz", - "integrity": "sha512-8oNoRom66vNcT4+FRH6+E3tNv50zwU2SI/78P8xRi8ZenVYbfTaX6KOCP2bbSIhrz+HFQo8rK0+VCMi9MIq9Ng==" + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@tokens-studio/types/-/types-0.4.0.tgz", + "integrity": "sha512-rp5t0NP3Kai+Z+euGfHRUMn3AvPQ0bd9Dd2qbtfgnTvujxM5QYVr4psx/mwrVwA3NS9829mE6cD3ln+PIaptBA==" }, "node_modules/@tsconfig/node10": { "version": "1.0.9", @@ -9704,9 +9704,9 @@ } }, "node_modules/style-dictionary": { - "version": "4.0.0-prerelease.9", - "resolved": "https://registry.npmjs.org/style-dictionary/-/style-dictionary-4.0.0-prerelease.9.tgz", - "integrity": "sha512-fpQe740k88tf2HqkqTn2bOOUKTUM2OA+z0Xsy/EAbgncFjohrCdJ855w2Gn9LW0K867qB82Or7lpL8xwaaD3ug==", + "version": "4.0.0-prerelease.10", + "resolved": "https://registry.npmjs.org/style-dictionary/-/style-dictionary-4.0.0-prerelease.10.tgz", + "integrity": "sha512-lk0qRDGvbqj+xT2qTZHxKuSqW51NZfvyXXEoU7dI0uu2dgD+oTakAVhZGDSaXypxEjGhJyZexeE9QujH33Gliw==", "hasInstallScript": true, "dependencies": { "@bundled-es-modules/deepmerge": "^4.3.1", diff --git a/package.json b/package.json index db6bbc6..31d2670 100644 --- a/package.json +++ b/package.json @@ -39,14 +39,14 @@ }, "types": "./dist/src/index.d.ts", "dependencies": { - "@tokens-studio/types": "^0.2.4", + "@tokens-studio/types": "^0.4.0", "color2k": "^2.0.1", "colorjs.io": "^0.4.3", "deepmerge": "^4.3.1", "expr-eval-fork": "^2.0.2", "is-mergeable-object": "^1.1.1", "postcss-calc-ast-parser": "^0.1.4", - "style-dictionary": "4.0.0-prerelease.9" + "style-dictionary": "4.0.0-prerelease.10" }, "devDependencies": { "@changesets/cli": "^2.26.0", diff --git a/src/color-modifiers/transformColorModifiers.ts b/src/color-modifiers/transformColorModifiers.ts index d08739e..4b9cf58 100644 --- a/src/color-modifiers/transformColorModifiers.ts +++ b/src/color-modifiers/transformColorModifiers.ts @@ -22,5 +22,5 @@ export function transformColorModifiers( if (options?.format) { modifier.format = options.format; } - return modifyColor(token.value, modifier); + return modifyColor(token.$value ?? token.value, modifier); } diff --git a/src/parsers/add-font-styles.ts b/src/parsers/add-font-styles.ts index c02081a..343e34f 100644 --- a/src/parsers/add-font-styles.ts +++ b/src/parsers/add-font-styles.ts @@ -13,21 +13,16 @@ function recurse( if (typeof token !== 'object' || token === null) { continue; } - const { type, value } = token; - if (type === 'typography') { + const { value, type, $type } = token; + if ($type === 'typography' || type === 'typography') { if (typeof value !== 'object' || value.fontWeight === undefined) { continue; } let fontWeight = value.fontWeight; if (usesReferences(fontWeight)) { - try { - const resolved = resolveReferences(fontWeight, copy); - if (resolved) { - fontWeight = `${resolved}`; - } - } catch (e) { - // we don't want to throw a fatal error, we'll just keep the ref as is - console.error(e); + const resolved = resolveReferences(fontWeight, copy); + if (resolved) { + fontWeight = `${resolved}`; } } // cast because fontStyle is a prop we will add ourselves diff --git a/src/parsers/expand-composites.ts b/src/parsers/expand-composites.ts index 9dd210d..74f0483 100644 --- a/src/parsers/expand-composites.ts +++ b/src/parsers/expand-composites.ts @@ -32,33 +32,45 @@ const typeMaps = { }; function flattenValues['value']>(val: T): T { - return Object.fromEntries(Object.entries(val).map(([k, v]) => [k, v.value])) as T; + if (val) { + return Object.fromEntries(Object.entries(val).map(([k, v]) => [k, v.value])) as T; + } + return val; } -export function expandToken(compToken: SingleToken, isShadow = false): SingleToken { - if (typeof compToken.value !== 'object') { - return compToken; +export function expandToken(token: Expandables, isShadow = false): SingleToken { + const uses$ = token.$value != null; + const value = uses$ ? token.$value : token.value; + if (typeof value !== 'object') { + return token; + } + const tokenType = token.$type ?? token.type; + // the $type and type may both be missing if the $type is coming from an ancestor, + // however, style-dictionary runs a preprocessing step so missing $type is added from the closest ancestor + // our token types are not aware of that however, so we must do an undefined check here + if (tokenType === undefined) { + return token; } - const expandedObj = {} as SingleToken; - const getType = (key: string) => typeMaps[compToken.type][key] ?? key; + const expandedObj = {} as SingleToken; + const getType = (key: string) => typeMaps[tokenType][key] ?? key; // multi-shadow - if (isShadow && Array.isArray(compToken.value)) { - compToken.value.forEach((shadow, index) => { + if (isShadow && Array.isArray(value)) { + value.forEach((shadow, index) => { expandedObj[index + 1] = {}; Object.entries(shadow).forEach(([key, value]) => { expandedObj[index + 1][key] = { - value: `${value}`, + [`${uses$ ? '$' : ''}value`]: `${value}`, type: getType(key), }; }); }); } else { - Object.entries(compToken.value).forEach(([key, value]) => { + Object.entries(value).forEach(([key, value]) => { expandedObj[key] = { - value: `${value}`, - type: getType(key), + [`${uses$ ? '$' : ''}value`]: `${value}`, + [`${uses$ ? '$' : ''}type`]: getType(key), }; }); } @@ -99,8 +111,11 @@ function recurse( if (typeof token !== 'object' || token === null) { continue; } - const { type } = token; - if (token.value && type) { + + const uses$ = token.$value != null; + const value = uses$ ? token.$value : token.value; + const type = token.$type ?? token.type; + if (value && type) { if (typeof type === 'string' && expandablesAsStringsArr.includes(type)) { const expandType = (type as ExpandablesAsStrings) === 'boxShadow' ? 'shadow' : type; const expand = shouldExpand( @@ -110,28 +125,31 @@ function recurse( ); if (expand) { // if token uses a reference, attempt to resolve it - if (typeof token.value === 'string' && usesReferences(token.value)) { - try { - const resolved = resolveReferences(token.value, copy as DesignTokens); - if (resolved) { - token.value = resolved; - } - } catch (e) { - // we don't want to throw a fatal error, expansion can still occur, just with the reference kept as is - console.error(e); - } - // If every key of the result (object) is a number, the ref value is a multi-value, which means TokenBoxshadowValue[] - if (typeof token.value === 'object') { - if (Object.keys(token.value).every(key => !isNaN(Number(key)))) { - token.value = (Object.values(token.value) as TokenBoxshadowValue[]).map(part => + if (typeof value === 'string' && usesReferences(value)) { + let resolved = resolveReferences( + value, + copy as DesignTokens, + ) as SingleToken['value']; + if (typeof resolved === 'object') { + // If every key of the result (object) is a number, the ref value is a multi-value, which means TokenBoxshadowValue[] + if (Object.keys(resolved).every(key => !isNaN(Number(key)))) { + resolved = (Object.values(resolved) as TokenBoxshadowValue[]).map(part => flattenValues(part), ); } else { - token.value = flattenValues(token.value); + resolved = flattenValues(resolved); + } + } + + if (resolved) { + if (uses$) { + token.$value = resolved; + } else { + token.value = resolved; } } } - slice[key] = expandToken(token, expandType === 'shadow'); + slice[key] = expandToken(token as Expandables, expandType === 'shadow'); } } } else { diff --git a/src/registerTransforms.ts b/src/registerTransforms.ts index 0ffc884..ad37c05 100644 --- a/src/registerTransforms.ts +++ b/src/registerTransforms.ts @@ -70,54 +70,59 @@ export async function registerTransforms( sd.registerTransform({ name: 'ts/descriptionToComment', type: 'attribute', - matcher: token => token.description, + // in style-dictionary v4.0.0-prerelease.9, $description is converted to comments (createPropertyFormatter) + matcher: token => !token.$description && token.description, transformer: token => mapDescriptionToComment(token), }); sd.registerTransform({ name: 'ts/size/px', type: 'value', - matcher: token => - typeof token.type === 'string' && - ['sizing', 'spacing', 'borderRadius', 'borderWidth', 'fontSizes', 'dimension'].includes( - token.type, - ), - transformer: token => transformDimension(token.value), + matcher: token => { + const type = token.$type ?? token.type; + return ( + typeof type === 'string' && + ['sizing', 'spacing', 'borderRadius', 'borderWidth', 'fontSizes', 'dimension'].includes( + type, + ) + ); + }, + transformer: token => transformDimension(token.$value ?? token.value), }); sd.registerTransform({ name: 'ts/opacity', type: 'value', - matcher: token => token.type === 'opacity', - transformer: token => transformOpacity(token.value), + matcher: token => (token.$type ?? token.type) === 'opacity', + transformer: token => transformOpacity(token.$value ?? token.value), }); sd.registerTransform({ name: 'ts/size/css/letterspacing', type: 'value', - matcher: token => token.type === 'letterSpacing', - transformer: token => transformLetterSpacingForCSS(token.value), + matcher: token => (token.$type ?? token.type) === 'letterSpacing', + transformer: token => transformLetterSpacingForCSS(token.$value ?? token.value), }); sd.registerTransform({ name: 'ts/size/lineheight', type: 'value', - matcher: token => token.type === 'lineHeights', - transformer: token => transformLineHeight(token.value), + matcher: token => (token.$type ?? token.type) === 'lineHeights', + transformer: token => transformLineHeight(token.$value ?? token.value), }); sd.registerTransform({ name: 'ts/typography/fontWeight', type: 'value', - matcher: token => token.type === 'fontWeights', - transformer: token => transformFontWeights(token.value), + matcher: token => (token.$type ?? token.type) === 'fontWeights', + transformer: token => transformFontWeights(token.$value ?? token.value), }); sd.registerTransform({ name: 'ts/typography/css/fontFamily', type: 'value', - matcher: token => token.type === 'fontFamilies', - transformer: token => processFontFamily(token.value), + matcher: token => (token.$type ?? token.type) === 'fontFamilies', + transformer: token => processFontFamily(token.$value ?? token.value), }); /** @@ -135,51 +140,58 @@ export async function registerTransforms( name: 'ts/resolveMath', type: 'value', transitive: true, - matcher: token => typeof token.value === 'string', - transformer: token => checkAndEvaluateMath(token.value), + matcher: token => typeof (token.$value ?? token.value) === 'string', + transformer: token => checkAndEvaluateMath(token.$value ?? token.value), }); sd.registerTransform({ name: 'ts/typography/css/shorthand', type: 'value', transitive: true, - matcher: token => token.type === 'typography', - transformer: token => transformTypographyForCSS(token.value), + matcher: token => (token.$type ?? token.type) === 'typography', + transformer: token => transformTypographyForCSS(token.$value ?? token.value), }); sd.registerTransform({ name: 'ts/typography/compose/shorthand', type: 'value', transitive: true, - matcher: token => token.type === 'typography', - transformer: token => transformTypographyForCompose(token.value), + matcher: token => (token.$type ?? token.type) === 'typography', + transformer: token => transformTypographyForCompose(token.$value ?? token.value), }); sd.registerTransform({ name: 'ts/border/css/shorthand', type: 'value', transitive: true, - matcher: token => token.type === 'border', - transformer: token => transformBorderForCSS(token.value), + matcher: token => { + return (token.$type ?? token.type) === 'border'; + }, + transformer: token => transformBorderForCSS(token.$value ?? token.value), }); sd.registerTransform({ name: 'ts/shadow/css/shorthand', type: 'value', transitive: true, - matcher: token => typeof token.type === 'string' && ['boxShadow'].includes(token.type), - transformer: token => - Array.isArray(token.value) - ? token.value.map(single => transformShadowForCSS(single)).join(', ') - : transformShadowForCSS(token.value), + matcher: token => { + const type = token.$type ?? token.type; + return typeof type === 'string' && ['boxShadow'].includes(type); + }, + transformer: token => { + const val = token.$value ?? token.value; + return Array.isArray(val) + ? val.map(single => transformShadowForCSS(single)).join(', ') + : transformShadowForCSS(val); + }, }); sd.registerTransform({ name: 'ts/color/css/hexrgba', type: 'value', transitive: true, - matcher: token => typeof token.value === 'string' && token.type === 'color', - transformer: token => transformHEXRGBaForCSS(token.value), + matcher: token => (token.$type ?? token.type) === 'color', + transformer: token => transformHEXRGBaForCSS(token.$value ?? token.value), }); sd.registerTransform({ @@ -187,7 +199,9 @@ export async function registerTransforms( type: 'value', transitive: true, matcher: token => - token.type === 'color' && token.$extensions && token.$extensions['studio.tokens']?.modify, + (token.$type ?? token.type) === 'color' && + token.$extensions && + token.$extensions['studio.tokens']?.modify, transformer: token => transformColorModifiers(token, transformOpts?.['ts/color/modifiers']), }); diff --git a/test/integration/tokens/w3c-spec-compliance.tokens.json b/test/integration/tokens/w3c-spec-compliance.tokens.json new file mode 100644 index 0000000..0d8bcdb --- /dev/null +++ b/test/integration/tokens/w3c-spec-compliance.tokens.json @@ -0,0 +1,174 @@ +{ + "dimension": { + "$type": "dimension", + "scale": { + "$value": "2", + "$type": "math" + }, + "xs": { + "$value": "4px" + }, + "sm": { + "$value": "{dimension.xs} * {dimension.scale}" + }, + "md": { + "$value": "{dimension.sm} * {dimension.scale}" + }, + "lg": { + "$value": "{dimension.md} * {dimension.scale}" + }, + "xl": { + "$value": "{dimension.lg} * {dimension.scale}" + } + }, + "opacity": { + "$value": "25%", + "$type": "opacity" + }, + "spacing": { + "$type": "spacing", + "sm": { + "$value": "{dimension.sm}" + }, + "xl": { + "$value": "{dimension.xl}" + }, + "multi-value": { + "$value": "{dimension.sm} {dimension.xl}", + "$description": "You can have multiple values in a single spacing token. Read more on these: https://docs.tokens.studio/available-tokens/spacing-tokens#multi-$value-spacing-tokens" + } + }, + "colors": { + "$type": "color", + "black": { + "$value": "#000000" + }, + "white": { + "$value": "#ffffff" + }, + "blue": { + "$value": "#0000FF" + }, + "blue-alpha": { + "$value": "rgba({colors.blue}, 50%)" + }, + "red": { + "400": { + "$value": "{colors.red.500}", + "$extensions": { + "studio.tokens": { + "modify": { + "type": "lighten", + "value": "0.1", + "space": "srgb" + } + } + } + }, + "500": { + "$value": "#f56565" + }, + "600": { + "$value": "{colors.red.500}", + "$extensions": { + "studio.tokens": { + "modify": { + "type": "darken", + "value": "0.1", + "space": "srgb" + } + } + } + } + }, + "gradient": { + "$value": "linear-gradient(180deg, {colors.black} 0%, rgba({colors.black}, 0.00) 45%)" + } + }, + "lineHeights": { + "$type": "lineHeights", + "heading": { + "$value": "110%" + }, + "body": { + "$value": 1.4 + } + }, + "letterSpacing": { + "$type": "letterSpacing", + "default": { + "$value": 0 + }, + "increased": { + "$value": "150%" + }, + "decreased": { + "$value": "-5%" + } + }, + "fontWeights": { + "$type": "fontWeights", + "headingRegular": { + "$value": "600" + }, + "headingBold": { + "$value": 700 + }, + "bodyRegular": { + "$value": "Regular" + } + }, + "fontSizes": { + "$type": "fontSizes", + "h6": { + "$value": "{fontSizes.body} * 1" + }, + "body": { + "$value": "16" + } + }, + "heading-6": { + "$value": { + "fontSize": "{fontSizes.h6}", + "fontWeight": "700", + "fontFamily": "Arial Black, Suisse Int'l, sans-serif", + "lineHeight": "1" + }, + "$type": "typography" + }, + "shadow-blur": { + "$value": "10", + "$type": "sizing" + }, + "shadow": { + "$value": { + "x": "0", + "y": "4", + "blur": "{shadow-blur}", + "spread": "0", + "color": "rgba(0,0,0,0.4)", + "type": "innerShadow" + }, + "$type": "boxShadow" + }, + "border-width": { + "$value": "5", + "$type": "sizing" + }, + "border": { + "$value": { + "style": "solid", + "width": "{border-width}", + "color": "#000000" + }, + "$type": "border" + }, + "color": { + "$value": "#FF00FF", + "$type": "color" + }, + "usesColor": { + "$value": "rgba( {color}, 1)", + "$type": "color" + } +} diff --git a/test/integration/w3c-spec-compliance.test.ts b/test/integration/w3c-spec-compliance.test.ts new file mode 100644 index 0000000..b37c2a3 --- /dev/null +++ b/test/integration/w3c-spec-compliance.test.ts @@ -0,0 +1,81 @@ +import type StyleDictionary from 'style-dictionary'; +import { expect } from '@esm-bundle/chai'; +import { promises } from 'node:fs'; +import path from 'node:path'; +import { cleanup, init } from './utils.js'; + +const outputDir = 'test/integration/tokens/'; +const outputFileName = 'vars.css'; +const outputFilePath = path.resolve(outputDir, outputFileName); + +const cfg = { + source: ['test/integration/tokens/w3c-spec-compliance.tokens.json'], + platforms: { + css: { + transformGroup: 'tokens-studio', + prefix: 'sd', + buildPath: outputDir, + files: [ + { + destination: outputFileName, + format: 'css/variables', + }, + ], + }, + }, +}; + +let dict: StyleDictionary | undefined; + +describe('w3c spec compliance smoke test', () => { + beforeEach(async () => { + cleanup(dict); + dict = await init(cfg, { 'ts/color/modifiers': { format: 'hex' } }); + }); + + afterEach(async () => { + await cleanup(dict); + }); + + // https://design-tokens.github.io/community-group/format/ + it('supports W3C DTCG draft spec', async () => { + const file = await promises.readFile(outputFilePath, 'utf-8'); + expect(file).to.include(`:root { + --sdDimensionScale: 2; + --sdDimensionXs: 4px; + --sdDimensionSm: 8px; + --sdDimensionMd: 16px; + --sdDimensionLg: 32px; + --sdDimensionXl: 64px; + --sdOpacity: 0.25; + --sdSpacingSm: 8px; + --sdSpacingXl: 64px; + --sdSpacingMultiValue: 8px 64px; /* You can have multiple values in a single spacing token. Read more on these: https://docs.tokens.studio/available-tokens/spacing-tokens#multi-$value-spacing-tokens */ + --sdColorsBlack: #000000; + --sdColorsWhite: #ffffff; + --sdColorsBlue: #0000FF; + --sdColorsBlueAlpha: rgba(0, 0, 255, 50%); + --sdColorsRed400: #f67474; + --sdColorsRed500: #f56565; + --sdColorsRed600: #dd5b5b; + --sdColorsGradient: linear-gradient(180deg, #000000 0%, rgba(0, 0, 0, 0.00) 45%); + --sdLineHeightsHeading: 1.1; + --sdLineHeightsBody: 1.4; + --sdLetterSpacingDefault: 0; + --sdLetterSpacingIncreased: 1.5em; + --sdLetterSpacingDecreased: -0.05em; + --sdFontWeightsHeadingRegular: 600; + --sdFontWeightsHeadingBold: 700; + --sdFontWeightsBodyRegular: 400; + --sdFontSizesH6: 16px; + --sdFontSizesBody: 16px; + --sdHeading6: 700 16px/1 'Arial Black', 'Suisse Int\\'l', sans-serif; + --sdShadowBlur: 10px; + --sdShadow: inset 0 4px 10px 0 rgba(0,0,0,0.4); + --sdBorderWidth: 5px; + --sdBorder: 5px solid #000000; + --sdColor: #FF00FF; + --sdUsesColor: rgba(255, 0, 255, 1); +}`); + }); +}); diff --git a/test/spec/parsers/add-font-styles.spec.ts b/test/spec/parsers/add-font-styles.spec.ts index 24470b3..622d883 100644 --- a/test/spec/parsers/add-font-styles.spec.ts +++ b/test/spec/parsers/add-font-styles.spec.ts @@ -97,7 +97,7 @@ describe('add font style', () => { expect(addFontStyles(tokensInput as DeepKeyTokenMap)).to.eql(tokensOutput); }); - it(`should ignore broken fontWeight reference`, () => { + it(`throw when encountering a broken fontWeight reference`, () => { const inputTokens = { usesFwRef: { value: { @@ -107,7 +107,18 @@ describe('add font style', () => { }, }; - expect(addFontStyles(inputTokens as DeepKeyTokenMap)).to.eql(inputTokens); + let error; + try { + addFontStyles(inputTokens as DeepKeyTokenMap); + } catch (e) { + if (e instanceof Error) { + error = e.message; + } + } + + expect(error).to.equal( + "Reference doesn't exist: tries to reference fwRef, which is not defined.", + ); }); it(`allows always adding a default fontStyle`, () => { diff --git a/test/spec/parsers/expand.spec.ts b/test/spec/parsers/expand.spec.ts index b31c80a..2fc0c32 100644 --- a/test/spec/parsers/expand.spec.ts +++ b/test/spec/parsers/expand.spec.ts @@ -655,8 +655,9 @@ describe('expand', () => { ).to.eql({ typography: tokensInput.typography }); }); - it(`should handle when a token reference in a composite cannot be resolved`, () => { - expect( + it(`should throw when token reference in a composite cannot be resolved`, () => { + let error; + try { expandComposites( { ref: { @@ -670,13 +671,16 @@ describe('expand', () => { }, }, 'foo/bar.json', - ), - ).to.eql({ - ref: { - value: '{typography.foo}', - type: 'typography', - }, - }); + ); + } catch (e) { + if (e instanceof Error) { + error = e.message; + } + } + + expect(error).to.equal( + "Reference doesn't exist: tries to reference typography.foo, which is not defined.", + ); }); it('should not trip up when the recursed token contains a primitive value', () => {