Skip to content

Commit e5bb358

Browse files
authored
Merge pull request #6 from Ch-Valentine/align-with-css-loader
feat: add exportLocalsConvention support and fix keyword handling
2 parents e435d21 + beef042 commit e5bb358

File tree

9 files changed

+346
-167
lines changed

9 files changed

+346
-167
lines changed

README.md

Lines changed: 172 additions & 102 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"build:watch": "tsc --watch",
3939
"clean": "rm -rf dist",
4040
"type-check": "tsc --noEmit && tsc --project tsconfig.test.json",
41-
"test": "vitest run --coverage",
41+
"test": "pnpm build && vitest run --coverage",
4242
"test:watch": "vitest",
4343
"lint": "eslint .",
4444
"lint:fix": "eslint . --fix",

src/constants.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
1+
/**
2+
* Export locals convention types matching css-loader
3+
*/
4+
export type ExportLocalsConvention =
5+
| "as-is"
6+
| "camel-case"
7+
| "camel-case-only"
8+
| "dashes"
9+
| "dashes-only";
10+
111
/**
212
* Loader options interface
313
*/
414
export interface LoaderOptions {
15+
/** @deprecated Use exportLocalsConvention instead. Will be removed in v2.0 */
516
camelCase?: boolean;
17+
exportLocalsConvention?: ExportLocalsConvention;
618
quote?: "single" | "double";
719
indentStyle?: "tab" | "space";
820
indentSize?: number;
@@ -59,6 +71,10 @@ export const SCHEMA: SchemaDefinition = {
5971
type: "object",
6072
properties: {
6173
camelCase: { type: "boolean" },
74+
exportLocalsConvention: {
75+
type: "string",
76+
enum: ["as-is", "camel-case", "camel-case-only", "dashes", "dashes-only"]
77+
},
6278
quote: { type: "string", enum: ["single", "double"] },
6379
indentStyle: { type: "string", enum: ["tab", "space"] },
6480
indentSize: { type: "number" },
@@ -72,13 +88,15 @@ export const SCHEMA: SchemaDefinition = {
7288

7389
/**
7490
* Default options for the loader.
91+
* Note: exportLocalsConvention default depends on namedExport and is set dynamically
7592
*/
76-
export const DEFAULT_OPTIONS: Required<LoaderOptions> = {
77-
camelCase: false,
78-
quote: "double",
79-
indentStyle: "space",
93+
export const DEFAULT_OPTIONS = {
94+
camelCase: undefined as boolean | undefined,
95+
exportLocalsConvention: undefined as ExportLocalsConvention | undefined,
96+
quote: "double" as const,
97+
indentStyle: "space" as const,
8098
indentSize: 2,
81-
mode: "emit",
99+
mode: "emit" as const,
82100
sort: false,
83101
namedExport: true,
84102
banner: "// This file is automatically generated.\n// Please do not change this file!"

src/index.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,19 @@ export default function cssModuleTypesLoader(this: LoaderContext, source: string
9191
});
9292

9393
// Merge default options with user options
94-
const options = { ...DEFAULT_OPTIONS, ...providedOptions };
94+
const mergedOptions = { ...DEFAULT_OPTIONS, ...providedOptions };
95+
96+
// Handle backward compatibility: camelCase -> exportLocalsConvention
97+
if (mergedOptions.camelCase !== undefined && mergedOptions.exportLocalsConvention === undefined) {
98+
mergedOptions.exportLocalsConvention = mergedOptions.camelCase ? "camel-case-only" : "as-is";
99+
}
100+
101+
// Set exportLocalsConvention default based on namedExport (matching css-loader)
102+
if (mergedOptions.exportLocalsConvention === undefined) {
103+
mergedOptions.exportLocalsConvention = mergedOptions.namedExport ? "as-is" : "camel-case-only";
104+
}
105+
106+
const options = mergedOptions as Required<LoaderOptions>;
95107

96108
const classNames = extractClassNames(source);
97109

src/utils.ts

Lines changed: 113 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* Utility functions for CSS Modules DTS loader.
33
*/
4-
import { CSS_MODULE_PATTERNS, EXPORT_MARKERS, JS_KEYWORDS, LoaderOptions } from "./constants.js";
4+
import { CSS_MODULE_PATTERNS, EXPORT_MARKERS, JS_KEYWORDS, LoaderOptions, ExportLocalsConvention } from "./constants.js";
55
import { readFileSync, writeFileSync, existsSync } from "fs";
66

77
/**
@@ -32,7 +32,9 @@ export interface HandleDtsFileParams {
3232
*/
3333
export interface GenerateDtsContentParams {
3434
classNames: string[];
35-
options: Required<LoaderOptions>;
35+
options: Required<Omit<LoaderOptions, "camelCase" | "exportLocalsConvention">> & {
36+
exportLocalsConvention: ExportLocalsConvention;
37+
};
3638
}
3739

3840
/**
@@ -79,6 +81,91 @@ export const enforceLFLineSeparators = (text: string): string => typeof text ===
7981
*/
8082
export const toCamelCase = (name: string): string => name.replace(/-([a-z])/g, (_, p1) => p1.toUpperCase());
8183

84+
/**
85+
* Transforms class names according to exportLocalsConvention setting.
86+
* Matches css-loader's behavior.
87+
*
88+
* @param classNames - Array of original class names
89+
* @param convention - The export locals convention to apply
90+
* @returns Array of transformed class names (may include duplicates if convention exports both forms)
91+
*
92+
* @example
93+
* ```ts
94+
* applyExportLocalsConvention(["foo-bar"], "as-is") // ["foo-bar"]
95+
* applyExportLocalsConvention(["foo-bar"], "camel-case") // ["foo-bar", "fooBar"]
96+
* applyExportLocalsConvention(["foo-bar"], "camel-case-only") // ["fooBar"]
97+
* ```
98+
*/
99+
export function applyExportLocalsConvention(
100+
classNames: string[],
101+
convention: ExportLocalsConvention
102+
): string[] {
103+
const result: string[] = [];
104+
const seen = new Set<string>();
105+
106+
for (const className of classNames) {
107+
switch (convention) {
108+
case "as-is":
109+
// Export exactly as-is
110+
if (!seen.has(className)) {
111+
result.push(className);
112+
seen.add(className);
113+
}
114+
break;
115+
116+
case "camel-case": {
117+
// Export both original and camelCase
118+
if (!seen.has(className)) {
119+
result.push(className);
120+
seen.add(className);
121+
}
122+
const camelCased = toCamelCase(className);
123+
if (!seen.has(camelCased) && camelCased !== className) {
124+
result.push(camelCased);
125+
seen.add(camelCased);
126+
}
127+
break;
128+
}
129+
130+
case "camel-case-only": {
131+
// Export only camelCase
132+
const camelCased = toCamelCase(className);
133+
if (!seen.has(camelCased)) {
134+
result.push(camelCased);
135+
seen.add(camelCased);
136+
}
137+
break;
138+
}
139+
140+
case "dashes": {
141+
// Export both original and camelCase (same as camel-case)
142+
if (!seen.has(className)) {
143+
result.push(className);
144+
seen.add(className);
145+
}
146+
const camelCased = toCamelCase(className);
147+
if (!seen.has(camelCased) && camelCased !== className) {
148+
result.push(camelCased);
149+
seen.add(camelCased);
150+
}
151+
break;
152+
}
153+
154+
case "dashes-only": {
155+
// Export only camelCase (same as camel-case-only)
156+
const camelCased = toCamelCase(className);
157+
if (!seen.has(camelCased)) {
158+
result.push(camelCased);
159+
seen.add(camelCased);
160+
}
161+
break;
162+
}
163+
}
164+
}
165+
166+
return result;
167+
}
168+
82169
/**
83170
* Determines the CSS loader version and export format based on source content.
84171
* Supports css-loader versions 3, 4, and 5 with different export formats.
@@ -229,56 +316,52 @@ export function handleDtsFile({ dtsFilePath, dtsContent, mode, logger: _logger,
229316
* Generates the content for a .d.ts file based on extracted CSS class names and options.
230317
*
231318
* This function performs several transformations:
232-
* 1. Applies camelCase conversion if enabled
319+
* 1. Applies exportLocalsConvention transformation (or legacy camelCase)
233320
* 2. Sorts class names alphabetically if enabled
234321
* 3. Checks for JavaScript reserved keywords in class names
235322
* 4. Chooses appropriate export format based on options and keyword detection
236323
* 5. Formats the output with custom indentation and quotes
237324
*
238325
* Export format selection:
239-
* - If namedExport=true and no keywords: generates named exports (export const foo: string;)
240-
* - If namedExport=true but has keywords: falls back to interface + export = (for compatibility)
241-
* - If namedExport=false: generates interface + default export
326+
* - If namedExport=true: generates named exports for non-keyword classes only (keywords are skipped)
327+
* - If namedExport=false: generates interface + default export with all classes
242328
*
243329
* @param params - Parameters object
244330
* @param params.classNames - Array of CSS class names extracted from the module
245-
* @param params.options - Loader options (camelCase, quote, indentStyle, etc.)
331+
* @param params.options - Loader options (exportLocalsConvention, quote, indentStyle, etc.)
246332
* @returns Generated TypeScript declaration file content with trailing newline
247333
*
248334
* @example
249335
* ```ts
250336
* // Named exports (no keywords)
251337
* generateDtsContent({
252338
* classNames: ["button", "container"],
253-
* options: { namedExport: true, quote: "double", ... }
339+
* options: { namedExport: true, exportLocalsConvention: "as-is", ... }
254340
* });
255341
* // Returns:
256342
* // export const button: string;
257343
* // export const container: string;
258344
*
259-
* // Interface format (with keywords)
345+
* // Named exports with keywords (keywords are skipped)
260346
* generateDtsContent({
261347
* classNames: ["class", "button"],
262-
* options: { namedExport: true, quote: "double", ... }
348+
* options: { namedExport: true, exportLocalsConvention: "as-is", ... }
263349
* });
264350
* // Returns:
265-
* // interface CssExports {
266-
* // "class": string;
267-
* // "button": string;
268-
* // }
269-
* // export const cssExports: CssExports;
270-
* // export = cssExports;
351+
* // export const button: string;
271352
* ```
272353
*/
273354
export function generateDtsContent({ classNames, options }: GenerateDtsContentParams): string {
355+
// Apply exportLocalsConvention transformation
356+
const transformedClassNames = applyExportLocalsConvention(
357+
classNames,
358+
options.exportLocalsConvention as ExportLocalsConvention
359+
);
274360

275-
const baseClassNames = options.camelCase
276-
? classNames.map(toCamelCase)
277-
: classNames;
278-
361+
// Sort if requested
279362
const processedClassNames = options.sort
280-
? [...baseClassNames].sort((a, b) => a.localeCompare(b))
281-
: baseClassNames;
363+
? [...transformedClassNames].sort((a, b) => a.localeCompare(b))
364+
: transformedClassNames;
282365

283366
const quoteChar = options.quote === "single" ? "'" : "\"";
284367
const indent = options.indentStyle === "tab"
@@ -291,16 +374,16 @@ export function generateDtsContent({ classNames, options }: GenerateDtsContentPa
291374
content.push(...options.banner.split("\n"));
292375
}
293376

294-
// Check if any class names are JS keywords
295-
const hasKeywords = processedClassNames.some(cls => JS_KEYWORDS.has(cls));
296-
297-
// If namedExport is requested but we have keywords, fall back to interface format
298-
// because we can't use keywords as named exports (e.g., export const class: string;)
299-
const useNamedExport = options.namedExport && !hasKeywords;
377+
// Separate keywords from non-keywords
378+
const nonKeywords = processedClassNames.filter(cls => !JS_KEYWORDS.has(cls));
300379

301-
if (useNamedExport) {
302-
content.push(...processedClassNames.map(cls => `export const ${cls}: string;`));
380+
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"]
384+
content.push(...nonKeywords.map(cls => `export const ${cls}: string;`));
303385
} else {
386+
// namedExport:false - always use interface format
304387
content.push(
305388
"interface CssExports {",
306389
...processedClassNames.map((cls) => `${indent}${quoteChar}${cls}${quoteChar}: string;`),

test/__snapshots__/index.test.ts.snap

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,7 @@ export default cssExports;
3030
exports[`css-modules-dts-loader > JavaScript Keywords as Class Names > should handle JS keyword class names with namedExport=true 1`] = `
3131
"// This file is automatically generated.
3232
// Please do not change this file!
33-
interface CssExports {
34-
"validClass": string;
35-
"class": string;
36-
"export": string;
37-
"import": string;
38-
}
39-
40-
export const cssExports: CssExports;
41-
export default cssExports;
33+
export const validClass: string;
4234
"
4335
`;
4436

test/compiler.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,11 @@ function compileProject({ files, loaderOptions = {} }: CompileProjectOptions): P
6464
loader: "css-loader",
6565
options: {
6666
modules: {
67-
namedExport: true
67+
// Match css-loader's namedExport with the loader's namedExport option
68+
namedExport: loaderOptions.namedExport !== false,
69+
// Match exportLocalsConvention or use appropriate default
70+
exportLocalsConvention: loaderOptions.exportLocalsConvention ||
71+
(loaderOptions.namedExport !== false ? "as-is" : "camel-case-only")
6872
}
6973
}
7074
}

test/index.test.ts

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

246-
// Should contain all class names
247-
expect(dtsContent).toContain("class");
248-
expect(dtsContent).toContain("export");
249-
expect(dtsContent).toContain("import");
250-
expect(dtsContent).toContain("validClass");
246+
expect(dtsContent).toContain("export const validClass: string;");
247+
248+
expect(dtsContent).not.toContain("export const class: string;");
249+
expect(dtsContent).not.toContain("export const export: string;");
250+
expect(dtsContent).not.toContain("export const import: string;");
251251
});
252252

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

0 commit comments

Comments
 (0)