import fs from 'node:fs' import { colord, extend } from 'colord' import mixPlugin from 'colord/plugins/mix' import json from './variables.json' assert { type: 'json' } import { FALLBACK_THEME, getThemeForToken, ignoreStyles, kebabify, } from './utils' 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' : 'unset' } // Handle text decoration else if (/text decoration/i.test(variable.name)) { token = variable.name.replace( /text decoration/i, 'Text-Decoration' ) value = variable.value ? 'underline' : 'unset' } } 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} ${y} ${effect.radius} ${effect.spread} 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) }) }) // Process hover alpha colors // All */Hover* styles will blend with */Default themes.forEach((themeTokenValue) => { themeTokenValue.forEach((value, token) => { if (token.toLowerCase().indexOf('hover') >= 0) { const hoverValue = value.resolved if (typeof hoverValue === 'string') { if (hoverValue.startsWith('#') && hoverValue.length === 9) { // The color that the hover color with alpha mixes with. const baseToken = token .split('/') .map((tokenPart) => { if (tokenPart.toLowerCase().indexOf('hover') >= 0) { return 'Default' } return tokenPart }) .join('/') const baseValue = themeTokenValue.get(baseToken) ?? themes.get(FALLBACK_THEME)?.get(baseToken) if (baseValue) { if (typeof baseValue.resolved === 'string' && baseValue.resolved) { const baseColor = colord(baseValue.resolved) const hoverColor = colord(hoverValue) if (baseColor.alpha() > 0 && hoverColor.alpha() > 0) { const computedHoverValue = baseColor .mix(hoverColor.alpha(1), hoverColor.alpha()) .toHex() themeTokenValue.set(token, { // no alias for the computed hover color resolved: computedHoverValue, }) } } else { throw new Error( `Base token ${baseToken} is unresolved: ${baseValue}` ) } } else { throw new Error( `Unable to find base token (${baseToken}) for hover token ${token}` ) } } } } }) }) // 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 }, {}), 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', })