From 59532bf042ea66e08e34685d545c2c9182c08a59 Mon Sep 17 00:00:00 2001 From: mizdra Date: Mon, 12 Jan 2026 17:44:10 +0900 Subject: [PATCH] feat(core, ts-plugin, codegen)!: include types in .d.ts files for unresolved or unmatched module imports --- .changeset/nine-shoes-cough.md | 7 ++ packages/codegen/src/project.ts | 2 +- packages/core/src/dts-generator.test.ts | 115 ++++------------------ packages/core/src/dts-generator.ts | 103 ++++--------------- packages/ts-plugin/src/index.cts | 2 +- packages/ts-plugin/src/language-plugin.ts | 9 +- 6 files changed, 51 insertions(+), 187 deletions(-) create mode 100644 .changeset/nine-shoes-cough.md diff --git a/.changeset/nine-shoes-cough.md b/.changeset/nine-shoes-cough.md new file mode 100644 index 00000000..4342dc69 --- /dev/null +++ b/.changeset/nine-shoes-cough.md @@ -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 diff --git a/packages/codegen/src/project.ts b/packages/codegen/src/project.ts index 8be10b0f..79f7e17b 100644 --- a/packages/codegen/src/project.ts +++ b/packages/codegen/src/project.ts @@ -212,7 +212,7 @@ export function createProject(args: ProjectArgs): Project { const promises: Promise[] = []; 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, diff --git a/packages/core/src/dts-generator.test.ts b/packages/core/src/dts-generator.test.ts index f0f3618a..f340bd8d 100644 --- a/packages/core/src/dts-generator.test.ts +++ b/packages/core/src/dts-generator.test.ts @@ -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, @@ -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 = { }; @@ -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, @@ -47,68 +40,32 @@ 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 = { }; @@ -116,24 +73,6 @@ describe('generateDts', () => { " `); }); - 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` @@ -141,13 +80,8 @@ describe('generateDts', () => { @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 = { }; @@ -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 = { }; @@ -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, @@ -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; @@ -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, diff --git a/packages/core/src/dts-generator.ts b/packages/core/src/dts-generator.ts index 2f229c7a..3aed37f8 100644 --- a/packages/core/src/dts-generator.ts +++ b/packages/core/src/dts-generator.ts @@ -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; @@ -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 @@ -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) { diff --git a/packages/ts-plugin/src/index.cts b/packages/ts-plugin/src/index.cts index 8daf802a..745f6fc8 100644 --- a/packages/ts-plugin/src/index.cts +++ b/packages/ts-plugin/src/index.cts @@ -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( diff --git a/packages/ts-plugin/src/language-plugin.ts b/packages/ts-plugin/src/language-plugin.ts index c3644955..adc8463d 100644 --- a/packages/ts-plugin/src/language-plugin.ts +++ b/packages/ts-plugin/src/language-plugin.ts @@ -1,4 +1,4 @@ -import type { CMKConfig, CSSModule, MatchesPattern, Resolver } from '@css-modules-kit/core'; +import type { CMKConfig, CSSModule, MatchesPattern } from '@css-modules-kit/core'; import { generateDts, parseCSSModule } from '@css-modules-kit/core'; import type { LanguagePlugin, SourceScript, VirtualCode } from '@volar/language-core'; import type {} from '@volar/typescript'; @@ -19,7 +19,6 @@ export interface CSSModuleScript extends SourceScript { } export function createCSSLanguagePlugin( - resolver: Resolver, matchesPattern: MatchesPattern, config: CMKConfig, ): LanguagePlugin { @@ -53,11 +52,7 @@ export function createCSSLanguagePlugin( keyframes: config.keyframes, }); // eslint-disable-next-line prefer-const - let { text, mapping, linkedCodeMapping } = generateDts( - cssModule, - { resolver, matchesPattern }, - { ...config, forTsPlugin: true }, - ); + let { text, mapping, linkedCodeMapping } = generateDts(cssModule, { ...config, forTsPlugin: true }); return { id: 'main', languageId: 'typescript',