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
274 changes: 172 additions & 102 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"build:watch": "tsc --watch",
"clean": "rm -rf dist",
"type-check": "tsc --noEmit && tsc --project tsconfig.test.json",
"test": "vitest run --coverage",
"test": "pnpm build && vitest run --coverage",
"test:watch": "vitest",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
Expand Down
28 changes: 23 additions & 5 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
/**
* Export locals convention types matching css-loader
*/
export type ExportLocalsConvention =
| "as-is"
| "camel-case"
| "camel-case-only"
| "dashes"
| "dashes-only";

/**
* Loader options interface
*/
export interface LoaderOptions {
/** @deprecated Use exportLocalsConvention instead. Will be removed in v2.0 */
camelCase?: boolean;
exportLocalsConvention?: ExportLocalsConvention;
quote?: "single" | "double";
indentStyle?: "tab" | "space";
indentSize?: number;
Expand Down Expand Up @@ -59,6 +71,10 @@ export const SCHEMA: SchemaDefinition = {
type: "object",
properties: {
camelCase: { type: "boolean" },
exportLocalsConvention: {
type: "string",
enum: ["as-is", "camel-case", "camel-case-only", "dashes", "dashes-only"]
},
quote: { type: "string", enum: ["single", "double"] },
indentStyle: { type: "string", enum: ["tab", "space"] },
indentSize: { type: "number" },
Expand All @@ -72,13 +88,15 @@ export const SCHEMA: SchemaDefinition = {

/**
* Default options for the loader.
* Note: exportLocalsConvention default depends on namedExport and is set dynamically
*/
export const DEFAULT_OPTIONS: Required<LoaderOptions> = {
camelCase: false,
quote: "double",
indentStyle: "space",
export const DEFAULT_OPTIONS = {
camelCase: undefined as boolean | undefined,
exportLocalsConvention: undefined as ExportLocalsConvention | undefined,
quote: "double" as const,
indentStyle: "space" as const,
indentSize: 2,
mode: "emit",
mode: "emit" as const,
sort: false,
namedExport: true,
banner: "// This file is automatically generated.\n// Please do not change this file!"
Expand Down
14 changes: 13 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,19 @@ export default function cssModuleTypesLoader(this: LoaderContext, source: string
});

// Merge default options with user options
const options = { ...DEFAULT_OPTIONS, ...providedOptions };
const mergedOptions = { ...DEFAULT_OPTIONS, ...providedOptions };

// Handle backward compatibility: camelCase -> exportLocalsConvention
if (mergedOptions.camelCase !== undefined && mergedOptions.exportLocalsConvention === undefined) {
mergedOptions.exportLocalsConvention = mergedOptions.camelCase ? "camel-case-only" : "as-is";
}

// Set exportLocalsConvention default based on namedExport (matching css-loader)
if (mergedOptions.exportLocalsConvention === undefined) {
mergedOptions.exportLocalsConvention = mergedOptions.namedExport ? "as-is" : "camel-case-only";
}

const options = mergedOptions as Required<LoaderOptions>;

const classNames = extractClassNames(source);

Expand Down
143 changes: 113 additions & 30 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Utility functions for CSS Modules DTS loader.
*/
import { CSS_MODULE_PATTERNS, EXPORT_MARKERS, JS_KEYWORDS, LoaderOptions } from "./constants.js";
import { CSS_MODULE_PATTERNS, EXPORT_MARKERS, JS_KEYWORDS, LoaderOptions, ExportLocalsConvention } from "./constants.js";
import { readFileSync, writeFileSync, existsSync } from "fs";

/**
Expand Down Expand Up @@ -32,7 +32,9 @@ export interface HandleDtsFileParams {
*/
export interface GenerateDtsContentParams {
classNames: string[];
options: Required<LoaderOptions>;
options: Required<Omit<LoaderOptions, "camelCase" | "exportLocalsConvention">> & {
exportLocalsConvention: ExportLocalsConvention;
};
}

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

/**
* Transforms class names according to exportLocalsConvention setting.
* Matches css-loader's behavior.
*
* @param classNames - Array of original class names
* @param convention - The export locals convention to apply
* @returns Array of transformed class names (may include duplicates if convention exports both forms)
*
* @example
* ```ts
* applyExportLocalsConvention(["foo-bar"], "as-is") // ["foo-bar"]
* applyExportLocalsConvention(["foo-bar"], "camel-case") // ["foo-bar", "fooBar"]
* applyExportLocalsConvention(["foo-bar"], "camel-case-only") // ["fooBar"]
* ```
*/
export function applyExportLocalsConvention(
classNames: string[],
convention: ExportLocalsConvention
): string[] {
const result: string[] = [];
const seen = new Set<string>();

for (const className of classNames) {
switch (convention) {
case "as-is":
// Export exactly as-is
if (!seen.has(className)) {
result.push(className);
seen.add(className);
}
break;

case "camel-case": {
// Export both original and camelCase
if (!seen.has(className)) {
result.push(className);
seen.add(className);
}
const camelCased = toCamelCase(className);
if (!seen.has(camelCased) && camelCased !== className) {
result.push(camelCased);
seen.add(camelCased);
}
break;
}

case "camel-case-only": {
// Export only camelCase
const camelCased = toCamelCase(className);
if (!seen.has(camelCased)) {
result.push(camelCased);
seen.add(camelCased);
}
break;
}

case "dashes": {
// Export both original and camelCase (same as camel-case)
if (!seen.has(className)) {
result.push(className);
seen.add(className);
}
const camelCased = toCamelCase(className);
if (!seen.has(camelCased) && camelCased !== className) {
result.push(camelCased);
seen.add(camelCased);
}
break;
}

case "dashes-only": {
// Export only camelCase (same as camel-case-only)
const camelCased = toCamelCase(className);
if (!seen.has(camelCased)) {
result.push(camelCased);
seen.add(camelCased);
}
break;
}
}
}

return result;
}

/**
* Determines the CSS loader version and export format based on source content.
* Supports css-loader versions 3, 4, and 5 with different export formats.
Expand Down Expand Up @@ -229,56 +316,52 @@ export function handleDtsFile({ dtsFilePath, dtsContent, mode, logger: _logger,
* Generates the content for a .d.ts file based on extracted CSS class names and options.
*
* This function performs several transformations:
* 1. Applies camelCase conversion if enabled
* 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
* 5. Formats the output with custom indentation and quotes
*
* Export format selection:
* - If namedExport=true and no keywords: generates named exports (export const foo: string;)
* - If namedExport=true but has keywords: falls back to interface + export = (for compatibility)
* - If namedExport=false: generates interface + default export
* - If namedExport=true: generates named exports for non-keyword classes only (keywords are skipped)
* - If namedExport=false: generates interface + default export with all classes
*
* @param params - Parameters object
* @param params.classNames - Array of CSS class names extracted from the module
* @param params.options - Loader options (camelCase, quote, indentStyle, etc.)
* @param params.options - Loader options (exportLocalsConvention, quote, indentStyle, etc.)
* @returns Generated TypeScript declaration file content with trailing newline
*
* @example
* ```ts
* // Named exports (no keywords)
* generateDtsContent({
* classNames: ["button", "container"],
* options: { namedExport: true, quote: "double", ... }
* options: { namedExport: true, exportLocalsConvention: "as-is", ... }
* });
* // Returns:
* // export const button: string;
* // export const container: string;
*
* // Interface format (with keywords)
* // Named exports with keywords (keywords are skipped)
* generateDtsContent({
* classNames: ["class", "button"],
* options: { namedExport: true, quote: "double", ... }
* options: { namedExport: true, exportLocalsConvention: "as-is", ... }
* });
* // Returns:
* // interface CssExports {
* // "class": string;
* // "button": string;
* // }
* // export const cssExports: CssExports;
* // export = cssExports;
* // export const button: string;
* ```
*/
export function generateDtsContent({ classNames, options }: GenerateDtsContentParams): string {
// Apply exportLocalsConvention transformation
const transformedClassNames = applyExportLocalsConvention(
classNames,
options.exportLocalsConvention as ExportLocalsConvention
);

const baseClassNames = options.camelCase
? classNames.map(toCamelCase)
: classNames;

// Sort if requested
const processedClassNames = options.sort
? [...baseClassNames].sort((a, b) => a.localeCompare(b))
: baseClassNames;
? [...transformedClassNames].sort((a, b) => a.localeCompare(b))
: transformedClassNames;

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

// Check if any class names are JS keywords
const hasKeywords = processedClassNames.some(cls => JS_KEYWORDS.has(cls));

// If namedExport is requested but we have keywords, fall back to interface format
// because we can't use keywords as named exports (e.g., export const class: string;)
const useNamedExport = options.namedExport && !hasKeywords;
// Separate keywords from non-keywords
const nonKeywords = processedClassNames.filter(cls => !JS_KEYWORDS.has(cls));

if (useNamedExport) {
content.push(...processedClassNames.map(cls => `export const ${cls}: string;`));
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"]
content.push(...nonKeywords.map(cls => `export const ${cls}: string;`));
} else {
// namedExport:false - always use interface format
content.push(
"interface CssExports {",
...processedClassNames.map((cls) => `${indent}${quoteChar}${cls}${quoteChar}: string;`),
Expand Down
10 changes: 1 addition & 9 deletions test/__snapshots__/index.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,7 @@ export default cssExports;
exports[`css-modules-dts-loader > JavaScript Keywords as Class Names > should handle JS keyword class names with namedExport=true 1`] = `
"// This file is automatically generated.
// Please do not change this file!
interface CssExports {
"validClass": string;
"class": string;
"export": string;
"import": string;
}

export const cssExports: CssExports;
export default cssExports;
export const validClass: string;
"
`;

Expand Down
6 changes: 5 additions & 1 deletion test/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@ function compileProject({ files, loaderOptions = {} }: CompileProjectOptions): P
loader: "css-loader",
options: {
modules: {
namedExport: true
// Match css-loader's namedExport with the loader's namedExport option
namedExport: loaderOptions.namedExport !== false,
// Match exportLocalsConvention or use appropriate default
exportLocalsConvention: loaderOptions.exportLocalsConvention ||
(loaderOptions.namedExport !== false ? "as-is" : "camel-case-only")
}
}
}
Expand Down
10 changes: 5 additions & 5 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,11 +243,11 @@ describe("css-modules-dts-loader", () => {
const dtsContent = readFile(tmpDir, "styles.module.css.d.ts");
expect(normalizeLineEndings(dtsContent)).toMatchSnapshot();

// Should contain all class names
expect(dtsContent).toContain("class");
expect(dtsContent).toContain("export");
expect(dtsContent).toContain("import");
expect(dtsContent).toContain("validClass");
expect(dtsContent).toContain("export const validClass: string;");

expect(dtsContent).not.toContain("export const class: string;");
expect(dtsContent).not.toContain("export const export: string;");
expect(dtsContent).not.toContain("export const import: string;");
});

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