diff --git a/packages/vite/helpers/css-ast.spec.ts b/packages/vite/helpers/css-ast.spec.ts index d18132a30e..677cfea060 100644 --- a/packages/vite/helpers/css-ast.spec.ts +++ b/packages/vite/helpers/css-ast.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { joinEscapedSelectorCommas, parseCssAst } from './css-ast.js'; +import { joinEscapedSelectorCommas, normalizeCssAtRules, parseCssAst } from './css-ast.js'; describe('parseCssAst', () => { it('keeps an escaped comma inside a class name', () => { @@ -19,6 +19,58 @@ describe('parseCssAst', () => { }); }); +describe('parseCssAst with at-rules css cannot read', () => { + const selectors = (code: string) => { + const ast = parseCssAst(code, { silent: true }); + expect(ast.stylesheet.parsingErrors).toEqual([]); + return ast.stylesheet.rules.map((rule: any) => (rule.type === 'rule' ? rule.selectors.join(',') : `@${rule.type} ${rule.media ?? ''}`.trim())); + }; + + it.each([ + ['@layer', '@layer base { .a { color: red; } } .b { color: blue; }', ['.a', '.b']], + ['@container', '@container card (min-width: 400px) { .a { color: red; } } .b { color: blue; }', ['.b']], + ['@starting-style', '@starting-style { .a { opacity: 0; } } .b { color: blue; }', ['.b']], + ['@property', '@property --x { syntax: ""; inherits: false; initial-value: 0px; } .b { color: blue; }', ['.b']], + ['@scope', '@scope (.card) { .title { color: red; } } .b { color: blue; }', ['.b']], + ])('keeps the rules after %s', (_name, code, expected) => { + expect(selectors(code)).toEqual(expected); + }); + + it('orders layered rules the way the cascade does: layers in declaration order, then unlayered rules', () => { + const code = ['@charset "utf-8";', '@layer reboot, utilities;', '.app { color: green; }', '@layer utilities { .u { margin: 0; } }', '@layer reboot { .r { padding: 0; } }', '@layer { .anon { color: red; } }', '@layer utilities { .u2 { margin: 1px; } }'].join('\n'); + const ast = parseCssAst(code, { silent: true }); + expect(ast.stylesheet.rules[0].type).toBe('charset'); + expect(selectors(code).slice(1)).toEqual(['.r', '.u', '.u2', '.anon', '.app']); + }); + + it('unwraps layers inside other at-rules and drops unknown at-rules at any depth', () => { + const code = '@media (min-width: 1px) { @layer x { .a { color: red; } } @container (width > 1px) { .c { color: red; } } } .b { color: blue; @starting-style { opacity: 0; } }'; + const ast = parseCssAst(code, { silent: true }); + expect(ast.stylesheet.parsingErrors).toEqual([]); + expect(ast.stylesheet.rules[0].media).toBe('(min-width: 1px)'); + expect(ast.stylesheet.rules[0].rules.map((rule: any) => rule.selectors)).toEqual([['.a']]); + expect(ast.stylesheet.rules[1].declarations.map((decl: any) => `${decl.property}: ${decl.value}`)).toEqual(['color: blue']); + }); + + it('keeps layered @media and @keyframes for core', () => { + const code = '@layer components { @media (min-width: 576px) { .c { width: 1px; } } @keyframes spin { to { opacity: 1; } } }'; + const rules = parseCssAst(code, { silent: true }).stylesheet.rules; + expect(rules.map((rule: any) => rule.type)).toEqual(['media', 'keyframes']); + }); +}); + +describe('normalizeCssAtRules', () => { + it('returns a stylesheet without such at-rules unchanged', () => { + const code = '@import "a.css";\n@media (min-width: 1px) { .a { background: url(a@2x.png); } }\n.b::after { content: "@layer {"; }\n@-webkit-keyframes k { to { opacity: 1 } }'; + expect(normalizeCssAtRules(code)).toBe(code); + }); + + it('leaves text outside the rewritten at-rules as written', () => { + const code = '.a { background: url(data:image/png;base64,AA==); /* } */ }\n@property --x { inherits: false; }\n.b { content: "}"; }'; + expect(normalizeCssAtRules(code)).toBe('.a { background: url(data:image/png;base64,AA==); /* } */ }\n.b { content: "}"; }'); + }); +}); + describe('joinEscapedSelectorCommas', () => { it('does not join after an escaped backslash', () => { // `.a\\` ends in an escaped backslash, so the comma after it is a real separator. diff --git a/packages/vite/helpers/css-ast.ts b/packages/vite/helpers/css-ast.ts index b7606e96a2..afdc993bcc 100644 --- a/packages/vite/helpers/css-ast.ts +++ b/packages/vite/helpers/css-ast.ts @@ -35,9 +35,146 @@ function repairRules(rules: any[] | undefined): void { } } +const PARSEABLE_AT_RULE = /^(?:(?:-[\w]+-)?(?:keyframes|document)|media|custom-media|supports|import|charset|namespace|page|host|font-face)$/i; +const LEADING_AT_RULE = /^(?:charset|import|namespace|layer)$/i; + +interface CssItem { + start: number; + end: number; + open?: number; + close?: number; + children?: CssItem[]; +} + +function splitCssItems(css: string, from: number, nested: boolean): { items: CssItem[]; end: number } { + const items: CssItem[] = []; + let start = from; + let parens = 0; + let i = from; + while (i < css.length) { + const ch = css[i]; + if (ch === '/' && css[i + 1] === '*') { + const close = css.indexOf('*/', i + 2); + i = close === -1 ? css.length : close + 2; + continue; + } + if (ch === '"' || ch === "'") { + i++; + while (i < css.length && css[i] !== ch && css[i] !== '\n') { + i += css[i] === '\\' ? 2 : 1; + } + i++; + continue; + } + if (ch === '\\') { + i += 2; + continue; + } + if (ch === '(') { + parens++; + } else if (ch === ')') { + parens = Math.max(0, parens - 1); + } else if (ch === ';' && parens === 0) { + items.push({ start, end: i + 1 }); + start = i + 1; + } else if (ch === '{') { + const inner = splitCssItems(css, i + 1, true); + const end = Math.min(inner.end + 1, css.length); + items.push({ start, end, open: i, close: inner.end, children: inner.items }); + start = i = end; + parens = 0; + continue; + } else if (ch === '}' && nested) { + break; + } + i++; + } + i = Math.min(i, css.length); + if (start < i) { + items.push({ start, end: i }); + } + return { items, end: i }; +} + +function cssItemAtRule(css: string, item: CssItem): { name: string; prelude: string } | null { + const prelude = css + .slice(item.start, item.open ?? item.end) + .replace(/\/\*[\s\S]*?(?:\*\/|$)/g, ' ') + .trim(); + const match = /^@([-\w]+)/.exec(prelude); + return match ? { name: match[1].toLowerCase(), prelude: prelude.slice(match[0].length).replace(/;$/, '').trim() } : null; +} + +export function normalizeCssAtRules(css: string): string { + let foreign = false; + for (const match of css.matchAll(/@([-\w]+)/g)) { + if (!PARSEABLE_AT_RULE.test(match[1])) { + foreign = true; + break; + } + } + if (!foreign) return css; + + const { items } = splitCssItems(css, 0, false); + + const layerOrder: (string | symbol)[] = []; + const layers = new Map(); + const blockLayer = new Map(); + const declareLayer = (name: string | symbol) => { + if (!layers.has(name)) { + layerOrder.push(name); + layers.set(name, []); + } + }; + for (const item of items) { + const atRule = cssItemAtRule(css, item); + if (atRule?.name !== 'layer') continue; + const names = atRule.prelude + .split(',') + .map((name) => name.trim()) + .filter(Boolean); + if (item.children) { + const name = names[0] ?? Symbol('anonymous layer'); + blockLayer.set(item, name); + declareLayer(name); + } else { + names.forEach(declareLayer); + } + } + + const write = (items: CssItem[], topLevel: boolean): string => { + let out = ''; + for (const item of items) { + const atRule = cssItemAtRule(css, item); + if (atRule && !PARSEABLE_AT_RULE.test(atRule.name)) { + if (atRule.name !== 'layer' || !item.children) continue; + const content = write(item.children, false); + if (!topLevel) { + out += content; + continue; + } + layers.get(blockLayer.get(item)!)!.push(content); + continue; + } + out += item.children ? css.slice(item.start, item.open! + 1) + write(item.children, false) + css.slice(item.close!, item.end) : css.slice(item.start, item.end); + } + return out; + }; + + let head = 0; + while (head < items.length && !items[head].children && LEADING_AT_RULE.test(cssItemAtRule(css, items[head])?.name ?? '')) { + head++; + } + const leading = write(items.slice(0, head), true); + const rest = write(items.slice(head), true); + const layered = layerOrder.flatMap((name) => layers.get(name)!); + if (!layered.length) return leading + rest; + return leading + '\n' + layered.join('\n') + '\n' + rest; +} + /** `css`'s parse, with selector lists repaired (see joinEscapedSelectorCommas). */ export function parseCssAst(code: string, options?: { silent?: boolean; source?: string }): any { - const ast: any = parse(code, options); + const ast: any = parse(normalizeCssAtRules(code), options); repairRules(ast?.stylesheet?.rules); return ast; }