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 } })