Skip to content

Commit b0e431d

Browse files
authored
Merge pull request #7 from Ch-Valentine/support-keywords-for-named-export
feat: support js keywords for named export
2 parents 85ea779 + bc655d5 commit b0e431d

File tree

5 files changed

+107
-20
lines changed

5 files changed

+107
-20
lines changed

README.md

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

121121
**Behavior:**
122-
- Keywords are **skipped** in the generated `.d.ts` file
123-
- You can still access them via namespace import: `import * as styles from './styles.css'` and then `styles["class"]`
122+
- Keywords are exported using **aliased exports** to provide full type safety
123+
- Non-keyword classes are exported normally as named exports
124+
- Keywords are accessible via namespace import with full type safety
124125

125126
**Example:**
126127

@@ -134,19 +135,21 @@ Generated with `namedExport: true`:
134135
```ts
135136
// styles.module.css.d.ts
136137
export const container: string;
137-
// Note: "class" is skipped because it's a keyword
138+
139+
declare const __dts_class: string;
140+
export { __dts_class as "class" };
138141
```
139142

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

144-
// Works fine:
145-
styles.container; // Type-safe
146-
styles["class"]; // Also works, but not type-safe
147+
// Both are fully type-safe:
148+
styles.container; // Type-safe
149+
styles["class"]; // ✅ Type-safe via aliased export
147150
```
148151

149-
**Recommendation:** If you need type-safety for keyword class names, use `namedExport: false` which generates an interface including all classes.
152+
**Note:** With `namedExport: false`, all classes (including keywords) are included in the interface, so there's no difference in behavior for keywords.
150153

151154
### Example Output
152155

@@ -165,14 +168,34 @@ import { button, container } from './styles.module.css';
165168
import * as styles from './styles.module.css';
166169
```
167170

171+
If your CSS contains keywords:
172+
```ts
173+
// This file is automatically generated.
174+
// Please do not change this file!
175+
export const button: string;
176+
export const container: string;
177+
178+
declare const __dts_class: string;
179+
export { __dts_class as "class" };
180+
```
181+
182+
Usage with keywords:
183+
```ts
184+
import * as styles from './styles.module.css';
185+
186+
styles.button;
187+
styles.container;
188+
styles["class"]; // Type-safe via aliased export
189+
```
190+
168191
#### With `namedExport: false`
169192
```ts
170193
// This file is automatically generated.
171194
// Please do not change this file!
172195
interface CssExports {
173196
"button": string;
174197
"container": string;
175-
"class": string; // Keywords work here!
198+
"class": string;
176199
}
177200

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

186209
styles.button;
187210
styles.container;
188-
styles.class; // Keywords are type-safe here
211+
styles.class;
189212
```
190213

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

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

197-
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`.
198-
199220
## How It Works
200221

201222
1. **Runs after css-loader:** The loader processes the JavaScript output from css-loader, not the raw CSS.

src/utils.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -319,11 +319,11 @@ export function handleDtsFile({ dtsFilePath, dtsContent, mode, logger: _logger,
319319
* 1. Applies exportLocalsConvention transformation (or legacy camelCase)
320320
* 2. Sorts class names alphabetically if enabled
321321
* 3. Checks for JavaScript reserved keywords in class names
322-
* 4. Chooses appropriate export format based on options and keyword detection
322+
* 4. Chooses appropriate export format based on options
323323
* 5. Formats the output with custom indentation and quotes
324324
*
325325
* Export format selection:
326-
* - If namedExport=true: generates named exports for non-keyword classes only (keywords are skipped)
326+
* - If namedExport=true: generates named exports for non-keyword classes, aliased exports for keywords
327327
* - If namedExport=false: generates interface + default export with all classes
328328
*
329329
* @param params - Parameters object
@@ -342,13 +342,16 @@ export function handleDtsFile({ dtsFilePath, dtsContent, mode, logger: _logger,
342342
* // export const button: string;
343343
* // export const container: string;
344344
*
345-
* // Named exports with keywords (keywords are skipped)
345+
* // Named exports with keywords (keywords use aliased exports)
346346
* generateDtsContent({
347347
* classNames: ["class", "button"],
348348
* options: { namedExport: true, exportLocalsConvention: "as-is", ... }
349349
* });
350350
* // Returns:
351351
* // export const button: string;
352+
* //
353+
* // declare const __dts_class: string;
354+
* // export { __dts_class as "class" };
352355
* ```
353356
*/
354357
export function generateDtsContent({ classNames, options }: GenerateDtsContentParams): string {
@@ -375,13 +378,20 @@ export function generateDtsContent({ classNames, options }: GenerateDtsContentPa
375378
}
376379

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

380384
if (options.namedExport) {
381-
// namedExport:true - only export non-keyword classes as named exports
382-
// Keywords are skipped because they cannot be used as named exports in JavaScript
383-
// Users can still access them via import * as styles and styles["keyword"]
385+
// namedExport:true - export non-keyword classes directly
384386
content.push(...nonKeywords.map(cls => `export const ${cls}: string;`));
387+
388+
// For keywords, use aliased exports to provide type safety
389+
// declare const __dts_class: string; export { __dts_class as "class" };
390+
if (keywords.length > 0) {
391+
content.push("");
392+
content.push(...keywords.map(cls => `declare const __dts_${cls}: string;`));
393+
content.push(...keywords.map(cls => `export { __dts_${cls} as "${cls}" };`));
394+
}
385395
} else {
386396
// namedExport:false - always use interface format
387397
content.push(

test/__snapshots__/index.test.ts.snap

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ exports[`css-modules-dts-loader > JavaScript Keywords as Class Names > should ha
3131
"// This file is automatically generated.
3232
// Please do not change this file!
3333
export const validClass: string;
34+
35+
declare const __dts_class: string;
36+
declare const __dts_export: string;
37+
declare const __dts_import: string;
38+
export { __dts_class as "class" };
39+
export { __dts_export as "export" };
40+
export { __dts_import as "import" };
3441
"
3542
`;
3643

test/index.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,11 +243,21 @@ describe("css-modules-dts-loader", () => {
243243
const dtsContent = readFile(tmpDir, "styles.module.css.d.ts");
244244
expect(normalizeLineEndings(dtsContent)).toMatchSnapshot();
245245

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

249+
// Should NOT contain keywords as direct named exports
248250
expect(dtsContent).not.toContain("export const class: string;");
249251
expect(dtsContent).not.toContain("export const export: string;");
250252
expect(dtsContent).not.toContain("export const import: string;");
253+
254+
// Should contain aliased exports for keywords
255+
expect(dtsContent).toContain("declare const __dts_class: string;");
256+
expect(dtsContent).toContain("declare const __dts_export: string;");
257+
expect(dtsContent).toContain("declare const __dts_import: string;");
258+
expect(dtsContent).toContain('export { __dts_class as "class" };');
259+
expect(dtsContent).toContain('export { __dts_export as "export" };');
260+
expect(dtsContent).toContain('export { __dts_import as "import" };');
251261
});
252262

253263
it("should handle JS keyword class names with namedExport=false", async () => {

test/utils.test.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ describe("utils", () => {
194194
expect(result).toContain("export const test: string;");
195195
});
196196

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

212-
// Should only export non-keyword classes
212+
// Should export non-keyword classes normally
213213
expect(result).toContain("export const validName: string;");
214+
215+
// Should NOT export keywords as named exports directly
214216
expect(result).not.toContain("export const class");
215217
expect(result).not.toContain("export const export");
218+
219+
// Should use aliased exports for keywords
220+
expect(result).toContain("declare const __dts_class: string;");
221+
expect(result).toContain("declare const __dts_export: string;");
222+
expect(result).toContain('export { __dts_class as "class" };');
223+
expect(result).toContain('export { __dts_export as "export" };');
224+
216225
expect(result).not.toContain("interface");
217226
});
218227

@@ -281,5 +290,35 @@ describe("utils", () => {
281290
expect(result).toContain("anotherClass");
282291
expect(result).not.toContain("kebab-case-name");
283292
});
293+
294+
test("should handle collision with __dts_ prefix (class name starting with __dts_)", () => {
295+
const result = generateDtsContent({
296+
classNames: ["__dts_class", "class", "normalClass"],
297+
options: {
298+
exportLocalsConvention: "as-is",
299+
quote: "double",
300+
indentStyle: "space",
301+
indentSize: 2,
302+
sort: false,
303+
namedExport: true,
304+
mode: "emit",
305+
banner: "// Test"
306+
}
307+
});
308+
309+
// Normal classes should export normally
310+
expect(result).toContain("export const normalClass: string;");
311+
expect(result).toContain("export const __dts_class: string;");
312+
313+
// Keywords should use aliased exports (potentially colliding with existing class)
314+
expect(result).toContain("declare const __dts_class: string;");
315+
expect(result).toContain('export { __dts_class as "class" };');
316+
317+
// Note: In this edge case, there will be both:
318+
// - export const __dts_class (for the actual CSS class named __dts_class)
319+
// - declare const __dts_class (for aliasing the keyword "class")
320+
// This will cause a TypeScript error, but it's an extremely unlikely edge case
321+
// where users are intentionally naming classes with the __dts_ prefix
322+
});
284323
});
285324
});

0 commit comments

Comments
 (0)