import { extend } from "colord" import mixPlugin from "colord/plugins/mix" import fs from "node:fs" import { FALLBACK_THEME, getThemeForToken, ignoreStyles, kebabify, } from "./utils" import json from "./variables.json" assert { type: "json" } extend([mixPlugin]) type TokenName = string type TokenValue = { alias?: string; resolved: string | number } type ThemeTokenValues = Map type ThemeKey = string // Holds a lookup table of all the themes (signature hotels, etc.) const themes = new Map() // Set the fallback theme that holds all properties that are used by all themes themes.set(FALLBACK_THEME, new Map()) // Collect all real themes json.collections .find((collection) => { return collection.name === "Color Theming" }) ?.modes.forEach((mode) => { themes.set(mode.name, new Map()) }) // Some tokens aliases values that have a responsive nature. // The responsiveness cannot be encoded into this distribution as we do not // distribute CSS selectors, only CSS properties and JSON objects. // We mark these properties with a prefix of "Impl-" in order to convey that // their responsiveness must be implemented in their target environment. // This also means that we must revisit this in order to support React Native. const responsiveTokens = new Set() // Collect responsive tokens json.collections .find((collection) => collection.name === "Responsive") ?.modes.forEach((mode) => { mode.variables.forEach((variable) => { responsiveTokens.add(variable.name) }) }) // Collect non-alias tokens json.collections.forEach((collection) => { collection.modes.filter(ignoreStyles).forEach((mode) => { mode.variables.forEach((variable) => { if (!variable.isAlias) { let token = variable.name let value = variable.value const tokenTheme = getThemeForToken(token, mode, themes) switch (variable.type) { case "boolean": if (typeof variable.value === "boolean") { // Handle text transform if (/text transform/i.test(variable.name)) { token = variable.name.replace( /text transform/i, "Text-Transform" ) value = variable.value ? "uppercase" : "none" } // Handle text decoration else if (/text decoration/i.test(variable.name)) { token = variable.name.replace( /text decoration/i, "Text-Decoration" ) value = variable.value ? "underline" : "none" } } else { throw new Error( `Bad variable value input type. Expected 'boolean' got '${variable.type}'` ) } break case "number": if (typeof variable.value === "number") { // Only use two decimals for all numbers value = Number(variable.value.toFixed(2)) } else { throw new Error( `Bad variable value input type. Expected 'number' got '${variable.type}'` ) } break } if (typeof value === "string" || typeof value === "number") { const theme = themes.get(tokenTheme) if (theme) { theme.set(token, { // primitives will not have aliases resolved: value, }) } } else { throw new Error( `Unsupported value type: ${typeof value} (${token}: ${value})` ) } } }) }) }) // Collect alias tokens json.collections.forEach((collection) => { collection.modes.filter(ignoreStyles).forEach((mode) => { mode.variables.forEach((variable) => { if (variable.isAlias) { const token = variable.name let value = variable.value const tokenTheme = getThemeForToken(token, mode, themes) switch (variable.type) { case "color": case "string": case "number": // Some values are defined as objects, so we need to dig out the real value if (typeof value === "object" && "name" in value) { value = value.name } // Some values are alises to tokens that have a responsive nature. // TODO: Revisit for RN support if (responsiveTokens.has(value)) { value = `Impl-${value}` } if (typeof value === "string") { const theme = themes.get(tokenTheme) if (theme) { theme.set(token, { // We can only resolve aliases after we have collected all // primitives and aliases. resolved: "", alias: value, }) } } break default: console.warn( `Unsupported variable type: ${variable.type} (${variable.name}: ${variable.value})` ) } } }) }) }) // Collect effects tokens json.collections.forEach((collection) => { collection.modes.forEach((mode) => { mode.variables.forEach((variable) => { if (variable.type === "effect") { if (typeof variable.value === "object" && "effects" in variable.value) { if (variable.value.effects.length > 1) { console.warn( `Unsupported effect declaration with multiple effects.`, variable.value.effects ) } // We only support one effect declaration per variable const effect = variable.value.effects[0] switch (effect.type) { case "DROP_SHADOW": { const { r, g, b, a } = effect.color const { x, y } = effect.offset const value = `${x}px ${y}px ${effect.radius}px ${effect.spread}px rgba(${r}, ${g}, ${b}, ${a})` const token = `BoxShadow-${variable.name}` const theme = themes.get(FALLBACK_THEME) if (theme) { theme.set(token, { // primitives will not have aliases resolved: value, }) } break } } } } }) }) }) // Resolve all aliases to primitives themes.forEach((themeTokenValues) => { themeTokenValues.forEach((value, token) => { // Default the resolved value to the original value. Meaning if no value // could be resolved the token value does not change. let resolveValue = value // For each loop the while loop checks if the resolved value is an alias // without a resolved value. If so it continues to resolve the value until a // primitive value is found. while (resolveValue.alias && !resolveValue.resolved) { // Used for breaking the while loop. The while loop continues to // recursively resolve alias that have aliases as their value. // If we have loop through all the themes and not found any primitive // value, then we stop looking by breaking the while loop. let found = false Array.from(themes).some(([, innerTheme]) => { return Array.from(innerTheme).some(([innerToken, innerValue]) => { if (innerToken === value.alias && innerValue.resolved) { resolveValue = { ...value, resolved: innerValue.resolved } found = true return true } }) }) // We have search through all the themes and nothing matched, stop looking if (!found) { break } } // Update the token value with the result of the above. // Either nothing matches and the original is set, meaning nothing changed. // Or we did get a match and the new value is set. themeTokenValues.set(token, resolveValue) }) }) // Prepare for output const variablesJsOutput = [ "/* This file is generated, do not edit manually! */", ] themes.forEach((themeTokenValues, themeKey) => { const cssContentPrimitives: string[] = [] const cssContentAliases: string[] = [] themeTokenValues.forEach((value, token) => { const outputToken = kebabify(token) let outputValue = value.alias ? value.alias : value.resolved if (value.alias) { cssContentAliases.push( ` --${outputToken}: var(--${kebabify(outputValue.toString())});` ) } else { if (typeof outputValue === "string") { // Check for properties that need quotes if (/font(-| )family/gi.test(token)) { outputValue = `"${outputValue}"` } } else if (typeof outputValue === "number") { // font-weight is unitless if (!/font(-| )weight/gi.test(token)) { outputValue = `${outputValue}px` } } else { throw new Error( `Unsupported value type: ${typeof value} (${token}: ${value})` ) } cssContentPrimitives.push(` --${outputToken}: ${outputValue};`) } }) const filename = kebabify(themeKey).toLowerCase() const cssOutput = [ "/* This file is generated, do not edit manually! */", // The base styles target the :root. // All themes require the use of their scoping classname to be used. themeKey === FALLBACK_THEME ? ":root {" : `.${filename} {`, " /* Values */", ...cssContentPrimitives.sort(), "", " /* Aliases */", ...cssContentAliases.sort(), "}", "", // New line at end of file ] fs.writeFileSync(`../lib/styles/${filename}.css`, cssOutput.join("\n"), { encoding: "utf-8", }) const resolvedJsOutput = [ "/* This file is generated, do not edit manually! */", `export const theme = ${JSON.stringify( Array.from(themeTokenValues).reduce( (acc, [token, value]) => { if (value.resolved) { acc[token] = value.resolved } return acc }, {} as Record ), null, 2 )}`, ] fs.writeFileSync( `../lib/styles/${filename}.js`, resolvedJsOutput.join("\n"), { encoding: "utf-8", } ) // This output is only meant for the `Variables` component. // It is mainly used for the Storybook to show all the variables. variablesJsOutput.push( `export const ${themeKey .split(" ") .map((v, i) => (i === 0 ? v.toLowerCase() : v)) .join( "" )} = ${JSON.stringify(Object.fromEntries(themeTokenValues.entries()))} as const` ) }) fs.writeFileSync(`../lib/tokens/index.ts`, variablesJsOutput.join("\n"), { encoding: "utf-8", })