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 .changeset/nine-shoes-cough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@css-modules-kit/ts-plugin': minor
'@css-modules-kit/codegen': minor
'@css-modules-kit/core': minor
---

feat!: include types in .d.ts files for unresolved or unmatched module imports
2 changes: 1 addition & 1 deletion packages/codegen/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ export function createProject(args: ProjectArgs): Project {
const promises: Promise<void>[] = [];
for (const cssModule of cssModuleMap.values()) {
if (emittedSet.has(cssModule.fileName)) continue;
const dts = generateDts(cssModule, { resolver, matchesPattern }, { ...config, forTsPlugin: false });
const dts = generateDts(cssModule, { ...config, forTsPlugin: false });
promises.push(
writeDtsFile(dts.text, cssModule.fileName, {
outDir: config.dtsOutDir,
Expand Down
115 changes: 20 additions & 95 deletions packages/core/src/dts-generator.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
import dedent from 'dedent';
import { describe, expect, test } from 'vitest';
import { generateDts, type GenerateDtsHost, type GenerateDtsOptions } from './dts-generator.js';
import { generateDts, type GenerateDtsOptions } from './dts-generator.js';
import { readAndParseCSSModule } from './test/css-module.js';
import { fakeMatchesPattern, fakeResolver } from './test/faker.js';
import { createIFF } from './test/fixture.js';

const host: GenerateDtsHost = {
resolver: fakeResolver(),
matchesPattern: fakeMatchesPattern(),
};
const options: GenerateDtsOptions = {
namedExports: false,
prioritizeNamedImports: false,
Expand All @@ -20,8 +15,7 @@ describe('generateDts', () => {
const iff = await createIFF({
'test.module.css': '',
});
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, host, options).text)
.toMatchInlineSnapshot(`
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, options).text).toMatchInlineSnapshot(`
"// @ts-nocheck
declare const styles = {
};
Expand All @@ -36,8 +30,7 @@ describe('generateDts', () => {
.local2 { color: red; }
`,
});
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, host, options).text)
.toMatchInlineSnapshot(`
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, options).text).toMatchInlineSnapshot(`
"// @ts-nocheck
declare const styles = {
local1: '' as readonly string,
Expand All @@ -47,107 +40,48 @@ describe('generateDts', () => {
"
`);
});
test('generates d.ts file with token importers', async () => {
test('generates types for token importers', async () => {
const iff = await createIFF({
'test.module.css': dedent`
@import './a.module.css';
@value imported1 from './b.module.css';
@value imported2 as aliasedImported2 from './c.module.css';
@value imported1, imported2 as aliasedImported2 from './b.module.css';
`,
'a.module.css': '',
'b.module.css': '',
'c.module.css': '',
});
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, host, options).text)
.toMatchInlineSnapshot(`
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, options).text).toMatchInlineSnapshot(`
"// @ts-nocheck
declare const styles = {
...(await import('./a.module.css')).default,
imported1: (await import('./b.module.css')).default.imported1,
aliasedImported2: (await import('./c.module.css')).default.imported2,
aliasedImported2: (await import('./b.module.css')).default.imported2,
};
export default styles;
"
`);
});
test('resolves specifiers', async () => {
test('does not generate types for URL token importers', async () => {
const iff = await createIFF({
'test.module.css': dedent`
@import '@/a.module.css';
@value imported1 from '@/b.module.css';
@value imported2 as aliasedImported2 from '@/c.module.css';
@import 'https://example.com/a.module.css';
@value imported1 from 'https://example.com/b.module.css';
`,
'a.module.css': '',
'b.module.css': '',
'c.module.css': '',
});
const resolver = (specifier: string) => specifier.replace('@', '/src');
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, { ...host, resolver }, options).text)
.toMatchInlineSnapshot(`
"// @ts-nocheck
declare const styles = {
...(await import('@/a.module.css')).default,
imported1: (await import('@/b.module.css')).default.imported1,
aliasedImported2: (await import('@/c.module.css')).default.imported2,
};
export default styles;
"
`);
});
test('does not generate types for unmatched modules', async () => {
const iff = await createIFF({
'test.module.css': dedent`
@import './unmatched.module.css';
@value unmatched_1 from './unmatched.module.css';
`,
'unmatched.module.css': '.unmatched_1 { color: red; }',
});
expect(
generateDts(
readAndParseCSSModule(iff.paths['test.module.css'])!,
{ ...host, matchesPattern: (path) => path.endsWith('.module.css') && !path.endsWith('unmatched.module.css') },
options,
).text,
).toMatchInlineSnapshot(`
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, options).text).toMatchInlineSnapshot(`
"// @ts-nocheck
declare const styles = {
};
export default styles;
"
`);
});
test('generates types for unresolvable modules', async () => {
const iff = await createIFF({
'test.module.css': dedent`
@import './unresolvable.module.css';
@value unresolvable_1 from './unresolvable.module.css';
`,
});
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, host, options).text)
.toMatchInlineSnapshot(`
"// @ts-nocheck
declare const styles = {
...(await import('./unresolvable.module.css')).default,
unresolvable_1: (await import('./unresolvable.module.css')).default.unresolvable_1,
};
export default styles;
"
`);
});
test('does not generate types for invalid name as JS identifier', async () => {
const iff = await createIFF({
'test.module.css': dedent`
.a-1 { color: red; }
@value b-1 from './b.module.css';
@value b_2 as a-2 from './b.module.css';
`,
'b.module.css': dedent`
@value b-1: red;
@value b_2: red;
`,
});
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, host, options).text)
.toMatchInlineSnapshot(`
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, options).text).toMatchInlineSnapshot(`
"// @ts-nocheck
declare const styles = {
};
Expand All @@ -159,8 +93,7 @@ describe('generateDts', () => {
const iff = await createIFF({
'test.module.css': '.__proto__ { color: red; }',
});
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, host, options).text)
.toMatchInlineSnapshot(`
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, options).text).toMatchInlineSnapshot(`
"// @ts-nocheck
declare const styles = {
};
Expand All @@ -172,15 +105,13 @@ describe('generateDts', () => {
const iff = await createIFF({
'test.module.css': '.default { color: red; }',
});
expect(
generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, host, { ...options, namedExports: true }).text,
).toMatchInlineSnapshot(`
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, { ...options, namedExports: true }).text)
.toMatchInlineSnapshot(`
"// @ts-nocheck
"
`);
expect(
generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, host, { ...options, namedExports: false }).text,
).toMatchInlineSnapshot(`
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, { ...options, namedExports: false }).text)
.toMatchInlineSnapshot(`
"// @ts-nocheck
declare const styles = {
default: '' as readonly string,
Expand All @@ -197,15 +128,9 @@ describe('generateDts', () => {
@import './a.module.css';
@value imported1, imported2 as aliasedImported2 from './b.module.css';
`,
'a.module.css': '',
'b.module.css': dedent`
@value imported1: red;
@value imported2: red;
`,
});
expect(
generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, host, { ...options, namedExports: true }).text,
).toMatchInlineSnapshot(`
expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, { ...options, namedExports: true }).text)
.toMatchInlineSnapshot(`
"// @ts-nocheck
export var local1: string;
export var local2: string;
Expand All @@ -222,7 +147,7 @@ describe('generateDts', () => {
'test.module.css': '.local1 { color: red; }',
});
expect(
generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, host, {
generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, {
...options,
namedExports: true,
forTsPlugin: true,
Expand Down
103 changes: 20 additions & 83 deletions packages/core/src/dts-generator.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import type { CSSModule, MatchesPattern, Resolver, Token, TokenImporter } from './type.js';
import type { CSSModule, Token, TokenImporter } from './type.js';
import { isURLSpecifier, isValidAsJSIdentifier } from './util.js';

export const STYLES_EXPORT_NAME = 'styles';

export interface GenerateDtsHost {
resolver: Resolver;
matchesPattern: MatchesPattern;
}

export interface GenerateDtsOptions {
namedExports: boolean;
prioritizeNamedImports: boolean;
Expand Down Expand Up @@ -46,11 +41,7 @@ interface GenerateDtsResult {
/**
* Generate .d.ts from `CSSModule`.
*/
export function generateDts(
cssModule: CSSModule,
host: GenerateDtsHost,
options: GenerateDtsOptions,
): GenerateDtsResult {
export function generateDts(cssModule: CSSModule, options: GenerateDtsOptions): GenerateDtsResult {
// Exclude invalid tokens
const localTokens = cssModule.localTokens.filter((token) => isValidName(token.name, options));
const tokenImporters = cssModule.tokenImporters
Expand All @@ -71,95 +62,41 @@ export function generateDts(
})
.filter((tokenImporter) => {
/**
* Token importers with the following specifiers are excluded from type definitions:
*
* - URL specifiers
* - Specifiers that are not URLs, can be resolved, and do not match the pattern
*
* On the other hand, token importers with the following specifiers are included in type definitions:
*
* - Specifiers that are not URLs, can be resolved, and match the pattern
* - Specifiers that are not URLs and cannot be resolved
*
* Including the latter (non-existent specifiers) in type definitions may look unnatural, but
* without doing so, watch mode will stop working correctly.
*
* As an example, consider the following setup:
* In principle, token importers with specifiers that cannot be resolved are still included in the type
* definitions. For example, consider the following:
*
* ```css
* // src/a.module.css
* @import '@/b.module.css';
* @import './unresolved.module.css';
* @import './unmatched.css';
* .a_1 { color: red; }
* ```
*
* ```json
* // tsconfig.json
* {
* "compilerOptions": {
* "paths": {
* "@/*": ["src/*"]
* }
* }
* }
* ```
*
* In watch mode, only the type definitions for files whose changes are detected are regenerated
* (unchanged files are not regenerated). Therefore, on the first generation, only the type
* definition for `a.module.css` is generated, with the following content:
* In this case, CSS Modules Kit generates the following type definitions:
*
* ```ts
* // generated/src/a.module.css.d.ts
* // @ts-nocheck
* declare const styles = {
* a_1: '' as readonly string,
* ...(await import('./unresolved.module.css')).default,
* ...(await import('./unmatched.css')).default,
* };
* export default styles;
* ```
*
* At this point, since `src/b.module.css` does not exist yet, `@/b.module.css` cannot be
* resolved. As a result, the type definition for `a.module.css` does not include tokens from
* `src/b.module.css`.
* Even if `./unresolved.module.css` or `./unmatched.css` does not exist, the same type definitions are
* generated. It is important that the generated type definitions do not change depending on whether the
* files exist. This provides the following benefits:
*
* Next, suppose the user creates `src/b.module.css`:
* - Simplifies the watch mode implementation
* - Only the type definitions for changed files need to be regenerated
* - Makes it easier to parallelize code generation
* - Type definitions can be generated independently per file
*
* ```css
* // src/b.module.css
* .b_1 { color: blue; }
* ```
*
* When watch mode detects this, on the second generation only the type definition for
* `b.module.css` is generated:
*
* ```ts
* // generated/src/b.module.css.d.ts
* // @ts-nocheck
* declare const styles = {
* b_1: '' as readonly string,
* };
* export default styles;
* ```
*
* However, since the type definition for `a.module.css` is not regenerated, `a.module.css`
* still does not have `b_1`.
*
* To prevent this, token importers for specifiers that match the pattern must be included in
* the type definitions even if they do not exist yet.
*
* Therefore, css-modules-kit generates the following type definition in the first code
* generation:
*
* ```ts
* // generated/src/a.module.css.d.ts
* // @ts-nocheck
* declare const styles = {
* ...(await import('@/b.module.css')).default,
* };
* export default styles;
* ```
* However, as an exception, URL specifiers are not included in the type definitions, because URL
* specifiers are typically resolved at runtime.
*/
if (isURLSpecifier(tokenImporter.from)) return false;
const resolved = host.resolver(tokenImporter.from, { request: cssModule.fileName });
if (!resolved) return true;
return host.matchesPattern(resolved);
return !isURLSpecifier(tokenImporter.from);
});

if (options.namedExports) {
Expand Down
2 changes: 1 addition & 1 deletion packages/ts-plugin/src/index.cts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const plugin = createLanguageServicePlugin((ts, info) => {
const matchesPattern = createMatchesPattern(config);

return {
languagePlugins: [createCSSLanguagePlugin(resolver, matchesPattern, config)],
languagePlugins: [createCSSLanguagePlugin(matchesPattern, config)],
setup: (language) => {
projectToLanguage.set(info.project, language);
info.languageService = proxyLanguageService(
Expand Down
Loading