From c439cdf43cee80e70bffdb5a7ecdd583472aa289 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 29 Oct 2024 01:17:25 +0100 Subject: [PATCH] Internal refactor, introduce `AtRule` (#14802) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR introduces an internal refactor where we introduce the `AtRule` CSS Node in our AST. The motivation for this is that in a lot of places we need to differentiate between a `Rule` and an `AtRule`. We often do this with code that looks like this: ```ts rule.selector[0] === '@' && rule.selector.startsWith('@media') ``` Another issue we have is that we often need to check for `'@media '` including the space, because we don't want to match `@mediafoobar` if somebody has this in their CSS. Alternatively, if you CSS is minified then it could be that you have a rule that looks like `@media(width>=100px)`, in this case we _also_ have to check for `@media(`. Here is a snippet of code that we have in our codebase: ```ts // Find at-rules rules if (node.kind === 'rule') { if ( node.selector[0] === '@' && (node.selector.startsWith('@media ') || node.selector.startsWith('@media(') || node.selector.startsWith('@custom-media ') || node.selector.startsWith('@custom-media(') || node.selector.startsWith('@container ') || node.selector.startsWith('@container(') || node.selector.startsWith('@supports ') || node.selector.startsWith('@supports(')) && node.selector.includes(THEME_FUNCTION_INVOCATION) ) { node.selector = substituteFunctionsInValue(node.selector, resolveThemeValue) } } ``` Which will now be replaced with a much simpler version: ```ts // Find at-rules rules if (node.kind === 'at-rule') { if ( (node.name === '@media' || node.name === '@custom-media' || node.name === '@container' || node.name === '@supports') && node.params.includes(THEME_FUNCTION_INVOCATION) ) { node.params = substituteFunctionsInValue(node.params, resolveThemeValue) } } ``` Checking for all the cases from the first snippet is not the end of the world, but it is error prone. It's easy to miss a case. A direct comparison is also faster than comparing via the `startsWith(…)` function. --- Note: this is only a refactor without changing other code _unless_ it was required to make the tests pass. The tests themselves are all passing and none of them changed (because the behavior should be the same). The one exception is the tests where we check the parsed AST, which now includes `at-rule` nodes instead of `rule` nodes when we have an at-rule. --- .../src/template/codemods/variant-order.ts | 4 +- packages/@tailwindcss-upgrade/tsconfig.json | 4 +- packages/tailwindcss/src/apply.ts | 19 +- packages/tailwindcss/src/ast.test.ts | 8 +- packages/tailwindcss/src/ast.ts | 67 +++++-- packages/tailwindcss/src/at-import.ts | 18 +- .../src/compat/apply-compat-hooks.ts | 18 +- .../compat/apply-keyframes-to-theme.test.ts | 14 +- .../src/compat/apply-keyframes-to-theme.ts | 8 +- packages/tailwindcss/src/compat/plugin-api.ts | 6 +- .../tailwindcss/src/compat/screens-config.ts | 4 +- packages/tailwindcss/src/compile.ts | 25 ++- packages/tailwindcss/src/css-functions.ts | 19 +- packages/tailwindcss/src/css-parser.test.ts | 58 +++--- packages/tailwindcss/src/css-parser.ts | 58 +++++- packages/tailwindcss/src/index.ts | 82 ++++----- packages/tailwindcss/src/intellisense.ts | 24 ++- packages/tailwindcss/src/theme.ts | 6 +- packages/tailwindcss/src/utilities.ts | 26 +-- packages/tailwindcss/src/variants.ts | 171 +++++++++--------- 20 files changed, 363 insertions(+), 276 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/variant-order.ts b/packages/@tailwindcss-upgrade/src/template/codemods/variant-order.ts index 5234208a5..8ee45d157 100644 --- a/packages/@tailwindcss-upgrade/src/template/codemods/variant-order.ts +++ b/packages/@tailwindcss-upgrade/src/template/codemods/variant-order.ts @@ -57,7 +57,7 @@ function isAtRuleVariant(designSystem: DesignSystem, variant: Variant) { return true } let stack = getAppliedNodeStack(designSystem, variant) - return stack.every((node) => node.kind === 'rule' && node.selector[0] === '@') + return stack.every((node) => node.kind === 'at-rule') } function isCombinatorVariant(designSystem: DesignSystem, variant: Variant) { @@ -65,8 +65,6 @@ function isCombinatorVariant(designSystem: DesignSystem, variant: Variant) { return stack.some( (node) => node.kind === 'rule' && - // Ignore at-rules as they are hoisted - node.selector[0] !== '@' && // Combinators include any of the following characters (node.selector.includes(' ') || node.selector.includes('>') || diff --git a/packages/@tailwindcss-upgrade/tsconfig.json b/packages/@tailwindcss-upgrade/tsconfig.json index b7ac105af..c4a3e047a 100644 --- a/packages/@tailwindcss-upgrade/tsconfig.json +++ b/packages/@tailwindcss-upgrade/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "../tsconfig.base.json", "compilerOptions": { - "allowSyntheticDefaultImports":true - } + "allowSyntheticDefaultImports": true, + }, } diff --git a/packages/tailwindcss/src/apply.ts b/packages/tailwindcss/src/apply.ts index 66a97ab40..90e7478c2 100644 --- a/packages/tailwindcss/src/apply.ts +++ b/packages/tailwindcss/src/apply.ts @@ -5,28 +5,21 @@ import { escape } from './utils/escape' export function substituteAtApply(ast: AstNode[], designSystem: DesignSystem) { walk(ast, (node, { replaceWith }) => { - if (node.kind !== 'rule') return + if (node.kind !== 'at-rule') return // Do not allow `@apply` rules inside `@keyframes` rules. - if (node.selector[0] === '@' && node.selector.startsWith('@keyframes')) { + if (node.name === '@keyframes') { walk(node.nodes, (child) => { - if ( - child.kind === 'rule' && - child.selector[0] === '@' && - child.selector.startsWith('@apply ') - ) { + if (child.kind === 'at-rule' && child.name === '@apply') { throw new Error(`You cannot use \`@apply\` inside \`@keyframes\`.`) } }) return WalkAction.Skip } - if (!(node.selector[0] === '@' && node.selector.startsWith('@apply '))) return + if (node.name !== '@apply') return - let candidates = node.selector - .slice(7 /* Ignore `@apply ` when parsing the selector */) - .trim() - .split(/\s+/g) + let candidates = node.params.split(/\s+/g) // Replace the `@apply` rule with the actual utility classes { @@ -43,7 +36,7 @@ export function substituteAtApply(ast: AstNode[], designSystem: DesignSystem) { // don't want the wrapping selector. let newNodes: AstNode[] = [] for (let candidateNode of candidateAst) { - if (candidateNode.kind === 'rule' && candidateNode.selector[0] !== '@') { + if (candidateNode.kind === 'rule') { for (let child of candidateNode.nodes) { newNodes.push(child) } diff --git a/packages/tailwindcss/src/ast.test.ts b/packages/tailwindcss/src/ast.test.ts index 21915cba4..1c1f162c9 100644 --- a/packages/tailwindcss/src/ast.test.ts +++ b/packages/tailwindcss/src/ast.test.ts @@ -1,5 +1,5 @@ import { expect, it } from 'vitest' -import { context, decl, rule, toCss, walk } from './ast' +import { context, decl, styleRule, toCss, walk } from './ast' import * as CSS from './css-parser' it('should pretty print an AST', () => { @@ -16,13 +16,13 @@ it('should pretty print an AST', () => { it('allows the placement of context nodes', () => { const ast = [ - rule('.foo', [decl('color', 'red')]), + styleRule('.foo', [decl('color', 'red')]), context({ context: 'a' }, [ - rule('.bar', [ + styleRule('.bar', [ decl('color', 'blue'), context({ context: 'b' }, [ // - rule('.baz', [decl('color', 'green')]), + styleRule('.baz', [decl('color', 'green')]), ]), ]), ]), diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts index 2ab5a0046..ee79365b9 100644 --- a/packages/tailwindcss/src/ast.ts +++ b/packages/tailwindcss/src/ast.ts @@ -1,9 +1,20 @@ -export type Rule = { +import { parseAtRule } from './css-parser' + +const AT_SIGN = 0x40 + +export type StyleRule = { kind: 'rule' selector: string nodes: AstNode[] } +export type AtRule = { + kind: 'at-rule' + name: string + params: string + nodes: AstNode[] +} + export type Declaration = { kind: 'declaration' property: string @@ -27,9 +38,10 @@ export type AtRoot = { nodes: AstNode[] } -export type AstNode = Rule | Declaration | Comment | Context | AtRoot +export type Rule = StyleRule | AtRule +export type AstNode = StyleRule | AtRule | Declaration | Comment | Context | AtRoot -export function rule(selector: string, nodes: AstNode[]): Rule { +export function styleRule(selector: string, nodes: AstNode[] = []): StyleRule { return { kind: 'rule', selector, @@ -37,6 +49,23 @@ export function rule(selector: string, nodes: AstNode[]): Rule { } } +export function atRule(name: string, params: string = '', nodes: AstNode[] = []): AtRule { + return { + kind: 'at-rule', + name, + params, + nodes, + } +} + +export function rule(selector: string, nodes: AstNode[] = []): StyleRule | AtRule { + if (selector.charCodeAt(0) === AT_SIGN) { + return parseAtRule(selector, nodes) + } + + return styleRule(selector, nodes) +} + export function decl(property: string, value: string | undefined): Declaration { return { kind: 'declaration', @@ -126,7 +155,7 @@ export function walk( // Skip visiting the children of this node if (status === WalkAction.Skip) continue - if (node.kind === 'rule') { + if (node.kind === 'rule' || node.kind === 'at-rule') { walk(node.nodes, visit, path, context) } } @@ -152,7 +181,7 @@ export function walkDepth( let path = [...parentPath, node] let parent = parentPath.at(-1) ?? null - if (node.kind === 'rule') { + if (node.kind === 'rule' || node.kind === 'at-rule') { walkDepth(node.nodes, visit, path, context) } else if (node.kind === 'context') { walkDepth(node.nodes, visit, parentPath, { ...context, ...node.context }) @@ -185,7 +214,16 @@ export function toCss(ast: AstNode[]) { // Rule if (node.kind === 'rule') { - if (node.selector === '@tailwind utilities') { + css += `${indent}${node.selector} {\n` + for (let child of node.nodes) { + css += stringify(child, depth + 1) + } + css += `${indent}}\n` + } + + // AtRule + else if (node.kind === 'at-rule') { + if (node.name === '@tailwind' && node.params === 'utilities') { for (let child of node.nodes) { css += stringify(child, depth) } @@ -199,20 +237,21 @@ export function toCss(ast: AstNode[]) { // ```css // @layer base, components, utilities; // ``` - if (node.selector[0] === '@' && node.nodes.length === 0) { - return `${indent}${node.selector};\n` + else if (node.nodes.length === 0) { + return `${indent}${node.name} ${node.params};\n` } - if (node.selector[0] === '@' && node.selector.startsWith('@property ') && depth === 0) { + // + else if (node.name === '@property' && depth === 0) { // Don't output duplicate `@property` rules - if (seenAtProperties.has(node.selector)) { + if (seenAtProperties.has(node.params)) { return '' } // Collect fallbacks for `@property` rules for Firefox support // We turn these into rules on `:root` or `*` and some pseudo-elements // based on the value of `inherits`` - let property = node.selector.replace(/@property\s*/g, '') + let property = node.params let initialValue = null let inherits = false @@ -231,10 +270,10 @@ export function toCss(ast: AstNode[]) { propertyFallbacksUniversal.push(decl(property, initialValue ?? 'initial')) } - seenAtProperties.add(node.selector) + seenAtProperties.add(node.params) } - css += `${indent}${node.selector} {\n` + css += `${indent}${node.name}${node.params ? ` ${node.params} ` : ' '}{\n` for (let child of node.nodes) { css += stringify(child, depth + 1) } @@ -292,7 +331,7 @@ export function toCss(ast: AstNode[]) { if (fallbackAst.length) { fallback = stringify( - rule('@supports (-moz-orient: inline)', [rule('@layer base', fallbackAst)]), + atRule('@supports', '(-moz-orient: inline)', [atRule('@layer', 'base', fallbackAst)]), ) } diff --git a/packages/tailwindcss/src/at-import.ts b/packages/tailwindcss/src/at-import.ts index 29c0ce2c5..705441912 100644 --- a/packages/tailwindcss/src/at-import.ts +++ b/packages/tailwindcss/src/at-import.ts @@ -1,4 +1,4 @@ -import { context, rule, walk, WalkAction, type AstNode } from './ast' +import { atRule, context, walk, WalkAction, type AstNode } from './ast' import * as CSS from './css-parser' import * as ValueParser from './value-parser' @@ -13,15 +13,9 @@ export async function substituteAtImports( let promises: Promise[] = [] walk(ast, (node, { replaceWith }) => { - if ( - node.kind === 'rule' && - node.selector[0] === '@' && - node.selector.toLowerCase().startsWith('@import ') - ) { + if (node.kind === 'at-rule' && node.name === '@import') { try { - let { uri, layer, media, supports } = parseImportParams( - ValueParser.parse(node.selector.slice(8)), - ) + let { uri, layer, media, supports } = parseImportParams(ValueParser.parse(node.params)) // Skip importing data or remote URIs if (uri.startsWith('data:')) return @@ -132,15 +126,15 @@ function buildImportNodes( let root = importedAst if (layer !== null) { - root = [rule('@layer ' + layer, root)] + root = [atRule('@layer', layer, root)] } if (media !== null) { - root = [rule('@media ' + media, root)] + root = [atRule('@media', media, root)] } if (supports !== null) { - root = [rule(`@supports ${supports[0] === '(' ? supports : `(${supports})`}`, root)] + root = [atRule('@supports', supports[0] === '(' ? supports : `(${supports})`, root)] } return root diff --git a/packages/tailwindcss/src/compat/apply-compat-hooks.ts b/packages/tailwindcss/src/compat/apply-compat-hooks.ts index 75b4dbafd..c4cfec81c 100644 --- a/packages/tailwindcss/src/compat/apply-compat-hooks.ts +++ b/packages/tailwindcss/src/compat/apply-compat-hooks.ts @@ -1,4 +1,4 @@ -import { rule, toCss, walk, WalkAction, type AstNode } from '../ast' +import { styleRule, toCss, walk, WalkAction, type AstNode } from '../ast' import type { DesignSystem } from '../design-system' import { segment } from '../utils/segment' import { applyConfigToTheme } from './apply-config-to-theme' @@ -34,15 +34,15 @@ export async function applyCompatibilityHooks({ let configPaths: { id: string; base: string }[] = [] walk(ast, (node, { parent, replaceWith, context }) => { - if (node.kind !== 'rule' || node.selector[0] !== '@') return + if (node.kind !== 'at-rule') return // Collect paths from `@plugin` at-rules - if (node.selector === '@plugin' || node.selector.startsWith('@plugin ')) { + if (node.name === '@plugin') { if (parent !== null) { throw new Error('`@plugin` cannot be nested.') } - let pluginPath = node.selector.slice(9, -1) + let pluginPath = node.params.slice(1, -1) if (pluginPath.length === 0) { throw new Error('`@plugin` must have a path.') } @@ -100,7 +100,7 @@ export async function applyCompatibilityHooks({ } // Collect paths from `@config` at-rules - if (node.selector === '@config' || node.selector.startsWith('@config ')) { + if (node.name === '@config') { if (node.nodes.length > 0) { throw new Error('`@config` cannot have a body.') } @@ -109,7 +109,7 @@ export async function applyCompatibilityHooks({ throw new Error('`@config` cannot be nested.') } - configPaths.push({ id: node.selector.slice(9, -1), base: context.base }) + configPaths.push({ id: node.params.slice(1, -1), base: context.base }) replaceWith([]) return } @@ -268,15 +268,15 @@ function upgradeToFullPluginSupport({ let wrappingSelector = resolvedConfig.important walk(ast, (node, { replaceWith, parent }) => { - if (node.kind !== 'rule') return - if (node.selector !== '@tailwind utilities') return + if (node.kind !== 'at-rule') return + if (node.name !== '@tailwind' || node.params !== 'utilities') return // The AST node was already manually wrapped so there's nothing to do if (parent?.kind === 'rule' && parent.selector === wrappingSelector) { return WalkAction.Stop } - replaceWith(rule(wrappingSelector, [node])) + replaceWith(styleRule(wrappingSelector, [node])) return WalkAction.Stop }) diff --git a/packages/tailwindcss/src/compat/apply-keyframes-to-theme.test.ts b/packages/tailwindcss/src/compat/apply-keyframes-to-theme.test.ts index 313d98c3c..a0b1c54d5 100644 --- a/packages/tailwindcss/src/compat/apply-keyframes-to-theme.test.ts +++ b/packages/tailwindcss/src/compat/apply-keyframes-to-theme.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest' -import { decl, rule, toCss } from '../ast' +import { atRule, decl, styleRule, toCss } from '../ast' import { buildDesignSystem } from '../design-system' import { Theme } from '../theme' import { applyKeyframesToTheme } from './apply-keyframes-to-theme' @@ -58,15 +58,15 @@ test('will append to the default keyframes with new keyframes', () => { let design = buildDesignSystem(theme) theme.addKeyframes( - rule('@keyframes slide-in', [ - rule('from', [decl('opacity', 'translateX(0%)')]), - rule('to', [decl('opacity', 'translateX(100%)')]), + atRule('@keyframes', 'slide-in', [ + styleRule('from', [decl('opacity', 'translateX(0%)')]), + styleRule('to', [decl('opacity', 'translateX(100%)')]), ]), ) theme.addKeyframes( - rule('@keyframes slide-out', [ - rule('from', [decl('opacity', 'translateX(100%)')]), - rule('to', [decl('opacity', 'translateX(0%)')]), + atRule('@keyframes', 'slide-out', [ + styleRule('from', [decl('opacity', 'translateX(100%)')]), + styleRule('to', [decl('opacity', 'translateX(0%)')]), ]), ) diff --git a/packages/tailwindcss/src/compat/apply-keyframes-to-theme.ts b/packages/tailwindcss/src/compat/apply-keyframes-to-theme.ts index 7f853a0d4..a58ca13df 100644 --- a/packages/tailwindcss/src/compat/apply-keyframes-to-theme.ts +++ b/packages/tailwindcss/src/compat/apply-keyframes-to-theme.ts @@ -1,4 +1,4 @@ -import { rule, type Rule } from '../ast' +import { atRule, type AtRule } from '../ast' import type { DesignSystem } from '../design-system' import type { ResolvedConfig } from './config/types' import { objectToAst } from './plugin-api' @@ -13,11 +13,11 @@ export function applyKeyframesToTheme( } } -export function keyframesToRules(resolvedConfig: Pick): Rule[] { - let rules: Rule[] = [] +export function keyframesToRules(resolvedConfig: Pick): AtRule[] { + let rules: AtRule[] = [] if ('keyframes' in resolvedConfig.theme) { for (let [name, keyframe] of Object.entries(resolvedConfig.theme.keyframes)) { - rules.push(rule(`@keyframes ${name}`, objectToAst(keyframe as any))) + rules.push(atRule('@keyframes', name, objectToAst(keyframe as any))) } } return rules diff --git a/packages/tailwindcss/src/compat/plugin-api.ts b/packages/tailwindcss/src/compat/plugin-api.ts index a03b05a13..fd3782241 100644 --- a/packages/tailwindcss/src/compat/plugin-api.ts +++ b/packages/tailwindcss/src/compat/plugin-api.ts @@ -1,5 +1,5 @@ import { substituteAtApply } from '../apply' -import { decl, rule, type AstNode } from '../ast' +import { atRule, decl, rule, type AstNode } from '../ast' import type { Candidate, CandidateModifier, NamedUtilityValue } from '../candidate' import { substituteFunctions } from '../css-functions' import * as CSS from '../css-parser' @@ -86,7 +86,7 @@ export function buildPluginApi( addBase(css) { let baseNodes = objectToAst(css) substituteFunctions(baseNodes, api.theme) - ast.push(rule('@layer base', baseNodes)) + ast.push(atRule('@layer', 'base', baseNodes)) }, addVariant(name, variant) { @@ -434,7 +434,7 @@ export function objectToAst(rules: CssInJs | CssInJs[]): AstNode[] { for (let [name, value] of entries) { if (typeof value !== 'object') { if (!name.startsWith('--') && value === '@slot') { - ast.push(rule(name, [rule('@slot', [])])) + ast.push(rule(name, [atRule('@slot')])) } else { // Convert camelCase to kebab-case: // https://github.com/postcss/postcss-js/blob/b3db658b932b42f6ac14ca0b1d50f50c4569805b/parser.js#L30-L35 diff --git a/packages/tailwindcss/src/compat/screens-config.ts b/packages/tailwindcss/src/compat/screens-config.ts index 04fdaa7d1..8d5c65136 100644 --- a/packages/tailwindcss/src/compat/screens-config.ts +++ b/packages/tailwindcss/src/compat/screens-config.ts @@ -1,4 +1,4 @@ -import { rule } from '../ast' +import { atRule } from '../ast' import type { DesignSystem } from '../design-system' import type { ResolvedConfig } from './config/types' @@ -45,7 +45,7 @@ export function registerScreensConfig(userConfig: ResolvedConfig, designSystem: designSystem.variants.static( name, (ruleNode) => { - ruleNode.nodes = [rule(`@media ${query}`, ruleNode.nodes)] + ruleNode.nodes = [atRule('@media', query, ruleNode.nodes)] }, { order }, ) diff --git a/packages/tailwindcss/src/compile.ts b/packages/tailwindcss/src/compile.ts index 87b5133da..655259bf8 100644 --- a/packages/tailwindcss/src/compile.ts +++ b/packages/tailwindcss/src/compile.ts @@ -1,4 +1,13 @@ -import { decl, rule, walk, WalkAction, type AstNode, type Rule } from './ast' +import { + atRule, + decl, + rule, + walk, + WalkAction, + type AstNode, + type Rule, + type StyleRule, +} from './ast' import { type Candidate, type Variant } from './candidate' import { substituteFunctions } from './css-functions' import { type DesignSystem } from './design-system' @@ -144,7 +153,7 @@ export function compileAstNodes(candidate: Candidate, designSystem: DesignSystem applyImportant(nodes) } - let node: Rule = { + let node: StyleRule = { kind: 'rule', selector, nodes, @@ -205,7 +214,7 @@ export function applyVariant( // To solve this, we provide an isolated placeholder node to the variant. // The variant can now apply its logic to the isolated node without // affecting the original node. - let isolatedNode = rule('@slot', []) + let isolatedNode = atRule('@slot') let result = applyVariant(isolatedNode, variant.variant, variants, depth + 1) if (result === null) return null @@ -218,16 +227,16 @@ export function applyVariant( // This means `child` may be a declaration and we don't want to apply the // variant to it. This also means the entire variant as a whole is not // applicable to the rule and should generate nothing. - if (child.kind !== 'rule') return null + if (child.kind !== 'rule' && child.kind !== 'at-rule') return null - let result = applyFn(child as Rule, variant) + let result = applyFn(child, variant) if (result === null) return null } // Replace the placeholder node with the actual node { walk(isolatedNode.nodes, (child) => { - if (child.kind === 'rule' && child.nodes.length <= 0) { + if ((child.kind === 'rule' || child.kind === 'at-rule') && child.nodes.length <= 0) { child.nodes = node.nodes return WalkAction.Skip } @@ -301,7 +310,7 @@ function applyImportant(ast: AstNode[]): void { if (node.kind === 'declaration') { node.important = true - } else if (node.kind === 'rule') { + } else if (node.kind === 'rule' || node.kind === 'at-rule') { applyImportant(node.nodes) } } @@ -327,7 +336,7 @@ function getPropertySort(nodes: AstNode[]) { let idx = GLOBAL_PROPERTY_ORDER.indexOf(node.property) if (idx !== -1) propertySort.add(idx) - } else if (node.kind === 'rule') { + } else if (node.kind === 'rule' || node.kind === 'at-rule') { for (let child of node.nodes) { q.push(child) } diff --git a/packages/tailwindcss/src/css-functions.ts b/packages/tailwindcss/src/css-functions.ts index 80cbd8fbe..7b51f2569 100644 --- a/packages/tailwindcss/src/css-functions.ts +++ b/packages/tailwindcss/src/css-functions.ts @@ -15,20 +15,15 @@ export function substituteFunctions(ast: AstNode[], resolveThemeValue: ResolveTh } // Find at-rules rules - if (node.kind === 'rule') { + if (node.kind === 'at-rule') { if ( - node.selector[0] === '@' && - (node.selector.startsWith('@media ') || - node.selector.startsWith('@media(') || - node.selector.startsWith('@custom-media ') || - node.selector.startsWith('@custom-media(') || - node.selector.startsWith('@container ') || - node.selector.startsWith('@container(') || - node.selector.startsWith('@supports ') || - node.selector.startsWith('@supports(')) && - node.selector.includes(THEME_FUNCTION_INVOCATION) + (node.name === '@media' || + node.name === '@custom-media' || + node.name === '@container' || + node.name === '@supports') && + node.params.includes(THEME_FUNCTION_INVOCATION) ) { - node.selector = substituteFunctionsInValue(node.selector, resolveThemeValue) + node.params = substituteFunctionsInValue(node.params, resolveThemeValue) } } }) diff --git a/packages/tailwindcss/src/css-parser.test.ts b/packages/tailwindcss/src/css-parser.test.ts index fa3f122ce..0da36c40a 100644 --- a/packages/tailwindcss/src/css-parser.test.ts +++ b/packages/tailwindcss/src/css-parser.test.ts @@ -630,7 +630,7 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { parse(css` @charset "UTF-8"; `), - ).toEqual([{ kind: 'rule', selector: '@charset "UTF-8"', nodes: [] }]) + ).toEqual([{ kind: 'at-rule', name: '@charset', params: '"UTF-8"', nodes: [] }]) }) it('should parse an at-rule without a block or semicolon', () => { @@ -638,7 +638,7 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { parse(` @tailwind utilities `), - ).toEqual([{ kind: 'rule', selector: '@tailwind utilities', nodes: [] }]) + ).toEqual([{ kind: 'at-rule', name: '@tailwind', params: 'utilities', nodes: [] }]) }) it("should parse an at-rule without a block or semicolon when it's the last rule in a block", () => { @@ -650,9 +650,10 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { `), ).toEqual([ { - kind: 'rule', - selector: '@layer utilities', - nodes: [{ kind: 'rule', selector: '@tailwind utilities', nodes: [] }], + kind: 'at-rule', + name: '@layer', + params: 'utilities', + nodes: [{ kind: 'at-rule', name: '@tailwind', params: 'utilities', nodes: [] }], }, ]) }) @@ -670,14 +671,17 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { `), ).toEqual([ { - kind: 'rule', - selector: '@layer utilities', - nodes: [{ kind: 'rule', selector: '@charset "UTF-8"', nodes: [] }], + kind: 'at-rule', + name: '@layer', + params: 'utilities', + nodes: [{ kind: 'at-rule', name: '@charset', params: '"UTF-8"', nodes: [] }], }, { kind: 'rule', selector: '.foo', - nodes: [{ kind: 'rule', selector: '@apply font-bold hover:text-red-500', nodes: [] }], + nodes: [ + { kind: 'at-rule', name: '@apply', params: 'font-bold hover:text-red-500', nodes: [] }, + ], }, ]) }) @@ -689,8 +693,8 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { @tailwind base; `), ).toEqual([ - { kind: 'rule', selector: '@tailwind', nodes: [] }, - { kind: 'rule', selector: '@tailwind base', nodes: [] }, + { kind: 'at-rule', name: '@tailwind', params: '', nodes: [] }, + { kind: 'at-rule', name: '@tailwind', params: 'base', nodes: [] }, ]) }) @@ -711,8 +715,9 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { `), ).toEqual([ { - kind: 'rule', - selector: '@media (width >= 600px)', + kind: 'at-rule', + name: '@media', + params: '(width >= 600px)', nodes: [ { kind: 'rule', @@ -720,15 +725,17 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { nodes: [ { kind: 'declaration', property: 'color', value: 'red', important: false }, { - kind: 'rule', - selector: '@media (width >= 800px)', + kind: 'at-rule', + name: '@media', + params: '(width >= 800px)', nodes: [ { kind: 'declaration', property: 'color', value: 'blue', important: false }, ], }, { - kind: 'rule', - selector: '@media (width >= 1000px)', + kind: 'at-rule', + name: '@media', + params: '(width >= 1000px)', nodes: [ { kind: 'declaration', property: 'color', value: 'green', important: false }, ], @@ -756,10 +763,11 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { kind: 'rule', nodes: [ { - kind: 'rule', + kind: 'at-rule', + name: '@apply', + params: + 'hover:text-red-100 sm:hover:text-red-200 md:hover:text-red-300 lg:hover:text-red-400 xl:hover:text-red-500', nodes: [], - selector: - '@apply hover:text-red-100 sm:hover:text-red-200 md:hover:text-red-300 lg:hover:text-red-400 xl:hover:text-red-500', }, ], selector: '.foo', @@ -923,8 +931,9 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { `), ).toEqual([ { - kind: 'rule', - selector: '@custom \\{', + kind: 'at-rule', + name: '@custom', + params: '\\{', nodes: [{ kind: 'declaration', property: 'foo', value: 'bar', important: false }], }, ]) @@ -940,8 +949,9 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { nodes: [ { kind: 'declaration', property: 'color', value: 'red', important: false }, { - kind: 'rule', - selector: '@media(width>=600px)', + kind: 'at-rule', + name: '@media', + params: '(width>=600px)', nodes: [ { kind: 'rule', diff --git a/packages/tailwindcss/src/css-parser.ts b/packages/tailwindcss/src/css-parser.ts index f5eb5979f..8fb4a546d 100644 --- a/packages/tailwindcss/src/css-parser.ts +++ b/packages/tailwindcss/src/css-parser.ts @@ -1,4 +1,13 @@ -import { comment, rule, type AstNode, type Comment, type Declaration, type Rule } from './ast' +import { + atRule, + comment, + rule, + type AstNode, + type AtRule, + type Comment, + type Declaration, + type Rule, +} from './ast' const BACKSLASH = 0x5c const SLASH = 0x2f @@ -294,7 +303,7 @@ export function parse(input: string) { // ^ // ``` else if (currentChar === SEMICOLON && buffer.charCodeAt(0) === AT_SIGN) { - node = rule(buffer, []) + node = parseAtRule(buffer) // At-rule is nested inside of a rule, attach it to the parent. if (parent) { @@ -338,7 +347,7 @@ export function parse(input: string) { closingBracketStack += '}' // At this point `buffer` should resemble a selector or an at-rule. - node = rule(buffer.trim(), []) + node = rule(buffer.trim()) // Attach the rule to the parent in case it's nested. if (parent) { @@ -381,7 +390,7 @@ export function parse(input: string) { // } // ``` if (buffer.charCodeAt(0) === AT_SIGN) { - node = rule(buffer.trim(), []) + node = parseAtRule(buffer) // At-rule is nested inside of a rule, attach it to the parent. if (parent) { @@ -464,14 +473,19 @@ export function parse(input: string) { // If we have a leftover `buffer` that happens to start with an `@` then it // means that we have an at-rule that is not terminated with a semicolon at // the end of the input. - if (buffer[0] === '@') { - ast.push(rule(buffer.trim(), [])) + if (buffer.charCodeAt(0) === AT_SIGN) { + ast.push(parseAtRule(buffer)) } // When we are done parsing then everything should be balanced. If we still // have a leftover `parent`, then it means that we have an unterminated block. if (closingBracketStack.length > 0 && parent) { - throw new Error(`Missing closing } at ${parent.selector}`) + if (parent.kind === 'rule') { + throw new Error(`Missing closing } at ${parent.selector}`) + } + if (parent.kind === 'at-rule') { + throw new Error(`Missing closing } at ${parent.name} ${parent.params}`) + } } if (licenseComments.length > 0) { @@ -481,6 +495,36 @@ export function parse(input: string) { return ast } +export function parseAtRule(buffer: string, nodes: AstNode[] = []): AtRule { + // Assumption: The smallest at-rule in CSS right now is `@page`, this means + // that we can always skip the first 5 characters and start at the + // sixth (at index 5). + // + // There is a chance someone is using a shorter at-rule, in that case we have + // to adjust this number back to 2, e.g.: `@x`. + // + // This issue can only occur if somebody does the following things: + // + // 1. Uses a shorter at-rule than `@page` + // 2. Disables Lightning CSS from `@tailwindcss/postcss` (because Lightning + // CSS doesn't handle custom at-rules properly right now) + // 3. Sandwiches the `@tailwindcss/postcss` plugin between two other plugins + // that can handle the shorter at-rule + // + // Let's use the more common case as the default and we can adjust this + // behavior if necessary. + for (let i = 5 /* '@page'.length */; i < buffer.length; i++) { + let currentChar = buffer.charCodeAt(i) + if (currentChar === SPACE || currentChar === OPEN_PAREN) { + let name = buffer.slice(0, i).trim() + let params = buffer.slice(i).trim() + return atRule(name, params, nodes) + } + } + + return atRule(buffer.trim(), '', nodes) +} + function parseDeclaration(buffer: string, colonIdx: number = buffer.indexOf(':')): Declaration { let importantIdx = buffer.indexOf('!important', colonIdx + 1) return { diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 743e713f2..31ec72c6c 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -2,15 +2,18 @@ import { version } from '../package.json' import { substituteAtApply } from './apply' import { atRoot, + atRule, comment, context, decl, rule, + styleRule, toCss, walk, WalkAction, type AstNode, - type Rule, + type AtRule, + type StyleRule, } from './ast' import { substituteAtImports } from './at-import' import { applyCompatibilityHooks } from './compat/apply-compat-hooks' @@ -46,11 +49,11 @@ function throwOnLoadStylesheet(): never { throw new Error('No `loadStylesheet` function provided to `compile`') } -function parseThemeOptions(selector: string) { +function parseThemeOptions(params: string) { let options = ThemeOptions.NONE let prefix = null - for (let option of segment(selector.slice(6) /* '@theme'.length */, ' ')) { + for (let option of segment(params, ' ')) { if (option === 'reference') { options |= ThemeOptions.REFERENCE } else if (option === 'inline') { @@ -81,21 +84,20 @@ async function parseCss( let theme = new Theme() let customVariants: ((designSystem: DesignSystem) => void)[] = [] let customUtilities: ((designSystem: DesignSystem) => void)[] = [] - let firstThemeRule: Rule | null = null + let firstThemeRule = null as StyleRule | null let globs: { base: string; pattern: string }[] = [] // Handle at-rules walk(ast, (node, { parent, replaceWith, context }) => { - if (node.kind !== 'rule') return - if (node.selector[0] !== '@') return + if (node.kind !== 'at-rule') return // Collect custom `@utility` at-rules - if (node.selector.startsWith('@utility ')) { + if (node.name === '@utility') { if (parent !== null) { throw new Error('`@utility` cannot be nested.') } - let name = node.selector.slice(9).trim() + let name = node.params if (!IS_VALID_UTILITY_NAME.test(name)) { throw new Error( @@ -120,7 +122,7 @@ async function parseCss( } // Collect paths from `@source` at-rules - if (node.selector.startsWith('@source ')) { + if (node.name === '@source') { if (node.nodes.length > 0) { throw new Error('`@source` cannot have a body.') } @@ -129,7 +131,7 @@ async function parseCss( throw new Error('`@source` cannot be nested.') } - let path = node.selector.slice(8) + let path = node.params if ( (path[0] === '"' && path[path.length - 1] !== '"') || (path[0] === "'" && path[path.length - 1] !== "'") || @@ -143,7 +145,7 @@ async function parseCss( } // Register custom variants from `@variant` at-rules - if (node.selector.startsWith('@variant ')) { + if (node.name === '@variant') { if (parent !== null) { throw new Error('`@variant` cannot be nested.') } @@ -151,7 +153,7 @@ async function parseCss( // Remove `@variant` at-rule so it's not included in the compiled CSS replaceWith([]) - let [name, selector] = segment(node.selector.slice(9), ' ') + let [name, selector] = segment(node.params, ' ') if (node.nodes.length > 0 && selector) { throw new Error(`\`@variant ${name}\` cannot have both a selector and a body.`) @@ -165,14 +167,14 @@ async function parseCss( let selectors = segment(selector.slice(1, -1), ',') - let atRuleSelectors: string[] = [] + let atRuleParams: string[] = [] let styleRuleSelectors: string[] = [] for (let selector of selectors) { selector = selector.trim() if (selector[0] === '@') { - atRuleSelectors.push(selector) + atRuleParams.push(selector) } else { styleRuleSelectors.push(selector) } @@ -185,17 +187,17 @@ async function parseCss( let nodes: AstNode[] = [] if (styleRuleSelectors.length > 0) { - nodes.push(rule(styleRuleSelectors.join(', '), r.nodes)) + nodes.push(styleRule(styleRuleSelectors.join(', '), r.nodes)) } - for (let selector of atRuleSelectors) { + for (let selector of atRuleParams) { nodes.push(rule(selector, r.nodes)) } r.nodes = nodes }, { - compounds: compoundsForSelectors([...styleRuleSelectors, ...atRuleSelectors]), + compounds: compoundsForSelectors([...styleRuleSelectors, ...atRuleParams]), }, ) }) @@ -227,8 +229,8 @@ async function parseCss( } } - if (node.selector.startsWith('@media ')) { - let params = segment(node.selector.slice(7), ' ') + if (node.name === '@media') { + let params = segment(node.params, ' ') let unknownParams: string[] = [] for (let param of params) { @@ -241,13 +243,13 @@ async function parseCss( let themeParams = param.slice(6, -1) walk(node.nodes, (child) => { - if (child.kind !== 'rule') { + if (child.kind !== 'at-rule') { throw new Error( 'Files imported with `@import "…" theme(…)` must only contain `@theme` blocks.', ) } - if (child.selector === '@theme' || child.selector.startsWith('@theme ')) { - child.selector += ' ' + themeParams + if (child.name === '@theme') { + child.params += ' ' + themeParams return WalkAction.Skip } }) @@ -261,9 +263,9 @@ async function parseCss( let prefix = param.slice(7, -1) walk(node.nodes, (child) => { - if (child.kind !== 'rule') return - if (child.selector === '@theme' || child.selector.startsWith('@theme ')) { - child.selector += ` prefix(${prefix})` + if (child.kind !== 'at-rule') return + if (child.name === '@theme') { + child.params += ` prefix(${prefix})` return WalkAction.Skip } }) @@ -281,7 +283,7 @@ async function parseCss( } if (unknownParams.length > 0) { - node.selector = `@media ${unknownParams.join(' ')}` + node.params = unknownParams.join(' ') } else if (params.length > 0) { replaceWith(node.nodes) } @@ -290,8 +292,8 @@ async function parseCss( } // Handle `@theme` - if (node.selector === '@theme' || node.selector.startsWith('@theme ')) { - let [themeOptions, themePrefix] = parseThemeOptions(node.selector) + if (node.name === '@theme') { + let [themeOptions, themePrefix] = parseThemeOptions(node.params) if (themePrefix) { if (!IS_VALID_PREFIX.test(themePrefix)) { @@ -307,7 +309,7 @@ async function parseCss( walk(node.nodes, (child, { replaceWith }) => { // Collect `@keyframes` rules to re-insert with theme variables later, // since the `@theme` rule itself will be removed. - if (child.kind === 'rule' && child.selector.startsWith('@keyframes ')) { + if (child.kind === 'at-rule' && child.name === '@keyframes') { theme.addKeyframes(child) replaceWith([]) return WalkAction.Skip @@ -319,7 +321,7 @@ async function parseCss( return } - let snippet = toCss([rule(node.selector, [child])]) + let snippet = toCss([atRule(node.name, node.params, [child])]) .split('\n') .map((line, idx, all) => `${idx === 0 || idx >= all.length - 2 ? ' ' : '>'} ${line}`) .join('\n') @@ -332,7 +334,8 @@ async function parseCss( // Keep a reference to the first `@theme` rule to update with the full // theme later, and delete any other `@theme` rules. if (!firstThemeRule && !(themeOptions & ThemeOptions.REFERENCE)) { - firstThemeRule = node + firstThemeRule = styleRule(':root', node.nodes) + replaceWith([firstThemeRule]) } else { replaceWith([]) } @@ -363,9 +366,6 @@ async function parseCss( // Output final set of theme variables at the position of the first `@theme` // rule. if (firstThemeRule) { - firstThemeRule = firstThemeRule as Rule - firstThemeRule.selector = ':root' - let nodes = [] for (let [key, value] of theme.entries()) { @@ -381,16 +381,14 @@ async function parseCss( for (let keyframesRule of keyframesRules) { // Remove any keyframes that aren't used by an animation variable. - let keyframesName = keyframesRule.selector.slice(11) // `@keyframes `.length + let keyframesName = keyframesRule.params if (!animationParts.includes(keyframesName)) { continue } // Wrap `@keyframes` in `AtRoot` so they are hoisted out of `:root` when // printing. - nodes.push( - Object.assign(keyframesRule, atRoot([rule(keyframesRule.selector, keyframesRule.nodes)])), - ) + nodes.push(atRoot([keyframesRule])) } } firstThemeRule.nodes = nodes @@ -404,9 +402,9 @@ async function parseCss( // Remove `@utility`, we couldn't replace it before yet because we had to // handle the nested `@apply` at-rules first. walk(ast, (node, { replaceWith }) => { - if (node.kind !== 'rule') return + if (node.kind !== 'at-rule') return - if (node.selector[0] === '@' && node.selector.startsWith('@utility ')) { + if (node.name === '@utility') { replaceWith([]) } @@ -431,12 +429,12 @@ export async function compile( }> { let { designSystem, ast, globs } = await parseCss(css, opts) - let tailwindUtilitiesNode: Rule | null = null + let tailwindUtilitiesNode: AtRule | null = null // Find `@tailwind utilities` so that we can later replace it with the actual // generated utility class CSS. walk(ast, (node) => { - if (node.kind === 'rule' && node.selector === '@tailwind utilities') { + if (node.kind === 'at-rule' && node.name === '@tailwind' && node.params === 'utilities') { tailwindUtilitiesNode = node // Stop walking after finding `@tailwind utilities` to avoid walking all diff --git a/packages/tailwindcss/src/intellisense.ts b/packages/tailwindcss/src/intellisense.ts index 815aa1cc0..5713afda4 100644 --- a/packages/tailwindcss/src/intellisense.ts +++ b/packages/tailwindcss/src/intellisense.ts @@ -1,4 +1,4 @@ -import { rule, walkDepth } from './ast' +import { styleRule, walkDepth } from './ast' import { applyVariant } from './compile' import type { DesignSystem } from './design-system' @@ -69,7 +69,7 @@ export function getVariants(design: DesignSystem) { if (!variant) return [] // Apply the variant to a placeholder rule - let node = rule('.__placeholder__', []) + let node = styleRule('.__placeholder__', []) // If the rule produces no nodes it means the variant does not apply if (applyVariant(node, variant, design.variants) === null) { @@ -82,16 +82,13 @@ export function getVariants(design: DesignSystem) { // Produce v3-style selector strings in the face of nested rules // this is more visible for things like group-*, not-*, etc… walkDepth(node.nodes, (node, { path }) => { - if (node.kind !== 'rule') return + if (node.kind !== 'rule' && node.kind !== 'at-rule') return if (node.nodes.length > 0) return // Sort at-rules before style rules path.sort((a, b) => { - // This won't actually happen, but it's here to make TypeScript happy - if (a.kind !== 'rule' || b.kind !== 'rule') return 0 - - let aIsAtRule = a.selector[0] === '@' - let bIsAtRule = b.selector[0] === '@' + let aIsAtRule = a.kind === 'at-rule' + let bIsAtRule = b.kind === 'at-rule' if (aIsAtRule && !bIsAtRule) return -1 if (!aIsAtRule && bIsAtRule) return 1 @@ -101,8 +98,15 @@ export function getVariants(design: DesignSystem) { // A list of the selectors / at rules encountered to get to this point let group = path.flatMap((node) => { - if (node.kind !== 'rule') return [] - return node.selector === '&' ? [] : [node.selector] + if (node.kind === 'rule') { + return node.selector === '&' ? [] : [node.selector] + } + + if (node.kind === 'at-rule') { + return [`${node.name} ${node.params}`] + } + + return [] }) // Build a v3-style nested selector diff --git a/packages/tailwindcss/src/theme.ts b/packages/tailwindcss/src/theme.ts index 6ba289f1a..883ca9b3f 100644 --- a/packages/tailwindcss/src/theme.ts +++ b/packages/tailwindcss/src/theme.ts @@ -1,4 +1,4 @@ -import type { Rule } from './ast' +import type { AtRule } from './ast' import { escape } from './utils/escape' export const enum ThemeOptions { @@ -13,7 +13,7 @@ export class Theme { constructor( private values = new Map(), - private keyframes = new Set([]), + private keyframes = new Set([]), ) {} add(key: string, value: string, options = ThemeOptions.NONE): void { @@ -204,7 +204,7 @@ export class Theme { return values } - addKeyframes(value: Rule): void { + addKeyframes(value: AtRule): void { this.keyframes.add(value) } diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index 17244e236..81ee0b71d 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -1,4 +1,4 @@ -import { atRoot, decl, rule, type AstNode } from './ast' +import { atRoot, atRule, decl, styleRule, type AstNode } from './ast' import type { Candidate, CandidateModifier, NamedUtilityValue } from './candidate' import type { Theme, ThemeKey } from './theme' import { DefaultMap } from './utils/default-map' @@ -85,7 +85,7 @@ export class Utilities { } function property(ident: string, initialValue?: string, syntax?: string) { - return rule(`@property ${ident}`, [ + return atRule('@property', ident, [ decl('syntax', syntax ? `"${syntax}"` : `"*"`), decl('inherits', 'false'), @@ -2024,7 +2024,7 @@ export function createUtilities(theme: Theme) { handle: (value) => [ atRoot([property('--tw-space-x-reverse', '0', '')]), - rule(':where(& > :not(:last-child))', [ + styleRule(':where(& > :not(:last-child))', [ decl('--tw-sort', 'row-gap'), decl('margin-inline-start', `calc(${value} * var(--tw-space-x-reverse))`), decl('margin-inline-end', `calc(${value} * calc(1 - var(--tw-space-x-reverse)))`), @@ -2038,7 +2038,7 @@ export function createUtilities(theme: Theme) { handle: (value) => [ atRoot([property('--tw-space-y-reverse', '0', '')]), - rule(':where(& > :not(:last-child))', [ + styleRule(':where(& > :not(:last-child))', [ decl('--tw-sort', 'column-gap'), decl('margin-block-start', `calc(${value} * var(--tw-space-y-reverse))`), decl('margin-block-end', `calc(${value} * calc(1 - var(--tw-space-y-reverse)))`), @@ -2049,7 +2049,7 @@ export function createUtilities(theme: Theme) { staticUtility('space-x-reverse', [ () => atRoot([property('--tw-space-x-reverse', '0', '')]), () => - rule(':where(& > :not(:last-child))', [ + styleRule(':where(& > :not(:last-child))', [ decl('--tw-sort', 'row-gap'), decl('--tw-space-x-reverse', '1'), ]), @@ -2058,7 +2058,7 @@ export function createUtilities(theme: Theme) { staticUtility('space-y-reverse', [ () => atRoot([property('--tw-space-y-reverse', '0', '')]), () => - rule(':where(& > :not(:last-child))', [ + styleRule(':where(& > :not(:last-child))', [ decl('--tw-sort', 'column-gap'), decl('--tw-space-y-reverse', '1'), ]), @@ -2078,7 +2078,7 @@ export function createUtilities(theme: Theme) { colorUtility('divide', { themeKeys: ['--divide-color', '--color'], handle: (value) => [ - rule(':where(& > :not(:last-child))', [ + styleRule(':where(& > :not(:last-child))', [ decl('--tw-sort', 'divide-color'), decl('border-color', value), ]), @@ -2386,7 +2386,7 @@ export function createUtilities(theme: Theme) { handle: (value) => [ atRoot([property('--tw-divide-x-reverse', '0', '')]), - rule(':where(& > :not(:last-child))', [ + styleRule(':where(& > :not(:last-child))', [ decl('--tw-sort', 'divide-x-width'), borderProperties(), decl('border-inline-style', 'var(--tw-border-style)'), @@ -2406,7 +2406,7 @@ export function createUtilities(theme: Theme) { handle: (value) => [ atRoot([property('--tw-divide-y-reverse', '0', '')]), - rule(':where(& > :not(:last-child))', [ + styleRule(':where(& > :not(:last-child))', [ decl('--tw-sort', 'divide-y-width'), borderProperties(), decl('border-bottom-style', 'var(--tw-border-style)'), @@ -2435,18 +2435,18 @@ export function createUtilities(theme: Theme) { staticUtility('divide-x-reverse', [ () => atRoot([property('--tw-divide-x-reverse', '0', '')]), - () => rule(':where(& > :not(:last-child))', [decl('--tw-divide-x-reverse', '1')]), + () => styleRule(':where(& > :not(:last-child))', [decl('--tw-divide-x-reverse', '1')]), ]) staticUtility('divide-y-reverse', [ () => atRoot([property('--tw-divide-y-reverse', '0', '')]), - () => rule(':where(& > :not(:last-child))', [decl('--tw-divide-y-reverse', '1')]), + () => styleRule(':where(& > :not(:last-child))', [decl('--tw-divide-y-reverse', '1')]), ]) for (let value of ['solid', 'dashed', 'dotted', 'double', 'none']) { staticUtility(`divide-${value}`, [ () => - rule(':where(& > :not(:last-child))', [ + styleRule(':where(& > :not(:last-child))', [ decl('--tw-sort', 'divide-style'), decl('--tw-border-style', value), decl('border-style', value), @@ -3150,7 +3150,7 @@ export function createUtilities(theme: Theme) { colorUtility('placeholder', { themeKeys: ['--background-color', '--color'], handle: (value) => [ - rule('&::placeholder', [decl('--tw-sort', 'placeholder-color'), decl('color', value)]), + styleRule('&::placeholder', [decl('--tw-sort', 'placeholder-color'), decl('color', value)]), ], }) diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index a51a06a38..cdebac3c1 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -1,4 +1,16 @@ -import { WalkAction, atRoot, decl, rule, walk, type AstNode, type Rule } from './ast' +import { + WalkAction, + atRoot, + atRule, + decl, + rule, + styleRule, + walk, + type AstNode, + type AtRule, + type Rule, + type StyleRule, +} from './ast' import { type Variant } from './candidate' import type { Theme } from './theme' import { DefaultMap } from './utils/default-map' @@ -69,9 +81,11 @@ export class Variants { let selectors: string[] = [] walk(ast, (node) => { - if (node.kind !== 'rule') return - if (node.selector === '@slot') return - selectors.push(node.selector) + if (node.kind === 'rule') { + selectors.push(node.selector) + } else if (node.kind === 'at-rule' && node.name !== '@slot') { + selectors.push(`${node.name} ${node.params}`) + } }) this.static( @@ -333,7 +347,7 @@ export function createVariants(theme: Theme): Variants { return parts.slice(1).join(' ') } - if (ruleName === 'container') { + if (ruleName === '@container') { // @container {query} if (parts[0][0] === '(') { return `not ${condition}` @@ -356,28 +370,25 @@ export function createVariants(theme: Theme): Variants { let conditionalRules = ['@media', '@supports', '@container'] - function negateSelector(selector: string) { - if (selector[0] === '@') { - for (let ruleName of conditionalRules) { - if (!selector.startsWith(ruleName)) continue + function negateAtRule(rule: AtRule) { + for (let ruleName of conditionalRules) { + if (ruleName !== rule.name) continue - let name = ruleName.slice(1) - let params = selector.slice(ruleName.length).trim() + let conditions = segment(rule.params, ',') - let conditions = segment(params, ',') + // We don't support things like `@media screen, print` because + // the negation would be `@media not screen and print` and we don't + // want to deal with that complexity. + if (conditions.length > 1) return null - // We don't support things like `@media screen, print` because - // the negation would be `@media not screen and print` and we don't - // want to deal with that complexity. - if (conditions.length > 1) return null - - conditions = negateConditions(name, conditions) - return `@${name} ${conditions.join(', ')}` - } - - return null + conditions = negateConditions(rule.name, conditions) + return atRule(rule.name, conditions.join(', ')) } + return null + } + + function negateSelector(selector: string) { if (selector.includes('::')) return null let selectors = segment(selector, ',').map((sel) => { @@ -404,18 +415,17 @@ export function createVariants(theme: Theme): Variants { let didApply = false walk([ruleNode], (node, { path }) => { - if (node.kind !== 'rule') return WalkAction.Continue + if (node.kind !== 'rule' && node.kind !== 'at-rule') return WalkAction.Continue if (node.nodes.length > 0) return WalkAction.Continue // Throw out any candidates with variants using nested style rules - let atRules: Rule[] = [] - let styleRules: Rule[] = [] + let atRules: AtRule[] = [] + let styleRules: StyleRule[] = [] for (let parent of path) { - if (parent.kind !== 'rule') continue - if (parent.selector[0] === '@') { + if (parent.kind === 'at-rule') { atRules.push(parent) - } else { + } else if (parent.kind === 'rule') { styleRules.push(parent) } } @@ -425,28 +435,27 @@ export function createVariants(theme: Theme): Variants { let rules: Rule[] = [] - for (let styleRule of styleRules) { - let selector = negateSelector(styleRule.selector) + for (let node of styleRules) { + let selector = negateSelector(node.selector) if (!selector) { didApply = false return WalkAction.Stop } - rules.push(rule(selector, [])) + rules.push(styleRule(selector, [])) } - for (let atRule of atRules) { - let selector = negateSelector(atRule.selector) - if (!selector) { + for (let node of atRules) { + let negatedAtRule = negateAtRule(node) + if (!negatedAtRule) { didApply = false return WalkAction.Stop } - rules.push(rule(selector, [])) + rules.push(negatedAtRule) } - ruleNode.selector = '&' - ruleNode.nodes = rules + Object.assign(ruleNode, styleRule('&', rules)) // Track that the variant was actually applied didApply = true @@ -455,9 +464,8 @@ export function createVariants(theme: Theme): Variants { }) // TODO: Tweak group, peer, has to ignore intermediate `&` selectors (maybe?) - if (ruleNode.selector === '&' && ruleNode.nodes.length === 1) { - ruleNode.selector = (ruleNode.nodes[0] as Rule).selector - ruleNode.nodes = (ruleNode.nodes[0] as Rule).nodes + if (ruleNode.kind === 'rule' && ruleNode.selector === '&' && ruleNode.nodes.length === 1) { + Object.assign(ruleNode, ruleNode.nodes[0]) } // If the node wasn't modified, this variant is not compatible with @@ -485,13 +493,9 @@ export function createVariants(theme: Theme): Variants { walk([ruleNode], (node, { path }) => { if (node.kind !== 'rule') return WalkAction.Continue - // Skip past at-rules, and continue traversing the children of the at-rule - if (node.selector[0] === '@') return WalkAction.Continue - // Throw out any candidates with variants using nested style rules for (let parent of path.slice(0, -1)) { if (parent.kind !== 'rule') continue - if (parent.selector[0] === '@') continue didApply = false return WalkAction.Stop @@ -541,13 +545,9 @@ export function createVariants(theme: Theme): Variants { walk([ruleNode], (node, { path }) => { if (node.kind !== 'rule') return WalkAction.Continue - // Skip past at-rules, and continue traversing the children of the at-rule - if (node.selector[0] === '@') return WalkAction.Continue - // Throw out any candidates with variants using nested style rules for (let parent of path.slice(0, -1)) { if (parent.kind !== 'rule') continue - if (parent.selector[0] === '@') continue didApply = false return WalkAction.Stop @@ -597,7 +597,7 @@ export function createVariants(theme: Theme): Variants { { function contentProperties() { return atRoot([ - rule('@property --tw-content', [ + atRule('@property', '--tw-content', [ decl('syntax', '"*"'), decl('initial-value', '""'), decl('inherits', 'false'), @@ -608,7 +608,7 @@ export function createVariants(theme: Theme): Variants { 'before', (v) => { v.nodes = [ - rule('&::before', [ + styleRule('&::before', [ contentProperties(), decl('content', 'var(--tw-content)'), ...v.nodes, @@ -622,7 +622,11 @@ export function createVariants(theme: Theme): Variants { 'after', (v) => { v.nodes = [ - rule('&::after', [contentProperties(), decl('content', 'var(--tw-content)'), ...v.nodes]), + styleRule('&::after', [ + contentProperties(), + decl('content', 'var(--tw-content)'), + ...v.nodes, + ]), ] }, { compounds: Compounds.Never }, @@ -664,7 +668,7 @@ export function createVariants(theme: Theme): Variants { // Interactive staticVariant('focus-within', ['&:focus-within']) variants.static('hover', (r) => { - r.nodes = [rule('&:hover', [rule('@media (hover: hover)', r.nodes)])] + r.nodes = [styleRule('&:hover', [atRule('@media', '(hover: hover)', r.nodes)])] }) staticVariant('focus', ['&:focus']) staticVariant('focus-visible', ['&:focus-visible']) @@ -682,13 +686,9 @@ export function createVariants(theme: Theme): Variants { walk([ruleNode], (node, { path }) => { if (node.kind !== 'rule') return WalkAction.Continue - // Skip past at-rules, and continue traversing the children of the at-rule - if (node.selector[0] === '@') return WalkAction.Continue - // Throw out any candidates with variants using nested style rules for (let parent of path.slice(0, -1)) { if (parent.kind !== 'rule') continue - if (parent.selector[0] === '@') continue didApply = false return WalkAction.Stop @@ -717,9 +717,11 @@ export function createVariants(theme: Theme): Variants { if (!variant.value || variant.modifier) return null if (variant.value.kind === 'arbitrary') { - ruleNode.nodes = [rule(`&[aria-${quoteAttributeValue(variant.value.value)}]`, ruleNode.nodes)] + ruleNode.nodes = [ + styleRule(`&[aria-${quoteAttributeValue(variant.value.value)}]`, ruleNode.nodes), + ] } else { - ruleNode.nodes = [rule(`&[aria-${variant.value.value}="true"]`, ruleNode.nodes)] + ruleNode.nodes = [styleRule(`&[aria-${variant.value.value}="true"]`, ruleNode.nodes)] } }) @@ -738,7 +740,9 @@ export function createVariants(theme: Theme): Variants { variants.functional('data', (ruleNode, variant) => { if (!variant.value || variant.modifier) return null - ruleNode.nodes = [rule(`&[data-${quoteAttributeValue(variant.value.value)}]`, ruleNode.nodes)] + ruleNode.nodes = [ + styleRule(`&[data-${quoteAttributeValue(variant.value.value)}]`, ruleNode.nodes), + ] }) variants.functional('nth', (ruleNode, variant) => { @@ -747,7 +751,7 @@ export function createVariants(theme: Theme): Variants { // Only numeric bare values are allowed if (variant.value.kind === 'named' && !isPositiveInteger(variant.value.value)) return null - ruleNode.nodes = [rule(`&:nth-child(${variant.value.value})`, ruleNode.nodes)] + ruleNode.nodes = [styleRule(`&:nth-child(${variant.value.value})`, ruleNode.nodes)] }) variants.functional('nth-last', (ruleNode, variant) => { @@ -756,7 +760,7 @@ export function createVariants(theme: Theme): Variants { // Only numeric bare values are allowed if (variant.value.kind === 'named' && !isPositiveInteger(variant.value.value)) return null - ruleNode.nodes = [rule(`&:nth-last-child(${variant.value.value})`, ruleNode.nodes)] + ruleNode.nodes = [styleRule(`&:nth-last-child(${variant.value.value})`, ruleNode.nodes)] }) variants.functional('nth-of-type', (ruleNode, variant) => { @@ -765,7 +769,7 @@ export function createVariants(theme: Theme): Variants { // Only numeric bare values are allowed if (variant.value.kind === 'named' && !isPositiveInteger(variant.value.value)) return null - ruleNode.nodes = [rule(`&:nth-of-type(${variant.value.value})`, ruleNode.nodes)] + ruleNode.nodes = [styleRule(`&:nth-of-type(${variant.value.value})`, ruleNode.nodes)] }) variants.functional('nth-last-of-type', (ruleNode, variant) => { @@ -774,7 +778,7 @@ export function createVariants(theme: Theme): Variants { // Only numeric bare values are allowed if (variant.value.kind === 'named' && !isPositiveInteger(variant.value.value)) return null - ruleNode.nodes = [rule(`&:nth-last-of-type(${variant.value.value})`, ruleNode.nodes)] + ruleNode.nodes = [styleRule(`&:nth-last-of-type(${variant.value.value})`, ruleNode.nodes)] }) variants.functional( @@ -792,7 +796,7 @@ export function createVariants(theme: Theme): Variants { // `(condition1) or (condition2)` is supported. let query = value.replace(/\b(and|or|not)\b/g, ' $1 ') - ruleNode.nodes = [rule(`@supports ${query}`, ruleNode.nodes)] + ruleNode.nodes = [atRule('@supports', query, ruleNode.nodes)] return } @@ -818,7 +822,7 @@ export function createVariants(theme: Theme): Variants { value = `(${value})` } - ruleNode.nodes = [rule(`@supports ${value}`, ruleNode.nodes)] + ruleNode.nodes = [atRule('@supports', value, ruleNode.nodes)] }, { compounds: Compounds.AtRules }, ) @@ -937,7 +941,7 @@ export function createVariants(theme: Theme): Variants { let value = resolvedBreakpoints.get(variant) if (value === null) return null - ruleNode.nodes = [rule(`@media (width < ${value})`, ruleNode.nodes)] + ruleNode.nodes = [atRule('@media', `(width < ${value})`, ruleNode.nodes)] }, { compounds: Compounds.AtRules }, ) @@ -959,7 +963,7 @@ export function createVariants(theme: Theme): Variants { variants.static( key, (ruleNode) => { - ruleNode.nodes = [rule(`@media (width >= ${value})`, ruleNode.nodes)] + ruleNode.nodes = [atRule('@media', `(width >= ${value})`, ruleNode.nodes)] }, { compounds: Compounds.AtRules }, ) @@ -972,7 +976,7 @@ export function createVariants(theme: Theme): Variants { let value = resolvedBreakpoints.get(variant) if (value === null) return null - ruleNode.nodes = [rule(`@media (width >= ${value})`, ruleNode.nodes)] + ruleNode.nodes = [atRule('@media', `(width >= ${value})`, ruleNode.nodes)] }, { compounds: Compounds.AtRules }, ) @@ -1024,10 +1028,11 @@ export function createVariants(theme: Theme): Variants { if (value === null) return null ruleNode.nodes = [ - rule( + atRule( + '@container', variant.modifier - ? `@container ${variant.modifier.value} (width < ${value})` - : `@container (width < ${value})`, + ? `${variant.modifier.value} (width < ${value})` + : `(width < ${value})`, ruleNode.nodes, ), ] @@ -1052,10 +1057,11 @@ export function createVariants(theme: Theme): Variants { if (value === null) return null ruleNode.nodes = [ - rule( + atRule( + '@container', variant.modifier - ? `@container ${variant.modifier.value} (width >= ${value})` - : `@container (width >= ${value})`, + ? `${variant.modifier.value} (width >= ${value})` + : `(width >= ${value})`, ruleNode.nodes, ), ] @@ -1069,10 +1075,11 @@ export function createVariants(theme: Theme): Variants { if (value === null) return null ruleNode.nodes = [ - rule( + atRule( + '@container', variant.modifier - ? `@container ${variant.modifier.value} (width >= ${value})` - : `@container (width >= ${value})`, + ? `${variant.modifier.value} (width >= ${value})` + : `(width >= ${value})`, ruleNode.nodes, ), ] @@ -1140,17 +1147,13 @@ function quoteAttributeValue(input: string) { export function substituteAtSlot(ast: AstNode[], nodes: AstNode[]) { walk(ast, (node, { replaceWith }) => { // Replace `@slot` with rule nodes - if (node.kind === 'rule' && node.selector === '@slot') { + if (node.kind === 'at-rule' && node.name === '@slot') { replaceWith(nodes) } // Wrap `@keyframes` and `@property` in `AtRoot` nodes - else if ( - node.kind === 'rule' && - node.selector[0] === '@' && - (node.selector.startsWith('@keyframes ') || node.selector.startsWith('@property ')) - ) { - Object.assign(node, atRoot([rule(node.selector, node.nodes)])) + else if (node.kind === 'at-rule' && (node.name === '@keyframes' || node.name === '@property')) { + Object.assign(node, atRoot([atRule(node.name, node.params, node.nodes)])) return WalkAction.Skip } })