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
45 changes: 33 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ A Rspack and Webpack loader for generating TypeScript declaration files (`.d.ts`
- **Automatic Declaration Generation:** Creates `.d.ts` files alongside your CSS files.
- **css-loader v7 Compatible:** Fully supports css-loader's `exportLocalsConvention` option.
- **Named Exports Support:** Generates named exports or interface-based exports.
- **JavaScript Keywords Handling:** Properly handles reserved keywords as class names.
- **JavaScript Keywords Handling:** Properly handles reserved keywords as class names using aliased exports for full type safety.
- **Verification Mode:** Checks if existing declaration files are up-to-date (perfect for CI/CD).
- **Customizable Formatting:** Choose quote style, indentation (tabs or spaces), and sorting.
- **Seamless Integration:** Works with Webpack 5+ and Rspack.
Expand Down Expand Up @@ -119,8 +119,9 @@ This option matches css-loader's behavior:
When using `namedExport: true`, JavaScript reserved keywords (like `class`, `export`, `import`, etc.) **cannot** be exported as named exports because they're invalid JavaScript syntax.

**Behavior:**
- Keywords are **skipped** in the generated `.d.ts` file
- You can still access them via namespace import: `import * as styles from './styles.css'` and then `styles["class"]`
- 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

**Example:**

Expand All @@ -134,19 +135,21 @@ Generated with `namedExport: true`:
```ts
// styles.module.css.d.ts
export const container: string;
// Note: "class" is skipped because it's a keyword

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

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

// Works fine:
styles.container; // Type-safe
styles["class"]; // Also works, but not type-safe
// Both are fully type-safe:
styles.container; // ✅ Type-safe
styles["class"]; // ✅ Type-safe via aliased export
```

**Recommendation:** If you need type-safety for keyword class names, use `namedExport: false` which generates an interface including all classes.
**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 All @@ -165,14 +168,34 @@ import { button, container } from './styles.module.css';
import * as styles from './styles.module.css';
```

If your CSS contains keywords:
```ts
// This file is automatically generated.
// Please do not change this file!
export const button: string;
export const container: string;

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

Usage with keywords:
```ts
import * as styles from './styles.module.css';

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

#### With `namedExport: false`
```ts
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
"button": string;
"container": string;
"class": string; // Keywords work here!
"class": string;
}

export const cssExports: CssExports;
Expand All @@ -185,7 +208,7 @@ import styles from './styles.module.css';

styles.button;
styles.container;
styles.class; // Keywords are type-safe here
styles.class;
```

### Configuration Tips
Expand All @@ -194,8 +217,6 @@ styles.class; // Keywords are type-safe here

2. **Use verify mode in CI:** Set `mode: "verify"` in production/CI to ensure `.d.ts` files are always up-to-date.

3. **Avoid keywords in class names:** While technically possible, using JavaScript keywords as class names can cause confusion. Consider using prefixes like `btn-class` instead of `class`.

## How It Works

1. **Runs after css-loader:** The loader processes the JavaScript output from css-loader, not the raw CSS.
Expand Down
22 changes: 16 additions & 6 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,11 +319,11 @@ export function handleDtsFile({ dtsFilePath, dtsContent, mode, logger: _logger,
* 1. Applies exportLocalsConvention transformation (or legacy camelCase)
* 2. Sorts class names alphabetically if enabled
* 3. Checks for JavaScript reserved keywords in class names
* 4. Chooses appropriate export format based on options and keyword detection
* 4. Chooses appropriate export format based on options
* 5. Formats the output with custom indentation and quotes
*
* Export format selection:
* - If namedExport=true: generates named exports for non-keyword classes only (keywords are skipped)
* - If namedExport=true: generates named exports for non-keyword classes, aliased exports for keywords
* - If namedExport=false: generates interface + default export with all classes
*
* @param params - Parameters object
Expand All @@ -342,13 +342,16 @@ export function handleDtsFile({ dtsFilePath, dtsContent, mode, logger: _logger,
* // export const button: string;
* // export const container: string;
*
* // Named exports with keywords (keywords are skipped)
* // Named exports with keywords (keywords use aliased exports)
* generateDtsContent({
* classNames: ["class", "button"],
* options: { namedExport: true, exportLocalsConvention: "as-is", ... }
* });
* // Returns:
* // export const button: string;
* //
* // declare const __dts_class: string;
* // export { __dts_class as "class" };
* ```
*/
export function generateDtsContent({ classNames, options }: GenerateDtsContentParams): string {
Expand All @@ -375,13 +378,20 @@ export function generateDtsContent({ classNames, options }: GenerateDtsContentPa
}

// Separate keywords from non-keywords
const keywords = processedClassNames.filter(cls => JS_KEYWORDS.has(cls));
const nonKeywords = processedClassNames.filter(cls => !JS_KEYWORDS.has(cls));

if (options.namedExport) {
// namedExport:true - only export non-keyword classes as named exports
// Keywords are skipped because they cannot be used as named exports in JavaScript
// Users can still access them via import * as styles and styles["keyword"]
// namedExport:true - export non-keyword classes directly
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" };
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}" };`));
}
} else {
// namedExport:false - always use interface format
content.push(
Expand Down
7 changes: 7 additions & 0 deletions test/__snapshots__/index.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ exports[`css-modules-dts-loader > JavaScript Keywords as Class Names > should ha
"// 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;
declare const __dts_import: string;
export { __dts_class as "class" };
export { __dts_export as "export" };
export { __dts_import as "import" };
"
`;

Expand Down
10 changes: 10 additions & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,11 +243,21 @@ describe("css-modules-dts-loader", () => {
const dtsContent = readFile(tmpDir, "styles.module.css.d.ts");
expect(normalizeLineEndings(dtsContent)).toMatchSnapshot();

// Should contain normal export for non-keywords
expect(dtsContent).toContain("export const validClass: string;");

// Should NOT contain keywords as direct named exports
expect(dtsContent).not.toContain("export const class: string;");
expect(dtsContent).not.toContain("export const export: string;");
expect(dtsContent).not.toContain("export const import: string;");

// Should contain aliased exports for keywords
expect(dtsContent).toContain("declare const __dts_class: string;");
expect(dtsContent).toContain("declare const __dts_export: string;");
expect(dtsContent).toContain("declare const __dts_import: string;");
expect(dtsContent).toContain('export { __dts_class as "class" };');
expect(dtsContent).toContain('export { __dts_export as "export" };');
expect(dtsContent).toContain('export { __dts_import as "import" };');
});

it("should handle JS keyword class names with namedExport=false", async () => {
Expand Down
43 changes: 41 additions & 2 deletions test/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ describe("utils", () => {
expect(result).toContain("export const test: string;");
});

test("should skip keywords when namedExport=true", () => {
test("should use aliased exports for keywords when namedExport=true", () => {
const result = generateDtsContent({
classNames: ["class", "export", "validName"],
options: {
Expand All @@ -209,10 +209,19 @@ describe("utils", () => {
}
});

// Should only export non-keyword classes
// Should export non-keyword classes normally
expect(result).toContain("export const validName: string;");

// Should NOT export keywords as named exports directly
expect(result).not.toContain("export const class");
expect(result).not.toContain("export const export");

// Should use aliased exports for keywords
expect(result).toContain("declare const __dts_class: string;");
expect(result).toContain("declare const __dts_export: string;");
expect(result).toContain('export { __dts_class as "class" };');
expect(result).toContain('export { __dts_export as "export" };');

expect(result).not.toContain("interface");
});

Expand Down Expand Up @@ -281,5 +290,35 @@ describe("utils", () => {
expect(result).toContain("anotherClass");
expect(result).not.toContain("kebab-case-name");
});

test("should handle collision with __dts_ prefix (class name starting with __dts_)", () => {
const result = generateDtsContent({
classNames: ["__dts_class", "class", "normalClass"],
options: {
exportLocalsConvention: "as-is",
quote: "double",
indentStyle: "space",
indentSize: 2,
sort: false,
namedExport: true,
mode: "emit",
banner: "// Test"
}
});

// Normal classes should export normally
expect(result).toContain("export const normalClass: string;");
expect(result).toContain("export const __dts_class: string;");

// Keywords should use aliased exports (potentially colliding with existing class)
expect(result).toContain("declare const __dts_class: string;");
expect(result).toContain('export { __dts_class as "class" };');

// Note: In this edge case, there will be both:
// - export const __dts_class (for the actual CSS class named __dts_class)
// - declare const __dts_class (for aliasing the keyword "class")
// This will cause a TypeScript error, but it's an extremely unlikely edge case
// where users are intentionally naming classes with the __dts_ prefix
});
});
});