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" ;
55import { readFileSync , writeFileSync , existsSync } from "fs" ;
66
77/**
@@ -32,7 +32,9 @@ export interface HandleDtsFileParams {
3232 */
3333export 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 */
8082export 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 */
273354export 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;` ) ,
0 commit comments