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
34 changes: 31 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ module.exports = {
| ------------------------ | --------------------------------------------------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `exportLocalsConvention` | `"as-is"` \| `"camel-case"` \| `"camel-case-only"` \| `"dashes"` \| `"dashes-only"` | See description | How to transform class names. Defaults based on `namedExport`: `"as-is"` if `true`, `"camel-case-only"` if `false`. Matches css-loader's option. |
| `namedExport` | `boolean` | `true` | When `true`, generates named exports (`export const foo: string;`). When `false`, generates an interface with default export. Should match css-loader's setting. |
| `keywordPrefix` | `string` | `"__dts_"` | Prefix used for aliased exports of JavaScript reserved keywords (e.g., `class`, `export`). Must be a valid JavaScript identifier. Only applies when `namedExport` is `true`. |
| `quote` | `"single"` \| `"double"` | `"double"` | Quote style used for interface properties when `namedExport` is `false`. |
| `indentStyle` | `"tab"` \| `"space"` | `"space"` | Indentation style for interface properties. |
| `indentSize` | `number` | `2` | Number of spaces for indentation when `indentStyle` is `"space"`. |
Expand Down Expand Up @@ -122,6 +123,7 @@ When using `namedExport: true`, JavaScript reserved keywords (like `class`, `exp
- Keywords are exported using **aliased exports** to provide full type safety
- Non-keyword classes are exported normally as named exports
- Keywords are accessible via namespace import with full type safety
- The prefix for aliased exports can be customized using the `keywordPrefix` option

**Example:**

Expand All @@ -131,7 +133,7 @@ When using `namedExport: true`, JavaScript reserved keywords (like `class`, `exp
.container { padding: 10px; }
```

Generated with `namedExport: true`:
Generated with `namedExport: true` and default `keywordPrefix`:
```ts
// styles.module.css.d.ts
export const container: string;
Expand All @@ -140,15 +142,29 @@ declare const __dts_class: string;
export { __dts_class as "class" };
```

Generated with `namedExport: true` and `keywordPrefix: "dts"`:
```ts
// styles.module.css.d.ts
export const container: string;

declare const dtsclass: string;
export { dtsclass as "class" };
```

Usage in TypeScript:
```ts
import * as styles from './styles.module.css';

// Both are fully type-safe:
styles.container; // ✅ Type-safe
styles["class"]; // ✅ Type-safe via aliased export
styles.class; // ✅ Type-safe via aliased export
```

**Why customize `keywordPrefix`?**
- **Linter compatibility**: Some ESLint configurations flag identifiers starting with `__` (double underscore)
- **Naming conventions**: Match your project's naming standards
- **Readability**: Use a prefix that's clearer in your codebase (e.g., `dts`, `css_`, `module_`)

**Note:** With `namedExport: false`, all classes (including keywords) are included in the interface, so there's no difference in behavior for keywords.

### Example Output
Expand Down Expand Up @@ -185,7 +201,19 @@ import * as styles from './styles.module.css';

styles.button;
styles.container;
styles["class"]; // Type-safe via aliased export
styles.class; // Type-safe via aliased export

// Or

import {
button,
container,
class as notReservedJsKeyword
} from './styles.module.css';

<div className={notReservedJsKeyword}>
No conflicts with JS keywords!
</div>
```

#### With `namedExport: false`
Expand Down
7 changes: 5 additions & 2 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface LoaderOptions {
sort?: boolean;
namedExport?: boolean;
banner?: string;
keywordPrefix?: string;
}

export interface ExportMarker {
Expand Down Expand Up @@ -61,7 +62,8 @@ export const SCHEMA: SchemaDefinition = {
mode: { type: "string", enum: ["emit", "verify"] },
sort: { type: "boolean" },
namedExport: { type: "boolean" },
banner: { type: "string" }
banner: { type: "string" },
keywordPrefix: { type: "string" }
},
additionalProperties: false
};
Expand All @@ -79,7 +81,8 @@ export const DEFAULT_OPTIONS = {
mode: "emit" as const,
sort: false,
namedExport: true,
banner: "// This file is automatically generated.\n// Please do not change this file!"
banner: "// This file is automatically generated.\n// Please do not change this file!",
keywordPrefix: "__dts_"
};

/**
Expand Down
14 changes: 14 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,20 @@ export default function cssModuleTypesLoader(this: LoaderContext, source: string
mergedOptions.exportLocalsConvention = mergedOptions.namedExport ? "as-is" : "camel-case-only";
}

// Validate keywordPrefix format
if (mergedOptions.keywordPrefix !== undefined) {
const keywordPrefix = mergedOptions.keywordPrefix;
// Must be a valid JavaScript identifier start
if (!/^[a-zA-Z_$][\w$]*$/.test(keywordPrefix)) {
this.emitError(new Error(
`Invalid keywordPrefix: "${keywordPrefix}". ` +
"The prefix must be a valid JavaScript identifier (start with letter, _, or $, " +
"followed by letters, digits, _, or $)."
));
return source;
}
}

const options = mergedOptions as Required<LoaderOptions>;

const classNames = extractClassNames(source);
Expand Down
20 changes: 11 additions & 9 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export interface GenerateDtsContentParams {
classNames: string[];
options: Required<Omit<LoaderOptions, "camelCase" | "exportLocalsConvention">> & {
exportLocalsConvention: ExportLocalsConvention;
keywordPrefix: string;
};
}

Expand Down Expand Up @@ -338,30 +339,30 @@ export function handleDtsFile({ dtsFilePath, dtsContent, mode, logger: _logger,
*
* @param params - Parameters object
* @param params.classNames - Array of CSS class names extracted from the module
* @param params.options - Loader options (exportLocalsConvention, quote, indentStyle, etc.)
* @param params.options - Loader options (exportLocalsConvention, quote, indentStyle, keywordPrefix, etc.)
* @returns Generated TypeScript declaration file content with trailing newline
*
* @example
* ```ts
* // Named exports (no keywords)
* generateDtsContent({
* classNames: ["button", "container"],
* options: { namedExport: true, exportLocalsConvention: "as-is", ... }
* options: { namedExport: true, exportLocalsConvention: "as-is", keywordPrefix: "__dts_", ... }
* });
* // Returns:
* // export const button: string;
* // export const container: string;
*
* // Named exports with keywords (keywords use aliased exports)
* // Named exports with keywords (keywords use aliased exports with custom prefix)
* generateDtsContent({
* classNames: ["class", "button"],
* options: { namedExport: true, exportLocalsConvention: "as-is", ... }
* options: { namedExport: true, exportLocalsConvention: "as-is", keywordPrefix: "dts", ... }
* });
* // Returns:
* // export const button: string;
* //
* // declare const __dts_class: string;
* // export { __dts_class as "class" };
* // declare const dtsclass: string;
* // export { dtsclass as "class" };
* ```
*/
export function generateDtsContent({ classNames, options }: GenerateDtsContentParams): string {
Expand Down Expand Up @@ -396,11 +397,12 @@ export function generateDtsContent({ classNames, options }: GenerateDtsContentPa
content.push(...nonKeywords.map(cls => `export const ${cls}: string;`));

// For keywords, use aliased exports to provide type safety
// declare const __dts_class: string; export { __dts_class as "class" };
// declare const {prefix}class: string; export { {prefix}class as "class" };
if (keywords.length > 0) {
content.push("");
content.push(...keywords.map(cls => `declare const __dts_${cls}: string;`));
content.push(...keywords.map(cls => `export { __dts_${cls} as "${cls}" };`));
const prefix = options.keywordPrefix;
content.push(...keywords.map(cls => `declare const ${prefix}${cls}: string;`));
content.push(...keywords.map(cls => `export { ${prefix}${cls} as "${cls}" };`));
}
} else {
// namedExport:false - always use interface format
Expand Down
60 changes: 60 additions & 0 deletions test/__snapshots__/index.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,66 @@ export const testClass: string;
"
`;

exports[`css-modules-dts-loader > Options: keywordPrefix > should not affect interface export mode (namedExport=false) 1`] = `
"// This file is automatically generated.
// Please do not change this file!
interface CssExports {
"class": string;
"export": string;
}

export const cssExports: CssExports;
export default cssExports;
"
`;

exports[`css-modules-dts-loader > Options: keywordPrefix > should use custom keywordPrefix (dts) 1`] = `
"// This file is automatically generated.
// Please do not change this file!
export const validClass: string;

declare const dtsclass: string;
declare const dtsexport: string;
export { dtsclass as "class" };
export { dtsexport as "export" };
"
`;

exports[`css-modules-dts-loader > Options: keywordPrefix > should use custom keywordPrefix with underscores 1`] = `
"// This file is automatically generated.
// Please do not change this file!
export const button: string;

declare const my_prefix_import: string;
export { my_prefix_import as "import" };
"
`;

exports[`css-modules-dts-loader > Options: keywordPrefix > should use default __dts_ prefix for keywords 1`] = `
"// This file is automatically generated.
// Please do not change this file!
export const validClass: string;

declare const __dts_class: string;
declare const __dts_export: string;
export { __dts_class as "class" };
export { __dts_export as "export" };
"
`;

exports[`css-modules-dts-loader > Options: keywordPrefix > should work with keywordPrefix and sort option 1`] = `
"// This file is automatically generated.
// Please do not change this file!
export const alpha: string;
export const zebra: string;

declare const dtsclass: string;
declare const dtsexport: string;
export { dtsclass as "class" };
export { dtsexport as "export" };
"
`;

exports[`css-modules-dts-loader > Options: namedExport > should generate interface export when namedExport is false 1`] = `
"// This file is automatically generated.
// Please do not change this file!
Expand Down
Loading