Compare commits
2 commits
main
...
css-flatte
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee1cd3c0d1 | ||
|
|
fc5782bee1 |
5 changed files with 506 additions and 4 deletions
40
AGENTS.md
Normal file
40
AGENTS.md
Normal 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`.
|
||||||
|
|
||||||
|
|
@ -4,6 +4,7 @@ import type { DesignSystem } from './design-system'
|
||||||
import type { Source, SourceLocation } from './source-maps/source'
|
import type { Source, SourceLocation } from './source-maps/source'
|
||||||
import { Theme, ThemeOptions } from './theme'
|
import { Theme, ThemeOptions } from './theme'
|
||||||
import { DefaultMap } from './utils/default-map'
|
import { DefaultMap } from './utils/default-map'
|
||||||
|
import { segment } from './utils/segment'
|
||||||
import { extractUsedVariables } from './utils/variables'
|
import { extractUsedVariables } from './utils/variables'
|
||||||
import * as ValueParser from './value-parser'
|
import * as ValueParser from './value-parser'
|
||||||
|
|
||||||
|
|
@ -662,7 +663,11 @@ export function optimizeAst(
|
||||||
...declaration,
|
...declaration,
|
||||||
value: ValueParser.toCss(ast),
|
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
|
colorMixQuery.src = declaration.src
|
||||||
parent.splice(idx, 1, fallback, colorMixQuery)
|
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
|
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<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[] = []
|
||||||
|
// 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<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 })
|
||||||
|
// 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) {
|
export function toCss(ast: AstNode[], track?: boolean) {
|
||||||
let pos = 0
|
let pos = 0
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ import fs from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { describe, expect, it, test } from 'vitest'
|
import { describe, expect, it, test } from 'vitest'
|
||||||
import { compile, Features, Polyfills } from '.'
|
import { compile, Features, Polyfills } from '.'
|
||||||
|
import { flattenAst, toCss as toCssAst } from './ast'
|
||||||
import type { PluginAPI } from './compat/plugin-api'
|
import type { PluginAPI } from './compat/plugin-api'
|
||||||
|
import * as CSS from './css-parser'
|
||||||
import plugin from './plugin'
|
import plugin from './plugin'
|
||||||
import { compileCss, optimizeCss, run } from './test-utils/run'
|
import { compileCss, optimizeCss, run } from './test-utils/run'
|
||||||
|
|
||||||
|
|
@ -5966,3 +5968,191 @@ describe('feature detection', () => {
|
||||||
expect(compiler.features & Features.Utilities).toBeFalsy()
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -399,7 +399,10 @@ async function parseCss(
|
||||||
let nodes: AstNode[] = []
|
let nodes: AstNode[] = []
|
||||||
|
|
||||||
if (styleRuleSelectors.length > 0) {
|
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) {
|
for (let selector of atRuleParams) {
|
||||||
|
|
|
||||||
|
|
@ -6361,7 +6361,9 @@ function alphaReplacedShadowProperties(
|
||||||
if (requiresFallback) {
|
if (requiresFallback) {
|
||||||
return [
|
return [
|
||||||
decl(property, applyPrefix(replaceShadowColors(value, varInjector))),
|
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 {
|
} else {
|
||||||
return [decl(property, applyPrefix(replacedValue))]
|
return [decl(property, applyPrefix(replacedValue))]
|
||||||
|
|
@ -6412,7 +6414,9 @@ function alphaReplacedDropShadowProperties(
|
||||||
.map((value) => `drop-shadow(${replaceShadowColors(value, varInjector)})`)
|
.map((value) => `drop-shadow(${replaceShadowColors(value, varInjector)})`)
|
||||||
.join(' '),
|
.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 {
|
} else {
|
||||||
return [decl(property, prefix + replacedValue)]
|
return [decl(property, prefix + replacedValue)]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue