Support complex addUtilities() configs (#15029)

This PR adds support for complex `addUtilities()` configuration objects
that use child combinators and other features.

For example, in v3 it was possible to add a utility that changes the
behavior of all children of the utility class node by doing something
like this:

```ts
addUtilities({
  '.red-children > *': {
    color: 'red',
  },
});
```

This is a pattern that was used by first-party plugins like
`@tailwindcss/aspect-ratio` but that we never made working in v4, since
it requires parsing the selector and properly extracting all utility
candidates.

While working on the codemod that can transform `@layer utilities`
scoped declarations like the above, we found out a pretty neat
heuristics on how to migrate these cases. We're basically finding all
class selectors and replace them with `&`. Then we create a nested CSS
structure like this:

```css
.red-children {
  & > * {
    color: red;
  }
}
```

Due to first party support for nesting, this works as expected in v4.

## Test Plan

We added unit tests to ensure the rewriting works in some edge cases.
Furthermore we added an integration test running the
`@tailwindcss/aspect-ratio` plugin. We've also installed the tarballs in
the Remix example from the
[playgrounds](https://github.com/philipp-spiess/tailwindcss-playgrounds)
and ensure we can use the `@tailwindcss/aspect-ratio` plugin just like
we could in v3:
 
<img width="2560" alt="Screenshot 2024-11-18 at 13 44 52"
src="https://github.com/user-attachments/assets/31889131-fad0-4c37-b574-cfac2b99f786">

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
Co-authored-by: Jordan Pittman <jordan@cryptica.me>
This commit is contained in:
Philipp Spiess 2024-11-19 15:52:06 +01:00 • committed by GitHub
parent 93f9c99027
commit ab9e2b716b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 811 additions and 31 deletions

View file

@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Reintroduce `max-w-screen-*` utilities that read from the `--breakpoint` namespace as deprecated utilities ([#15013](https://github.com/tailwindlabs/tailwindcss/pull/15013))
- Support using CSS variables as arbitrary values without `var(…)` by using parentheses instead of square brackets (e.g. `bg-(--my-color)`) ([#15020](https://github.com/tailwindlabs/tailwindcss/pull/15020))
- Add new `in-*` variant ([#15025](https://github.com/tailwindlabs/tailwindcss/pull/15025))
- Allow `addUtilities()` and `addComponents()` to work with child combinators and other complex selectors ([#15029](https://github.com/tailwindlabs/tailwindcss/pull/15029))
- _Upgrade (experimental)_: Migrate `[&>*]` to the `*` variant ([#15022](https://github.com/tailwindlabs/tailwindcss/pull/15022))
- _Upgrade (experimental)_: Migrate `[&_*]` to the `**` variant ([#15022](https://github.com/tailwindlabs/tailwindcss/pull/15022))

View file

@ -122,6 +122,46 @@ test(
},
)
test(
'builds the `@tailwindcss/aspect-ratio` plugin utilities',
{
fs: {
'package.json': json`
{
"dependencies": {
"@tailwindcss/aspect-ratio": "^0.4.2",
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'index.html': html`
<div class="aspect-w-16 aspect-h-9">
<iframe
src="https://www.youtube.com/embed/dQw4w9WgXcQ"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
</div>
`,
'src/index.css': css`
@import 'tailwindcss';
@plugin '@tailwindcss/aspect-ratio';
`,
},
},
async ({ fs, exec }) => {
await exec('pnpm tailwindcss --input src/index.css --output dist/out.css')
await fs.expectFileToContain('dist/out.css', [
//
candidate`aspect-w-16`,
candidate`aspect-h-9`,
])
},
)
test(
'builds the `tailwindcss-animate` plugin utilities',
{

View file

@ -2760,7 +2760,7 @@ describe('addUtilities()', () => {
base,
module: ({ addUtilities }: PluginAPI) => {
addUtilities({
'.text-trim > *': {
':hover > *': {
'text-box-trim': 'both',
'text-box-edge': 'cap alphabetic',
},
@ -2842,18 +2842,171 @@ describe('addUtilities()', () => {
},
)
expect(optimizeCss(compiled.build(['form-input', 'lg:form-textarea'])).trim())
.toMatchInlineSnapshot(`
".form-input, .form-input::placeholder {
expect(compiled.build(['form-input', 'lg:form-textarea']).trim()).toMatchInlineSnapshot(`
".form-input {
background-color: red;
&::placeholder {
background-color: red;
}
}
.lg\\:form-textarea {
@media (width >= 1024px) {
.lg\\:form-textarea:hover:focus {
&:hover:focus {
background-color: red;
}
}"
`)
}
}"
`)
})
test('nests complex utility names', async () => {
let compiled = await compile(
css`
@plugin "my-plugin";
@layer utilities {
@tailwind utilities;
}
`,
{
async loadModule(id, base) {
return {
base,
module: ({ addUtilities }: PluginAPI) => {
addUtilities({
'.a .b:hover .c': {
color: 'red',
},
'.d > *': {
color: 'red',
},
'.e .bar:not(.f):has(.g)': {
color: 'red',
},
'.h~.i': {
color: 'red',
},
'.j.j': {
color: 'red',
},
})
},
}
},
},
)
expect(
compiled.build(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']).trim(),
).toMatchInlineSnapshot(
`
"@layer utilities {
.a {
& .b:hover .c {
color: red;
}
}
.b {
.a &:hover .c {
color: red;
}
}
.c {
.a .b:hover & {
color: red;
}
}
.d {
& > * {
color: red;
}
}
.e {
& .bar:not(.f):has(.g) {
color: red;
}
}
.g {
.e .bar:not(.f):has(&) {
color: red;
}
}
.h {
&~.i {
color: red;
}
}
.i {
.h~& {
color: red;
}
}
.j {
&.j {
color: red;
}
.j& {
color: red;
}
}
}"
`,
)
})
test('prefixes nested class names with the configured theme prefix', async () => {
let compiled = await compile(
css`
@plugin "my-plugin";
@layer utilities {
@tailwind utilities;
}
@theme prefix(tw) {
}
`,
{
async loadModule(id, base) {
return {
base,
module: ({ addUtilities }: PluginAPI) => {
addUtilities({
'.a .b:hover .c.d': {
color: 'red',
},
})
},
}
},
},
)
expect(compiled.build(['tw:a', 'tw:b', 'tw:c', 'tw:d']).trim()).toMatchInlineSnapshot(
`
"@layer utilities {
.tw\\:a {
& .tw\\:b:hover .tw\\:c.tw\\:d {
color: red;
}
}
.tw\\:b {
.tw\\:a &:hover .tw\\:c.tw\\:d {
color: red;
}
}
.tw\\:c {
.tw\\:a .tw\\:b:hover &.tw\\:d {
color: red;
}
}
.tw\\:d {
.tw\\:a .tw\\:b:hover .tw\\:c& {
color: red;
}
}
}
:root {
}"
`,
)
})
})

View file

@ -1,16 +1,18 @@
import { substituteAtApply } from '../apply'
import { atRule, decl, rule, type AstNode } from '../ast'
import { atRule, decl, rule, walk, type AstNode } from '../ast'
import type { Candidate, CandidateModifier, NamedUtilityValue } from '../candidate'
import { substituteFunctions } from '../css-functions'
import * as CSS from '../css-parser'
import type { DesignSystem } from '../design-system'
import { withAlpha } from '../utilities'
import { DefaultMap } from '../utils/default-map'
import { inferDataType } from '../utils/infer-data-type'
import { segment } from '../utils/segment'
import { toKeyPath } from '../utils/to-key-path'
import { compoundsForSelectors, substituteAtSlot } from '../variants'
import type { ResolvedConfig, UserConfig } from './config/types'
import { createThemeFn } from './plugin-functions'
import * as SelectorParser from './selector-parser'
export type Config = UserConfig
export type PluginFn = (api: PluginAPI) => void
@ -198,40 +200,68 @@ export function buildPluginApi(
)
// Merge entries for the same class
let utils: Record<string, CssInJs[]> = {}
let utils = new DefaultMap<string, AstNode[]>(() => [])
for (let [name, css] of entries) {
let [className, ...parts] = segment(name, ':')
// Modify classes using pseudo-classes or pseudo-elements to use nested rules
if (parts.length > 0) {
let pseudos = parts.map((p) => `:${p.trim()}`).join('')
css = {
[`&${pseudos}`]: css,
}
}
utils[className] ??= []
css = Array.isArray(css) ? css : [css]
utils[className].push(...css)
}
for (let [name, css] of Object.entries(utils)) {
if (name.startsWith('@keyframes ')) {
ast.push(rule(name, objectToAst(css)))
continue
}
if (name[0] !== '.' || !IS_VALID_UTILITY_NAME.test(name.slice(1))) {
let selectorAst = SelectorParser.parse(name)
let foundValidUtility = false
SelectorParser.walk(selectorAst, (node) => {
if (
node.kind === 'selector' &&
node.value[0] === '.' &&
IS_VALID_UTILITY_NAME.test(node.value.slice(1))
) {
let value = node.value
node.value = '&'
let selector = SelectorParser.toCss(selectorAst)
let className = value.slice(1)
let contents = selector === '&' ? objectToAst(css) : [rule(selector, objectToAst(css))]
utils.get(className).push(...contents)
foundValidUtility = true
node.value = value
return
}
if (node.kind === 'function' && node.value === ':not') {
return SelectorParser.SelectorWalkAction.Skip
}
})
if (!foundValidUtility) {
throw new Error(
`\`addUtilities({ '${name}' : … })\` defines an invalid utility selector. Utilities must be a single class name and start with a lowercase letter, eg. \`.scrollbar-none\`.`,
)
}
}
designSystem.utilities.static(name.slice(1), () => {
let ast = objectToAst(css)
substituteAtApply(ast, designSystem)
return ast
for (let [className, ast] of utils) {
// Prefix all class selector with the configured theme prefix
if (designSystem.theme.prefix) {
walk(ast, (node) => {
if (node.kind === 'rule') {
let selectorAst = SelectorParser.parse(node.selector)
SelectorParser.walk(selectorAst, (node) => {
if (node.kind === 'selector' && node.value[0] === '.') {
node.value = `.${designSystem.theme.prefix}\\:${node.value.slice(1)}`
}
})
node.selector = SelectorParser.toCss(selectorAst)
}
})
}
designSystem.utilities.static(className, () => {
let clonedAst = structuredClone(ast)
substituteAtApply(clonedAst, designSystem)
return clonedAst
})
}
},

View file

@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest'
import { parse, toCss, walk } from './selector-parser'
describe('parse', () => {
it('should parse a simple selector', () => {
expect(parse('.foo')).toEqual([{ kind: 'selector', value: '.foo' }])
})
it('should parse a compound selector', () => {
expect(parse('.foo.bar:hover#id')).toEqual([
{ kind: 'selector', value: '.foo' },
{ kind: 'selector', value: '.bar' },
{ kind: 'selector', value: ':hover' },
{ kind: 'selector', value: '#id' },
])
})
it('should parse a selector list', () => {
expect(parse('.foo,.bar')).toEqual([
{ kind: 'selector', value: '.foo' },
{ kind: 'separator', value: ',' },
{ kind: 'selector', value: '.bar' },
])
})
it('should combine everything within attribute selectors', () => {
expect(parse('.foo[bar="baz"]')).toEqual([
{ kind: 'selector', value: '.foo' },
{ kind: 'selector', value: '[bar="baz"]' },
])
})
it('should parse functions', () => {
expect(parse('.foo:hover:not(.bar:focus)')).toEqual([
{ kind: 'selector', value: '.foo' },
{ kind: 'selector', value: ':hover' },
{
kind: 'function',
nodes: [
{
kind: 'selector',
value: '.bar',
},
{
kind: 'selector',
value: ':focus',
},
],
value: ':not',
},
])
})
it('should handle next-children combinator', () => {
expect(parse('.foo + p')).toEqual([
{ kind: 'selector', value: '.foo' },
{ kind: 'combinator', value: ' + ' },
{ kind: 'selector', value: 'p' },
])
})
it('should handle escaped characters', () => {
expect(parse('foo\\.bar')).toEqual([{ kind: 'selector', value: 'foo\\.bar' }])
})
it('parses :nth-child()', () => {
expect(parse(':nth-child(n+1)')).toEqual([
{
kind: 'function',
value: ':nth-child',
nodes: [
{
kind: 'value',
value: 'n+1',
},
],
},
])
})
})
describe('toCss', () => {
it('should print a simple selector', () => {
expect(toCss(parse('.foo'))).toBe('.foo')
})
it('should print a compound selector', () => {
expect(toCss(parse('.foo.bar:hover#id'))).toBe('.foo.bar:hover#id')
})
it('should print a selector list', () => {
expect(toCss(parse('.foo,.bar'))).toBe('.foo,.bar')
})
it('should print an attribute selectors', () => {
expect(toCss(parse('.foo[bar="baz"]'))).toBe('.foo[bar="baz"]')
})
it('should print a function', () => {
expect(toCss(parse('.foo:hover:not(.bar:focus)'))).toBe('.foo:hover:not(.bar:focus)')
})
it('should print escaped characters', () => {
expect(toCss(parse('foo\\.bar'))).toBe('foo\\.bar')
})
it('should print :nth-child()', () => {
expect(toCss(parse(':nth-child(n+1)'))).toBe(':nth-child(n+1)')
})
})
describe('walk', () => {
it('can be used to replace a function call', () => {
const ast = parse('.foo:hover:not(.bar:focus)')
walk(ast, (node, { replaceWith }) => {
if (node.kind === 'function' && node.value === ':not') {
replaceWith({ kind: 'selector', value: '.inverted-bar' })
}
})
expect(toCss(ast)).toBe('.foo:hover.inverted-bar')
})
})

View file

@ -0,0 +1,434 @@
export type SelectorCombinatorNode = {
kind: 'combinator'
value: string
}
export type SelectorFunctionNode = {
kind: 'function'
value: string
nodes: SelectorAstNode[]
}
export type SelectorNode = {
kind: 'selector'
value: string
}
export type SelectorValueNode = {
kind: 'value'
value: string
}
export type SelectorSeparatorNode = {
kind: 'separator'
value: string
}
export type SelectorAstNode =
| SelectorCombinatorNode
| SelectorFunctionNode
| SelectorNode
| SelectorSeparatorNode
| SelectorValueNode
type SelectorParentNode = SelectorFunctionNode | null
function combinator(value: string): SelectorCombinatorNode {
return {
kind: 'combinator',
value,
}
}
function fun(value: string, nodes: SelectorAstNode[]): SelectorFunctionNode {
return {
kind: 'function',
value: value,
nodes,
}
}
function selector(value: string): SelectorNode {
return {
kind: 'selector',
value,
}
}
function separator(value: string): SelectorSeparatorNode {
return {
kind: 'separator',
value,
}
}
function value(value: string): SelectorValueNode {
return {
kind: 'value',
value,
}
}
export enum SelectorWalkAction {
/** Continue walking, which is the default */
Continue,
/** Skip visiting the children of this node */
Skip,
/** Stop the walk entirely */
Stop,
}
export function walk(
ast: SelectorAstNode[],
visit: (
node: SelectorAstNode,
utils: {
parent: SelectorParentNode
replaceWith(newNode: SelectorAstNode | SelectorAstNode[]): void
},
) => void | SelectorWalkAction,
parent: SelectorParentNode = null,
) {
for (let i = 0; i < ast.length; i++) {
let node = ast[i]
let status =
visit(node, {
parent,
replaceWith(newNode) {
ast.splice(i, 1, ...(Array.isArray(newNode) ? newNode : [newNode]))
// We want to visit the newly replaced node(s), which start at the
// current index (i). By decrementing the index here, the next loop
// will process this position (containing the replaced node) again.
i--
},
}) ?? SelectorWalkAction.Continue
// Stop the walk entirely
if (status === SelectorWalkAction.Stop) return
// Skip visiting the children of this node
if (status === SelectorWalkAction.Skip) continue
if (node.kind === 'function') {
walk(node.nodes, visit, node)
}
}
}
export function toCss(ast: SelectorAstNode[]) {
let css = ''
for (const node of ast) {
switch (node.kind) {
case 'combinator':
case 'selector':
case 'separator':
case 'value': {
css += node.value
break
}
case 'function': {
css += node.value + '(' + toCss(node.nodes) + ')'
}
}
}
return css
}
const BACKSLASH = 0x5c
const CLOSE_BRACKET = 0x5d
const CLOSE_PAREN = 0x29
const COLON = 0x3a
const COMMA = 0x2c
const DOUBLE_QUOTE = 0x22
const FULL_STOP = 0x2e
const GREATER_THAN = 0x3e
const NEWLINE = 0x0a
const NUMBER_SIGN = 0x23
const OPEN_BRACKET = 0x5b
const OPEN_PAREN = 0x28
const PLUS = 0x2b
const SINGLE_QUOTE = 0x27
const SPACE = 0x20
const TAB = 0x09
const TILDE = 0x7e
export function parse(input: string) {
input = input.replaceAll('\r\n', '\n')
let ast: SelectorAstNode[] = []
let stack: (SelectorFunctionNode | null)[] = []
let parent = null as SelectorFunctionNode | null
let buffer = ''
let peekChar
for (let i = 0; i < input.length; i++) {
let currentChar = input.charCodeAt(i)
switch (currentChar) {
// E.g.:
//
// ```css
// .foo .bar
// ^
//
// .foo > .bar
// ^^^
// ```
case COMMA:
case GREATER_THAN:
case NEWLINE:
case SPACE:
case PLUS:
case TAB:
case TILDE: {
// 1. Handle everything before the combinator as a selector
if (buffer.length > 0) {
let node = selector(buffer)
if (parent) {
parent.nodes.push(node)
} else {
ast.push(node)
}
buffer = ''
}
// 2. Look ahead and find the end of the combinator
let start = i
let end = i + 1
for (; end < input.length; end++) {
peekChar = input.charCodeAt(end)
if (
peekChar !== COMMA &&
peekChar !== GREATER_THAN &&
peekChar !== NEWLINE &&
peekChar !== SPACE &&
peekChar !== PLUS &&
peekChar !== TAB &&
peekChar !== TILDE
) {
break
}
}
i = end - 1
let contents = input.slice(start, end)
let node = contents.trim() === ',' ? separator(contents) : combinator(contents)
if (parent) {
parent.nodes.push(node)
} else {
ast.push(node)
}
break
}
// Start of a function call.
//
// E.g.:
//
// ```css
// .foo:not(.bar)
// ^
// ```
case OPEN_PAREN: {
let node = fun(buffer, [])
buffer = ''
// If the function is not one of the following, we combine all it's
// contents into a single value node
if (
node.value !== ':not' &&
node.value !== ':where' &&
node.value !== ':has' &&
node.value !== ':is'
) {
// Find the end of the function call
let start = i + 1
let nesting = 0
// Find the closing bracket.
for (let j = i + 1; j < input.length; j++) {
peekChar = input.charCodeAt(j)
if (peekChar === OPEN_PAREN) {
nesting++
continue
}
if (peekChar === CLOSE_PAREN) {
if (nesting === 0) {
i = j
break
}
nesting--
}
}
let end = i
node.nodes.push(value(input.slice(start, end)))
buffer = ''
i = end
ast.push(node)
break
}
if (parent) {
parent.nodes.push(node)
} else {
ast.push(node)
}
stack.push(node)
parent = node
break
}
// End of a function call.
//
// E.g.:
//
// ```css
// foo(bar, baz)
// ^
// ```
case CLOSE_PAREN: {
let tail = stack.pop()
// Handle everything before the closing paren a selector
if (buffer.length > 0) {
let node = selector(buffer)
tail!.nodes.push(node)
buffer = ''
}
if (stack.length > 0) {
parent = stack[stack.length - 1]
} else {
parent = null
}
break
}
// Split compound selectors.
//
// E.g.:
//
// ```css
// .foo.bar
// ^
// ```
case FULL_STOP:
case COLON:
case NUMBER_SIGN: {
// Handle everything before the combinator as a selector and
// start a new selector
if (buffer.length > 0) {
let node = selector(buffer)
if (parent) {
parent.nodes.push(node)
} else {
ast.push(node)
}
}
buffer = String.fromCharCode(currentChar)
break
}
// Start of an attribute selector.
case OPEN_BRACKET: {
// Handle everything before the combinator as a selector
if (buffer.length > 0) {
let node = selector(buffer)
if (parent) {
parent.nodes.push(node)
} else {
ast.push(node)
}
}
buffer = ''
let start = i
let nesting = 0
// Find the closing bracket.
for (let j = i + 1; j < input.length; j++) {
peekChar = input.charCodeAt(j)
if (peekChar === OPEN_BRACKET) {
nesting++
continue
}
if (peekChar === CLOSE_BRACKET) {
if (nesting === 0) {
i = j
break
}
nesting--
}
}
// Adjust `buffer` to include the string.
buffer += input.slice(start, i + 1)
break
}
// Start of a string.
case SINGLE_QUOTE:
case DOUBLE_QUOTE: {
let start = i
// We need to ensure that the closing quote is the same as the opening
// quote.
//
// E.g.:
//
// ```css
// "This is a string with a 'quote' in it"
// ^ ^ -> These are not the end of the string.
// ```
for (let j = i + 1; j < input.length; j++) {
peekChar = input.charCodeAt(j)
// Current character is a `\` therefore the next character is escaped.
if (peekChar === BACKSLASH) {
j += 1
}
// End of the string.
else if (peekChar === currentChar) {
i = j
break
}
}
// Adjust `buffer` to include the string.
buffer += input.slice(start, i + 1)
break
}
// Escaped characters.
case BACKSLASH: {
let nextChar = input.charCodeAt(i + 1)
buffer += String.fromCharCode(currentChar) + String.fromCharCode(nextChar)
i += 1
break
}
// Everything else will be collected in the buffer
default: {
buffer += String.fromCharCode(currentChar)
}
}
}
// Collect the remainder as a word
if (buffer.length > 0) {
ast.push(selector(buffer))
}
return ast
}