@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.
- package/README.md +10 -0
- package/dist/cli.js +26 -395
- 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 {
|
|
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
|
|
516
|
-
return `${(ratio
|
|
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