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..006d2ef30 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' @@ -662,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) } @@ -729,9 +734,269 @@ 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 } +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 bufferDecls: AstNode[] = [] + let flush = () => { + if (bufferDecls.length === 0) return + for (let sel of current) { + result.push({ + kind: 'rule', + selector: normalizePseudoElements(sel), + nodes: bufferDecls.slice(), + }) + } + bufferDecls = [] + } + + 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') { + // 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' || + child.name === '@charset' || + child.name === '@custom-media' || + child.name === '@namespace' || + child.name === '@import' + ) { + result.push({ ...child, nodes: inner }) + } + } + } + + // Flush remaining decls after processing nested children + flush() + } 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[] = [] + // 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) { + 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 }) + // Reset the merge cache across at-rule boundaries to preserve + // ordering of selectors before/after the block. + bySelector = new Map() + } else { + result.push(node) + } + } + + return result + } + + 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) { let pos = 0 diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index cfd0d1db8..abc8ea731 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,191 @@ 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, .a:focus, .b:hover, .b:focus { + opacity: 0.5; + } + " + `) + }) + + 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, .a .c2, .b .child, .b .c2 { + color: blue; + } + " + `) + }) + + it('relative combinators (>, +, ~)', () => { + let ast = CSS.parse(css` + .a { + > .b, + + .c, + ~ .d { + color: red; + } + } + `) + let flat = flattenAst(ast) + expect(toCssAst(flat)).toMatchInlineSnapshot(` + ".a > .b, .a + .c, .a ~ .d { + color: red; + } + " + `) + }) + + it('multiple &: & + & over lists', () => { + let ast = CSS.parse(css` + .a, + .b { + & + & { + color: green; + } + } + `) + let flat = flattenAst(ast) + expect(toCssAst(flat)).toMatchInlineSnapshot(` + ".a + .a, .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), .b:is(:hover, :focus) { + opacity: 0.5; + } + .a :where(.x, .y), .b :where(.x, .y) { + color: red; + } + " + `) + }) + + 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, .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)]