+
+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;
}