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.
This commit is contained in:
Robin Malfait 2024-10-29 01:17:25 +01:00 committed by GitHub
parent 4e5e0a3e1b
commit c439cdf43c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 363 additions and 276 deletions

View File

@ -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('>') ||

View File

@ -1,6 +1,6 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"allowSyntheticDefaultImports":true
}
"allowSyntheticDefaultImports": true,
},
}

View File

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

View File

@ -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')]),
]),
]),
]),

View File

@ -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)]),
)
}

View File

@ -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<void>[] = []
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

View File

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

View File

@ -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%)')]),
]),
)

View File

@ -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<ResolvedConfig, 'theme'>): Rule[] {
let rules: Rule[] = []
export function keyframesToRules(resolvedConfig: Pick<ResolvedConfig, 'theme'>): 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

View File

@ -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

View File

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

View File

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

View File

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

View File

@ -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',

View File

@ -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 {

View File

@ -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

View File

@ -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

View File

@ -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<string, { value: string; options: ThemeOptions }>(),
private keyframes = new Set<Rule>([]),
private keyframes = new Set<AtRule>([]),
) {}
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)
}

View File

@ -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', '<number>')]),
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', '<number>')]),
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', '<number>')]),
() =>
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', '<number>')]),
() =>
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', '<number>')]),
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', '<number>')]),
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', '<number>')]),
() => 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', '<number>')]),
() => 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)]),
],
})

View File

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