Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/css-tree-parser.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@css-modules-kit/core': patch
---

Replace the postcss-based CSS parser with css-tree to reduce dependencies
4 changes: 2 additions & 2 deletions packages/codegen/e2e-test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,10 @@ test('reports CSS syntax error', async () => {
const cmk = spawnSync('node', [binPath, '--pretty'], { cwd: iff.rootDir });
expect(cmk.status).toBe(1);
expect(stripVTControlCharacters(cmk.stderr.toString())).toMatchInlineSnapshot(`
"src/a.module.css:1:1 - error: Unknown word badword
"src/a.module.css:1:8 - error: "{" is expected

1 badword
~~~~~~~

"
`);
Expand Down
6 changes: 3 additions & 3 deletions packages/codegen/src/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,12 +436,12 @@ describe('getDiagnostics', () => {
{
"category": "error",
"fileName": "<rootDir>/src/b.module.css",
"length": 5,
"length": 1,
"start": {
"column": 8,
"column": 14,
"line": 1,
},
"text": "Unknown word color",
"text": "Colon is expected",
},
]
`);
Expand Down
8 changes: 4 additions & 4 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@
"build": "tsc -b tsconfig.build.json"
},
"dependencies": {
"postcss": "^8.5.9",
"postcss-safe-parser": "^7.0.1",
"postcss-selector-parser": "^7.1.1",
"postcss-value-parser": "^4.2.0"
"css-tree": "^3.2.1"
},
"devDependencies": {
"@types/css-tree": "^2.3.11"
},
"peerDependencies": {
"typescript": ">=5.7.3"
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
export type { CMKConfig } from './config.js';
export { readConfigFile } from './config.js';
export { TsConfigFileNotFoundError, SystemError } from './error.js';
export { parseCSSModule, type ParseCSSModuleOptions } from './parser/css-module-parser.js';
export { parseRule } from './parser/rule-parser.js';
export { parseCSSModule, type ParseCSSModuleOptions, getClassSelectors } from './parser/css-module-parser.js';
export type { ClassSelector } from './parser/rule-parser.js';
export {
type Location,
type Position,
Expand Down
53 changes: 20 additions & 33 deletions packages/core/src/parser/animation-parser.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Declaration } from 'postcss';
import postcssValueParser from 'postcss-value-parser';
import type { Declaration, FunctionNode, Identifier } from 'css-tree';
import type { DiagnosticWithDetachedLocation, TokenReference } from '../type.js';
import { calcDeclValueLoc } from './decl-value-location.js';
import { toLocation } from './csstree.js';

const ANIMATION_NAME_PROP_RE = /^(?:-(?:webkit|moz|o|ms)-)?animation-name$/iu;
const ANIMATION_PROP_RE = /^(?:-(?:webkit|moz|o|ms)-)?animation$/iu;
Expand Down Expand Up @@ -83,35 +82,27 @@ export function parseAnimationProp(decl: Declaration): ParseAnimationResult {
/**
* Walk the top-level value nodes and collect token references. `local(...)` is unwrapped
* to a single reference, while `global(...)`, `var()`, and other functions are skipped.
* Word nodes are turned into references only when `isReference` returns `true`.
* Identifiers are turned into references only when `isReference` returns `true`.
*/
function collectAnimationReferences(decl: Declaration, isReference: (word: string) => boolean): ParseAnimationResult {
const references: TokenReference[] = [];
const diagnostics: DiagnosticWithDetachedLocation[] = [];
const parsed = postcssValueParser(decl.value);
for (const node of parsed.nodes) {
if (node.type === 'function') {
if (decl.value.type !== 'Value') return { references, diagnostics };
for (const node of decl.value.children) {
if (node.type === 'Function') {
// `global(name)`, `var(...)`, `env(...)`, and any other function are skipped.
if (node.value !== 'local') continue;
if (node.name !== 'local') continue;
const nameNodeOrError = unwrapLocalCall(node);
if (nameNodeOrError === 'invalid') {
diagnostics.push(createInvalidLocalCallDiagnostic(decl, node));
diagnostics.push(createInvalidLocalCallDiagnostic(node));
continue;
}
references.push({
type: 'local',
name: nameNodeOrError.value,
loc: calcDeclValueLoc(decl, nameNodeOrError.sourceIndex, nameNodeOrError.value.length),
});
references.push({ type: 'local', name: nameNodeOrError.name, loc: toLocation(nameNodeOrError.loc!) });
continue;
}
if (node.type !== 'word') continue;
if (!isReference(node.value)) continue;
references.push({
type: 'local',
name: node.value,
loc: calcDeclValueLoc(decl, node.sourceIndex, node.value.length),
});
if (node.type !== 'Identifier') continue;
if (!isReference(node.name)) continue;
references.push({ type: 'local', name: node.name, loc: toLocation(node.loc!) });
}
return { references, diagnostics };
}
Expand All @@ -131,24 +122,20 @@ function isKeyframesName(word: string): boolean {
* Inspect a `local(...)` call and return its single identifier, or `'invalid'`
* for any other shape (empty, multiple words, nested functions, strings, etc.).
*/
function unwrapLocalCall(fn: postcssValueParser.FunctionNode): postcssValueParser.WordNode | 'invalid' {
const words = fn.nodes.filter((n) => n.type !== 'space');
if (words.length === 1 && words[0]!.type === 'word') {
return words[0]!;
function unwrapLocalCall(fn: FunctionNode): Identifier | 'invalid' {
const nodes = fn.children.toArray();
if (nodes.length === 1 && nodes[0]!.type === 'Identifier') {
return nodes[0];
}
return 'invalid';
}

function createInvalidLocalCallDiagnostic(
decl: Declaration,
fn: postcssValueParser.FunctionNode,
): DiagnosticWithDetachedLocation {
const length = fn.sourceEndIndex - fn.sourceIndex;
const { start } = calcDeclValueLoc(decl, fn.sourceIndex, length);
function createInvalidLocalCallDiagnostic(fn: FunctionNode): DiagnosticWithDetachedLocation {
const loc = fn.loc!;
return {
text: '`local(...)` must contain exactly one identifier.',
category: 'error',
start: { line: start.line, column: start.column },
length,
start: { line: loc.start.line, column: loc.start.column },
length: loc.end.offset - loc.start.offset,
};
}
19 changes: 9 additions & 10 deletions packages/core/src/parser/at-import-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,15 @@ import { fakeAtImports, fakeRoot } from '../test/ast.js';
import { parseAtImport } from './at-import-parser.js';

test('parseAtImport', () => {
const atImports = fakeAtImports(
fakeRoot(dedent`
@import;
@import "test.css";
@import url("test.css");
@import url(test.css);
@import "test.css" print;
`),
);
expect(atImports.map(parseAtImport)).toMatchInlineSnapshot(`
const text = dedent`
@import;
@import "test.css";
@import url("test.css");
@import url(test.css);
@import "test.css" print;
`;
const atImports = fakeAtImports(fakeRoot(text));
expect(atImports.map((atImport) => parseAtImport(atImport, text))).toMatchInlineSnapshot(`
[
undefined,
{
Expand Down
42 changes: 22 additions & 20 deletions packages/core/src/parser/at-import-parser.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { AtRule } from 'postcss';
import postcssValueParser from 'postcss-value-parser';
import type { Atrule, Url } from 'css-tree';
import type { Location } from '../type.js';
import { stringInnerLocation } from './csstree.js';

interface ParsedAtImport {
from: string;
Expand All @@ -10,33 +10,35 @@ interface ParsedAtImport {
/**
* Parse the `@import` rule.
* @param atImport The `@import` rule to parse.
* @param text The source text of the CSS Module, used to locate the specifier inside `url(...)`.
* @returns The specifier of the imported file.
*/
export function parseAtImport(atImport: AtRule): ParsedAtImport | undefined {
const firstNode = postcssValueParser(atImport.params).nodes[0];
if (firstNode === undefined) return undefined;
if (firstNode.type === 'string') return convertParsedAtImport(atImport, firstNode);
if (firstNode.type === 'function' && firstNode.value === 'url') {
if (firstNode.nodes[0] === undefined) return undefined;
if (firstNode.nodes[0].type === 'string') return convertParsedAtImport(atImport, firstNode.nodes[0]);
if (firstNode.nodes[0].type === 'word') return convertParsedAtImport(atImport, firstNode.nodes[0]);
export function parseAtImport(atImport: Atrule, text: string): ParsedAtImport | undefined {
const prelude = atImport.prelude;
if (prelude === null || prelude.type !== 'AtrulePrelude') return undefined;
const firstNode = prelude.children.first;
if (firstNode === null) return undefined;
if (firstNode.type === 'String') {
return { from: firstNode.value, fromLoc: stringInnerLocation(firstNode.loc!) };
}
if (firstNode.type === 'Url') {
return parseUrl(firstNode, text);
}
return undefined;
}

function convertParsedAtImport(
atImport: AtRule,
node: postcssValueParser.StringNode | postcssValueParser.WordNode,
): ParsedAtImport {
// The length of the `@import ` part in `@import '...'`
const baseLength = 7 + (atImport.raws.afterName?.length ?? 0);
// For string nodes, skip the leading quote to point at the specifier itself.
const startIndex = baseLength + node.sourceIndex + (node.type === 'string' ? 1 : 0);
function parseUrl(node: Url, text: string): ParsedAtImport {
const loc = node.loc!;
// `url(` and the specifier are always on the same line, so a column offset is sufficient.
let offset = loc.start.offset + 'url('.length;
while (/\s/u.test(text[offset] ?? '')) offset++;
if (text[offset] === '"' || text[offset] === "'") offset++;
const startColumn = loc.start.column + (offset - loc.start.offset);
return {
from: node.value,
fromLoc: {
start: atImport.positionBy({ index: startIndex }),
end: atImport.positionBy({ index: startIndex + node.value.length }),
start: { line: loc.start.line, column: startColumn, offset },
end: { line: loc.start.line, column: startColumn + node.value.length, offset: offset + node.value.length },
},
};
}
11 changes: 5 additions & 6 deletions packages/core/src/parser/at-value-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,9 +416,8 @@ describe('parseAtValue', () => {
// guarantee any specific behavior for them. The snapshots below document how the current
// implementation parses them rather than a behavior we commit to.
//
// The `@value \31 e` case is tokenized as `\31` and `e` instead of a single ident `1e`,
// because postcss-value-parser does not interpret CSS escape sequences in identifiers.
// This is a known bug: https://github.com/postcss/postcss-value-parser/issues/64
// The `@value \31 e` case is parsed as the single ident `\31 e`, because css-tree's tokenizer
// treats the CSS escape sequence and the following characters as one identifier.
const atValues = fakeAtValues(
fakeRoot(dedent`
@value;
Expand Down Expand Up @@ -583,17 +582,17 @@ describe('parseAtValue', () => {
},
"loc": {
"end": {
"column": 11,
"column": 13,
"line": 5,
"offset": 83,
"offset": 85,
},
"start": {
"column": 8,
"line": 5,
"offset": 80,
},
},
"name": "\\31",
"name": "\\31 e",
"type": "declaration",
},
"diagnostics": [],
Expand Down
Loading
Loading