/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ const fs = require("node:fs"); const path = require("node:path"); const StyleDictionary = require("style-dictionary"); const { createPropertyFormatter } = StyleDictionary.formatHelpers; const figmaConfig = require("./figma-tokens-config"); const PURPOSE = { SEMANTIC: "semantic", STORYBOOK: "storybook", }; /** * @typedef {object[]} TokenCategories * @property {string} name - A name used to group tokens into a category for storybook/stylelint to reference. * @property {string[]} alternateNames - Names not matching standard token naming conventions (e.g. "width" instead of "size"). * @property {string[]} purposes - What the token category is used for, either semantic tokens used by stylelint or tokens to be demonstrated in storybook. */ const TOKEN_CATEGORIES = [ { name: "table-background", purposes: [PURPOSE.STORYBOOK], }, { name: "table-border", purposes: [PURPOSE.STORYBOOK], }, { name: "table-header", purposes: [PURPOSE.STORYBOOK], }, { name: "background-color", purposes: [PURPOSE.SEMANTIC, PURPOSE.STORYBOOK], }, { name: "text-color", alternateNames: ["link-color"], purposes: [PURPOSE.SEMANTIC, PURPOSE.STORYBOOK], }, { name: "border-color", purposes: [PURPOSE.SEMANTIC, PURPOSE.STORYBOOK], }, { name: "border-radius", purposes: [PURPOSE.SEMANTIC, PURPOSE.STORYBOOK], }, { name: "border-width", purposes: [PURPOSE.SEMANTIC, PURPOSE.STORYBOOK], }, { name: "border", purposes: [PURPOSE.SEMANTIC, PURPOSE.STORYBOOK], }, { name: "outline-color", purposes: [PURPOSE.SEMANTIC], }, { name: "outline-radius", purposes: [PURPOSE.SEMANTIC], }, { name: "outline-width", purposes: [PURPOSE.SEMANTIC], }, { name: "outline-offset", alternateNames: ["outline-inset"], purposes: [PURPOSE.SEMANTIC], }, { name: "outline", purposes: [PURPOSE.SEMANTIC, PURPOSE.STORYBOOK], }, { name: "focus-outline", purposes: [PURPOSE.SEMANTIC], }, { name: "box-shadow", purposes: [PURPOSE.SEMANTIC, PURPOSE.STORYBOOK], }, { name: "font-size", purposes: [PURPOSE.SEMANTIC, PURPOSE.STORYBOOK], }, { name: "font-weight", purposes: [PURPOSE.SEMANTIC, PURPOSE.STORYBOOK], }, { name: "icon-size", purposes: [PURPOSE.SEMANTIC, PURPOSE.STORYBOOK], }, { name: "icon-color", alternateNames: ["fill", "stroke"], purposes: [PURPOSE.SEMANTIC, PURPOSE.STORYBOOK], }, { name: "size", alternateNames: ["height", "width"], purposes: [PURPOSE.SEMANTIC, PURPOSE.STORYBOOK], }, { name: "space", alternateNames: ["padding", "margin"], purposes: [PURPOSE.SEMANTIC, PURPOSE.STORYBOOK], }, { name: "dimension", purposes: [PURPOSE.SEMANTIC, PURPOSE.STORYBOOK], }, { name: "opacity", purposes: [PURPOSE.SEMANTIC, PURPOSE.STORYBOOK], }, { name: "color", purposes: [PURPOSE.SEMANTIC, PURPOSE.STORYBOOK], }, ]; const getTokenSections = () => { const fileNames = fs.readdirSync( path.join(__dirname, "../src/tokens/components/") ); const componentSections = fileNames.reduce((components, fileName) => { const componentName = fileName.replace(".tokens.json", ""); return { ...components, [componentName]: componentName, }; }, {}); const baseSections = TOKEN_CATEGORIES.filter(category => category.purposes.includes(PURPOSE.SEMANTIC) ).reduce((sections, category) => { return { ...sections, [category.name]: category.name, }; }, {}); const allSections = { ...baseSections, ...componentSections, }; return Object.fromEntries( Object.keys(allSections) .sort() .map(key => [key, allSections[key]]) ); }; const TSHIRT_ORDER = [ "circle", "xxxsmall", "xxsmall", "xsmall", "small", "medium", "large", "xlarge", "xxlarge", "xxxlarge", ]; const STATE_ORDER = [ "base", "default", "root", "hover", "active", "focus", "disabled", ]; /** * Adds the Mozilla Public License header in one comment and * how to make changes in the generated output files via the * design-tokens.json file in another comment. Also imports * tokens-shared.css when applicable. * * @param {string} surface * Desktop surface, either "brand" or "platform". Determines * whether or not we need to import tokens-shared.css. * @returns {string} Formatted comment header string */ let customFileHeader = ({ surface, platform }) => { let licenseString = [ "/* This Source Code Form is subject to the terms of the Mozilla Public", " * License, v. 2.0. If a copy of the MPL was not distributed with this", " * file, You can obtain one at http://mozilla.org/MPL/2.0/. */", ].join("\n"); let commentString = [ "/* DO NOT EDIT this file directly, instead modify design-tokens.json", " * and run `npm run build` to see your changes. */", ].join("\n"); let cssImport = surface ? `@import url("chrome://global/skin/design-system/tokens-shared.css");\n\n` : ""; let layerString = !surface && !platform ? `@layer tokens-foundation, tokens-prefers-contrast, tokens-forced-colors;\n\n` : ""; return [ licenseString + "\n\n" + commentString + "\n\n" + cssImport + layerString, ]; }; const NEST_MEDIA_QUERIES_COMMENT = `/* Bug 1879900: Can't nest media queries inside of :host, :root selector until Bug 1879349 lands */`; const MEDIA_QUERY_PROPERTY_MAP = { "forced-colors": "forcedColors", "prefers-contrast": "prefersContrast", }; function formatBaseTokenNames(str) { return str.replaceAll(/(?\w+)-base(?=\b)/g, "$"); } /** * Creates a surface-specific formatter. The formatter is used to build * our different CSS files, including "prefers-contrast" and "forced-colors" * media queries. See more at * https://amzn.github.io/style-dictionary/#/formats?id=formatter * * @param {string} surface * Which desktop area we are generating CSS for. * Either "brand" (i.e. in-content) or "platform" (i.e. chrome). * @returns {Function} - Formatter function that returns a CSS string. */ const createDesktopFormat = surface => args => { return formatBaseTokenNames( customFileHeader({ surface }) + formatTokens({ surface, args, }) + formatTokens({ mediaQuery: "prefers-contrast", surface, args, }) + formatTokens({ mediaQuery: "forced-colors", surface, args, }) ); }; /** * Formats a subset of tokens into CSS. Wraps token CSS in a media query when * applicable. * * @param {object} tokenArgs * @param {string} [tokenArgs.mediaQuery] * Media query formatted CSS should be wrapped in. This is used * to determine what property we are parsing from the token values. * @param {string} [tokenArgs.surface] * Specifies a desktop surface, either "brand" or "platform". * @param {object} tokenArgs.args * Formatter arguments provided by style-dictionary. See more at * https://amzn.github.io/style-dictionary/#/formats?id=formatter * @returns {string} Tokens formatted into a CSS string. */ function formatTokens({ mediaQuery, surface, args }) { let prop = MEDIA_QUERY_PROPERTY_MAP[mediaQuery] ?? "default"; let dictionary = Object.assign({}, args.dictionary); let tokens = []; dictionary.allTokens.forEach(token => { let originalVal = getOriginalTokenValue(token, prop, surface); if (originalVal != undefined) { let formattedToken = transformToken( token, originalVal, dictionary, surface ); tokens.push(formattedToken); } }); if (!tokens.length) { return ""; } dictionary.allTokens = dictionary.allProperties = tokens; let formattedVars = formatVariables({ format: "css", dictionary, outputReferences: args.options.outputReferences, formatting: { indentation: mediaQuery ? " " : " ", }, }); let layer = `tokens-${mediaQuery ?? "foundation"}`; // Weird spacing below is unfortunately necessary for formatting the built CSS. if (mediaQuery) { return ` ${NEST_MEDIA_QUERIES_COMMENT} @layer ${layer} { @media (${mediaQuery}) { :root, :host(.anonymous-content-host) { ${formattedVars} } } } `; } return `@layer ${layer} { :root, :host(.anonymous-content-host) { ${formattedVars} } } `; } /** * Finds the original value of a token for a given media query and surface. * * @param {object} token - Token object parsed by style-dictionary. * @param {string} prop - Name of the property we're querying for. * @param {string} surface * The desktop surface we're generating CSS for, either "brand" or "platform". * @returns {string} The original token value based on our parameters. */ function getOriginalTokenValue(token, prop, surface) { if (surface) { return token.original.value[surface]?.[prop]; } else if (prop == "default" && typeof token.original.value != "object") { return token.original.value; } return token.original.value?.[prop]; } /** * Updates a token's value to the relevant original value after resolving * variable references. Also checks for surface specific comments. * * @param {object} token - Token object parsed from JSON by style-dictionary. * @param {string} originalVal * Original value of the token for the combination of surface and media query. * @param {object} dictionary * Object of transformed tokens and helper fns provided by style-dictionary. * @param {string} surface * The desktop surface we're generating CSS for, either "brand", "platform", * or "shared". * @returns {object} Token object with an updated value. */ function transformToken(token, originalVal, dictionary, surface) { let value = originalVal; if (dictionary.usesReference(value)) { dictionary.getReferences(value).forEach(ref => { value = value.replace(`{${ref.path.join(".")}}`, `var(--${ref.name})`); }); } let surfaceComment = token.original?.value[surface]?.comment; return { ...token, value, comment: surfaceComment ?? token.comment }; } /** * Creates a light-dark transform that works for a given surface. Registers * the transform with style-dictionary and returns the transform's name. * * @param {string} surface * The desktop surface we're generating CSS for, either "brand", "platform", * or "shared". * @returns {string} Name of the transform that was registered. */ const createLightDarkTransform = surface => { let name = `lightDarkTransform/${surface}`; // Matcher function for determining if a token's value needs to undergo // a light-dark transform. let matcher = token => { if (surface != "shared") { return ( token.original.value[surface]?.light && token.original.value[surface]?.dark ); } return token.original.value.light && token.original.value.dark; }; // Function that uses the token's original value to create a new "default" // light-dark value and updates the original value object. let transformer = token => { if (surface != "shared") { let lightDarkVal = `light-dark(${token.original.value[surface].light}, ${token.original.value[surface].dark})`; token.original.value[surface].default = lightDarkVal; return token.value; } let value = `light-dark(${token.original.value.light}, ${token.original.value.dark})`; token.original.value.default = value; return value; }; StyleDictionary.registerTransform({ type: "value", transitive: true, name, matcher, transformer, }); return name; }; /** * Format the tokens dictionary to a string. This mostly defers to * StyleDictionary.createPropertyFormatter but first it sorts the tokens based * on the groupings in TOKEN_SECTIONS and adds comment headers to CSS output. * * @param {object} options * Options for tokens to format. * @param {string} options.format * The format to output. Supported: "css" * @param {object} options.dictionary * The tokens dictionary. * @param {string} options.outputReferences * Whether to output variable references. * @param {object} options.formatting * The formatting settings to be passed to createPropertyFormatter. * @returns {string} The formatted tokens. */ function formatVariables({ format, dictionary, outputReferences, formatting }) { let lastSection = []; let propertyFormatter = createPropertyFormatter({ outputReferences, dictionary, format, formatting, }); let outputParts = []; let remainingTokens = [...dictionary.allTokens]; let isFirst = true; function tokenParts(name) { let lastDash = name.lastIndexOf("-"); let suffix = name.substring(lastDash + 1); if (TSHIRT_ORDER.includes(suffix) || STATE_ORDER.includes(suffix)) { return [name.substring(0, lastDash), suffix]; } return [name, ""]; } for (let [label, selector] of Object.entries(getTokenSections())) { let sectionMatchers = Array.isArray(selector) ? selector : [selector]; let sectionParts = []; remainingTokens = remainingTokens.filter(token => { if ( sectionMatchers.some(m => m.test ? m.test(token.name) : token.name.startsWith(m) ) ) { sectionParts.push(token); return false; } return true; }); if (sectionParts.length) { sectionParts.sort((a, b) => { let aName = formatBaseTokenNames(a.name); let bName = formatBaseTokenNames(b.name); let [aToken, aSuffix] = tokenParts(aName); let [bToken, bSuffix] = tokenParts(bName); if (aSuffix || bSuffix) { if (aToken == bToken) { let aSize = TSHIRT_ORDER.indexOf(aSuffix); let bSize = TSHIRT_ORDER.indexOf(bSuffix); if (aSize != -1 && bSize != -1) { return aSize - bSize; } let aState = STATE_ORDER.indexOf(aSuffix); let bState = STATE_ORDER.indexOf(bSuffix); if (aState != -1 && bState != -1) { return aState - bState; } } } return aToken.localeCompare(bToken, undefined, { numeric: true }); }); let headingParts = []; if (!isFirst) { headingParts.push(""); } isFirst = false; let sectionLevel = "**"; let labelParts = label.split("/"); for (let i = 0; i < labelParts.length; i++) { if (labelParts[i] != lastSection[i]) { headingParts.push( `${formatting.indentation}/${sectionLevel} ${labelParts[i]} ${sectionLevel}/` ); } sectionLevel += "*"; } lastSection = labelParts; outputParts = outputParts.concat( headingParts.concat(sectionParts.map(propertyFormatter)) ); } } return outputParts.join("\n"); } // Easy way to grab variable values later for display. let variableLookupTable = {}; function tokensTableFormat(args, isSemanticTable = false) { let dictionary = Object.assign({}, args.dictionary); let resolvedTokens = dictionary.allTokens.map(token => { let tokenVal = resolveReferences(dictionary, token.original); return { name: token.name, ...tokenVal, }; }); dictionary.allTokens = dictionary.allProperties = resolvedTokens; let parsedData = JSON.parse( formatBaseTokenNames( StyleDictionary.format["javascript/module-flat"]({ ...args, dictionary, }) ) .trim() .replaceAll(/(^module\.exports\s*=\s*|\;$)/g, "") ); let tokensTable = formatTokensTableData(parsedData, isSemanticTable); return `${customFileHeader({ platform: "tokens-table" })} export const tokensTable = ${JSON.stringify(tokensTable)}; export const variableLookupTable = ${JSON.stringify(variableLookupTable)}; `; } function resolveReferences(dictionary, originalVal) { let resolvedValues = {}; Object.entries(originalVal).forEach(([key, value]) => { if (typeof value === "object" && value != null) { resolvedValues[key] = resolveReferences(dictionary, value); } else { let resolvedVal = getValueWithReferences(dictionary, value); resolvedValues[key] = resolvedVal; } }); return resolvedValues; } function getValueWithReferences(dictionary, value) { let valWithRefs = value; if (dictionary.usesReference(value)) { dictionary.getReferences(value).forEach(ref => { valWithRefs = valWithRefs.replace( `{${ref.path.join(".")}}`, `var(--${ref.name})` ); }); } return valWithRefs; } function formatTokensTableData(tokensData, isSemanticTable = false) { let tokensTable = {}; Object.entries(tokensData).forEach(([key, value]) => { variableLookupTable[key] = value; let formattedToken = { value, name: `--${key}`, }; const tableName = getTokenCategoryName( key, isSemanticTable ? PURPOSE.SEMANTIC : PURPOSE.STORYBOOK ); if (tokensTable[tableName]) { tokensTable[tableName].push(formattedToken); } else { tokensTable[tableName] = [formattedToken]; } }); return tokensTable; } function getTokenCategoryName(tokenName, purpose) { // Use the token's name to determine the category it belongs to. // e.g. --button-background-color-primary goes to "background-color" const matchingCategory = TOKEN_CATEGORIES.find( ({ name, alternateNames, purposes }) => { if (!purposes.includes(purpose)) { return false; } if (tokenName.includes(name)) { return true; } return alternateNames?.some(alternateName => tokenName.includes(alternateName) ); } ); if (!matchingCategory) { return "uncategorized"; } return matchingCategory.name; } function getTokenCategory(filePath) { const fileName = filePath.split("/").at(-1); const tokenCategory = fileName.replace(".tokens.json", ""); return tokenCategory; } module.exports = { source: ["src/tokens/**/*.json"], format: { "css/variables/shared": createDesktopFormat(), "css/variables/brand": createDesktopFormat("brand"), "css/variables/platform": createDesktopFormat("platform"), // Organize tokens to be consumed by Storybook. "javascript/tokens-table": args => tokensTableFormat(args, false), // Organize tokens to be used by stylelint rules. "javascript/semantic-categories": args => tokensTableFormat(args, true), ...figmaConfig.formats, }, parsers: [ { pattern: /\.json$/, parse: ({ filePath, contents }) => JSON.parse(`{"${getTokenCategory(filePath)}": ${contents}}`), }, ], platforms: { css: { options: { outputReferences: true, showFileHeader: false, }, transforms: [ ...StyleDictionary.transformGroup.css, ...["shared", "platform", "brand"].map(createLightDarkTransform), ], files: [ { destination: "dist/tokens-shared.css", format: "css/variables/shared", }, { destination: "dist/tokens-brand.css", format: "css/variables/brand", filter: token => typeof token.original.value == "object" && token.original.value.brand, }, { destination: "dist/tokens-platform.css", format: "css/variables/platform", filter: token => typeof token.original.value == "object" && token.original.value.platform, }, ], }, tables: { options: { outputReferences: true, showFileHeader: false, }, transforms: [ ...StyleDictionary.transformGroup.css, ...["shared", "platform", "brand"].map(createLightDarkTransform), ], files: [ { destination: "dist/tokens-table.mjs", format: "javascript/tokens-table", }, { destination: "dist/semantic-categories.mjs", format: "javascript/semantic-categories", }, ], }, figma: figmaConfig.platform, }, };