From 7418c9770c817581802c4035aabf2bd420cd2f95 Mon Sep 17 00:00:00 2001 From: Joren Broekema Date: Thu, 23 May 2024 13:35:56 +0200 Subject: [PATCH] feat: add convert to DTCG spec utilities, file converter docs site (#1200) --- .changeset/rare-toys-peel.md | 11 + __tests__/__setup.js | 41 +- __tests__/__tokens/tokens.zip | Bin 0 -> 507 bytes __tests__/utils/convertToDTCG.test.js | 521 ++++++++++++++++++ docs/src/components/sd-dtcg-convert.ts | 65 +++ docs/src/components/sd-playground.ts | 8 +- docs/src/content/docs/index.mdx | 5 + docs/src/content/docs/info/DTCG.mdx | 35 ++ docs/src/content/docs/info/architecture.md | 2 + .../content/docs/info/package_structure.mdx | 2 + docs/src/content/docs/reference/Utils/DTCG.md | 55 ++ docs/src/setup.ts | 25 +- docs/src/utils/downloadZIP.ts | 23 - lib/fs.js | 2 +- lib/utils/convertToDTCG.js | 196 +++++++ lib/utils/downloadFile.js | 62 +++ lib/utils/index.js | 4 + types/DesignToken.d.ts | 1 + web-test-runner.config.mjs | 54 +- 19 files changed, 1048 insertions(+), 64 deletions(-) create mode 100644 .changeset/rare-toys-peel.md create mode 100644 __tests__/__tokens/tokens.zip create mode 100644 __tests__/utils/convertToDTCG.test.js create mode 100644 docs/src/components/sd-dtcg-convert.ts create mode 100644 docs/src/content/docs/info/DTCG.mdx delete mode 100644 docs/src/utils/downloadZIP.ts create mode 100644 lib/utils/convertToDTCG.js create mode 100644 lib/utils/downloadFile.js diff --git a/.changeset/rare-toys-peel.md b/.changeset/rare-toys-peel.md new file mode 100644 index 000000000..4c10089d0 --- /dev/null +++ b/.changeset/rare-toys-peel.md @@ -0,0 +1,11 @@ +--- +'style-dictionary': minor +--- + +Add a couple of utilities for converting a regular Style Dictionary tokens object/file(s) to DTCG formatted tokens: + +- `convertToDTCG` +- `convertJSONToDTCG` +- `convertZIPToDTCG` + +[Documentation of these utilities](https://v4.styledictionary.com/reference/utils/dtcg/) \ No newline at end of file diff --git a/__tests__/__setup.js b/__tests__/__setup.js index e99a4ff33..570a1a79c 100644 --- a/__tests__/__setup.js +++ b/__tests__/__setup.js @@ -4,6 +4,7 @@ import { dirname } from 'path-unified'; import { fs } from 'style-dictionary/fs'; import { chaiWtrSnapshot } from '../snapshot-plugin/chai-wtr-snapshot.js'; import { fixDate } from './__helpers.js'; +import { writeZIP } from '../lib/utils/convertToDTCG.js'; /** * We have a bunch of files that we use a mock data for our tests @@ -17,7 +18,19 @@ import { fixDate } from './__helpers.js'; fixDate(); -function ensureDirectoryExistence(filePath) { +let hasInitializedResolve; +export const hasInitialized = new Promise((resolve) => { + hasInitializedResolve = resolve; +}); +// in case of Node env, we can resolve it immediately since we don't do this setup stuff +if (typeof window !== 'object') { + hasInitializedResolve(); +} + +/** + * @param {string} filePath + */ +function ensureDirectoryExists(filePath) { const dir = dirname(filePath); if (fs.existsSync(dir)) { return true; @@ -25,16 +38,26 @@ function ensureDirectoryExistence(filePath) { fs.mkdirSync(dir, { recursive: true }); } -function mirrorFile(file, contents) { - ensureDirectoryExistence(file); - fs.writeFileSync(file, contents, 'utf-8'); +/** + * @param {string} file + * @param {string | Record} contents + */ +async function mirrorFile(file, contents) { + ensureDirectoryExists(file); + // zip files cannot just be written to FS using utf-8 encoding.. + if (file.endsWith('.zip')) { + const zipResult = await writeZIP(contents); + contents = new Uint8Array(await zipResult.arrayBuffer()); + } + await fs.promises.writeFile(file, contents); } -export function setup(filesToMirror) { +/** + * @param {[string, string | Record][]} filesToMirror + */ +export async function setup(filesToMirror) { use(chaiAsPromised); use(chaiWtrSnapshot); - - filesToMirror.forEach(([file, contents]) => { - mirrorFile(file, contents); - }); + await Promise.all(filesToMirror.map(([file, contents]) => mirrorFile(file, contents))); + hasInitializedResolve(); } diff --git a/__tests__/__tokens/tokens.zip b/__tests__/__tokens/tokens.zip new file mode 100644 index 0000000000000000000000000000000000000000..0b78856732954112f14576c3aba4fb9fc6c6d9a0 GIT binary patch literal 507 zcmWIWW@Zs#U|`^2xDvBH!fVb;*@-~jJSGMPejuHepH~uJoLQAxtd~`spSSv1P(Xmz zxiwy)n}SxbZED=J<QdBp5`N~qY&QECbgFSpi|5& zowSK>PIBF8la!v26%mwVxQ<~p+fpst31PM#CRc@0O~iZFJYDsai6H>)`b9w3EAabY zX#u+00_b{PAYG7{l9HL1j>GLuN2W|Uv!`SSL%<5Hts3&3{34p(XEa$~8eVYhm>MG1 z&@v^+s5tm@m+|M%Qx+6$HE{J(omn~c25*p2n9|u5kM10qBO{YGb5cfFP*R~5-(ntK z4-;+mjT*Ac6PH-6VVch0>rooU>bJP3)1-^r?OM^*B1WKd8JR>Fa0fTg%?ykT3Lpvy k&;lP>GdACXv_LfP1F}&39^lQ&29jn1!U`ar%Lw8D0Ehvlt^fc4 literal 0 HcmV?d00001 diff --git a/__tests__/utils/convertToDTCG.test.js b/__tests__/utils/convertToDTCG.test.js new file mode 100644 index 000000000..60d4ad398 --- /dev/null +++ b/__tests__/utils/convertToDTCG.test.js @@ -0,0 +1,521 @@ +/* + * Copyright 2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with + * the License. A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +import { expect } from 'chai'; +import { fs } from 'style-dictionary/fs'; +import { hasInitialized } from '../__setup.js'; +import { + convertToDTCG, + convertJSONToDTCG, + convertZIPToDTCG, + readZIP, +} from '../../lib/utils/convertToDTCG.js'; + +const paddingsOutput = { + $type: 'dimension', + size: { + padding: { + zero: { + $value: 0, + }, + tiny: { + $value: '3', + }, + small: { + $value: '5', + }, + base: { + $value: '10', + }, + large: { + $value: '15', + }, + xl: { + $value: '20', + }, + xxl: { + $value: '30', + }, + }, + }, +}; + +const fontSizesOutput = { + $type: 'fontSize', + size: { + font: { + tiny: { + $value: '11px', + }, + small: { + $value: '13px', + }, + medium: { + $value: '15px', + }, + large: { + $value: '17px', + }, + xl: { + $value: '21px', + }, + xxl: { + $value: '25px', + }, + xxxl: { + $value: '30px', + }, + base: { + $value: '{size.font.medium}', + }, + }, + }, +}; + +describe('utils', () => { + describe('convertToDTCG', () => { + it('should swap value, type and description to use $ property prefix', () => { + const result = convertToDTCG( + { + colors: { + red: { + value: '#ff0000', + type: 'color', + description: 'A red color', + }, + green: { + value: '#00ff00', + type: 'color', + description: 'A green color', + }, + blue: { + value: '#0000ff', + type: 'color', + description: 'A blue color', + }, + }, + }, + { applyTypesToGroup: false }, + ); + expect(result).to.eql({ + colors: { + red: { + $value: '#ff0000', + $type: 'color', + $description: 'A red color', + }, + green: { + $value: '#00ff00', + $type: 'color', + $description: 'A green color', + }, + blue: { + $value: '#0000ff', + $type: 'color', + $description: 'A blue color', + }, + }, + }); + }); + + it('should apply type to the upper most common ancestor', () => { + const result = convertToDTCG({ + colors: { + red: { + value: '#ff0000', + type: 'color', + }, + green: { + value: '#00ff00', + type: 'color', + }, + blue: { + value: '#0000ff', + type: 'color', + }, + }, + dimensions: { + sm: { + value: '2px', + type: 'dimension', + }, + md: { + value: '8px', + type: 'dimension', + }, + lg: { + value: '16px', + type: 'dimension', + }, + }, + }); + expect(result).to.eql({ + colors: { + $type: 'color', + red: { + $value: '#ff0000', + }, + green: { + $value: '#00ff00', + }, + blue: { + $value: '#0000ff', + }, + }, + dimensions: { + $type: 'dimension', + sm: { + $value: '2px', + }, + md: { + $value: '8px', + }, + lg: { + $value: '16px', + }, + }, + }); + }); + + it('should keep types as is when not shared with all siblings', () => { + const result = convertToDTCG({ + colors: { + red: { + value: '#ff0000', + type: 'color', + }, + green: { + value: '#00ff00', + type: 'color', + }, + blue: { + value: '#0000ff', + type: 'different-type', + }, + }, + dimensions: { + sm: { + value: '2px', + type: 'dimension', + }, + md: { + value: '8px', + type: 'dimension', + }, + lg: { + value: '16px', + type: 'dimension', + }, + }, + }); + expect(result).to.eql({ + colors: { + red: { + $value: '#ff0000', + $type: 'color', + }, + green: { + $value: '#00ff00', + $type: 'color', + }, + blue: { + $value: '#0000ff', + $type: 'different-type', + }, + }, + dimensions: { + $type: 'dimension', + sm: { + $value: '2px', + }, + md: { + $value: '8px', + }, + lg: { + $value: '16px', + }, + }, + }); + }); + + it('should work with any number of nestings', () => { + const result = convertToDTCG({ + colors: { + red: { + value: '#ff0000', + type: 'color', + }, + grey: { + 100: { + value: '#aaaaaa', + type: 'color', + }, + 200: { + deeper: { + value: '#cccccc', + type: 'color', + }, + }, + 400: { + value: '#dddddd', + type: 'color', + }, + 500: { + foo: { + bar: { + qux: { + value: '#eeeeee', + type: 'color', + }, + }, + }, + }, + }, + green: { + value: '#00ff00', + type: 'color', + }, + blue: { + value: '#0000ff', + type: 'color', + }, + }, + }); + expect(result).to.eql({ + $type: 'color', + colors: { + red: { + $value: '#ff0000', + }, + grey: { + 100: { + $value: '#aaaaaa', + }, + 200: { + deeper: { + $value: '#cccccc', + }, + }, + 400: { + $value: '#dddddd', + }, + 500: { + foo: { + bar: { + qux: { + $value: '#eeeeee', + }, + }, + }, + }, + }, + green: { + $value: '#00ff00', + }, + blue: { + $value: '#0000ff', + }, + }, + }); + }); + + it('should handle scenarios where not all types are the same', () => { + const result = convertToDTCG({ + colors: { + red: { + value: '#ff0000', + type: 'color', + }, + grey: { + 100: { + value: '#aaaaaa', + type: 'color', + }, + 200: { + deeper: { + value: '#cccccc', + type: 'color', + }, + }, + 400: { + value: '#dddddd', + type: 'color', + }, + 500: { + foo: { + bar: { + qux: { + value: '#eeeeee', + type: 'different-type', + }, + }, + }, + }, + }, + green: { + value: '#00ff00', + type: 'color', + }, + blue: { + value: '#0000ff', + type: 'color', + }, + }, + }); + expect(result).to.eql({ + colors: { + red: { + $value: '#ff0000', + $type: 'color', + }, + grey: { + 100: { + $value: '#aaaaaa', + $type: 'color', + }, + 200: { + $type: 'color', + deeper: { + $value: '#cccccc', + }, + }, + 400: { + $value: '#dddddd', + $type: 'color', + }, + 500: { + $type: 'different-type', + foo: { + bar: { + qux: { + $value: '#eeeeee', + }, + }, + }, + }, + }, + green: { + $value: '#00ff00', + $type: 'color', + }, + blue: { + $value: '#0000ff', + $type: 'color', + }, + }, + }); + }); + + it('should keep input property key order intact', () => { + const input = { + red: { + type: 'color', + value: '#ff0000', + }, + }; + + const inputKeys = Object.keys(input.red); + const convertedKeys = Object.keys(convertToDTCG(input, { applyTypesToGroup: false }).red); + expect(inputKeys.every((prop, index) => convertedKeys[index] === `$${prop}`)).to.be.true; + }); + + it('should order $type on group as first property', () => { + const input = { + colors: { + red: { + value: '#ff0000', + type: 'color', + }, + green: { + value: '#00ff00', + type: 'color', + }, + blue: { + value: '#0000ff', + type: 'color', + }, + }, + dimensions: { + sm: { + value: '2px', + type: 'dimension', + }, + md: { + value: '8px', + type: 'dimension', + }, + lg: { + value: '16px', + type: 'dimension', + }, + }, + }; + const output = convertToDTCG(input); + const colorsKeys = Object.keys(output.colors); + const dimensionsKeys = Object.keys(output.dimensions); + expect(colorsKeys[0]).to.equal('$type'); + expect(dimensionsKeys[0]).to.equal('$type'); + }); + }); + + describe('convertJSONToDTCG', async () => { + const buf = await fs.promises.readFile('__tests__/__tokens/paddings.json'); + const jsonBlob = new Blob([buf], { type: 'application/json' }); + + it('should allow passing a JSON blob, and converting it to DTCG JSON blob', async () => { + const outputFile = await convertJSONToDTCG(jsonBlob); + const output = JSON.parse(await outputFile.text()); + expect(output).to.eql(paddingsOutput); + }); + + it('should allow passing a JSON filepath, and converting it to DTCG JSON blob', async () => { + const outputFile = await convertJSONToDTCG('__tests__/__tokens/paddings.json'); + const output = JSON.parse(await outputFile.text()); + expect(output).to.eql(paddingsOutput); + }); + + it('should throw when file is not JSON', async () => { + // zip files mirrored to memfs for browser testing takes a while to initialize apparently... + await hasInitialized; + + const buf = await fs.promises.readFile('__tests__/__tokens/tokens.zip'); + const zipBlob = new Blob([buf], { type: 'application/zip' }); + await expect(convertJSONToDTCG(zipBlob)).to.eventually.rejectedWith( + 'File (Blob) is of type application/zip, but a json type blob was expected.', + ); + }); + }); + + describe('convertZIPToDTCG', async () => { + it('should allow passing a ZIP blob, and converting it to DTCG ZIP blob', async () => { + // zip files mirrored to memfs for browser testing takes a while to initialize apparently... + await hasInitialized; + + const buf = await fs.promises.readFile('__tests__/__tokens/tokens.zip'); + const zipBlob = new Blob([buf], { type: 'application/zip' }); + const outputZIP = await convertZIPToDTCG(zipBlob); + const zipObjectWithData = await readZIP(outputZIP); + expect(JSON.parse(zipObjectWithData['font_sizes.json'])).to.eql(fontSizesOutput); + expect(JSON.parse(zipObjectWithData['paddings.json'])).to.eql(paddingsOutput); + }); + + it('should allow passing a ZIP filepath, and converting it to DTCG ZIP blob', async () => { + // zip files mirrored to memfs for browser testing takes a while to initialize apparently... + await hasInitialized; + + const outputZIP = await convertZIPToDTCG('__tests__/__tokens/tokens.zip'); + const zipObjectWithData = await readZIP(outputZIP); + expect(JSON.parse(zipObjectWithData['font_sizes.json'])).to.eql(fontSizesOutput); + expect(JSON.parse(zipObjectWithData['paddings.json'])).to.eql(paddingsOutput); + }); + }); +}); diff --git a/docs/src/components/sd-dtcg-convert.ts b/docs/src/components/sd-dtcg-convert.ts new file mode 100644 index 000000000..ad5e78d02 --- /dev/null +++ b/docs/src/components/sd-dtcg-convert.ts @@ -0,0 +1,65 @@ +import { LitElement, css, html } from 'lit'; +import { ref, createRef } from 'lit/directives/ref.js'; +import '@shoelace-style/shoelace/dist/components/button/button.js'; +import { convertJSONToDTCG, convertZIPToDTCG } from '../../../lib/utils/convertToDTCG.js'; +import { downloadJSON, downloadZIP } from '../../../lib/utils/downloadFile.js'; + +class SdDtcgConvert extends LitElement { + fileInputRef = createRef(); + + static get styles() { + return [ + css` + :host { + display: block; + } + `, + ]; + } + + render() { + return html` + Convert tokens to DTCG + + `; + } + + triggerUpload() { + const fileInput = this.fileInputRef.value; + if (fileInput) { + fileInput.dispatchEvent(new MouseEvent('click')); + } + } + + async upload(ev: Event) { + if (ev.target instanceof HTMLInputElement) { + const file = ev.target.files?.[0]; + if (file) { + const today = new Date(Date.now()); + const filename = `dtcg-tokens_${today.getFullYear()}-${today.getMonth()}-${( + '0' + today.getDate() + ).slice(-2)}`; + + if (file.type.includes('zip')) { + const zipBlob = await convertZIPToDTCG(file); + await downloadZIP(zipBlob, `${filename}.zip`); + } else if (file.type.includes('json')) { + const jsonBlob = await convertJSONToDTCG(file); + await downloadJSON(jsonBlob, `${filename}.json`); + } else { + throw new Error('Only ZIP and JSON type uploads are supported.'); + } + } + } + } +} + +customElements.define('sd-dtcg-convert', SdDtcgConvert); diff --git a/docs/src/components/sd-playground.ts b/docs/src/components/sd-playground.ts index afeef8d14..6167d5208 100644 --- a/docs/src/components/sd-playground.ts +++ b/docs/src/components/sd-playground.ts @@ -11,8 +11,8 @@ import '@shoelace-style/shoelace/dist/components/option/option.js'; import { bundle } from '../utils/rollup-bundle.ts'; import { changeLang, init, monaco } from '../monaco/monaco.ts'; import { analyzeDependencies } from '../utils/analyzeDependencies.ts'; +import { downloadZIP } from '../../../lib/utils/downloadFile.js'; import type SlRadioGroup from '@shoelace-style/shoelace/dist/components/radio-group/radio-group.js'; -import { downloadZIP } from '../utils/downloadZIP.ts'; const { Volume } = memfs; @@ -459,7 +459,11 @@ node build-tokens.${scriptLang} \`\`\` `; - await downloadZIP(files); + const today = new Date(Date.now()); + const filename = `sd-output_${today.getFullYear()}-${today.getMonth()}-${( + '0' + today.getDate() + ).slice(-2)}.zip`; + await downloadZIP(files, filename); } } diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index 41a6541a1..13b29c7bf 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -31,3 +31,8 @@ Below is a showcase of how a set of DTCG tokens would be exported to CSS:
+ +Upload your JSON or ZIP of tokens and convert them to DTCG format: + + +[Read more about DTCG](/info/dtcg/) diff --git a/docs/src/content/docs/info/DTCG.mdx b/docs/src/content/docs/info/DTCG.mdx new file mode 100644 index 000000000..d135b7754 --- /dev/null +++ b/docs/src/content/docs/info/DTCG.mdx @@ -0,0 +1,35 @@ +--- +title: Design Token Community Group +sidebar: + order: 4 +--- + +import tokens from '/public/demo-tokens.json'; + +There is a [W3C Design Token Community Group](https://www.w3.org/community/design-tokens/), whose goal it is to "provide standards upon which products and design tools can rely for sharing stylistic pieces of a design system at scale". + +What that boils down to right now is a [draft specification for how Design Tokens ought to be formatted](https://design-tokens.github.io/community-group/format/), for cross-tool and cross-platform interoperability. +Since Style Dictionary v4, we have first-class support for this format. + +## Convert your Tokens to DTCG + +We also export a tool that allows you to convert your design tokens in the old format to the DTCG format: + +
+ + +What it does: + +- Turn `value`, `type` and `description` design token property keys into `$value`, `$type` and `$description` respectively. +- Move the `$type` properties from the design tokens onto the uppermost common ancestor token group + +What it does not do (atm, feel free to raise suggestions): + +- Refactor type values commonly used to the DTCG types, e.g. size -> dimension. + +## Live demo + + + +
+
diff --git a/docs/src/content/docs/info/architecture.md b/docs/src/content/docs/info/architecture.md index 614b00491..09c8d63ea 100644 --- a/docs/src/content/docs/info/architecture.md +++ b/docs/src/content/docs/info/architecture.md @@ -1,5 +1,7 @@ --- title: Architecture +sidebar: + order: 2 --- This is how Style Dictionary works under the hood. diff --git a/docs/src/content/docs/info/package_structure.mdx b/docs/src/content/docs/info/package_structure.mdx index e3be97aa4..aaa9e2e8c 100644 --- a/docs/src/content/docs/info/package_structure.mdx +++ b/docs/src/content/docs/info/package_structure.mdx @@ -1,5 +1,7 @@ --- title: Package Structure +sidebar: + order: 3 --- import { FileTree } from '@astrojs/starlight/components'; diff --git a/docs/src/content/docs/reference/Utils/DTCG.md b/docs/src/content/docs/reference/Utils/DTCG.md index abf360b6e..fa9edf280 100644 --- a/docs/src/content/docs/reference/Utils/DTCG.md +++ b/docs/src/content/docs/reference/Utils/DTCG.md @@ -4,6 +4,61 @@ title: Design Token Community Group These utilities have to do with the [Design Token Community Group Draft spec](https://design-tokens.github.io/community-group/format/). +For converting a ZIP or JSON tokens file to DTCG format, use the button below: + + + +This button is a tiny Web Component using file input as a wrapper around the convert DTCG utils listed below. + +## convertToDTCG + +This function converts your dictionary object to DTCG formatted dictionary, meaning that your `value`, `type` and `description` properties are converted to be prefixed with `$`, and the `$type` property is moved from the token level to the topmost common ancestor token group. + +```js +import { convertToDTCG } from 'style-dictionary/utils'; + +const outputDictionary = convertToDTCG(dictionary, { applyTypesToGroup: false }); +``` + +`applyTypesToGroup` is `true` by default, but can be turned off by setting to `false`. + +`dictionary` is the result of doing for example JSON.parse() on your tokens JSON string so it becomes a JavaScript object type. + +:::danger +Do not use this hook with `applyTypesToGroup` set to `true` (default) inside of a Preprocessor hook!\ +Style Dictionary relies on `typeDtcgDelegate` utility being ran right before user-defined preprocessors delegating all of the token group types to the token level, +because this makes it easier and more performant to grab the token type from the token itself, without needing to know about and traverse its ancestor tree to find it.\ +`typeDtcgDelegate` is doing the opposite action of `convertToDTCG`, delegating the `$type` down rather than moving and condensing the `$type` up. +::: + +### convertJSONToDTCG + +This function converts your JSON (either a JSON Blob or string that is an absolute filepath to your JSON file) to a JSON Blob which has been converted to DTCG format, see `convertToDTCG` function above. + +```js +import { convertToDTCG } from 'style-dictionary/utils'; + +const outputBlob = convertJSONToDTCG(JSONBlobOrFilepath, { applyTypesToGroup: false }); +``` + +`applyTypesToGroup` option can be passed, same as for `convertToDTCG` function. + +Note that if you use a filepath instead of Blob as input, this filepath should preferably be an absolute path. +You can use a utility like [`node:path`](https://nodejs.org/api/path.html) or a browser-compatible copy like [`path-unified`](https://www.npmjs.com/package/path-unified) +to resolve path segments or relative paths to absolute ones. + +### convertZIPToDTCG + +This function converts your ZIP (either a ZIP Blob or string that is an absolute filepath to your ZIP file) to a ZIP Blob which has been converted to DTCG format, see `convertToDTCG` function above. + +Basically the same as `convertJSONToDTCG` but for a ZIP file of JSON tokens. + +```js +import { convertZIPToDTCG } from 'style-dictionary/utils'; + +const outputBlob = convertZIPToDTCG(ZIPBlobOrFilepath, { applyTypesToGroup: false }); +``` + ## typeDtcgDelegate This function processes your ["Design Token Community Group Draft spec"-compliant](https://design-tokens.github.io/community-group/format/) dictionary of tokens, and ensures that `$type` inheritance is applied. diff --git a/docs/src/setup.ts b/docs/src/setup.ts index 139165590..701abb7e4 100644 --- a/docs/src/setup.ts +++ b/docs/src/setup.ts @@ -55,16 +55,19 @@ themeObserver.observe(document.documentElement, { attributeFilter: [themeAttr], }); -// Conditionally load the sd-playground Web Component definition if we find an instance of it. -const firstPlaygroundEl = document.querySelector('sd-playground'); +const CEs = ['sd-playground', 'sd-dtcg-convert']; -if (firstPlaygroundEl) { - const observer = new IntersectionObserver((entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - import('./components/sd-playground.ts'); - } +CEs.forEach((ce) => { + // Conditionally load the sd-playground Web Component definition if we find an instance of it. + const firstEl = document.querySelector(ce); + if (firstEl) { + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + import(`./components/${ce}.ts`); + } + }); }); - }); - observer.observe(firstPlaygroundEl); -} + observer.observe(firstEl); + } +}); diff --git a/docs/src/utils/downloadZIP.ts b/docs/src/utils/downloadZIP.ts deleted file mode 100644 index 2c18146c6..000000000 --- a/docs/src/utils/downloadZIP.ts +++ /dev/null @@ -1,23 +0,0 @@ -import * as zip from '@zip.js/zip.js'; - -export async function downloadZIP(files: Record) { - const zipWriter = new zip.ZipWriter(new zip.BlobWriter('application/zip')); - - await Promise.all( - Object.entries(files).map(([key, value]) => zipWriter.add(key, new zip.TextReader(value))), - ); - - // Close zip and make into URL - const dataURI = await zipWriter.close(); - const url = URL.createObjectURL(dataURI); - - // Auto-download the ZIP through anchor - const anchor = document.createElement('a'); - anchor.href = url; - const today = new Date(); - anchor.download = `sd-output_${today.getFullYear()}-${today.getMonth()}-${( - '0' + today.getDate() - ).slice(-2)}.zip`; - anchor.click(); - URL.revokeObjectURL(url); -} diff --git a/lib/fs.js b/lib/fs.js index 3aaf9e4b0..057f1bb79 100644 --- a/lib/fs.js +++ b/lib/fs.js @@ -7,7 +7,7 @@ import memfs from '@bundled-es-modules/memfs'; /** * Allow to be overridden by setter, set default to memfs for browser env, node:fs for node env */ -export let fs = /** @type {Volume} */ memfs; +export let fs = /** @type {Volume} */ (memfs); /** * since ES modules exports are read-only, use a setter diff --git a/lib/utils/convertToDTCG.js b/lib/utils/convertToDTCG.js new file mode 100644 index 000000000..51f00863c --- /dev/null +++ b/lib/utils/convertToDTCG.js @@ -0,0 +1,196 @@ +import isPlainObject from 'is-plain-obj'; +import { + BlobReader, + TextWriter, + ZipReader, + ZipWriter, + BlobWriter, + TextReader, +} from '@zip.js/zip.js'; +import { fs } from 'style-dictionary/fs'; + +/** + * @typedef {import('@zip.js/zip.js').Entry} Entry + * @typedef {import('../../types/DesignToken.d.ts').DesignToken} DesignToken + * @typedef {import('../../types/DesignToken.d.ts').DesignTokens} DesignTokens + */ + +/** + * @param {DesignTokens} slice + * @param {{applyTypesToGroup?: boolean}} [opts] + */ +function recurse(slice, opts) { + // we use a Set to avoid duplicate values + /** @type {Set} */ + let types = new Set(); + + // this slice within the dictionary is a design token + if (Object.hasOwn(slice, 'value')) { + const token = /** @type {DesignToken} */ (slice); + // convert to $ prefixed properties + Object.keys(token).forEach((key) => { + switch (key) { + case 'type': + // track the encountered types for this layer + types.add(/** @type {string} */ (token[key])); + // eslint-disable-next-line no-fallthrough + case 'value': + case 'description': + token[`$${key}`] = token[key]; + delete token[key]; + // no-default + } + }); + return types; + } else { + // token group, not a token + // go through all props and call itself recursively for object-value props + Object.keys(slice).forEach((key) => { + if (isPlainObject(slice[key])) { + // call Set again to dedupe the accumulation of the two sets + types = new Set([...types, ...recurse(slice[key], opts)]); + } + }); + + // Now that we've checked every property, let's see how many types we found + // If it's only 1 type, we know we can apply the type on the ancestor group + // and remove it from the children + if (types.size === 1 && opts?.applyTypesToGroup !== false) { + const groupType = [...types][0]; + const entries = Object.entries(slice).map(([key, value]) => { + // remove the type from the child + delete value.$type; + return /** @type {[string, DesignToken|DesignTokens]} */ ([key, value]); + }); + + Object.keys(slice).forEach((key) => { + delete slice[key]; + }); + // put the type FIRST + slice.$type = groupType; + // then put the rest of the key value pairs back, now we always ordered $type first on the token group + entries.forEach(([key, value]) => { + if (key !== '$type') { + slice[key] = value; + } + }); + } + } + return types; +} + +/** + * @param {DesignTokens} dictionary + * @param {{applyTypesToGroup?: boolean}} [opts] + */ +export function convertToDTCG(dictionary, opts) { + // making a copy, so we don't mutate the original input + // this makes for more predictable API (input -> output) + const copy = structuredClone(dictionary); + recurse(copy, opts); + return copy; +} + +/** + * @param {Entry} entry + */ +export async function resolveZIPEntryData(entry) { + let data; + if (entry.getData) { + data = await entry.getData(new TextWriter('utf-8')); + } + return [entry.filename, data]; +} + +/** + * @param {Blob} zipBlob + * @returns {Promise>} + */ +export async function readZIP(zipBlob) { + const zipReader = new ZipReader(new BlobReader(zipBlob)); + const zipEntries = await zipReader.getEntries({ + filenameEncoding: 'utf-8', + }); + const zipEntriesWithData = /** @type {string[][]} */ ( + ( + await Promise.all( + zipEntries.filter((entry) => !entry.directory).map((entry) => resolveZIPEntryData(entry)), + ) + ).filter((entry) => !!entry[1]) + ); + return Object.fromEntries(zipEntriesWithData); +} + +/** + * + * @param {Record} zipEntries + */ +export async function writeZIP(zipEntries) { + const zipWriter = new ZipWriter(new BlobWriter('application/zip')); + await Promise.all( + Object.entries(zipEntries).map(([key, value]) => zipWriter.add(key, new TextReader(value))), + ); + // Close zip and return Blob + return zipWriter.close(); +} + +/** + * @param {Blob|string} blobOrPath + * @param {string} type + */ +async function blobify(blobOrPath, type) { + if (typeof blobOrPath === 'string') { + const buf = await fs.promises.readFile(blobOrPath); + return new Blob([buf], { type }); + } + return blobOrPath; +} + +/** + * @param {Blob} blob + * @param {string} type + * @param {string} [path] + */ +function validateBlobType(blob, type, path) { + if (!blob.type.includes(type)) { + throw new Error( + `File ${path ?? '(Blob)'} is of type ${blob.type}, but a ${type} type blob was expected.`, + ); + } +} + +/** + * @param {Blob|string} blobOrPath + * @param {{applyTypesToGroup?: boolean}} [opts] + */ +export async function convertJSONToDTCG(blobOrPath, opts) { + const jsonBlob = await blobify(blobOrPath, 'application/json'); + validateBlobType(jsonBlob, 'json', typeof blobOrPath === 'string' ? blobOrPath : undefined); + + const fileContent = await jsonBlob.text(); + const converted = JSON.stringify(convertToDTCG(JSON.parse(fileContent), opts), null, 2); + return new Blob([converted], { + type: 'application/json', + }); +} + +/** + * @param {Blob|string} blobOrPath + * @param {{applyTypesToGroup?: boolean}} [opts] + */ +export async function convertZIPToDTCG(blobOrPath, opts) { + const zipBlob = await blobify(blobOrPath, 'application/zip'); + validateBlobType(zipBlob, 'zip', typeof blobOrPath === 'string' ? blobOrPath : undefined); + const zipObjectWithData = await readZIP(zipBlob); + + const convertedZipObject = Object.fromEntries( + Object.entries(zipObjectWithData).map(([fileName, data]) => [ + fileName, + JSON.stringify(convertToDTCG(JSON.parse(data), opts), null, 2), + ]), + ); + + const zipBlobOut = await writeZIP(convertedZipObject); + + return zipBlobOut; +} diff --git a/lib/utils/downloadFile.js b/lib/utils/downloadFile.js new file mode 100644 index 000000000..5baebc8b0 --- /dev/null +++ b/lib/utils/downloadFile.js @@ -0,0 +1,62 @@ +import { ZipWriter, BlobWriter, TextReader } from '@zip.js/zip.js'; + +/** + * Caution: browser-only utilities + * Would be weird to support in NodeJS since end-user = developer + * so the question would be: where to store the file, if we don't know + * where the blob/files object came from to begin with + */ + +/** + * @param {Blob} blob + * @param {string} filename + */ +function downloadBlob(blob, filename) { + const url = URL.createObjectURL(blob); + + // Auto-download the ZIP through anchor + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); +} + +/** + * @param {string | Blob} stringOrBlob + * @param {string} filename + */ +export function downloadJSON(stringOrBlob, filename = 'output.json') { + /** @type {Blob} */ + let jsonBlob; + // check if it's a Blob.., instanceof is too strict e.g. Blob polyfills + if (stringOrBlob.constructor.name === 'Blob') { + jsonBlob = /** @type {Blob} */ (stringOrBlob); + } else { + jsonBlob = new Blob([stringOrBlob], { type: 'application/json' }); + } + downloadBlob(jsonBlob, filename); +} + +/** + * @param {Record | Blob} filesOrBlob + * @param {string} filename + */ +export async function downloadZIP(filesOrBlob, filename = 'output.zip') { + /** @type {Blob} */ + let zipBlob; + // check if it's a Blob.., instanceof is too strict e.g. Blob polyfills + if (filesOrBlob.constructor.name === 'Blob') { + zipBlob = /** @type {Blob} */ (filesOrBlob); + } else { + const zipWriter = new ZipWriter(new BlobWriter('application/zip')); + + await Promise.all( + Object.entries(filesOrBlob).map(([key, value]) => zipWriter.add(key, new TextReader(value))), + ); + + // Close zip and make into URL + zipBlob = await zipWriter.close(); + } + downloadBlob(zipBlob, filename); +} diff --git a/lib/utils/index.js b/lib/utils/index.js index b278a19d7..da9f0cb86 100644 --- a/lib/utils/index.js +++ b/lib/utils/index.js @@ -18,6 +18,7 @@ import { outputReferencesFilter } from './references/outputReferencesFilter.js'; import { outputReferencesTransformed } from './references/outputReferencesTransformed.js'; import flattenTokens from './flattenTokens.js'; import { typeDtcgDelegate } from './typeDtcgDelegate.js'; +import { convertToDTCG, convertJSONToDTCG, convertZIPToDTCG } from './convertToDTCG.js'; // Public style-dictionary/utils API export { @@ -28,5 +29,8 @@ export { outputReferencesTransformed, flattenTokens, typeDtcgDelegate, + convertToDTCG, + convertJSONToDTCG, + convertZIPToDTCG, }; export * from '../common/formatHelpers/index.js'; diff --git a/types/DesignToken.d.ts b/types/DesignToken.d.ts index 163b893be..4d595a7bc 100644 --- a/types/DesignToken.d.ts +++ b/types/DesignToken.d.ts @@ -29,6 +29,7 @@ export interface DesignToken { } export interface DesignTokens { + $type?: string; [key: string]: DesignTokens | DesignToken; } diff --git a/web-test-runner.config.mjs b/web-test-runner.config.mjs index f7f05b223..718f26baf 100644 --- a/web-test-runner.config.mjs +++ b/web-test-runner.config.mjs @@ -1,24 +1,42 @@ import { playwrightLauncher } from '@web/test-runner-playwright'; import { snapshotPlugin } from '@web/test-runner-commands/plugins'; import fs from 'node:fs'; -import { globSync } from '@bundled-es-modules/glob'; +import { glob } from '@bundled-es-modules/glob'; +import { readZIP } from './lib/utils/convertToDTCG.js'; -const filesToMirror = globSync( - [ - '__tests__/__assets/**/*', - '__tests__/__configs/**/*', - '__tests__/__json_files/**/*', - '__tests__/__tokens/**/*', - '__integration__/tokens/**/*', - ], - { - fs, - nodir: true, - posix: true, - }, +/** @type {string[]} */ +const globResult = ( + await glob( + [ + '__tests__/__assets/**/*', + '__tests__/__configs/**/*', + '__tests__/__json_files/**/*', + '__tests__/__tokens/**/*', + '__integration__/tokens/**/*', + ], + { + fs, + nodir: true, + posix: true, + }, + ) ) // sort because for some reason glob result is not sorted like filesystem is (alphabetically) - .sort() - .map((filePath) => [filePath, fs.readFileSync(filePath, 'utf-8')]); + .sort(); + +/** @type {[string, Record | string][]} */ +const fileEntriesToMirror = []; +for (const filePath of globResult) { + /** @type {Record | string} */ + let fileContents; + // ZIP is binary content, so serialize it to string first.. + if (filePath.endsWith('.zip')) { + const buf = await fs.promises.readFile(filePath); + fileContents = await readZIP(new Blob([buf], { type: 'application/zip' })); + } else { + fileContents = await fs.promises.readFile(filePath, 'utf-8'); + } + fileEntriesToMirror.push([filePath, fileContents]); +} export default { nodeResolve: true, @@ -41,10 +59,10 @@ export default { plugins: [snapshotPlugin()], testRunnerHtml: (testFramework) => ` -