Skip to content

Commit 4f59d0c

Browse files
mizdraclaude
andauthored
feat(core, ts-plugin): support composes property with from specifier (#409)
Class names referenced via `composes: foo from './other.module.css';` are now parsed as external token references, enabling Go to Definition, Find All References, and Rename across files. The check phase reports unresolvable specifiers and tokens not exported by the referenced file. `TokenReference` is now a discriminated union of local and external references so that each phase can handle them separately. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 947d06d commit 4f59d0c

21 files changed

Lines changed: 1106 additions & 506 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@css-modules-kit/core': minor
3+
'@css-modules-kit/ts-plugin': minor
4+
---
5+
6+
feat(core, ts-plugin): support `composes: <name> from '<specifier>'`
7+
8+
Class names referenced via `composes: foo from './other.module.css';` are now linked to the `.foo {...}` declaration in the referenced file. Go to Definition jumps from the reference to the declaration, Find All References lists reference sites across files, and Rename updates the declaration and every reference together.
9+
10+
Two diagnostics are also emitted in the check phase:
11+
12+
- `Cannot import module '<specifier>'` when the specifier cannot be resolved.
13+
- `Module '<specifier>' has no exported token '<name>'.` when the referenced file does not export the token.

AGENTS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,9 @@ function myFunction() {
8787
## Glossary
8888

8989
- **Token**: A generic term for things exported by CSS Modules, such as class names, `@value` definitions, and `@keyframes` names.
90-
- **Token reference**: A usage of a token elsewhere in the CSS. Currently produced for `animation-name: <name>`. The referenced token may be defined in the same file (e.g. `@keyframes`) or imported via `@import` / `@value ... from`.
90+
- **Token reference**: A usage of a token elsewhere in the CSS. Currently produced for `animation-name: <name>` and `composes: <name>`. There are two kinds:
91+
- **Local token reference**: References a token available in the current file. The token may be defined in the same file (e.g. `@keyframes`) or imported via `@import` / `@value ... from`.
92+
- **External token reference**: References tokens exported by another file, like `composes: <name> ... from '<specifier>'`. One reference corresponds to one `from` clause and holds an entry per referenced token.
9193
- **Diagnostic**: An object representing errors or warnings
9294
- **Parse phase**: The phase that parses CSS Modules files and extracts token information
9395
- **Check phase**: The phase that validates CSS Modules files

docs/ts-plugin-internals.ja.md

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -302,9 +302,9 @@ CSS Modules Kit ではこの問題を、
302302

303303
参考: [mizdra/volar-single-quote-span-problem](https://github.com/mizdra/volar-single-quote-span-problem)
304304

305-
### Token References (`animation-name`) のサポート
305+
### Local Token References (`animation-name`, `composes`) のサポート
306306

307-
CSS では `@keyframes foo {...}` で定義したアニメーション名を `animation-name: foo;` で参照できます。CSS Modules Kit はこの参照を Volar.js の mapping を介して定義側と結びつけ、Go to Definition / Find All References / Rename を一貫して動作させます。
307+
CSS では `@keyframes foo {...}` で定義したアニメーション名を `animation-name: foo;` で参照できます。また CSS Modules では `composes: foo;` で同一ファイル内の別のクラス名を参照できます。CSS Modules Kit はこうした現在のファイルで利用可能なトークンへの参照 (local token reference) を Volar.js の mapping を介して定義側と結びつけ、Go to Definition / Find All References / Rename を一貫して動作させます。
308308

309309
仕組みとしては、生成する `.d.ts` の末尾に「参照の式」を埋め込みます。default export の場合は `styles['<name>'];` という bracket access の式文として、named export の場合は自モジュールへの self-import (`declare const __self: typeof import('./<self-basename>');`) を 1 度生成した上で `__self['<name>'];` という bracket access として吐きます。各参照式のクオート内側部分には CSS 側の参照位置の mapping を張ります。
310310

@@ -340,3 +340,35 @@ export { _token_1 as 'a_2' };
340340
declare const __self: typeof import('./a.module.css');
341341
__self['a_1'];
342342
```
343+
344+
### External Token References (`composes: ... from '<specifier>'`) のサポート
345+
346+
CSS Modules では `composes: b_1 b_2 from './b.module.css';` のように、`from` 句で指定した別ファイルのトークンを参照できます。CSS Modules Kit はこうした別ファイルがエクスポートするトークンへの参照 (external token reference) も、local token reference と同じく参照式と mapping によって定義側と結びつけます。
347+
348+
例えば、次のような CSS モジュールがあるとします:
349+
350+
`src/a.module.css`:
351+
352+
```css
353+
.a_1 { composes: b_1 b_2 from './b.module.css'; }
354+
```
355+
356+
default export の場合、次のような型定義が生成されます:
357+
358+
```ts
359+
declare const styles = {
360+
'a_1': '' as string,
361+
} as const;
362+
(await import('./b.module.css')).default['b_1'];
363+
(await import('./b.module.css')).default['b_2'];
364+
export default styles;
365+
```
366+
367+
named export の場合、次のような型定義が生成されます:
368+
369+
```ts
370+
var _token_0: string;
371+
export { _token_0 as 'a_1' };
372+
(await import('./b.module.css'))['b_1'];
373+
(await import('./b.module.css'))['b_2'];
374+
```

docs/ts-plugin-internals.md

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -305,9 +305,9 @@ With this, `getDefinitionAtPosition`'s `{ start: 27, length: 5 }` also matches t
305305

306306
Reference: [mizdra/volar-single-quote-span-problem](https://github.com/mizdra/volar-single-quote-span-problem)
307307

308-
### Token References (`animation-name`) Support
308+
### Local Token References (`animation-name`, `composes`) Support
309309

310-
In CSS, an animation name defined with `@keyframes foo {...}` can be referenced by `animation-name: foo;`. CSS Modules Kit links such references to their definitions via Volar.js mappings, making Go to Definition / Find All References / Rename work consistently.
310+
In CSS, an animation name defined with `@keyframes foo {...}` can be referenced by `animation-name: foo;`. In CSS Modules, `composes: foo;` can also reference another class name in the same file. CSS Modules Kit links such references to tokens available in the current file (local token references) to their definitions via Volar.js mappings, making Go to Definition / Find All References / Rename work consistently.
311311

312312
The mechanism is to embed "reference expressions" at the end of the generated `.d.ts`. For default export, it is emitted as a bracket access expression statement like `styles['<name>'];`. For named export, after emitting a self-import (`declare const __self: typeof import('./<self-basename>');`) once, it is emitted as a bracket access like `__self['<name>'];`. A mapping is attached to the inside-of-quotes part of each reference expression, pointing to the reference position in the CSS.
313313

@@ -343,3 +343,35 @@ export { _token_1 as 'a_2' };
343343
declare const __self: typeof import('./a.module.css');
344344
__self['a_1'];
345345
```
346+
347+
### External Token References (`composes: ... from '<specifier>'`) Support
348+
349+
In CSS Modules, tokens of another file can be referenced with a `from` clause, like `composes: b_1 b_2 from './b.module.css';`. CSS Modules Kit links such references to tokens exported by another file (external token references) to their definitions via reference expressions and mappings, just like local token references.
350+
351+
For example, suppose we have the following CSS module:
352+
353+
`src/a.module.css`:
354+
355+
```css
356+
.a_1 { composes: b_1 b_2 from './b.module.css'; }
357+
```
358+
359+
For default export, the following type definition is generated:
360+
361+
```ts
362+
declare const styles = {
363+
'a_1': '' as string,
364+
} as const;
365+
(await import('./b.module.css')).default['b_1'];
366+
(await import('./b.module.css')).default['b_2'];
367+
export default styles;
368+
```
369+
370+
For named export, the following type definition is generated:
371+
372+
```ts
373+
var _token_0: string;
374+
export { _token_0 as 'a_1' };
375+
(await import('./b.module.css'))['b_1'];
376+
(await import('./b.module.css'))['b_2'];
377+
```

examples/1-basic/src/a.module.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
100% { transform: translateY(-100%); }
88
}
99
.a_5 {
10-
composes: a_1;
10+
composes: a_1, b_1 from './b.module.css';
1111
animation-name: a_4;
1212
}
1313

packages/core/src/checker.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,4 +300,59 @@ describe('checkCSSModule', () => {
300300
const diagnostics = check(readAndParseCSSModule(iff.paths['a.module.css'])!);
301301
expect(diagnostics).toEqual([]);
302302
});
303+
test('report diagnostics for external references with unresolvable modules', async () => {
304+
// The diagnostic is reported once per `from` clause, even if it has multiple entries.
305+
const iff = await createIFF({
306+
'a.module.css': `.a_1 { composes: b_1 b_2 from './b.module.css'; }`,
307+
});
308+
const check = prepareChecker();
309+
const diagnostics = check(readAndParseCSSModule(iff.paths['a.module.css'])!);
310+
expect(formatDiagnostics(diagnostics, iff.rootDir)).toMatchInlineSnapshot(`
311+
[
312+
{
313+
"category": "error",
314+
"fileName": "<rootDir>/a.module.css",
315+
"length": 14,
316+
"start": {
317+
"column": 32,
318+
"line": 1,
319+
},
320+
"text": "Cannot import module './b.module.css'",
321+
},
322+
]
323+
`);
324+
});
325+
test('report diagnostics for external references to non-exported tokens', async () => {
326+
const iff = await createIFF({
327+
'a.module.css': `.a_1 { composes: b_1 b_2 from './b.module.css'; }`,
328+
'b.module.css': `.b_1 { color: red; }`,
329+
});
330+
const check = prepareChecker();
331+
const diagnostics = check(readAndParseCSSModule(iff.paths['a.module.css'])!);
332+
expect(formatDiagnostics(diagnostics, iff.rootDir)).toMatchInlineSnapshot(`
333+
[
334+
{
335+
"category": "error",
336+
"fileName": "<rootDir>/a.module.css",
337+
"length": 3,
338+
"start": {
339+
"column": 22,
340+
"line": 1,
341+
},
342+
"text": "Module './b.module.css' has no exported token 'b_2'.",
343+
},
344+
]
345+
`);
346+
});
347+
test('do not report diagnostics for external references for unmatched modules', async () => {
348+
const iff = await createIFF({
349+
'a.module.css': `.a_1 { composes: unmatched_1 from './unmatched.module.css'; }`,
350+
'unmatched.module.css': '.unmatched_1 { color: red; }',
351+
});
352+
const check = prepareChecker({
353+
matchesPattern: (path) => path.endsWith('.module.css') && !path.endsWith('unmatched.module.css'),
354+
});
355+
const diagnostics = check(readAndParseCSSModule(iff.paths['a.module.css'])!);
356+
expect(diagnostics).toEqual([]);
357+
});
303358
});

packages/core/src/checker.ts

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,9 @@ import type {
44
Diagnostic,
55
ExportRecord,
66
Location,
7+
LocalTokenReference,
78
MatchesPattern,
8-
NamedTokenImporter,
9-
NamedTokenImporterEntry,
109
Resolver,
11-
TokenImporter,
12-
TokenReference,
1310
} from './type.js';
1411
import { isURLSpecifier, type TokenNameViolation, validateTokenName } from './util.js';
1512

@@ -37,7 +34,7 @@ export function checkCSSModule(cssModule: CSSModule, args: CheckerArgs): Diagnos
3734
if (isURLSpecifier(tokenImporter.from)) continue;
3835
const from = args.resolver(tokenImporter.from, { request: cssModule.fileName });
3936
if (!from) {
40-
diagnostics.push(createCannotImportModuleDiagnostic(cssModule, tokenImporter));
37+
diagnostics.push(createCannotImportModuleDiagnostic(cssModule, tokenImporter.from, tokenImporter.fromLoc));
4138
continue;
4239
}
4340
if (!args.matchesPattern(from)) continue;
@@ -48,7 +45,9 @@ export function checkCSSModule(cssModule: CSSModule, args: CheckerArgs): Diagnos
4845
const exportRecord = args.getExportRecord(imported);
4946
for (const entry of tokenImporter.entries) {
5047
if (!exportRecord.allTokens.includes(entry.name)) {
51-
diagnostics.push(createModuleHasNoExportedTokenDiagnostic(cssModule, tokenImporter, entry));
48+
diagnostics.push(
49+
createModuleHasNoExportedTokenDiagnostic(cssModule, tokenImporter.from, entry.name, entry.loc),
50+
);
5251
}
5352
const nameViolation = validateTokenName(entry.name, { namedExports: config.namedExports });
5453
if (nameViolation) {
@@ -66,8 +65,26 @@ export function checkCSSModule(cssModule: CSSModule, args: CheckerArgs): Diagnos
6665

6766
const exportRecord = args.getExportRecord(cssModule);
6867
for (const reference of cssModule.tokenReferences) {
69-
if (!exportRecord.allTokens.includes(reference.name)) {
70-
diagnostics.push(createTokenNotFoundDiagnostic(cssModule, reference));
68+
if (reference.type === 'local') {
69+
if (!exportRecord.allTokens.includes(reference.name)) {
70+
diagnostics.push(createTokenNotFoundDiagnostic(cssModule, reference));
71+
}
72+
continue;
73+
}
74+
// Unlike `@import`, URL specifiers are not skipped because css-loader fails to resolve them in `composes`.
75+
const from = args.resolver(reference.from, { request: cssModule.fileName });
76+
if (!from) {
77+
diagnostics.push(createCannotImportModuleDiagnostic(cssModule, reference.from, reference.fromLoc));
78+
continue;
79+
}
80+
if (!args.matchesPattern(from)) continue;
81+
const imported = args.getCSSModule(from);
82+
if (!imported) throw new Error('unreachable: `imported` is undefined');
83+
const importedExportRecord = args.getExportRecord(imported);
84+
for (const entry of reference.entries) {
85+
if (!importedExportRecord.allTokens.includes(entry.name)) {
86+
diagnostics.push(createModuleHasNoExportedTokenDiagnostic(cssModule, reference.from, entry.name, entry.loc));
87+
}
7188
}
7289
}
7390
return diagnostics;
@@ -97,31 +114,32 @@ function createTokenNameDiagnostic(cssModule: CSSModule, loc: Location, violatio
97114
};
98115
}
99116

100-
function createCannotImportModuleDiagnostic(cssModule: CSSModule, tokenImporter: TokenImporter): Diagnostic {
117+
function createCannotImportModuleDiagnostic(cssModule: CSSModule, from: string, fromLoc: Location): Diagnostic {
101118
return {
102-
text: `Cannot import module '${tokenImporter.from}'`,
119+
text: `Cannot import module '${from}'`,
103120
category: 'error',
104121
file: { fileName: cssModule.fileName, text: cssModule.text },
105-
start: { line: tokenImporter.fromLoc.start.line, column: tokenImporter.fromLoc.start.column },
106-
length: tokenImporter.fromLoc.end.offset - tokenImporter.fromLoc.start.offset,
122+
start: { line: fromLoc.start.line, column: fromLoc.start.column },
123+
length: fromLoc.end.offset - fromLoc.start.offset,
107124
};
108125
}
109126

110127
function createModuleHasNoExportedTokenDiagnostic(
111128
cssModule: CSSModule,
112-
tokenImporter: NamedTokenImporter,
113-
entry: NamedTokenImporterEntry,
129+
from: string,
130+
name: string,
131+
loc: Location,
114132
): Diagnostic {
115133
return {
116-
text: `Module '${tokenImporter.from}' has no exported token '${entry.name}'.`,
134+
text: `Module '${from}' has no exported token '${name}'.`,
117135
category: 'error',
118136
file: { fileName: cssModule.fileName, text: cssModule.text },
119-
start: { line: entry.loc.start.line, column: entry.loc.start.column },
120-
length: entry.loc.end.offset - entry.loc.start.offset,
137+
start: { line: loc.start.line, column: loc.start.column },
138+
length: loc.end.offset - loc.start.offset,
121139
};
122140
}
123141

124-
function createTokenNotFoundDiagnostic(cssModule: CSSModule, reference: TokenReference): Diagnostic {
142+
function createTokenNotFoundDiagnostic(cssModule: CSSModule, reference: LocalTokenReference): Diagnostic {
125143
return {
126144
text: `Cannot find token '${reference.name}'.`,
127145
category: 'error',

packages/core/src/dts-generator.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,90 @@ describe('emits token reference statements when forTsPlugin is true', () => {
370370
});
371371
});
372372

373+
describe('emits external token reference statements when forTsPlugin is true', () => {
374+
// `b_1` and `b_2` share one `from` clause. The `from` clause of `b_3` has the same specifier
375+
// as the first one, but is a separate clause.
376+
const source = `.a_1 { composes: b_1 b_2 from './b.module.css', b_3 from './b.module.css'; }`;
377+
test('default export', async () => {
378+
expect(await run(source, { ...defaultExportOptions, forTsPlugin: true })).toMatchInlineSnapshot(`
379+
"=== source ===
380+
.a_1 { composes: b_1 b_2 from './b.module.css', b_3 from './b.module.css'; }
381+
^^^^^^^^^^^^^^^^ mapping[4]
382+
^^^ mapping[5]
383+
^^^^^^^^^^^^^^^^ mapping[1]
384+
^^^ mapping[3]
385+
^^^ mapping[2]
386+
^^^ mapping[0]
387+
388+
=== generated ===
389+
// @ts-nocheck
390+
declare const styles = {
391+
'a_1': '' as string,
392+
^^^ mapping[0]
393+
} as const;
394+
(await import('./b.module.css')).default['b_1'];
395+
^^^ mapping[2]
396+
^^^^^^^^^^^^^^^^ mapping[1]
397+
(await import('./b.module.css')).default['b_2'];
398+
^^^ mapping[3]
399+
(await import('./b.module.css')).default['b_3'];
400+
^^^ mapping[5]
401+
^^^^^^^^^^^^^^^^ mapping[4]
402+
export default styles;
403+
"
404+
`);
405+
});
406+
test('named export', async () => {
407+
expect(await run(source, { ...namedExportOptions, forTsPlugin: true })).toMatchInlineSnapshot(`
408+
"=== source ===
409+
.a_1 { composes: b_1 b_2 from './b.module.css', b_3 from './b.module.css'; }
410+
^^^^^^^^^^^^^^^^ mapping[4]
411+
^^^ mapping[5]
412+
^^^^^^^^^^^^^^^^ mapping[1]
413+
^^^ mapping[3]
414+
^^^ mapping[2]
415+
^^^ mapping[0]
416+
417+
=== generated ===
418+
// @ts-nocheck
419+
var _token_0: string;
420+
^^^^^^^^ mapping[0]
421+
export { _token_0 as 'a_1' };
422+
^^^^^ linkedCodeMapping[0]
423+
^^^^^^^^ linkedCodeMapping[0]
424+
(await import('./b.module.css'))['b_1'];
425+
^^^ mapping[2]
426+
^^^^^^^^^^^^^^^^ mapping[1]
427+
(await import('./b.module.css'))['b_2'];
428+
^^^ mapping[3]
429+
(await import('./b.module.css'))['b_3'];
430+
^^^ mapping[5]
431+
^^^^^^^^^^^^^^^^ mapping[4]
432+
declare const styles: {};
433+
export default styles;
434+
"
435+
`);
436+
});
437+
});
438+
439+
test('omits external token reference statements whose specifier is a URL', async () => {
440+
const source = `.a_1 { composes: b_1 from 'https://example.com/b.module.css'; }`;
441+
expect(await run(source, { ...defaultExportOptions, forTsPlugin: true })).toMatchInlineSnapshot(`
442+
"=== source ===
443+
.a_1 { composes: b_1 from 'https://example.com/b.module.css'; }
444+
^^^ mapping[0]
445+
446+
=== generated ===
447+
// @ts-nocheck
448+
declare const styles = {
449+
'a_1': '' as string,
450+
^^^ mapping[0]
451+
} as const;
452+
export default styles;
453+
"
454+
`);
455+
});
456+
373457
describe('omits importers whose specifier is a URL', () => {
374458
const source = dedent`
375459
@import 'https://example.com/b.module.css';

0 commit comments

Comments
 (0)