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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
16 changes: 15 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
Expand Down
33 changes: 22 additions & 11 deletions src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) {
Expand All @@ -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}`));
Expand All @@ -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) => {
Expand Down
55 changes: 55 additions & 0 deletions test/loose-typing/index.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
3 changes: 3 additions & 0 deletions test/loose-typing/src/a.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.a {
font-size: 14px;
}
3 changes: 3 additions & 0 deletions test/loose-typing/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import style from './a.module.scss';

console.log(style.a, style.unknownClass);
16 changes: 16 additions & 0 deletions test/loose-typing/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
15 changes: 0 additions & 15 deletions test/named-export/rsbuild.config.ts

This file was deleted.

1 change: 0 additions & 1 deletion test/named-export/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
{
"compilerOptions": {
"outDir": "./dist",
"baseUrl": "./",
"target": "ES2020",
"lib": ["DOM", "ESNext"],
"module": "ESNext",
Expand Down