Extract more backwards compatibility logic to compatibility layer (#14365)

I noticed a lot more backwards compatibility concerns had started
leaking into core, especially around the `theme` function, so did a bit
of work to try and pull that stuff out and into the compatibility layer.

Now the core version of `theme` only handles CSS variables (like
`--color-red-500`) and has no knowledge of the dot notation or how to
upgrade it. Instead, we unconditionally override that function in the
compatibility layer with a light version that _does_ know how to do the
dot notation upgrade, and override that again with the very heavy/slow
version that handles JS config objects only if plugins/JS configs are
actually used.

I've also renamed `registerPlugins` to `applyCompatibilityHooks` because
the name was definitely a bit out of date given how much work it's doing
now, and now call it unconditionally from core, leaving that function to
do any conditional optimizations itself internally.

Next steps I think would be to split up `plugin-api.ts` a bit and maybe
make `applyCompatibilityHooks` its own file, and move both of those
files into the `compat` folder so everything is truly isolated there.

My goal with this stuff is that if/when we ever decide to drop backwards
compatibility with these features in the future (maybe v5), that all we
have to do is delete the one line of code that calls
`applyCompatibilityHooks` in `index.ts`, and delete the `compat` folder
and we're done. I could be convinced that this isn't a worthwhile goal
if we feel it's making the codebase needlessly complex, so open to that
discussion as well.

---------

Co-authored-by: Adam Wathan <4323180+adamwathan@users.noreply.github.com>
Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
This commit is contained in:
Adam Wathan 2024-09-11 10:17:02 -04:00 committed by GitHub
parent 8c6c291869
commit 8b0fff6edd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 232 additions and 323 deletions

View File

@ -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',

View File

@ -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

View File

@ -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<typeof compileAstNodes>
getUsedVariants(): ReturnType<typeof parseVariant>[]
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
},
}

View File

@ -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)
}

View File

@ -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<string, Primitive | Primitive[]>
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<Plugin>
configPaths: string[]
loadConfig: (path: string) => Promise<UserConfig>
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',
}

View File

@ -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',
}

View File

@ -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}`

View File

@ -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<T extends ColorThemeKey>(
function resolveThemeColor<T extends ThemeKey>(
candidate: Extract<Candidate, { kind: 'functional' }>,
theme: Theme,
themeKeys: T[],
@ -324,7 +324,7 @@ export function createUtilities(theme: Theme) {
}
type ColorUtilityDescription = {
themeKeys: ColorThemeKey[]
themeKeys: ThemeKey[]
handle: (value: string) => AstNode[] | undefined
}