feat(SW-2027): update variables.json * feat(SW-2027): update variables.json * fix(SW-2027). replace old tokens * fix(SW-2027): remove uppercase check Approved-by: Michael Zetterberg Approved-by: Erik Tiekstra
388 lines
12 KiB
TypeScript
388 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
|
|
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',
|
|
})
|