diff --git a/.changeset/true-kiwis-kneel.md b/.changeset/true-kiwis-kneel.md new file mode 100644 index 00000000..6a9d7eb5 --- /dev/null +++ b/.changeset/true-kiwis-kneel.md @@ -0,0 +1,7 @@ +--- +'@css-modules-kit/ts-plugin': minor +'@css-modules-kit/codegen': minor +'@css-modules-kit/core': minor +--- + +feat: support `namedExports` option diff --git a/README.md b/README.md index 8f84e943..7a7c167e 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,9 @@ In TypeScript, the `include`/`exclude` properties specify which `*.ts` files to ### `cmkOptions.dtsOutDir` -Specifies the directory where `*.d.ts` files are output. The default is `"generated"`. +Type: `string`, Default: `"generated"` + +Specifies the directory where `*.d.ts` files are output. ```jsonc { @@ -131,7 +133,9 @@ Specifies the directory where `*.d.ts` files are output. The default is `"genera ### `cmkOptions.arbitraryExtensions` -Determines whether to generate `*.module.d.css.ts` instead of `*.module.css.d.ts`. The default is `false`. +Type: `boolean`, Default: `false` + +Determines whether to generate `*.module.d.css.ts` instead of `*.module.css.d.ts`. ```jsonc { @@ -144,6 +148,23 @@ Determines whether to generate `*.module.d.css.ts` instead of `*.module.css.d.ts } ``` +### `cmkOptions.namedExports` + +Type: `boolean`, Default: `false` + +Determines whether to generate named exports in the d.ts file instead of a default export. + +```jsonc +{ + "compilerOptions": { + // ... + }, + "cmkOptions": { + "namedExports": true, + }, +} +``` + ## Limitations - Sass/Less are not supported to simplify the implementation diff --git a/example/generated/src/a.module.css.d.ts b/example/generated/src/a.module.css.d.ts index d321479c..fba7f2bc 100644 --- a/example/generated/src/a.module.css.d.ts +++ b/example/generated/src/a.module.css.d.ts @@ -1,11 +1,10 @@ // @ts-nocheck -declare const styles = { - a_1: '' as readonly string, - a_2: '' as readonly string, - a_2: '' as readonly string, - a_3: '' as readonly string, - ...(await import('./b.module.css')).default, - c_1: (await import('./c.module.css')).default.c_1, - c_alias: (await import('./c.module.css')).default.c_2, -}; -export default styles; +export var a_1: string; +export var a_2: string; +export var a_2: string; +export var a_3: string; +export * from './b.module.css'; +export { + c_1, + c_2 as c_alias, +} from './c.module.css'; diff --git a/example/generated/src/b.module.css.d.ts b/example/generated/src/b.module.css.d.ts index 63416ea8..f7cf5dc7 100644 --- a/example/generated/src/b.module.css.d.ts +++ b/example/generated/src/b.module.css.d.ts @@ -1,6 +1,3 @@ // @ts-nocheck -declare const styles = { - b_1: '' as readonly string, - b_2: '' as readonly string, -}; -export default styles; +export var b_1: string; +export var b_2: string; diff --git a/example/generated/src/c.module.css.d.ts b/example/generated/src/c.module.css.d.ts index 83129be6..07e44be9 100644 --- a/example/generated/src/c.module.css.d.ts +++ b/example/generated/src/c.module.css.d.ts @@ -1,6 +1,3 @@ // @ts-nocheck -declare const styles = { - c_1: '' as readonly string, - c_2: '' as readonly string, -}; -export default styles; +export var c_1: string; +export var c_2: string; diff --git a/example/generated/src/d.module.css.d.ts b/example/generated/src/d.module.css.d.ts deleted file mode 100644 index 8af83fff..00000000 --- a/example/generated/src/d.module.css.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -// @ts-nocheck -declare const styles = { - d_1: '' as readonly string, -}; -export default styles; diff --git a/example/src/index.tsx b/example/src/index.tsx index a016dba1..806ba683 100644 --- a/example/src/index.tsx +++ b/example/src/index.tsx @@ -1,9 +1,3 @@ -import styles from './a.module.css'; +import * as styles from "./a.module.css"; styles.a_1; -styles.a_2; -styles.a_3; -styles.b_1; -styles.b_2; -styles.c_1; -styles.c_alias; diff --git a/example/tsconfig.json b/example/tsconfig.json index 82d5b7ee..82c06baa 100644 --- a/example/tsconfig.json +++ b/example/tsconfig.json @@ -12,5 +12,8 @@ "paths": { "@/*": ["./*"] }, "rootDirs": [".", "generated"], "types": [] // Simplify tsserver.log + }, + "cmkOptions": { + "namedExports": true } } diff --git a/packages/codegen/src/runner.ts b/packages/codegen/src/runner.ts index 7260ccb4..be42c93f 100644 --- a/packages/codegen/src/runner.ts +++ b/packages/codegen/src/runner.ts @@ -41,11 +41,11 @@ async function parseCSSModuleByFileName(fileName: string): Promise { - const dts = createDts(cssModule, { resolver, matchesPattern }); + const dts = createDts(cssModule, { resolver, matchesPattern, namedExports }); await writeDtsFile(dts.text, cssModule.fileName, { outDir: dtsOutDir, basePath, diff --git a/packages/core/src/config.test.ts b/packages/core/src/config.test.ts index a6f2f148..3c26a22a 100644 --- a/packages/core/src/config.test.ts +++ b/packages/core/src/config.test.ts @@ -27,7 +27,9 @@ describe('readTsConfigFile', () => { "module": "esnext" }, "cmkOptions": { - "dtsOutDir": "generated/cmk" + "dtsOutDir": "generated/cmk", + "arbitraryExtensions": false, + "namedExports": true } } `, @@ -38,6 +40,8 @@ describe('readTsConfigFile', () => { includes: ['src'], excludes: ['src/test'], dtsOutDir: 'generated/cmk', + arbitraryExtensions: false, + namedExports: true, }, compilerOptions: expect.objectContaining({ module: ts.ModuleKind.ESNext, diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 5e017728..7b98fa67 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -15,6 +15,8 @@ export interface CMKConfig { excludes: string[]; dtsOutDir: string; arbitraryExtensions: boolean; + /** Whether to generate named exports in the d.ts file instead of a default export. */ + namedExports: boolean; /** * A root directory to resolve relative path entries in the config file to. * This is an absolute path. @@ -68,6 +70,7 @@ interface UnnormalizedRawConfig { excludes?: string[]; dtsOutDir?: string; arbitraryExtensions?: boolean; + namedExports?: boolean; } /** @@ -134,6 +137,17 @@ function parseRawData(raw: unknown, tsConfigSourceFile: ts.TsConfigSourceFile): }); } } + if ('namedExports' in raw.cmkOptions) { + if (typeof raw.cmkOptions.namedExports === 'boolean') { + result.config.namedExports = raw.cmkOptions.namedExports; + } else { + result.diagnostics.push({ + category: 'error', + text: `\`namedExports\` in ${tsConfigSourceFile.fileName} must be a boolean.`, + // MEMO: Location information can be obtained from `tsConfigSourceFile.statements`, but this is complicated and will be omitted. + }); + } + } } return result; } @@ -146,6 +160,7 @@ function mergeParsedRawData(base: ParsedRawData, overrides: ParsedRawData): Pars if (overrides.config.dtsOutDir !== undefined) result.config.dtsOutDir = overrides.config.dtsOutDir; if (overrides.config.arbitraryExtensions !== undefined) result.config.arbitraryExtensions = overrides.config.arbitraryExtensions; + if (overrides.config.namedExports !== undefined) result.config.namedExports = overrides.config.namedExports; result.diagnostics.push(...overrides.diagnostics); return result; } @@ -226,6 +241,7 @@ export function readConfigFile(project: string): CMKConfig { excludes: (config.excludes ?? []).map((e) => join(basePath, e)), dtsOutDir: join(basePath, config.dtsOutDir ?? 'generated'), arbitraryExtensions: config.arbitraryExtensions ?? false, + namedExports: config.namedExports ?? false, basePath, configFileName, compilerOptions, diff --git a/packages/core/src/dts-creator.test.ts b/packages/core/src/dts-creator.test.ts index 5d3375c4..93e67ee1 100644 --- a/packages/core/src/dts-creator.test.ts +++ b/packages/core/src/dts-creator.test.ts @@ -6,6 +6,7 @@ import { fakeCSSModule } from './test/css-module.js'; const options: CreateDtsOptions = { resolver: (specifier, { request }) => join(dirname(request), specifier), matchesPattern: () => true, + namedExports: false, }; function fakeLoc(offset: number) { @@ -377,4 +378,84 @@ describe('createDts', () => { } `); }); + test('creates d.ts file with named exports', () => { + expect( + createDts( + fakeCSSModule({ + localTokens: [ + { name: 'local1', loc: fakeLoc(0) }, + { name: 'local2', loc: fakeLoc(1) }, + ], + tokenImporters: [ + { type: 'import', from: './a.module.css', fromLoc: fakeLoc(2) }, + { + type: 'value', + values: [ + { name: 'imported1', loc: fakeLoc(3) }, + { name: 'imported2', loc: fakeLoc(4), localName: 'aliasedImported2', localLoc: fakeLoc(5) }, + ], + from: './b.module.css', + fromLoc: fakeLoc(6), + }, + ], + }), + { ...options, namedExports: true }, + ), + ).toMatchInlineSnapshot(` + { + "linkedCodeMapping": { + "generatedLengths": [ + 9, + ], + "generatedOffsets": [ + 125, + ], + "lengths": [ + 16, + ], + "sourceOffsets": [ + 138, + ], + }, + "mapping": { + "generatedOffsets": [ + 26, + 53, + 83, + 112, + 125, + 138, + 163, + ], + "lengths": [ + 6, + 6, + 16, + 9, + 9, + 16, + 16, + ], + "sourceOffsets": [ + 0, + 1, + 1, + 3, + 4, + 5, + 5, + ], + }, + "text": "// @ts-nocheck + export var local1: string; + export var local2: string; + export * from './a.module.css'; + export { + imported1, + imported2 as aliasedImported2, + } from './b.module.css'; + ", + } + `); + }); }); diff --git a/packages/core/src/dts-creator.ts b/packages/core/src/dts-creator.ts index c3233cb3..0f52465c 100644 --- a/packages/core/src/dts-creator.ts +++ b/packages/core/src/dts-creator.ts @@ -1,10 +1,11 @@ -import type { CSSModule, MatchesPattern, Resolver } from './type.js'; +import type { CSSModule, MatchesPattern, Resolver, Token, TokenImporter } from './type.js'; export const STYLES_EXPORT_NAME = 'styles'; export interface CreateDtsOptions { resolver: Resolver; matchesPattern: MatchesPattern; + namedExports: boolean; } interface CodeMapping { @@ -29,8 +30,122 @@ interface LinkedCodeMapping extends CodeMapping { generatedLengths: number[]; } +interface CreateDtsResult { + text: string; + mapping: CodeMapping; + linkedCodeMapping: LinkedCodeMapping; +} + +/** + * Create a d.ts file. + */ +export function createDts(cssModules: CSSModule, options: CreateDtsOptions): CreateDtsResult { + // Filter external files + const tokenImporters = cssModules.tokenImporters.filter((tokenImporter) => { + const resolved = options.resolver(tokenImporter.from, { request: cssModules.fileName }); + return resolved !== undefined && options.matchesPattern(resolved); + }); + if (options.namedExports) { + return createNamedExportsDts(cssModules.localTokens, tokenImporters); + } else { + return createDefaultExportDts(cssModules.localTokens, tokenImporters); + } +} + /** - * Create a d.ts file from a CSS module file. + * Create a d.ts file with named exports. + * @example + * If the CSS module file is: + * ```css + * @import './a.module.css'; + * @value local1: string; + * @value imported1, imported2 as aliasedImported2 from './b.module.css'; + * .local2 { color: red } + * ``` + * The d.ts file would be: + * ```ts + * // @ts-nocheck + * export var local1: string; + * export var local2: string; + * export * from './a.module.css'; + * export { + * imported1, + * imported2 as aliasedImported2, + * } from './b.module.css'; + * ``` + */ +function createNamedExportsDts( + localTokens: Token[], + tokenImporters: TokenImporter[], +): { text: string; mapping: CodeMapping; linkedCodeMapping: LinkedCodeMapping } { + const mapping: CodeMapping = { sourceOffsets: [], lengths: [], generatedOffsets: [] }; + const linkedCodeMapping: LinkedCodeMapping = { + sourceOffsets: [], + lengths: [], + generatedOffsets: [], + generatedLengths: [], + }; + + // MEMO: Depending on the TypeScript compilation options, the generated type definition file contains compile errors. + // For example, it contains `Top-level 'await' expressions are only allowed when the 'module' option is set to ...` error. + // + // If `--skipLibCheck` is false, those errors will be reported by `tsc`. However, these are negligible errors. + // Therefore, `@ts-nocheck` is added to the generated type definition file. + let text = `// @ts-nocheck\n`; + + for (const token of localTokens) { + text += `export var `; + mapping.sourceOffsets.push(token.loc.start.offset); + mapping.generatedOffsets.push(text.length); + mapping.lengths.push(token.name.length); + text += `${token.name}: string;\n`; + } + for (const tokenImporter of tokenImporters) { + if (tokenImporter.type === 'import') { + text += `export * from `; + mapping.sourceOffsets.push(tokenImporter.fromLoc.start.offset - 1); + mapping.lengths.push(tokenImporter.from.length + 2); + mapping.generatedOffsets.push(text.length); + text += `'${tokenImporter.from}';\n`; + } else { + text += `export {\n`; + // eslint-disable-next-line no-loop-func + tokenImporter.values.forEach((value) => { + const localName = value.localName ?? value.name; + const localLoc = value.localLoc ?? value.loc; + text += ` `; + if ('localName' in value) { + mapping.sourceOffsets.push(value.loc.start.offset); + mapping.lengths.push(value.name.length); + mapping.generatedOffsets.push(text.length); + linkedCodeMapping.generatedOffsets.push(text.length); + linkedCodeMapping.generatedLengths.push(value.name.length); + text += `${value.name} as `; + mapping.sourceOffsets.push(localLoc.start.offset); + mapping.lengths.push(localName.length); + mapping.generatedOffsets.push(text.length); + linkedCodeMapping.sourceOffsets.push(text.length); + linkedCodeMapping.lengths.push(localName.length); + text += `${localName},\n`; + } else { + mapping.sourceOffsets.push(value.loc.start.offset); + mapping.lengths.push(value.name.length); + mapping.generatedOffsets.push(text.length); + text += `${value.name},\n`; + } + }); + text += `} from `; + mapping.sourceOffsets.push(tokenImporter.fromLoc.start.offset - 1); + mapping.lengths.push(tokenImporter.from.length + 2); + mapping.generatedOffsets.push(text.length); + text += `'${tokenImporter.from}';\n`; + } + } + return { text, mapping, linkedCodeMapping }; +} + +/** + * Create a d.ts file with a default export. * @example * If the CSS module file is: * ```css @@ -52,9 +167,9 @@ interface LinkedCodeMapping extends CodeMapping { * export default styles; * ``` */ -export function createDts( - { fileName, localTokens, tokenImporters: _tokenImporters }: CSSModule, - options: CreateDtsOptions, +function createDefaultExportDts( + localTokens: Token[], + tokenImporters: TokenImporter[], ): { text: string; mapping: CodeMapping; linkedCodeMapping: LinkedCodeMapping } { const mapping: CodeMapping = { sourceOffsets: [], lengths: [], generatedOffsets: [] }; const linkedCodeMapping: LinkedCodeMapping = { @@ -64,12 +179,6 @@ export function createDts( generatedLengths: [], }; - // Filter external files - const tokenImporters = _tokenImporters.filter((tokenImporter) => { - const resolved = options.resolver(tokenImporter.from, { request: fileName }); - return resolved !== undefined && options.matchesPattern(resolved); - }); - // MEMO: Depending on the TypeScript compilation options, the generated type definition file contains compile errors. // For example, it contains `Top-level 'await' expressions are only allowed when the 'module' option is set to ...` error. // diff --git a/packages/ts-plugin/e2e/named-exports.test.ts b/packages/ts-plugin/e2e/named-exports.test.ts new file mode 100644 index 00000000..075e215d --- /dev/null +++ b/packages/ts-plugin/e2e/named-exports.test.ts @@ -0,0 +1,335 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import dedent from 'dedent'; +import ts from 'typescript'; +import { describe, expect, test } from 'vitest'; +import { createIFF } from './test-util/fixture.js'; +import { + formatPath, + launchTsserver, + mergeSpanGroups, + normalizeCodeFixActions, + normalizeCompletionEntry, + normalizeRefItems, + normalizeSpanGroups, +} from './test-util/tsserver.js'; + +describe('supports basic language features', async () => { + const tsserver = launchTsserver(); + const iff = await createIFF({ + 'index.ts': dedent` + import * as styles from './a.module.css'; + styles.a_1; + styles.b_1; + styles.c_1; + styles.c_alias; + `, + 'a.module.css': dedent` + .a_1 { color: red; } + .a_1 { color: red; } + @import './b.module.css'; + @value c_1, c_2 as c_alias from './c.module.css'; + `, + 'b.module.css': dedent` + .b_1 { color: red; } + `, + 'c.module.css': dedent` + @value c_1: red; + @value c_2: red; + `, + 'tsconfig.json': dedent` + { + "cmkOptions": { + "namedExports": true + } + } + `, + }); + await tsserver.sendUpdateOpen({ + openFiles: [{ file: iff.paths['tsconfig.json'] }], + }); + await tsserver.sendConfigure({ + preferences: { + includeCompletionsForModuleExports: true, + includeCompletionsWithSnippetText: true, + includeCompletionsWithInsertText: true, + jsxAttributeCompletionStyle: 'auto', + quotePreference: 'single', + }, + }); + const a_1_in_index_ts = { + file: formatPath(iff.paths['index.ts']), + start: { line: 2, offset: 8 }, + end: { line: 2, offset: 11 }, + }; + const b_1_in_index_ts = { + file: formatPath(iff.paths['index.ts']), + start: { line: 3, offset: 8 }, + end: { line: 3, offset: 11 }, + }; + const c_1_in_index_ts = { + file: formatPath(iff.paths['index.ts']), + start: { line: 4, offset: 8 }, + end: { line: 4, offset: 11 }, + }; + const c_alias_in_index_ts = { + file: formatPath(iff.paths['index.ts']), + start: { line: 5, offset: 8 }, + end: { line: 5, offset: 15 }, + }; + const a_1_1_in_a_module_css = { + file: formatPath(iff.paths['a.module.css']), + start: { line: 1, offset: 2 }, + end: { line: 1, offset: 5 }, + }; + const a_1_2_in_a_module_css = { + file: formatPath(iff.paths['a.module.css']), + start: { line: 2, offset: 2 }, + end: { line: 2, offset: 5 }, + }; + const b_1_in_b_module_css = { + file: formatPath(iff.paths['b.module.css']), + start: { line: 1, offset: 2 }, + end: { line: 1, offset: 5 }, + }; + const c_1_in_a_module_css = { + file: formatPath(iff.paths['a.module.css']), + start: { line: 4, offset: 8 }, + end: { line: 4, offset: 11 }, + }; + const c_2_in_a_module_css = { + file: formatPath(iff.paths['a.module.css']), + start: { line: 4, offset: 13 }, + end: { line: 4, offset: 16 }, + }; + const c_alias_in_a_module_css = { + file: formatPath(iff.paths['a.module.css']), + start: { line: 4, offset: 20 }, + end: { line: 4, offset: 27 }, + }; + const c_1_in_c_module_css = { + file: formatPath(iff.paths['c.module.css']), + start: { line: 1, offset: 8 }, + end: { line: 1, offset: 11 }, + }; + const c_2_in_c_module_css = { + file: formatPath(iff.paths['c.module.css']), + start: { line: 2, offset: 8 }, + end: { line: 2, offset: 11 }, + }; + test.each([ + { + name: 'a_1 in index.ts', + file: iff.paths['index.ts'], + line: 2, + offset: 8, + expected: mergeSpanGroups([a_1_in_index_ts, a_1_1_in_a_module_css, a_1_2_in_a_module_css]), + }, + { + name: 'b_1 in index.ts', + file: iff.paths['index.ts'], + line: 3, + offset: 8, + expected: mergeSpanGroups([b_1_in_index_ts, b_1_in_b_module_css]), + }, + { + name: 'c_1 in index.ts', + file: iff.paths['index.ts'], + line: 4, + offset: 8, + expected: mergeSpanGroups([c_1_in_index_ts, c_1_in_a_module_css, c_1_in_c_module_css]), + }, + { + name: 'c_alias in index.ts', + file: iff.paths['index.ts'], + line: 5, + offset: 8, + expected: mergeSpanGroups([ + c_alias_in_index_ts, + c_2_in_a_module_css, + c_alias_in_a_module_css, + c_2_in_c_module_css, + ]), + }, + ])('Rename Symbol for $name', async ({ file, line, offset, expected }) => { + const res = await tsserver.sendRename({ + file, + line, + offset, + }); + expect(normalizeSpanGroups(res.body?.locs ?? [])).toStrictEqual(normalizeSpanGroups(expected)); + }); + test.each([ + { + name: 'a_1 in index.ts', + file: a_1_in_index_ts.file, + ...a_1_in_index_ts.start, + expected: [a_1_in_index_ts, a_1_1_in_a_module_css, a_1_2_in_a_module_css], + }, + { + name: 'b_1 in index.ts', + file: b_1_in_index_ts.file, + ...b_1_in_index_ts.start, + expected: [b_1_in_index_ts, b_1_in_b_module_css], + }, + { + name: 'c_1 in index.ts', + file: c_1_in_index_ts.file, + ...c_1_in_index_ts.start, + expected: [c_1_in_index_ts, c_1_in_a_module_css, c_1_in_c_module_css], + }, + { + name: 'c_alias in index.ts', + file: c_alias_in_index_ts.file, + ...c_alias_in_index_ts.start, + expected: [ + // For some reason, `c_alias_in_a_module_css` and `c_alias_in_index_ts` appear to be duplicated. + // This is likely a bug in Volar.js. + c_alias_in_index_ts, + c_alias_in_index_ts, + c_2_in_a_module_css, + c_alias_in_a_module_css, + c_alias_in_a_module_css, + c_2_in_c_module_css, + ], + }, + ])('Find All References for $name', async ({ file, line, offset, expected }) => { + const res = await tsserver.sendReferences({ + file, + line, + offset, + }); + expect(normalizeRefItems(res.body?.refs ?? [])).toStrictEqual(normalizeRefItems(expected)); + }); +}); + +describe('supports completions', async () => { + const baseIff = await createIFF({ + 'index.ts': dedent` + styles; + a_1; + `, + 'a.module.css': dedent` + .a_1 { color: red; } + `, + }); + describe('prioritize named imports by default', async () => { + const iff = await baseIff.fork({ + 'tsconfig.json': dedent` + { + "cmkOptions": { + "namedExports": true + } + } + `, + }); + const tsserver = launchTsserver(); + await tsserver.sendUpdateOpen({ + openFiles: [{ file: iff.paths['tsconfig.json'] }], + }); + await tsserver.sendConfigure({ + preferences: { + includeCompletionsForModuleExports: true, + }, + }); + test.each([ + { + name: 'styles', + entryName: 'styles', + file: iff.paths['index.ts'], + line: 1, + offset: 7, + expected: [], + }, + { + name: 'a_1', + entryName: 'a_1', + file: iff.paths['index.ts'], + line: 2, + offset: 4, + expected: [{ name: 'a_1', sortText: '16', source: formatPath(iff.paths['a.module.css']) }], + }, + ])('Completions for $name', async ({ entryName, file, line, offset, expected }) => { + const res = await tsserver.sendCompletionInfo({ + file, + line, + offset, + }); + expect( + normalizeCompletionEntry(res.body?.entries.filter((entry) => entry.name === entryName) ?? []), + ).toStrictEqual(normalizeCompletionEntry(expected)); + }); + }); +}); + +describe('supports code fixes', async () => { + const baseIff = await createIFF({ + 'index.ts': dedent` + styles; + a_1; + `, + 'a.module.css': dedent` + .a_1 { color: red; } + `, + }); + describe('prioritize named imports by default', async () => { + const iff = await baseIff.fork({ + 'tsconfig.json': dedent` + { + "cmkOptions": { + "namedExports": true + } + } + `, + }); + const tsserver = launchTsserver(); + await tsserver.sendUpdateOpen({ + openFiles: [{ file: iff.paths['tsconfig.json'] }], + }); + test.each([ + { + name: 'styles', + file: iff.paths['index.ts'], + startLine: 1, + startOffset: 1, + endLine: 1, + endOffset: 7, + expected: [], + }, + { + name: 'a_1', + file: iff.paths['index.ts'], + startLine: 2, + startOffset: 1, + endLine: 2, + endOffset: 4, + expected: [ + { + fixName: 'import', + changes: [ + { + fileName: formatPath(iff.paths['index.ts']), + textChanges: [ + { + start: { line: 1, offset: 1 }, + end: { line: 1, offset: 1 }, + newText: `import { a_1 } from "./a.module.css";${ts.sys.newLine}${ts.sys.newLine}`, + }, + ], + }, + ], + }, + ], + }, + ])('$name', async ({ file, startLine, startOffset, endLine, endOffset, expected }) => { + const res = await tsserver.sendGetCodeFixes({ + errorCodes: [2304], + file, + startLine, + startOffset, + endLine, + endOffset, + }); + expect(normalizeCodeFixActions(res.body!)).toStrictEqual(normalizeCodeFixActions(expected)); + }); + }); +}); diff --git a/packages/ts-plugin/src/index.ts b/packages/ts-plugin/src/index.ts index c9327c3b..5bf5e260 100644 --- a/packages/ts-plugin/src/index.ts +++ b/packages/ts-plugin/src/index.ts @@ -38,7 +38,7 @@ const plugin = createLanguageServicePlugin((ts, info) => { const matchesPattern = createMatchesPattern(config); return { - languagePlugins: [createCSSLanguagePlugin(resolver, matchesPattern)], + languagePlugins: [createCSSLanguagePlugin(resolver, matchesPattern, config)], setup: (language) => { info.languageService = proxyLanguageService( language, diff --git a/packages/ts-plugin/src/language-plugin.ts b/packages/ts-plugin/src/language-plugin.ts index 3618ce26..a44fb578 100644 --- a/packages/ts-plugin/src/language-plugin.ts +++ b/packages/ts-plugin/src/language-plugin.ts @@ -1,4 +1,4 @@ -import type { CSSModule, DiagnosticWithLocation, MatchesPattern, Resolver } from '@css-modules-kit/core'; +import type { CMKConfig, CSSModule, DiagnosticWithLocation, MatchesPattern, Resolver } from '@css-modules-kit/core'; import { createDts, parseCSSModule } from '@css-modules-kit/core'; import type { LanguagePlugin, SourceScript, VirtualCode } from '@volar/language-core'; import type {} from '@volar/typescript'; @@ -24,6 +24,7 @@ export interface CSSModuleScript extends SourceScript { export function createCSSLanguagePlugin( resolver: Resolver, matchesPattern: MatchesPattern, + config: CMKConfig, ): LanguagePlugin { return { getLanguageId(scriptId) { @@ -52,7 +53,11 @@ export function createCSSLanguagePlugin( // So, ts-plugin uses a fault-tolerant Parser to parse CSS. safe: true, }); - const { text, mapping, linkedCodeMapping } = createDts(cssModule, { resolver, matchesPattern }); + const { text, mapping, linkedCodeMapping } = createDts(cssModule, { + resolver, + matchesPattern, + namedExports: config.namedExports, + }); return { id: 'main', languageId: LANGUAGE_ID,