@emulsify/core 4.2.1 → 4.3.1

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 (84) hide show
  1. package/.storybook/main-vite.js +102 -37
  2. package/.storybook/main.js +20 -0
  3. package/.storybook/ready-reporter.js +230 -0
  4. package/README.md +186 -56
  5. package/config/a11y.config.js +2 -1
  6. package/config/vite/entries.js +4 -4
  7. package/config/vite/plugins/assets/copy-src-assets.js +120 -0
  8. package/config/vite/plugins/assets/copy-twig-files.js +130 -0
  9. package/config/vite/plugins/{css-asset-relativizer.js → assets/css-asset-relativizer.js} +9 -0
  10. package/config/vite/plugins/{mirror-components.js → assets/mirror-components.js} +2 -28
  11. package/config/vite/plugins/{source-file-index.js → assets/source-file-index.js} +1 -1
  12. package/config/vite/plugins/{svg-sprite.js → assets/svg-sprite.js} +2 -2
  13. package/config/vite/plugins/index.js +23 -14
  14. package/config/vite/plugins/reporter/asset-resolver.js +541 -0
  15. package/config/vite/plugins/reporter/build-errors.js +284 -0
  16. package/config/vite/plugins/reporter/diagnostics.js +367 -0
  17. package/config/vite/plugins/reporter/format.js +240 -0
  18. package/config/vite/plugins/reporter/index.js +417 -0
  19. package/config/vite/plugins/reporter/render.js +1332 -0
  20. package/config/vite/plugins/reporter/sass-logger.js +237 -0
  21. package/config/vite/plugins/reporter/source-roots.js +561 -0
  22. package/config/vite/plugins/reporter/verbosity.js +119 -0
  23. package/config/vite/plugins/reporter/vite-logger.js +249 -0
  24. package/config/vite/plugins/reporter/watch-mode.js +40 -0
  25. package/config/vite/plugins/{twig-extension-installers.js → twig/extension-installers.js} +1 -1
  26. package/config/vite/{twig-extensions.js → plugins/twig/extensions.js} +2 -2
  27. package/config/vite/plugins/{twig-module.js → twig/twig-module.js} +197 -85
  28. package/config/vite/plugins/{virtual-twig-asset-sources.js → twig/virtual-twig-asset-sources.js} +37 -136
  29. package/config/vite/plugins/{virtual-twig-globs.js → twig/virtual-twig-globs.js} +3 -32
  30. package/config/vite/plugins/{vituum-patch.js → twig/vituum-patch.js} +3 -3
  31. package/config/vite/plugins.js +1 -1
  32. package/config/vite/project-config.js +1 -1
  33. package/config/vite/project-structure.js +1 -1
  34. package/config/vite/utils/lru.js +77 -0
  35. package/config/vite/utils/package-version.js +42 -0
  36. package/config/vite/utils/paths.js +1 -9
  37. package/config/vite/utils/react-singleton.js +1 -1
  38. package/config/vite/vite.config.js +91 -5
  39. package/package.json +95 -57
  40. package/scripts/a11y.js +115 -23
  41. package/scripts/audit/checks/core-imports.js +78 -0
  42. package/scripts/audit/checks/css-asset-references.js +99 -0
  43. package/scripts/audit/checks/drupal-assumptions.js +48 -0
  44. package/scripts/audit/checks/files-outside-roots.js +53 -0
  45. package/scripts/audit/checks/generated-package-scripts.js +113 -0
  46. package/scripts/audit/checks/legacy-twig-stories.js +33 -0
  47. package/scripts/audit/checks/package-overrides.js +91 -0
  48. package/scripts/audit/checks/project-config.js +71 -0
  49. package/scripts/audit/checks/story-discovery.js +35 -0
  50. package/scripts/audit/checks/twig-references.js +69 -0
  51. package/scripts/audit/checks/twig-volume.js +54 -0
  52. package/scripts/audit/checks/webpack-patterns.js +86 -0
  53. package/scripts/audit/index.js +177 -0
  54. package/scripts/audit/lib/css.js +165 -0
  55. package/scripts/audit/lib/files.js +168 -0
  56. package/scripts/audit/lib/findings.js +31 -0
  57. package/scripts/audit/lib/package-json.js +65 -0
  58. package/scripts/audit/lib/twig.js +227 -0
  59. package/scripts/audit/report.js +273 -0
  60. package/scripts/audit-twig-stories.js +115 -78
  61. package/scripts/audit.js +150 -1632
  62. package/scripts/check-node-version.js +136 -10
  63. package/scripts/inspect-components.js +456 -0
  64. package/scripts/lib/cli.js +179 -0
  65. package/scripts/lib/fs.js +31 -0
  66. package/scripts/lib/proc.js +78 -0
  67. package/scripts/lib/text.js +14 -0
  68. package/scripts/loadYaml.js +2 -2
  69. package/src/extensions/shared/attributes.js +3 -3
  70. package/src/extensions/shared/lists.js +2 -6
  71. package/src/extensions/shared/root-relative.js +38 -0
  72. package/src/storybook/index.js +4 -0
  73. package/src/storybook/render-twig.js +1 -1
  74. package/src/storybook/render-web-component.js +459 -0
  75. package/src/storybook/twig/asset-source-runtime.js +193 -0
  76. package/src/storybook/twig/{source-extensions.js → constants.js} +3 -1
  77. package/src/storybook/twig/reference-paths.js +2 -13
  78. package/src/storybook/twig/setup.js +12 -2
  79. package/src/storybook/twig/source-function.js +5 -2
  80. package/config/vite/plugins/copy-src-assets.js +0 -76
  81. package/config/vite/plugins/copy-twig-files.js +0 -84
  82. package/config/vite/utils/unique.js +0 -36
  83. package/src/storybook/twig/include.js +0 -28
  84. package/src/storybook/twig/source-events.js +0 -5
@@ -0,0 +1,1332 @@
1
+ /**
2
+ * @file Line rendering for the Emulsify develop reporter.
3
+ *
4
+ * Every function here is pure: it takes a diagnostics snapshot and returns an
5
+ * array of finished lines. Keeping rendering separate from the Vite plugin
6
+ * lifecycle means the output can be asserted directly in tests without running
7
+ * a build, and it enforces the append-only constraint by construction — there
8
+ * is no stream to rewrite, only strings to hand back.
9
+ */
10
+
11
+ import {
12
+ SYMBOLS,
13
+ displayLocation,
14
+ displayPath,
15
+ formatBytes,
16
+ formatClockTime,
17
+ formatDuration,
18
+ formatPreciseBytes,
19
+ platformLabel,
20
+ pluralize,
21
+ } from './format.js';
22
+ import { deprecationFix, deprecationMigrator } from './sass-logger.js';
23
+ import { sharedRootPath } from './source-roots.js';
24
+
25
+ const INDENT = ' ';
26
+ const DETAIL_INDENT = ' ';
27
+ const ROW_INDENT = ' ';
28
+ const SEPARATOR = ' · ';
29
+
30
+ /**
31
+ * Maximum number of individual problems listed before collapsing to a count.
32
+ *
33
+ * @type {number}
34
+ */
35
+ const MAX_DETAIL_ROWS = 5;
36
+
37
+ /**
38
+ * Maximum number of unresolved asset URLs listed before collapsing.
39
+ *
40
+ * Higher than the general detail cap because these rows are single short
41
+ * paths, and the same image referenced from two stylesheets with different
42
+ * relative spellings is two separate edits — hiding either is unhelpful.
43
+ *
44
+ * @type {number}
45
+ */
46
+ const MAX_ASSET_ROWS = 8;
47
+
48
+ /**
49
+ * Maximum number of files listed in the deprecation worklist.
50
+ *
51
+ * @type {number}
52
+ */
53
+ const MAX_DEPRECATION_FILES = 4;
54
+
55
+ /**
56
+ * Maximum number of deprecation kinds listed beneath a single file.
57
+ *
58
+ * @type {number}
59
+ */
60
+ const MAX_DEPRECATION_KINDS_PER_FILE = 4;
61
+
62
+ /**
63
+ * Maximum number of line numbers named on one row before collapsing.
64
+ *
65
+ * @type {number}
66
+ */
67
+ const MAX_LINES_PER_ROW = 3;
68
+
69
+ /**
70
+ * Render the affected line numbers for one deprecation within one file.
71
+ *
72
+ * @param {number[]} lineNumbers - Sorted line numbers.
73
+ * @returns {string} Compact line reference, for example `:30,31 +2`.
74
+ */
75
+ export function formatLineList(lineNumbers = []) {
76
+ if (lineNumbers.length === 0) return ':?';
77
+
78
+ const shown = lineNumbers.slice(0, MAX_LINES_PER_ROW).join(',');
79
+ const hidden = lineNumbers.length - MAX_LINES_PER_ROW;
80
+
81
+ return hidden > 0 ? `:${shown} +${hidden}` : `:${shown}`;
82
+ }
83
+
84
+ /**
85
+ * Half-block wordmark drawn when the terminal can render the glyphs.
86
+ *
87
+ * Spells EMULSIFY at 31 columns, which fits comfortably in an 80-column
88
+ * terminal alongside the two-space indent.
89
+ *
90
+ * @type {string[]}
91
+ */
92
+ const WORDMARK = [
93
+ '█▀▀ █▀▄▀█ █ █ █ █▀▀ █ █▀▀ █ █',
94
+ '█▀▀ █ ▀ █ █ █ █ ▀▀█ █ █▀▀ ▀▄▀',
95
+ '▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀ ▀▀▀ ▀ ▀ ▀ ',
96
+ ];
97
+
98
+ /**
99
+ * Render the header shown once when the watcher starts.
100
+ *
101
+ * The wordmark exists to mark where Emulsify's output begins. `npm run develop`
102
+ * interleaves npm's script echo, Vite, and Storybook, so a single dim line is
103
+ * easy to scroll past; a block of art is not. Terminals that cannot render the
104
+ * glyphs get the plain name instead of mojibake.
105
+ *
106
+ * The banner carries only the version. It is emitted from `configResolved`,
107
+ * before the build has run, so it cannot know what was written to `dist/` — and
108
+ * splitting the project facts across two moments would mean reading the input
109
+ * roots in one place and the output tally in another. They belong together, so
110
+ * both live in the facts block that {@link renderFacts} prints with the summary.
111
+ *
112
+ * @param {{
113
+ * version?: string,
114
+ * unicode?: boolean,
115
+ * styler: (format: string|string[], text: string) => string
116
+ * }} options - Banner inputs.
117
+ * @returns {string[]} Banner lines.
118
+ */
119
+ export function renderBanner({ version, unicode = true, styler }) {
120
+ const mark = unicode
121
+ ? WORDMARK.map((row) => `${INDENT}${styler(['bold', 'cyan'], row)}`)
122
+ : [`${INDENT}${styler(['bold', 'cyan'], 'EMULSIFY')}`];
123
+
124
+ return [
125
+ '',
126
+ ...mark,
127
+ `${INDENT}${styler('gray', `core ${version || '0.0.0'}`)}`,
128
+ '',
129
+ ];
130
+ }
131
+
132
+ /**
133
+ * Labels for the rows in the facts block.
134
+ *
135
+ * @type {{platform: string, input: string, output: string}}
136
+ */
137
+ const FACT_LABELS = {
138
+ platform: 'platform',
139
+ input: 'input',
140
+ output: 'output',
141
+ };
142
+
143
+ /**
144
+ * Render the project facts block.
145
+ *
146
+ * The `input` rows are the reason this block exists. A total entry count cannot
147
+ * distinguish a healthy project from one whose second source root was never
148
+ * discovered, so each root is named with what it contributed. A configured root
149
+ * reporting zero is reported rather than hidden — that row is usually the bug.
150
+ *
151
+ * @param {{
152
+ * platform?: string,
153
+ * inputRows?: Array<{name: string, path: string, count: number}>,
154
+ * outDir?: string,
155
+ * write?: {fileCount: number, totalBytes: number, largest?: {fileName: string, bytes: number}},
156
+ * styler: (format: string|string[], text: string) => string
157
+ * }} options - Facts inputs.
158
+ * @returns {string[]} Facts lines.
159
+ */
160
+ export function renderFacts({
161
+ platform,
162
+ inputRows = [],
163
+ outDir = 'dist',
164
+ write,
165
+ styler,
166
+ }) {
167
+ const labelWidth = Math.max(
168
+ ...Object.values(FACT_LABELS).map((label) => label.length),
169
+ );
170
+
171
+ /**
172
+ * Render one labelled row, or a continuation row when the label repeats.
173
+ *
174
+ * @param {string|undefined} label - Row label, omitted for continuations.
175
+ * @param {string} value - Rendered value.
176
+ * @returns {string} Finished line.
177
+ */
178
+ const row = (label, value) =>
179
+ `${DETAIL_INDENT}${styler('gray', (label || '').padEnd(labelWidth))} ${value}`;
180
+
181
+ const lines = [row(FACT_LABELS.platform, platformLabel(platform))];
182
+
183
+ // Paths are padded to a shared width and counts are right-aligned on their
184
+ // digits, so both the paths and the numbers read as columns however many roots
185
+ // a project declares. Padding is applied before styling because ANSI escapes
186
+ // carry no display width and would skew every row by a different amount.
187
+ if (inputRows.length > 0) {
188
+ const pathWidth = Math.max(...inputRows.map((entry) => entry.path.length));
189
+ const countWidth = Math.max(
190
+ ...inputRows.map((entry) => String(entry.count).length),
191
+ );
192
+
193
+ inputRows.forEach((entry, index) => {
194
+ // An overflow row names a count of directories rather than a directory, so
195
+ // it is dimmed to keep it from reading as a path.
196
+ const path = styler(
197
+ entry.overflow ? 'gray' : 'cyan',
198
+ entry.path.padEnd(pathWidth),
199
+ );
200
+ const noun = entry.count === 1 ? 'entry' : 'entries';
201
+ const count = styler(
202
+ // A configured root that matched nothing is the row most likely to be a
203
+ // misconfiguration, so it is the one row here that is not dim.
204
+ entry.count === 0 ? 'yellow' : 'gray',
205
+ `${String(entry.count).padStart(countWidth)} ${noun}`,
206
+ );
207
+
208
+ lines.push(
209
+ row(index === 0 ? FACT_LABELS.input : '', `${path} ${count}`),
210
+ );
211
+ });
212
+ }
213
+
214
+ const outputFacts = [];
215
+ if (write) {
216
+ outputFacts.push(pluralize(write.fileCount, 'file'));
217
+ outputFacts.push(formatBytes(write.totalBytes));
218
+
219
+ if (write.largest) {
220
+ outputFacts.push(
221
+ `largest ${write.largest.fileName} ${formatBytes(write.largest.bytes)}`,
222
+ );
223
+ }
224
+ }
225
+
226
+ const outputSuffix =
227
+ outputFacts.length > 0
228
+ ? styler('gray', ` ${outputFacts.join(SEPARATOR)}`)
229
+ : '';
230
+
231
+ lines.push(row(FACT_LABELS.output, `${outDir}${outputSuffix}`));
232
+
233
+ return lines;
234
+ }
235
+
236
+ /**
237
+ * Labels for the URL rows printed beneath a ready headline.
238
+ *
239
+ * @type {{local: string, network: string}}
240
+ */
241
+ const URL_LABELS = {
242
+ local: 'local',
243
+ network: 'network',
244
+ };
245
+
246
+ /**
247
+ * Render the ready state for a long-running service.
248
+ *
249
+ * Storybook announces itself with a boxed banner drawn in its own visual
250
+ * language, which reads as a second tool's output rather than part of the
251
+ * build. This renders the same facts in the reporter's vocabulary so one
252
+ * `develop` run looks like one tool.
253
+ *
254
+ * Kept pure and service-agnostic so both callers share it: the Storybook
255
+ * preset that runs while `concurrently` owns the terminal, and any launcher
256
+ * that owns both child processes and prints a combined block.
257
+ *
258
+ * A port that does not match the one requested is reported rather than
259
+ * silently accepted. Storybook falls forward to the next free port, so the
260
+ * difference usually means a previous session is still running — and a browser
261
+ * pointed at the requested port would then be showing a stale instance.
262
+ *
263
+ * @param {{
264
+ * service?: string,
265
+ * urls?: {local?: string, network?: string},
266
+ * durationMs?: number,
267
+ * portDrift?: {requested: number|string, actual: number|string},
268
+ * styler: (format: string|string[], text: string) => string
269
+ * }} options - Ready-state inputs.
270
+ * @returns {string[]} Ready lines.
271
+ */
272
+ export function renderReady({
273
+ service = 'storybook',
274
+ urls = {},
275
+ durationMs,
276
+ portDrift,
277
+ unicode = true,
278
+ styler,
279
+ }) {
280
+ const drifted =
281
+ portDrift && String(portDrift.requested) !== String(portDrift.actual);
282
+
283
+ const facts = [];
284
+ if (Number.isFinite(durationMs)) facts.push(formatDuration(durationMs));
285
+ if (drifted) {
286
+ facts.push(`port ${portDrift.requested} in use, using ${portDrift.actual}`);
287
+ }
288
+
289
+ const symbol = drifted
290
+ ? styler('yellow', SYMBOLS.warning)
291
+ : styler('green', SYMBOLS.ok);
292
+ const headline = drifted
293
+ ? styler('yellow', `${service} ready`)
294
+ : `${service} ready`;
295
+ const suffix =
296
+ facts.length > 0 ? styler('gray', SEPARATOR + facts.join(SEPARATOR)) : '';
297
+
298
+ const lines = [`${INDENT}${symbol} ${headline}${suffix}`];
299
+
300
+ const rows = Object.entries(URL_LABELS).filter(([key]) => urls[key]);
301
+ if (rows.length === 0) return lines;
302
+
303
+ // Padding is applied before styling so ANSI escapes never skew the columns.
304
+ const labelWidth = Math.max(...rows.map(([, label]) => label.length));
305
+
306
+ const body = rows.map(
307
+ ([key, label]) =>
308
+ `${INDENT}${INDENT}${styler('gray', label.padEnd(labelWidth))} ${styler(['bold', 'cyan'], urls[key])}`,
309
+ );
310
+
311
+ // The rules are measured from the longest row rather than fixed, so a long
312
+ // network address or an added row cannot punch through the panel. Width is
313
+ // taken from the unstyled text because ANSI escapes carry no display width.
314
+ const width =
315
+ Math.max(
316
+ ...rows.map(
317
+ ([key, label]) =>
318
+ INDENT.length * 2 +
319
+ label.padEnd(labelWidth).length +
320
+ 3 +
321
+ urls[key].length,
322
+ ),
323
+ ) - INDENT.length;
324
+
325
+ lines.push('');
326
+ lines.push(...renderPanel(body, width, drifted, unicode, styler));
327
+ // Storybook keeps logging after it announces itself — timing lines, and under
328
+ // `--ci` a migration notice or two. Closing with a blank line stops those from
329
+ // butting straight up against the panel's lower rule.
330
+ lines.push('');
331
+
332
+ return lines;
333
+ }
334
+
335
+ /**
336
+ * Frame a block of rows between two half-block rules.
337
+ *
338
+ * Storybook draws its ready state in a rounded box, which reads as a second
339
+ * tool's output rather than as part of the build. The rules here are built from
340
+ * the same half-block glyphs as the wordmark, so the panel belongs to Emulsify's
341
+ * visual language instead of importing another tool's.
342
+ *
343
+ * The glyph choice is deliberate: `▄` sits on the baseline and `▀` sits at cap
344
+ * height, so the pair encloses the rows without the corner joins that
345
+ * box-drawing characters need — and without the alignment failures those joins
346
+ * produce when a row contains a character the font renders at a different width.
347
+ *
348
+ * Terminals that cannot render block glyphs get the rows alone. The same
349
+ * `supportsUnicode()` gate gives the wordmark its plain-text fallback, so a
350
+ * terminal always gets both treatments or neither.
351
+ *
352
+ * @param {string[]} body - Rendered rows to enclose.
353
+ * @param {number} width - Rule width in columns.
354
+ * @param {boolean} warned - Whether the panel reports a problem.
355
+ * @param {boolean} unicode - Whether block glyphs are safe to emit.
356
+ * @param {(format: string|string[], text: string) => string} styler - Styling function.
357
+ * @returns {string[]} Panel lines.
358
+ */
359
+ function renderPanel(body, width, warned, unicode, styler) {
360
+ if (!unicode) return body;
361
+
362
+ const color = warned ? 'yellow' : 'cyan';
363
+ const safeWidth = Math.max(1, Math.round(width));
364
+
365
+ return [
366
+ `${INDENT}${styler(color, '▄'.repeat(safeWidth))}`,
367
+ ...body,
368
+ `${INDENT}${styler(color, '▀'.repeat(safeWidth))}`,
369
+ ];
370
+ }
371
+
372
+ /**
373
+ * Render the detail rows for a set of errors or warnings.
374
+ *
375
+ * @param {Array<{message?: string, file?: string, line?: number, count: number}>} entries - Reported entries.
376
+ * @param {string} projectDir - Project root.
377
+ * @param {(format: string|string[], text: string) => string} styler - Styling function.
378
+ * @returns {string[]} Detail lines.
379
+ */
380
+ function renderDetailRows(entries, projectDir, styler) {
381
+ const lines = [];
382
+
383
+ for (const entry of entries.slice(0, MAX_DETAIL_ROWS)) {
384
+ const repeat = entry.count > 1 ? styler('gray', ` (×${entry.count})`) : '';
385
+
386
+ if (entry.file) {
387
+ const location = displayLocation(entry.file, entry.line, projectDir);
388
+ lines.push(`${DETAIL_INDENT}${styler('gray', location)}${repeat}`);
389
+
390
+ if (entry.message) {
391
+ lines.push(`${DETAIL_INDENT}${entry.message}`);
392
+ }
393
+ continue;
394
+ }
395
+
396
+ // Some warnings arrive with no span. Printing a "<unknown>" path row for
397
+ // those wastes a line and reads like a failure to resolve something, so the
398
+ // message carries the repeat count instead. An entry with neither a
399
+ // location nor a message has nothing to act on and is dropped.
400
+ if (entry.message) {
401
+ lines.push(`${DETAIL_INDENT}${entry.message}${repeat}`);
402
+ }
403
+ }
404
+
405
+ const hidden = entries.length - MAX_DETAIL_ROWS;
406
+ if (hidden > 0) {
407
+ lines.push(
408
+ `${DETAIL_INDENT}${styler('gray', `+${pluralize(hidden, 'more')}`)}`,
409
+ );
410
+ }
411
+
412
+ return lines;
413
+ }
414
+
415
+ /**
416
+ * Render the deduplicated Sass deprecation block.
417
+ *
418
+ * This replaces several hundred lines of repeated Dart Sass output with a
419
+ * worklist: total first, then each affected file with the deprecations inside
420
+ * it, then the command that fixes most of them. Each row carries the affected
421
+ * lines, how many occurrences, the Sass deprecation ID, and the substitution to
422
+ * make — the ID alone identifies nothing actionable, and the substitution alone
423
+ * gives no way to look up the details.
424
+ *
425
+ * @param {object} snapshot - Diagnostics snapshot.
426
+ * @param {string} projectDir - Project root.
427
+ * @param {(format: string|string[], text: string) => string} styler - Styling function.
428
+ * @param {string} sourceGlob - Glob matching the project stylesheets.
429
+ * @returns {string[]} Deprecation summary lines.
430
+ */
431
+ function renderDeprecations(snapshot, projectDir, styler, sourceGlob) {
432
+ const { deprecations, deprecationsByFile, deprecationTotal } = snapshot;
433
+ if (deprecations.length === 0) return [];
434
+
435
+ const headline = [
436
+ pluralize(deprecationTotal, 'sass deprecation'),
437
+ pluralize(deprecationsByFile.length || 1, 'file'),
438
+ ].join(SEPARATOR);
439
+
440
+ const lines = [
441
+ `${INDENT}${styler('yellow', SYMBOLS.warning)} ${styler('yellow', headline)}`,
442
+ '',
443
+ ];
444
+
445
+ const shownFiles = deprecationsByFile.slice(0, MAX_DEPRECATION_FILES);
446
+
447
+ // Column widths are measured across every row that will be printed so the
448
+ // line, count, and ID columns align and the block scans as a table. Padding
449
+ // is applied before styling so escape sequences never affect the width.
450
+ const rows = shownFiles.flatMap((group) =>
451
+ group.entries.slice(0, MAX_DEPRECATION_KINDS_PER_FILE),
452
+ );
453
+ const lineWidth = Math.max(
454
+ DEPRECATION_HEADINGS.lines.length,
455
+ ...rows.map((e) => formatLineList(e.lines).length),
456
+ );
457
+ const countWidth = Math.max(
458
+ DEPRECATION_HEADINGS.count.length,
459
+ ...rows.map((e) => `${e.count}×`.length),
460
+ );
461
+ const idWidth = Math.max(
462
+ DEPRECATION_HEADINGS.id.length,
463
+ ...rows.map((e) => e.id.length),
464
+ );
465
+
466
+ lines.push(
467
+ `${ROW_INDENT}${styler(
468
+ 'gray',
469
+ `${DEPRECATION_HEADINGS.lines.padEnd(lineWidth)} ${DEPRECATION_HEADINGS.count.padStart(countWidth)} ${DEPRECATION_HEADINGS.id.padEnd(idWidth)} ${DEPRECATION_HEADINGS.fix}`,
470
+ )}`,
471
+ );
472
+
473
+ for (const group of shownFiles) {
474
+ lines.push(
475
+ `${DETAIL_INDENT}${styler('cyan', displayPath(group.file, projectDir))}`,
476
+ );
477
+
478
+ for (const entry of group.entries.slice(
479
+ 0,
480
+ MAX_DEPRECATION_KINDS_PER_FILE,
481
+ )) {
482
+ const lineRef = formatLineList(entry.lines).padEnd(lineWidth);
483
+ const count = `${entry.count}×`.padStart(countWidth);
484
+ const id = entry.id.padEnd(idWidth);
485
+ const fix = deprecationFix(entry.id) || '';
486
+
487
+ lines.push(
488
+ `${ROW_INDENT}${styler('gray', lineRef)} ${styler('yellow', count)} ${styler('gray', id)} ${fix}`.trimEnd(),
489
+ );
490
+ }
491
+
492
+ const hiddenKinds = group.entries.length - MAX_DEPRECATION_KINDS_PER_FILE;
493
+ if (hiddenKinds > 0) {
494
+ lines.push(
495
+ `${ROW_INDENT}${styler('gray', `+${pluralize(hiddenKinds, 'more kind')}`)}`,
496
+ );
497
+ }
498
+ }
499
+
500
+ const hiddenFiles = deprecationsByFile.length - MAX_DEPRECATION_FILES;
501
+ if (hiddenFiles > 0) {
502
+ lines.push(
503
+ `${DETAIL_INDENT}${styler('gray', `+${pluralize(hiddenFiles, 'more file')}`)}`,
504
+ );
505
+ }
506
+
507
+ const command = renderMigratorCommand(deprecations, sourceGlob, styler);
508
+ if (command) {
509
+ lines.push('', command);
510
+ }
511
+
512
+ return lines;
513
+ }
514
+
515
+ /**
516
+ * Render the `sass-migrator` invocation that resolves most of the debt.
517
+ *
518
+ * The migrator runs exactly one migration per invocation, so a combined command
519
+ * would not work. The dominant migration is shown in full and any others are
520
+ * named after it, which keeps the block to one line while staying accurate
521
+ * about what has to be run.
522
+ *
523
+ * @param {Array<{id: string, occurrences: number}>} deprecations - ID-keyed buckets, most frequent first.
524
+ * @param {string} sourceGlob - Glob matching the project stylesheets.
525
+ * @param {(format: string|string[], text: string) => string} styler - Styling function.
526
+ * @returns {string|undefined} Command line, when a migrator applies.
527
+ * @see https://sass-lang.com/documentation/cli/migrator/
528
+ */
529
+ function renderMigratorCommand(deprecations, sourceGlob, styler) {
530
+ const migrators = [];
531
+ for (const bucket of deprecations) {
532
+ const migrator = deprecationMigrator(bucket.id);
533
+ if (migrator && !migrators.includes(migrator)) migrators.push(migrator);
534
+ }
535
+
536
+ if (migrators.length === 0) return undefined;
537
+
538
+ const [primary, ...rest] = migrators;
539
+ const command = `npx sass-migrator ${primary} '${sourceGlob}'`;
540
+ const others = rest.length > 0 ? ` (then: ${rest.join(', ')})` : '';
541
+
542
+ return `${DETAIL_INDENT}${styler('gray', command + others)}`;
543
+ }
544
+
545
+ /**
546
+ * Column headings for the unresolved asset table.
547
+ *
548
+ * `on disk` has to label both a directory and a not-found state, which is why
549
+ * it is not called something like "found in".
550
+ *
551
+ * @type {{where: string, url: string, disk: string}}
552
+ */
553
+ const ASSET_HEADINGS = {
554
+ where: 'referenced in',
555
+ url: 'url',
556
+ disk: 'on disk',
557
+ };
558
+
559
+ /**
560
+ * Column headings for the deprecation worklist.
561
+ *
562
+ * These sit at the row indent rather than the file indent so each label lands
563
+ * directly above the column it names.
564
+ *
565
+ * @type {{lines: string, count: string, id: string, fix: string}}
566
+ */
567
+ const DEPRECATION_HEADINGS = {
568
+ lines: 'lines',
569
+ count: 'count',
570
+ id: 'deprecation',
571
+ fix: 'fix',
572
+ };
573
+
574
+ /**
575
+ * Colors for each on-disk resolution state.
576
+ *
577
+ * @type {Record<string, string>}
578
+ */
579
+ const ASSET_STATUS_COLORS = {
580
+ found: 'green',
581
+ missing: 'red',
582
+ ambiguous: 'yellow',
583
+ unknown: 'gray',
584
+ };
585
+
586
+ /**
587
+ * Column headings for the missing-import table.
588
+ *
589
+ * @type {{where: string, specifier: string, disk: string}}
590
+ */
591
+ const IMPORT_HEADINGS = {
592
+ where: 'imported by',
593
+ specifier: 'import',
594
+ disk: 'on disk',
595
+ };
596
+
597
+ /**
598
+ * Maximum likely-source leads listed for one syntax error.
599
+ *
600
+ * @type {number}
601
+ */
602
+ const MAX_SOURCE_LEADS = 4;
603
+
604
+ /**
605
+ * Render the CSS syntax error block.
606
+ *
607
+ * The minifier runs on the concatenated bundle, so its line number refers to
608
+ * generated CSS and names no file. The offending declaration is shown verbatim
609
+ * with the minifier's caret, followed by wherever that declaration's literals
610
+ * appear in the project. Those are labelled likely, not definite, because they
611
+ * come from a search rather than a source map.
612
+ *
613
+ * @param {Array<{
614
+ * minifier: string,
615
+ * message: string,
616
+ * bundleLine?: number,
617
+ * declaration?: string,
618
+ * caretColumn?: number,
619
+ * lead?: {token: string, matches: Array<{file: string, line: number}>}
620
+ * }>} errors - Parsed syntax errors.
621
+ * @param {(format: string|string[], text: string) => string} styler - Styling function.
622
+ * @returns {string[]} Syntax error lines.
623
+ */
624
+ function renderSyntaxErrors(errors, styler) {
625
+ if (errors.length === 0) return [];
626
+
627
+ const lines = [
628
+ `${INDENT}${styler('red', SYMBOLS.error)} ${styler('red', pluralize(errors.length, 'css syntax error'))}`,
629
+ '',
630
+ ];
631
+
632
+ for (const error of errors) {
633
+ lines.push(
634
+ `${DETAIL_INDENT}${styler('gray', error.minifier)} ${error.message}`,
635
+ );
636
+
637
+ if (error.declaration) {
638
+ lines.push(`${DETAIL_INDENT}${error.declaration}`);
639
+
640
+ if (error.caretColumn != null) {
641
+ lines.push(
642
+ `${DETAIL_INDENT}${' '.repeat(error.caretColumn)}${styler('red', '^')}`,
643
+ );
644
+ }
645
+ }
646
+
647
+ const matches = error.lead?.matches || [];
648
+ if (matches.length > 0) {
649
+ lines.push('', `${DETAIL_INDENT}${styler('gray', 'likely source')}`);
650
+
651
+ const width = Math.max(
652
+ ...matches
653
+ .slice(0, MAX_SOURCE_LEADS)
654
+ .map((match) => `${match.file}:${match.line}`.length),
655
+ );
656
+
657
+ for (const match of matches.slice(0, MAX_SOURCE_LEADS)) {
658
+ const location = `${match.file}:${match.line}`.padEnd(width);
659
+ lines.push(
660
+ `${DETAIL_INDENT}${styler('cyan', location)} ${styler('gray', error.lead.token)}`,
661
+ );
662
+ }
663
+
664
+ const hidden = matches.length - MAX_SOURCE_LEADS;
665
+ if (hidden > 0) {
666
+ lines.push(
667
+ `${DETAIL_INDENT}${styler('gray', `+${pluralize(hidden, 'more')}`)}`,
668
+ );
669
+ }
670
+ }
671
+
672
+ if (error.bundleLine != null) {
673
+ // Named as generated output so it never reads as a source location.
674
+ lines.push(
675
+ '',
676
+ `${DETAIL_INDENT}${styler('gray', `bundle line ${error.bundleLine} · EMULSIFY_VERBOSE=1 for full output`)}`,
677
+ );
678
+ }
679
+ }
680
+
681
+ return lines;
682
+ }
683
+
684
+ /**
685
+ * Render the missing Sass import block.
686
+ *
687
+ * Replaces Rolldown's aggregate dump, which prints every error three times
688
+ * with a Dart Sass stack trace attached. The table keeps one row per importing
689
+ * site and closes with the directory they all point into, because a deleted
690
+ * partial usually breaks several components at once.
691
+ *
692
+ * @param {Array<{where: string, specifier: string, status: string, label: string}>} rows - Import rows.
693
+ * @param {string|undefined} sharedDirectory - Directory every import resolves under.
694
+ * @param {boolean} directoryExists - Whether that directory is present.
695
+ * @param {(format: string|string[], text: string) => string} styler - Styling function.
696
+ * @returns {string[]} Missing import lines.
697
+ */
698
+ function renderImportErrors(rows, sharedDirectory, directoryExists, styler) {
699
+ if (rows.length === 0) return [];
700
+
701
+ const shown = rows.slice(0, MAX_ASSET_ROWS);
702
+ const distinct = new Set(rows.map((row) => row.specifier)).size;
703
+ const headline = [
704
+ pluralize(distinct, 'missing stylesheet'),
705
+ pluralize(rows.length, 'import error'),
706
+ ].join(SEPARATOR);
707
+
708
+ const whereWidth = Math.max(
709
+ IMPORT_HEADINGS.where.length,
710
+ ...shown.map((row) => row.where.length),
711
+ );
712
+ const specifierWidth = Math.max(
713
+ IMPORT_HEADINGS.specifier.length,
714
+ ...shown.map((row) => row.specifier.length),
715
+ );
716
+
717
+ const lines = [
718
+ `${INDENT}${styler('red', SYMBOLS.error)} ${styler('red', headline)}`,
719
+ '',
720
+ `${DETAIL_INDENT}${styler(
721
+ 'gray',
722
+ `${IMPORT_HEADINGS.where.padEnd(whereWidth)} ${IMPORT_HEADINGS.specifier.padEnd(specifierWidth)} ${IMPORT_HEADINGS.disk}`,
723
+ )}`,
724
+ ];
725
+
726
+ for (const row of shown) {
727
+ const where = styler('cyan', row.where.padEnd(whereWidth));
728
+ const specifier = row.specifier.padEnd(specifierWidth);
729
+ const disk = styler(row.status === 'moved' ? 'yellow' : 'red', row.label);
730
+
731
+ lines.push(`${DETAIL_INDENT}${where} ${specifier} ${disk}`.trimEnd());
732
+ }
733
+
734
+ const hidden = rows.length - MAX_ASSET_ROWS;
735
+ if (hidden > 0) {
736
+ lines.push(
737
+ `${DETAIL_INDENT}${styler('gray', `+${pluralize(hidden, 'more')}`)}`,
738
+ );
739
+ }
740
+
741
+ if (sharedDirectory) {
742
+ const cause = directoryExists
743
+ ? `all ${rows.length} resolve under ${sharedDirectory}`
744
+ : `all ${rows.length} resolve under ${sharedDirectory} — directory not found`;
745
+ lines.push('', `${DETAIL_INDENT}${styler('gray', cause)}`);
746
+ }
747
+
748
+ return lines;
749
+ }
750
+
751
+ /**
752
+ * Render the unresolved CSS asset block.
753
+ *
754
+ * Vite prints one of these per `url()` it cannot resolve, mid-build and in
755
+ * whatever order the transforms finish. Collapsing them into one table also
756
+ * lets the reporter answer what the raw notice cannot: which stylesheet writes
757
+ * the URL, and whether the file exists anywhere in the project.
758
+ *
759
+ * @param {Array<{where: string, url: string, status: string, label: string}>} rows - Enriched rows.
760
+ * @param {(format: string|string[], text: string) => string} styler - Styling function.
761
+ * @returns {string[]} Unresolved asset lines.
762
+ */
763
+ function renderUnresolvedAssets(rows, styler) {
764
+ if (rows.length === 0) return [];
765
+
766
+ const shown = rows.slice(0, MAX_ASSET_ROWS);
767
+ const found = rows.filter((row) => row.status === 'found').length;
768
+ const missing = rows.filter((row) => row.status === 'missing').length;
769
+
770
+ const tally = [`${found} found`, `${missing} missing`].join(', ');
771
+ const headline = `${pluralize(rows.length, 'unresolved css url')}${SEPARATOR}${tally}`;
772
+
773
+ // Padding is applied before styling so ANSI escapes never skew the columns.
774
+ const whereWidth = Math.max(
775
+ ASSET_HEADINGS.where.length,
776
+ ...shown.map((row) => row.where.length),
777
+ );
778
+ const urlWidth = Math.max(
779
+ ASSET_HEADINGS.url.length,
780
+ ...shown.map((row) => row.url.length),
781
+ );
782
+
783
+ const lines = [
784
+ `${INDENT}${styler('yellow', SYMBOLS.warning)} ${styler('yellow', headline)}`,
785
+ '',
786
+ `${DETAIL_INDENT}${styler(
787
+ 'gray',
788
+ `${ASSET_HEADINGS.where.padEnd(whereWidth)} ${ASSET_HEADINGS.url.padEnd(urlWidth)} ${ASSET_HEADINGS.disk}`,
789
+ )}`,
790
+ ];
791
+
792
+ for (const row of shown) {
793
+ const where = styler('cyan', row.where.padEnd(whereWidth));
794
+ const url = row.url.padEnd(urlWidth);
795
+ const disk = styler(ASSET_STATUS_COLORS[row.status] || 'gray', row.label);
796
+
797
+ lines.push(`${DETAIL_INDENT}${where} ${url} ${disk}`.trimEnd());
798
+ }
799
+
800
+ const hidden = rows.length - MAX_ASSET_ROWS;
801
+ if (hidden > 0) {
802
+ lines.push(
803
+ `${DETAIL_INDENT}${styler('gray', `+${pluralize(hidden, 'more')}`)}`,
804
+ );
805
+ }
806
+
807
+ lines.push(
808
+ '',
809
+ `${DETAIL_INDENT}${styler(
810
+ 'gray',
811
+ 'paths resolve from dist/, not from the scss file',
812
+ )}`,
813
+ );
814
+
815
+ return lines;
816
+ }
817
+
818
+ /**
819
+ * Render the problem blocks shared by first builds and rebuilds.
820
+ *
821
+ * @param {object} snapshot - Diagnostics snapshot.
822
+ * @param {string} projectDir - Project root.
823
+ * @param {(format: string|string[], text: string) => string} styler - Styling function.
824
+ * @param {string} sourceGlob - Glob matching the project stylesheets.
825
+ * @param {Array<object>} assetRows - Enriched unresolved asset rows.
826
+ * @returns {string[]} Problem lines.
827
+ */
828
+ function renderProblems(
829
+ snapshot,
830
+ projectDir,
831
+ styler,
832
+ sourceGlob,
833
+ assetRows,
834
+ importErrors,
835
+ syntaxErrors,
836
+ unicode = true,
837
+ ) {
838
+ const attention = [];
839
+
840
+ const syntaxLines = renderSyntaxErrors(syntaxErrors, styler);
841
+ if (syntaxLines.length > 0) {
842
+ attention.push('');
843
+ attention.push(...syntaxLines);
844
+ }
845
+
846
+ const importLines = renderImportErrors(
847
+ importErrors.rows || [],
848
+ importErrors.sharedDirectory,
849
+ Boolean(importErrors.directoryExists),
850
+ styler,
851
+ );
852
+ if (importLines.length > 0) {
853
+ attention.push('');
854
+ attention.push(...importLines);
855
+ }
856
+
857
+ if (snapshot.errors.length > 0) {
858
+ attention.push('');
859
+ attention.push(
860
+ `${INDENT}${styler('red', SYMBOLS.error)} ${styler('red', pluralize(snapshot.errors.length, 'error'))}`,
861
+ );
862
+ attention.push(...renderDetailRows(snapshot.errors, projectDir, styler));
863
+ }
864
+
865
+ if (snapshot.warnings.length > 0) {
866
+ attention.push('');
867
+ attention.push(
868
+ `${INDENT}${styler('yellow', SYMBOLS.warning)} ${styler('yellow', pluralize(snapshot.warnings.length, 'warning'))}`,
869
+ );
870
+ attention.push(...renderDetailRows(snapshot.warnings, projectDir, styler));
871
+ }
872
+
873
+ const assetLines = renderUnresolvedAssets(assetRows, styler);
874
+ if (assetLines.length > 0) {
875
+ attention.push('');
876
+ attention.push(...assetLines);
877
+ }
878
+
879
+ const debt = renderDeprecations(snapshot, projectDir, styler, sourceGlob);
880
+
881
+ const lines = [];
882
+
883
+ // Sass deprecations are inherited debt on almost every project, and there are
884
+ // usually two orders of magnitude more of them than of today's actual
885
+ // breakages. Without the split, 190 deprecations and six broken asset URLs
886
+ // compete for the same attention; with it, the reader knows which block is
887
+ // about the edit they just made.
888
+ //
889
+ // A divider is only drawn when its section has content. Labelling an empty
890
+ // category advertises a problem the project does not have.
891
+ if (attention.length > 0) {
892
+ lines.push('', renderDivider('needs attention', unicode, styler));
893
+ lines.push(...attention);
894
+ }
895
+
896
+ if (debt.length > 0) {
897
+ lines.push('', renderDivider('pre-existing debt', unicode, styler));
898
+ lines.push('');
899
+ lines.push(...debt);
900
+ }
901
+
902
+ return lines;
903
+ }
904
+
905
+ /**
906
+ * Total width of a section divider, in columns.
907
+ *
908
+ * Chosen to sit inside an 80-column terminal alongside the two-space indent.
909
+ *
910
+ * @type {number}
911
+ */
912
+ const DIVIDER_WIDTH = 54;
913
+
914
+ /**
915
+ * Render a labelled section divider.
916
+ *
917
+ * Falls back to ASCII dashes where box-drawing characters would not render, on
918
+ * the same gate as the wordmark and the ready panel.
919
+ *
920
+ * @param {string} label - Section label.
921
+ * @param {boolean} unicode - Whether box-drawing characters are safe to emit.
922
+ * @param {(format: string|string[], text: string) => string} styler - Styling function.
923
+ * @returns {string} Divider line.
924
+ */
925
+ function renderDivider(label, unicode, styler) {
926
+ const rule = unicode ? '─' : '-';
927
+ const prefix = `${rule.repeat(2)} ${label} `;
928
+ const fill = Math.max(3, DIVIDER_WIDTH - prefix.length);
929
+
930
+ return `${INDENT}${styler('gray', `${prefix}${rule.repeat(fill)}`)}`;
931
+ }
932
+
933
+ /**
934
+ * Column headings for the verbose input listing.
935
+ *
936
+ * @type {{source: string, size: string}}
937
+ */
938
+ const INPUT_FILE_HEADINGS = { source: 'source', size: 'size' };
939
+
940
+ /**
941
+ * Column headings for the verbose output listing.
942
+ *
943
+ * @type {{file: string, size: string, gzip: string}}
944
+ */
945
+ const OUTPUT_FILE_HEADINGS = { file: 'file', size: 'size', gzip: 'gzip' };
946
+
947
+ /**
948
+ * Placeholder for a size that does not apply or could not be read.
949
+ *
950
+ * @type {string}
951
+ */
952
+ const NO_SIZE = '—';
953
+
954
+ /**
955
+ * Render a right-aligned size column.
956
+ *
957
+ * Padding is applied to the unstyled text because ANSI escapes carry no display
958
+ * width and would skew every row by a different amount.
959
+ *
960
+ * @param {number|undefined} bytes - Size in bytes.
961
+ * @param {number} width - Column width.
962
+ * @param {(format: string|string[], text: string) => string} styler - Styling function.
963
+ * @returns {string} Padded, styled size.
964
+ */
965
+ const sizeColumn = (bytes, width, styler) =>
966
+ styler(
967
+ 'gray',
968
+ (Number.isFinite(bytes) ? formatPreciseBytes(bytes) : NO_SIZE).padStart(
969
+ width,
970
+ ),
971
+ );
972
+
973
+ /**
974
+ * Measure the widest rendered size in a set of rows.
975
+ *
976
+ * @param {Array<number|undefined>} values - Byte values.
977
+ * @param {string} heading - Column heading, which also has to fit.
978
+ * @returns {number} Column width.
979
+ */
980
+ const sizeWidth = (values, heading) =>
981
+ Math.max(
982
+ heading.length,
983
+ ...values.map(
984
+ (bytes) =>
985
+ (Number.isFinite(bytes) ? formatPreciseBytes(bytes) : NO_SIZE).length,
986
+ ),
987
+ );
988
+
989
+ /**
990
+ * Render the verbose listing of every entry the build reads.
991
+ *
992
+ * @param {Array<{path: string, bytes?: number}>} rows - Input file rows.
993
+ * @param {boolean} unicode - Whether box-drawing characters are safe to emit.
994
+ * @param {(format: string|string[], text: string) => string} styler - Styling function.
995
+ * @returns {string[]} Input listing lines.
996
+ */
997
+ function renderInputFiles(rows, unicode, styler) {
998
+ if (rows.length === 0) return [];
999
+
1000
+ const pathWidth = Math.max(
1001
+ INPUT_FILE_HEADINGS.source.length,
1002
+ ...rows.map((row) => row.path.length),
1003
+ );
1004
+ const width = sizeWidth(
1005
+ rows.map((row) => row.bytes),
1006
+ INPUT_FILE_HEADINGS.size,
1007
+ );
1008
+
1009
+ const lines = [
1010
+ '',
1011
+ renderDivider('input files', unicode, styler),
1012
+ '',
1013
+ `${DETAIL_INDENT}${styler(
1014
+ 'gray',
1015
+ `${INPUT_FILE_HEADINGS.source.padEnd(pathWidth)} ${INPUT_FILE_HEADINGS.size.padStart(width)}`,
1016
+ )}`,
1017
+ ];
1018
+
1019
+ for (const row of rows) {
1020
+ lines.push(
1021
+ `${DETAIL_INDENT}${styler('cyan', row.path.padEnd(pathWidth))} ${sizeColumn(row.bytes, width, styler)}`,
1022
+ );
1023
+ }
1024
+
1025
+ return lines;
1026
+ }
1027
+
1028
+ /**
1029
+ * Render the verbose listing of every file the build wrote.
1030
+ *
1031
+ * @param {Array<{fileName: string, bytes: number, gzipBytes?: number}>} rows - Output file rows.
1032
+ * @param {boolean} unicode - Whether box-drawing characters are safe to emit.
1033
+ * @param {(format: string|string[], text: string) => string} styler - Styling function.
1034
+ * @returns {string[]} Output listing lines.
1035
+ */
1036
+ function renderOutputFiles(rows, unicode, styler) {
1037
+ if (rows.length === 0) return [];
1038
+
1039
+ const lines = [
1040
+ '',
1041
+ renderDivider('output files', unicode, styler),
1042
+ '',
1043
+ ...renderSizeTable(rows, styler),
1044
+ ];
1045
+
1046
+ return lines;
1047
+ }
1048
+
1049
+ /**
1050
+ * Render a file-and-size table, with a gzip column when any row has one.
1051
+ *
1052
+ * Shared by the first build's output listing and the rebuild's changed-file
1053
+ * listing so the two read identically — the second is a filtered view of the
1054
+ * first, and formatting them differently would obscure that.
1055
+ *
1056
+ * @param {Array<{fileName: string, bytes: number, gzipBytes?: number}>} rows - Output file rows.
1057
+ * @param {(format: string|string[], text: string) => string} styler - Styling function.
1058
+ * @returns {string[]} Table lines.
1059
+ */
1060
+ function renderSizeTable(rows, styler) {
1061
+ const nameWidth = Math.max(
1062
+ OUTPUT_FILE_HEADINGS.file.length,
1063
+ ...rows.map((row) => row.fileName.length),
1064
+ );
1065
+ const width = sizeWidth(
1066
+ rows.map((row) => row.bytes),
1067
+ OUTPUT_FILE_HEADINGS.size,
1068
+ );
1069
+
1070
+ // The gzip column is dropped entirely when nothing in the table is
1071
+ // compressible, rather than printed as a column of dashes.
1072
+ const compressed = rows.some((row) => Number.isFinite(row.gzipBytes));
1073
+ const gzipHeading = compressed
1074
+ ? ` ${OUTPUT_FILE_HEADINGS.gzip.padStart(
1075
+ sizeWidth(
1076
+ rows.map((row) => row.gzipBytes),
1077
+ OUTPUT_FILE_HEADINGS.gzip,
1078
+ ),
1079
+ )}`
1080
+ : '';
1081
+ const gzipWidth = compressed
1082
+ ? sizeWidth(
1083
+ rows.map((row) => row.gzipBytes),
1084
+ OUTPUT_FILE_HEADINGS.gzip,
1085
+ )
1086
+ : 0;
1087
+
1088
+ const lines = [
1089
+ `${DETAIL_INDENT}${styler(
1090
+ 'gray',
1091
+ `${OUTPUT_FILE_HEADINGS.file.padEnd(nameWidth)} ${OUTPUT_FILE_HEADINGS.size.padStart(width)}${gzipHeading}`,
1092
+ )}`,
1093
+ ];
1094
+
1095
+ for (const row of rows) {
1096
+ const gzip = compressed
1097
+ ? ` ${sizeColumn(row.gzipBytes, gzipWidth, styler)}`
1098
+ : '';
1099
+
1100
+ lines.push(
1101
+ `${DETAIL_INDENT}${styler('cyan', row.fileName.padEnd(nameWidth))} ${sizeColumn(row.bytes, width, styler)}${gzip}`,
1102
+ );
1103
+ }
1104
+
1105
+ return lines;
1106
+ }
1107
+
1108
+ /**
1109
+ * Render the summary printed after the first successful watch build.
1110
+ *
1111
+ * Emitted as four labelled sections — project, build, and whichever problem
1112
+ * headings have content. `watchLabel` is supplied by the plugin, which has the
1113
+ * resolved source roots; without it the label is inferred from the input rows.
1114
+ *
1115
+ * @param {{
1116
+ * snapshot: object,
1117
+ * durationMs: number,
1118
+ * outDir?: string,
1119
+ * projectDir?: string,
1120
+ * sourceGlob?: string,
1121
+ * assetRows?: Array<object>,
1122
+ * importErrors?: {rows?: Array<object>, sharedDirectory?: string, directoryExists?: boolean},
1123
+ * platform?: string,
1124
+ * inputRows?: Array<{name: string, path: string, count: number, overflow?: boolean}>,
1125
+ * watchLabel?: string,
1126
+ * write?: {fileCount: number, totalBytes: number, largest?: {fileName: string, bytes: number}},
1127
+ * inputFiles?: Array<{path: string, bytes?: number}>,
1128
+ * outputFiles?: Array<{fileName: string, bytes: number, gzipBytes?: number}>,
1129
+ * unicode?: boolean,
1130
+ * styler: (format: string|string[], text: string) => string
1131
+ * }} options - Summary inputs.
1132
+ * @returns {string[]} Summary lines.
1133
+ */
1134
+ export function renderSummary({
1135
+ snapshot,
1136
+ durationMs,
1137
+ outDir = 'dist',
1138
+ projectDir = '',
1139
+ sourceGlob = 'src/**/*.scss',
1140
+ assetRows = [],
1141
+ importErrors = {},
1142
+ syntaxErrors = [],
1143
+ platform,
1144
+ inputRows = [],
1145
+ watchLabel,
1146
+ write,
1147
+ inputFiles = [],
1148
+ outputFiles = [],
1149
+ unicode = true,
1150
+ styler,
1151
+ }) {
1152
+ const failed =
1153
+ snapshot.errors.length > 0 ||
1154
+ (importErrors.rows || []).length > 0 ||
1155
+ syntaxErrors.length > 0;
1156
+ const symbol = failed
1157
+ ? styler('red', SYMBOLS.error)
1158
+ : styler('green', SYMBOLS.ok);
1159
+ const headline = failed
1160
+ ? `build failed after ${formatDuration(durationMs)}`
1161
+ : `built in ${formatDuration(durationMs)}`;
1162
+
1163
+ // `dist/` is written, not watched. Falling back to the input rows keeps the
1164
+ // label honest for any caller that renders a summary without the resolved
1165
+ // source roots to hand.
1166
+ const watching =
1167
+ watchLabel ||
1168
+ sharedRootPath(
1169
+ inputRows.filter((entry) => !entry.overflow).map((entry) => entry.path),
1170
+ ) ||
1171
+ 'sources';
1172
+
1173
+ // The two halves are labelled with the same dividers the problem blocks use, so
1174
+ // the whole summary reads as one sequence of named sections rather than a wall
1175
+ // of rows followed by some headings. The labels also give the facts block
1176
+ // somewhere to end: without one, `output` ran straight into the build result.
1177
+ //
1178
+ // Storybook's startup lines land between the banner and this block, so it opens
1179
+ // with a blank line rather than trusting whatever printed last to have left one.
1180
+ return [
1181
+ '',
1182
+ renderDivider('project', unicode, styler),
1183
+ '',
1184
+ ...renderFacts({ platform, inputRows, outDir, write, styler }),
1185
+ // The verbose listings expand the two rows above them, so they sit directly
1186
+ // under the totals they itemize rather than after the build result.
1187
+ ...renderInputFiles(inputFiles, unicode, styler),
1188
+ ...renderOutputFiles(outputFiles, unicode, styler),
1189
+ '',
1190
+ renderDivider('build', unicode, styler),
1191
+ '',
1192
+ `${INDENT}${symbol} ${headline}${styler('gray', `${SEPARATOR}watching ${watching}`)}`,
1193
+ ...renderProblems(
1194
+ snapshot,
1195
+ projectDir,
1196
+ styler,
1197
+ sourceGlob,
1198
+ assetRows,
1199
+ importErrors,
1200
+ syntaxErrors,
1201
+ unicode,
1202
+ ),
1203
+ '',
1204
+ ];
1205
+ }
1206
+
1207
+ /**
1208
+ * Render the compact line printed after each watch rebuild.
1209
+ *
1210
+ * In detailed mode the line is followed by what the rebuild actually produced:
1211
+ * how many modules were transformed, and which outputs came out different. That
1212
+ * is a deliberate departure from Rolldown's table, which reprints all seventy-odd
1213
+ * files every cycle because Rollup regenerates the whole bundle every cycle. The
1214
+ * question after an edit is which files changed, and the negative answer — an
1215
+ * edit that compiled to byte-identical output — is worth a line of its own.
1216
+ *
1217
+ * @param {{
1218
+ * snapshot: object,
1219
+ * durationMs: number,
1220
+ * changedFiles?: string[],
1221
+ * projectDir?: string,
1222
+ * moduleCount?: number,
1223
+ * changedOutputs?: Array<{fileName: string, bytes: number, gzipBytes?: number}>,
1224
+ * removedOutputs?: string[],
1225
+ * detailed?: boolean,
1226
+ * styler: (format: string|string[], text: string) => string,
1227
+ * now?: Date
1228
+ * }} options - Rebuild inputs.
1229
+ * @returns {string[]} Rebuild lines.
1230
+ */
1231
+ export function renderRebuild({
1232
+ snapshot,
1233
+ durationMs,
1234
+ changedFiles = [],
1235
+ projectDir = '',
1236
+ moduleCount,
1237
+ changedOutputs = [],
1238
+ removedOutputs = [],
1239
+ detailed = false,
1240
+ styler,
1241
+ now = new Date(),
1242
+ }) {
1243
+ const failed = snapshot.errors.length > 0;
1244
+ const [firstChange] = changedFiles;
1245
+ const changeLabel =
1246
+ changedFiles.length > 1
1247
+ ? `${displayLocation(firstChange, undefined, projectDir)} +${changedFiles.length - 1}`
1248
+ : firstChange
1249
+ ? displayLocation(firstChange, undefined, projectDir)
1250
+ : 'sources';
1251
+
1252
+ const outcome = failed
1253
+ ? styler('red', `rebuild failed after ${formatDuration(durationMs)}`)
1254
+ : styler('gray', `rebuilt in ${formatDuration(durationMs)}`);
1255
+
1256
+ const symbol = failed
1257
+ ? styler('red', SYMBOLS.error)
1258
+ : styler('gray', SYMBOLS.change);
1259
+
1260
+ const lines = [
1261
+ `${INDENT}${styler('gray', formatClockTime(now))} ${symbol} ${changeLabel}${styler('gray', SEPARATOR)}${outcome}`,
1262
+ ];
1263
+
1264
+ // Repeating the deprecation tally on every keystroke would recreate the noise
1265
+ // this reporter exists to remove, so rebuilds only surface hard failures.
1266
+ if (failed) {
1267
+ lines.push(...renderDetailRows(snapshot.errors, projectDir, styler));
1268
+ return lines;
1269
+ }
1270
+
1271
+ if (detailed)
1272
+ lines.push(
1273
+ ...renderRebuildDetail(
1274
+ { moduleCount, changedOutputs, removedOutputs },
1275
+ styler,
1276
+ ),
1277
+ );
1278
+
1279
+ return lines;
1280
+ }
1281
+
1282
+ /**
1283
+ * Render the detailed tail of a successful rebuild.
1284
+ *
1285
+ * @param {{
1286
+ * moduleCount?: number,
1287
+ * changedOutputs?: Array<{fileName: string, bytes: number, gzipBytes?: number}>,
1288
+ * removedOutputs?: string[]
1289
+ * }} cycle - What the rebuild produced.
1290
+ * @param {(format: string|string[], text: string) => string} styler - Styling function.
1291
+ * @returns {string[]} Detail lines.
1292
+ */
1293
+ function renderRebuildDetail(
1294
+ { moduleCount, changedOutputs = [], removedOutputs = [] },
1295
+ styler,
1296
+ ) {
1297
+ const facts = [];
1298
+ if (Number.isFinite(moduleCount)) {
1299
+ facts.push(`${pluralize(moduleCount, 'module')} transformed`);
1300
+ }
1301
+
1302
+ facts.push(
1303
+ changedOutputs.length === 0
1304
+ ? 'no output changed'
1305
+ : `${pluralize(changedOutputs.length, 'output')} changed`,
1306
+ );
1307
+
1308
+ if (removedOutputs.length > 0) {
1309
+ facts.push(`${pluralize(removedOutputs.length, 'output')} removed`);
1310
+ }
1311
+
1312
+ const lines = [
1313
+ '',
1314
+ `${DETAIL_INDENT}${styler('gray', facts.join(SEPARATOR))}`,
1315
+ ];
1316
+
1317
+ if (changedOutputs.length > 0) {
1318
+ lines.push('', ...renderSizeTable(changedOutputs, styler));
1319
+ }
1320
+
1321
+ // Removals carry no size, so they cannot share the table without a column of
1322
+ // dashes. They get their own labelled group instead.
1323
+ if (removedOutputs.length > 0) {
1324
+ lines.push('', `${DETAIL_INDENT}${styler('gray', 'no longer written')}`);
1325
+
1326
+ for (const fileName of removedOutputs) {
1327
+ lines.push(`${DETAIL_INDENT}${styler('cyan', fileName)}`);
1328
+ }
1329
+ }
1330
+
1331
+ return lines;
1332
+ }