diff --git a/CHANGELOG.md b/CHANGELOG.md index 204dccc5d7d1..38324dbd1726 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/integrations/cli/plugins.test.ts b/integrations/cli/plugins.test.ts index 8d889b7b9a62..833b0f6779a6 100644 --- a/integrations/cli/plugins.test.ts +++ b/integrations/cli/plugins.test.ts @@ -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` +
+ +
+ `, + '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', { diff --git a/packages/tailwindcss/src/compat/plugin-api.test.ts b/packages/tailwindcss/src/compat/plugin-api.test.ts index 6aabab529865..a74e6cdc7c3f 100644 --- a/packages/tailwindcss/src/compat/plugin-api.test.ts +++ b/packages/tailwindcss/src/compat/plugin-api.test.ts @@ -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 { + }" + `, + ) }) }) diff --git a/packages/tailwindcss/src/compat/plugin-api.ts b/packages/tailwindcss/src/compat/plugin-api.ts index 9afb93140092..9e85001a8e8d 100644 --- a/packages/tailwindcss/src/compat/plugin-api.ts +++ b/packages/tailwindcss/src/compat/plugin-api.ts @@ -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 = {} + let utils = new DefaultMap(() => []) 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\`.`, ) } + } + + 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(name.slice(1), () => { - let ast = objectToAst(css) - substituteAtApply(ast, designSystem) - return ast + designSystem.utilities.static(className, () => { + let clonedAst = structuredClone(ast) + substituteAtApply(clonedAst, designSystem) + return clonedAst }) } }, diff --git a/packages/tailwindcss/src/compat/selector-parser.test.ts b/packages/tailwindcss/src/compat/selector-parser.test.ts new file mode 100644 index 000000000000..1c0b808b08d1 --- /dev/null +++ b/packages/tailwindcss/src/compat/selector-parser.test.ts @@ -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') + }) +}) diff --git a/packages/tailwindcss/src/compat/selector-parser.ts b/packages/tailwindcss/src/compat/selector-parser.ts new file mode 100644 index 000000000000..f2ff408d9294 --- /dev/null +++ b/packages/tailwindcss/src/compat/selector-parser.ts @@ -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 +}