style-dictionary
Version:
Style once, use everywhere. A build system for creating cross-platform styles.
200 lines (182 loc) • 5.82 kB
JavaScript
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<string>} */
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) => {
const prop = slice[key];
if (isPlainObject(prop)) {
// call Set again to dedupe the accumulation of the two sets
types = new Set([...types, ...recurse(prop, 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]) => {
if (isPlainObject(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<Record<string, string>>}
*/
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<string, string>} 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;
}