Skip to content

Commit a20c93f

Browse files
authored
feat: add looseTyping option (#33)
1 parent 012182f commit a20c93f

File tree

9 files changed

+133
-28
lines changed

9 files changed

+133
-28
lines changed

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,25 @@ In the above example, `src/index.module.css.d.ts` is generated by compilation, y
9797

9898
In addition, if the generated code causes ESLint to report errors, you can also add the above configuration to the `.eslintignore` file.
9999

100+
## Options
101+
102+
### looseTyping
103+
104+
- **Type:** `boolean`
105+
- **Default:** `false`
106+
107+
When enabled, the generated type definition will include an index signature (`[key: string]: string`).
108+
109+
This allows you to reference any class name without TypeScript errors, while still keeping autocomplete and type hints for existing class names.
110+
111+
It's useful if you only need editor IntelliSense and don't require strict type checking for CSS module exports.
112+
113+
```js
114+
pluginTypedCSSModules({
115+
looseTyping: true,
116+
});
117+
```
118+
100119
## Credits
101120

102121
The loader was forked from [seek-oss/css-modules-typescript-loader](https://github.com/seek-oss/css-modules-typescript-loader).

src/index.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,20 @@ import type { CSSLoaderOptions, RsbuildPlugin } from '@rsbuild/core';
55
export const PLUGIN_TYPED_CSS_MODULES_NAME = 'rsbuild:typed-css-modules';
66
const __dirname = path.dirname(fileURLToPath(import.meta.url));
77

8-
export const pluginTypedCSSModules = (): RsbuildPlugin => ({
8+
export type PluginOptions = {
9+
/**
10+
* When enabled, the generated type definition will include an index signature (`[key: string]: string`).
11+
* This allows you to reference any class name without TypeScript errors, while still keeping autocomplete
12+
* and type hints for existing class names. It’s useful if you only need editor IntelliSense and don't require
13+
* strict type checking for CSS module exports.
14+
* @default false
15+
*/
16+
looseTyping?: boolean;
17+
};
18+
19+
export const pluginTypedCSSModules = (
20+
options: PluginOptions = {},
21+
): RsbuildPlugin => ({
922
name: PLUGIN_TYPED_CSS_MODULES_NAME,
1023

1124
setup(api) {
@@ -56,6 +69,7 @@ export const pluginTypedCSSModules = (): RsbuildPlugin => ({
5669
.loader(path.resolve(__dirname, './loader.cjs'))
5770
.options({
5871
modules: cssLoaderOptions.modules,
72+
looseTyping: options.looseTyping,
5973
})
6074
.before(CHAIN_ID.USE.CSS);
6175
}

src/loader.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import fs from 'node:fs';
1313
import path from 'node:path';
1414
import type { CSSModules, Rspack } from '@rsbuild/core';
1515
import LineDiff from 'line-diff';
16+
import type { PluginOptions } from './index.js';
1617

1718
export type CssLoaderModules =
1819
| boolean
@@ -108,13 +109,17 @@ const cssModuleToNamedExports = (cssModuleKeys: string[]) => {
108109
.join('\n');
109110
};
110111

111-
const cssModuleToInterface = (cssModulesKeys: string[]) => {
112+
const cssModuleToInterface = (
113+
cssModulesKeys: string[],
114+
looseTyping: boolean,
115+
) => {
116+
const spaces = ' ';
112117
const interfaceFields = cssModulesKeys
113118
.sort()
114-
.map((key) => ` ${wrapQuotes(key)}: string;`)
119+
.map((key) => `${spaces}${wrapQuotes(key)}: string;`)
115120
.join('\n');
116121

117-
return `interface CssExports {\n${interfaceFields}\n}`;
122+
return `interface CssExports {\n${interfaceFields}\n${looseTyping ? `${spaces}[key: string]: string;\n` : ''}}`;
118123
};
119124

120125
const filenameToTypingsFilename = (filename: string) => {
@@ -194,7 +199,7 @@ const getCSSModulesKeys = (content: string, namedExport: boolean): string[] => {
194199
return Array.from(keys);
195200
};
196201

197-
function codegen(keys: string[], namedExport: boolean) {
202+
function codegen(keys: string[], namedExport: boolean, looseTyping: boolean) {
198203
const bannerMessage =
199204
'// This file is automatically generated.\n// Please do not change this file!';
200205
if (namedExport) {
@@ -203,21 +208,27 @@ function codegen(keys: string[], namedExport: boolean) {
203208

204209
const cssModuleExport =
205210
'declare const cssExports: CssExports;\nexport default cssExports;\n';
206-
return `${bannerMessage}\n${cssModuleToInterface(keys)}\n${cssModuleExport}`;
211+
return `${bannerMessage}\n${cssModuleToInterface(keys, looseTyping)}\n${cssModuleExport}`;
207212
}
208213

209214
export default function (
210-
this: Rspack.LoaderContext<{
211-
mode: string;
212-
modules: CssLoaderModules;
213-
}>,
215+
this: Rspack.LoaderContext<
216+
{
217+
mode: string;
218+
modules: CssLoaderModules;
219+
} & PluginOptions
220+
>,
214221
content: string,
215222
...rest: any[]
216223
): void {
217224
const { failed, success } = makeDoneHandlers(this.async(), content, rest);
218225

219226
const { resourcePath, resourceQuery, resourceFragment } = this;
220-
const { mode = 'emit', modules = true } = this.getOptions() || {};
227+
const {
228+
mode = 'emit',
229+
modules = true,
230+
looseTyping = false,
231+
} = this.getOptions() || {};
221232

222233
if (!validModes.includes(mode)) {
223234
failed(new Error(`Invalid mode option: ${mode}`));
@@ -237,7 +248,7 @@ export default function (
237248

238249
const namedExport = isNamedExport(modules);
239250
const cssModulesKeys = getCSSModulesKeys(content, namedExport);
240-
const cssModulesCode = codegen(cssModulesKeys, namedExport);
251+
const cssModulesCode = codegen(cssModulesKeys, namedExport, looseTyping);
241252

242253
if (mode === 'verify') {
243254
read((err, fileContents) => {

test/loose-typing/index.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import fs from 'node:fs';
2+
import { dirname, join, resolve } from 'node:path';
3+
import { fileURLToPath } from 'node:url';
4+
import { expect, test } from '@playwright/test';
5+
import { createRsbuild } from '@rsbuild/core';
6+
import { pluginSass } from '@rsbuild/plugin-sass';
7+
import { pluginTypedCSSModules } from '../../dist';
8+
9+
const __dirname = dirname(fileURLToPath(import.meta.url));
10+
const fixtures = __dirname;
11+
12+
const generatorTempDir = async (testDir: string) => {
13+
fs.rmSync(testDir, { recursive: true, force: true });
14+
await fs.promises.cp(join(fixtures, 'src'), testDir, { recursive: true });
15+
16+
return () => fs.promises.rm(testDir, { force: true, recursive: true });
17+
};
18+
19+
test('generator TS declaration with loose typing', async () => {
20+
const testDir = join(fixtures, 'test-temp-src-1');
21+
const clear = await generatorTempDir(testDir);
22+
23+
const rsbuild = await createRsbuild({
24+
cwd: __dirname,
25+
rsbuildConfig: {
26+
plugins: [
27+
pluginSass(),
28+
pluginTypedCSSModules({
29+
looseTyping: true,
30+
}),
31+
],
32+
source: {
33+
entry: { index: resolve(testDir, 'index.js') },
34+
},
35+
},
36+
});
37+
38+
await rsbuild.build();
39+
40+
expect(fs.existsSync(join(testDir, './a.module.scss.d.ts'))).toBeTruthy();
41+
const aContent = fs.readFileSync(join(testDir, './a.module.scss.d.ts'), {
42+
encoding: 'utf-8',
43+
});
44+
expect(aContent).toEqual(`// This file is automatically generated.
45+
// Please do not change this file!
46+
interface CssExports {
47+
a: string;
48+
[key: string]: string;
49+
}
50+
declare const cssExports: CssExports;
51+
export default cssExports;
52+
`);
53+
54+
await clear();
55+
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.a {
2+
font-size: 14px;
3+
}

test/loose-typing/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import style from './a.module.scss';
2+
3+
console.log(style.a, style.unknownClass);

test/loose-typing/tsconfig.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"compilerOptions": {
3+
"outDir": "./dist",
4+
"target": "ES2020",
5+
"lib": ["DOM", "ESNext"],
6+
"module": "ESNext",
7+
"strict": true,
8+
"declaration": true,
9+
"isolatedModules": true,
10+
"esModuleInterop": true,
11+
"skipLibCheck": true,
12+
"resolveJsonModule": true,
13+
"moduleResolution": "bundler"
14+
},
15+
"include": ["src"]
16+
}

test/named-export/rsbuild.config.ts

Lines changed: 0 additions & 15 deletions
This file was deleted.

test/named-export/tsconfig.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
{
22
"compilerOptions": {
33
"outDir": "./dist",
4-
"baseUrl": "./",
54
"target": "ES2020",
65
"lib": ["DOM", "ESNext"],
76
"module": "ESNext",

0 commit comments

Comments
 (0)