Skip to content

Commit 32ecdc2

Browse files
authored
feat(core, ts-plugin, codegen)!: include types in .d.ts files for unresolved or unmatched module imports (#302)
1 parent 96108ad commit 32ecdc2

6 files changed

Lines changed: 51 additions & 187 deletions

File tree

.changeset/nine-shoes-cough.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@css-modules-kit/ts-plugin': minor
3+
'@css-modules-kit/codegen': minor
4+
'@css-modules-kit/core': minor
5+
---
6+
7+
feat!: include types in .d.ts files for unresolved or unmatched module imports

packages/codegen/src/project.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ export function createProject(args: ProjectArgs): Project {
212212
const promises: Promise<void>[] = [];
213213
for (const cssModule of cssModuleMap.values()) {
214214
if (emittedSet.has(cssModule.fileName)) continue;
215-
const dts = generateDts(cssModule, { resolver, matchesPattern }, { ...config, forTsPlugin: false });
215+
const dts = generateDts(cssModule, { ...config, forTsPlugin: false });
216216
promises.push(
217217
writeDtsFile(dts.text, cssModule.fileName, {
218218
outDir: config.dtsOutDir,

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

Lines changed: 20 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,9 @@
11
import dedent from 'dedent';
22
import { describe, expect, test } from 'vitest';
3-
import { generateDts, type GenerateDtsHost, type GenerateDtsOptions } from './dts-generator.js';
3+
import { generateDts, type GenerateDtsOptions } from './dts-generator.js';
44
import { readAndParseCSSModule } from './test/css-module.js';
5-
import { fakeMatchesPattern, fakeResolver } from './test/faker.js';
65
import { createIFF } from './test/fixture.js';
76

8-
const host: GenerateDtsHost = {
9-
resolver: fakeResolver(),
10-
matchesPattern: fakeMatchesPattern(),
11-
};
127
const options: GenerateDtsOptions = {
138
namedExports: false,
149
prioritizeNamedImports: false,
@@ -20,8 +15,7 @@ describe('generateDts', () => {
2015
const iff = await createIFF({
2116
'test.module.css': '',
2217
});
23-
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, host, options).text)
24-
.toMatchInlineSnapshot(`
18+
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, options).text).toMatchInlineSnapshot(`
2519
"// @ts-nocheck
2620
declare const styles = {
2721
};
@@ -36,8 +30,7 @@ describe('generateDts', () => {
3630
.local2 { color: red; }
3731
`,
3832
});
39-
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, host, options).text)
40-
.toMatchInlineSnapshot(`
33+
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, options).text).toMatchInlineSnapshot(`
4134
"// @ts-nocheck
4235
declare const styles = {
4336
local1: '' as readonly string,
@@ -47,107 +40,48 @@ describe('generateDts', () => {
4740
"
4841
`);
4942
});
50-
test('generates d.ts file with token importers', async () => {
43+
test('generates types for token importers', async () => {
5144
const iff = await createIFF({
5245
'test.module.css': dedent`
5346
@import './a.module.css';
54-
@value imported1 from './b.module.css';
55-
@value imported2 as aliasedImported2 from './c.module.css';
47+
@value imported1, imported2 as aliasedImported2 from './b.module.css';
5648
`,
57-
'a.module.css': '',
58-
'b.module.css': '',
59-
'c.module.css': '',
6049
});
61-
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, host, options).text)
62-
.toMatchInlineSnapshot(`
50+
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, options).text).toMatchInlineSnapshot(`
6351
"// @ts-nocheck
6452
declare const styles = {
6553
...(await import('./a.module.css')).default,
6654
imported1: (await import('./b.module.css')).default.imported1,
67-
aliasedImported2: (await import('./c.module.css')).default.imported2,
55+
aliasedImported2: (await import('./b.module.css')).default.imported2,
6856
};
6957
export default styles;
7058
"
7159
`);
7260
});
73-
test('resolves specifiers', async () => {
61+
test('does not generate types for URL token importers', async () => {
7462
const iff = await createIFF({
7563
'test.module.css': dedent`
76-
@import '@/a.module.css';
77-
@value imported1 from '@/b.module.css';
78-
@value imported2 as aliasedImported2 from '@/c.module.css';
64+
@import 'https://example.com/a.module.css';
65+
@value imported1 from 'https://example.com/b.module.css';
7966
`,
80-
'a.module.css': '',
81-
'b.module.css': '',
82-
'c.module.css': '',
8367
});
84-
const resolver = (specifier: string) => specifier.replace('@', '/src');
85-
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, { ...host, resolver }, options).text)
86-
.toMatchInlineSnapshot(`
87-
"// @ts-nocheck
88-
declare const styles = {
89-
...(await import('@/a.module.css')).default,
90-
imported1: (await import('@/b.module.css')).default.imported1,
91-
aliasedImported2: (await import('@/c.module.css')).default.imported2,
92-
};
93-
export default styles;
94-
"
95-
`);
96-
});
97-
test('does not generate types for unmatched modules', async () => {
98-
const iff = await createIFF({
99-
'test.module.css': dedent`
100-
@import './unmatched.module.css';
101-
@value unmatched_1 from './unmatched.module.css';
102-
`,
103-
'unmatched.module.css': '.unmatched_1 { color: red; }',
104-
});
105-
expect(
106-
generateDts(
107-
readAndParseCSSModule(iff.paths['test.module.css'])!,
108-
{ ...host, matchesPattern: (path) => path.endsWith('.module.css') && !path.endsWith('unmatched.module.css') },
109-
options,
110-
).text,
111-
).toMatchInlineSnapshot(`
68+
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, options).text).toMatchInlineSnapshot(`
11269
"// @ts-nocheck
11370
declare const styles = {
11471
};
11572
export default styles;
11673
"
11774
`);
11875
});
119-
test('generates types for unresolvable modules', async () => {
120-
const iff = await createIFF({
121-
'test.module.css': dedent`
122-
@import './unresolvable.module.css';
123-
@value unresolvable_1 from './unresolvable.module.css';
124-
`,
125-
});
126-
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, host, options).text)
127-
.toMatchInlineSnapshot(`
128-
"// @ts-nocheck
129-
declare const styles = {
130-
...(await import('./unresolvable.module.css')).default,
131-
unresolvable_1: (await import('./unresolvable.module.css')).default.unresolvable_1,
132-
};
133-
export default styles;
134-
"
135-
`);
136-
});
13776
test('does not generate types for invalid name as JS identifier', async () => {
13877
const iff = await createIFF({
13978
'test.module.css': dedent`
14079
.a-1 { color: red; }
14180
@value b-1 from './b.module.css';
14281
@value b_2 as a-2 from './b.module.css';
14382
`,
144-
'b.module.css': dedent`
145-
@value b-1: red;
146-
@value b_2: red;
147-
`,
14883
});
149-
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, host, options).text)
150-
.toMatchInlineSnapshot(`
84+
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, options).text).toMatchInlineSnapshot(`
15185
"// @ts-nocheck
15286
declare const styles = {
15387
};
@@ -159,8 +93,7 @@ describe('generateDts', () => {
15993
const iff = await createIFF({
16094
'test.module.css': '.__proto__ { color: red; }',
16195
});
162-
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, host, options).text)
163-
.toMatchInlineSnapshot(`
96+
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, options).text).toMatchInlineSnapshot(`
16497
"// @ts-nocheck
16598
declare const styles = {
16699
};
@@ -172,15 +105,13 @@ describe('generateDts', () => {
172105
const iff = await createIFF({
173106
'test.module.css': '.default { color: red; }',
174107
});
175-
expect(
176-
generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, host, { ...options, namedExports: true }).text,
177-
).toMatchInlineSnapshot(`
108+
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, { ...options, namedExports: true }).text)
109+
.toMatchInlineSnapshot(`
178110
"// @ts-nocheck
179111
"
180112
`);
181-
expect(
182-
generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, host, { ...options, namedExports: false }).text,
183-
).toMatchInlineSnapshot(`
113+
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, { ...options, namedExports: false }).text)
114+
.toMatchInlineSnapshot(`
184115
"// @ts-nocheck
185116
declare const styles = {
186117
default: '' as readonly string,
@@ -197,15 +128,9 @@ describe('generateDts', () => {
197128
@import './a.module.css';
198129
@value imported1, imported2 as aliasedImported2 from './b.module.css';
199130
`,
200-
'a.module.css': '',
201-
'b.module.css': dedent`
202-
@value imported1: red;
203-
@value imported2: red;
204-
`,
205131
});
206-
expect(
207-
generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, host, { ...options, namedExports: true }).text,
208-
).toMatchInlineSnapshot(`
132+
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, { ...options, namedExports: true }).text)
133+
.toMatchInlineSnapshot(`
209134
"// @ts-nocheck
210135
export var local1: string;
211136
export var local2: string;
@@ -222,7 +147,7 @@ describe('generateDts', () => {
222147
'test.module.css': '.local1 { color: red; }',
223148
});
224149
expect(
225-
generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, host, {
150+
generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, {
226151
...options,
227152
namedExports: true,
228153
forTsPlugin: true,

packages/core/src/dts-generator.ts

Lines changed: 20 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
1-
import type { CSSModule, MatchesPattern, Resolver, Token, TokenImporter } from './type.js';
1+
import type { CSSModule, Token, TokenImporter } from './type.js';
22
import { isURLSpecifier, isValidAsJSIdentifier } from './util.js';
33

44
export const STYLES_EXPORT_NAME = 'styles';
55

6-
export interface GenerateDtsHost {
7-
resolver: Resolver;
8-
matchesPattern: MatchesPattern;
9-
}
10-
116
export interface GenerateDtsOptions {
127
namedExports: boolean;
138
prioritizeNamedImports: boolean;
@@ -46,11 +41,7 @@ interface GenerateDtsResult {
4641
/**
4742
* Generate .d.ts from `CSSModule`.
4843
*/
49-
export function generateDts(
50-
cssModule: CSSModule,
51-
host: GenerateDtsHost,
52-
options: GenerateDtsOptions,
53-
): GenerateDtsResult {
44+
export function generateDts(cssModule: CSSModule, options: GenerateDtsOptions): GenerateDtsResult {
5445
// Exclude invalid tokens
5546
const localTokens = cssModule.localTokens.filter((token) => isValidName(token.name, options));
5647
const tokenImporters = cssModule.tokenImporters
@@ -71,95 +62,41 @@ export function generateDts(
7162
})
7263
.filter((tokenImporter) => {
7364
/**
74-
* Token importers with the following specifiers are excluded from type definitions:
75-
*
76-
* - URL specifiers
77-
* - Specifiers that are not URLs, can be resolved, and do not match the pattern
78-
*
79-
* On the other hand, token importers with the following specifiers are included in type definitions:
80-
*
81-
* - Specifiers that are not URLs, can be resolved, and match the pattern
82-
* - Specifiers that are not URLs and cannot be resolved
83-
*
84-
* Including the latter (non-existent specifiers) in type definitions may look unnatural, but
85-
* without doing so, watch mode will stop working correctly.
86-
*
87-
* As an example, consider the following setup:
65+
* In principle, token importers with specifiers that cannot be resolved are still included in the type
66+
* definitions. For example, consider the following:
8867
*
8968
* ```css
9069
* // src/a.module.css
91-
* @import '@/b.module.css';
70+
* @import './unresolved.module.css';
71+
* @import './unmatched.css';
9272
* .a_1 { color: red; }
9373
* ```
9474
*
95-
* ```json
96-
* // tsconfig.json
97-
* {
98-
* "compilerOptions": {
99-
* "paths": {
100-
* "@/*": ["src/*"]
101-
* }
102-
* }
103-
* }
104-
* ```
105-
*
106-
* In watch mode, only the type definitions for files whose changes are detected are regenerated
107-
* (unchanged files are not regenerated). Therefore, on the first generation, only the type
108-
* definition for `a.module.css` is generated, with the following content:
75+
* In this case, CSS Modules Kit generates the following type definitions:
10976
*
11077
* ```ts
11178
* // generated/src/a.module.css.d.ts
11279
* // @ts-nocheck
11380
* declare const styles = {
81+
* a_1: '' as readonly string,
82+
* ...(await import('./unresolved.module.css')).default,
83+
* ...(await import('./unmatched.css')).default,
11484
* };
115-
* export default styles;
11685
* ```
11786
*
118-
* At this point, since `src/b.module.css` does not exist yet, `@/b.module.css` cannot be
119-
* resolved. As a result, the type definition for `a.module.css` does not include tokens from
120-
* `src/b.module.css`.
87+
* Even if `./unresolved.module.css` or `./unmatched.css` does not exist, the same type definitions are
88+
* generated. It is important that the generated type definitions do not change depending on whether the
89+
* files exist. This provides the following benefits:
12190
*
122-
* Next, suppose the user creates `src/b.module.css`:
91+
* - Simplifies the watch mode implementation
92+
* - Only the type definitions for changed files need to be regenerated
93+
* - Makes it easier to parallelize code generation
94+
* - Type definitions can be generated independently per file
12395
*
124-
* ```css
125-
* // src/b.module.css
126-
* .b_1 { color: blue; }
127-
* ```
128-
*
129-
* When watch mode detects this, on the second generation only the type definition for
130-
* `b.module.css` is generated:
131-
*
132-
* ```ts
133-
* // generated/src/b.module.css.d.ts
134-
* // @ts-nocheck
135-
* declare const styles = {
136-
* b_1: '' as readonly string,
137-
* };
138-
* export default styles;
139-
* ```
140-
*
141-
* However, since the type definition for `a.module.css` is not regenerated, `a.module.css`
142-
* still does not have `b_1`.
143-
*
144-
* To prevent this, token importers for specifiers that match the pattern must be included in
145-
* the type definitions even if they do not exist yet.
146-
*
147-
* Therefore, css-modules-kit generates the following type definition in the first code
148-
* generation:
149-
*
150-
* ```ts
151-
* // generated/src/a.module.css.d.ts
152-
* // @ts-nocheck
153-
* declare const styles = {
154-
* ...(await import('@/b.module.css')).default,
155-
* };
156-
* export default styles;
157-
* ```
96+
* However, as an exception, URL specifiers are not included in the type definitions, because URL
97+
* specifiers are typically resolved at runtime.
15898
*/
159-
if (isURLSpecifier(tokenImporter.from)) return false;
160-
const resolved = host.resolver(tokenImporter.from, { request: cssModule.fileName });
161-
if (!resolved) return true;
162-
return host.matchesPattern(resolved);
99+
return !isURLSpecifier(tokenImporter.from);
163100
});
164101

165102
if (options.namedExports) {

packages/ts-plugin/src/index.cts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ const plugin = createLanguageServicePlugin((ts, info) => {
6262
const matchesPattern = createMatchesPattern(config);
6363

6464
return {
65-
languagePlugins: [createCSSLanguagePlugin(resolver, matchesPattern, config)],
65+
languagePlugins: [createCSSLanguagePlugin(matchesPattern, config)],
6666
setup: (language) => {
6767
projectToLanguage.set(info.project, language);
6868
info.languageService = proxyLanguageService(

0 commit comments

Comments
 (0)