From 54bc7b2e60e8d2991358d44584416591eadbec3c Mon Sep 17 00:00:00 2001 From: neverland Date: Mon, 10 Nov 2025 15:16:37 +0800 Subject: [PATCH 1/2] feat(: add looseTyping option --- src/index.ts | 16 ++++++++- src/loader.ts | 33 +++++++++++------ test/loose-typing/index.test.ts | 55 +++++++++++++++++++++++++++++ test/loose-typing/src/a.module.scss | 3 ++ test/loose-typing/src/index.ts | 3 ++ test/loose-typing/tsconfig.json | 16 +++++++++ test/named-export/rsbuild.config.ts | 15 -------- test/named-export/tsconfig.json | 1 - 8 files changed, 114 insertions(+), 28 deletions(-) create mode 100644 test/loose-typing/index.test.ts create mode 100644 test/loose-typing/src/a.module.scss create mode 100644 test/loose-typing/src/index.ts create mode 100644 test/loose-typing/tsconfig.json delete mode 100644 test/named-export/rsbuild.config.ts diff --git a/src/index.ts b/src/index.ts index 7548074..3444006 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,20 @@ import type { CSSLoaderOptions, RsbuildPlugin } from '@rsbuild/core'; export const PLUGIN_TYPED_CSS_MODULES_NAME = 'rsbuild:typed-css-modules'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -export const pluginTypedCSSModules = (): RsbuildPlugin => ({ +export type PluginOptions = { + /** + * When enabled, the generated type definition will include an index signature (`[key: string]: string`). + * This allows you to reference any class name without TypeScript errors, while still keeping autocomplete + * and type hints for existing class names. It’s useful if you only need editor IntelliSense and don't require + * strict type checking for CSS module exports. + * @default false + */ + looseTyping?: boolean; +}; + +export const pluginTypedCSSModules = ( + options: PluginOptions = {}, +): RsbuildPlugin => ({ name: PLUGIN_TYPED_CSS_MODULES_NAME, setup(api) { @@ -56,6 +69,7 @@ export const pluginTypedCSSModules = (): RsbuildPlugin => ({ .loader(path.resolve(__dirname, './loader.cjs')) .options({ modules: cssLoaderOptions.modules, + looseTyping: options.looseTyping, }) .before(CHAIN_ID.USE.CSS); } diff --git a/src/loader.ts b/src/loader.ts index 09c1ff7..6f773de 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -13,6 +13,7 @@ import fs from 'node:fs'; import path from 'node:path'; import type { CSSModules, Rspack } from '@rsbuild/core'; import LineDiff from 'line-diff'; +import type { PluginOptions } from './index.js'; export type CssLoaderModules = | boolean @@ -108,13 +109,17 @@ const cssModuleToNamedExports = (cssModuleKeys: string[]) => { .join('\n'); }; -const cssModuleToInterface = (cssModulesKeys: string[]) => { +const cssModuleToInterface = ( + cssModulesKeys: string[], + looseTyping: boolean, +) => { + const spaces = ' '; const interfaceFields = cssModulesKeys .sort() - .map((key) => ` ${wrapQuotes(key)}: string;`) + .map((key) => `${spaces}${wrapQuotes(key)}: string;`) .join('\n'); - return `interface CssExports {\n${interfaceFields}\n}`; + return `interface CssExports {\n${interfaceFields}\n${looseTyping ? `${spaces}[key: string]: string;\n` : ''}}`; }; const filenameToTypingsFilename = (filename: string) => { @@ -194,7 +199,7 @@ const getCSSModulesKeys = (content: string, namedExport: boolean): string[] => { return Array.from(keys); }; -function codegen(keys: string[], namedExport: boolean) { +function codegen(keys: string[], namedExport: boolean, looseTyping: boolean) { const bannerMessage = '// This file is automatically generated.\n// Please do not change this file!'; if (namedExport) { @@ -203,21 +208,27 @@ function codegen(keys: string[], namedExport: boolean) { const cssModuleExport = 'declare const cssExports: CssExports;\nexport default cssExports;\n'; - return `${bannerMessage}\n${cssModuleToInterface(keys)}\n${cssModuleExport}`; + return `${bannerMessage}\n${cssModuleToInterface(keys, looseTyping)}\n${cssModuleExport}`; } export default function ( - this: Rspack.LoaderContext<{ - mode: string; - modules: CssLoaderModules; - }>, + this: Rspack.LoaderContext< + { + mode: string; + modules: CssLoaderModules; + } & PluginOptions + >, content: string, ...rest: any[] ): void { const { failed, success } = makeDoneHandlers(this.async(), content, rest); const { resourcePath, resourceQuery, resourceFragment } = this; - const { mode = 'emit', modules = true } = this.getOptions() || {}; + const { + mode = 'emit', + modules = true, + looseTyping = false, + } = this.getOptions() || {}; if (!validModes.includes(mode)) { failed(new Error(`Invalid mode option: ${mode}`)); @@ -237,7 +248,7 @@ export default function ( const namedExport = isNamedExport(modules); const cssModulesKeys = getCSSModulesKeys(content, namedExport); - const cssModulesCode = codegen(cssModulesKeys, namedExport); + const cssModulesCode = codegen(cssModulesKeys, namedExport, looseTyping); if (mode === 'verify') { read((err, fileContents) => { diff --git a/test/loose-typing/index.test.ts b/test/loose-typing/index.test.ts new file mode 100644 index 0000000..67771a1 --- /dev/null +++ b/test/loose-typing/index.test.ts @@ -0,0 +1,55 @@ +import fs from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { expect, test } from '@playwright/test'; +import { createRsbuild } from '@rsbuild/core'; +import { pluginSass } from '@rsbuild/plugin-sass'; +import { pluginTypedCSSModules } from '../../dist'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixtures = __dirname; + +const generatorTempDir = async (testDir: string) => { + fs.rmSync(testDir, { recursive: true, force: true }); + await fs.promises.cp(join(fixtures, 'src'), testDir, { recursive: true }); + + return () => fs.promises.rm(testDir, { force: true, recursive: true }); +}; + +test('generator TS declaration with loose typing', async () => { + const testDir = join(fixtures, 'test-temp-src-1'); + const clear = await generatorTempDir(testDir); + + const rsbuild = await createRsbuild({ + cwd: __dirname, + rsbuildConfig: { + plugins: [ + pluginSass(), + pluginTypedCSSModules({ + looseTyping: true, + }), + ], + source: { + entry: { index: resolve(testDir, 'index.js') }, + }, + }, + }); + + await rsbuild.build(); + + expect(fs.existsSync(join(testDir, './a.module.scss.d.ts'))).toBeTruthy(); + const aContent = fs.readFileSync(join(testDir, './a.module.scss.d.ts'), { + encoding: 'utf-8', + }); + expect(aContent).toEqual(`// This file is automatically generated. +// Please do not change this file! +interface CssExports { + a: string; + [key: string]: string; +} +declare const cssExports: CssExports; +export default cssExports; +`); + + await clear(); +}); diff --git a/test/loose-typing/src/a.module.scss b/test/loose-typing/src/a.module.scss new file mode 100644 index 0000000..ead7721 --- /dev/null +++ b/test/loose-typing/src/a.module.scss @@ -0,0 +1,3 @@ +.a { + font-size: 14px; +} diff --git a/test/loose-typing/src/index.ts b/test/loose-typing/src/index.ts new file mode 100644 index 0000000..9e57659 --- /dev/null +++ b/test/loose-typing/src/index.ts @@ -0,0 +1,3 @@ +import style from './a.module.scss'; + +console.log(style.a, style.unknownClass); diff --git a/test/loose-typing/tsconfig.json b/test/loose-typing/tsconfig.json new file mode 100644 index 0000000..9d2415c --- /dev/null +++ b/test/loose-typing/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "outDir": "./dist", + "target": "ES2020", + "lib": ["DOM", "ESNext"], + "module": "ESNext", + "strict": true, + "declaration": true, + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "moduleResolution": "bundler" + }, + "include": ["src"] +} diff --git a/test/named-export/rsbuild.config.ts b/test/named-export/rsbuild.config.ts deleted file mode 100644 index dc08d82..0000000 --- a/test/named-export/rsbuild.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { pluginLess } from '@rsbuild/plugin-less'; -import { pluginSass } from '@rsbuild/plugin-sass'; -import { pluginTypedCSSModules } from '@rsbuild/plugin-typed-css-modules'; -import { pluginTypeCheck } from '@rsbuild/plugin-type-check'; -import { defineConfig } from '@rsbuild/core'; - - -export default defineConfig({ - plugins: [pluginLess(), pluginSass(), pluginTypedCSSModules(), pluginTypeCheck()], - output: { - cssModules: { - namedExport: true, - } - }, -}); diff --git a/test/named-export/tsconfig.json b/test/named-export/tsconfig.json index 8c1e092..9d2415c 100644 --- a/test/named-export/tsconfig.json +++ b/test/named-export/tsconfig.json @@ -1,7 +1,6 @@ { "compilerOptions": { "outDir": "./dist", - "baseUrl": "./", "target": "ES2020", "lib": ["DOM", "ESNext"], "module": "ESNext", From c02a39ed51796c27af39686bb5717af409e74dcd Mon Sep 17 00:00:00 2001 From: neverland Date: Mon, 10 Nov 2025 15:24:34 +0800 Subject: [PATCH 2/2] docs --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index c01a3d6..95af9b7 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,25 @@ In the above example, `src/index.module.css.d.ts` is generated by compilation, y In addition, if the generated code causes ESLint to report errors, you can also add the above configuration to the `.eslintignore` file. +## Options + +### looseTyping + +- **Type:** `boolean` +- **Default:** `false` + +When enabled, the generated type definition will include an index signature (`[key: string]: string`). + +This allows you to reference any class name without TypeScript errors, while still keeping autocomplete and type hints for existing class names. + +It's useful if you only need editor IntelliSense and don't require strict type checking for CSS module exports. + +```js +pluginTypedCSSModules({ + looseTyping: true, +}); +``` + ## Credits The loader was forked from [seek-oss/css-modules-typescript-loader](https://github.com/seek-oss/css-modules-typescript-loader).