Skip to content

Commit d9aad77

Browse files
Further clean up compatibility APIs (#14408)
This PR builds on top of #14365 and adds a few more changes we discussed during a sync on the latter PR: - We now split `plugin-api.ts` into two files and moved it into `compat/`. One file is now defining the comat plugin API only where as another file deals with the compat hook. - The walk for `@plugin` and `@config` is now happening inside the compat hook. The one remaining work item is to change the `loadPlugin` and `loadConfig` APIs to a more unified `resolveModule` one that does not care on what we try to load it for. I suggest we should make this change at the same time we start working on finalizing the `tailwindcss` APIs, since a lot of things will have to be rethought then anyways.
1 parent adde6b0 commit d9aad77

File tree

9 files changed

+342
-341
lines changed

9 files changed

+342
-341
lines changed
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
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+
}

packages/tailwindcss/src/compat/config/resolve-config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { DesignSystem } from '../../design-system'
2-
import type { PluginWithConfig } from '../../plugin-api'
2+
import type { PluginWithConfig } from '../plugin-api'
33
import { createThemeFn } from '../plugin-functions'
44
import { deepMerge, isPlainObject } from './deep-merge'
55
import {

packages/tailwindcss/src/compat/config/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Plugin, PluginWithConfig } from '../../plugin-api'
1+
import type { Plugin, PluginWithConfig } from '../plugin-api'
22
import type { PluginUtils } from './resolve-config'
33

44
export type ResolvableTo<T> = T | ((utils: PluginUtils) => T)

packages/tailwindcss/src/compat/dark-mode.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { PluginAPI } from '../plugin-api'
21
import type { ResolvedConfig } from './config/types'
2+
import type { PluginAPI } from './plugin-api'
33

44
export function darkModePlugin({ addVariant, config }: PluginAPI) {
55
let darkMode = config('darkMode', null) as ResolvedConfig['darkMode']

packages/tailwindcss/src/plugin-api.test.ts renamed to packages/tailwindcss/src/compat/plugin-api.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { describe, expect, test, vi } from 'vitest'
2-
import { compile } from '.'
3-
import defaultTheme from './compat/default-theme'
4-
import plugin from './plugin'
2+
import { compile } from '..'
3+
import plugin from '../plugin'
4+
import { optimizeCss } from '../test-utils/run'
5+
import defaultTheme from './default-theme'
56
import type { CssInJs, PluginAPI } from './plugin-api'
6-
import { optimizeCss } from './test-utils/run'
77

88
const css = String.raw
99

0 commit comments

Comments
 (0)