vite-plugin-css-module-types 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,499 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import type { ClassEntry } from "../lib/types.ts";
3
+ import { generateDtsContent } from "../lib/generate-dts.ts";
4
+
5
+ // ─── generateDtsContent (default mode) ─────────────────────────────────────
6
+
7
+ describe("generateDtsContent", () => {
8
+ it("generates declaration for a single class without line info", () => {
9
+ const entries: ClassEntry[] = [
10
+ { rule: "button", value: "btn_hash", comment: "", cssBlock: "" },
11
+ ];
12
+
13
+ const { content: dts } = generateDtsContent(entries, "comp.module.css", "/abs/comp.module.css");
14
+
15
+ expect(dts).toContain("declare const styles: {");
16
+ expect(dts).toContain('readonly "button": "btn_hash";');
17
+ expect(dts).toContain("export default styles;");
18
+ expect(dts).toContain("comp.module.css");
19
+ expect(dts).toContain("file:///abs/comp.module.css");
20
+ });
21
+
22
+ it("includes line number in link when available", () => {
23
+ const entries: ClassEntry[] = [
24
+ { rule: "card", value: "card_hash", line: 15, comment: "", cssBlock: "" },
25
+ ];
26
+
27
+ const { content: dts } = generateDtsContent(entries, "card.module.css", "/abs/card.module.css");
28
+
29
+ expect(dts).toContain(":15");
30
+ expect(dts).toContain("#L15");
31
+ });
32
+
33
+ it("includes comment block when comment is present", () => {
34
+ const entries: ClassEntry[] = [
35
+ {
36
+ rule: "card",
37
+ value: "card_hash",
38
+ line: 5,
39
+ comment: "Card container style",
40
+ cssBlock: "",
41
+ },
42
+ ];
43
+
44
+ const { content: dts } = generateDtsContent(entries, "card.module.css", "/abs/card.module.css");
45
+
46
+ expect(dts).toContain("```txt");
47
+ expect(dts).toContain("Card container style");
48
+ expect(dts).toContain("```");
49
+ });
50
+
51
+ it("includes CSS rule block when cssBlock is present", () => {
52
+ const entries: ClassEntry[] = [
53
+ {
54
+ rule: "text-s",
55
+ value: "text-s_hash",
56
+ line: 5,
57
+ comment: "",
58
+ cssBlock: ".text-s {\n font-size: var(--fs-s);\n line-height: var(--lh-s);\n}",
59
+ },
60
+ ];
61
+
62
+ const { content: dts } = generateDtsContent(
63
+ entries,
64
+ "typography.module.css",
65
+ "/abs/typography.module.css",
66
+ );
67
+
68
+ expect(dts).toContain("```css");
69
+ expect(dts).toContain(".text-s {");
70
+ expect(dts).toContain("font-size: var(--fs-s);");
71
+ expect(dts).toContain("```");
72
+ // Horizontal rule separates CSS block from the file link
73
+ expect(dts).toContain("---");
74
+ });
75
+
76
+ it("includes both comment and CSS block when both present", () => {
77
+ const entries: ClassEntry[] = [
78
+ {
79
+ rule: "btn",
80
+ value: "btn_hash",
81
+ line: 3,
82
+ comment: "Primary action button",
83
+ cssBlock: ".btn {\n color: blue;\n}",
84
+ },
85
+ ];
86
+
87
+ const { content: dts } = generateDtsContent(entries, "btn.module.css", "/abs/btn.module.css");
88
+
89
+ expect(dts).toContain("```txt");
90
+ expect(dts).toContain("Primary action button");
91
+ expect(dts).toContain("```css");
92
+ expect(dts).toContain(".btn {");
93
+ });
94
+
95
+ it("escapes CSS comment delimiters with look-alike characters", () => {
96
+ const entries: ClassEntry[] = [
97
+ {
98
+ rule: "gap",
99
+ value: "gap_hash",
100
+ line: 3,
101
+ comment: "",
102
+ cssBlock: ".gap {\n gap: 0.25rem; /** 4px; */\n}",
103
+ },
104
+ ];
105
+
106
+ const { content: dts } = generateDtsContent(
107
+ entries,
108
+ "layout.module.css",
109
+ "/abs/layout.module.css",
110
+ );
111
+
112
+ // The literal CSS comment delimiters must not appear inside the fenced block
113
+ expect(dts).not.toContain("/** 4px; */");
114
+ // The original `/*` open delimiter is replaced with ASTERISK OPERATOR
115
+ expect(dts).not.toMatch(/```css[\s\S]*\/\*[\s\S]*```/);
116
+ // Comment content is preserved with ASTERISK OPERATOR (U+2217) look-alikes
117
+ expect(dts).toContain("/\u2217");
118
+ expect(dts).toContain("\u2217/");
119
+ expect(dts).toContain("4px;");
120
+ expect(dts).toContain("gap: 0.25rem;");
121
+ // Verify the JSDoc closes properly — the link should be present after the CSS block
122
+ expect(dts).toContain("layout.module.css:3");
123
+ });
124
+
125
+ it("handles multiple entries", () => {
126
+ const entries: ClassEntry[] = [
127
+ { rule: "a", value: "a_h", comment: "", cssBlock: "" },
128
+ { rule: "b", value: "b_h", comment: "", cssBlock: "" },
129
+ ];
130
+
131
+ const { content: dts } = generateDtsContent(entries, "f.module.css", "/abs/f.module.css");
132
+
133
+ expect(dts).toContain('readonly "a": "a_h";');
134
+ expect(dts).toContain('readonly "b": "b_h";');
135
+ });
136
+
137
+ it('generates "export {};" for empty entries (empty module)', () => {
138
+ const { content: dts, lineMappings } = generateDtsContent(
139
+ [],
140
+ "empty.module.css",
141
+ "/abs/empty.module.css",
142
+ );
143
+
144
+ expect(dts).toBe("export {};\n");
145
+ expect(lineMappings).toEqual([]);
146
+ });
147
+
148
+ it("properly escapes special characters in class and value names", () => {
149
+ const entries: ClassEntry[] = [
150
+ { rule: "my-class", value: "my-class_abc123", comment: "", cssBlock: "" },
151
+ ];
152
+
153
+ const { content: dts } = generateDtsContent(entries, "f.module.css", "/f.module.css");
154
+
155
+ expect(dts).toContain('readonly "my-class": "my-class_abc123";');
156
+ });
157
+ });
158
+
159
+ // ─── generateDtsContent: Vite-provided camelCase variants ─────────────────
160
+
161
+ describe("generateDtsContent with Vite-provided variants", () => {
162
+ it("emits both kebab and camelCase entries from Vite localsConvention", () => {
163
+ // Vite `localsConvention: 'camelCase'` provides both variants
164
+ const entries: ClassEntry[] = [
165
+ { rule: "my-class", value: "my-class_hash", comment: "", cssBlock: "" },
166
+ { rule: "myClass", value: "my-class_hash", comment: "", cssBlock: "" },
167
+ ];
168
+
169
+ const { content: dts } = generateDtsContent(entries, "f.module.css", "/f.module.css");
170
+
171
+ expect(dts).toContain('readonly "my-class": "my-class_hash";');
172
+ expect(dts).toContain('readonly "myClass": "my-class_hash";');
173
+ });
174
+
175
+ it("does not duplicate when entry name has no variants", () => {
176
+ const entries: ClassEntry[] = [
177
+ { rule: "button", value: "button_hash", comment: "", cssBlock: "" },
178
+ ];
179
+
180
+ const { content: dts } = generateDtsContent(entries, "f.module.css", "/f.module.css");
181
+
182
+ const matches = dts.match(/readonly "button"/g);
183
+ expect(matches).toHaveLength(1);
184
+ });
185
+
186
+ it("emits entries exactly as provided — no extra aliases generated", () => {
187
+ // Only kebab-case entry, no camelCase — plugin should NOT generate one
188
+ const entries: ClassEntry[] = [
189
+ { rule: "my-cool-class", value: "hash", comment: "", cssBlock: "" },
190
+ ];
191
+
192
+ const { content: dts } = generateDtsContent(entries, "f.module.css", "/f.module.css");
193
+
194
+ expect(dts).toContain('readonly "my-cool-class": "hash";');
195
+ // No camelCase alias generated by the plugin
196
+ expect(dts).not.toContain("myCoolClass");
197
+ });
198
+ });
199
+
200
+ // ─── generateDtsContent: namedExports option ───────────────────────────────
201
+
202
+ describe("generateDtsContent with namedExports", () => {
203
+ it("emits named exports only for valid JS identifiers", () => {
204
+ // Vite provides both kebab and camelCase entries
205
+ const entries: ClassEntry[] = [
206
+ { rule: "my-class", value: "my-class_hash", comment: "", cssBlock: "" },
207
+ { rule: "myClass", value: "my-class_hash", comment: "", cssBlock: "" },
208
+ ];
209
+
210
+ const { content: dts } = generateDtsContent(entries, "f.module.css", "/f.module.css", {
211
+ namedExports: true,
212
+ });
213
+
214
+ // Named export only for valid JS identifier
215
+ expect(dts).toContain("export declare const myClass: string;");
216
+ // Kebab-case does NOT get a named export
217
+ expect(dts).not.toMatch(/export declare const my-class/);
218
+ // Default export block includes both variants
219
+ expect(dts).toContain("declare const styles: {");
220
+ expect(dts).toContain("export default styles;");
221
+ expect(dts).not.toContain("export = styles");
222
+ expect(dts).toContain('readonly "my-class": "my-class_hash";');
223
+ expect(dts).toContain('readonly "myClass": "my-class_hash";');
224
+ });
225
+
226
+ it("skips named export for kebab-only entries without Vite camelCase", () => {
227
+ const entries: ClassEntry[] = [
228
+ { rule: "primary-button", value: "hash", comment: "", cssBlock: "" },
229
+ ];
230
+
231
+ const { content: dts } = generateDtsContent(entries, "f.module.css", "/f.module.css", {
232
+ namedExports: true,
233
+ });
234
+
235
+ // No named export — kebab-case is not a valid JS identifier
236
+ expect(dts).not.toContain("export declare const");
237
+ // Default export still has the entry
238
+ expect(dts).toContain('readonly "primary-button": "hash";');
239
+ });
240
+
241
+ it("handles multiple named exports with Vite-provided variants", () => {
242
+ const entries: ClassEntry[] = [
243
+ { rule: "btn", value: "btn_h", comment: "", cssBlock: "" },
244
+ { rule: "card-wrap", value: "card_h", comment: "", cssBlock: "" },
245
+ { rule: "cardWrap", value: "card_h", comment: "", cssBlock: "" },
246
+ ];
247
+
248
+ const { content: dts } = generateDtsContent(entries, "f.module.css", "/f.module.css", {
249
+ namedExports: true,
250
+ });
251
+
252
+ expect(dts).toContain("export declare const btn: string;");
253
+ expect(dts).toContain("export declare const cardWrap: string;");
254
+ // Default export includes all entries
255
+ expect(dts).toContain('readonly "btn": "btn_h";');
256
+ expect(dts).toContain('readonly "card-wrap": "card_h";');
257
+ expect(dts).toContain('readonly "cardWrap": "card_h";');
258
+ expect(dts).toContain("export default styles;");
259
+ });
260
+
261
+ it("includes JSDoc comments when line info available", () => {
262
+ const entries: ClassEntry[] = [
263
+ {
264
+ rule: "btn",
265
+ value: "hash",
266
+ line: 5,
267
+ comment: "Primary button",
268
+ cssBlock: "",
269
+ },
270
+ ];
271
+
272
+ const { content: dts } = generateDtsContent(entries, "f.module.css", "/abs/f.module.css", {
273
+ namedExports: true,
274
+ });
275
+
276
+ expect(dts).toContain("```txt");
277
+ expect(dts).toContain("Primary button");
278
+ expect(dts).toContain("#L5");
279
+ expect(dts).toContain("export declare const btn: string;");
280
+ });
281
+
282
+ it('emits "export {};" for empty entries', () => {
283
+ const { content: dts, lineMappings } = generateDtsContent([], "f.module.css", "/f.module.css", {
284
+ namedExports: true,
285
+ });
286
+
287
+ expect(dts).toBe("export {};\n");
288
+ expect(lineMappings).toEqual([]);
289
+ });
290
+
291
+ it("does not duplicate default export property when name has no dashes", () => {
292
+ const entries: ClassEntry[] = [
293
+ { rule: "button", value: "button_h", comment: "", cssBlock: "" },
294
+ ];
295
+
296
+ const { content: dts } = generateDtsContent(entries, "f.module.css", "/f.module.css", {
297
+ namedExports: true,
298
+ });
299
+
300
+ // Only one readonly "button" in the default export (no camelCase alias needed)
301
+ const matches = dts.match(/readonly "button"/g);
302
+ expect(matches).toHaveLength(1);
303
+ expect(dts).toContain("export default styles;");
304
+ });
305
+ });
306
+
307
+ // ─── generateDtsContent: lineMappings ──────────────────────────────────────
308
+
309
+ describe("generateDtsContent lineMappings", () => {
310
+ it("returns empty lineMappings when entries have no line info", () => {
311
+ const entries: ClassEntry[] = [{ rule: "btn", value: "btn_h", comment: "", cssBlock: "" }];
312
+
313
+ const { lineMappings } = generateDtsContent(entries, "f.module.css", "/f.module.css");
314
+
315
+ expect(lineMappings).toEqual([]);
316
+ });
317
+
318
+ it("returns lineMappings for default mode entries with line info", () => {
319
+ const entries: ClassEntry[] = [
320
+ { rule: "card", value: "card_h", line: 5, comment: "", cssBlock: "" },
321
+ ];
322
+
323
+ const { content, lineMappings } = generateDtsContent(entries, "f.module.css", "/f.module.css");
324
+
325
+ expect(lineMappings.length).toBeGreaterThan(0);
326
+ // All mappings should reference CSS line 5
327
+ for (const m of lineMappings) {
328
+ expect(m.cssLine).toBe(5);
329
+ expect(m.cssColumn).toBe(0);
330
+ }
331
+ // At least one mapped .d.ts line should be the declaration line
332
+ const dtsLines = content.split("\n");
333
+ const hasDeclarationMapping = lineMappings.some((m) =>
334
+ dtsLines[m.dtsLine - 1]?.includes("card"),
335
+ );
336
+ expect(hasDeclarationMapping).toBe(true);
337
+ });
338
+
339
+ it("returns lineMappings for Vite-provided variants", () => {
340
+ // Vite provides both kebab and camelCase
341
+ const entries: ClassEntry[] = [
342
+ {
343
+ rule: "my-class",
344
+ value: "my-class_h",
345
+ line: 3,
346
+ comment: "",
347
+ cssBlock: "",
348
+ },
349
+ {
350
+ rule: "myClass",
351
+ value: "my-class_h",
352
+ line: 3,
353
+ comment: "",
354
+ cssBlock: "",
355
+ },
356
+ ];
357
+
358
+ const { lineMappings } = generateDtsContent(entries, "f.module.css", "/f.module.css");
359
+
360
+ // 6 mappings: JSDoc + declaration + guard for each of the 2 entries
361
+ expect(lineMappings).toHaveLength(6);
362
+ for (const m of lineMappings) {
363
+ expect(m.cssLine).toBe(3);
364
+ }
365
+ });
366
+
367
+ it("returns lineMappings for namedExports mode with Vite variants", () => {
368
+ // Vite provides kebab + camelCase
369
+ const entries: ClassEntry[] = [
370
+ {
371
+ rule: "my-class",
372
+ value: "my-class_h",
373
+ line: 1,
374
+ comment: "",
375
+ cssBlock: "",
376
+ },
377
+ {
378
+ rule: "myClass",
379
+ value: "my-class_h",
380
+ line: 1,
381
+ comment: "",
382
+ cssBlock: "",
383
+ },
384
+ ];
385
+
386
+ const { content, lineMappings } = generateDtsContent(entries, "f.module.css", "/f.module.css", {
387
+ namedExports: true,
388
+ });
389
+
390
+ // 9 mappings: JSDoc + declaration + guard for:
391
+ // 1 named export (myClass) + 2 default block props (my-class, myClass)
392
+ expect(lineMappings.length).toBe(9);
393
+ for (const m of lineMappings) {
394
+ expect(m.cssLine).toBe(1);
395
+ }
396
+
397
+ // Second mapping (declaration after single JSDoc line) should be the named export
398
+ const dtsLines = content.split("\n");
399
+ expect(dtsLines[lineMappings[1]!.dtsLine - 1]).toContain("export declare const");
400
+ });
401
+
402
+ it("maps multiple entries to their respective CSS lines", () => {
403
+ const entries: ClassEntry[] = [
404
+ { rule: "a", value: "a_h", line: 1, comment: "", cssBlock: "" },
405
+ { rule: "b", value: "b_h", line: 10, comment: "", cssBlock: "" },
406
+ ];
407
+
408
+ const { lineMappings } = generateDtsContent(entries, "f.module.css", "/f.module.css");
409
+
410
+ const cssLines = lineMappings.map((m) => m.cssLine);
411
+ expect(cssLines).toContain(1);
412
+ expect(cssLines).toContain(10);
413
+ });
414
+ });
415
+
416
+ // ─── deduplication with Vite localsConvention ───────────────────────────────
417
+
418
+ describe("generateDtsContent deduplication", () => {
419
+ it("deduplicates when classMapping has both kebab and camelCase keys (default mode)", () => {
420
+ // Vite `localsConvention: 'camelCase'` provides both variants
421
+ const entries: ClassEntry[] = [
422
+ {
423
+ rule: "my-class",
424
+ value: "my-class_h",
425
+ line: 5,
426
+ comment: "",
427
+ cssBlock: "",
428
+ },
429
+ {
430
+ rule: "myClass",
431
+ value: "my-class_h",
432
+ line: 5,
433
+ comment: "",
434
+ cssBlock: "",
435
+ },
436
+ ];
437
+
438
+ const { content: dts } = generateDtsContent(entries, "f.module.css", "/f.module.css");
439
+
440
+ // Each variant appears exactly once
441
+ const kebabMatches = dts.match(/readonly "my-class"/g);
442
+ const camelMatches = dts.match(/readonly "myClass"/g);
443
+ expect(kebabMatches).toHaveLength(1);
444
+ expect(camelMatches).toHaveLength(1);
445
+ });
446
+
447
+ it("deduplicates named exports when classMapping has both kebab and camelCase keys", () => {
448
+ const entries: ClassEntry[] = [
449
+ {
450
+ rule: "my-class",
451
+ value: "my-class_h",
452
+ line: 3,
453
+ comment: "",
454
+ cssBlock: "",
455
+ },
456
+ {
457
+ rule: "myClass",
458
+ value: "my-class_h",
459
+ line: 3,
460
+ comment: "",
461
+ cssBlock: "",
462
+ },
463
+ ];
464
+
465
+ const { content: dts, lineMappings } = generateDtsContent(
466
+ entries,
467
+ "f.module.css",
468
+ "/f.module.css",
469
+ { namedExports: true },
470
+ );
471
+
472
+ // Named export: `export declare const myClass` should appear once
473
+ const namedExportMatches = dts.match(/export declare const myClass/g);
474
+ expect(namedExportMatches).toHaveLength(1);
475
+
476
+ // Default export properties: kebab + camel, each exactly once
477
+ const kebabMatches = dts.match(/readonly "my-class"/g);
478
+ const camelMatches = dts.match(/readonly "myClass"/g);
479
+ expect(kebabMatches).toHaveLength(1);
480
+ expect(camelMatches).toHaveLength(1);
481
+
482
+ // All lineMappings should point to CSS line 3
483
+ for (const m of lineMappings) {
484
+ expect(m.cssLine).toBe(3);
485
+ }
486
+ });
487
+
488
+ it("keeps distinct properties when names are genuinely different", () => {
489
+ const entries: ClassEntry[] = [
490
+ { rule: "alpha", value: "alpha_h", line: 1, comment: "", cssBlock: "" },
491
+ { rule: "beta", value: "beta_h", line: 5, comment: "", cssBlock: "" },
492
+ ];
493
+
494
+ const { content: dts } = generateDtsContent(entries, "f.module.css", "/f.module.css");
495
+
496
+ expect(dts).toContain('"alpha"');
497
+ expect(dts).toContain('"beta"');
498
+ });
499
+ });
@@ -0,0 +1,175 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import type { LineMapping } from "../lib/sourcemap.ts";
3
+ import { patchViteModuleCode, evaluateModuleExports } from "../lib/transform.ts";
4
+ import { extractCssComments } from "../lib/css-comments.ts";
5
+ import { resolveClassEntries, buildOriginalClassLineMap } from "../lib/class-index.ts";
6
+
7
+ // ─── patchViteModuleCode + evaluateModuleExports ────────────────────────────
8
+
9
+ describe("patchViteModuleCode + evaluateModuleExports integration", () => {
10
+ it("processes a realistic Vite CSS module transform output", async () => {
11
+ const viteOutput = [
12
+ 'import { updateStyle as __vite__updateStyle, removeStyle as __vite__removeStyle } from "/@vite/client"',
13
+ 'const __vite__id = "src/button.module.css"',
14
+ 'const __vite__css = ".btn_x1y2z { color: red; }\\n.icon_a3b4c { width: 24px; }"',
15
+ "__vite__updateStyle(__vite__id, __vite__css)",
16
+ 'export default { "btn": "btn_x1y2z", "icon": "icon_a3b4c" }',
17
+ "if (import.meta.hot) {",
18
+ " import.meta.hot.accept()",
19
+ "}",
20
+ ].join("\n");
21
+
22
+ const patched = patchViteModuleCode(viteOutput);
23
+ const { classMapping, cssText } = await evaluateModuleExports(patched);
24
+
25
+ expect(classMapping).toEqual({
26
+ btn: "btn_x1y2z",
27
+ icon: "icon_a3b4c",
28
+ });
29
+ expect(cssText).toContain(".btn_x1y2z");
30
+ expect(cssText).toContain(".icon_a3b4c");
31
+ });
32
+ });
33
+
34
+ // ─── extractCssComments + resolveClassEntries ───────────────────────────────
35
+
36
+ describe("extractCssComments + resolveClassEntries integration", () => {
37
+ it("links comments to classes via line numbers", () => {
38
+ const css = [
39
+ "/** Primary action button */",
40
+ ".button { color: blue; }",
41
+ "",
42
+ "/** Card wrapper */",
43
+ ".card { padding: 16px; }",
44
+ ].join("\n");
45
+
46
+ const comments = extractCssComments(css);
47
+
48
+ const buttonMapping: LineMapping = {
49
+ generatedLine: 1,
50
+ generatedContent: ".button_hash { color: blue; }",
51
+ originalLine: 2,
52
+ };
53
+ const cardMapping: LineMapping = {
54
+ generatedLine: 2,
55
+ generatedContent: ".card_hash { padding: 16px; }",
56
+ originalLine: 5,
57
+ };
58
+
59
+ const classIndex = new Map<string, LineMapping>([
60
+ ["button_hash", buttonMapping],
61
+ ["card_hash", cardMapping],
62
+ ]);
63
+
64
+ const entries = resolveClassEntries(
65
+ { button: "button_hash", card: "card_hash" },
66
+ classIndex,
67
+ comments,
68
+ );
69
+
70
+ expect(entries[0]?.comment).toBe("Primary action button");
71
+ expect(entries[1]?.comment).toBe("Card wrapper");
72
+ });
73
+ });
74
+
75
+ // ─── buildOriginalClassLineMap + resolveClassEntries ────────────────────────
76
+
77
+ describe("buildOriginalClassLineMap + resolveClassEntries integration", () => {
78
+ it("resolves composes classes that fail sourcemap lookup via original CSS scan", () => {
79
+ const originalCss = [
80
+ ".hrbp-filter {",
81
+ " position: relative;",
82
+ "}",
83
+ "",
84
+ "/** Controlled counter badge */",
85
+ ".hrbp-controlled-counter {",
86
+ ' composes: text-s_bold from "@app/styles/typography.module.css";',
87
+ " margin-right: 0.25rem;",
88
+ "}",
89
+ "",
90
+ ".hrbp-state-filter {",
91
+ " display: flex;",
92
+ "}",
93
+ ].join("\n");
94
+
95
+ const comments = extractCssComments(originalCss);
96
+ const originalClassLines = buildOriginalClassLineMap(originalCss);
97
+
98
+ // Simulate what Vite produces: composes creates multi-class hash values
99
+ const classMapping = {
100
+ "hrbp-filter": "_hrbp-filter_abc12_1",
101
+ "hrbp-controlled-counter": "_hrbp-controlled-counter_abc12_6 _text-s_bold_xyz_22",
102
+ "hrbp-state-filter": "_hrbp-state-filter_abc12_11",
103
+ };
104
+
105
+ // classIndex only has single-class hashes — the composed value won't match
106
+ const filterMapping: LineMapping = {
107
+ generatedLine: 1,
108
+ generatedContent: "._hrbp-filter_abc12_1 { }",
109
+ originalLine: 1,
110
+ };
111
+ const stateMapping: LineMapping = {
112
+ generatedLine: 3,
113
+ generatedContent: "._hrbp-state-filter_abc12_11 { }",
114
+ originalLine: 11,
115
+ };
116
+ const classIndex = new Map<string, LineMapping>([
117
+ ["_hrbp-filter_abc12_1", filterMapping],
118
+ ["_hrbp-state-filter_abc12_11", stateMapping],
119
+ ]);
120
+
121
+ const entries = resolveClassEntries(classMapping, classIndex, comments, originalClassLines);
122
+
123
+ const filterEntry = entries.find((e) => e.rule === "hrbp-filter");
124
+ const counterEntry = entries.find((e) => e.rule === "hrbp-controlled-counter");
125
+ const stateEntry = entries.find((e) => e.rule === "hrbp-state-filter");
126
+
127
+ // All classes resolve to correct lines
128
+ expect(filterEntry?.line).toBe(1);
129
+ expect(counterEntry?.line).toBe(6); // was broken before — would have been undefined
130
+ expect(stateEntry?.line).toBe(11);
131
+
132
+ // Comment from the line above .hrbp-controlled-counter
133
+ expect(counterEntry?.comment).toBe("Controlled counter badge");
134
+ });
135
+ });
136
+
137
+ // ─── Vite localsConvention camelCase mismatch ───────────────────────────────
138
+
139
+ describe("Vite localsConvention camelCase + plugin camelCase integration", () => {
140
+ it("resolves all camelCase variants including lodash-style deep camelCase", () => {
141
+ // CSS has `.text_c-disabled` at line 169.
142
+ // Vite localsConvention: 'camelCase' (lodash) produces:
143
+ // 'text_c-disabled' → hash, 'textCDisabled' → hash
144
+ // Our toCamelCase only converts hyphens → 'text_cDisabled'.
145
+ // So 'textCDisabled' (Vite's variant) won't be in originalClassLines.
146
+ const originalCss = [
147
+ "/** Disabled text color */",
148
+ ".text_c-disabled {",
149
+ " color: var(--c-text-disabled);",
150
+ "}",
151
+ ].join("\n");
152
+
153
+ const comments = extractCssComments(originalCss);
154
+ const originalClassLines = buildOriginalClassLineMap(originalCss);
155
+
156
+ const hash = "_text_c-disabled_wt63m_169";
157
+ const classMapping = {
158
+ "text_c-disabled": hash,
159
+ textCDisabled: hash, // Vite's lodash-style camelCase
160
+ };
161
+
162
+ const entries = resolveClassEntries(classMapping, null, comments, originalClassLines);
163
+
164
+ const original = entries.find((e) => e.rule === "text_c-disabled");
165
+ const lodashCamel = entries.find((e) => e.rule === "textCDisabled");
166
+
167
+ // Both resolve to line 2 (the CSS selector line)
168
+ expect(original?.line).toBe(2);
169
+ expect(lodashCamel?.line).toBe(2);
170
+
171
+ // Both get the comment from the preceding line
172
+ expect(original?.comment).toBe("Disabled text color");
173
+ expect(lodashCamel?.comment).toBe("Disabled text color");
174
+ });
175
+ });