Files
web/packages/design-system/generate/generate.ts
Michael Zetterberg 56973888c9 feat(SW-375): new tokens
new asset generation logic

BREAKING CHANGE: New tokens.
2025-03-07 07:24:37 +00:00

380 lines
12 KiB
TypeScript

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<TokenName, TokenValue>
type ThemeKey = string
// Holds a lookup table of all the themes (signature hotels, etc.)
const themes = new Map<ThemeKey, ThemeTokenValues>()
// 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 UPPERCASE
if (/upper(\s+)?case|text transform/i.test(variable.name)) {
token = variable.name.replace(
/upper(\s+)?case|text transform/i,
'Text-Transform'
)
value = variable.value ? 'uppercase' : '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',
})