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,439 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import type { LineMapping } from "../lib/sourcemap.ts";
3
+ import type { CssCommentInfo } from "../lib/types.ts";
4
+ import {
5
+ buildClassNameIndex,
6
+ buildOriginalClassLineMap,
7
+ extractCssRuleBodies,
8
+ resolveClassEntries,
9
+ } from "../lib/class-index.ts";
10
+
11
+ // ─── buildClassNameIndex ────────────────────────────────────────────────────
12
+
13
+ describe("buildClassNameIndex", () => {
14
+ it("builds index from generated CSS lines with class selectors", () => {
15
+ const mappings: LineMapping[] = [
16
+ {
17
+ generatedLine: 1,
18
+ generatedContent: ".btn_hash { color: red; }",
19
+ originalLine: 5,
20
+ },
21
+ {
22
+ generatedLine: 2,
23
+ generatedContent: ".card_hash { padding: 10px; }",
24
+ originalLine: 10,
25
+ },
26
+ ];
27
+
28
+ const index = buildClassNameIndex(mappings);
29
+ expect(index.get("btn_hash")).toBe(mappings[0]);
30
+ expect(index.get("card_hash")).toBe(mappings[1]);
31
+ });
32
+
33
+ it("keeps the first occurrence when a class appears multiple times", () => {
34
+ const mappings: LineMapping[] = [
35
+ {
36
+ generatedLine: 1,
37
+ generatedContent: ".btn_hash { color: red; }",
38
+ originalLine: 5,
39
+ },
40
+ {
41
+ generatedLine: 3,
42
+ generatedContent: ".btn_hash:hover { color: blue; }",
43
+ originalLine: 8,
44
+ },
45
+ ];
46
+
47
+ const index = buildClassNameIndex(mappings);
48
+ expect(index.get("btn_hash")?.originalLine).toBe(5);
49
+ });
50
+
51
+ it("skips lines without originalLine", () => {
52
+ const mappings: LineMapping[] = [
53
+ {
54
+ generatedLine: 1,
55
+ generatedContent: ".btn_hash { color: red; }",
56
+ },
57
+ ];
58
+
59
+ const index = buildClassNameIndex(mappings);
60
+ expect(index.size).toBe(0);
61
+ });
62
+
63
+ it("extracts multiple class names from a single line", () => {
64
+ const mappings: LineMapping[] = [
65
+ {
66
+ generatedLine: 1,
67
+ generatedContent: ".container .btn_hash, .card_hash { }",
68
+ originalLine: 3,
69
+ },
70
+ ];
71
+
72
+ const index = buildClassNameIndex(mappings);
73
+ expect(index.has("container")).toBe(true);
74
+ expect(index.has("btn_hash")).toBe(true);
75
+ expect(index.has("card_hash")).toBe(true);
76
+ });
77
+
78
+ it('ignores numeric-starting tokens like ".5em"', () => {
79
+ const mappings: LineMapping[] = [
80
+ {
81
+ generatedLine: 1,
82
+ generatedContent: "margin: .5em; border-radius: .25rem;",
83
+ originalLine: 2,
84
+ },
85
+ ];
86
+
87
+ const index = buildClassNameIndex(mappings);
88
+ expect(index.size).toBe(0);
89
+ });
90
+
91
+ it("returns empty map for empty input", () => {
92
+ const index = buildClassNameIndex([]);
93
+ expect(index.size).toBe(0);
94
+ });
95
+ });
96
+
97
+ // ─── resolveClassEntries ────────────────────────────────────────────────────
98
+
99
+ describe("resolveClassEntries", () => {
100
+ it("resolves classes with line numbers and comments", () => {
101
+ const classMapping = { button: "btn_hash" };
102
+ const mapping: LineMapping = {
103
+ generatedLine: 1,
104
+ generatedContent: ".btn_hash { }",
105
+ originalLine: 10,
106
+ };
107
+ const classIndex = new Map([["btn_hash", mapping]]);
108
+
109
+ const comments: CssCommentInfo[] = [];
110
+ comments[9] = { content: "Primary button", endLine: 9 };
111
+
112
+ const entries = resolveClassEntries(classMapping, classIndex, comments);
113
+ expect(entries).toEqual([
114
+ {
115
+ rule: "button",
116
+ value: "btn_hash",
117
+ line: 10,
118
+ comment: "Primary button",
119
+ cssBlock: "",
120
+ },
121
+ ]);
122
+ });
123
+
124
+ it("resolves classes without sourcemap (null index)", () => {
125
+ const classMapping = { button: "btn_hash" };
126
+
127
+ const entries = resolveClassEntries(classMapping, null, null);
128
+ expect(entries).toEqual([
129
+ {
130
+ rule: "button",
131
+ value: "btn_hash",
132
+ line: undefined,
133
+ comment: "",
134
+ cssBlock: "",
135
+ },
136
+ ]);
137
+ });
138
+
139
+ it("resolves classes with no preceding comment", () => {
140
+ const classMapping = { button: "btn_hash" };
141
+ const mapping: LineMapping = {
142
+ generatedLine: 1,
143
+ generatedContent: ".btn_hash { }",
144
+ originalLine: 5,
145
+ };
146
+ const classIndex = new Map([["btn_hash", mapping]]);
147
+
148
+ const entries = resolveClassEntries(classMapping, classIndex, []);
149
+ expect(entries).toEqual([
150
+ {
151
+ rule: "button",
152
+ value: "btn_hash",
153
+ line: 5,
154
+ comment: "",
155
+ cssBlock: "",
156
+ },
157
+ ]);
158
+ });
159
+
160
+ it("handles multiple classes ordered by mapping keys", () => {
161
+ const classMapping = { alpha: "a_hash", beta: "b_hash" };
162
+ const entries = resolveClassEntries(classMapping, null, null);
163
+ expect(entries).toHaveLength(2);
164
+ expect(entries[0]?.rule).toBe("alpha");
165
+ expect(entries[1]?.rule).toBe("beta");
166
+ });
167
+
168
+ it("prefers originalClassLines over classIndex for line resolution", () => {
169
+ const classMapping = { button: "btn_hash" };
170
+ const mapping: LineMapping = {
171
+ generatedLine: 1,
172
+ generatedContent: ".btn_hash { }",
173
+ originalLine: 99, // wrong line from sourcemap
174
+ };
175
+ const classIndex = new Map([["btn_hash", mapping]]);
176
+ const originalClassLines = new Map([["button", 5]]); // correct line
177
+
178
+ const entries = resolveClassEntries(classMapping, classIndex, [], originalClassLines);
179
+ expect(entries[0]?.line).toBe(5);
180
+ });
181
+
182
+ it("falls back to classIndex when originalClassLines has no match", () => {
183
+ const classMapping = { button: "btn_hash" };
184
+ const mapping: LineMapping = {
185
+ generatedLine: 1,
186
+ generatedContent: ".btn_hash { }",
187
+ originalLine: 10,
188
+ };
189
+ const classIndex = new Map([["btn_hash", mapping]]);
190
+ const originalClassLines = new Map<string, number>(); // empty
191
+
192
+ const entries = resolveClassEntries(classMapping, classIndex, [], originalClassLines);
193
+ expect(entries[0]?.line).toBe(10);
194
+ });
195
+
196
+ it("resolves composes classes that fail sourcemap lookup", () => {
197
+ // Simulates the `composes:` case where the hashed value is multi-class
198
+ const classMapping = {
199
+ "zone-controlled-counter": "_zone-controlled-counter_15m5f_8 _text-s_bold_wt63m_93",
200
+ };
201
+ // The multi-class hash won't match any single entry in classIndex
202
+ const classIndex = new Map<string, LineMapping>();
203
+ const originalClassLines = new Map([["zone-controlled-counter", 8]]);
204
+
205
+ const entries = resolveClassEntries(classMapping, classIndex, [], originalClassLines);
206
+ expect(entries[0]?.line).toBe(8);
207
+ });
208
+
209
+ it("propagates line from resolved entry to unresolved entry with same hash", () => {
210
+ // Simulates Vite localsConvention: 'camelCase' with lodash-style camelCase
211
+ // that produces a variant our toCamelCase doesn't match.
212
+ // CSS: .text_c-disabled (line 169)
213
+ // Vite classMapping keys: 'text_c-disabled', 'textCDisabled' (lodash camelCase)
214
+ // Our toCamelCase('text_c-disabled') = 'text_cDisabled' (hyphens only)
215
+ // So 'textCDisabled' is NOT in originalClassLines.
216
+ const hash = "_text_c-disabled_wt63m_169";
217
+ const classMapping = {
218
+ "text_c-disabled": hash,
219
+ textCDisabled: hash,
220
+ };
221
+ const originalClassLines = new Map([
222
+ ["text_c-disabled", 169],
223
+ ["text_cDisabled", 169], // our toCamelCase variant
224
+ ]);
225
+
226
+ const entries = resolveClassEntries(classMapping, null, null, originalClassLines);
227
+
228
+ const original = entries.find((e) => e.rule === "text_c-disabled");
229
+ const viteVariant = entries.find((e) => e.rule === "textCDisabled");
230
+
231
+ expect(original?.line).toBe(169);
232
+ // textCDisabled is not in originalClassLines, but shares the same hash
233
+ // → inherits line from the resolved entry via second-pass propagation.
234
+ expect(viteVariant?.line).toBe(169);
235
+ });
236
+
237
+ it("propagates comment along with line in second pass", () => {
238
+ const hash = "_btn-primary_h";
239
+ const classMapping = {
240
+ "btn-primary": hash,
241
+ btnPrimary: hash, // Vite camelCase variant
242
+ };
243
+ const originalClassLines = new Map([
244
+ ["btn-primary", 5],
245
+ ["btnPrimary", 5],
246
+ ]);
247
+ const comments: CssCommentInfo[] = [];
248
+ comments[4] = { content: "Primary button", endLine: 4 };
249
+
250
+ // Remove btnPrimary from originalClassLines to force second-pass
251
+ originalClassLines.delete("btnPrimary");
252
+
253
+ const entries = resolveClassEntries(classMapping, null, comments, originalClassLines);
254
+
255
+ const camelEntry = entries.find((e) => e.rule === "btnPrimary");
256
+ expect(camelEntry?.line).toBe(5);
257
+ expect(camelEntry?.comment).toBe("Primary button");
258
+ });
259
+ });
260
+
261
+ // ─── buildOriginalClassLineMap ──────────────────────────────────────────────
262
+
263
+ describe("buildOriginalClassLineMap", () => {
264
+ it("maps class selectors to their 1-based line numbers", () => {
265
+ const css = [
266
+ ".zone-filter {",
267
+ " position: relative;",
268
+ "}",
269
+ "",
270
+ ".zone-controlled-counter {",
271
+ " margin-right: 0.25rem;",
272
+ "}",
273
+ "",
274
+ ".zone-state-filter {",
275
+ " display: flex;",
276
+ "}",
277
+ ].join("\n");
278
+
279
+ const map = buildOriginalClassLineMap(css);
280
+
281
+ expect(map.get("zone-filter")).toBe(1);
282
+ expect(map.get("zone-controlled-counter")).toBe(5);
283
+ expect(map.get("zone-state-filter")).toBe(9);
284
+
285
+ // camelCase variants are also registered
286
+ expect(map.get("zoneFilter")).toBe(1);
287
+ expect(map.get("zoneControlledCounter")).toBe(5);
288
+ expect(map.get("zoneStateFilter")).toBe(9);
289
+ });
290
+
291
+ it("keeps the first occurrence when a class appears multiple times", () => {
292
+ const css = [".btn { color: red; }", ".btn:hover { color: blue; }"].join("\n");
293
+
294
+ const map = buildOriginalClassLineMap(css);
295
+ expect(map.get("btn")).toBe(1);
296
+ });
297
+
298
+ it('ignores numeric-starting tokens like ".5em"', () => {
299
+ const css = "margin: .5em; border-radius: .25rem;";
300
+ const map = buildOriginalClassLineMap(css);
301
+ expect(map.size).toBe(0);
302
+ });
303
+
304
+ it("extracts multiple class names from a single line", () => {
305
+ const css = ".container .btn, .card { }";
306
+ const map = buildOriginalClassLineMap(css);
307
+
308
+ expect(map.has("container")).toBe(true);
309
+ expect(map.has("btn")).toBe(true);
310
+ expect(map.has("card")).toBe(true);
311
+ });
312
+
313
+ it("returns empty map for CSS with no class selectors", () => {
314
+ const css = "body { margin: 0; }";
315
+ const map = buildOriginalClassLineMap(css);
316
+ expect(map.size).toBe(0);
317
+ });
318
+
319
+ it("returns empty map for empty input", () => {
320
+ const map = buildOriginalClassLineMap("");
321
+ expect(map.size).toBe(0);
322
+ });
323
+
324
+ it("does not add camelCase variant when name has no dashes", () => {
325
+ const css = ".button { color: red; }";
326
+ const map = buildOriginalClassLineMap(css);
327
+ expect(map.get("button")).toBe(1);
328
+ // "button" is already camelCase, so no extra entry
329
+ expect(map.size).toBe(1);
330
+ });
331
+
332
+ it("registers camelCase variant for kebab-case class", () => {
333
+ const css = ".my-component { color: red; }";
334
+ const map = buildOriginalClassLineMap(css);
335
+ expect(map.get("my-component")).toBe(1);
336
+ expect(map.get("myComponent")).toBe(1);
337
+ expect(map.size).toBe(2);
338
+ });
339
+
340
+ it("handles composes directive without picking up from-path as class", () => {
341
+ const css = [
342
+ ".counter {",
343
+ ' composes: text-bold from "@app/styles/typography.module.css";',
344
+ " margin: 0;",
345
+ "}",
346
+ ].join("\n");
347
+
348
+ const map = buildOriginalClassLineMap(css);
349
+ expect(map.get("counter")).toBe(1);
350
+ // text-bold in composes has no `.` prefix, so the regex won't pick it up
351
+ expect(map.has("text-bold")).toBe(false);
352
+ });
353
+ });
354
+
355
+ // ─── extractCssRuleBodies ───────────────────────────────────────────────────
356
+
357
+ describe("extractCssRuleBodies", () => {
358
+ it("extracts a single-line rule block", () => {
359
+ const css = ".btn { color: red; }";
360
+ const map = extractCssRuleBodies(css);
361
+ expect(map.get("btn")).toBe(".btn { color: red; }");
362
+ });
363
+
364
+ it("extracts a multi-line rule block", () => {
365
+ const css = [
366
+ ".text-s {",
367
+ " font-size: var(--fs-s);",
368
+ " font-weight: var(--fw-regular);",
369
+ " line-height: var(--lh-s);",
370
+ "}",
371
+ ].join("\n");
372
+
373
+ const map = extractCssRuleBodies(css);
374
+ expect(map.get("text-s")).toBe(css);
375
+ });
376
+
377
+ it("extracts multiple rule blocks", () => {
378
+ const css = [
379
+ ".zone-filter {",
380
+ " position: relative;",
381
+ "}",
382
+ "",
383
+ ".zone-controlled-counter {",
384
+ " margin-right: 0.25rem;",
385
+ "}",
386
+ ].join("\n");
387
+
388
+ const map = extractCssRuleBodies(css);
389
+ expect(map.get("zone-filter")).toBe(".zone-filter {\n position: relative;\n}");
390
+ expect(map.get("zone-controlled-counter")).toBe(
391
+ ".zone-controlled-counter {\n margin-right: 0.25rem;\n}",
392
+ );
393
+ });
394
+
395
+ it("registers camelCase variants", () => {
396
+ const css = ".my-component { color: red; }";
397
+ const map = extractCssRuleBodies(css);
398
+ expect(map.get("my-component")).toBe(".my-component { color: red; }");
399
+ expect(map.get("myComponent")).toBe(".my-component { color: red; }");
400
+ });
401
+
402
+ it("keeps only the first occurrence", () => {
403
+ const css = [".btn { color: red; }", ".btn:hover { color: blue; }"].join("\n");
404
+
405
+ const map = extractCssRuleBodies(css);
406
+ expect(map.get("btn")).toBe(".btn { color: red; }");
407
+ });
408
+
409
+ it("ignores numeric-starting tokens", () => {
410
+ const css = "margin: .5em;";
411
+ const map = extractCssRuleBodies(css);
412
+ expect(map.size).toBe(0);
413
+ });
414
+
415
+ it("returns empty map for CSS without class selectors", () => {
416
+ const css = "body { margin: 0; }";
417
+ const map = extractCssRuleBodies(css);
418
+ expect(map.size).toBe(0);
419
+ });
420
+
421
+ it("returns empty map for empty input", () => {
422
+ const map = extractCssRuleBodies("");
423
+ expect(map.size).toBe(0);
424
+ });
425
+
426
+ it("handles nested braces correctly", () => {
427
+ const css = [
428
+ ".container {",
429
+ " display: grid;",
430
+ " @media (max-width: 768px) {",
431
+ " display: block;",
432
+ " }",
433
+ "}",
434
+ ].join("\n");
435
+
436
+ const map = extractCssRuleBodies(css);
437
+ expect(map.get("container")).toBe(css);
438
+ });
439
+ });
@@ -0,0 +1,116 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildNewlineIndex, getLineAtOffset, extractCssComments } from "../lib/css-comments.ts";
3
+
4
+ // ─── buildNewlineIndex ──────────────────────────────────────────────────────
5
+
6
+ describe("buildNewlineIndex", () => {
7
+ it("returns empty array for content without newlines", () => {
8
+ expect(buildNewlineIndex("no newlines here")).toEqual([]);
9
+ });
10
+
11
+ it("returns offsets for each newline character", () => {
12
+ expect(buildNewlineIndex("a\nb\nc")).toEqual([1, 3]);
13
+ });
14
+
15
+ it("handles content starting with a newline", () => {
16
+ expect(buildNewlineIndex("\nfirst")).toEqual([0]);
17
+ });
18
+
19
+ it("handles consecutive newlines", () => {
20
+ expect(buildNewlineIndex("a\n\n\nb")).toEqual([1, 2, 3]);
21
+ });
22
+
23
+ it("returns empty array for empty string", () => {
24
+ expect(buildNewlineIndex("")).toEqual([]);
25
+ });
26
+ });
27
+
28
+ // ─── getLineAtOffset ────────────────────────────────────────────────────────
29
+
30
+ describe("getLineAtOffset", () => {
31
+ // Content: "Hello\nWorld\nFoo"
32
+ // Newlines at: [5, 11]
33
+ const newlineIndex = [5, 11];
34
+
35
+ it("returns line 1 for offsets before the first newline", () => {
36
+ expect(getLineAtOffset(newlineIndex, 0)).toBe(1);
37
+ expect(getLineAtOffset(newlineIndex, 3)).toBe(1);
38
+ expect(getLineAtOffset(newlineIndex, 5)).toBe(1);
39
+ });
40
+
41
+ it("returns line 2 for offsets after the first newline", () => {
42
+ expect(getLineAtOffset(newlineIndex, 6)).toBe(2);
43
+ expect(getLineAtOffset(newlineIndex, 11)).toBe(2);
44
+ });
45
+
46
+ it("returns line 3 for offsets after the second newline", () => {
47
+ expect(getLineAtOffset(newlineIndex, 12)).toBe(3);
48
+ expect(getLineAtOffset(newlineIndex, 14)).toBe(3);
49
+ });
50
+
51
+ it("returns line 1 for empty newline index", () => {
52
+ expect(getLineAtOffset([], 0)).toBe(1);
53
+ expect(getLineAtOffset([], 100)).toBe(1);
54
+ });
55
+ });
56
+
57
+ // ─── extractCssComments ─────────────────────────────────────────────────────
58
+
59
+ describe("extractCssComments", () => {
60
+ it("returns empty array when no docblock comments exist", () => {
61
+ const css = ".button { color: red; }";
62
+ const comments = extractCssComments(css);
63
+ expect(comments.filter(Boolean)).toEqual([]);
64
+ });
65
+
66
+ it("extracts a single-line docblock comment", () => {
67
+ const css = "/** Primary button style */\n.button { color: red; }";
68
+ const comments = extractCssComments(css);
69
+ expect(comments[1]).toEqual({
70
+ content: "Primary button style",
71
+ endLine: 1,
72
+ });
73
+ });
74
+
75
+ it("extracts a multi-line docblock comment", () => {
76
+ const css = [
77
+ "/**",
78
+ " * Multi-line comment",
79
+ " * with details",
80
+ " */",
81
+ ".button { color: red; }",
82
+ ].join("\n");
83
+ const comments = extractCssComments(css);
84
+ expect(comments[4]).toBeDefined();
85
+ expect(comments[4]?.content).toContain("Multi-line comment");
86
+ expect(comments[4]?.content).toContain("with details");
87
+ expect(comments[4]?.endLine).toBe(4);
88
+ });
89
+
90
+ it("extracts multiple docblock comments keyed by end line", () => {
91
+ const css = [
92
+ "/** First comment */",
93
+ ".a { color: red; }",
94
+ "/** Second comment */",
95
+ ".b { color: blue; }",
96
+ ].join("\n");
97
+ const comments = extractCssComments(css);
98
+ expect(comments[1]).toEqual({ content: "First comment", endLine: 1 });
99
+ expect(comments[3]).toEqual({ content: "Second comment", endLine: 3 });
100
+ });
101
+
102
+ it("ignores non-docblock comments (single asterisk)", () => {
103
+ const css = "/* Regular comment */\n.a { color: red; }";
104
+ const comments = extractCssComments(css);
105
+ expect(comments.filter(Boolean)).toEqual([]);
106
+ });
107
+
108
+ it("returns sparse array with correct line numbers", () => {
109
+ const css = "\n\n/** Comment on line 3 */\n.a { }";
110
+ const comments = extractCssComments(css);
111
+ expect(comments[3]).toBeDefined();
112
+ expect(comments[3]?.endLine).toBe(3);
113
+ expect(comments[1]).toBeUndefined();
114
+ expect(comments[2]).toBeUndefined();
115
+ });
116
+ });
@@ -0,0 +1,102 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { decode } from "@jridgewell/sourcemap-codec";
3
+ import { generateDeclarationMap } from "../lib/declaration-map.ts";
4
+ import type { DtsSourceMapping } from "../lib/types.ts";
5
+
6
+ // ─── generateDeclarationMap ────────────────────────────────────────────────
7
+
8
+ describe("generateDeclarationMap", () => {
9
+ it("generates a valid V3 source map JSON", () => {
10
+ const mappings: DtsSourceMapping[] = [{ dtsLine: 2, dtsColumn: 0, cssLine: 1, cssColumn: 0 }];
11
+
12
+ const result = generateDeclarationMap(
13
+ "comp.module.d.css.ts",
14
+ "../comp.module.css",
15
+ mappings,
16
+ 5,
17
+ );
18
+ const map = JSON.parse(result);
19
+
20
+ expect(map.version).toBe(3);
21
+ expect(map.file).toBe("comp.module.d.css.ts");
22
+ expect(map.sources).toEqual(["../comp.module.css"]);
23
+ expect(typeof map.mappings).toBe("string");
24
+ });
25
+
26
+ it("produces correct mapping for a single entry", () => {
27
+ const mappings: DtsSourceMapping[] = [{ dtsLine: 2, dtsColumn: 0, cssLine: 1, cssColumn: 0 }];
28
+
29
+ const result = generateDeclarationMap("f.d.ts", "../f.css", mappings, 3);
30
+ const map = JSON.parse(result);
31
+ const decoded = decode(map.mappings);
32
+
33
+ // Line 1 (idx 0): empty, Line 2 (idx 1): one segment, Line 3 (idx 2): empty
34
+ expect(decoded[0]).toEqual([]);
35
+ expect(decoded[1]).toEqual([[0, 0, 0, 0]]); // genCol=0, src=0, srcLine=0, srcCol=0
36
+ expect(decoded[2]).toEqual([]);
37
+ });
38
+
39
+ it("produces correct mappings for multiple entries", () => {
40
+ const mappings: DtsSourceMapping[] = [
41
+ { dtsLine: 2, dtsColumn: 0, cssLine: 1, cssColumn: 0 },
42
+ { dtsLine: 4, dtsColumn: 0, cssLine: 8, cssColumn: 0 },
43
+ { dtsLine: 6, dtsColumn: 0, cssLine: 13, cssColumn: 0 },
44
+ ];
45
+
46
+ const result = generateDeclarationMap("f.d.ts", "../f.css", mappings, 7);
47
+ const map = JSON.parse(result);
48
+ const decoded = decode(map.mappings);
49
+
50
+ expect(decoded[1]).toEqual([[0, 0, 0, 0]]); // dts line 2 → css line 1 (0-based: 0)
51
+ expect(decoded[3]).toEqual([[0, 0, 7, 0]]); // dts line 4 → css line 8 (0-based: 7)
52
+ expect(decoded[5]).toEqual([[0, 0, 12, 0]]); // dts line 6 → css line 13 (0-based: 12)
53
+ });
54
+
55
+ it("handles entries with non-zero dtsColumn", () => {
56
+ const mappings: DtsSourceMapping[] = [{ dtsLine: 3, dtsColumn: 2, cssLine: 5, cssColumn: 0 }];
57
+
58
+ const result = generateDeclarationMap("f.d.ts", "../f.css", mappings, 4);
59
+ const map = JSON.parse(result);
60
+ const decoded = decode(map.mappings);
61
+
62
+ expect(decoded[2]).toEqual([[2, 0, 4, 0]]); // genCol=2, srcLine=4 (0-based)
63
+ });
64
+
65
+ it("handles multiple mappings on the same line", () => {
66
+ const mappings: DtsSourceMapping[] = [
67
+ { dtsLine: 2, dtsColumn: 0, cssLine: 1, cssColumn: 0 },
68
+ { dtsLine: 2, dtsColumn: 10, cssLine: 3, cssColumn: 5 },
69
+ ];
70
+
71
+ const result = generateDeclarationMap("f.d.ts", "../f.css", mappings, 3);
72
+ const map = JSON.parse(result);
73
+ const decoded = decode(map.mappings);
74
+
75
+ expect(decoded[1]).toEqual([
76
+ [0, 0, 0, 0], // first segment
77
+ [10, 0, 2, 5], // second segment
78
+ ]);
79
+ });
80
+
81
+ it("returns empty lines for no line mappings", () => {
82
+ const result = generateDeclarationMap("f.d.ts", "../f.css", [], 3);
83
+ const map = JSON.parse(result);
84
+ const decoded = decode(map.mappings);
85
+
86
+ expect(decoded).toEqual([[], [], []]);
87
+ });
88
+
89
+ it("sorts unsorted mappings by line and column", () => {
90
+ const mappings: DtsSourceMapping[] = [
91
+ { dtsLine: 4, dtsColumn: 0, cssLine: 8, cssColumn: 0 },
92
+ { dtsLine: 2, dtsColumn: 0, cssLine: 1, cssColumn: 0 },
93
+ ];
94
+
95
+ const result = generateDeclarationMap("f.d.ts", "../f.css", mappings, 5);
96
+ const map = JSON.parse(result);
97
+ const decoded = decode(map.mappings);
98
+
99
+ expect(decoded[1]).toEqual([[0, 0, 0, 0]]); // dts 2 → css 1
100
+ expect(decoded[3]).toEqual([[0, 0, 7, 0]]); // dts 4 → css 8
101
+ });
102
+ });