@projectwallace/css-code-coverage 0.6.0 → 0.8.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/dist/index.js CHANGED
@@ -25,19 +25,20 @@ function parse_coverage(input) {
25
25
 
26
26
  //#endregion
27
27
  //#region src/lib/chunkify.ts
28
+ const WHITESPACE_ONLY_REGEX = /^\s+$/;
28
29
  function merge(stylesheet) {
29
30
  let new_chunks = [];
30
31
  let previous_chunk;
31
32
  for (let i = 0; i < stylesheet.chunks.length; i++) {
32
33
  let chunk = stylesheet.chunks.at(i);
33
- if (/^\s+$/.test(stylesheet.text.slice(chunk.start_offset, chunk.end_offset))) continue;
34
+ if (WHITESPACE_ONLY_REGEX.test(stylesheet.text.slice(chunk.start_offset, chunk.end_offset))) continue;
34
35
  let latest_chunk = new_chunks.at(-1);
35
36
  if (i > 0 && previous_chunk && latest_chunk) {
36
37
  if (previous_chunk.is_covered === chunk.is_covered) {
37
38
  latest_chunk.end_offset = chunk.end_offset;
38
39
  previous_chunk = chunk;
39
40
  continue;
40
- } else if (/^\s+$/.test(stylesheet.text.slice(chunk.start_offset, chunk.end_offset)) || chunk.end_offset === chunk.start_offset) {
41
+ } else if (WHITESPACE_ONLY_REGEX.test(stylesheet.text.slice(chunk.start_offset, chunk.end_offset)) || chunk.end_offset === chunk.start_offset) {
41
42
  latest_chunk.end_offset = chunk.end_offset;
42
43
  continue;
43
44
  }
@@ -87,10 +88,10 @@ function prettify(stylesheet) {
87
88
  let line = 1;
88
89
  let offset = 0;
89
90
  let pretty_chunks = stylesheet.chunks.map((chunk, index) => {
90
- let css = format(stylesheet.text.slice(chunk.start_offset, chunk.end_offset - 1)).trim();
91
+ let css = format(stylesheet.text.substring(chunk.start_offset, chunk.end_offset - 1)).trim();
91
92
  if (chunk.is_covered) {
92
- let is_last = index === stylesheet.chunks.length - 1;
93
- if (index === 0) css = css + (is_last ? "" : "\n");
93
+ let is_last_chunk = index === stylesheet.chunks.length - 1;
94
+ if (index === 0) css = css + (is_last_chunk ? "" : "\n");
94
95
  else if (index === stylesheet.chunks.length - 1) css = "\n" + css;
95
96
  else css = "\n" + css + "\n";
96
97
  }
@@ -120,65 +121,46 @@ function prettify(stylesheet) {
120
121
 
121
122
  //#endregion
122
123
  //#region src/lib/decuplicate.ts
123
- function concatenate(ranges) {
124
- let result = [];
125
- for (let range of ranges) if (result.length > 0 && (result.at(-1).end === range.start - 1 || result.at(-1).end === range.start)) result.at(-1).end = range.end;
126
- else result.push(range);
127
- return result;
124
+ function merge_ranges(ranges) {
125
+ if (ranges.length === 0) return [];
126
+ ranges.sort((a, b) => a.start - b.start);
127
+ let merged = [ranges[0]];
128
+ for (let r of ranges.slice(1)) {
129
+ let last = merged.at(-1);
130
+ if (last && r.start <= last.end + 1) {
131
+ if (r.end > last.end) last.end = r.end;
132
+ } else merged.push({
133
+ start: r.start,
134
+ end: r.end
135
+ });
136
+ }
137
+ return merged;
128
138
  }
129
- function dedupe_list(ranges) {
130
- let new_ranges = /* @__PURE__ */ new Set();
131
- outer: for (let range of ranges) {
132
- for (let processed_range of new_ranges) {
133
- if (range.start <= processed_range.start && range.end >= processed_range.end) {
134
- new_ranges.delete(processed_range);
135
- new_ranges.add(range);
136
- continue outer;
137
- }
138
- if (range.start >= processed_range.start && range.end <= processed_range.end) continue outer;
139
- if (range.start < processed_range.end && range.start > processed_range.start && range.end > processed_range.end) {
140
- new_ranges.delete(processed_range);
141
- new_ranges.add({
142
- start: processed_range.start,
143
- end: range.end
144
- });
145
- continue outer;
146
- }
139
+ function merge_entry_ranges(sheet, entry) {
140
+ if (!sheet) return {
141
+ url: entry.url,
142
+ ranges: [...entry.ranges]
143
+ };
144
+ let seen = new Set(sheet.ranges.map((r) => `${r.start}:${r.end}`));
145
+ for (let range of entry.ranges) {
146
+ let id = `${range.start}:${range.end}`;
147
+ if (!seen.has(id)) {
148
+ seen.add(id);
149
+ sheet.ranges.push({ ...range });
147
150
  }
148
- new_ranges.add(range);
149
151
  }
150
- return new_ranges;
152
+ return sheet;
151
153
  }
152
- /**
153
- * @description
154
- * prerequisites
155
- * - we check each stylesheet content only once (to avoid counting the same content multiple times)
156
- * - if a duplicate stylesheet enters the room, we add it's ranges to the existing stylesheet's ranges
157
- * - only bytes of deduplicated stylesheets are counted
158
- */
159
154
  function deduplicate_entries(entries) {
160
- let checked_stylesheets = /* @__PURE__ */ new Map();
161
- for (let entry of entries) {
162
- let text = entry.text;
163
- if (checked_stylesheets.has(text)) {
164
- let ranges = checked_stylesheets.get(text).ranges;
165
- for (let range of entry.ranges) {
166
- let found = false;
167
- for (let checked_range of ranges) if (checked_range.start === range.start && checked_range.end === range.end) {
168
- found = true;
169
- break;
170
- }
171
- if (!found) ranges.push(range);
172
- }
173
- } else checked_stylesheets.set(text, {
174
- url: entry.url,
175
- ranges: entry.ranges
176
- });
177
- }
178
- return Array.from(checked_stylesheets, ([text, { url, ranges }]) => ({
155
+ let grouped = entries.reduce((acc, entry) => {
156
+ let key = entry.text;
157
+ acc[key] = merge_entry_ranges(acc[key], entry);
158
+ return acc;
159
+ }, Object.create(null));
160
+ return Object.entries(grouped).map(([text, { url, ranges }]) => ({
179
161
  text,
180
162
  url,
181
- ranges: concatenate(dedupe_list(ranges.sort((a, b) => a.start - b.start))).sort((a, b) => a.start - b.start)
163
+ ranges: merge_ranges(ranges)
182
164
  }));
183
165
  }
184
166
 
@@ -194,15 +176,43 @@ function ext(url) {
194
176
  }
195
177
  }
196
178
 
179
+ //#endregion
180
+ //#region src/lib/html-parser.ts
181
+ /**
182
+ * @description
183
+ * Very, very naive but effective DOMParser.
184
+ * It can only find <style> elements and their .textContent
185
+ */
186
+ var DOMParser = class {
187
+ parseFromString(html, _type) {
188
+ let styles = [];
189
+ let lower = html.toLowerCase();
190
+ let pos = 0;
191
+ while (true) {
192
+ let open = lower.indexOf("<style", pos);
193
+ if (open === -1) break;
194
+ let start = lower.indexOf(">", open);
195
+ if (start === -1) break;
196
+ let close = lower.indexOf("</style>", start);
197
+ if (close === -1) break;
198
+ let text = html.slice(start + 1, close);
199
+ styles.push({ textContent: text });
200
+ pos = close + 8;
201
+ }
202
+ return { querySelectorAll(selector) {
203
+ return styles;
204
+ } };
205
+ }
206
+ };
207
+
197
208
  //#endregion
198
209
  //#region src/lib/remap-html.ts
199
- async function get_dom_parser() {
210
+ function get_dom_parser() {
200
211
  if (typeof window !== "undefined" && "DOMParser" in window) return new window.DOMParser();
201
- let { DOMParser } = await import("./esm-CWr4VY0v.js");
202
212
  return new DOMParser();
203
213
  }
204
- async function remap_html(html, old_ranges) {
205
- let doc = (await get_dom_parser()).parseFromString(html, "text/html");
214
+ function remap_html(html, old_ranges) {
215
+ let doc = get_dom_parser().parseFromString(html, "text/html");
206
216
  let combined_css = "";
207
217
  let new_ranges = [];
208
218
  let current_offset = 0;
@@ -230,31 +240,44 @@ async function remap_html(html, old_ranges) {
230
240
  function is_html(text) {
231
241
  return /<\/?(html|body|head|div|span|script|style)/i.test(text);
232
242
  }
233
- async function filter_coverage(coverage) {
234
- let result = [];
235
- for (let entry of coverage) {
236
- let extension = ext(entry.url).toLowerCase();
237
- if (extension === "js") continue;
238
- if (extension === "css") {
239
- result.push(entry);
240
- continue;
241
- }
242
- if (is_html(entry.text)) {
243
- let { css, ranges } = await remap_html(entry.text, entry.ranges);
244
- result.push({
245
- url: entry.url,
246
- text: css,
247
- ranges
248
- });
249
- continue;
250
- }
251
- result.push({
243
+ const SELECTOR_REGEX = /(@[a-z-]+|\[[^\]]+\]|[a-zA-Z_#.-][a-zA-Z0-9_-]*)\s*\{/;
244
+ const DECLARATION_REGEX = /^\s*[a-zA-Z-]+\s*:\s*.+;?\s*$/m;
245
+ function is_css_like(text) {
246
+ return SELECTOR_REGEX.test(text) || DECLARATION_REGEX.test(text);
247
+ }
248
+ function is_js_like(text) {
249
+ try {
250
+ new Function(text);
251
+ return true;
252
+ } catch {
253
+ return false;
254
+ }
255
+ }
256
+ function filter_coverage(acc, entry) {
257
+ let extension = ext(entry.url).toLowerCase();
258
+ if (extension === "js") return acc;
259
+ if (extension === "css") {
260
+ acc.push(entry);
261
+ return acc;
262
+ }
263
+ if (is_html(entry.text)) {
264
+ let { css, ranges } = remap_html(entry.text, entry.ranges);
265
+ acc.push({
266
+ url: entry.url,
267
+ text: css,
268
+ ranges
269
+ });
270
+ return acc;
271
+ }
272
+ if (is_css_like(entry.text) && !is_js_like(entry.text)) {
273
+ acc.push({
252
274
  url: entry.url,
253
275
  text: entry.text,
254
276
  ranges: entry.ranges
255
277
  });
278
+ return acc;
256
279
  }
257
- return result;
280
+ return acc;
258
281
  }
259
282
 
260
283
  //#endregion
@@ -262,38 +285,37 @@ async function filter_coverage(coverage) {
262
285
  const AT_SIGN = 64;
263
286
  const LONGEST_ATRULE_NAME = 28;
264
287
  function extend_ranges(coverage) {
265
- return coverage.map(({ text, ranges, url }) => {
266
- return {
267
- text,
268
- ranges: ranges.map((range, index) => {
269
- let prev_range = ranges[index - 1];
270
- for (let i = range.start; i >= range.start - LONGEST_ATRULE_NAME; i--) {
271
- if (prev_range && prev_range.end > i) break;
272
- let char_position = i;
273
- if (text.charCodeAt(char_position) === AT_SIGN) {
274
- range.start = char_position;
275
- let next_offset = range.end;
276
- let next_char$1 = text.charAt(next_offset);
277
- while (/\s/.test(next_char$1)) {
278
- next_offset++;
279
- next_char$1 = text.charAt(next_offset);
280
- }
281
- if (next_char$1 === "{") range.end = range.end + 1;
282
- break;
288
+ let { ranges, url, text } = coverage;
289
+ return {
290
+ text,
291
+ ranges: ranges.map((range, index) => {
292
+ let prev_range = ranges[index - 1];
293
+ for (let i = range.start; i >= range.start - LONGEST_ATRULE_NAME; i--) {
294
+ if (prev_range && prev_range.end > i) break;
295
+ let char_position = i;
296
+ if (text.charCodeAt(char_position) === AT_SIGN) {
297
+ range.start = char_position;
298
+ let next_offset = range.end;
299
+ let next_char$1 = text.charAt(next_offset);
300
+ while (/\s/.test(next_char$1)) {
301
+ next_offset++;
302
+ next_char$1 = text.charAt(next_offset);
283
303
  }
304
+ if (next_char$1 === "{") range.end = range.end + 1;
305
+ break;
284
306
  }
285
- let offset = range.end;
286
- let next_char = text.charAt(offset);
287
- while (/\s/.test(next_char)) {
288
- offset++;
289
- next_char = text.charAt(offset);
290
- }
291
- if (next_char === "}") range.end = offset + 1;
292
- return range;
293
- }),
294
- url
295
- };
296
- });
307
+ }
308
+ let offset = range.end;
309
+ let next_char = text.charAt(offset);
310
+ while (/\s/.test(next_char)) {
311
+ offset++;
312
+ next_char = text.charAt(offset);
313
+ }
314
+ if (next_char === "}") range.end = offset + 1;
315
+ return range;
316
+ }),
317
+ url
318
+ };
297
319
  }
298
320
 
299
321
  //#endregion
@@ -336,20 +358,9 @@ function calculate_stylesheet_coverage({ text, url, chunks }) {
336
358
  chunks
337
359
  };
338
360
  }
339
- /**
340
- * @description
341
- * CSS Code Coverage calculation
342
- *
343
- * These are the steps performed to calculate coverage:
344
- * 1. Filter eligible files / validate input
345
- * 2. Prettify the CSS dicovered in each Coverage and update their ranges
346
- * 3. De-duplicate Coverages: merge all ranges for CSS sources occurring multiple times
347
- * 4. Calculate used/unused CSS bytes (fastest path, no inspection of the actual CSS needed)
348
- * 5. Calculate line-coverage, byte-coverage per stylesheet
349
- */
350
- async function calculate_coverage(coverage) {
361
+ function calculate_coverage(coverage) {
351
362
  let total_files_found = coverage.length;
352
- let coverage_per_stylesheet = extend_ranges(deduplicate_entries(await filter_coverage(coverage))).map((sheet) => chunkify(sheet)).map((sheet) => prettify(sheet)).map((stylesheet) => calculate_stylesheet_coverage(stylesheet));
363
+ let coverage_per_stylesheet = coverage.reduce((acc, entry) => filter_coverage(acc, entry), []).reduce((entries, entry) => deduplicate_entries(entries.concat(entry)), []).map((coverage$1) => extend_ranges(coverage$1)).map((sheet) => chunkify(sheet)).map((sheet) => prettify(sheet)).map((stylesheet) => calculate_stylesheet_coverage(stylesheet));
353
364
  let { total_lines, total_covered_lines, total_uncovered_lines, total_bytes, total_used_bytes, total_unused_bytes } = coverage_per_stylesheet.reduce((totals, sheet) => {
354
365
  totals.total_lines += sheet.total_lines;
355
366
  totals.total_covered_lines += sheet.covered_lines;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@projectwallace/css-code-coverage",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Generate useful CSS Code Coverage report from browser-reported coverage",
5
5
  "author": "Bart Veneman <bart@projectwallace.com>",
6
6
  "repository": {
@@ -37,17 +37,19 @@
37
37
  },
38
38
  "types": "dist/index.d.ts",
39
39
  "scripts": {
40
- "test": "c8 --reporter=text playwright test",
40
+ "test": "c8 --reporter=text --reporter=lcov playwright test",
41
41
  "build": "tsdown",
42
42
  "check": "tsc --noEmit",
43
43
  "lint": "oxlint --config .oxlintrc.json",
44
- "lint-package": "publint"
44
+ "lint-package": "publint",
45
+ "knip": "knip"
45
46
  },
46
47
  "devDependencies": {
48
+ "@codecov/vite-plugin": "^1.9.1",
47
49
  "@playwright/test": "^1.56.0",
48
- "@types/node": "^24.8.1",
50
+ "@types/node": "^24.9.2",
49
51
  "c8": "^10.1.3",
50
- "linkedom": "^0.18.12",
52
+ "knip": "^5.66.4",
51
53
  "oxlint": "^1.22.0",
52
54
  "publint": "^0.3.14",
53
55
  "tsdown": "^0.15.8",