|
| 1 | +import { toCss, walk, type AstNode } from '../ast' |
| 2 | +import type { DesignSystem } from '../design-system' |
| 3 | +import type { Theme, ThemeKey } from '../theme' |
| 4 | +import { withAlpha } from '../utilities' |
| 5 | +import { segment } from '../utils/segment' |
| 6 | +import { toKeyPath } from '../utils/to-key-path' |
| 7 | +import { applyConfigToTheme } from './apply-config-to-theme' |
| 8 | +import { createCompatConfig } from './config/create-compat-config' |
| 9 | +import { resolveConfig } from './config/resolve-config' |
| 10 | +import type { UserConfig } from './config/types' |
| 11 | +import { darkModePlugin } from './dark-mode' |
| 12 | +import { buildPluginApi, type CssPluginOptions, type Plugin } from './plugin-api' |
| 13 | + |
| 14 | +export async function applyCompatibilityHooks({ |
| 15 | + designSystem, |
| 16 | + ast, |
| 17 | + loadPlugin, |
| 18 | + loadConfig, |
| 19 | + globs, |
| 20 | +}: { |
| 21 | + designSystem: DesignSystem |
| 22 | + ast: AstNode[] |
| 23 | + loadPlugin: (path: string) => Promise<Plugin> |
| 24 | + loadConfig: (path: string) => Promise<UserConfig> |
| 25 | + globs: { origin?: string; pattern: string }[] |
| 26 | +}) { |
| 27 | + let pluginPaths: [string, CssPluginOptions | null][] = [] |
| 28 | + let configPaths: string[] = [] |
| 29 | + |
| 30 | + walk(ast, (node, { parent, replaceWith }) => { |
| 31 | + if (node.kind !== 'rule' || node.selector[0] !== '@') return |
| 32 | + |
| 33 | + // Collect paths from `@plugin` at-rules |
| 34 | + if (node.selector === '@plugin' || node.selector.startsWith('@plugin ')) { |
| 35 | + if (parent !== null) { |
| 36 | + throw new Error('`@plugin` cannot be nested.') |
| 37 | + } |
| 38 | + |
| 39 | + let pluginPath = node.selector.slice(9, -1) |
| 40 | + if (pluginPath.length === 0) { |
| 41 | + throw new Error('`@plugin` must have a path.') |
| 42 | + } |
| 43 | + |
| 44 | + let options: CssPluginOptions = {} |
| 45 | + |
| 46 | + for (let decl of node.nodes ?? []) { |
| 47 | + if (decl.kind !== 'declaration') { |
| 48 | + throw new Error( |
| 49 | + `Unexpected \`@plugin\` option:\n\n${toCss([decl])}\n\n\`@plugin\` options must be a flat list of declarations.`, |
| 50 | + ) |
| 51 | + } |
| 52 | + |
| 53 | + if (decl.value === undefined) continue |
| 54 | + |
| 55 | + // Parse the declaration value as a primitive type |
| 56 | + // These are the same primitive values supported by JSON |
| 57 | + let value: CssPluginOptions[keyof CssPluginOptions] = decl.value |
| 58 | + |
| 59 | + let parts = segment(value, ',').map((part) => { |
| 60 | + part = part.trim() |
| 61 | + |
| 62 | + if (part === 'null') { |
| 63 | + return null |
| 64 | + } else if (part === 'true') { |
| 65 | + return true |
| 66 | + } else if (part === 'false') { |
| 67 | + return false |
| 68 | + } else if (!Number.isNaN(Number(part))) { |
| 69 | + return Number(part) |
| 70 | + } else if ( |
| 71 | + (part[0] === '"' && part[part.length - 1] === '"') || |
| 72 | + (part[0] === "'" && part[part.length - 1] === "'") |
| 73 | + ) { |
| 74 | + return part.slice(1, -1) |
| 75 | + } else if (part[0] === '{' && part[part.length - 1] === '}') { |
| 76 | + throw new Error( |
| 77 | + `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.`, |
| 78 | + ) |
| 79 | + } |
| 80 | + |
| 81 | + return part |
| 82 | + }) |
| 83 | + |
| 84 | + options[decl.property] = parts.length === 1 ? parts[0] : parts |
| 85 | + } |
| 86 | + |
| 87 | + pluginPaths.push([pluginPath, Object.keys(options).length > 0 ? options : null]) |
| 88 | + |
| 89 | + replaceWith([]) |
| 90 | + return |
| 91 | + } |
| 92 | + |
| 93 | + // Collect paths from `@config` at-rules |
| 94 | + if (node.selector === '@config' || node.selector.startsWith('@config ')) { |
| 95 | + if (node.nodes.length > 0) { |
| 96 | + throw new Error('`@config` cannot have a body.') |
| 97 | + } |
| 98 | + |
| 99 | + if (parent !== null) { |
| 100 | + throw new Error('`@config` cannot be nested.') |
| 101 | + } |
| 102 | + |
| 103 | + configPaths.push(node.selector.slice(9, -1)) |
| 104 | + replaceWith([]) |
| 105 | + return |
| 106 | + } |
| 107 | + }) |
| 108 | + |
| 109 | + // Override `resolveThemeValue` with a version that is backwards compatible |
| 110 | + // with dot notation paths like `colors.red.500`. We could do this by default |
| 111 | + // in `resolveThemeValue` but handling it here keeps all backwards |
| 112 | + // compatibility concerns localized to our compatibility layer. |
| 113 | + let resolveThemeVariableValue = designSystem.resolveThemeValue |
| 114 | + |
| 115 | + designSystem.resolveThemeValue = function resolveThemeValue(path: string) { |
| 116 | + if (path.startsWith('--')) { |
| 117 | + return resolveThemeVariableValue(path) |
| 118 | + } |
| 119 | + |
| 120 | + // Extract an eventual modifier from the path. e.g.: |
| 121 | + // - "colors.red.500 / 50%" -> "50%" |
| 122 | + let lastSlash = path.lastIndexOf('/') |
| 123 | + let modifier: string | null = null |
| 124 | + if (lastSlash !== -1) { |
| 125 | + modifier = path.slice(lastSlash + 1).trim() |
| 126 | + path = path.slice(0, lastSlash).trim() as ThemeKey |
| 127 | + } |
| 128 | + |
| 129 | + let themeValue = lookupThemeValue(designSystem.theme, path) |
| 130 | + |
| 131 | + // Apply the opacity modifier if present |
| 132 | + if (modifier && themeValue) { |
| 133 | + return withAlpha(themeValue, modifier) |
| 134 | + } |
| 135 | + |
| 136 | + return themeValue |
| 137 | + } |
| 138 | + |
| 139 | + // If there are no plugins or configs registered, we don't need to register |
| 140 | + // any additional backwards compatibility hooks. |
| 141 | + if (!pluginPaths.length && !configPaths.length) return |
| 142 | + |
| 143 | + let configs = await Promise.all( |
| 144 | + configPaths.map(async (configPath) => ({ |
| 145 | + path: configPath, |
| 146 | + config: await loadConfig(configPath), |
| 147 | + })), |
| 148 | + ) |
| 149 | + let pluginDetails = await Promise.all( |
| 150 | + pluginPaths.map(async ([pluginPath, pluginOptions]) => ({ |
| 151 | + path: pluginPath, |
| 152 | + plugin: await loadPlugin(pluginPath), |
| 153 | + options: pluginOptions, |
| 154 | + })), |
| 155 | + ) |
| 156 | + |
| 157 | + let plugins = pluginDetails.map((detail) => { |
| 158 | + if (!detail.options) { |
| 159 | + return detail.plugin |
| 160 | + } |
| 161 | + |
| 162 | + if ('__isOptionsFunction' in detail.plugin) { |
| 163 | + return detail.plugin(detail.options) |
| 164 | + } |
| 165 | + |
| 166 | + throw new Error(`The plugin "${detail.path}" does not accept options`) |
| 167 | + }) |
| 168 | + |
| 169 | + let userConfig = [{ config: { plugins } }, ...configs] |
| 170 | + |
| 171 | + let resolvedConfig = resolveConfig(designSystem, [ |
| 172 | + { config: createCompatConfig(designSystem.theme) }, |
| 173 | + ...userConfig, |
| 174 | + { config: { plugins: [darkModePlugin] } }, |
| 175 | + ]) |
| 176 | + |
| 177 | + let pluginApi = buildPluginApi(designSystem, ast, resolvedConfig) |
| 178 | + |
| 179 | + for (let { handler } of resolvedConfig.plugins) { |
| 180 | + handler(pluginApi) |
| 181 | + } |
| 182 | + |
| 183 | + // Merge the user-configured theme keys into the design system. The compat |
| 184 | + // config would otherwise expand into namespaces like `background-color` which |
| 185 | + // core utilities already read from. |
| 186 | + applyConfigToTheme(designSystem, userConfig) |
| 187 | + |
| 188 | + // Replace `resolveThemeValue` with a version that is backwards compatible |
| 189 | + // with dot-notation but also aware of any JS theme configurations registered |
| 190 | + // by plugins or JS config files. This is significantly slower than just |
| 191 | + // upgrading dot-notation keys so we only use this version if plugins or |
| 192 | + // config files are actually being used. In the future we may want to optimize |
| 193 | + // this further by only doing this if plugins or config files _actually_ |
| 194 | + // registered JS config objects. |
| 195 | + designSystem.resolveThemeValue = function resolveThemeValue(path: string, defaultValue?: string) { |
| 196 | + let resolvedValue = pluginApi.theme(path, defaultValue) |
| 197 | + |
| 198 | + if (Array.isArray(resolvedValue) && resolvedValue.length === 2) { |
| 199 | + // When a tuple is returned, return the first element |
| 200 | + return resolvedValue[0] |
| 201 | + } else if (Array.isArray(resolvedValue)) { |
| 202 | + // Arrays get serialized into a comma-separated lists |
| 203 | + return resolvedValue.join(', ') |
| 204 | + } else if (typeof resolvedValue === 'string') { |
| 205 | + // Otherwise only allow string values here, objects (and namespace maps) |
| 206 | + // are treated as non-resolved values for the CSS `theme()` function. |
| 207 | + return resolvedValue |
| 208 | + } |
| 209 | + } |
| 210 | + |
| 211 | + for (let file of resolvedConfig.content.files) { |
| 212 | + if ('raw' in file) { |
| 213 | + throw new Error( |
| 214 | + `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.`, |
| 215 | + ) |
| 216 | + } |
| 217 | + |
| 218 | + globs.push({ origin: file.base, pattern: file.pattern }) |
| 219 | + } |
| 220 | +} |
| 221 | + |
| 222 | +function toThemeKey(keypath: string[]) { |
| 223 | + return ( |
| 224 | + keypath |
| 225 | + // [1] should move into the nested object tuple. To create the CSS variable |
| 226 | + // name for this, we replace it with an empty string that will result in two |
| 227 | + // subsequent dashes when joined. |
| 228 | + .map((path) => (path === '1' ? '' : path)) |
| 229 | + |
| 230 | + // Resolve the key path to a CSS variable segment |
| 231 | + .map((part) => |
| 232 | + part |
| 233 | + .replaceAll('.', '_') |
| 234 | + .replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}-${b.toLowerCase()}`), |
| 235 | + ) |
| 236 | + |
| 237 | + // Remove the `DEFAULT` key at the end of a path |
| 238 | + // We're reading from CSS anyway so it'll be a string |
| 239 | + .filter((part, index) => part !== 'DEFAULT' || index !== keypath.length - 1) |
| 240 | + .join('-') |
| 241 | + ) |
| 242 | +} |
| 243 | + |
| 244 | +function lookupThemeValue(theme: Theme, path: string) { |
| 245 | + let baseThemeKey = '--' + toThemeKey(toKeyPath(path)) |
| 246 | + |
| 247 | + let resolvedValue = theme.get([baseThemeKey as ThemeKey]) |
| 248 | + |
| 249 | + if (resolvedValue !== null) { |
| 250 | + return resolvedValue |
| 251 | + } |
| 252 | + |
| 253 | + for (let [givenKey, upgradeKey] of Object.entries(themeUpgradeKeys)) { |
| 254 | + if (!baseThemeKey.startsWith(givenKey)) continue |
| 255 | + |
| 256 | + let upgradedKey = upgradeKey + baseThemeKey.slice(givenKey.length) |
| 257 | + let resolvedValue = theme.get([upgradedKey as ThemeKey]) |
| 258 | + |
| 259 | + if (resolvedValue !== null) { |
| 260 | + return resolvedValue |
| 261 | + } |
| 262 | + } |
| 263 | +} |
| 264 | + |
| 265 | +let themeUpgradeKeys = { |
| 266 | + '--colors': '--color', |
| 267 | + '--accent-color': '--color', |
| 268 | + '--backdrop-blur': '--blur', |
| 269 | + '--backdrop-brightness': '--brightness', |
| 270 | + '--backdrop-contrast': '--contrast', |
| 271 | + '--backdrop-grayscale': '--grayscale', |
| 272 | + '--backdrop-hue-rotate': '--hueRotate', |
| 273 | + '--backdrop-invert': '--invert', |
| 274 | + '--backdrop-opacity': '--opacity', |
| 275 | + '--backdrop-saturate': '--saturate', |
| 276 | + '--backdrop-sepia': '--sepia', |
| 277 | + '--background-color': '--color', |
| 278 | + '--background-opacity': '--opacity', |
| 279 | + '--border-color': '--color', |
| 280 | + '--border-opacity': '--opacity', |
| 281 | + '--border-spacing': '--spacing', |
| 282 | + '--box-shadow-color': '--color', |
| 283 | + '--caret-color': '--color', |
| 284 | + '--divide-color': '--borderColor', |
| 285 | + '--divide-opacity': '--borderOpacity', |
| 286 | + '--divide-width': '--borderWidth', |
| 287 | + '--fill': '--color', |
| 288 | + '--flex-basis': '--spacing', |
| 289 | + '--gap': '--spacing', |
| 290 | + '--gradient-color-stops': '--color', |
| 291 | + '--height': '--spacing', |
| 292 | + '--inset': '--spacing', |
| 293 | + '--margin': '--spacing', |
| 294 | + '--max-height': '--spacing', |
| 295 | + '--max-width': '--spacing', |
| 296 | + '--min-height': '--spacing', |
| 297 | + '--min-width': '--spacing', |
| 298 | + '--outline-color': '--color', |
| 299 | + '--padding': '--spacing', |
| 300 | + '--placeholder-color': '--color', |
| 301 | + '--placeholder-opacity': '--opacity', |
| 302 | + '--ring-color': '--color', |
| 303 | + '--ring-offset-color': '--color', |
| 304 | + '--ring-opacity': '--opacity', |
| 305 | + '--scroll-margin': '--spacing', |
| 306 | + '--scroll-padding': '--spacing', |
| 307 | + '--space': '--spacing', |
| 308 | + '--stroke': '--color', |
| 309 | + '--text-color': '--color', |
| 310 | + '--text-decoration-color': '--color', |
| 311 | + '--text-indent': '--spacing', |
| 312 | + '--text-opacity': '--opacity', |
| 313 | + '--translate': '--spacing', |
| 314 | + '--size': '--spacing', |
| 315 | + '--width': '--spacing', |
| 316 | +} |
0 commit comments