From fc5782bee1c13c32512d4b2355ac17e680fdf1aa Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Mon, 15 Sep 2025 12:28:54 +0200 Subject: [PATCH 1/2] Add flattener --- AGENTS.md | 40 ++++++ packages/tailwindcss/src/ast.ts | 165 +++++++++++++++++++++++++ packages/tailwindcss/src/index.test.ts | 148 ++++++++++++++++++++++ 3 files changed, 353 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..25358efc6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,40 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- `packages/*` — TypeScript packages. Core lives in `packages/tailwindcss`; adapters/tools are under `@tailwindcss-*`. +- `crates/*` — Rust crates (e.g. `crates/oxide`, `crates/node`). Node bindings are published from here. +- `integrations/` — Vitest-based integration tests for CLIs/build tools. +- `scripts/` — Release and maintenance utilities (e.g. `version-packages.mjs`). +- `dist/` — Packed artifacts after builds; useful for local installs. +- `playgrounds/*` — Sample apps to manually verify changes. + +## Build, Test, and Development Commands +- `pnpm install` — Install JS deps. Ensure Rust toolchain via `rustup default stable` and add `wasm32-wasip1-threads`. +- `pnpm dev` — Turbo watch builds; filter example: `pnpm dev --filter=tailwindcss`. +- `pnpm build` — Build all packages (excludes playgrounds). Creates tarballs in `dist/`. +- `pnpm test` — Run Rust `cargo test` and Vitest across the workspace. +- `pnpm test:integrations` — Run integration tests (run `pnpm build` first). +- `pnpm test:ui` — Run Playwright UI tests for browser-related behavior. +- `pnpm lint` / `pnpm format` — Check/fix formatting and package-level lint scripts. + +## Coding Style & Naming Conventions +- Prettier is authoritative: `semi: false`, `singleQuote: true`, `printWidth: 100`. +- Indentation: 2 spaces. TypeScript files prefer `.ts`; compatibility shims may use `.cts`. +- Naming: kebab-case file names, camelCase functions/vars, PascalCase types/classes. Rust modules use `snake_case`. +- Run `pnpm lint` locally before opening a PR. + +## Testing Guidelines +- Unit tests: Vitest; name files `*.test.ts` near the code they cover or in `src/**/__tests__`. +- Integration: tests live in `integrations/**`; run `pnpm build && pnpm test:integrations`. +- UI: Playwright tests via `pnpm test:ui`. +- Rust: crate tests run with `cargo test` (also covered by `pnpm test`). Include tests with any behavior change. + +## Commit & Pull Request Guidelines +- Commit messages: clear, present-tense summaries; reference issues like `#123` when relevant. +- New features: open a discussion before investing significant work. +- PRs must include: problem summary, rationale, and a test plan (exact commands; screenshots if UI-related). Ensure all tests pass; use `[ci-all]` in the description to run all platform suites. +- See `.github/CONTRIBUTING.md` and `.github/PULL_REQUEST_TEMPLATE.md` for details. + +## Security & Configuration Tips +- Do not commit secrets or machine-specific paths. Use `pnpm@9` (workspace-managed) and Rust stable. Turbo caches builds; when in doubt, rebuild with `pnpm build`. + diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts index a473b9193..9abff28a4 100644 --- a/packages/tailwindcss/src/ast.ts +++ b/packages/tailwindcss/src/ast.ts @@ -4,6 +4,7 @@ import type { DesignSystem } from './design-system' import type { Source, SourceLocation } from './source-maps/source' import { Theme, ThemeOptions } from './theme' import { DefaultMap } from './utils/default-map' +import { segment } from './utils/segment' import { extractUsedVariables } from './utils/variables' import * as ValueParser from './value-parser' @@ -568,6 +569,10 @@ export function optimizeAst( newAst = newAst.concat(atRoots) + // Flatten nested style-rule selectors globally so stringify produces + // standards-compliant CSS without nesting. + newAst = flattenAst(newAst) + // Fallbacks // Create fallback values for usages of the `color-mix(…)` function that reference variables // found in the theme config. @@ -732,6 +737,166 @@ export function optimizeAst( return newAst } +export function flattenAst(ast: AstNode[]): AstNode[] { + function splitSelectors(sel: string): string[] { + return segment(sel, ',') + .map((s) => s.trim()) + .filter(Boolean) + } + + function isRelative(child: string): boolean { + let s = child.trim() + return s.startsWith('>') || s.startsWith('+') || s.startsWith('~') + } + + function normalizeSpaces(s: string): string { + return s.replace(/\s+/g, ' ').trim() + } + + function normalizePseudoElements(s: string): string { + // Move known pseudo-elements to the end of the last compound selector + let parts = s.split(/(\s*[>+~]\s*)/) + let last = parts[parts.length - 1] + let pseudos: string[] = [] + last = last.replace( + /:{1,2}(before|after|first-letter|first-line|marker|backdrop|details-content)\b/g, + (m) => { + pseudos.push(m) + return '' + }, + ) + if (pseudos.length > 0) parts[parts.length - 1] = `${last}${pseudos.join('')}` + return parts.join('') + } + + function combine(parentList: string[] | null, childList: string[]): string[] { + if (!parentList || parentList.length === 0) return childList.map(normalizeSpaces) + + let out: string[] = [] + for (let p of parentList) { + for (let c of childList) { + let combined: string + if (c.includes('&')) { + combined = normalizeSpaces(c.replaceAll('&', p)) + } else if (isRelative(c)) { + combined = normalizeSpaces(`${p} ${c}`) + } else { + combined = normalizeSpaces(`${p} ${c}`) + } + out.push(normalizePseudoElements(combined)) + } + } + return out + } + + function flatten(nodes: AstNode[], parentSelectors: string[] | null): AstNode[] { + let result: AstNode[] = [] + + for (let node of nodes) { + if (node.kind === 'rule') { + let selfList = splitSelectors(node.selector) + let current = parentSelectors + ? combine(parentSelectors, selfList) + : selfList.map(normalizeSpaces) + + let decls: AstNode[] = [] + let nested: AstNode[] = [] + for (let child of node.nodes) { + if (child.kind === 'declaration' || child.kind === 'comment') decls.push(child) + else nested.push(child) + } + + if (decls.length > 0) { + for (let sel of current) { + result.push({ + kind: 'rule', + selector: normalizePseudoElements(sel), + nodes: decls.slice(), + }) + } + } + + for (let child of nested) { + if (child.kind === 'rule') { + result.push(...flatten([child], current)) + } else if (child.kind === 'at-rule') { + let inner = flatten(child.nodes, current) + if ( + inner.length > 0 || + child.name === '@layer' || + child.name === '@charset' || + child.name === '@custom-media' || + child.name === '@namespace' || + child.name === '@import' + ) { + result.push({ ...child, nodes: inner }) + } + } + } + } else if (node.kind === 'at-rule') { + let inner = flatten(node.nodes, parentSelectors) + if ( + inner.length > 0 || + node.name === '@layer' || + node.name === '@charset' || + node.name === '@custom-media' || + node.name === '@namespace' || + node.name === '@import' + ) { + result.push({ ...node, nodes: inner }) + } + } else { + result.push(node) + } + } + + return result + } + + function dedupeDecls(nodes: AstNode[]): AstNode[] { + let seen = new Set() + let out: AstNode[] = [] + for (let n of nodes) { + if (n.kind !== 'declaration') { + out.push(n) + continue + } + let key = `${n.property}\u0001${n.value ?? ''}\u0001${n.important ? '!' : ''}` + if (seen.has(key)) continue + seen.add(key) + out.push(n) + } + return out + } + + function mergeRules(nodes: AstNode[]): AstNode[] { + let result: AstNode[] = [] + let bySelector = new Map() + + for (let node of nodes) { + if (node.kind === 'rule') { + let existing = bySelector.get(node.selector) + if (existing) { + existing.nodes = dedupeDecls(existing.nodes.concat(node.nodes)) + continue + } + let copy: StyleRule = { ...node, nodes: dedupeDecls(node.nodes.slice()) } + bySelector.set(copy.selector, copy) + result.push(copy) + } else if (node.kind === 'at-rule') { + let mergedChildren = mergeRules(node.nodes) + result.push({ ...node, nodes: mergedChildren }) + } else { + result.push(node) + } + } + + return result + } + + return mergeRules(flatten(ast, null)) +} + export function toCss(ast: AstNode[], track?: boolean) { let pos = 0 diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index cfd0d1db8..b0e468eb8 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -2,7 +2,9 @@ import fs from 'node:fs' import path from 'node:path' import { describe, expect, it, test } from 'vitest' import { compile, Features, Polyfills } from '.' +import { flattenAst, toCss as toCssAst } from './ast' import type { PluginAPI } from './compat/plugin-api' +import * as CSS from './css-parser' import plugin from './plugin' import { compileCss, optimizeCss, run } from './test-utils/run' @@ -5966,3 +5968,149 @@ describe('feature detection', () => { expect(compiler.features & Features.Utilities).toBeFalsy() }) }) + +describe('CSS flattening', () => { + it('pseudo-element last: &::before:hover -> :hover::before', () => { + let ast = CSS.parse(css` + .btn { + &::before:hover { + content: ''; + } + } + `) + let flat = flattenAst(ast) + expect(toCssAst(flat)).toMatchInlineSnapshot(` + ".btn:hover::before {\n content: '';\n}\n" + `) + }) + + it('selector-list cross product (parent × child)', () => { + let ast = CSS.parse(css` + .a, + .b { + &:hover, + &:focus { + opacity: 0.5; + } + } + `) + let flat = flattenAst(ast) + expect(toCssAst(flat)).toMatchInlineSnapshot(` + ".a:hover {\n opacity: 0.5;\n}\n.a:focus {\n opacity: 0.5;\n}\n.b:hover {\n opacity: 0.5;\n}\n.b:focus {\n opacity: 0.5;\n}\n" + `) + }) + + it('descendant join when & is omitted', () => { + let ast = CSS.parse(css` + .a, + .b { + .child, + .c2 { + color: blue; + } + } + `) + let flat = flattenAst(ast) + expect(toCssAst(flat)).toMatchInlineSnapshot(` + ".a .child {\n color: blue;\n}\n.a .c2 {\n color: blue;\n}\n.b .child {\n color: blue;\n}\n.b .c2 {\n color: blue;\n}\n" + `) + }) + + it('relative combinators (>, +, ~)', () => { + let ast = CSS.parse(css` + .a { + > .b, + + .c, + ~ .d { + color: red; + } + } + `) + let flat = flattenAst(ast) + expect(toCssAst(flat)).toMatchInlineSnapshot(` + ".a > .b {\n color: red;\n}\n.a + .c {\n color: red;\n}\n.a ~ .d {\n color: red;\n}\n" + `) + }) + + it('multiple &: & + & over lists', () => { + let ast = CSS.parse(css` + .a, + .b { + & + & { + color: green; + } + } + `) + let flat = flattenAst(ast) + expect(toCssAst(flat)).toMatchInlineSnapshot(` + ".a + .a { + color: green; + } + .b + .b { + color: green; + } + " + `) + }) + + it('preserves :is/:where groupings', () => { + let ast = CSS.parse(css` + .a, + .b { + &:is(:hover, :focus) { + opacity: 0.5; + } + :where(.x, .y) { + color: red; + } + } + `) + let flat = flattenAst(ast) + expect(toCssAst(flat)).toMatchInlineSnapshot(` + ".a:is(:hover, :focus) {\n opacity: 0.5;\n}\n.b:is(:hover, :focus) {\n opacity: 0.5;\n}\n.a :where(.x, .y) {\n color: red;\n}\n.b :where(.x, .y) {\n color: red;\n}\n" + `) + }) + + it('combines duplicate rules and de-dupes declarations', () => { + let ast = CSS.parse(css` + .a { + & { + color: red; + color: red; + } + & { + background: blue; + } + } + `) + let flat = flattenAst(ast) + expect(toCssAst(flat)).toMatchInlineSnapshot(` + ".a {\n color: red;\n background: blue;\n}\n" + `) + }) + + it('nested @media wrappers preserved', () => { + let ast = CSS.parse(css` + .a, + .b { + @media (min-width: 768px) { + &:hover { + color: red; + } + } + } + `) + let flat = flattenAst(ast) + expect(toCssAst(flat)).toMatchInlineSnapshot(` + "@media (min-width: 768px) { + .a:hover { + color: red; + } + .b:hover { + color: red; + } + } + " + `) + }) +}) From ee1cd3c0d1d8f44f11027d2f645e7bb9ad74fba1 Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Tue, 16 Sep 2025 14:01:49 +0200 Subject: [PATCH 2/2] WIP --- packages/tailwindcss/src/ast.ts | 134 +++++++++++++++++++++---- packages/tailwindcss/src/index.test.ts | 66 +++++++++--- packages/tailwindcss/src/index.ts | 5 +- packages/tailwindcss/src/utilities.ts | 8 +- 4 files changed, 181 insertions(+), 32 deletions(-) diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts index 9abff28a4..006d2ef30 100644 --- a/packages/tailwindcss/src/ast.ts +++ b/packages/tailwindcss/src/ast.ts @@ -569,10 +569,6 @@ export function optimizeAst( newAst = newAst.concat(atRoots) - // Flatten nested style-rule selectors globally so stringify produces - // standards-compliant CSS without nesting. - newAst = flattenAst(newAst) - // Fallbacks // Create fallback values for usages of the `color-mix(…)` function that reference variables // found in the theme config. @@ -667,7 +663,11 @@ export function optimizeAst( ...declaration, value: ValueParser.toCss(ast), } - let colorMixQuery = rule('@supports (color: color-mix(in lab, red, red))', [declaration]) + let colorMixQuery = rule('@supports (color: color-mix(in lab, red, red))', [ + // Keep the original declaration behind a `@supports` rule and ensure + // it remains scoped to the current selector by wrapping it in `&`. + rule('&', [declaration]), + ]) colorMixQuery.src = declaration.src parent.splice(idx, 1, fallback, colorMixQuery) } @@ -734,6 +734,12 @@ export function optimizeAst( } } + // Flatten nested style-rule selectors globally so stringify produces + // standards-compliant CSS without nesting. This must run after we insert + // polyfills that depend on the original parent/child arrays created during + // transformation, otherwise we would be mutating stale arrays. + newAst = flattenAst(newAst) + return newAst } @@ -799,28 +805,36 @@ export function flattenAst(ast: AstNode[]): AstNode[] { ? combine(parentSelectors, selfList) : selfList.map(normalizeSpaces) - let decls: AstNode[] = [] - let nested: AstNode[] = [] - for (let child of node.nodes) { - if (child.kind === 'declaration' || child.kind === 'comment') decls.push(child) - else nested.push(child) - } - - if (decls.length > 0) { + let bufferDecls: AstNode[] = [] + let flush = () => { + if (bufferDecls.length === 0) return for (let sel of current) { result.push({ kind: 'rule', selector: normalizePseudoElements(sel), - nodes: decls.slice(), + nodes: bufferDecls.slice(), }) } + bufferDecls = [] } - for (let child of nested) { + for (let child of node.nodes) { + if (child.kind === 'declaration' || child.kind === 'comment') { + bufferDecls.push(child) + continue + } + + // We hit a nested block; flush accumulated decls first to preserve order + flush() + if (child.kind === 'rule') { result.push(...flatten([child], current)) } else if (child.kind === 'at-rule') { - let inner = flatten(child.nodes, current) + // Ensure declarations inside nested at-rules remain scoped to the + // current selector by first wrapping them in a synthetic rule and + // then flattening. This prevents emitting invalid CSS like + // `@media{ color:red }` without a selector. + let inner = flatten([styleRule('&', child.nodes)], current) if ( inner.length > 0 || child.name === '@layer' || @@ -833,6 +847,9 @@ export function flattenAst(ast: AstNode[]): AstNode[] { } } } + + // Flush remaining decls after processing nested children + flush() } else if (node.kind === 'at-rule') { let inner = flatten(node.nodes, parentSelectors) if ( @@ -871,6 +888,9 @@ export function flattenAst(ast: AstNode[]): AstNode[] { function mergeRules(nodes: AstNode[]): AstNode[] { let result: AstNode[] = [] + // Only merge rules within contiguous segments so we don't reorder + // content across at-rule boundaries (e.g. keep rules before/after + // an intervening @supports or @media block separate). let bySelector = new Map() for (let node of nodes) { @@ -886,6 +906,9 @@ export function flattenAst(ast: AstNode[]): AstNode[] { } else if (node.kind === 'at-rule') { let mergedChildren = mergeRules(node.nodes) result.push({ ...node, nodes: mergedChildren }) + // Reset the merge cache across at-rule boundaries to preserve + // ordering of selectors before/after the block. + bySelector = new Map() } else { result.push(node) } @@ -894,7 +917,84 @@ export function flattenAst(ast: AstNode[]): AstNode[] { return result } - return mergeRules(flatten(ast, null)) + function canRecombine(nodes: AstNode[]): boolean { + for (let n of nodes) { + if (n.kind !== 'declaration' && n.kind !== 'comment') return false + } + return true + } + + function fingerprint(nodes: AstNode[]): string | null { + if (!canRecombine(nodes)) return null + let parts: string[] = [] + for (let n of nodes) { + if (n.kind === 'declaration') { + parts.push(`d\u0001${n.property}\u0001${n.value ?? ''}\u0001${n.important ? '!' : ''}`) + } else if (n.kind === 'comment') { + parts.push(`c\u0001${n.value}`) + } + } + return parts.join('\u0002') + } + + function recombineAdjacentRules(nodes: AstNode[]): AstNode[] { + let out: AstNode[] = [] + for (let node of nodes) { + if (node.kind === 'at-rule') { + let mergedChildren = recombineAdjacentRules(node.nodes) + + // Merge adjacent conditional at-rules with identical name/params + // to reduce duplication like: + // @media foo { … } + // @media foo { … } + // becomes: + // @media foo { … … } + function canMergeAtRule(name: string) { + return name === '@media' || name === '@supports' || name === '@container' + } + + if ( + out.length > 0 && + out[out.length - 1].kind === 'at-rule' && + (out[out.length - 1] as AtRule).name === node.name && + (out[out.length - 1] as AtRule).params === node.params && + canMergeAtRule(node.name) + ) { + let prev = out[out.length - 1] as AtRule + prev.nodes = recombineAdjacentRules(prev.nodes.concat(mergedChildren)) + continue + } + + out.push({ ...node, nodes: mergedChildren }) + continue + } + + if (node.kind !== 'rule') { + out.push(node) + continue + } + + let fp = fingerprint(node.nodes) + if ( + fp && + out.length > 0 && + out[out.length - 1].kind === 'rule' && + fingerprint((out[out.length - 1] as StyleRule).nodes) === fp + ) { + let prev = out[out.length - 1] as StyleRule + prev.selector = `${prev.selector}, ${node.selector}` + continue + } + + out.push(node) + } + return out + } + + let flattened = flatten(ast, null) + let merged = mergeRules(flattened) + let recombined = recombineAdjacentRules(merged) + return recombined } export function toCss(ast: AstNode[], track?: boolean) { diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index b0e468eb8..abc8ea731 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -5996,7 +5996,10 @@ describe('CSS flattening', () => { `) let flat = flattenAst(ast) expect(toCssAst(flat)).toMatchInlineSnapshot(` - ".a:hover {\n opacity: 0.5;\n}\n.a:focus {\n opacity: 0.5;\n}\n.b:hover {\n opacity: 0.5;\n}\n.b:focus {\n opacity: 0.5;\n}\n" + ".a:hover, .a:focus, .b:hover, .b:focus { + opacity: 0.5; + } + " `) }) @@ -6012,7 +6015,10 @@ describe('CSS flattening', () => { `) let flat = flattenAst(ast) expect(toCssAst(flat)).toMatchInlineSnapshot(` - ".a .child {\n color: blue;\n}\n.a .c2 {\n color: blue;\n}\n.b .child {\n color: blue;\n}\n.b .c2 {\n color: blue;\n}\n" + ".a .child, .a .c2, .b .child, .b .c2 { + color: blue; + } + " `) }) @@ -6028,7 +6034,10 @@ describe('CSS flattening', () => { `) let flat = flattenAst(ast) expect(toCssAst(flat)).toMatchInlineSnapshot(` - ".a > .b {\n color: red;\n}\n.a + .c {\n color: red;\n}\n.a ~ .d {\n color: red;\n}\n" + ".a > .b, .a + .c, .a ~ .d { + color: red; + } + " `) }) @@ -6043,10 +6052,7 @@ describe('CSS flattening', () => { `) let flat = flattenAst(ast) expect(toCssAst(flat)).toMatchInlineSnapshot(` - ".a + .a { - color: green; - } - .b + .b { + ".a + .a, .b + .b { color: green; } " @@ -6067,7 +6073,13 @@ describe('CSS flattening', () => { `) let flat = flattenAst(ast) expect(toCssAst(flat)).toMatchInlineSnapshot(` - ".a:is(:hover, :focus) {\n opacity: 0.5;\n}\n.b:is(:hover, :focus) {\n opacity: 0.5;\n}\n.a :where(.x, .y) {\n color: red;\n}\n.b :where(.x, .y) {\n color: red;\n}\n" + ".a:is(:hover, :focus), .b:is(:hover, :focus) { + opacity: 0.5; + } + .a :where(.x, .y), .b :where(.x, .y) { + color: red; + } + " `) }) @@ -6103,14 +6115,44 @@ describe('CSS flattening', () => { let flat = flattenAst(ast) expect(toCssAst(flat)).toMatchInlineSnapshot(` "@media (min-width: 768px) { - .a:hover { - color: red; - } - .b:hover { + .a:hover, .b:hover { color: red; } } " `) }) + + it('recombines blocks nested in multiple @media rules', () => { + let ast = CSS.parse(css` + .x { + @media foo { + @media bar { + color: red; + } + } + } + .y { + @media foo { + @media bar { + color: blue; + } + } + } + `) + let flat = flattenAst(ast) + expect(toCssAst(flat)).toMatchInlineSnapshot(` + "@media foo { + @media bar { + .x { + color: red; + } + .y { + color: blue; + } + } + } + " + `) + }) }) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index ca2a0b0df..28857baaf 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -399,7 +399,10 @@ async function parseCss( let nodes: AstNode[] = [] if (styleRuleSelectors.length > 0) { - nodes.push(styleRule(styleRuleSelectors.join(', '), r.nodes)) + // Preserve specificity across a selector list by wrapping in :is(…) + // so later flattening/combination doesn’t turn this into two + // separate rules with potentially differing specificity. + nodes.push(styleRule(`:is(${styleRuleSelectors.join(', ')})`, r.nodes)) } for (let selector of atRuleParams) { diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index efe7b3dc6..b515190ad 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -6361,7 +6361,9 @@ function alphaReplacedShadowProperties( if (requiresFallback) { return [ decl(property, applyPrefix(replaceShadowColors(value, varInjector))), - rule('@supports (color: lab(from red l a b))', [decl(property, applyPrefix(replacedValue))]), + rule('@supports (color: lab(from red l a b))', [ + rule('&', [decl(property, applyPrefix(replacedValue))]), + ]), ] } else { return [decl(property, applyPrefix(replacedValue))] @@ -6412,7 +6414,9 @@ function alphaReplacedDropShadowProperties( .map((value) => `drop-shadow(${replaceShadowColors(value, varInjector)})`) .join(' '), ), - rule('@supports (color: lab(from red l a b))', [decl(property, prefix + replacedValue)]), + rule('@supports (color: lab(from red l a b))', [ + rule('&', [decl(property, prefix + replacedValue)]), + ]), ] } else { return [decl(property, prefix + replacedValue)]