From fc5782bee1c13c32512d4b2355ac17e680fdf1aa Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Mon, 15 Sep 2025 12:28:54 +0200 Subject: [PATCH] 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; + } + } + " + `) + }) +})