From bc655d598327509f8a2cceee90a8d0d233b6e714 Mon Sep 17 00:00:00 2001 From: Valentyn Date: Tue, 2 Dec 2025 20:05:14 +0000 Subject: [PATCH] feat: support js keywords for named export --- README.md | 45 ++++++++++++++++++++------- src/utils.ts | 22 +++++++++---- test/__snapshots__/index.test.ts.snap | 7 +++++ test/index.test.ts | 10 ++++++ test/utils.test.ts | 43 +++++++++++++++++++++++-- 5 files changed, 107 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 3a836e2..4849407 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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:** @@ -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 @@ -165,6 +168,26 @@ 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. @@ -172,7 +195,7 @@ import * as styles from './styles.module.css'; interface CssExports { "button": string; "container": string; - "class": string; // Keywords work here! + "class": string; } export const cssExports: CssExports; @@ -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 @@ -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. diff --git a/src/utils.ts b/src/utils.ts index 1661848..d9caa3f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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 @@ -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 { @@ -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( diff --git a/test/__snapshots__/index.test.ts.snap b/test/__snapshots__/index.test.ts.snap index f17eade..63083cc 100644 --- a/test/__snapshots__/index.test.ts.snap +++ b/test/__snapshots__/index.test.ts.snap @@ -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" }; " `; diff --git a/test/index.test.ts b/test/index.test.ts index af352c3..4c72ceb 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -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 () => { diff --git a/test/utils.test.ts b/test/utils.test.ts index a9cc305..b36729b 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -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: { @@ -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"); }); @@ -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 + }); }); });