[v4] Only allow CSS variables in @theme (#13125)

* add `WalkAction` to indicate how the `walk` function should behave

You can `Continue` its execution (the default behaviour), `Skip` walking
the current node, or `Stop` walking entirely.

* walk the nodes of `@theme` directly

There is no need to walk the `@theme` itself or to create a temporary
array here.

* improvement: skip walking

* only allow CSS variables inside `@theme`

* update error message
This commit is contained in:
Robin Malfait 2024-03-07 15:59:59 +01:00 committed by GitHub
parent d27f4cee32
commit 324273ce8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 71 additions and 17 deletions

View File

@ -42,6 +42,17 @@ export function comment(value: string): Comment {
}
}
export enum WalkAction {
/** Continue walking, which is the default */
Continue,
/** Skip visiting the children of this node */
Skip,
/** Stop the walk entirely */
Stop,
}
export function walk(
ast: AstNode[],
visit: (
@ -49,21 +60,26 @@ export function walk(
utils: {
replaceWith(newNode: AstNode | AstNode[]): void
},
) => void | false,
) => void | WalkAction,
) {
for (let i = 0; i < ast.length; i++) {
let node = ast[i]
let shouldContinue = visit(node, {
replaceWith(newNode) {
ast.splice(i, 1, ...(Array.isArray(newNode) ? newNode : [newNode]))
// We want to visit the newly replaced node(s), which start at the current
// index (i). By decrementing the index here, the next loop will process
// this position (containing the replaced node) again.
i--
},
})
let status =
visit(node, {
replaceWith(newNode) {
ast.splice(i, 1, ...(Array.isArray(newNode) ? newNode : [newNode]))
// We want to visit the newly replaced node(s), which start at the
// current index (i). By decrementing the index here, the next loop
// will process this position (containing the replaced node) again.
i--
},
}) ?? WalkAction.Continue
if (shouldContinue === false) return
// Stop the walk entirely
if (status === WalkAction.Stop) return
// Skip visiting the children of this node
if (status === WalkAction.Skip) continue
if (node.kind === 'rule') {
walk(node.nodes, visit)

View File

@ -51,6 +51,32 @@ describe('compiling CSS', () => {
`)
})
test('that only CSS variables are allowed', () => {
expect(() =>
compileCss(
css`
@theme {
--color-primary: red;
.foo {
--color-primary: blue;
}
}
@tailwind utilities;
`,
['bg-primary'],
),
).toThrowErrorMatchingInlineSnapshot(`
[Error: \`@theme\` blocks must only contain custom properties or \`@keyframes\`.
@theme {
> .foo {
> --color-primary: blue;
> }
}
]
`)
})
test('`@tailwind utilities` is only processed once', () => {
expect(
compileCss(

View File

@ -1,6 +1,6 @@
import { Features, transform } from 'lightningcss'
import { version } from '../package.json'
import { comment, decl, rule, toCss, walk, type AstNode, type Rule } from './ast'
import { WalkAction, comment, decl, rule, toCss, walk, type AstNode, type Rule } from './ast'
import { compileCandidates } from './compile'
import * as CSS from './css-parser'
import { buildDesignSystem } from './design-system'
@ -23,19 +23,29 @@ export function compile(css: string, rawCandidates: string[]) {
if (node.selector !== '@theme') return
// Record all custom properties in the `@theme` declaration
walk([node], (node, { replaceWith }) => {
walk(node.nodes, (node, { replaceWith }) => {
// Collect `@keyframes` rules to re-insert with theme variables later,
// since the `@theme` rule itself will be removed.
if (node.kind === 'rule' && node.selector.startsWith('@keyframes ')) {
keyframesRules.push(node)
replaceWith([])
return WalkAction.Skip
}
if (node.kind === 'comment') return
if (node.kind === 'declaration' && node.property.startsWith('--')) {
theme.add(node.property, node.value)
return
}
if (node.kind !== 'declaration') return
if (!node.property.startsWith('--')) return
let snippet = toCss([rule('@theme', [node])])
.split('\n')
.map((line, idx, all) => `${idx === 0 || idx >= all.length - 2 ? ' ' : '>'} ${line}`)
.join('\n')
theme.add(node.property, node.value)
throw new Error(
`\`@theme\` blocks must only contain custom properties or \`@keyframes\`.\n\n${snippet}`,
)
})
// Keep a reference to the first `@theme` rule to update with the full theme
@ -45,6 +55,7 @@ export function compile(css: string, rawCandidates: string[]) {
} else {
replaceWith([])
}
return WalkAction.Skip
})
// Output final set of theme variables at the position of the first `@theme`
@ -94,7 +105,7 @@ export function compile(css: string, rawCandidates: string[]) {
// Stop walking after finding `@tailwind utilities` to avoid walking all
// of the generated CSS. This means `@tailwind utilities` can only appear
// once per file but that's the intended usage at this point in time.
return false
return WalkAction.Stop
}
})
@ -145,6 +156,7 @@ export function compile(css: string, rawCandidates: string[]) {
walk(ast, (node, { replaceWith }) => {
if (node.kind === 'rule' && node.selector === '@media reference') {
replaceWith([])
return WalkAction.Skip
}
})
}