diff --git a/packages/tailwindcss/src/compat/apply-config-to-theme.ts b/packages/tailwindcss/src/compat/apply-config-to-theme.ts index 92d1c2c15..3ba4ddb53 100644 --- a/packages/tailwindcss/src/compat/apply-config-to-theme.ts +++ b/packages/tailwindcss/src/compat/apply-config-to-theme.ts @@ -63,7 +63,7 @@ export function applyConfigToTheme(designSystem: DesignSystem, configs: ConfigFi { let fontFamily = resolveThemeValue(theme.fontFamily.mono) if (fontFamily && designSystem.theme.hasDefault('--font-family-mono')) { - designSystem.theme.add('--default-mono-font-family', 'theme(fontFamily.mono)', options) + designSystem.theme.add('--default-mono-font-family', fontFamily, options) designSystem.theme.add( '--default-mono-font-feature-settings', resolveThemeValue(theme.fontFamily.mono, 'fontFeatureSettings') ?? 'normal', diff --git a/packages/tailwindcss/src/css-functions.ts b/packages/tailwindcss/src/css-functions.ts index 0ca4d57aa..c02bef3cd 100644 --- a/packages/tailwindcss/src/css-functions.ts +++ b/packages/tailwindcss/src/css-functions.ts @@ -4,7 +4,7 @@ import { type ValueAstNode } from './value-parser' export const THEME_FUNCTION_INVOCATION = 'theme(' -type ResolveThemeValue = (path: string) => unknown +type ResolveThemeValue = (path: string) => string | undefined export function substituteFunctions(ast: AstNode[], resolveThemeValue: ResolveThemeValue) { walk(ast, (node) => { @@ -39,7 +39,7 @@ export function substituteFunctionsInValue( if (node.kind === 'function' && node.value === 'theme') { if (node.nodes.length < 1) { throw new Error( - 'Expected `theme()` function call to have a path. For example: `theme(colors.red.500)`.', + 'Expected `theme()` function call to have a path. For example: `theme(--color-red-500)`.', ) } @@ -55,7 +55,7 @@ export function substituteFunctionsInValue( // comma (`,`), spaces alone should be merged into the previous word to // avoid splitting in this case: // - // theme(colors.red.500 / 75%) theme(colors.red.500 / 75%, foo, bar) + // theme(--color-red-500 / 75%) theme(--color-red-500 / 75%, foo, bar) // // We only need to do this for the first node, as the fallback values are // passed through as-is. @@ -83,20 +83,7 @@ function cssThemeFn( path: string, fallbackValues: ValueAstNode[], ): ValueAstNode[] { - let resolvedValue: string | null = null - let themeValue = resolveThemeValue(path) - - if (Array.isArray(themeValue) && themeValue.length === 2) { - // When a tuple is returned, return the first element - resolvedValue = themeValue[0] - } else if (Array.isArray(themeValue)) { - // Arrays get serialized into a comma-separated lists - resolvedValue = themeValue.join(', ') - } else if (typeof themeValue === 'string') { - // Otherwise only allow string values here, objects (and namespace maps) - // are treated as non-resolved values for the CSS `theme()` function. - resolvedValue = themeValue - } + let resolvedValue = resolveThemeValue(path) if (!resolvedValue && fallbackValues.length > 0) { return fallbackValues diff --git a/packages/tailwindcss/src/design-system.ts b/packages/tailwindcss/src/design-system.ts index 18a50d614..0c317dbfd 100644 --- a/packages/tailwindcss/src/design-system.ts +++ b/packages/tailwindcss/src/design-system.ts @@ -3,9 +3,8 @@ import { parseCandidate, parseVariant, type Candidate } from './candidate' import { compileAstNodes, compileCandidates } from './compile' import { getClassList, getVariants, type ClassEntry, type VariantEntry } from './intellisense' import { getClassOrder } from './sort' -import type { Theme } from './theme' -import { resolveThemeValue } from './theme-fn' -import { Utilities, createUtilities } from './utilities' +import type { Theme, ThemeKey } from './theme' +import { Utilities, createUtilities, withAlpha } from './utilities' import { DefaultMap } from './utils/default-map' import { Variants, createVariants } from './variants' @@ -24,7 +23,7 @@ export type DesignSystem = { compileAstNodes(candidate: Candidate): ReturnType getUsedVariants(): ReturnType[] - resolveThemeValue(path: string, defaultValue?: string): string | undefined + resolveThemeValue(path: string): string | undefined } export function buildDesignSystem(theme: Theme): DesignSystem { @@ -82,8 +81,24 @@ export function buildDesignSystem(theme: Theme): DesignSystem { return Array.from(parsedVariants.values()) }, - resolveThemeValue(path: string, defaultValue?: string) { - return resolveThemeValue(theme, path, defaultValue) + resolveThemeValue(path: `${ThemeKey}` | `${ThemeKey}${string}`) { + // Extract an eventual modifier from the path. e.g.: + // - "--color-red-500 / 50%" -> "50%" + let lastSlash = path.lastIndexOf('/') + let modifier: string | null = null + if (lastSlash !== -1) { + modifier = path.slice(lastSlash + 1).trim() + path = path.slice(0, lastSlash).trim() as ThemeKey + } + + let themeValue = theme.get([path]) ?? undefined + + // Apply the opacity modifier if present + if (modifier && themeValue) { + return withAlpha(themeValue, modifier) + } + + return themeValue }, } diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index f8514ab71..c3cd9e70c 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -6,7 +6,7 @@ import { compileCandidates } from './compile' import { substituteFunctions, THEME_FUNCTION_INVOCATION } from './css-functions' import * as CSS from './css-parser' import { buildDesignSystem, type DesignSystem } from './design-system' -import { registerPlugins, type CssPluginOptions, type Plugin } from './plugin-api' +import { applyCompatibilityHooks, type CssPluginOptions, type Plugin } from './plugin-api' import { Theme, ThemeOptions } from './theme' import { segment } from './utils/segment' export type Config = UserConfig @@ -306,24 +306,19 @@ async function parseCss( let designSystem = buildDesignSystem(theme) - let configs = await Promise.all( - configPaths.map(async (configPath) => ({ - path: configPath, - config: await loadConfig(configPath), - })), - ) - - let plugins = await Promise.all( - pluginPaths.map(async ([pluginPath, pluginOptions]) => ({ - path: pluginPath, - plugin: await loadPlugin(pluginPath), - options: pluginOptions, - })), - ) - - if (plugins.length || configs.length) { - registerPlugins(plugins, designSystem, ast, configs, globs) - } + // Apply hooks from backwards compatibility layer. This function takes a lot + // of random arguments because it really just needs access to "the world" to + // do whatever ungodly things it needs to do to make things backwards + // compatible without polluting core. + await applyCompatibilityHooks({ + designSystem, + ast, + pluginPaths, + loadPlugin, + configPaths, + loadConfig, + globs, + }) for (let customVariant of customVariants) { customVariant(designSystem) @@ -376,11 +371,7 @@ async function parseCss( substituteAtApply(ast, designSystem) } - // Replace `theme()` function calls with the actual theme variables. Plugins - // could register new rules that include functions, and JS config files could - // also contain functions or plugins that use functions so we need to evaluate - // functions if either of those are present. - if (plugins.length > 0 || configs.length > 0 || css.includes(THEME_FUNCTION_INVOCATION)) { + if (css.includes(THEME_FUNCTION_INVOCATION)) { substituteFunctions(ast, designSystem.resolveThemeValue) } diff --git a/packages/tailwindcss/src/plugin-api.ts b/packages/tailwindcss/src/plugin-api.ts index 54027dfa3..20c9219cf 100644 --- a/packages/tailwindcss/src/plugin-api.ts +++ b/packages/tailwindcss/src/plugin-api.ts @@ -3,11 +3,13 @@ import { decl, rule, type AstNode } from './ast' import type { Candidate, NamedUtilityValue } from './candidate' import { applyConfigToTheme } from './compat/apply-config-to-theme' import { createCompatConfig } from './compat/config/create-compat-config' -import { resolveConfig, type ConfigFile } from './compat/config/resolve-config' +import { resolveConfig } from './compat/config/resolve-config' import type { ResolvedConfig, UserConfig } from './compat/config/types' import { darkModePlugin } from './compat/dark-mode' import { createThemeFn } from './compat/plugin-functions' +import { substituteFunctions } from './css-functions' import type { DesignSystem } from './design-system' +import type { Theme, ThemeKey } from './theme' import { withAlpha, withNegative } from './utilities' import { inferDataType } from './utils/infer-data-type' import { segment } from './utils/segment' @@ -73,7 +75,9 @@ function buildPluginApi( ): PluginAPI { let api: PluginAPI = { addBase(css) { - ast.push(rule('@layer base', objectToAst(css))) + let baseNodes = objectToAst(css) + substituteFunctions(baseNodes, api.theme) + ast.push(rule('@layer base', baseNodes)) }, addVariant(name, variant) { @@ -349,19 +353,71 @@ function objectToAst(rules: CssInJs | CssInJs[]): AstNode[] { type Primitive = string | number | boolean | null export type CssPluginOptions = Record -interface PluginDetail { - path: string - plugin: Plugin - options: CssPluginOptions | null -} +export async function applyCompatibilityHooks({ + designSystem, + ast, + pluginPaths, + loadPlugin, + configPaths, + loadConfig, + globs, +}: { + designSystem: DesignSystem + ast: AstNode[] + pluginPaths: [string, CssPluginOptions | null][] + loadPlugin: (path: string) => Promise + configPaths: string[] + loadConfig: (path: string) => Promise + globs: { origin?: string; pattern: string }[] +}) { + // Override `resolveThemeValue` with a version that is backwards compatible + // with dot notation paths like `colors.red.500`. We could do this by default + // in `resolveThemeValue` but handling it here keeps all backwards + // compatibility concerns localized to our compatibility layer. + let resolveThemeVariableValue = designSystem.resolveThemeValue + + designSystem.resolveThemeValue = function resolveThemeValue(path: string) { + if (path.startsWith('--')) { + return resolveThemeVariableValue(path) + } + + // Extract an eventual modifier from the path. e.g.: + // - "colors.red.500 / 50%" -> "50%" + let lastSlash = path.lastIndexOf('/') + let modifier: string | null = null + if (lastSlash !== -1) { + modifier = path.slice(lastSlash + 1).trim() + path = path.slice(0, lastSlash).trim() as ThemeKey + } + + let themeValue = lookupThemeValue(designSystem.theme, path) + + // Apply the opacity modifier if present + if (modifier && themeValue) { + return withAlpha(themeValue, modifier) + } + + return themeValue + } + + // If there are no plugins or configs registered, we don't need to register + // any additional backwards compatibility hooks. + if (!pluginPaths.length && !configPaths.length) return + + let configs = await Promise.all( + configPaths.map(async (configPath) => ({ + path: configPath, + config: await loadConfig(configPath), + })), + ) + let pluginDetails = await Promise.all( + pluginPaths.map(async ([pluginPath, pluginOptions]) => ({ + path: pluginPath, + plugin: await loadPlugin(pluginPath), + options: pluginOptions, + })), + ) -export function registerPlugins( - pluginDetails: PluginDetail[], - designSystem: DesignSystem, - ast: AstNode[], - configs: ConfigFile[], - globs: { origin?: string; pattern: string }[], -) { let plugins = pluginDetails.map((detail) => { if (!detail.options) { return detail.plugin @@ -393,8 +449,27 @@ export function registerPlugins( // core utilities already read from. applyConfigToTheme(designSystem, userConfig) + // Replace `resolveThemeValue` with a version that is backwards compatible + // with dot-notation but also aware of any JS theme configurations registered + // by plugins or JS config files. This is significantly slower than just + // upgrading dot-notation keys so we only use this version if plugins or + // config files are actually being used. In the future we may want to optimize + // this further by only doing this if plugins or config files _actually_ + // registered JS config objects. designSystem.resolveThemeValue = function resolveThemeValue(path: string, defaultValue?: string) { - return pluginApi.theme(path, defaultValue) + let resolvedValue = pluginApi.theme(path, defaultValue) + + if (Array.isArray(resolvedValue) && resolvedValue.length === 2) { + // When a tuple is returned, return the first element + return resolvedValue[0] + } else if (Array.isArray(resolvedValue)) { + // Arrays get serialized into a comma-separated lists + return resolvedValue.join(', ') + } else if (typeof resolvedValue === 'string') { + // Otherwise only allow string values here, objects (and namespace maps) + // are treated as non-resolved values for the CSS `theme()` function. + return resolvedValue + } } for (let file of resolvedConfig.content.files) { @@ -407,3 +482,99 @@ export function registerPlugins( globs.push({ origin: file.base, pattern: file.pattern }) } } + +function toThemeKey(keypath: string[]) { + return ( + keypath + // [1] should move into the nested object tuple. To create the CSS variable + // name for this, we replace it with an empty string that will result in two + // subsequent dashes when joined. + .map((path) => (path === '1' ? '' : path)) + + // Resolve the key path to a CSS variable segment + .map((part) => + part + .replaceAll('.', '_') + .replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}-${b.toLowerCase()}`), + ) + + // Remove the `DEFAULT` key at the end of a path + // We're reading from CSS anyway so it'll be a string + .filter((part, index) => part !== 'DEFAULT' || index !== keypath.length - 1) + .join('-') + ) +} + +function lookupThemeValue(theme: Theme, path: string) { + let baseThemeKey = '--' + toThemeKey(toKeyPath(path)) + + let resolvedValue = theme.get([baseThemeKey as ThemeKey]) + + if (resolvedValue !== null) { + return resolvedValue + } + + for (let [givenKey, upgradeKey] of Object.entries(themeUpgradeKeys)) { + if (!baseThemeKey.startsWith(givenKey)) continue + + let upgradedKey = upgradeKey + baseThemeKey.slice(givenKey.length) + let resolvedValue = theme.get([upgradedKey as ThemeKey]) + + if (resolvedValue !== null) { + return resolvedValue + } + } +} + +let themeUpgradeKeys = { + '--colors': '--color', + '--accent-color': '--color', + '--backdrop-blur': '--blur', + '--backdrop-brightness': '--brightness', + '--backdrop-contrast': '--contrast', + '--backdrop-grayscale': '--grayscale', + '--backdrop-hue-rotate': '--hueRotate', + '--backdrop-invert': '--invert', + '--backdrop-opacity': '--opacity', + '--backdrop-saturate': '--saturate', + '--backdrop-sepia': '--sepia', + '--background-color': '--color', + '--background-opacity': '--opacity', + '--border-color': '--color', + '--border-opacity': '--opacity', + '--border-spacing': '--spacing', + '--box-shadow-color': '--color', + '--caret-color': '--color', + '--divide-color': '--borderColor', + '--divide-opacity': '--borderOpacity', + '--divide-width': '--borderWidth', + '--fill': '--color', + '--flex-basis': '--spacing', + '--gap': '--spacing', + '--gradient-color-stops': '--color', + '--height': '--spacing', + '--inset': '--spacing', + '--margin': '--spacing', + '--max-height': '--spacing', + '--max-width': '--spacing', + '--min-height': '--spacing', + '--min-width': '--spacing', + '--outline-color': '--color', + '--padding': '--spacing', + '--placeholder-color': '--color', + '--placeholder-opacity': '--opacity', + '--ring-color': '--color', + '--ring-offset-color': '--color', + '--ring-opacity': '--opacity', + '--scroll-margin': '--spacing', + '--scroll-padding': '--spacing', + '--space': '--spacing', + '--stroke': '--color', + '--text-color': '--color', + '--text-decoration-color': '--color', + '--text-indent': '--spacing', + '--text-opacity': '--opacity', + '--translate': '--spacing', + '--size': '--spacing', + '--width': '--spacing', +} diff --git a/packages/tailwindcss/src/theme-fn.ts b/packages/tailwindcss/src/theme-fn.ts deleted file mode 100644 index a60637809..000000000 --- a/packages/tailwindcss/src/theme-fn.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { Theme, ThemeKey } from './theme' -import { withAlpha } from './utilities' -import { toKeyPath } from './utils/to-key-path' - -/** - * Looks up a value in the CSS theme - */ -export function resolveThemeValue(theme: Theme, path: string, defaultValue?: string) { - // Extract an eventual modifier from the path. e.g.: - // - "colors.red.500 / 50%" -> "50%" - // - "foo/bar/baz/50%" -> "50%" - let lastSlash = path.lastIndexOf('/') - let modifier: string | null = null - if (lastSlash !== -1) { - modifier = path.slice(lastSlash + 1).trim() - path = path.slice(0, lastSlash).trim() - } - - let themeValue = lookupThemeValue(theme, path, defaultValue) - - // Apply the opacity modifier if present - if (modifier && typeof themeValue === 'string') { - return withAlpha(themeValue, modifier) - } - - return themeValue -} - -function toThemeKey(keypath: string[]) { - return ( - keypath - // [1] should move into the nested object tuple. To create the CSS variable - // name for this, we replace it with an empty string that will result in two - // subsequent dashes when joined. - .map((path) => (path === '1' ? '' : path)) - - // Resolve the key path to a CSS variable segment - .map((part) => - part - .replaceAll('.', '_') - .replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}-${b.toLowerCase()}`), - ) - - // Remove the `DEFAULT` key at the end of a path - // We're reading from CSS anyway so it'll be a string - .filter((part, index) => part !== 'DEFAULT' || index !== keypath.length - 1) - .join('-') - ) -} - -function lookupThemeValue(theme: Theme, path: string, defaultValue?: string) { - if (path.startsWith('--')) { - return theme.get([path as any]) ?? defaultValue - } - - let baseThemeKey = '--' + toThemeKey(toKeyPath(path)) - - let resolvedValue = theme.get([baseThemeKey as ThemeKey]) - - if (resolvedValue !== null) { - return resolvedValue - } - - for (let [givenKey, upgradeKey] of Object.entries(themeUpgradeKeys)) { - if (!baseThemeKey.startsWith(givenKey)) continue - - let upgradedKey = upgradeKey + baseThemeKey.slice(givenKey.length) - let resolvedValue = theme.get([upgradedKey as ThemeKey]) - - if (resolvedValue !== null) { - return resolvedValue - } - } - - return defaultValue -} - -let themeUpgradeKeys = { - '--colors': '--color', - '--accent-color': '--color', - '--backdrop-blur': '--blur', - '--backdrop-brightness': '--brightness', - '--backdrop-contrast': '--contrast', - '--backdrop-grayscale': '--grayscale', - '--backdrop-hue-rotate': '--hueRotate', - '--backdrop-invert': '--invert', - '--backdrop-opacity': '--opacity', - '--backdrop-saturate': '--saturate', - '--backdrop-sepia': '--sepia', - '--background-color': '--color', - '--background-opacity': '--opacity', - '--border-color': '--color', - '--border-opacity': '--opacity', - '--border-spacing': '--spacing', - '--box-shadow-color': '--color', - '--caret-color': '--color', - '--divide-color': '--borderColor', - '--divide-opacity': '--borderOpacity', - '--divide-width': '--borderWidth', - '--fill': '--color', - '--flex-basis': '--spacing', - '--gap': '--spacing', - '--gradient-color-stops': '--color', - '--height': '--spacing', - '--inset': '--spacing', - '--margin': '--spacing', - '--max-height': '--spacing', - '--max-width': '--spacing', - '--min-height': '--spacing', - '--min-width': '--spacing', - '--outline-color': '--color', - '--padding': '--spacing', - '--placeholder-color': '--color', - '--placeholder-opacity': '--opacity', - '--ring-color': '--color', - '--ring-offset-color': '--color', - '--ring-opacity': '--opacity', - '--scroll-margin': '--spacing', - '--scroll-padding': '--spacing', - '--space': '--spacing', - '--stroke': '--color', - '--text-color': '--color', - '--text-decoration-color': '--color', - '--text-indent': '--spacing', - '--text-opacity': '--opacity', - '--translate': '--spacing', - '--size': '--spacing', - '--width': '--spacing', -} diff --git a/packages/tailwindcss/src/theme.ts b/packages/tailwindcss/src/theme.ts index b545462ef..ea73b6ebc 100644 --- a/packages/tailwindcss/src/theme.ts +++ b/packages/tailwindcss/src/theme.ts @@ -54,7 +54,7 @@ export class Theme { return keys } - get(themeKeys: (ThemeKey | `${ThemeKey}-${string}`)[]): string | null { + get(themeKeys: ThemeKey[]): string | null { for (let key of themeKeys) { let value = this.values.get(key) if (value) { @@ -179,130 +179,4 @@ export class Theme { } } -export type ThemeKey = - | '--accent-color' - | '--animate' - | '--aspect-ratio' - | '--backdrop-blur' - | '--backdrop-brightness' - | '--backdrop-contrast' - | '--backdrop-grayscale' - | '--backdrop-hue-rotate' - | '--backdrop-invert' - | '--backdrop-opacity' - | '--backdrop-saturate' - | '--backdrop-sepia' - | '--background-color' - | '--background-image' - | '--blur' - | '--border-color' - | '--border-spacing' - | '--border-width' - | '--box-shadow-color' - | '--breakpoint' - | '--brightness' - | '--caret-color' - | '--color' - | '--columns' - | '--contrast' - | '--cursor' - | '--default-border-width' - | '--default-ring-color' - | '--default-ring-width' - | '--default-transition-timing-function' - | '--default-transition-duration' - | '--divide-width' - | '--divide-color' - | '--drop-shadow' - | '--fill' - | '--flex-basis' - | '--font-family' - | '--font-size' - | '--font-weight' - | '--gap' - | '--gradient-color-stop-positions' - | '--grayscale' - | '--grid-auto-columns' - | '--grid-auto-rows' - | '--grid-column' - | '--grid-column-end' - | '--grid-column-start' - | '--grid-row' - | '--grid-row-end' - | '--grid-row-start' - | '--grid-template-columns' - | '--grid-template-rows' - | '--height' - | '--hue-rotate' - | '--inset' - | '--inset-shadow' - | '--invert' - | '--letter-spacing' - | '--line-height' - | '--line-clamp' - | '--list-style-image' - | '--list-style-type' - | '--margin' - | '--max-height' - | '--max-width' - | '--min-height' - | '--min-width' - | '--object-position' - | '--opacity' - | '--order' - | '--outline-color' - | '--outline-width' - | '--outline-offset' - | '--padding' - | '--placeholder-color' - | '--perspective' - | '--perspective-origin' - | '--radius' - | '--ring-color' - | '--ring-offset-color' - | '--ring-offset-width' - | '--ring-width' - | '--rotate' - | '--saturate' - | '--scale' - | '--scroll-margin' - | '--scroll-padding' - | '--sepia' - | '--shadow' - | '--size' - | '--skew' - | '--space' - | '--spacing' - | '--stroke' - | '--stroke-width' - | '--text-color' - | '--text-decoration-color' - | '--text-decoration-thickness' - | '--text-indent' - | '--text-underline-offset' - | '--transform-origin' - | '--transition-delay' - | '--transition-duration' - | '--transition-property' - | '--transition-timing-function' - | '--translate' - | '--width' - | '--z-index' - | `--default-${string}` - -export type ColorThemeKey = - | '--color' - | '--accent-color' - | '--background-color' - | '--border-color' - | '--box-shadow-color' - | '--caret-color' - | '--divide-color' - | '--fill' - | '--outline-color' - | '--placeholder-color' - | '--ring-color' - | '--ring-offset-color' - | '--stroke' - | '--text-color' - | '--text-decoration-color' +export type ThemeKey = `--${string}` diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index 8835ac06a..bfcb774c1 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -1,6 +1,6 @@ import { decl, rule, type AstNode, type Rule } from './ast' import type { Candidate, CandidateModifier, NamedUtilityValue } from './candidate' -import type { ColorThemeKey, Theme, ThemeKey } from './theme' +import type { Theme, ThemeKey } from './theme' import { DefaultMap } from './utils/default-map' import { inferDataType } from './utils/infer-data-type' import { replaceShadowColors } from './utils/replace-shadow-colors' @@ -161,7 +161,7 @@ export function withNegative( * The values `inherit`, `transparent` and `current` are special-cased as they * are universal and don't need to be resolved from the theme. */ -function resolveThemeColor( +function resolveThemeColor( candidate: Extract, theme: Theme, themeKeys: T[], @@ -324,7 +324,7 @@ export function createUtilities(theme: Theme) { } type ColorUtilityDescription = { - themeKeys: ColorThemeKey[] + themeKeys: ThemeKey[] handle: (value: string) => AstNode[] | undefined }