Skip to content

Commit f06472e

Browse files
thecrypticaceadamwathanRobinMalfait
authored
Add matchUtilities and addUtilities APIs (#14114)
This PR introduces support for the v3-like `addUtilities` and `matchUtilities` APIs in v4. We anticipate designing a new API that feels more native to the way v4 works before shipping v4.0 stable, but we're continuing to support these APIs for backwards compatibility. We've tried to make the behavior as identical as possible, but because of fundamental differences between the v3 and v4 engines there are a few things that work differently: ## Only simple single-class selectors are supported In v3 you could pass a complex CSS selector to `addUtilities` and we would generate a utility for every class in the selector. In v4 we only allow you to use a simple, single-class selector. You should use nesting if you need a more complex selector, or need to include at-rules like `@media` or `@supports`. ```js // v3 function ({ addUtilities }) { addUtilities({ '.scrollbar-none::-webkit-scrollbar': { display: 'none', }, }) } // v4 function ({ addUtilities }) { addUtilities({ '.scrollbar-none': { '&::-webkit-scrollbar': { display: 'none', }, }, }) } ``` If you were adding custom utilities that included two classes and were depending on both of those classes behaving like utilities (they could each be used with variants), those custom utilities will need to be rewritten as two separate utilities that each use nesting: ```js // v3 function ({ addUtilities }) { addUtilities({ '.section > .row': { color: 'red', }, }) } // v4 function ({ addUtilities }) { addUtilities({ '.section': { '& > .row': { color: 'red', }, }, '.row': { 'section > &': { color: 'red', }, }, }) } ``` We may introduce support for this in the future if this limitation turns out to be a huge pain in the ass, but crossing our fingers that people were mostly doing simple stuff here. ## Opacity modifiers support bare values To be consistent with how built-in utilities work in v4, custom utilities that specify `type: "color"` now get "bare value" support for opacity modifiers. This means that a utility like `foo-black/33` will work out of the box without having to either add `--opacity-33` to your theme nor would you need to add it to the `modifiers` option. ## The `preferOnConflict` type option is gone In v3 we introduced an internal API called `preferOnConflict` for types. This was used as a way to disambiguate between two utilities with the same "root" but which produced different properties which used the same CSS data types. This was only applicable to arbitrary values and was only used for disambiguating between `background-position` and `background-size`. In v4, both of these properties are handled by a single plugin meaning this feature is no longer necessary. No one should've really been using this option anyway as it was never documented so we're dropping the feature. ## The options `respectPrefix` and `respectImportant` are not yet supported Neither the `prefix` nor `important` features exist in any form in v4 at this time. Therefore, they are not currently supported by this PR. We will look into supporting them if/when those features return. ## The `theme(…)` function is not currently supported Custom utilities defined using `matchUtilities` often use the `theme(…)` function to define their default values, but we haven't implemented support for `theme(…)` yet in v4. This means that as of this PR, default values for custom utilities must be hardcoded: ```js function ({ matchUtilities }) { matchUtilities({ 'tab': (value) => { return { 'tab-size': value, } }, }, { values: { 2: '2', 4: '4', 8: '8', }, }) } ``` Getting `theme(…)` working is a big project so we're going to tackle it in a separate PR. --------- Co-authored-by: Adam Wathan <[email protected]> Co-authored-by: Robin Malfait <[email protected]>
1 parent d223112 commit f06472e

File tree

8 files changed

+1141
-166
lines changed

8 files changed

+1141
-166
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- Add `inert` variant ([#14129](https://github.com/tailwindlabs/tailwindcss/pull/14129))
1414
- Add support for explicitly registering content paths using new `@source` at-rule ([#14078](https://github.com/tailwindlabs/tailwindcss/pull/14078))
1515
- Add support for scanning `<style>` tags in Vue files to the Vite plugin ([#14158](https://github.com/tailwindlabs/tailwindcss/pull/14158))
16+
- Add support for basic `addUtilities` and `matchUtilities` plugins using the `@plugin` directive ([#14114](https://github.com/tailwindlabs/tailwindcss/pull/14114))
1617

1718
### Fixed
1819

packages/tailwindcss/src/apply.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { walk, type AstNode } from './ast'
2+
import { compileCandidates } from './compile'
3+
import type { DesignSystem } from './design-system'
4+
import { escape } from './utils/escape'
5+
6+
export function substituteAtApply(ast: AstNode[], designSystem: DesignSystem) {
7+
walk(ast, (node, { replaceWith }) => {
8+
if (node.kind !== 'rule') return
9+
if (!(node.selector[0] === '@' && node.selector.startsWith('@apply '))) return
10+
11+
let candidates = node.selector
12+
.slice(7 /* Ignore `@apply ` when parsing the selector */)
13+
.trim()
14+
.split(/\s+/g)
15+
16+
// Replace the `@apply` rule with the actual utility classes
17+
{
18+
// Parse the candidates to an AST that we can replace the `@apply` rule
19+
// with.
20+
let candidateAst = compileCandidates(candidates, designSystem, {
21+
onInvalidCandidate: (candidate) => {
22+
throw new Error(`Cannot apply unknown utility class: ${candidate}`)
23+
},
24+
}).astNodes
25+
26+
// Collect the nodes to insert in place of the `@apply` rule. When a rule
27+
// was used, we want to insert its children instead of the rule because we
28+
// don't want the wrapping selector.
29+
let newNodes: AstNode[] = []
30+
for (let candidateNode of candidateAst) {
31+
if (candidateNode.kind === 'rule' && candidateNode.selector[0] !== '@') {
32+
for (let child of candidateNode.nodes) {
33+
newNodes.push(child)
34+
}
35+
} else {
36+
newNodes.push(candidateNode)
37+
}
38+
}
39+
40+
// Verify that we don't have any circular dependencies by verifying that
41+
// the current node does not appear in the new nodes.
42+
walk(newNodes, (child) => {
43+
if (child !== node) return
44+
45+
// At this point we already know that we have a circular dependency.
46+
//
47+
// Figure out which candidate caused the circular dependency. This will
48+
// help to create a useful error message for the end user.
49+
for (let candidate of candidates) {
50+
let selector = `.${escape(candidate)}`
51+
52+
for (let rule of candidateAst) {
53+
if (rule.kind !== 'rule') continue
54+
if (rule.selector !== selector) continue
55+
56+
walk(rule.nodes, (child) => {
57+
if (child !== node) return
58+
59+
throw new Error(
60+
`You cannot \`@apply\` the \`${candidate}\` utility here because it creates a circular dependency.`,
61+
)
62+
})
63+
}
64+
}
65+
})
66+
67+
replaceWith(newNodes)
68+
}
69+
})
70+
}

packages/tailwindcss/src/ast.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ export function objectToAst(obj: CssInJs): AstNode[] {
5252
if (!name.startsWith('--') && value === '@slot') {
5353
ast.push(rule(name, [rule('@slot', [])]))
5454
} else {
55+
// Convert camelCase to kebab-case:
56+
// https://github.com/postcss/postcss-js/blob/b3db658b932b42f6ac14ca0b1d50f50c4569805b/parser.js#L30-L35
57+
name = name.replace(/([A-Z])/g, '-$1').toLowerCase()
58+
5559
ast.push(decl(name, value))
5660
}
5761
} else {

packages/tailwindcss/src/compile.ts

Lines changed: 34 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { WalkAction, rule, walk, type AstNode, type Rule } from './ast'
1+
import { WalkAction, decl, rule, walk, type AstNode, type Rule } from './ast'
22
import { type Candidate, type Variant } from './candidate'
33
import { type DesignSystem } from './design-system'
44
import GLOBAL_PROPERTY_ORDER from './property-order'
5+
import { asColor } from './utilities'
56
import { compare } from './utils/compare'
67
import { escape } from './utils/escape'
78
import type { Variants } from './variants'
@@ -102,39 +103,9 @@ export function compileAstNodes(rawCandidate: string, designSystem: DesignSystem
102103
let candidate = designSystem.parseCandidate(rawCandidate)
103104
if (candidate === null) return null
104105

105-
let nodes: AstNode[] = []
106+
let nodes = compileBaseUtility(candidate, designSystem)
106107

107-
// Handle arbitrary properties
108-
if (candidate.kind === 'arbitrary') {
109-
let compileFn = designSystem.utilities.getArbitrary()
110-
111-
// Build the node
112-
let compiledNodes = compileFn(candidate)
113-
if (compiledNodes === undefined) return null
114-
115-
nodes = compiledNodes
116-
}
117-
118-
// Handle named utilities
119-
else if (candidate.kind === 'static' || candidate.kind === 'functional') {
120-
let fns = designSystem.utilities.get(candidate.root)
121-
122-
// Build the node
123-
let compiledNodes: AstNode[] | undefined
124-
125-
for (let i = fns.length - 1; i >= 0; i--) {
126-
let fn = fns[i]
127-
128-
if (candidate.kind !== fn.kind) continue
129-
130-
compiledNodes = fn.compileFn(candidate)
131-
if (compiledNodes) break
132-
}
133-
134-
if (compiledNodes === undefined) return null
135-
136-
nodes = compiledNodes
137-
}
108+
if (!nodes) return null
138109

139110
let propertySort = getPropertySort(nodes)
140111

@@ -226,6 +197,36 @@ export function applyVariant(node: Rule, variant: Variant, variants: Variants):
226197
if (result === null) return null
227198
}
228199

200+
function compileBaseUtility(candidate: Candidate, designSystem: DesignSystem) {
201+
if (candidate.kind === 'arbitrary') {
202+
let value: string | null = candidate.value
203+
204+
// Assumption: If an arbitrary property has a modifier, then we assume it
205+
// is an opacity modifier.
206+
if (candidate.modifier) {
207+
value = asColor(value, candidate.modifier, designSystem.theme)
208+
}
209+
210+
if (value === null) return
211+
212+
return [decl(candidate.property, value)]
213+
}
214+
215+
let utilities = designSystem.utilities.get(candidate.root) ?? []
216+
217+
for (let i = utilities.length - 1; i >= 0; i--) {
218+
let utility = utilities[i]
219+
220+
if (candidate.kind !== utility.kind) continue
221+
222+
let compiledNodes = utility.compileFn(candidate)
223+
if (compiledNodes === null) return null
224+
if (compiledNodes) return compiledNodes
225+
}
226+
227+
return null
228+
}
229+
229230
function applyImportant(ast: AstNode[]): void {
230231
for (let node of ast) {
231232
// Skip any `@at-root` rules — we don't want to make the contents of things

packages/tailwindcss/src/index.ts

Lines changed: 5 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,15 @@
11
import { version } from '../package.json'
2-
import {
3-
WalkAction,
4-
comment,
5-
decl,
6-
objectToAst,
7-
rule,
8-
toCss,
9-
walk,
10-
type AstNode,
11-
type CssInJs,
12-
type Rule,
13-
} from './ast'
2+
import { substituteAtApply } from './apply'
3+
import { WalkAction, comment, decl, rule, toCss, walk, type Rule } from './ast'
144
import { compileCandidates } from './compile'
155
import * as CSS from './css-parser'
166
import { buildDesignSystem, type DesignSystem } from './design-system'
7+
import { buildPluginApi, type PluginAPI } from './plugin-api'
178
import { Theme } from './theme'
18-
import { escape } from './utils/escape'
199
import { segment } from './utils/segment'
2010

2111
const IS_VALID_UTILITY_NAME = /^[a-z][a-zA-Z0-9/%._-]*$/
2212

23-
type PluginAPI = {
24-
addVariant(name: string, variant: string | string[] | CssInJs): void
25-
}
26-
2713
type Plugin = (api: PluginAPI) => void
2814

2915
type CompileOptions = {
@@ -311,30 +297,9 @@ export async function compile(
311297
customUtility(designSystem)
312298
}
313299

314-
let api: PluginAPI = {
315-
addVariant(name, variant) {
316-
// Single selector
317-
if (typeof variant === 'string') {
318-
designSystem.variants.static(name, (r) => {
319-
r.nodes = [rule(variant, r.nodes)]
320-
})
321-
}
322-
323-
// Multiple parallel selectors
324-
else if (Array.isArray(variant)) {
325-
designSystem.variants.static(name, (r) => {
326-
r.nodes = variant.map((selector) => rule(selector, r.nodes))
327-
})
328-
}
329-
330-
// CSS-in-JS object
331-
else if (typeof variant === 'object') {
332-
designSystem.variants.fromAst(name, objectToAst(variant))
333-
}
334-
},
335-
}
300+
let pluginApi = buildPluginApi(designSystem)
336301

337-
await Promise.all(pluginLoaders.map((loader) => loader.then((plugin) => plugin(api))))
302+
await Promise.all(pluginLoaders.map((loader) => loader.then((plugin) => plugin(pluginApi))))
338303

339304
let tailwindUtilitiesNode: Rule | null = null
340305

@@ -420,72 +385,6 @@ export async function compile(
420385
}
421386
}
422387

423-
function substituteAtApply(ast: AstNode[], designSystem: DesignSystem) {
424-
walk(ast, (node, { replaceWith }) => {
425-
if (node.kind !== 'rule') return
426-
if (!(node.selector[0] === '@' && node.selector.startsWith('@apply '))) return
427-
428-
let candidates = node.selector
429-
.slice(7 /* Ignore `@apply ` when parsing the selector */)
430-
.trim()
431-
.split(/\s+/g)
432-
433-
// Replace the `@apply` rule with the actual utility classes
434-
{
435-
// Parse the candidates to an AST that we can replace the `@apply` rule
436-
// with.
437-
let candidateAst = compileCandidates(candidates, designSystem, {
438-
onInvalidCandidate: (candidate) => {
439-
throw new Error(`Cannot apply unknown utility class: ${candidate}`)
440-
},
441-
}).astNodes
442-
443-
// Collect the nodes to insert in place of the `@apply` rule. When a rule
444-
// was used, we want to insert its children instead of the rule because we
445-
// don't want the wrapping selector.
446-
let newNodes: AstNode[] = []
447-
for (let candidateNode of candidateAst) {
448-
if (candidateNode.kind === 'rule' && candidateNode.selector[0] !== '@') {
449-
for (let child of candidateNode.nodes) {
450-
newNodes.push(child)
451-
}
452-
} else {
453-
newNodes.push(candidateNode)
454-
}
455-
}
456-
457-
// Verify that we don't have any circular dependencies by verifying that
458-
// the current node does not appear in the new nodes.
459-
walk(newNodes, (child) => {
460-
if (child !== node) return
461-
462-
// At this point we already know that we have a circular dependency.
463-
//
464-
// Figure out which candidate caused the circular dependency. This will
465-
// help to create a useful error message for the end user.
466-
for (let candidate of candidates) {
467-
let selector = `.${escape(candidate)}`
468-
469-
for (let rule of candidateAst) {
470-
if (rule.kind !== 'rule') continue
471-
if (rule.selector !== selector) continue
472-
473-
walk(rule.nodes, (child) => {
474-
if (child !== node) return
475-
476-
throw new Error(
477-
`You cannot \`@apply\` the \`${candidate}\` utility here because it creates a circular dependency.`,
478-
)
479-
})
480-
}
481-
}
482-
})
483-
484-
replaceWith(newNodes)
485-
}
486-
})
487-
}
488-
489388
export function __unstable__loadDesignSystem(css: string) {
490389
// Find all `@theme` declarations
491390
let theme = new Theme()

0 commit comments

Comments
 (0)