@projectwallace/css-code-coverage 0.8.0 → 0.8.2

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.
Files changed (3) hide show
  1. package/README.md +10 -0
  2. package/dist/cli.js +26 -395
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -87,3 +87,13 @@ for (let file of files) {
87
87
  coverage_data.push(...parse_coverage(json_content))
88
88
  }
89
89
  ```
90
+
91
+ ## CLI
92
+
93
+ Use the CLI tool (`css-coverage`) to check if coverage meets minimum requirements, globally and/or per file.
94
+
95
+ ```sh
96
+ css-coverage --coverage-dir=<dir> --min-coverage=<number> [options]
97
+ ```
98
+
99
+ [CLI docs](src/cli/README.md)
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs, styleText } from "node:util";
3
3
  import * as v from "valibot";
4
- import { format } from "@projectwallace/format-css";
4
+ import { calculate_coverage } from "@projectwallace/css-code-coverage";
5
5
  import { readFile, readdir, stat } from "node:fs/promises";
6
6
  import { join } from "node:path";
7
7
 
@@ -65,398 +65,6 @@ function parse_arguments(args) {
65
65
  return values;
66
66
  }
67
67
 
68
- //#endregion
69
- //#region src/lib/parse-coverage.ts
70
- let RangeSchema = v.object({
71
- start: v.number(),
72
- end: v.number()
73
- });
74
- let CoverageSchema = v.object({
75
- text: v.string(),
76
- url: v.string(),
77
- ranges: v.array(RangeSchema)
78
- });
79
- function is_valid_coverage(input) {
80
- return v.safeParse(v.array(CoverageSchema), input).success;
81
- }
82
- function parse_coverage(input) {
83
- try {
84
- let parse_result = JSON.parse(input);
85
- return is_valid_coverage(parse_result) ? parse_result : [];
86
- } catch {
87
- return [];
88
- }
89
- }
90
-
91
- //#endregion
92
- //#region src/lib/chunkify.ts
93
- const WHITESPACE_ONLY_REGEX = /^\s+$/;
94
- function merge(stylesheet) {
95
- let new_chunks = [];
96
- let previous_chunk;
97
- for (let i = 0; i < stylesheet.chunks.length; i++) {
98
- let chunk = stylesheet.chunks.at(i);
99
- if (WHITESPACE_ONLY_REGEX.test(stylesheet.text.slice(chunk.start_offset, chunk.end_offset))) continue;
100
- let latest_chunk = new_chunks.at(-1);
101
- if (i > 0 && previous_chunk && latest_chunk) {
102
- if (previous_chunk.is_covered === chunk.is_covered) {
103
- latest_chunk.end_offset = chunk.end_offset;
104
- previous_chunk = chunk;
105
- continue;
106
- } else if (WHITESPACE_ONLY_REGEX.test(stylesheet.text.slice(chunk.start_offset, chunk.end_offset)) || chunk.end_offset === chunk.start_offset) {
107
- latest_chunk.end_offset = chunk.end_offset;
108
- continue;
109
- }
110
- }
111
- previous_chunk = chunk;
112
- new_chunks.push(chunk);
113
- }
114
- return {
115
- ...stylesheet,
116
- chunks: new_chunks
117
- };
118
- }
119
- function chunkify(stylesheet) {
120
- let chunks = [];
121
- let offset = 0;
122
- for (let range of stylesheet.ranges) {
123
- if (offset !== range.start) {
124
- chunks.push({
125
- start_offset: offset,
126
- end_offset: range.start,
127
- is_covered: false
128
- });
129
- offset = range.start;
130
- }
131
- chunks.push({
132
- start_offset: range.start,
133
- end_offset: range.end,
134
- is_covered: true
135
- });
136
- offset = range.end;
137
- }
138
- if (offset !== stylesheet.text.length - 1) chunks.push({
139
- start_offset: offset,
140
- end_offset: stylesheet.text.length,
141
- is_covered: false
142
- });
143
- return merge({
144
- url: stylesheet.url,
145
- text: stylesheet.text,
146
- chunks
147
- });
148
- }
149
-
150
- //#endregion
151
- //#region src/lib/prettify.ts
152
- function prettify(stylesheet) {
153
- let line = 1;
154
- let offset = 0;
155
- let pretty_chunks = stylesheet.chunks.map((chunk, index) => {
156
- let css = format(stylesheet.text.substring(chunk.start_offset, chunk.end_offset - 1)).trim();
157
- if (chunk.is_covered) {
158
- let is_last_chunk = index === stylesheet.chunks.length - 1;
159
- if (index === 0) css = css + (is_last_chunk ? "" : "\n");
160
- else if (index === stylesheet.chunks.length - 1) css = "\n" + css;
161
- else css = "\n" + css + "\n";
162
- }
163
- let line_count = css.split("\n").length;
164
- let start_offset = offset;
165
- let end_offset = offset + css.length - 1;
166
- let start_line = line;
167
- let end_line = line + line_count;
168
- line = end_line;
169
- offset = end_offset;
170
- return {
171
- ...chunk,
172
- start_offset,
173
- start_line,
174
- end_line: end_line - 1,
175
- end_offset,
176
- css,
177
- total_lines: end_line - start_line
178
- };
179
- });
180
- return {
181
- ...stylesheet,
182
- chunks: pretty_chunks,
183
- text: pretty_chunks.map(({ css }) => css).join("\n")
184
- };
185
- }
186
-
187
- //#endregion
188
- //#region src/lib/decuplicate.ts
189
- function merge_ranges(ranges) {
190
- if (ranges.length === 0) return [];
191
- ranges.sort((a, b) => a.start - b.start);
192
- let merged = [ranges[0]];
193
- for (let r of ranges.slice(1)) {
194
- let last = merged.at(-1);
195
- if (last && r.start <= last.end + 1) {
196
- if (r.end > last.end) last.end = r.end;
197
- } else merged.push({
198
- start: r.start,
199
- end: r.end
200
- });
201
- }
202
- return merged;
203
- }
204
- function merge_entry_ranges(sheet, entry) {
205
- if (!sheet) return {
206
- url: entry.url,
207
- ranges: [...entry.ranges]
208
- };
209
- let seen = new Set(sheet.ranges.map((r) => `${r.start}:${r.end}`));
210
- for (let range of entry.ranges) {
211
- let id = `${range.start}:${range.end}`;
212
- if (!seen.has(id)) {
213
- seen.add(id);
214
- sheet.ranges.push({ ...range });
215
- }
216
- }
217
- return sheet;
218
- }
219
- function deduplicate_entries(entries) {
220
- let grouped = entries.reduce((acc, entry) => {
221
- let key = entry.text;
222
- acc[key] = merge_entry_ranges(acc[key], entry);
223
- return acc;
224
- }, Object.create(null));
225
- return Object.entries(grouped).map(([text, { url, ranges }]) => ({
226
- text,
227
- url,
228
- ranges: merge_ranges(ranges)
229
- }));
230
- }
231
-
232
- //#endregion
233
- //#region src/lib/ext.ts
234
- function ext(url) {
235
- try {
236
- let parsed_url = new URL(url);
237
- return parsed_url.pathname.slice(parsed_url.pathname.lastIndexOf(".") + 1);
238
- } catch {
239
- let ext_index = url.lastIndexOf(".");
240
- return url.slice(ext_index, url.indexOf("/", ext_index) + 1);
241
- }
242
- }
243
-
244
- //#endregion
245
- //#region src/lib/html-parser.ts
246
- /**
247
- * @description
248
- * Very, very naive but effective DOMParser.
249
- * It can only find <style> elements and their .textContent
250
- */
251
- var DOMParser = class {
252
- parseFromString(html, _type) {
253
- let styles = [];
254
- let lower = html.toLowerCase();
255
- let pos = 0;
256
- while (true) {
257
- let open = lower.indexOf("<style", pos);
258
- if (open === -1) break;
259
- let start = lower.indexOf(">", open);
260
- if (start === -1) break;
261
- let close = lower.indexOf("</style>", start);
262
- if (close === -1) break;
263
- let text = html.slice(start + 1, close);
264
- styles.push({ textContent: text });
265
- pos = close + 8;
266
- }
267
- return { querySelectorAll(selector) {
268
- return styles;
269
- } };
270
- }
271
- };
272
-
273
- //#endregion
274
- //#region src/lib/remap-html.ts
275
- function get_dom_parser() {
276
- if (typeof window !== "undefined" && "DOMParser" in window) return new window.DOMParser();
277
- return new DOMParser();
278
- }
279
- function remap_html(html, old_ranges) {
280
- let doc = get_dom_parser().parseFromString(html, "text/html");
281
- let combined_css = "";
282
- let new_ranges = [];
283
- let current_offset = 0;
284
- let style_elements = doc.querySelectorAll("style");
285
- for (let style_element of Array.from(style_elements)) {
286
- let style_content = style_element.textContent;
287
- if (!style_content.trim()) continue;
288
- combined_css += style_content;
289
- let start_index = html.indexOf(style_content);
290
- let end_index = start_index + style_content.length;
291
- for (let range of old_ranges) if (range.start >= start_index && range.end <= end_index) new_ranges.push({
292
- start: current_offset + (range.start - start_index),
293
- end: current_offset + (range.end - start_index)
294
- });
295
- current_offset += style_content.length;
296
- }
297
- return {
298
- css: combined_css,
299
- ranges: new_ranges
300
- };
301
- }
302
-
303
- //#endregion
304
- //#region src/lib/filter-entries.ts
305
- function is_html(text) {
306
- return /<\/?(html|body|head|div|span|script|style)/i.test(text);
307
- }
308
- const SELECTOR_REGEX = /(@[a-z-]+|\[[^\]]+\]|[a-zA-Z_#.-][a-zA-Z0-9_-]*)\s*\{/;
309
- const DECLARATION_REGEX = /^\s*[a-zA-Z-]+\s*:\s*.+;?\s*$/m;
310
- function is_css_like(text) {
311
- return SELECTOR_REGEX.test(text) || DECLARATION_REGEX.test(text);
312
- }
313
- function is_js_like(text) {
314
- try {
315
- new Function(text);
316
- return true;
317
- } catch {
318
- return false;
319
- }
320
- }
321
- function filter_coverage(acc, entry) {
322
- let extension = ext(entry.url).toLowerCase();
323
- if (extension === "js") return acc;
324
- if (extension === "css") {
325
- acc.push(entry);
326
- return acc;
327
- }
328
- if (is_html(entry.text)) {
329
- let { css, ranges } = remap_html(entry.text, entry.ranges);
330
- acc.push({
331
- url: entry.url,
332
- text: css,
333
- ranges
334
- });
335
- return acc;
336
- }
337
- if (is_css_like(entry.text) && !is_js_like(entry.text)) {
338
- acc.push({
339
- url: entry.url,
340
- text: entry.text,
341
- ranges: entry.ranges
342
- });
343
- return acc;
344
- }
345
- return acc;
346
- }
347
-
348
- //#endregion
349
- //#region src/lib/extend-ranges.ts
350
- const AT_SIGN = 64;
351
- const LONGEST_ATRULE_NAME = 28;
352
- function extend_ranges(coverage) {
353
- let { ranges, url, text } = coverage;
354
- return {
355
- text,
356
- ranges: ranges.map((range, index) => {
357
- let prev_range = ranges[index - 1];
358
- for (let i = range.start; i >= range.start - LONGEST_ATRULE_NAME; i--) {
359
- if (prev_range && prev_range.end > i) break;
360
- let char_position = i;
361
- if (text.charCodeAt(char_position) === AT_SIGN) {
362
- range.start = char_position;
363
- let next_offset = range.end;
364
- let next_char$1 = text.charAt(next_offset);
365
- while (/\s/.test(next_char$1)) {
366
- next_offset++;
367
- next_char$1 = text.charAt(next_offset);
368
- }
369
- if (next_char$1 === "{") range.end = range.end + 1;
370
- break;
371
- }
372
- }
373
- let offset = range.end;
374
- let next_char = text.charAt(offset);
375
- while (/\s/.test(next_char)) {
376
- offset++;
377
- next_char = text.charAt(offset);
378
- }
379
- if (next_char === "}") range.end = offset + 1;
380
- return range;
381
- }),
382
- url
383
- };
384
- }
385
-
386
- //#endregion
387
- //#region src/lib/index.ts
388
- function ratio(fraction, total) {
389
- if (total === 0) return 0;
390
- return fraction / total;
391
- }
392
- function calculate_stylesheet_coverage({ text, url, chunks }) {
393
- let uncovered_bytes = 0;
394
- let covered_bytes = 0;
395
- let total_bytes = 0;
396
- let total_lines = 0;
397
- let covered_lines = 0;
398
- let uncovered_lines = 0;
399
- for (let chunk of chunks) {
400
- let lines = chunk.total_lines;
401
- let bytes = chunk.end_offset - chunk.start_offset;
402
- total_lines += lines;
403
- total_bytes += bytes;
404
- if (chunk.is_covered) {
405
- covered_lines += lines;
406
- covered_bytes += bytes;
407
- } else {
408
- uncovered_lines += lines;
409
- uncovered_bytes += bytes;
410
- }
411
- }
412
- return {
413
- url,
414
- text,
415
- uncovered_bytes,
416
- covered_bytes,
417
- total_bytes,
418
- line_coverage_ratio: ratio(covered_lines, total_lines),
419
- byte_coverage_ratio: ratio(covered_bytes, total_bytes),
420
- total_lines,
421
- covered_lines,
422
- uncovered_lines,
423
- chunks
424
- };
425
- }
426
- function calculate_coverage(coverage) {
427
- let total_files_found = coverage.length;
428
- 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));
429
- let { total_lines, total_covered_lines, total_uncovered_lines, total_bytes, total_used_bytes, total_unused_bytes } = coverage_per_stylesheet.reduce((totals, sheet) => {
430
- totals.total_lines += sheet.total_lines;
431
- totals.total_covered_lines += sheet.covered_lines;
432
- totals.total_uncovered_lines += sheet.uncovered_lines;
433
- totals.total_bytes += sheet.total_bytes;
434
- totals.total_used_bytes += sheet.covered_bytes;
435
- totals.total_unused_bytes += sheet.uncovered_bytes;
436
- return totals;
437
- }, {
438
- total_lines: 0,
439
- total_covered_lines: 0,
440
- total_uncovered_lines: 0,
441
- total_bytes: 0,
442
- total_used_bytes: 0,
443
- total_unused_bytes: 0
444
- });
445
- return {
446
- total_files_found,
447
- total_bytes,
448
- total_lines,
449
- covered_bytes: total_used_bytes,
450
- covered_lines: total_covered_lines,
451
- uncovered_bytes: total_unused_bytes,
452
- uncovered_lines: total_uncovered_lines,
453
- byte_coverage_ratio: ratio(total_used_bytes, total_bytes),
454
- line_coverage_ratio: ratio(total_covered_lines, total_lines),
455
- coverage_per_stylesheet,
456
- total_stylesheets: coverage_per_stylesheet.length
457
- };
458
- }
459
-
460
68
  //#endregion
461
69
  //#region src/cli/program.ts
462
70
  function validate_min_line_coverage(actual, expected) {
@@ -492,6 +100,29 @@ function program({ min_coverage, min_file_coverage }, coverage_data) {
492
100
  };
493
101
  }
494
102
 
103
+ //#endregion
104
+ //#region src/lib/parse-coverage.ts
105
+ let RangeSchema = v.object({
106
+ start: v.number(),
107
+ end: v.number()
108
+ });
109
+ let CoverageSchema = v.object({
110
+ text: v.string(),
111
+ url: v.string(),
112
+ ranges: v.array(RangeSchema)
113
+ });
114
+ function is_valid_coverage(input) {
115
+ return v.safeParse(v.array(CoverageSchema), input).success;
116
+ }
117
+ function parse_coverage(input) {
118
+ try {
119
+ let parse_result = JSON.parse(input);
120
+ return is_valid_coverage(parse_result) ? parse_result : [];
121
+ } catch {
122
+ return [];
123
+ }
124
+ }
125
+
495
126
  //#endregion
496
127
  //#region src/cli/file-reader.ts
497
128
  async function read(coverage_dir) {
@@ -512,8 +143,8 @@ function indent(line) {
512
143
  return (line || "").replace(/^\t+/, (tabs) => " ".repeat(tabs.length * 4));
513
144
  }
514
145
  let line_number = (num, covered = true) => `${num.toString().padStart(5, " ")} ${covered ? "│" : "━"} `;
515
- function percentage(ratio$1, decimals = 2) {
516
- return `${(ratio$1 * 100).toFixed(ratio$1 === 1 ? 0 : decimals)}%`;
146
+ function percentage(ratio, decimals = 2) {
147
+ return `${(ratio * 100).toFixed(ratio === 1 ? 0 : decimals)}%`;
517
148
  }
518
149
  function print_lines({ report, context }, params, { styleText: styleText$1, print_width }) {
519
150
  let output = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@projectwallace/css-code-coverage",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Generate useful CSS Code Coverage report from browser-reported coverage",
5
5
  "author": "Bart Veneman <bart@projectwallace.com>",
6
6
  "repository": {