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.
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/dist/index.js +583 -0
- package/index.ts +3 -0
- package/package.json +55 -0
- package/src/lib/camel-case.ts +4 -0
- package/src/lib/class-index.ts +169 -0
- package/src/lib/css-comments.ts +55 -0
- package/src/lib/declaration-map.ts +40 -0
- package/src/lib/generate-dts.ts +157 -0
- package/src/lib/index.ts +16 -0
- package/src/lib/path-filter.ts +37 -0
- package/src/lib/sourcemap.ts +73 -0
- package/src/lib/transform.ts +30 -0
- package/src/lib/types.ts +65 -0
- package/src/plugin.ts +287 -0
- package/src/tests/camel-case.spec.ts +36 -0
- package/src/tests/class-index.spec.ts +439 -0
- package/src/tests/css-comments.spec.ts +116 -0
- package/src/tests/declaration-map.spec.ts +102 -0
- package/src/tests/generate-dts.spec.ts +499 -0
- package/src/tests/integration.spec.ts +175 -0
- package/src/tests/path-filter.spec.ts +130 -0
- package/src/tests/transform.spec.ts +101 -0
|
@@ -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
|
+
});
|