Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion packages/vite/helpers/css-ast.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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: "<length>"; 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([email protected]); } }\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.
Expand Down
139 changes: 138 additions & 1 deletion packages/vite/helpers/css-ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | symbol, string[]>();
const blockLayer = new Map<CssItem, string | symbol>();
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;
}
Loading