Skip to content

Commit c439cdf

Browse files
authored
Internal refactor, introduce AtRule (#14802)
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.
1 parent 4e5e0a3 commit c439cdf

20 files changed

+363
-276
lines changed

packages/@tailwindcss-upgrade/src/template/codemods/variant-order.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,16 +57,14 @@ function isAtRuleVariant(designSystem: DesignSystem, variant: Variant) {
5757
return true
5858
}
5959
let stack = getAppliedNodeStack(designSystem, variant)
60-
return stack.every((node) => node.kind === 'rule' && node.selector[0] === '@')
60+
return stack.every((node) => node.kind === 'at-rule')
6161
}
6262

6363
function isCombinatorVariant(designSystem: DesignSystem, variant: Variant) {
6464
let stack = getAppliedNodeStack(designSystem, variant)
6565
return stack.some(
6666
(node) =>
6767
node.kind === 'rule' &&
68-
// Ignore at-rules as they are hoisted
69-
node.selector[0] !== '@' &&
7068
// Combinators include any of the following characters
7169
(node.selector.includes(' ') ||
7270
node.selector.includes('>') ||
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"extends": "../tsconfig.base.json",
33
"compilerOptions": {
4-
"allowSyntheticDefaultImports":true
5-
}
4+
"allowSyntheticDefaultImports": true,
5+
},
66
}

packages/tailwindcss/src/apply.ts

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,21 @@ import { escape } from './utils/escape'
55

66
export function substituteAtApply(ast: AstNode[], designSystem: DesignSystem) {
77
walk(ast, (node, { replaceWith }) => {
8-
if (node.kind !== 'rule') return
8+
if (node.kind !== 'at-rule') return
99

1010
// Do not allow `@apply` rules inside `@keyframes` rules.
11-
if (node.selector[0] === '@' && node.selector.startsWith('@keyframes')) {
11+
if (node.name === '@keyframes') {
1212
walk(node.nodes, (child) => {
13-
if (
14-
child.kind === 'rule' &&
15-
child.selector[0] === '@' &&
16-
child.selector.startsWith('@apply ')
17-
) {
13+
if (child.kind === 'at-rule' && child.name === '@apply') {
1814
throw new Error(`You cannot use \`@apply\` inside \`@keyframes\`.`)
1915
}
2016
})
2117
return WalkAction.Skip
2218
}
2319

24-
if (!(node.selector[0] === '@' && node.selector.startsWith('@apply '))) return
20+
if (node.name !== '@apply') return
2521

26-
let candidates = node.selector
27-
.slice(7 /* Ignore `@apply ` when parsing the selector */)
28-
.trim()
29-
.split(/\s+/g)
22+
let candidates = node.params.split(/\s+/g)
3023

3124
// Replace the `@apply` rule with the actual utility classes
3225
{
@@ -43,7 +36,7 @@ export function substituteAtApply(ast: AstNode[], designSystem: DesignSystem) {
4336
// don't want the wrapping selector.
4437
let newNodes: AstNode[] = []
4538
for (let candidateNode of candidateAst) {
46-
if (candidateNode.kind === 'rule' && candidateNode.selector[0] !== '@') {
39+
if (candidateNode.kind === 'rule') {
4740
for (let child of candidateNode.nodes) {
4841
newNodes.push(child)
4942
}

packages/tailwindcss/src/ast.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, it } from 'vitest'
2-
import { context, decl, rule, toCss, walk } from './ast'
2+
import { context, decl, styleRule, toCss, walk } from './ast'
33
import * as CSS from './css-parser'
44

55
it('should pretty print an AST', () => {
@@ -16,13 +16,13 @@ it('should pretty print an AST', () => {
1616

1717
it('allows the placement of context nodes', () => {
1818
const ast = [
19-
rule('.foo', [decl('color', 'red')]),
19+
styleRule('.foo', [decl('color', 'red')]),
2020
context({ context: 'a' }, [
21-
rule('.bar', [
21+
styleRule('.bar', [
2222
decl('color', 'blue'),
2323
context({ context: 'b' }, [
2424
//
25-
rule('.baz', [decl('color', 'green')]),
25+
styleRule('.baz', [decl('color', 'green')]),
2626
]),
2727
]),
2828
]),

packages/tailwindcss/src/ast.ts

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
1-
export type Rule = {
1+
import { parseAtRule } from './css-parser'
2+
3+
const AT_SIGN = 0x40
4+
5+
export type StyleRule = {
26
kind: 'rule'
37
selector: string
48
nodes: AstNode[]
59
}
610

11+
export type AtRule = {
12+
kind: 'at-rule'
13+
name: string
14+
params: string
15+
nodes: AstNode[]
16+
}
17+
718
export type Declaration = {
819
kind: 'declaration'
920
property: string
@@ -27,16 +38,34 @@ export type AtRoot = {
2738
nodes: AstNode[]
2839
}
2940

30-
export type AstNode = Rule | Declaration | Comment | Context | AtRoot
41+
export type Rule = StyleRule | AtRule
42+
export type AstNode = StyleRule | AtRule | Declaration | Comment | Context | AtRoot
3143

32-
export function rule(selector: string, nodes: AstNode[]): Rule {
44+
export function styleRule(selector: string, nodes: AstNode[] = []): StyleRule {
3345
return {
3446
kind: 'rule',
3547
selector,
3648
nodes,
3749
}
3850
}
3951

52+
export function atRule(name: string, params: string = '', nodes: AstNode[] = []): AtRule {
53+
return {
54+
kind: 'at-rule',
55+
name,
56+
params,
57+
nodes,
58+
}
59+
}
60+
61+
export function rule(selector: string, nodes: AstNode[] = []): StyleRule | AtRule {
62+
if (selector.charCodeAt(0) === AT_SIGN) {
63+
return parseAtRule(selector, nodes)
64+
}
65+
66+
return styleRule(selector, nodes)
67+
}
68+
4069
export function decl(property: string, value: string | undefined): Declaration {
4170
return {
4271
kind: 'declaration',
@@ -126,7 +155,7 @@ export function walk(
126155
// Skip visiting the children of this node
127156
if (status === WalkAction.Skip) continue
128157

129-
if (node.kind === 'rule') {
158+
if (node.kind === 'rule' || node.kind === 'at-rule') {
130159
walk(node.nodes, visit, path, context)
131160
}
132161
}
@@ -152,7 +181,7 @@ export function walkDepth(
152181
let path = [...parentPath, node]
153182
let parent = parentPath.at(-1) ?? null
154183

155-
if (node.kind === 'rule') {
184+
if (node.kind === 'rule' || node.kind === 'at-rule') {
156185
walkDepth(node.nodes, visit, path, context)
157186
} else if (node.kind === 'context') {
158187
walkDepth(node.nodes, visit, parentPath, { ...context, ...node.context })
@@ -185,7 +214,16 @@ export function toCss(ast: AstNode[]) {
185214

186215
// Rule
187216
if (node.kind === 'rule') {
188-
if (node.selector === '@tailwind utilities') {
217+
css += `${indent}${node.selector} {\n`
218+
for (let child of node.nodes) {
219+
css += stringify(child, depth + 1)
220+
}
221+
css += `${indent}}\n`
222+
}
223+
224+
// AtRule
225+
else if (node.kind === 'at-rule') {
226+
if (node.name === '@tailwind' && node.params === 'utilities') {
189227
for (let child of node.nodes) {
190228
css += stringify(child, depth)
191229
}
@@ -199,20 +237,21 @@ export function toCss(ast: AstNode[]) {
199237
// ```css
200238
// @layer base, components, utilities;
201239
// ```
202-
if (node.selector[0] === '@' && node.nodes.length === 0) {
203-
return `${indent}${node.selector};\n`
240+
else if (node.nodes.length === 0) {
241+
return `${indent}${node.name} ${node.params};\n`
204242
}
205243

206-
if (node.selector[0] === '@' && node.selector.startsWith('@property ') && depth === 0) {
244+
//
245+
else if (node.name === '@property' && depth === 0) {
207246
// Don't output duplicate `@property` rules
208-
if (seenAtProperties.has(node.selector)) {
247+
if (seenAtProperties.has(node.params)) {
209248
return ''
210249
}
211250

212251
// Collect fallbacks for `@property` rules for Firefox support
213252
// We turn these into rules on `:root` or `*` and some pseudo-elements
214253
// based on the value of `inherits``
215-
let property = node.selector.replace(/@property\s*/g, '')
254+
let property = node.params
216255
let initialValue = null
217256
let inherits = false
218257

@@ -231,10 +270,10 @@ export function toCss(ast: AstNode[]) {
231270
propertyFallbacksUniversal.push(decl(property, initialValue ?? 'initial'))
232271
}
233272

234-
seenAtProperties.add(node.selector)
273+
seenAtProperties.add(node.params)
235274
}
236275

237-
css += `${indent}${node.selector} {\n`
276+
css += `${indent}${node.name}${node.params ? ` ${node.params} ` : ' '}{\n`
238277
for (let child of node.nodes) {
239278
css += stringify(child, depth + 1)
240279
}
@@ -292,7 +331,7 @@ export function toCss(ast: AstNode[]) {
292331

293332
if (fallbackAst.length) {
294333
fallback = stringify(
295-
rule('@supports (-moz-orient: inline)', [rule('@layer base', fallbackAst)]),
334+
atRule('@supports', '(-moz-orient: inline)', [atRule('@layer', 'base', fallbackAst)]),
296335
)
297336
}
298337

packages/tailwindcss/src/at-import.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { context, rule, walk, WalkAction, type AstNode } from './ast'
1+
import { atRule, context, walk, WalkAction, type AstNode } from './ast'
22
import * as CSS from './css-parser'
33
import * as ValueParser from './value-parser'
44

@@ -13,15 +13,9 @@ export async function substituteAtImports(
1313
let promises: Promise<void>[] = []
1414

1515
walk(ast, (node, { replaceWith }) => {
16-
if (
17-
node.kind === 'rule' &&
18-
node.selector[0] === '@' &&
19-
node.selector.toLowerCase().startsWith('@import ')
20-
) {
16+
if (node.kind === 'at-rule' && node.name === '@import') {
2117
try {
22-
let { uri, layer, media, supports } = parseImportParams(
23-
ValueParser.parse(node.selector.slice(8)),
24-
)
18+
let { uri, layer, media, supports } = parseImportParams(ValueParser.parse(node.params))
2519

2620
// Skip importing data or remote URIs
2721
if (uri.startsWith('data:')) return
@@ -132,15 +126,15 @@ function buildImportNodes(
132126
let root = importedAst
133127

134128
if (layer !== null) {
135-
root = [rule('@layer ' + layer, root)]
129+
root = [atRule('@layer', layer, root)]
136130
}
137131

138132
if (media !== null) {
139-
root = [rule('@media ' + media, root)]
133+
root = [atRule('@media', media, root)]
140134
}
141135

142136
if (supports !== null) {
143-
root = [rule(`@supports ${supports[0] === '(' ? supports : `(${supports})`}`, root)]
137+
root = [atRule('@supports', supports[0] === '(' ? supports : `(${supports})`, root)]
144138
}
145139

146140
return root

packages/tailwindcss/src/compat/apply-compat-hooks.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { rule, toCss, walk, WalkAction, type AstNode } from '../ast'
1+
import { styleRule, toCss, walk, WalkAction, type AstNode } from '../ast'
22
import type { DesignSystem } from '../design-system'
33
import { segment } from '../utils/segment'
44
import { applyConfigToTheme } from './apply-config-to-theme'
@@ -34,15 +34,15 @@ export async function applyCompatibilityHooks({
3434
let configPaths: { id: string; base: string }[] = []
3535

3636
walk(ast, (node, { parent, replaceWith, context }) => {
37-
if (node.kind !== 'rule' || node.selector[0] !== '@') return
37+
if (node.kind !== 'at-rule') return
3838

3939
// Collect paths from `@plugin` at-rules
40-
if (node.selector === '@plugin' || node.selector.startsWith('@plugin ')) {
40+
if (node.name === '@plugin') {
4141
if (parent !== null) {
4242
throw new Error('`@plugin` cannot be nested.')
4343
}
4444

45-
let pluginPath = node.selector.slice(9, -1)
45+
let pluginPath = node.params.slice(1, -1)
4646
if (pluginPath.length === 0) {
4747
throw new Error('`@plugin` must have a path.')
4848
}
@@ -100,7 +100,7 @@ export async function applyCompatibilityHooks({
100100
}
101101

102102
// Collect paths from `@config` at-rules
103-
if (node.selector === '@config' || node.selector.startsWith('@config ')) {
103+
if (node.name === '@config') {
104104
if (node.nodes.length > 0) {
105105
throw new Error('`@config` cannot have a body.')
106106
}
@@ -109,7 +109,7 @@ export async function applyCompatibilityHooks({
109109
throw new Error('`@config` cannot be nested.')
110110
}
111111

112-
configPaths.push({ id: node.selector.slice(9, -1), base: context.base })
112+
configPaths.push({ id: node.params.slice(1, -1), base: context.base })
113113
replaceWith([])
114114
return
115115
}
@@ -268,15 +268,15 @@ function upgradeToFullPluginSupport({
268268
let wrappingSelector = resolvedConfig.important
269269

270270
walk(ast, (node, { replaceWith, parent }) => {
271-
if (node.kind !== 'rule') return
272-
if (node.selector !== '@tailwind utilities') return
271+
if (node.kind !== 'at-rule') return
272+
if (node.name !== '@tailwind' || node.params !== 'utilities') return
273273

274274
// The AST node was already manually wrapped so there's nothing to do
275275
if (parent?.kind === 'rule' && parent.selector === wrappingSelector) {
276276
return WalkAction.Stop
277277
}
278278

279-
replaceWith(rule(wrappingSelector, [node]))
279+
replaceWith(styleRule(wrappingSelector, [node]))
280280

281281
return WalkAction.Stop
282282
})

packages/tailwindcss/src/compat/apply-keyframes-to-theme.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from 'vitest'
2-
import { decl, rule, toCss } from '../ast'
2+
import { atRule, decl, styleRule, toCss } from '../ast'
33
import { buildDesignSystem } from '../design-system'
44
import { Theme } from '../theme'
55
import { applyKeyframesToTheme } from './apply-keyframes-to-theme'
@@ -58,15 +58,15 @@ test('will append to the default keyframes with new keyframes', () => {
5858
let design = buildDesignSystem(theme)
5959

6060
theme.addKeyframes(
61-
rule('@keyframes slide-in', [
62-
rule('from', [decl('opacity', 'translateX(0%)')]),
63-
rule('to', [decl('opacity', 'translateX(100%)')]),
61+
atRule('@keyframes', 'slide-in', [
62+
styleRule('from', [decl('opacity', 'translateX(0%)')]),
63+
styleRule('to', [decl('opacity', 'translateX(100%)')]),
6464
]),
6565
)
6666
theme.addKeyframes(
67-
rule('@keyframes slide-out', [
68-
rule('from', [decl('opacity', 'translateX(100%)')]),
69-
rule('to', [decl('opacity', 'translateX(0%)')]),
67+
atRule('@keyframes', 'slide-out', [
68+
styleRule('from', [decl('opacity', 'translateX(100%)')]),
69+
styleRule('to', [decl('opacity', 'translateX(0%)')]),
7070
]),
7171
)
7272

packages/tailwindcss/src/compat/apply-keyframes-to-theme.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { rule, type Rule } from '../ast'
1+
import { atRule, type AtRule } from '../ast'
22
import type { DesignSystem } from '../design-system'
33
import type { ResolvedConfig } from './config/types'
44
import { objectToAst } from './plugin-api'
@@ -13,11 +13,11 @@ export function applyKeyframesToTheme(
1313
}
1414
}
1515

16-
export function keyframesToRules(resolvedConfig: Pick<ResolvedConfig, 'theme'>): Rule[] {
17-
let rules: Rule[] = []
16+
export function keyframesToRules(resolvedConfig: Pick<ResolvedConfig, 'theme'>): AtRule[] {
17+
let rules: AtRule[] = []
1818
if ('keyframes' in resolvedConfig.theme) {
1919
for (let [name, keyframe] of Object.entries(resolvedConfig.theme.keyframes)) {
20-
rules.push(rule(`@keyframes ${name}`, objectToAst(keyframe as any)))
20+
rules.push(atRule('@keyframes', name, objectToAst(keyframe as any)))
2121
}
2222
}
2323
return rules

0 commit comments

Comments
 (0)