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
7 changes: 7 additions & 0 deletions packages/core/css/css-tree-parser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ describe('CssTreeParser', () => {
expect(reworkAST.stylesheet.rules[0].declarations[0].value).toBe('red');
});

it('keeps escaped commas inside a selector', () => {
const testCase = '.grid-cols-\\[repeat\\(auto-fill\\,minmax\\(260px\\,1fr\\)\\)\\], .a\\\\, .b { color: red; }';
const expected = ['.grid-cols-\\[repeat\\(auto-fill\\,minmax\\(260px\\,1fr\\)\\)\\]', '.a\\\\', '.b'];
expect(reworkCssParse(testCase, { source: 'file.css' }).stylesheet.rules[0].selectors).toEqual(expected);
expect(cssTreeParse(testCase, 'file.css').stylesheet.rules[0].selectors).toEqual(expected);
});

it('empty rule', () => {
const css = `.test {
color: red;
Expand Down
6 changes: 5 additions & 1 deletion packages/core/css/css-tree-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ function mapSelectors(selector: string): string[] {
return [];
}

return selector.split(/\s*(?![^(]*\)),\s*/).map((s) => s.replace(/\u200C/g, ','));
// escaped commas (Tailwind arbitrary values) are part of the selector
return selector
.replace(/\\[\s\S]/g, (m) => (m === '\\,' ? '\\\u200C' : m))
.split(/\s*(?![^(]*\)),\s*/)
.map((s) => s.replace(/\u200C/g, ','));
}

function mapPosition(node, css) {
Expand Down
6 changes: 5 additions & 1 deletion packages/core/css/lib/parse/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,12 +195,16 @@ export function parse(css, options) {
var m = match(/^([^{]+)/);
if (!m) return;
/* @fix Remove all comments from selectors
* http://ostermiller.org/findcomment.html */
* http://ostermiller.org/findcomment.html
* Escaped commas (Tailwind arbitrary values) are masked like quoted ones. */
return trim(m[0])
.replace(/\/\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*\/+/g, '')
.replace(/"(?:\\"|[^"])*"|'(?:\\'|[^'])*'/g, function (m) {
return m.replace(/,/g, '\u200C');
})
.replace(/\\[\s\S]/g, function (m) {
return m === '\\,' ? '\\\u200C' : m;
})
.split(/\s*(?![^(]*\)),\s*/)
.map(function (s) {
return s.replace(/\u200C/g, ',');
Expand Down
27 changes: 27 additions & 0 deletions packages/vite/helpers/css-ast.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';

import { joinEscapedSelectorCommas, parseCssAst } from './css-ast.js';

describe('parseCssAst', () => {
it('keeps an escaped comma inside a class name', () => {
const ast = parseCssAst('.grid-cols-\\[repeat\\(auto-fill\\,minmax\\(260\\,1fr\\)\\)\\] { grid-template-columns: repeat(auto-fill,minmax(260,1fr)); }');
expect(ast.stylesheet.rules[0].selectors).toEqual(['.grid-cols-\\[repeat\\(auto-fill\\,minmax\\(260\\,1fr\\)\\)\\]']);
});

it('still splits a real selector list', () => {
const ast = parseCssAst('.a\\,b, .c { color: red; }');
expect(ast.stylesheet.rules[0].selectors).toEqual(['.a\\,b', '.c']);
});

it('repairs rules nested in at-rules', () => {
const ast = parseCssAst('@media (min-width: 1px) { .x-\\[a\\,b\\] { color: red; } }');
expect(ast.stylesheet.rules[0].rules[0].selectors).toEqual(['.x-\\[a\\,b\\]']);
});
});

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.
expect(joinEscapedSelectorCommas(['.a\\\\', '.b'])).toEqual(['.a\\\\', '.b']);
});
});
43 changes: 43 additions & 0 deletions packages/vite/helpers/css-ast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { parse } from 'css';

/**
* Rejoins selectors that `css` split at an escaped comma.
*
* `css` splits a rule's selector list on every comma outside parentheses, and
* treats an escaped `\(` as a parenthesis for that purpose, so an escaped
* `\,` inside a class name splits the class: Tailwind's
* `.grid-cols-\[repeat\(auto-fill\,minmax\(260\,1fr\)\)\]` became
* `.grid-cols-\[repeat\(auto-fill\` and `minmax\(260\,1fr\)\)\]`, neither of
* which matches anything. A piece that ends in an odd number of backslashes
* ended on an escaped comma, so it is glued back to the next one.
*/
export function joinEscapedSelectorCommas(selectors: string[]): string[] {
const joined: string[] = [];
for (const selector of selectors) {
const previous = joined[joined.length - 1];
if (previous !== undefined && /(?:^|[^\\])(?:\\\\)*\\$/.test(previous)) {
joined[joined.length - 1] = `${previous},${selector}`;
} else {
joined.push(selector);
}
}
return joined;
}

function repairRules(rules: any[] | undefined): void {
if (!Array.isArray(rules)) return;
for (const rule of rules) {
if (Array.isArray(rule?.selectors)) {
rule.selectors = joinEscapedSelectorCommas(rule.selectors);
}
// @media, @supports, @document, @host, ... nest their rules.
repairRules(rule?.rules);
}
}

/** `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);
repairRules(ast?.stylesheet?.rules);
return ast;
}
2 changes: 1 addition & 1 deletion packages/vite/helpers/main-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { getPackageJson, getProjectFilePath, getProjectRootPath } from './projec
import fs from 'fs';
import path from 'path';
import { preprocessCSS, type ResolvedConfig, type ViteDevServer } from 'vite';
import { parse as parseCssToAst } from 'css';
import { parseCssAst as parseCssToAst } from './css-ast.js';
import { getProjectFlavor } from './flavor.js';
import { getProjectAppPath, getProjectAppRelativePath, getProjectAppVirtualPath, resolveProjectGlobalCssPath } from './utils.js';
import { getResolvedAppComponents } from './app-components.js';
Expand Down
50 changes: 50 additions & 0 deletions packages/webpack5/__tests__/loaders/css2json-loader.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import css2jsonLoader, {
joinEscapedSelectorCommas,
} from '../../src/loaders/css2json-loader';

function run(css: string): any {
let output = '';
css2jsonLoader.call(
{
getOptions: () => ({}),
callback: (_error: unknown, code: string) => {
output = code;
},
},
css,
null,
);
const json = output
.slice(output.indexOf('=') + 1, output.lastIndexOf('export default'))
.trim();
return JSON.parse(json);
}

describe('css2json-loader', () => {
it('keeps an escaped comma inside a class name', () => {
const ast = run(
'.grid-cols-\\[repeat\\(auto-fill\\,minmax\\(260\\,1fr\\)\\)\\] { grid-template-columns: repeat(auto-fill,minmax(260,1fr)); }',
);
expect(ast.stylesheet.rules[0].selectors).toEqual([
'.grid-cols-\\[repeat\\(auto-fill\\,minmax\\(260\\,1fr\\)\\)\\]',
]);
});

it('still splits a real selector list, including in at-rules', () => {
const ast = run(
'.a\\,b, .c { color: red; } @media (min-width: 1px) { .x-\\[a\\,b\\], .y { color: red; } }',
);
expect(ast.stylesheet.rules[0].selectors).toEqual(['.a\\,b', '.c']);
expect(ast.stylesheet.rules[1].rules[0].selectors).toEqual([
'.x-\\[a\\,b\\]',
'.y',
]);
});

it('does not join after an escaped backslash', () => {
expect(joinEscapedSelectorCommas(['.a\\\\', '.b'])).toEqual([
'.a\\\\',
'.b',
]);
});
});
37 changes: 34 additions & 3 deletions packages/webpack5/src/loaders/css2json-loader/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export default function loader(content: string, map: any) {
const requirePrefix = inline ? inlineLoader : '';

const ast = parse(content);
repairSelectors(ast.stylesheet?.rules);

// todo: revise if this is necessary
// todo: perhaps use postCSS and just build imports into a single file?
Expand All @@ -36,7 +37,7 @@ export default function loader(content: string, map: any) {
this.callback(
null,
code, //`${dependencies.join('\n')}module.exports = ${str};`,
map
map,
);
}

Expand All @@ -46,15 +47,15 @@ function getImportRules(ast: Stylesheet): Import[] {
}
return <Import[]>(
ast.stylesheet.rules.filter(
(rule) => rule.type === 'import' && (<any>rule).import
(rule) => rule.type === 'import' && (<any>rule).import,
)
);
}

function getAndRemoveImportRules(ast: Stylesheet): Import[] {
const imports = getImportRules(ast);
ast.stylesheet.rules = ast.stylesheet.rules.filter(
(rule) => rule.type !== 'import'
(rule) => rule.type !== 'import',
);

return imports;
Expand All @@ -79,3 +80,33 @@ function createRequireUri(uri): { uri: string; requireURI: string } {
requireURI: urlToRequest(uri),
};
}

/**
* `css` splits a selector list on every comma outside parentheses and counts an escaped `\(` as a
* parenthesis, so an escaped `\,` in a class name (Tailwind's `grid-cols-[repeat(auto-fill,...)]`)
* splits the class in two and neither half matches. A piece ending in an odd number of
* backslashes ended on an escaped comma: glue it back to the next one.
*/
export function joinEscapedSelectorCommas(selectors: string[]): string[] {
const joined: string[] = [];
for (const selector of selectors) {
const previous = joined[joined.length - 1];
if (previous !== undefined && /(?:^|[^\\])(?:\\\\)*\\$/.test(previous)) {
joined[joined.length - 1] = `${previous},${selector}`;
} else {
joined.push(selector);
}
}
return joined;
}

function repairSelectors(rules: any[] | undefined): void {
if (!Array.isArray(rules)) return;
for (const rule of rules) {
if (Array.isArray(rule?.selectors)) {
rule.selectors = joinEscapedSelectorCommas(rule.selectors);
}
// @media, @supports, ... nest their rules.
repairSelectors(rule?.rules);
}
}
Loading