From 291b84efb5af844a0d007226cde5f3efb5c412d9 Mon Sep 17 00:00:00 2001 From: Valentyn Date: Wed, 3 Dec 2025 10:51:47 +0000 Subject: [PATCH] feat: added ability to provide keywordPrefix --- README.md | 34 +++++- src/constants.ts | 7 +- src/index.ts | 14 +++ src/utils.ts | 20 ++-- test/__snapshots__/index.test.ts.snap | 60 +++++++++++ test/index.test.ts | 149 ++++++++++++++++++++++++++ test/utils.test.ts | 139 +++++++++++++++++++++++- 7 files changed, 407 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 4849407..d7e6b1a 100644 --- a/README.md +++ b/README.md @@ -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"`. | @@ -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:** @@ -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; @@ -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 @@ -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'; + +
+ No conflicts with JS keywords! +
``` #### With `namedExport: false` diff --git a/src/constants.ts b/src/constants.ts index 889bbc9..5d2eaea 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -16,6 +16,7 @@ export interface LoaderOptions { sort?: boolean; namedExport?: boolean; banner?: string; + keywordPrefix?: string; } export interface ExportMarker { @@ -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 }; @@ -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_" }; /** diff --git a/src/index.ts b/src/index.ts index 4644760..cad456e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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; const classNames = extractClassNames(source); diff --git a/src/utils.ts b/src/utils.ts index 6a89ec3..7a0b416 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -34,6 +34,7 @@ export interface GenerateDtsContentParams { classNames: string[]; options: Required> & { exportLocalsConvention: ExportLocalsConvention; + keywordPrefix: string; }; } @@ -338,7 +339,7 @@ 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 @@ -346,22 +347,22 @@ export function handleDtsFile({ dtsFilePath, dtsContent, mode, logger: _logger, * // 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 { @@ -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 diff --git a/test/__snapshots__/index.test.ts.snap b/test/__snapshots__/index.test.ts.snap index eb98897..3eeda34 100644 --- a/test/__snapshots__/index.test.ts.snap +++ b/test/__snapshots__/index.test.ts.snap @@ -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! diff --git a/test/index.test.ts b/test/index.test.ts index 5c19e1f..a19c2d5 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -318,6 +318,155 @@ describe("css-modules-dts-loader", () => { }); }); + describe("Options: keywordPrefix", () => { + it("should use default __dts_ prefix for keywords", async () => { + const files = { + "index.js": "import styles from './styles.module.css';", + "styles.module.css": ` + .class { color: blue; } + .export { color: red; } + .validClass { color: black; } + ` + }; + + const { tmpDir } = await compileProject({ + files, + loaderOptions: { namedExport: true } + }); + + const dtsContent = readFile(tmpDir, "styles.module.css.d.ts"); + expect(normalizeLineEndings(dtsContent)).toMatchSnapshot(); + + // Should use default __dts_ prefix + expect(dtsContent).toContain("declare const __dts_class: string;"); + expect(dtsContent).toContain("declare const __dts_export: string;"); + expect(dtsContent).toContain('export { __dts_class as "class" };'); + expect(dtsContent).toContain('export { __dts_export as "export" };'); + expect(dtsContent).toContain("export const validClass: string;"); + }); + + it("should use custom keywordPrefix (dts)", async () => { + const files = { + "index.js": "import styles from './styles.module.css';", + "styles.module.css": ` + .class { color: blue; } + .export { color: red; } + .validClass { color: black; } + ` + }; + + const { tmpDir } = await compileProject({ + files, + loaderOptions: { + namedExport: true, + keywordPrefix: "dts" + } + }); + + const dtsContent = readFile(tmpDir, "styles.module.css.d.ts"); + expect(normalizeLineEndings(dtsContent)).toMatchSnapshot(); + + // Should use custom prefix + expect(dtsContent).toContain("declare const dtsclass: string;"); + expect(dtsContent).toContain("declare const dtsexport: string;"); + expect(dtsContent).toContain('export { dtsclass as "class" };'); + expect(dtsContent).toContain('export { dtsexport as "export" };'); + + // Should NOT use default prefix + expect(dtsContent).not.toContain("__dts_"); + + // Non-keyword class should be normal export + expect(dtsContent).toContain("export const validClass: string;"); + }); + + it("should use custom keywordPrefix with underscores", async () => { + const files = { + "index.js": "import styles from './styles.module.css';", + "styles.module.css": ` + .import { color: blue; } + .button { color: red; } + ` + }; + + const { tmpDir } = await compileProject({ + files, + loaderOptions: { + namedExport: true, + keywordPrefix: "my_prefix_" + } + }); + + const dtsContent = readFile(tmpDir, "styles.module.css.d.ts"); + expect(normalizeLineEndings(dtsContent)).toMatchSnapshot(); + + expect(dtsContent).toContain("declare const my_prefix_import: string;"); + expect(dtsContent).toContain('export { my_prefix_import as "import" };'); + expect(dtsContent).toContain("export const button: string;"); + }); + + it("should work with keywordPrefix and sort option", async () => { + const files = { + "index.js": "import styles from './styles.module.css';", + "styles.module.css": ` + .zebra { color: black; } + .class { color: blue; } + .alpha { color: green; } + .export { color: red; } + ` + }; + + const { tmpDir } = await compileProject({ + files, + loaderOptions: { + namedExport: true, + keywordPrefix: "dts", + sort: true + } + }); + + const dtsContent = readFile(tmpDir, "styles.module.css.d.ts"); + expect(normalizeLineEndings(dtsContent)).toMatchSnapshot(); + + // Keywords with custom prefix + expect(dtsContent).toContain("declare const dtsclass: string;"); + expect(dtsContent).toContain("declare const dtsexport: string;"); + + // Check sorted order of non-keywords + const lines = dtsContent.split("\n"); + const exportLines = lines.filter(l => l.startsWith("export const")); + expect(exportLines[0]).toContain("alpha"); + expect(exportLines[1]).toContain("zebra"); + }); + + it("should not affect interface export mode (namedExport=false)", async () => { + const files = { + "index.js": "import styles from './styles.module.css';", + "styles.module.css": ` + .class { color: blue; } + .export { color: red; } + ` + }; + + const { tmpDir } = await compileProject({ + files, + loaderOptions: { + namedExport: false, + keywordPrefix: "customPrefix" + } + }); + + const dtsContent = readFile(tmpDir, "styles.module.css.d.ts"); + expect(normalizeLineEndings(dtsContent)).toMatchSnapshot(); + + // Interface mode should not use prefix + expect(dtsContent).toContain("interface CssExports"); + expect(dtsContent).toContain('"class"'); + expect(dtsContent).toContain('"export"'); + expect(dtsContent).not.toContain("customPrefix"); + expect(dtsContent).not.toContain("declare const"); + }); + }); + describe("File Extensions", () => { // Note: These tests verify that the loader supports different CSS preprocessor extensions // In a real project, you would need sass-loader, less-loader, or stylus-loader installed diff --git a/test/utils.test.ts b/test/utils.test.ts index b36729b..b18991b 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -205,7 +205,8 @@ describe("utils", () => { sort: false, namedExport: true, mode: "emit", - banner: "// Test" + banner: "// Test", + keywordPrefix: "__dts_" } }); @@ -302,7 +303,8 @@ describe("utils", () => { sort: false, namedExport: true, mode: "emit", - banner: "// Test" + banner: "// Test", + keywordPrefix: "__dts_" } }); @@ -320,5 +322,138 @@ describe("utils", () => { // This will cause a TypeScript error, but it's an extremely unlikely edge case // where users are intentionally naming classes with the __dts_ prefix }); + + test("should use custom keywordPrefix (dts) when specified", () => { + const result = generateDtsContent({ + classNames: ["class", "export", "validName"], + options: { + exportLocalsConvention: "as-is", + quote: "double", + indentStyle: "space", + indentSize: 2, + sort: false, + namedExport: true, + mode: "emit", + banner: "// Test", + keywordPrefix: "dts" + } + }); + + // Should export non-keyword classes normally + expect(result).toContain("export const validName: string;"); + + // Should use custom prefix for keywords + expect(result).toContain("declare const dtsclass: string;"); + expect(result).toContain("declare const dtsexport: string;"); + expect(result).toContain('export { dtsclass as "class" };'); + expect(result).toContain('export { dtsexport as "export" };'); + + // Should NOT use default __dts_ prefix + expect(result).not.toContain("__dts_class"); + expect(result).not.toContain("__dts_export"); + }); + + test("should use custom keywordPrefix with underscores", () => { + const result = generateDtsContent({ + classNames: ["class", "import"], + options: { + exportLocalsConvention: "as-is", + quote: "double", + indentStyle: "space", + indentSize: 2, + sort: false, + namedExport: true, + mode: "emit", + banner: "// Test", + keywordPrefix: "my_prefix_" + } + }); + + expect(result).toContain("declare const my_prefix_class: string;"); + expect(result).toContain("declare const my_prefix_import: string;"); + expect(result).toContain('export { my_prefix_class as "class" };'); + expect(result).toContain('export { my_prefix_import as "import" };'); + }); + + test("should use custom keywordPrefix with camelCase style", () => { + const result = generateDtsContent({ + classNames: ["class", "for"], + options: { + exportLocalsConvention: "as-is", + quote: "double", + indentStyle: "space", + indentSize: 2, + sort: false, + namedExport: true, + mode: "emit", + banner: "// Test", + keywordPrefix: "dtsKeyword" + } + }); + + expect(result).toContain("declare const dtsKeywordclass: string;"); + expect(result).toContain("declare const dtsKeywordfor: string;"); + expect(result).toContain('export { dtsKeywordclass as "class" };'); + expect(result).toContain('export { dtsKeywordfor as "for" };'); + }); + + test("should not affect interface export mode (namedExport=false)", () => { + const result = generateDtsContent({ + classNames: ["class", "export", "normal"], + options: { + exportLocalsConvention: "as-is", + quote: "double", + indentStyle: "space", + indentSize: 2, + sort: false, + namedExport: false, + mode: "emit", + banner: "// Test", + keywordPrefix: "customPrefix" + } + }); + + // Interface mode should not use keyword prefix at all + expect(result).toContain("interface CssExports"); + expect(result).toContain('"class"'); + expect(result).toContain('"export"'); + expect(result).toContain('"normal"'); + expect(result).not.toContain("customPrefix"); + expect(result).not.toContain("declare const"); + }); + + test("should work with sorted keywords using custom prefix", () => { + const result = generateDtsContent({ + classNames: ["zebra", "class", "alpha", "export"], + options: { + exportLocalsConvention: "as-is", + quote: "double", + indentStyle: "space", + indentSize: 2, + sort: true, + namedExport: true, + mode: "emit", + banner: "// Test", + keywordPrefix: "dts" + } + }); + + const lines = result.split("\n"); + const exportLines = lines.filter(l => l.startsWith("export const")); + const declareLines = lines.filter(l => l.startsWith("declare const")); + const aliasedLines = lines.filter(l => l.startsWith("export {")); + + // Normal classes should be sorted + expect(exportLines[0]).toContain("alpha"); + expect(exportLines[1]).toContain("zebra"); + + // Keywords should use custom prefix + expect(declareLines).toHaveLength(2); + expect(declareLines[0]).toContain("dtsclass"); + expect(declareLines[1]).toContain("dtsexport"); + + expect(aliasedLines[0]).toContain('dtsclass as "class"'); + expect(aliasedLines[1]).toContain('dtsexport as "export"'); + }); }); });