Add flattener

This commit is contained in:
Philipp Spiess 2025-09-15 12:28:54 +02:00
parent d0a1bd655b
commit fc5782bee1
3 changed files with 353 additions and 0 deletions

40
AGENTS.md Normal file
View file

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

View file

@ -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<string>()
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<string, StyleRule>()
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

View file

@ -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;
}
}
"
`)
})
})