@emulsify/core 4.2.0 → 4.3.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.
Files changed (79) hide show
  1. package/.storybook/main-vite.js +91 -36
  2. package/README.md +185 -56
  3. package/config/a11y.config.js +2 -1
  4. package/config/vite/entries.js +4 -4
  5. package/config/vite/plugins/{copy-src-assets.js → assets/copy-src-assets.js} +1 -1
  6. package/config/vite/plugins/{copy-twig-files.js → assets/copy-twig-files.js} +1 -1
  7. package/config/vite/plugins/{css-asset-relativizer.js → assets/css-asset-relativizer.js} +9 -0
  8. package/config/vite/plugins/{mirror-components.js → assets/mirror-components.js} +2 -28
  9. package/config/vite/plugins/{source-file-index.js → assets/source-file-index.js} +1 -1
  10. package/config/vite/plugins/{svg-sprite.js → assets/svg-sprite.js} +2 -2
  11. package/config/vite/plugins/index.js +23 -14
  12. package/config/vite/plugins/reporter/asset-resolver.js +541 -0
  13. package/config/vite/plugins/reporter/build-errors.js +284 -0
  14. package/config/vite/plugins/reporter/diagnostics.js +366 -0
  15. package/config/vite/plugins/reporter/format.js +199 -0
  16. package/config/vite/plugins/reporter/index.js +308 -0
  17. package/config/vite/plugins/reporter/render.js +758 -0
  18. package/config/vite/plugins/reporter/sass-logger.js +237 -0
  19. package/config/vite/plugins/reporter/vite-logger.js +188 -0
  20. package/config/vite/plugins/reporter/watch-mode.js +40 -0
  21. package/config/vite/plugins/{twig-extension-installers.js → twig/extension-installers.js} +1 -1
  22. package/config/vite/{twig-extensions.js → plugins/twig/extensions.js} +2 -2
  23. package/config/vite/plugins/{twig-module.js → twig/twig-module.js} +387 -115
  24. package/config/vite/plugins/{virtual-twig-asset-sources.js → twig/virtual-twig-asset-sources.js} +37 -136
  25. package/config/vite/plugins/{virtual-twig-globs.js → twig/virtual-twig-globs.js} +3 -32
  26. package/config/vite/plugins/{vituum-patch.js → twig/vituum-patch.js} +3 -3
  27. package/config/vite/plugins.js +1 -1
  28. package/config/vite/project-config.js +1 -1
  29. package/config/vite/project-structure.js +1 -1
  30. package/config/vite/utils/lru.js +77 -0
  31. package/config/vite/utils/package-version.js +42 -0
  32. package/config/vite/utils/paths.js +1 -9
  33. package/config/vite/utils/react-singleton.js +1 -1
  34. package/config/vite/vite.config.js +51 -5
  35. package/package.json +93 -58
  36. package/scripts/a11y.js +115 -23
  37. package/scripts/audit/checks/core-imports.js +78 -0
  38. package/scripts/audit/checks/css-asset-references.js +99 -0
  39. package/scripts/audit/checks/drupal-assumptions.js +48 -0
  40. package/scripts/audit/checks/files-outside-roots.js +53 -0
  41. package/scripts/audit/checks/generated-package-scripts.js +113 -0
  42. package/scripts/audit/checks/legacy-twig-stories.js +33 -0
  43. package/scripts/audit/checks/package-overrides.js +91 -0
  44. package/scripts/audit/checks/project-config.js +71 -0
  45. package/scripts/audit/checks/story-discovery.js +35 -0
  46. package/scripts/audit/checks/twig-references.js +69 -0
  47. package/scripts/audit/checks/twig-volume.js +54 -0
  48. package/scripts/audit/checks/webpack-patterns.js +86 -0
  49. package/scripts/audit/index.js +177 -0
  50. package/scripts/audit/lib/css.js +165 -0
  51. package/scripts/audit/lib/files.js +168 -0
  52. package/scripts/audit/lib/findings.js +31 -0
  53. package/scripts/audit/lib/package-json.js +65 -0
  54. package/scripts/audit/lib/twig.js +227 -0
  55. package/scripts/audit/report.js +273 -0
  56. package/scripts/audit-twig-stories.js +115 -78
  57. package/scripts/audit.js +150 -1632
  58. package/scripts/check-node-version.js +136 -10
  59. package/scripts/inspect-components.js +456 -0
  60. package/scripts/lib/cli.js +179 -0
  61. package/scripts/lib/fs.js +31 -0
  62. package/scripts/lib/proc.js +78 -0
  63. package/scripts/lib/text.js +14 -0
  64. package/scripts/loadYaml.js +2 -2
  65. package/src/extensions/shared/attributes.js +3 -3
  66. package/src/extensions/shared/lists.js +2 -6
  67. package/src/extensions/shared/root-relative.js +38 -0
  68. package/src/storybook/index.js +4 -0
  69. package/src/storybook/render-twig.js +1 -1
  70. package/src/storybook/render-web-component.js +459 -0
  71. package/src/storybook/twig/asset-source-runtime.js +193 -0
  72. package/src/storybook/twig/{source-extensions.js → constants.js} +3 -1
  73. package/src/storybook/twig/reference-paths.js +2 -13
  74. package/src/storybook/twig/resolver.js +20 -3
  75. package/src/storybook/twig/setup.js +12 -2
  76. package/src/storybook/twig/source-function.js +5 -2
  77. package/config/vite/utils/unique.js +0 -36
  78. package/src/storybook/twig/include.js +0 -28
  79. package/src/storybook/twig/source-events.js +0 -5
@@ -0,0 +1,758 @@
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
+ formatClockTime,
16
+ formatDuration,
17
+ platformLabel,
18
+ pluralize,
19
+ } from './format.js';
20
+ import { deprecationFix, deprecationMigrator } from './sass-logger.js';
21
+
22
+ const INDENT = ' ';
23
+ const DETAIL_INDENT = ' ';
24
+ const ROW_INDENT = ' ';
25
+ const SEPARATOR = ' · ';
26
+
27
+ /**
28
+ * Maximum number of individual problems listed before collapsing to a count.
29
+ *
30
+ * @type {number}
31
+ */
32
+ const MAX_DETAIL_ROWS = 5;
33
+
34
+ /**
35
+ * Maximum number of unresolved asset URLs listed before collapsing.
36
+ *
37
+ * Higher than the general detail cap because these rows are single short
38
+ * paths, and the same image referenced from two stylesheets with different
39
+ * relative spellings is two separate edits — hiding either is unhelpful.
40
+ *
41
+ * @type {number}
42
+ */
43
+ const MAX_ASSET_ROWS = 8;
44
+
45
+ /**
46
+ * Maximum number of files listed in the deprecation worklist.
47
+ *
48
+ * @type {number}
49
+ */
50
+ const MAX_DEPRECATION_FILES = 4;
51
+
52
+ /**
53
+ * Maximum number of deprecation kinds listed beneath a single file.
54
+ *
55
+ * @type {number}
56
+ */
57
+ const MAX_DEPRECATION_KINDS_PER_FILE = 4;
58
+
59
+ /**
60
+ * Maximum number of line numbers named on one row before collapsing.
61
+ *
62
+ * @type {number}
63
+ */
64
+ const MAX_LINES_PER_ROW = 3;
65
+
66
+ /**
67
+ * Render the affected line numbers for one deprecation within one file.
68
+ *
69
+ * @param {number[]} lineNumbers - Sorted line numbers.
70
+ * @returns {string} Compact line reference, for example `:30,31 +2`.
71
+ */
72
+ export function formatLineList(lineNumbers = []) {
73
+ if (lineNumbers.length === 0) return ':?';
74
+
75
+ const shown = lineNumbers.slice(0, MAX_LINES_PER_ROW).join(',');
76
+ const hidden = lineNumbers.length - MAX_LINES_PER_ROW;
77
+
78
+ return hidden > 0 ? `:${shown} +${hidden}` : `:${shown}`;
79
+ }
80
+
81
+ /**
82
+ * Half-block wordmark drawn when the terminal can render the glyphs.
83
+ *
84
+ * Spells EMULSIFY at 31 columns, which fits comfortably in an 80-column
85
+ * terminal alongside the two-space indent.
86
+ *
87
+ * @type {string[]}
88
+ */
89
+ const WORDMARK = [
90
+ '█▀▀ █▀▄▀█ █ █ █ █▀▀ █ █▀▀ █ █',
91
+ '█▀▀ █ ▀ █ █ █ █ ▀▀█ █ █▀▀ ▀▄▀',
92
+ '▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀ ▀▀▀ ▀ ▀ ▀ ',
93
+ ];
94
+
95
+ /**
96
+ * Render the header shown once when the watcher starts.
97
+ *
98
+ * The wordmark exists to mark where Emulsify's output begins. `npm run develop`
99
+ * interleaves npm's script echo, Vite, and Storybook, so a single dim line is
100
+ * easy to scroll past; a block of art is not. Terminals that cannot render the
101
+ * glyphs get the plain name instead of mojibake.
102
+ *
103
+ * @param {{
104
+ * version?: string,
105
+ * platform?: string,
106
+ * entryCount?: number,
107
+ * unicode?: boolean,
108
+ * styler: (format: string|string[], text: string) => string
109
+ * }} options - Banner inputs.
110
+ * @returns {string[]} Banner lines.
111
+ */
112
+ export function renderBanner({
113
+ version,
114
+ platform,
115
+ entryCount,
116
+ unicode = true,
117
+ styler,
118
+ }) {
119
+ const facts = [
120
+ `core ${version || '0.0.0'}`,
121
+ `Platform: ${platformLabel(platform)}`,
122
+ ];
123
+
124
+ if (Number.isFinite(entryCount)) {
125
+ facts.push(pluralize(entryCount, 'entry', 'entries'));
126
+ }
127
+
128
+ const mark = unicode
129
+ ? WORDMARK.map((row) => `${INDENT}${styler(['bold', 'cyan'], row)}`)
130
+ : [`${INDENT}${styler(['bold', 'cyan'], 'EMULSIFY')}`];
131
+
132
+ return ['', ...mark, `${INDENT}${styler('gray', facts.join(SEPARATOR))}`, ''];
133
+ }
134
+
135
+ /**
136
+ * Render the detail rows for a set of errors or warnings.
137
+ *
138
+ * @param {Array<{message?: string, file?: string, line?: number, count: number}>} entries - Reported entries.
139
+ * @param {string} projectDir - Project root.
140
+ * @param {(format: string|string[], text: string) => string} styler - Styling function.
141
+ * @returns {string[]} Detail lines.
142
+ */
143
+ function renderDetailRows(entries, projectDir, styler) {
144
+ const lines = [];
145
+
146
+ for (const entry of entries.slice(0, MAX_DETAIL_ROWS)) {
147
+ const repeat = entry.count > 1 ? styler('gray', ` (×${entry.count})`) : '';
148
+
149
+ if (entry.file) {
150
+ const location = displayLocation(entry.file, entry.line, projectDir);
151
+ lines.push(`${DETAIL_INDENT}${styler('gray', location)}${repeat}`);
152
+
153
+ if (entry.message) {
154
+ lines.push(`${DETAIL_INDENT}${entry.message}`);
155
+ }
156
+ continue;
157
+ }
158
+
159
+ // Some warnings arrive with no span. Printing a "<unknown>" path row for
160
+ // those wastes a line and reads like a failure to resolve something, so the
161
+ // message carries the repeat count instead. An entry with neither a
162
+ // location nor a message has nothing to act on and is dropped.
163
+ if (entry.message) {
164
+ lines.push(`${DETAIL_INDENT}${entry.message}${repeat}`);
165
+ }
166
+ }
167
+
168
+ const hidden = entries.length - MAX_DETAIL_ROWS;
169
+ if (hidden > 0) {
170
+ lines.push(
171
+ `${DETAIL_INDENT}${styler('gray', `+${pluralize(hidden, 'more')}`)}`,
172
+ );
173
+ }
174
+
175
+ return lines;
176
+ }
177
+
178
+ /**
179
+ * Render the deduplicated Sass deprecation block.
180
+ *
181
+ * This replaces several hundred lines of repeated Dart Sass output with a
182
+ * worklist: total first, then each affected file with the deprecations inside
183
+ * it, then the command that fixes most of them. Each row carries the affected
184
+ * lines, how many occurrences, the Sass deprecation ID, and the substitution to
185
+ * make — the ID alone identifies nothing actionable, and the substitution alone
186
+ * gives no way to look up the details.
187
+ *
188
+ * @param {object} snapshot - Diagnostics snapshot.
189
+ * @param {string} projectDir - Project root.
190
+ * @param {(format: string|string[], text: string) => string} styler - Styling function.
191
+ * @param {string} sourceGlob - Glob matching the project stylesheets.
192
+ * @returns {string[]} Deprecation summary lines.
193
+ */
194
+ function renderDeprecations(snapshot, projectDir, styler, sourceGlob) {
195
+ const { deprecations, deprecationsByFile, deprecationTotal } = snapshot;
196
+ if (deprecations.length === 0) return [];
197
+
198
+ const headline = [
199
+ pluralize(deprecationTotal, 'sass deprecation'),
200
+ pluralize(deprecationsByFile.length || 1, 'file'),
201
+ ].join(SEPARATOR);
202
+
203
+ const lines = [
204
+ `${INDENT}${styler('yellow', SYMBOLS.warning)} ${styler('yellow', headline)}`,
205
+ '',
206
+ ];
207
+
208
+ const shownFiles = deprecationsByFile.slice(0, MAX_DEPRECATION_FILES);
209
+
210
+ // Column widths are measured across every row that will be printed so the
211
+ // line, count, and ID columns align and the block scans as a table. Padding
212
+ // is applied before styling so escape sequences never affect the width.
213
+ const rows = shownFiles.flatMap((group) =>
214
+ group.entries.slice(0, MAX_DEPRECATION_KINDS_PER_FILE),
215
+ );
216
+ const lineWidth = Math.max(
217
+ DEPRECATION_HEADINGS.lines.length,
218
+ ...rows.map((e) => formatLineList(e.lines).length),
219
+ );
220
+ const countWidth = Math.max(
221
+ DEPRECATION_HEADINGS.count.length,
222
+ ...rows.map((e) => `${e.count}×`.length),
223
+ );
224
+ const idWidth = Math.max(
225
+ DEPRECATION_HEADINGS.id.length,
226
+ ...rows.map((e) => e.id.length),
227
+ );
228
+
229
+ lines.push(
230
+ `${ROW_INDENT}${styler(
231
+ 'gray',
232
+ `${DEPRECATION_HEADINGS.lines.padEnd(lineWidth)} ${DEPRECATION_HEADINGS.count.padStart(countWidth)} ${DEPRECATION_HEADINGS.id.padEnd(idWidth)} ${DEPRECATION_HEADINGS.fix}`,
233
+ )}`,
234
+ );
235
+
236
+ for (const group of shownFiles) {
237
+ lines.push(
238
+ `${DETAIL_INDENT}${styler('cyan', displayPath(group.file, projectDir))}`,
239
+ );
240
+
241
+ for (const entry of group.entries.slice(
242
+ 0,
243
+ MAX_DEPRECATION_KINDS_PER_FILE,
244
+ )) {
245
+ const lineRef = formatLineList(entry.lines).padEnd(lineWidth);
246
+ const count = `${entry.count}×`.padStart(countWidth);
247
+ const id = entry.id.padEnd(idWidth);
248
+ const fix = deprecationFix(entry.id) || '';
249
+
250
+ lines.push(
251
+ `${ROW_INDENT}${styler('gray', lineRef)} ${styler('yellow', count)} ${styler('gray', id)} ${fix}`.trimEnd(),
252
+ );
253
+ }
254
+
255
+ const hiddenKinds = group.entries.length - MAX_DEPRECATION_KINDS_PER_FILE;
256
+ if (hiddenKinds > 0) {
257
+ lines.push(
258
+ `${ROW_INDENT}${styler('gray', `+${pluralize(hiddenKinds, 'more kind')}`)}`,
259
+ );
260
+ }
261
+ }
262
+
263
+ const hiddenFiles = deprecationsByFile.length - MAX_DEPRECATION_FILES;
264
+ if (hiddenFiles > 0) {
265
+ lines.push(
266
+ `${DETAIL_INDENT}${styler('gray', `+${pluralize(hiddenFiles, 'more file')}`)}`,
267
+ );
268
+ }
269
+
270
+ const command = renderMigratorCommand(deprecations, sourceGlob, styler);
271
+ if (command) {
272
+ lines.push('', command);
273
+ }
274
+
275
+ return lines;
276
+ }
277
+
278
+ /**
279
+ * Render the `sass-migrator` invocation that resolves most of the debt.
280
+ *
281
+ * The migrator runs exactly one migration per invocation, so a combined command
282
+ * would not work. The dominant migration is shown in full and any others are
283
+ * named after it, which keeps the block to one line while staying accurate
284
+ * about what has to be run.
285
+ *
286
+ * @param {Array<{id: string, occurrences: number}>} deprecations - ID-keyed buckets, most frequent first.
287
+ * @param {string} sourceGlob - Glob matching the project stylesheets.
288
+ * @param {(format: string|string[], text: string) => string} styler - Styling function.
289
+ * @returns {string|undefined} Command line, when a migrator applies.
290
+ * @see https://sass-lang.com/documentation/cli/migrator/
291
+ */
292
+ function renderMigratorCommand(deprecations, sourceGlob, styler) {
293
+ const migrators = [];
294
+ for (const bucket of deprecations) {
295
+ const migrator = deprecationMigrator(bucket.id);
296
+ if (migrator && !migrators.includes(migrator)) migrators.push(migrator);
297
+ }
298
+
299
+ if (migrators.length === 0) return undefined;
300
+
301
+ const [primary, ...rest] = migrators;
302
+ const command = `npx sass-migrator ${primary} '${sourceGlob}'`;
303
+ const others = rest.length > 0 ? ` (then: ${rest.join(', ')})` : '';
304
+
305
+ return `${DETAIL_INDENT}${styler('gray', command + others)}`;
306
+ }
307
+
308
+ /**
309
+ * Column headings for the unresolved asset table.
310
+ *
311
+ * `on disk` has to label both a directory and a not-found state, which is why
312
+ * it is not called something like "found in".
313
+ *
314
+ * @type {{where: string, url: string, disk: string}}
315
+ */
316
+ const ASSET_HEADINGS = {
317
+ where: 'referenced in',
318
+ url: 'url',
319
+ disk: 'on disk',
320
+ };
321
+
322
+ /**
323
+ * Column headings for the deprecation worklist.
324
+ *
325
+ * These sit at the row indent rather than the file indent so each label lands
326
+ * directly above the column it names.
327
+ *
328
+ * @type {{lines: string, count: string, id: string, fix: string}}
329
+ */
330
+ const DEPRECATION_HEADINGS = {
331
+ lines: 'lines',
332
+ count: 'count',
333
+ id: 'deprecation',
334
+ fix: 'fix',
335
+ };
336
+
337
+ /**
338
+ * Colors for each on-disk resolution state.
339
+ *
340
+ * @type {Record<string, string>}
341
+ */
342
+ const ASSET_STATUS_COLORS = {
343
+ found: 'green',
344
+ missing: 'red',
345
+ ambiguous: 'yellow',
346
+ unknown: 'gray',
347
+ };
348
+
349
+ /**
350
+ * Column headings for the missing-import table.
351
+ *
352
+ * @type {{where: string, specifier: string, disk: string}}
353
+ */
354
+ const IMPORT_HEADINGS = {
355
+ where: 'imported by',
356
+ specifier: 'import',
357
+ disk: 'on disk',
358
+ };
359
+
360
+ /**
361
+ * Maximum likely-source leads listed for one syntax error.
362
+ *
363
+ * @type {number}
364
+ */
365
+ const MAX_SOURCE_LEADS = 4;
366
+
367
+ /**
368
+ * Render the CSS syntax error block.
369
+ *
370
+ * The minifier runs on the concatenated bundle, so its line number refers to
371
+ * generated CSS and names no file. The offending declaration is shown verbatim
372
+ * with the minifier's caret, followed by wherever that declaration's literals
373
+ * appear in the project. Those are labelled likely, not definite, because they
374
+ * come from a search rather than a source map.
375
+ *
376
+ * @param {Array<{
377
+ * minifier: string,
378
+ * message: string,
379
+ * bundleLine?: number,
380
+ * declaration?: string,
381
+ * caretColumn?: number,
382
+ * lead?: {token: string, matches: Array<{file: string, line: number}>}
383
+ * }>} errors - Parsed syntax errors.
384
+ * @param {(format: string|string[], text: string) => string} styler - Styling function.
385
+ * @returns {string[]} Syntax error lines.
386
+ */
387
+ function renderSyntaxErrors(errors, styler) {
388
+ if (errors.length === 0) return [];
389
+
390
+ const lines = [
391
+ `${INDENT}${styler('red', SYMBOLS.error)} ${styler('red', pluralize(errors.length, 'css syntax error'))}`,
392
+ '',
393
+ ];
394
+
395
+ for (const error of errors) {
396
+ lines.push(
397
+ `${DETAIL_INDENT}${styler('gray', error.minifier)} ${error.message}`,
398
+ );
399
+
400
+ if (error.declaration) {
401
+ lines.push(`${DETAIL_INDENT}${error.declaration}`);
402
+
403
+ if (error.caretColumn != null) {
404
+ lines.push(
405
+ `${DETAIL_INDENT}${' '.repeat(error.caretColumn)}${styler('red', '^')}`,
406
+ );
407
+ }
408
+ }
409
+
410
+ const matches = error.lead?.matches || [];
411
+ if (matches.length > 0) {
412
+ lines.push('', `${DETAIL_INDENT}${styler('gray', 'likely source')}`);
413
+
414
+ const width = Math.max(
415
+ ...matches
416
+ .slice(0, MAX_SOURCE_LEADS)
417
+ .map((match) => `${match.file}:${match.line}`.length),
418
+ );
419
+
420
+ for (const match of matches.slice(0, MAX_SOURCE_LEADS)) {
421
+ const location = `${match.file}:${match.line}`.padEnd(width);
422
+ lines.push(
423
+ `${DETAIL_INDENT}${styler('cyan', location)} ${styler('gray', error.lead.token)}`,
424
+ );
425
+ }
426
+
427
+ const hidden = matches.length - MAX_SOURCE_LEADS;
428
+ if (hidden > 0) {
429
+ lines.push(
430
+ `${DETAIL_INDENT}${styler('gray', `+${pluralize(hidden, 'more')}`)}`,
431
+ );
432
+ }
433
+ }
434
+
435
+ if (error.bundleLine != null) {
436
+ // Named as generated output so it never reads as a source location.
437
+ lines.push(
438
+ '',
439
+ `${DETAIL_INDENT}${styler('gray', `bundle line ${error.bundleLine} · EMULSIFY_VERBOSE=1 for full output`)}`,
440
+ );
441
+ }
442
+ }
443
+
444
+ return lines;
445
+ }
446
+
447
+ /**
448
+ * Render the missing Sass import block.
449
+ *
450
+ * Replaces Rolldown's aggregate dump, which prints every error three times
451
+ * with a Dart Sass stack trace attached. The table keeps one row per importing
452
+ * site and closes with the directory they all point into, because a deleted
453
+ * partial usually breaks several components at once.
454
+ *
455
+ * @param {Array<{where: string, specifier: string, status: string, label: string}>} rows - Import rows.
456
+ * @param {string|undefined} sharedDirectory - Directory every import resolves under.
457
+ * @param {boolean} directoryExists - Whether that directory is present.
458
+ * @param {(format: string|string[], text: string) => string} styler - Styling function.
459
+ * @returns {string[]} Missing import lines.
460
+ */
461
+ function renderImportErrors(rows, sharedDirectory, directoryExists, styler) {
462
+ if (rows.length === 0) return [];
463
+
464
+ const shown = rows.slice(0, MAX_ASSET_ROWS);
465
+ const distinct = new Set(rows.map((row) => row.specifier)).size;
466
+ const headline = [
467
+ pluralize(distinct, 'missing stylesheet'),
468
+ pluralize(rows.length, 'import error'),
469
+ ].join(SEPARATOR);
470
+
471
+ const whereWidth = Math.max(
472
+ IMPORT_HEADINGS.where.length,
473
+ ...shown.map((row) => row.where.length),
474
+ );
475
+ const specifierWidth = Math.max(
476
+ IMPORT_HEADINGS.specifier.length,
477
+ ...shown.map((row) => row.specifier.length),
478
+ );
479
+
480
+ const lines = [
481
+ `${INDENT}${styler('red', SYMBOLS.error)} ${styler('red', headline)}`,
482
+ '',
483
+ `${DETAIL_INDENT}${styler(
484
+ 'gray',
485
+ `${IMPORT_HEADINGS.where.padEnd(whereWidth)} ${IMPORT_HEADINGS.specifier.padEnd(specifierWidth)} ${IMPORT_HEADINGS.disk}`,
486
+ )}`,
487
+ ];
488
+
489
+ for (const row of shown) {
490
+ const where = styler('cyan', row.where.padEnd(whereWidth));
491
+ const specifier = row.specifier.padEnd(specifierWidth);
492
+ const disk = styler(row.status === 'moved' ? 'yellow' : 'red', row.label);
493
+
494
+ lines.push(`${DETAIL_INDENT}${where} ${specifier} ${disk}`.trimEnd());
495
+ }
496
+
497
+ const hidden = rows.length - MAX_ASSET_ROWS;
498
+ if (hidden > 0) {
499
+ lines.push(
500
+ `${DETAIL_INDENT}${styler('gray', `+${pluralize(hidden, 'more')}`)}`,
501
+ );
502
+ }
503
+
504
+ if (sharedDirectory) {
505
+ const cause = directoryExists
506
+ ? `all ${rows.length} resolve under ${sharedDirectory}`
507
+ : `all ${rows.length} resolve under ${sharedDirectory} — directory not found`;
508
+ lines.push('', `${DETAIL_INDENT}${styler('gray', cause)}`);
509
+ }
510
+
511
+ return lines;
512
+ }
513
+
514
+ /**
515
+ * Render the unresolved CSS asset block.
516
+ *
517
+ * Vite prints one of these per `url()` it cannot resolve, mid-build and in
518
+ * whatever order the transforms finish. Collapsing them into one table also
519
+ * lets the reporter answer what the raw notice cannot: which stylesheet writes
520
+ * the URL, and whether the file exists anywhere in the project.
521
+ *
522
+ * @param {Array<{where: string, url: string, status: string, label: string}>} rows - Enriched rows.
523
+ * @param {(format: string|string[], text: string) => string} styler - Styling function.
524
+ * @returns {string[]} Unresolved asset lines.
525
+ */
526
+ function renderUnresolvedAssets(rows, styler) {
527
+ if (rows.length === 0) return [];
528
+
529
+ const shown = rows.slice(0, MAX_ASSET_ROWS);
530
+ const found = rows.filter((row) => row.status === 'found').length;
531
+ const missing = rows.filter((row) => row.status === 'missing').length;
532
+
533
+ const tally = [`${found} found`, `${missing} missing`].join(', ');
534
+ const headline = `${pluralize(rows.length, 'unresolved css url')}${SEPARATOR}${tally}`;
535
+
536
+ // Padding is applied before styling so ANSI escapes never skew the columns.
537
+ const whereWidth = Math.max(
538
+ ASSET_HEADINGS.where.length,
539
+ ...shown.map((row) => row.where.length),
540
+ );
541
+ const urlWidth = Math.max(
542
+ ASSET_HEADINGS.url.length,
543
+ ...shown.map((row) => row.url.length),
544
+ );
545
+
546
+ const lines = [
547
+ `${INDENT}${styler('yellow', SYMBOLS.warning)} ${styler('yellow', headline)}`,
548
+ '',
549
+ `${DETAIL_INDENT}${styler(
550
+ 'gray',
551
+ `${ASSET_HEADINGS.where.padEnd(whereWidth)} ${ASSET_HEADINGS.url.padEnd(urlWidth)} ${ASSET_HEADINGS.disk}`,
552
+ )}`,
553
+ ];
554
+
555
+ for (const row of shown) {
556
+ const where = styler('cyan', row.where.padEnd(whereWidth));
557
+ const url = row.url.padEnd(urlWidth);
558
+ const disk = styler(ASSET_STATUS_COLORS[row.status] || 'gray', row.label);
559
+
560
+ lines.push(`${DETAIL_INDENT}${where} ${url} ${disk}`.trimEnd());
561
+ }
562
+
563
+ const hidden = rows.length - MAX_ASSET_ROWS;
564
+ if (hidden > 0) {
565
+ lines.push(
566
+ `${DETAIL_INDENT}${styler('gray', `+${pluralize(hidden, 'more')}`)}`,
567
+ );
568
+ }
569
+
570
+ lines.push(
571
+ '',
572
+ `${DETAIL_INDENT}${styler(
573
+ 'gray',
574
+ 'paths resolve from dist/, not from the scss file',
575
+ )}`,
576
+ );
577
+
578
+ return lines;
579
+ }
580
+
581
+ /**
582
+ * Render the problem blocks shared by first builds and rebuilds.
583
+ *
584
+ * @param {object} snapshot - Diagnostics snapshot.
585
+ * @param {string} projectDir - Project root.
586
+ * @param {(format: string|string[], text: string) => string} styler - Styling function.
587
+ * @param {string} sourceGlob - Glob matching the project stylesheets.
588
+ * @param {Array<object>} assetRows - Enriched unresolved asset rows.
589
+ * @returns {string[]} Problem lines.
590
+ */
591
+ function renderProblems(
592
+ snapshot,
593
+ projectDir,
594
+ styler,
595
+ sourceGlob,
596
+ assetRows,
597
+ importErrors,
598
+ syntaxErrors,
599
+ ) {
600
+ const lines = [];
601
+
602
+ const syntaxLines = renderSyntaxErrors(syntaxErrors, styler);
603
+ if (syntaxLines.length > 0) {
604
+ lines.push('');
605
+ lines.push(...syntaxLines);
606
+ }
607
+
608
+ const importLines = renderImportErrors(
609
+ importErrors.rows || [],
610
+ importErrors.sharedDirectory,
611
+ Boolean(importErrors.directoryExists),
612
+ styler,
613
+ );
614
+ if (importLines.length > 0) {
615
+ lines.push('');
616
+ lines.push(...importLines);
617
+ }
618
+
619
+ if (snapshot.errors.length > 0) {
620
+ lines.push('');
621
+ lines.push(
622
+ `${INDENT}${styler('red', SYMBOLS.error)} ${styler('red', pluralize(snapshot.errors.length, 'error'))}`,
623
+ );
624
+ lines.push(...renderDetailRows(snapshot.errors, projectDir, styler));
625
+ }
626
+
627
+ if (snapshot.warnings.length > 0) {
628
+ lines.push('');
629
+ lines.push(
630
+ `${INDENT}${styler('yellow', SYMBOLS.warning)} ${styler('yellow', pluralize(snapshot.warnings.length, 'warning'))}`,
631
+ );
632
+ lines.push(...renderDetailRows(snapshot.warnings, projectDir, styler));
633
+ }
634
+
635
+ const assetLines = renderUnresolvedAssets(assetRows, styler);
636
+ if (assetLines.length > 0) {
637
+ lines.push('');
638
+ lines.push(...assetLines);
639
+ }
640
+
641
+ const deprecationLines = renderDeprecations(
642
+ snapshot,
643
+ projectDir,
644
+ styler,
645
+ sourceGlob,
646
+ );
647
+ if (deprecationLines.length > 0) {
648
+ lines.push('');
649
+ lines.push(...deprecationLines);
650
+ }
651
+
652
+ return lines;
653
+ }
654
+
655
+ /**
656
+ * Render the summary printed after the first successful watch build.
657
+ *
658
+ * @param {{
659
+ * snapshot: object,
660
+ * durationMs: number,
661
+ * outDir?: string,
662
+ * projectDir?: string,
663
+ * sourceGlob?: string,
664
+ * assetRows?: Array<object>,
665
+ * importErrors?: {rows?: Array<object>, sharedDirectory?: string, directoryExists?: boolean},
666
+ * styler: (format: string|string[], text: string) => string
667
+ * }} options - Summary inputs.
668
+ * @returns {string[]} Summary lines.
669
+ */
670
+ export function renderSummary({
671
+ snapshot,
672
+ durationMs,
673
+ outDir = 'dist',
674
+ projectDir = '',
675
+ sourceGlob = 'src/**/*.scss',
676
+ assetRows = [],
677
+ importErrors = {},
678
+ syntaxErrors = [],
679
+ styler,
680
+ }) {
681
+ const failed =
682
+ snapshot.errors.length > 0 ||
683
+ (importErrors.rows || []).length > 0 ||
684
+ syntaxErrors.length > 0;
685
+ const symbol = failed
686
+ ? styler('red', SYMBOLS.error)
687
+ : styler('green', SYMBOLS.ok);
688
+ const headline = failed
689
+ ? `build failed after ${formatDuration(durationMs)}`
690
+ : `built in ${formatDuration(durationMs)}`;
691
+
692
+ const lines = [
693
+ `${INDENT}${symbol} ${headline}${styler('gray', `${SEPARATOR}watching ${outDir}`)}`,
694
+ ...renderProblems(
695
+ snapshot,
696
+ projectDir,
697
+ styler,
698
+ sourceGlob,
699
+ assetRows,
700
+ importErrors,
701
+ syntaxErrors,
702
+ ),
703
+ '',
704
+ ];
705
+
706
+ return lines;
707
+ }
708
+
709
+ /**
710
+ * Render the compact line printed after each watch rebuild.
711
+ *
712
+ * @param {{
713
+ * snapshot: object,
714
+ * durationMs: number,
715
+ * changedFiles?: string[],
716
+ * projectDir?: string,
717
+ * styler: (format: string|string[], text: string) => string,
718
+ * now?: Date
719
+ * }} options - Rebuild inputs.
720
+ * @returns {string[]} Rebuild lines.
721
+ */
722
+ export function renderRebuild({
723
+ snapshot,
724
+ durationMs,
725
+ changedFiles = [],
726
+ projectDir = '',
727
+ styler,
728
+ now = new Date(),
729
+ }) {
730
+ const failed = snapshot.errors.length > 0;
731
+ const [firstChange] = changedFiles;
732
+ const changeLabel =
733
+ changedFiles.length > 1
734
+ ? `${displayLocation(firstChange, undefined, projectDir)} +${changedFiles.length - 1}`
735
+ : firstChange
736
+ ? displayLocation(firstChange, undefined, projectDir)
737
+ : 'sources';
738
+
739
+ const outcome = failed
740
+ ? styler('red', `rebuild failed after ${formatDuration(durationMs)}`)
741
+ : styler('gray', `rebuilt in ${formatDuration(durationMs)}`);
742
+
743
+ const symbol = failed
744
+ ? styler('red', SYMBOLS.error)
745
+ : styler('gray', SYMBOLS.change);
746
+
747
+ const lines = [
748
+ `${INDENT}${styler('gray', formatClockTime(now))} ${symbol} ${changeLabel}${styler('gray', SEPARATOR)}${outcome}`,
749
+ ];
750
+
751
+ // Repeating the deprecation tally on every keystroke would recreate the noise
752
+ // this reporter exists to remove, so rebuilds only surface hard failures.
753
+ if (failed) {
754
+ lines.push(...renderDetailRows(snapshot.errors, projectDir, styler));
755
+ }
756
+
757
+ return lines;
758
+ }