@emulsify/core 4.3.0 → 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.
@@ -12,12 +12,15 @@ import {
12
12
  SYMBOLS,
13
13
  displayLocation,
14
14
  displayPath,
15
+ formatBytes,
15
16
  formatClockTime,
16
17
  formatDuration,
18
+ formatPreciseBytes,
17
19
  platformLabel,
18
20
  pluralize,
19
21
  } from './format.js';
20
22
  import { deprecationFix, deprecationMigrator } from './sass-logger.js';
23
+ import { sharedRootPath } from './source-roots.js';
21
24
 
22
25
  const INDENT = ' ';
23
26
  const DETAIL_INDENT = ' ';
@@ -100,36 +103,270 @@ const WORDMARK = [
100
103
  * easy to scroll past; a block of art is not. Terminals that cannot render the
101
104
  * glyphs get the plain name instead of mojibake.
102
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
+ *
103
112
  * @param {{
104
113
  * version?: string,
105
- * platform?: string,
106
- * entryCount?: number,
107
114
  * unicode?: boolean,
108
115
  * styler: (format: string|string[], text: string) => string
109
116
  * }} options - Banner inputs.
110
117
  * @returns {string[]} Banner lines.
111
118
  */
112
- export function renderBanner({
113
- version,
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({
114
161
  platform,
115
- entryCount,
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,
116
277
  unicode = true,
117
278
  styler,
118
279
  }) {
119
- const facts = [
120
- `core ${version || '0.0.0'}`,
121
- `Platform: ${platformLabel(platform)}`,
122
- ];
280
+ const drifted =
281
+ portDrift && String(portDrift.requested) !== String(portDrift.actual);
123
282
 
124
- if (Number.isFinite(entryCount)) {
125
- facts.push(pluralize(entryCount, 'entry', 'entries'));
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}`);
126
287
  }
127
288
 
128
- const mark = unicode
129
- ? WORDMARK.map((row) => `${INDENT}${styler(['bold', 'cyan'], row)}`)
130
- : [`${INDENT}${styler(['bold', 'cyan'], 'EMULSIFY')}`];
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('');
131
331
 
132
- return ['', ...mark, `${INDENT}${styler('gray', facts.join(SEPARATOR))}`, ''];
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
+ ];
133
370
  }
134
371
 
135
372
  /**
@@ -596,13 +833,14 @@ function renderProblems(
596
833
  assetRows,
597
834
  importErrors,
598
835
  syntaxErrors,
836
+ unicode = true,
599
837
  ) {
600
- const lines = [];
838
+ const attention = [];
601
839
 
602
840
  const syntaxLines = renderSyntaxErrors(syntaxErrors, styler);
603
841
  if (syntaxLines.length > 0) {
604
- lines.push('');
605
- lines.push(...syntaxLines);
842
+ attention.push('');
843
+ attention.push(...syntaxLines);
606
844
  }
607
845
 
608
846
  const importLines = renderImportErrors(
@@ -612,41 +850,256 @@ function renderProblems(
612
850
  styler,
613
851
  );
614
852
  if (importLines.length > 0) {
615
- lines.push('');
616
- lines.push(...importLines);
853
+ attention.push('');
854
+ attention.push(...importLines);
617
855
  }
618
856
 
619
857
  if (snapshot.errors.length > 0) {
620
- lines.push('');
621
- lines.push(
858
+ attention.push('');
859
+ attention.push(
622
860
  `${INDENT}${styler('red', SYMBOLS.error)} ${styler('red', pluralize(snapshot.errors.length, 'error'))}`,
623
861
  );
624
- lines.push(...renderDetailRows(snapshot.errors, projectDir, styler));
862
+ attention.push(...renderDetailRows(snapshot.errors, projectDir, styler));
625
863
  }
626
864
 
627
865
  if (snapshot.warnings.length > 0) {
628
- lines.push('');
629
- lines.push(
866
+ attention.push('');
867
+ attention.push(
630
868
  `${INDENT}${styler('yellow', SYMBOLS.warning)} ${styler('yellow', pluralize(snapshot.warnings.length, 'warning'))}`,
631
869
  );
632
- lines.push(...renderDetailRows(snapshot.warnings, projectDir, styler));
870
+ attention.push(...renderDetailRows(snapshot.warnings, projectDir, styler));
633
871
  }
634
872
 
635
873
  const assetLines = renderUnresolvedAssets(assetRows, styler);
636
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));
637
898
  lines.push('');
638
- lines.push(...assetLines);
899
+ lines.push(...debt);
639
900
  }
640
901
 
641
- const deprecationLines = renderDeprecations(
642
- snapshot,
643
- projectDir,
644
- styler,
645
- sourceGlob,
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
+ ),
646
971
  );
647
- if (deprecationLines.length > 0) {
648
- lines.push('');
649
- lines.push(...deprecationLines);
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
+ );
650
1103
  }
651
1104
 
652
1105
  return lines;
@@ -655,6 +1108,10 @@ function renderProblems(
655
1108
  /**
656
1109
  * Render the summary printed after the first successful watch build.
657
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
+ *
658
1115
  * @param {{
659
1116
  * snapshot: object,
660
1117
  * durationMs: number,
@@ -663,6 +1120,13 @@ function renderProblems(
663
1120
  * sourceGlob?: string,
664
1121
  * assetRows?: Array<object>,
665
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,
666
1130
  * styler: (format: string|string[], text: string) => string
667
1131
  * }} options - Summary inputs.
668
1132
  * @returns {string[]} Summary lines.
@@ -676,6 +1140,13 @@ export function renderSummary({
676
1140
  assetRows = [],
677
1141
  importErrors = {},
678
1142
  syntaxErrors = [],
1143
+ platform,
1144
+ inputRows = [],
1145
+ watchLabel,
1146
+ write,
1147
+ inputFiles = [],
1148
+ outputFiles = [],
1149
+ unicode = true,
679
1150
  styler,
680
1151
  }) {
681
1152
  const failed =
@@ -689,8 +1160,36 @@ export function renderSummary({
689
1160
  ? `build failed after ${formatDuration(durationMs)}`
690
1161
  : `built in ${formatDuration(durationMs)}`;
691
1162
 
692
- const lines = [
693
- `${INDENT}${symbol} ${headline}${styler('gray', `${SEPARATOR}watching ${outDir}`)}`,
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}`)}`,
694
1193
  ...renderProblems(
695
1194
  snapshot,
696
1195
  projectDir,
@@ -699,21 +1198,31 @@ export function renderSummary({
699
1198
  assetRows,
700
1199
  importErrors,
701
1200
  syntaxErrors,
1201
+ unicode,
702
1202
  ),
703
1203
  '',
704
1204
  ];
705
-
706
- return lines;
707
1205
  }
708
1206
 
709
1207
  /**
710
1208
  * Render the compact line printed after each watch rebuild.
711
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
+ *
712
1217
  * @param {{
713
1218
  * snapshot: object,
714
1219
  * durationMs: number,
715
1220
  * changedFiles?: string[],
716
1221
  * projectDir?: string,
1222
+ * moduleCount?: number,
1223
+ * changedOutputs?: Array<{fileName: string, bytes: number, gzipBytes?: number}>,
1224
+ * removedOutputs?: string[],
1225
+ * detailed?: boolean,
717
1226
  * styler: (format: string|string[], text: string) => string,
718
1227
  * now?: Date
719
1228
  * }} options - Rebuild inputs.
@@ -724,6 +1233,10 @@ export function renderRebuild({
724
1233
  durationMs,
725
1234
  changedFiles = [],
726
1235
  projectDir = '',
1236
+ moduleCount,
1237
+ changedOutputs = [],
1238
+ removedOutputs = [],
1239
+ detailed = false,
727
1240
  styler,
728
1241
  now = new Date(),
729
1242
  }) {
@@ -752,6 +1265,67 @@ export function renderRebuild({
752
1265
  // this reporter exists to remove, so rebuilds only surface hard failures.
753
1266
  if (failed) {
754
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
+ }
755
1329
  }
756
1330
 
757
1331
  return lines;