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__/utils/convertToDTCG.test.js b/__tests__/utils/convertToDTCG.test.js new file mode 100644 index 000000000..2d54eed45 --- /dev/null +++ b/__tests__/utils/convertToDTCG.test.js @@ -0,0 +1,401 @@ +/* + * 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 { convertToDTCG } from '../../lib/utils/convertToDTCG.js'; + +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'); + }); + }); +}); 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..7f802eaab --- /dev/null +++ b/lib/utils/convertToDTCG.js @@ -0,0 +1,184 @@ +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 + */ +async function resolveZIPEntryData(entry) { + let data; + if (entry.getData) { + data = await entry.getData(new TextWriter('utf-8')); + } + return [entry.filename, data]; +} + +/** + * @param {Blob|string} blobOrPath + */ +async function blobify(blobOrPath) { + if (typeof blobOrPath === 'string') { + const buf = await fs.promises.readFile(blobOrPath); + return new Blob([buf]); + } + 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); + validateBlobType(jsonBlob, 'json', typeof blobOrPath === 'string' ? blobOrPath : undefined); + + const reader = new FileReader(); // no arguments + reader.readAsText(jsonBlob); + const fileContent = await new Promise((resolve) => { + reader.addEventListener('load', () => { + resolve(reader.result); + }); + }); + 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); + validateBlobType(zipBlob, 'zip', typeof blobOrPath === 'string' ? blobOrPath : undefined); + + 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]) + ); + + const convertedZip = Object.fromEntries( + zipEntriesWithData.map(([fileName, data]) => [ + fileName, + JSON.stringify(convertToDTCG(JSON.parse(data), opts), null, 2), + ]), + ); + + const zipWriter = new ZipWriter(new BlobWriter('application/zip')); + await Promise.all( + Object.entries(convertedZip).map(([key, value]) => zipWriter.add(key, new TextReader(value))), + ); + + // Close zip and return Blob + return zipWriter.close(); +} 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; }