@emulsify/core 4.3.2 → 4.5.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 (54) hide show
  1. package/.storybook/main-static-assets.js +5 -8
  2. package/.storybook/main-vite.js +11 -3
  3. package/README.md +14 -6
  4. package/config/a11y-wcag22.js +11 -0
  5. package/config/vite/entries.js +7 -2
  6. package/config/vite/environment.js +4 -0
  7. package/config/vite/plugins/assets/asset-url-rebase.js +241 -0
  8. package/config/vite/plugins/assets/copy-src-assets.js +82 -12
  9. package/config/vite/plugins/assets/copy-twig-files.js +85 -16
  10. package/config/vite/plugins/assets/css-asset-rebase.js +306 -0
  11. package/config/vite/plugins/assets/css-asset-relativizer.js +301 -21
  12. package/config/vite/plugins/assets/development-source-maps.js +273 -0
  13. package/config/vite/plugins/assets/mirror-components.js +98 -82
  14. package/config/vite/plugins/assets/output-freshness.js +235 -0
  15. package/config/vite/plugins/assets/source-file-index.js +13 -13
  16. package/config/vite/plugins/assets/stable-watch-output.js +165 -0
  17. package/config/vite/plugins/assets/storybook-output.js +27 -0
  18. package/config/vite/plugins/index.js +95 -9
  19. package/config/vite/plugins/reporter/asset-resolver.js +34 -6
  20. package/config/vite/plugins/reporter/build-errors.js +7 -3
  21. package/config/vite/plugins/reporter/diagnostics.js +140 -10
  22. package/config/vite/plugins/reporter/index.js +380 -75
  23. package/config/vite/plugins/reporter/render.js +297 -44
  24. package/config/vite/plugins/reporter/sass-logger.js +30 -0
  25. package/config/vite/plugins/reporter/source-roots.js +101 -21
  26. package/config/vite/plugins/reporter/strict-mode.js +99 -0
  27. package/config/vite/plugins/reporter/vite-logger.js +220 -8
  28. package/config/vite/plugins/reporter/watch-mode.js +6 -2
  29. package/config/vite/plugins/twig/twig-module.js +35 -258
  30. package/config/vite/plugins/twig/virtual-twig-asset-sources.js +48 -49
  31. package/config/vite/project-config.js +121 -21
  32. package/config/vite/project-structure.js +6 -0
  33. package/config/vite/utils/asset-roots.js +205 -0
  34. package/config/vite/utils/css-urls.js +350 -0
  35. package/config/vite/utils/fs-safe.js +38 -1
  36. package/config/vite/utils/source-directory-skips.js +13 -0
  37. package/config/vite/utils/source-maps.js +88 -0
  38. package/config/vite/utils/twig-component-resolver.js +316 -0
  39. package/config/vite/vite.config.js +106 -42
  40. package/package.json +54 -40
  41. package/scripts/a11y.js +88 -9
  42. package/scripts/audit/checks/css-asset-references.js +256 -24
  43. package/scripts/audit/checks/twig-references.js +16 -5
  44. package/scripts/audit/fix.js +836 -0
  45. package/scripts/audit/index.js +10 -2
  46. package/scripts/audit/lib/css.js +41 -35
  47. package/scripts/audit/lib/story-ast.js +392 -0
  48. package/scripts/audit/lib/story-render-paths.js +600 -0
  49. package/scripts/audit/lib/story-selection.js +190 -0
  50. package/scripts/audit/lib/twig.js +372 -80
  51. package/scripts/audit/report.js +83 -5
  52. package/scripts/audit-twig-stories.js +73 -3
  53. package/scripts/audit.js +87 -2
  54. package/src/storybook/twig/source-function.js +14 -10
@@ -16,13 +16,12 @@
16
16
  * - `apply: 'build'` excludes Storybook's dev server, which resolves with
17
17
  * `command: 'serve'`. This mirrors the existing guard on the component
18
18
  * mirror plugin.
19
- * - A `config.build.watch` check excludes `storybook build`, `npm run build`,
20
- * and every fixture verification, all of which run a one-shot production
21
- * build where `--watch` is absent. Those commands keep their current output
22
- * byte for byte.
19
+ * - A `config.build.watch` check selects the full develop summary. One-shot
20
+ * production builds remain silent unless the collector has an asset problem
21
+ * or a Sass tally that would otherwise be hidden.
23
22
  *
24
- * The net effect is that the reporter only speaks for the long-running watcher
25
- * started by `develop`, and Storybook keeps printing its own ready box.
23
+ * Storybook dev stays out of the reporter path and keeps its own ready box;
24
+ * standalone Storybook builds receive the compact diagnostics they need.
26
25
  */
27
26
 
28
27
  import { join, relative } from 'node:path';
@@ -37,17 +36,37 @@ import {
37
36
  sharedMissingDirectory,
38
37
  } from './asset-resolver.js';
39
38
  import { classifyBuildError } from './build-errors.js';
40
- import { renderBanner, renderRebuild, renderSummary } from './render.js';
41
- import { createStyler, supportsColor, supportsUnicode } from './format.js';
39
+ import {
40
+ hasCycleFailure,
41
+ renderAssetSummary,
42
+ renderBanner,
43
+ renderDeprecationSummary,
44
+ renderRebuild,
45
+ renderSummary,
46
+ } from './render.js';
47
+ import {
48
+ createStyler,
49
+ displayPath,
50
+ pluralize,
51
+ supportsColor,
52
+ supportsUnicode,
53
+ } from './format.js';
42
54
  import {
43
55
  buildInputFileRows,
44
56
  buildInputRows,
45
57
  buildOutputFileRows,
58
+ buildOutputSummaryRows,
46
59
  diffFingerprints,
47
60
  fingerprintBundle,
61
+ isReportableOutput,
48
62
  summarizeBundle,
49
63
  watchedRootLabel,
50
64
  } from './source-roots.js';
65
+ import {
66
+ STRICTNESS,
67
+ countStrictAssetFailures,
68
+ resolveAssetStrictness,
69
+ } from './strict-mode.js';
51
70
  import { isDetailed } from './verbosity.js';
52
71
  import { resolvePackageVersion } from '../../utils/package-version.js';
53
72
 
@@ -85,6 +104,132 @@ export function countEntries(input) {
85
104
  return undefined;
86
105
  }
87
106
 
107
+ /**
108
+ * Render one strict-mode asset failure with the stylesheet that imported it.
109
+ *
110
+ * @param {{url: string, rewritten?: string, importer?: string}} entry - Asset diagnostic.
111
+ * @param {string} [projectDir] - Project root used to shorten the importer.
112
+ * @returns {string} One actionable failure line.
113
+ */
114
+ const strictAssetFailureLine = (entry, projectDir) => {
115
+ const rewrite = entry.rewritten ? ` -> ${entry.rewritten}` : '';
116
+ return ` ${entry.url}${rewrite} (imported by ${displayPath(entry.importer, projectDir)})`;
117
+ };
118
+
119
+ /**
120
+ * Explain every CSS asset URL that strict mode is rejecting.
121
+ *
122
+ * Level 1 includes only URLs that remained unresolved. Level 2 also includes
123
+ * URLs repaired by the build and ambiguous asset-root matches; keeping those
124
+ * in separate sections avoids describing a successful repair as unresolved.
125
+ *
126
+ * @param {{unresolvedAssets?: object[], assetRebases?: object[]}} snapshot - Diagnostics snapshot.
127
+ * @param {string} strictness - One of {@link STRICTNESS}.
128
+ * @param {string} [projectDir] - Project root used to shorten importers.
129
+ * @param {Array<{where?: string, url?: string}>} [assetRows] - Resolved source locations for notices without an importer.
130
+ * @returns {string} Complete build failure message.
131
+ */
132
+ const strictAssetFailureMessage = (
133
+ snapshot,
134
+ strictness,
135
+ projectDir,
136
+ assetRows = [],
137
+ ) => {
138
+ const unresolved = snapshot.unresolvedAssets || [];
139
+ const rebases =
140
+ strictness === STRICTNESS.all ? snapshot.assetRebases || [] : [];
141
+ const repaired = rebases.filter((entry) => entry.status === 'rebased');
142
+ const ambiguous = rebases.filter((entry) => entry.status === 'ambiguous');
143
+ const lines = ['Emulsify: strict CSS asset checks failed.'];
144
+
145
+ if (unresolved.length) {
146
+ const unresolvedLines = unresolved.flatMap((entry) => {
147
+ if (entry.importer) return [strictAssetFailureLine(entry, projectDir)];
148
+
149
+ const sourceRows = assetRows.filter(
150
+ (row) => row.url === entry.url && row.where && row.where !== '—',
151
+ );
152
+ if (!sourceRows.length) {
153
+ return [strictAssetFailureLine(entry, projectDir)];
154
+ }
155
+
156
+ return sourceRows.map((row) =>
157
+ strictAssetFailureLine({ ...entry, importer: row.where }, projectDir),
158
+ );
159
+ });
160
+
161
+ lines.push(
162
+ `${pluralize(unresolved.length, 'CSS asset URL')} did not resolve:`,
163
+ ...unresolvedLines,
164
+ );
165
+ }
166
+
167
+ if (repaired.length) {
168
+ lines.push(
169
+ `${pluralize(repaired.length, 'CSS asset URL')} ${repaired.length === 1 ? 'was' : 'were'} repaired during the build:`,
170
+ ...repaired.map((entry) => strictAssetFailureLine(entry, projectDir)),
171
+ );
172
+ }
173
+
174
+ if (ambiguous.length) {
175
+ lines.push(
176
+ `${pluralize(ambiguous.length, 'CSS asset URL')} matched multiple asset roots:`,
177
+ ...ambiguous.map((entry) => strictAssetFailureLine(entry, projectDir)),
178
+ );
179
+ }
180
+
181
+ lines.push('Set EMULSIFY_STRICT_ASSETS=0 to report without failing.');
182
+ return lines.join('\n');
183
+ };
184
+
185
+ /**
186
+ * Merge filesystem copy events into Rollup's bundle-derived output diff.
187
+ *
188
+ * Twig, component metadata, and static source assets are written by plugins
189
+ * after Rollup has produced its bundle, so they can never appear in that
190
+ * bundle's fingerprints. The event map is last-write-wins per path: a future
191
+ * safe prune can mark a path removed, while a later write in the same cycle
192
+ * can restore it.
193
+ *
194
+ * @param {Array<{fileName: string, bytes?: number, gzipBytes?: number}>} changed - Bundle outputs whose bytes changed.
195
+ * @param {string[]} removed - Bundle outputs no longer present.
196
+ * @param {Map<string, {kind: 'written'|'removed', bytes?: number}>} copyChanges - Successful copied-output mutations.
197
+ * @returns {{changedOutputs: Array<{fileName: string, bytes?: number, gzipBytes?: number}>, removedOutputs: string[]}} Combined output diff.
198
+ */
199
+ const mergeCopiedOutputChanges = (changed, removed, copyChanges) => {
200
+ const changedByName = new Map(
201
+ changed.map((entry) => [entry.fileName, entry]),
202
+ );
203
+ const removedNames = new Set(removed);
204
+
205
+ for (const [fileName, change] of copyChanges) {
206
+ if (!isReportableOutput(fileName)) continue;
207
+
208
+ if (change?.kind === 'removed') {
209
+ changedByName.delete(fileName);
210
+ removedNames.add(fileName);
211
+ continue;
212
+ }
213
+
214
+ if (change?.kind !== 'written') continue;
215
+
216
+ removedNames.delete(fileName);
217
+ changedByName.set(fileName, {
218
+ fileName,
219
+ bytes: Number.isFinite(change.bytes) ? change.bytes : undefined,
220
+ });
221
+ }
222
+
223
+ return {
224
+ changedOutputs: [...changedByName.values()].sort((a, b) => {
225
+ const aBytes = Number.isFinite(a.bytes) ? a.bytes : -1;
226
+ const bBytes = Number.isFinite(b.bytes) ? b.bytes : -1;
227
+ return bBytes - aBytes || a.fileName.localeCompare(b.fileName, 'en');
228
+ }),
229
+ removedOutputs: [...removedNames].sort((a, b) => a.localeCompare(b, 'en')),
230
+ };
231
+ };
232
+
88
233
  /**
89
234
  * Create the Emulsify develop reporter plugin.
90
235
  *
@@ -96,8 +241,13 @@ export function countEntries(input) {
96
241
  * clock?: () => Date,
97
242
  * colorEnabled?: boolean,
98
243
  * detailed?: boolean,
244
+ * unchangedOutputs?: Set<string>,
245
+ * copiedOutputChanges?: Map<string, {kind: 'written'|'removed', bytes?: number}>,
99
246
  * version?: string
100
- * }} options - Plugin options.
247
+ * }} options - Plugin options. `unchangedOutputs` carries the files the stable
248
+ * output plugin dropped from this cycle's bundle because the bytes on disk
249
+ * already matched. `copiedOutputChanges` carries successful writes and
250
+ * removals that never enter Rollup's bundle.
101
251
  * @returns {import('vite').PluginOption} Develop reporter plugin.
102
252
  */
103
253
  export function developReporterPlugin({
@@ -109,6 +259,9 @@ export function developReporterPlugin({
109
259
  colorEnabled,
110
260
  unicodeEnabled,
111
261
  detailed,
262
+ strictness,
263
+ unchangedOutputs = new Set(),
264
+ copiedOutputChanges = new Map(),
112
265
  version,
113
266
  } = {}) {
114
267
  const styler = createStyler(
@@ -117,21 +270,29 @@ export function developReporterPlugin({
117
270
  const unicode =
118
271
  unicodeEnabled === undefined ? supportsUnicode() : unicodeEnabled;
119
272
  const verbose = detailed === undefined ? isDetailed() : detailed;
273
+ const strictLevel =
274
+ strictness === undefined ? resolveAssetStrictness() : strictness;
120
275
 
121
276
  let watching = false;
277
+ let oneShot = false;
278
+ let oneShotPrinted = false;
279
+ let oneShotAssetRows = [];
122
280
  let outDir = 'dist';
281
+ let outputRows = [];
123
282
  let inputRows = [];
124
283
  let inputFiles = [];
125
284
  let watchLabel;
126
285
  let cycleStart = 0;
127
286
  let cyclePrinted = true;
128
287
  let firstCycleComplete = false;
288
+ let previousCycleFailed = false;
129
289
  let changedFiles = [];
130
290
  let writeTally;
131
291
  let outputFiles = [];
132
292
  let changedOutputs = [];
133
293
  let removedOutputs = [];
134
294
  let fingerprints = new Map();
295
+ let outputNames = new Set();
135
296
  let transformedModules = new Set();
136
297
 
137
298
  /**
@@ -191,6 +352,47 @@ export function developReporterPlugin({
191
352
 
192
353
  const snapshot = diagnostics.snapshot();
193
354
  const durationMs = now() - cycleStart;
355
+ const failed = hasCycleFailure(snapshot);
356
+
357
+ // Enriching costs a filesystem read per stylesheet, so it only runs when
358
+ // the cycle actually produced something to attribute. A resolver is built
359
+ // per cycle so its read cache never serves stale contents after an edit.
360
+ //
361
+ // Rebuilds need this too, not just the first build: a rebuild that fails on
362
+ // a missing Sass import has to name the import, and that attribution is
363
+ // what turns "rebuild failed" into something actionable.
364
+ const needsResolver =
365
+ snapshot.unresolvedAssets.length > 0 ||
366
+ snapshot.importErrors.length > 0 ||
367
+ snapshot.syntaxErrors.length > 0;
368
+ const resolver = needsResolver ? createAssetResolver(env) : undefined;
369
+
370
+ const assetRows = resolver
371
+ ? buildAssetRows(snapshot.unresolvedAssets, resolver)
372
+ : [];
373
+
374
+ const importRows = resolver
375
+ ? buildImportRows(snapshot.importErrors, resolver, env.projectDir)
376
+ : [];
377
+ const sharedDirectory = sharedMissingDirectory(importRows, env.projectDir);
378
+ const importErrors = {
379
+ rows: importRows,
380
+ sharedDirectory,
381
+ directoryExists: Boolean(
382
+ sharedDirectory &&
383
+ env.projectDir &&
384
+ existsSync(join(env.projectDir, sharedDirectory)),
385
+ ),
386
+ };
387
+
388
+ // The minifier reports against generated CSS, so the only route back to
389
+ // a source file is searching for the literals in the offending rule.
390
+ const syntaxErrors = resolver
391
+ ? snapshot.syntaxErrors.map((error) => ({
392
+ ...error,
393
+ lead: findLikelySource(error.declaration, resolver),
394
+ }))
395
+ : snapshot.syntaxErrors;
194
396
 
195
397
  if (firstCycleComplete) {
196
398
  emit(
@@ -199,64 +401,35 @@ export function developReporterPlugin({
199
401
  durationMs,
200
402
  changedFiles,
201
403
  projectDir: env.projectDir,
404
+ outDir,
405
+ sourceGlob: resolveSourceGlob(env),
406
+ assetRows,
407
+ importErrors,
408
+ syntaxErrors,
409
+ recovered: previousCycleFailed && !failed,
202
410
  moduleCount: verbose ? transformedModules.size : undefined,
203
411
  changedOutputs,
204
412
  removedOutputs,
205
413
  detailed: verbose,
414
+ unicode,
206
415
  styler,
207
416
  now: clock(),
208
417
  }),
209
418
  );
210
419
  } else {
211
420
  firstCycleComplete = true;
212
- // Enriching costs a filesystem read per stylesheet, so it only runs when
213
- // the build actually produced unresolved URLs. A resolver is built per
214
- // cycle so its read cache never serves stale contents after an edit.
215
- const needsResolver =
216
- snapshot.unresolvedAssets.length > 0 ||
217
- snapshot.importErrors.length > 0 ||
218
- snapshot.syntaxErrors.length > 0;
219
- const resolver = needsResolver ? createAssetResolver(env) : undefined;
220
-
221
- const assetRows = resolver
222
- ? buildAssetRows(snapshot.unresolvedAssets, resolver)
223
- : [];
224
-
225
- const importRows = resolver
226
- ? buildImportRows(snapshot.importErrors, resolver, env.projectDir)
227
- : [];
228
- const sharedDirectory = sharedMissingDirectory(
229
- importRows,
230
- env.projectDir,
231
- );
232
-
233
- // The minifier reports against generated CSS, so the only route back to
234
- // a source file is searching for the literals in the offending rule.
235
- const syntaxErrors = resolver
236
- ? snapshot.syntaxErrors.map((error) => ({
237
- ...error,
238
- lead: findLikelySource(error.declaration, resolver),
239
- }))
240
- : snapshot.syntaxErrors;
241
421
 
242
422
  emit(
243
423
  renderSummary({
244
424
  snapshot,
245
425
  durationMs,
246
426
  outDir,
427
+ outputRows,
247
428
  projectDir: env.projectDir,
248
429
  sourceGlob: resolveSourceGlob(env),
249
430
  assetRows,
250
431
  syntaxErrors,
251
- importErrors: {
252
- rows: importRows,
253
- sharedDirectory,
254
- directoryExists: Boolean(
255
- sharedDirectory &&
256
- env.projectDir &&
257
- existsSync(join(env.projectDir, sharedDirectory)),
258
- ),
259
- },
432
+ importErrors,
260
433
  platform: env.platform,
261
434
  inputRows,
262
435
  watchLabel,
@@ -269,9 +442,59 @@ export function developReporterPlugin({
269
442
  );
270
443
  }
271
444
 
445
+ previousCycleFailed = failed;
272
446
  changedFiles = [];
273
447
  };
274
448
 
449
+ /**
450
+ * Report collected diagnostics after a one-shot build.
451
+ *
452
+ * Watch builds get the full per-cycle summary; a one-shot `vite build` or
453
+ * `storybook build` gets this and nothing else, and only when there is
454
+ * something to say. It covers both captured asset diagnostics and Sass
455
+ * deprecations swallowed by Storybook's quiet logger.
456
+ *
457
+ * @returns {void}
458
+ */
459
+ const reportOneShot = () => {
460
+ if (!oneShot || oneShotPrinted) return;
461
+ oneShotPrinted = true;
462
+
463
+ const snapshot = diagnostics.snapshot();
464
+ const rebases = snapshot.assetRebases || [];
465
+ const deprecations = snapshot.deprecations || [];
466
+ if (
467
+ !snapshot.unresolvedAssets.length &&
468
+ !rebases.length &&
469
+ !deprecations.length
470
+ ) {
471
+ return;
472
+ }
473
+
474
+ // Enriching walks the project, so it only runs when there is an unresolved
475
+ // URL to attribute. Rebase records already carry their own importer.
476
+ const resolver = snapshot.unresolvedAssets.length
477
+ ? createAssetResolver(env)
478
+ : undefined;
479
+ oneShotAssetRows = resolver
480
+ ? buildAssetRows(snapshot.unresolvedAssets, resolver)
481
+ : [];
482
+
483
+ emit([
484
+ ...renderAssetSummary({
485
+ assetRows: oneShotAssetRows,
486
+ rebases,
487
+ styler,
488
+ }),
489
+ ...renderDeprecationSummary({
490
+ snapshot,
491
+ projectDir: env.projectDir,
492
+ sourceGlob: resolveSourceGlob(env),
493
+ styler,
494
+ }),
495
+ ]);
496
+ };
497
+
275
498
  return {
276
499
  name: 'emulsify-develop-reporter',
277
500
 
@@ -284,9 +507,17 @@ export function developReporterPlugin({
284
507
 
285
508
  configResolved(config) {
286
509
  watching = Boolean(config.build?.watch);
510
+ // Everything below is watch-only setup. A one-shot build stays silent
511
+ // unless it has an asset problem or a collected Sass tally to report.
512
+ oneShot = !watching;
287
513
  if (!watching) return;
288
514
 
289
515
  outDir = config.build?.outDir || 'dist';
516
+ outputRows = buildOutputSummaryRows({
517
+ outDir,
518
+ mirrorComponentOutput: env.projectStructure?.mirrorComponentOutput,
519
+ componentOutput: env.projectStructure?.output?.components,
520
+ });
290
521
 
291
522
  // Attribution is resolved here, from the entry map the config already
292
523
  // carries, so the summary can name each source root and what it
@@ -331,6 +562,7 @@ export function developReporterPlugin({
331
562
  transformedModules = new Set();
332
563
  changedOutputs = [];
333
564
  removedOutputs = [];
565
+ copiedOutputChanges.clear();
334
566
  },
335
567
 
336
568
  // Rolldown's own `N modules transformed.` count is unavailable here by
@@ -375,43 +607,116 @@ export function developReporterPlugin({
375
607
  // only hook that receives it. Raising `logLevel` to quiet the develop loop
376
608
  // discards Rolldown's per-file asset table, and this recovers the three
377
609
  // facts from it worth keeping.
378
- writeBundle(_options, bundle) {
379
- if (watching) {
380
- writeTally = summarizeBundle(bundle);
381
-
382
- if (verbose) {
383
- // Rollup regenerates the whole bundle every cycle, so the first build
384
- // lists everything and later cycles list only what came out different.
385
- // Gzip is computed for the full listing once, then only for the files
386
- // a rebuild changed — which is what keeps the watch loop from paying
387
- // Rolldown's `computing gzip size...` pause on every keystroke.
388
- const current = fingerprintBundle(bundle);
389
-
390
- if (firstCycleComplete) {
391
- const diff = diffFingerprints(fingerprints, current);
392
- const changed = new Set(diff.changed);
393
-
394
- changedOutputs = buildOutputFileRows(
395
- Object.fromEntries(
396
- Object.entries(bundle).filter(([fileName]) =>
397
- changed.has(fileName),
398
- ),
399
- ),
610
+ writeBundle: {
611
+ // Copy and mirror hooks publish outside Rollup's bundle. Waiting for all
612
+ // normal-order hooks makes their filesystem events and diagnostics a
613
+ // hard input to this cycle's verdict, even if a producer later becomes
614
+ // asynchronous.
615
+ order: 'post',
616
+ sequential: true,
617
+ handler(_options, bundle) {
618
+ if (watching) {
619
+ writeTally = summarizeBundle(bundle);
620
+ outputRows = buildOutputSummaryRows({
621
+ bundle,
622
+ outDir,
623
+ mirrorComponentOutput: env.projectStructure?.mirrorComponentOutput,
624
+ componentOutput: env.projectStructure?.output?.components,
625
+ });
626
+
627
+ // Quiet mode does not hash output contents, but it still keeps the
628
+ // path set needed to identify removals. Carry stable skipped outputs
629
+ // forward just as the detailed fingerprint diff does below.
630
+ if (!verbose) {
631
+ const currentOutputNames = new Set(
632
+ Object.keys(bundle || {}).filter(isReportableOutput),
400
633
  );
401
- removedOutputs = diff.removed;
402
- } else {
403
- outputFiles = buildOutputFileRows(bundle);
634
+ for (const fileName of unchangedOutputs) {
635
+ if (!isReportableOutput(fileName)) continue;
636
+ if (outputNames.has(fileName)) currentOutputNames.add(fileName);
637
+ }
638
+
639
+ if (firstCycleComplete) {
640
+ removedOutputs = [...outputNames].filter(
641
+ (fileName) => !currentOutputNames.has(fileName),
642
+ );
643
+ }
644
+ outputNames = currentOutputNames;
404
645
  }
405
646
 
406
- fingerprints = current;
647
+ if (verbose) {
648
+ // Rollup regenerates the whole bundle every cycle, so the first
649
+ // build lists everything and later cycles list only what came out
650
+ // different. Gzip is computed for the full listing once, then only
651
+ // for the files a rebuild changed — which is what keeps the watch
652
+ // loop from paying Rolldown's `computing gzip size...` pause on
653
+ // every keystroke.
654
+ const current = fingerprintBundle(bundle);
655
+
656
+ // A file the stable-output plugin dropped is still on disk with the
657
+ // same bytes, so carry its fingerprint forward. Without this the
658
+ // diff below sees it missing from the bundle and calls it removed.
659
+ for (const fileName of unchangedOutputs) {
660
+ if (!isReportableOutput(fileName)) continue;
661
+ const previous = fingerprints.get(fileName);
662
+ if (previous !== undefined) current.set(fileName, previous);
663
+ }
664
+
665
+ if (firstCycleComplete) {
666
+ const diff = diffFingerprints(fingerprints, current);
667
+ const changed = new Set(diff.changed);
668
+
669
+ changedOutputs = buildOutputFileRows(
670
+ Object.fromEntries(
671
+ Object.entries(bundle).filter(([fileName]) =>
672
+ changed.has(fileName),
673
+ ),
674
+ ),
675
+ );
676
+ removedOutputs = diff.removed;
677
+ } else {
678
+ outputFiles = buildOutputFileRows(bundle);
679
+ }
680
+
681
+ fingerprints = current;
682
+ }
683
+
684
+ ({ changedOutputs, removedOutputs } = mergeCopiedOutputChanges(
685
+ changedOutputs,
686
+ removedOutputs,
687
+ copiedOutputChanges,
688
+ ));
407
689
  }
408
- }
409
690
 
410
- reportCycle();
691
+ reportCycle();
692
+ },
411
693
  },
412
694
 
413
695
  closeBundle() {
414
696
  reportCycle();
697
+ // Printed from here, not writeBundle, so the block lands after Rolldown's
698
+ // own asset table rather than in the middle of it.
699
+ reportOneShot();
700
+
701
+ const snapshot = diagnostics.snapshot();
702
+ const strictFailures = countStrictAssetFailures(snapshot, strictLevel);
703
+ if (!oneShot || strictLevel === STRICTNESS.off || !strictFailures) return;
704
+
705
+ // Not `process.exitCode`: `storybook build` ends with `process.exit(0)`
706
+ // in a commander postAction hook, which discards it. Rejecting the build
707
+ // is the only signal both runners honor. `closeBundle` also runs after
708
+ // mirrorComponentsToRoot's writeBundle, so a strict failure can never
709
+ // leave a half-mirrored tree behind.
710
+ const error = new Error(
711
+ strictAssetFailureMessage(
712
+ snapshot,
713
+ strictLevel,
714
+ env.projectDir,
715
+ oneShotAssetRows,
716
+ ),
717
+ );
718
+ error.stack = error.message;
719
+ throw error;
415
720
  },
416
721
  };
417
722
  }