import { toCss, walk, type AstNode } from '../ast'
import type { DesignSystem } from '../design-system'
import type { Theme, ThemeKey } from '../theme'
import { withAlpha } from '../utilities'
import { segment } from '../utils/segment'
import { toKeyPath } from '../utils/to-key-path'
import { applyConfigToTheme } from './apply-config-to-theme'
import { createCompatConfig } from './config/create-compat-config'
import { resolveConfig } from './config/resolve-config'
import type { UserConfig } from './config/types'
import { darkModePlugin } from './dark-mode'
import { buildPluginApi, type CssPluginOptions, type Plugin } from './plugin-api'
import { registerScreensConfig } from './screens-config'
import { registerThemeVariantOverrides } from './theme-variants'
const IS_VALID_PREFIX = /^[a-z]+$/
export async function applyCompatibilityHooks({
designSystem,
base,
ast,
loadModule,
globs,
}: {
designSystem: DesignSystem
base: string
ast: AstNode[]
loadModule: (
path: string,
base: string,
resourceHint: 'plugin' | 'config',
) => Promise<{ module: any; base: string }>
globs: { origin?: string; pattern: string }[]
}) {
let pluginPaths: [{ id: string; base: string }, CssPluginOptions | null][] = []
let configPaths: { id: string; base: string }[] = []
walk(ast, (node, { parent, replaceWith, context }) => {
if (node.kind !== 'rule' || node.selector[0] !== '@') return
if (node.selector === '@plugin' || node.selector.startsWith('@plugin ')) {
if (parent !== null) {
throw new Error('`@plugin` cannot be nested.')
}
let pluginPath = node.selector.slice(9, -1)
if (pluginPath.length === 0) {
throw new Error('`@plugin` must have a path.')
}
let options: CssPluginOptions = {}
for (let decl of node.nodes ?? []) {
if (decl.kind !== 'declaration') {
throw new Error(
`Unexpected \`@plugin\` option:\n\n${toCss([decl])}\n\n\`@plugin\` options must be a flat list of declarations.`,
)
}
if (decl.value === undefined) continue
let value: CssPluginOptions[keyof CssPluginOptions] = decl.value
let parts = segment(value, ',').map((part) => {
part = part.trim()
if (part === 'null') {
return null
} else if (part === 'true') {
return true
} else if (part === 'false') {
return false
} else if (!Number.isNaN(Number(part))) {
return Number(part)
} else if (
(part[0] === '"' && part[part.length - 1] === '"') ||
(part[0] === "'" && part[part.length - 1] === "'")
) {
return part.slice(1, -1)
} else if (part[0] === '{' && part[part.length - 1] === '}') {
throw new Error(
`Unexpected \`@plugin\` option: Value of declaration \`${toCss([decl]).trim()}\` is not supported.\n\nUsing an object as a plugin option is currently only supported in JavaScript configuration files.`,
)
}
return part
})
options[decl.property] = parts.length === 1 ? parts[0] : parts
}
pluginPaths.push([
{ id: pluginPath, base: context.base },
Object.keys(options).length > 0 ? options : null,
])
replaceWith([])
return
}
if (node.selector === '@config' || node.selector.startsWith('@config ')) {
if (node.nodes.length > 0) {
throw new Error('`@config` cannot have a body.')
}
if (parent !== null) {
throw new Error('`@config` cannot be nested.')
}
configPaths.push({ id: node.selector.slice(9, -1), base: context.base })
replaceWith([])
return
}
})
let resolveThemeVariableValue = designSystem.resolveThemeValue
designSystem.resolveThemeValue = function resolveThemeValue(path: string) {
if (path.startsWith('--')) {
return resolveThemeVariableValue(path)
}
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)
if (modifier && themeValue) {
return withAlpha(themeValue, modifier)
}
return themeValue
}
if (!pluginPaths.length && !configPaths.length) return
let [configs, pluginDetails] = await Promise.all([
Promise.all(
configPaths.map(async ({ id, base }) => {
let loaded = await loadModule(id, base, 'config')
return {
path: id,
base: loaded.base,
config: loaded.module as UserConfig,
}
}),
),
Promise.all(
pluginPaths.map(async ([{ id, base }, pluginOptions]) => {
let loaded = await loadModule(id, base, 'plugin')
return {
path: id,
base: loaded.base,
plugin: loaded.module as Plugin,
options: pluginOptions,
}
}),
),
])
let pluginConfigs = pluginDetails.map((detail) => {
if (!detail.options) {
return { config: { plugins: [detail.plugin] }, base: detail.base }
}
if ('__isOptionsFunction' in detail.plugin) {
return { config: { plugins: [detail.plugin(detail.options)] }, base: detail.base }
}
throw new Error(`The plugin "${detail.path}" does not accept options`)
})
let userConfig = [...pluginConfigs, ...configs]
let resolvedConfig = resolveConfig(designSystem, [
{ config: createCompatConfig(designSystem.theme), base },
...userConfig,
{ config: { plugins: [darkModePlugin] }, base },
])
let resolvedUserConfig = resolveConfig(designSystem, userConfig)
let pluginApi = buildPluginApi(designSystem, ast, resolvedConfig)
for (let { handler } of resolvedConfig.plugins) {
handler(pluginApi)
}
applyConfigToTheme(designSystem, resolvedUserConfig)
registerThemeVariantOverrides(resolvedUserConfig, designSystem)
registerScreensConfig(resolvedUserConfig, designSystem)
if (!designSystem.theme.prefix && resolvedConfig.prefix) {
if (resolvedConfig.prefix.endsWith('-')) {
resolvedConfig.prefix = resolvedConfig.prefix.slice(0, -1)
console.warn(
`The prefix "${resolvedConfig.prefix}" is invalid. Prefixes must be lowercase ASCII letters (a-z) only and is written as a variant before all utilities. We have fixed up the prefix for you. Remove the trailing \`-\` to silence this warning.`,
)
}
if (!IS_VALID_PREFIX.test(resolvedConfig.prefix)) {
throw new Error(
`The prefix "${resolvedConfig.prefix}" is invalid. Prefixes must be lowercase ASCII letters (a-z) only.`,
)
}
designSystem.theme.prefix = resolvedConfig.prefix
}
for (let candidate of resolvedConfig.blocklist) {
designSystem.invalidCandidates.add(candidate)
}
designSystem.resolveThemeValue = function resolveThemeValue(path: string, defaultValue?: string) {
let resolvedValue = pluginApi.theme(path, defaultValue)
if (Array.isArray(resolvedValue) && resolvedValue.length === 2) {
return resolvedValue[0]
} else if (Array.isArray(resolvedValue)) {
return resolvedValue.join(', ')
} else if (typeof resolvedValue === 'string') {
return resolvedValue
}
}
for (let file of resolvedConfig.content.files) {
if ('raw' in file) {
throw new Error(
`Error in the config file/plugin/preset. The \`content\` key contains a \`raw\` entry:\n\n${JSON.stringify(file, null, 2)}\n\nThis feature is not currently supported.`,
)
}
globs.push(file)
}
}
function toThemeKey(keypath: string[]) {
return (
keypath
.map((path) => (path === '1' ? '' : path))
.map((part) =>
part
.replaceAll('.', '_')
.replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}-${b.toLowerCase()}`),
)
.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-radius': '--radius',
'--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',
}