@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
@@ -152,6 +152,7 @@ const FACT_LABELS = {
152
152
  * platform?: string,
153
153
  * inputRows?: Array<{name: string, path: string, count: number}>,
154
154
  * outDir?: string,
155
+ * outputRows?: Array<{path: string, write?: {fileCount: number, totalBytes: number, largest?: {fileName: string, bytes: number}}}>,
155
156
  * write?: {fileCount: number, totalBytes: number, largest?: {fileName: string, bytes: number}},
156
157
  * styler: (format: string|string[], text: string) => string
157
158
  * }} options - Facts inputs.
@@ -161,6 +162,7 @@ export function renderFacts({
161
162
  platform,
162
163
  inputRows = [],
163
164
  outDir = 'dist',
165
+ outputRows = [],
164
166
  write,
165
167
  styler,
166
168
  }) {
@@ -211,24 +213,59 @@ export function renderFacts({
211
213
  });
212
214
  }
213
215
 
214
- const outputFacts = [];
215
- if (write) {
216
- outputFacts.push(pluralize(write.fileCount, 'file'));
217
- outputFacts.push(formatBytes(write.totalBytes));
216
+ /**
217
+ * Render the numeric facts following one output path.
218
+ *
219
+ * @param {{fileCount: number, totalBytes: number, largest?: {fileName: string, bytes: number}}|undefined} summary - Bundle tally.
220
+ * @param {boolean} includeLargest - Whether to name the largest file.
221
+ * @returns {string} Styled facts with their leading column gap.
222
+ */
223
+ const outputSuffix = (summary, includeLargest = true) => {
224
+ if (!summary) return '';
225
+
226
+ const facts = [
227
+ pluralize(summary.fileCount, 'file'),
228
+ formatBytes(summary.totalBytes),
229
+ ];
218
230
 
219
- if (write.largest) {
220
- outputFacts.push(
221
- `largest ${write.largest.fileName} ${formatBytes(write.largest.bytes)}`,
231
+ if (includeLargest && summary.largest) {
232
+ facts.push(
233
+ `largest ${summary.largest.fileName} ${formatBytes(summary.largest.bytes)}`,
222
234
  );
223
235
  }
224
- }
225
236
 
226
- const outputSuffix =
227
- outputFacts.length > 0
228
- ? styler('gray', ` ${outputFacts.join(SEPARATOR)}`)
229
- : '';
237
+ return styler('gray', ` ${facts.join(SEPARATOR)}`);
238
+ };
230
239
 
231
- lines.push(row(FACT_LABELS.output, `${outDir}${outputSuffix}`));
240
+ const destinations =
241
+ outputRows.length > 0 ? outputRows : [{ path: outDir, write }];
242
+ const hasTotal = destinations.length > 1 && Boolean(write);
243
+ const pathWidth = Math.max(
244
+ ...destinations.map((entry) => entry.path.length),
245
+ hasTotal ? 'total'.length : 0,
246
+ );
247
+
248
+ destinations.forEach((entry, index) => {
249
+ const destinationWrite =
250
+ destinations.length === 1 && !entry.write ? write : entry.write;
251
+ const path = styler('cyan', entry.path.padEnd(pathWidth));
252
+
253
+ lines.push(
254
+ row(
255
+ index === 0 ? FACT_LABELS.output : '',
256
+ `${path}${outputSuffix(destinationWrite)}`,
257
+ ),
258
+ );
259
+ });
260
+
261
+ if (hasTotal) {
262
+ lines.push(
263
+ row(
264
+ '',
265
+ `${styler('gray', 'total'.padEnd(pathWidth))}${outputSuffix(write, false)}`,
266
+ ),
267
+ );
268
+ }
232
269
 
233
270
  return lines;
234
271
  }
@@ -405,7 +442,7 @@ function renderDetailRows(entries, projectDir, styler) {
405
442
  const hidden = entries.length - MAX_DETAIL_ROWS;
406
443
  if (hidden > 0) {
407
444
  lines.push(
408
- `${DETAIL_INDENT}${styler('gray', `+${pluralize(hidden, 'more')}`)}`,
445
+ `${DETAIL_INDENT}${styler('gray', `+${pluralize(hidden, 'more', 'more')}`)}`,
409
446
  );
410
447
  }
411
448
 
@@ -432,16 +469,27 @@ function renderDeprecations(snapshot, projectDir, styler, sourceGlob) {
432
469
  const { deprecations, deprecationsByFile, deprecationTotal } = snapshot;
433
470
  if (deprecations.length === 0) return [];
434
471
 
435
- const headline = [
436
- pluralize(deprecationTotal, 'sass deprecation'),
437
- pluralize(deprecationsByFile.length || 1, 'file'),
438
- ].join(SEPARATOR);
472
+ const total = pluralize(deprecationTotal, 'sass deprecation');
473
+ const headline = deprecationsByFile.length
474
+ ? [total, pluralize(deprecationsByFile.length, 'file')].join(SEPARATOR)
475
+ : total;
439
476
 
440
477
  const lines = [
441
478
  `${INDENT}${styler('yellow', SYMBOLS.warning)} ${styler('yellow', headline)}`,
442
- '',
443
479
  ];
444
480
 
481
+ // Sass can report a deprecation without a span. It still belongs in the
482
+ // total, but there is no file to open and therefore no worklist to render.
483
+ // Omitting the empty table also avoids presenting "0 files" as actionable
484
+ // detail when the collector simply had no location to group.
485
+ if (deprecationsByFile.length === 0) {
486
+ const command = renderMigratorCommand(deprecations, sourceGlob, styler);
487
+ if (command) lines.push('', command);
488
+ return lines;
489
+ }
490
+
491
+ lines.push('');
492
+
445
493
  const shownFiles = deprecationsByFile.slice(0, MAX_DEPRECATION_FILES);
446
494
 
447
495
  // Column widths are measured across every row that will be printed so the
@@ -512,6 +560,27 @@ function renderDeprecations(snapshot, projectDir, styler, sourceGlob) {
512
560
  return lines;
513
561
  }
514
562
 
563
+ /**
564
+ * Render the Sass deprecation worklist for a one-shot build.
565
+ *
566
+ * The watch summary supplies its own section spacing. A standalone build has
567
+ * no surrounding report, so this wrapper adds the leading blank line that
568
+ * keeps the tally separate from Vite's output.
569
+ *
570
+ * @param {{snapshot: object, projectDir?: string, sourceGlob?: string, styler: Function}} options - Render inputs.
571
+ * @returns {string[]} Report lines.
572
+ */
573
+ export function renderDeprecationSummary({
574
+ snapshot,
575
+ projectDir = '',
576
+ sourceGlob = 'src/**/*.scss',
577
+ styler,
578
+ }) {
579
+ const lines = renderDeprecations(snapshot, projectDir, styler, sourceGlob);
580
+
581
+ return lines.length > 0 ? ['', ...lines] : [];
582
+ }
583
+
515
584
  /**
516
585
  * Render the `sass-migrator` invocation that resolves most of the debt.
517
586
  *
@@ -664,7 +733,7 @@ function renderSyntaxErrors(errors, styler) {
664
733
  const hidden = matches.length - MAX_SOURCE_LEADS;
665
734
  if (hidden > 0) {
666
735
  lines.push(
667
- `${DETAIL_INDENT}${styler('gray', `+${pluralize(hidden, 'more')}`)}`,
736
+ `${DETAIL_INDENT}${styler('gray', `+${pluralize(hidden, 'more', 'more')}`)}`,
668
737
  );
669
738
  }
670
739
  }
@@ -734,7 +803,7 @@ function renderImportErrors(rows, sharedDirectory, directoryExists, styler) {
734
803
  const hidden = rows.length - MAX_ASSET_ROWS;
735
804
  if (hidden > 0) {
736
805
  lines.push(
737
- `${DETAIL_INDENT}${styler('gray', `+${pluralize(hidden, 'more')}`)}`,
806
+ `${DETAIL_INDENT}${styler('gray', `+${pluralize(hidden, 'more', 'more')}`)}`,
738
807
  );
739
808
  }
740
809
 
@@ -800,7 +869,7 @@ function renderUnresolvedAssets(rows, styler) {
800
869
  const hidden = rows.length - MAX_ASSET_ROWS;
801
870
  if (hidden > 0) {
802
871
  lines.push(
803
- `${DETAIL_INDENT}${styler('gray', `+${pluralize(hidden, 'more')}`)}`,
872
+ `${DETAIL_INDENT}${styler('gray', `+${pluralize(hidden, 'more', 'more')}`)}`,
804
873
  );
805
874
  }
806
875
 
@@ -815,6 +884,61 @@ function renderUnresolvedAssets(rows, styler) {
815
884
  return lines;
816
885
  }
817
886
 
887
+ /**
888
+ * Render the tally of modules Vite externalized for browser compatibility.
889
+ *
890
+ * A dependency reaching for a Node builtin is a property of that dependency,
891
+ * not of the edit just made, so this belongs with the inherited debt rather
892
+ * than in "needs attention". Vite repeats the notice once per importing file on
893
+ * every cycle; one line per module, with its occurrence count, says the same
894
+ * thing without the repetition.
895
+ *
896
+ * @param {Array<{module: string, importer?: string, count: number}>} modules - Externalized modules.
897
+ * @param {(format: string|string[], text: string) => string} styler - Styling function.
898
+ * @returns {string[]} Report lines.
899
+ */
900
+ function renderExternalizedModules(modules = [], styler) {
901
+ if (modules.length === 0) return [];
902
+
903
+ const lines = [
904
+ `${INDENT}${styler('gray', `${pluralize(modules.length, 'module')} externalized for the browser`)}`,
905
+ '',
906
+ ];
907
+
908
+ for (const entry of modules.slice(0, MAX_ASSET_ROWS)) {
909
+ const times = entry.count > 1 ? ` (${entry.count}\u00d7)` : '';
910
+ lines.push(`${DETAIL_INDENT}${styler('gray', `${entry.module}${times}`)}`);
911
+ }
912
+
913
+ const hidden = modules.length - MAX_ASSET_ROWS;
914
+ if (hidden > 0) {
915
+ lines.push(
916
+ `${DETAIL_INDENT}${styler('gray', `+${pluralize(hidden, 'more', 'more')}`)}`,
917
+ );
918
+ }
919
+
920
+ return lines;
921
+ }
922
+
923
+ /**
924
+ * Determine whether a build cycle failed.
925
+ *
926
+ * Import and syntax errors live in their own buckets rather than in
927
+ * `snapshot.errors`, so a check against errors alone reads a stylesheet that
928
+ * never compiled as a success. That is precisely how a mistyped `@use` used to
929
+ * report "rebuilt in 1.69s" while dist/ kept the previous CSS.
930
+ *
931
+ * @param {{errors?: object[], importErrors?: object[], syntaxErrors?: object[]}} snapshot - Diagnostics snapshot.
932
+ * @returns {boolean} TRUE when the cycle produced no usable output.
933
+ */
934
+ export function hasCycleFailure(snapshot = {}) {
935
+ return (
936
+ (snapshot.errors?.length || 0) > 0 ||
937
+ (snapshot.importErrors?.length || 0) > 0 ||
938
+ (snapshot.syntaxErrors?.length || 0) > 0
939
+ );
940
+ }
941
+
818
942
  /**
819
943
  * Render the problem blocks shared by first builds and rebuilds.
820
944
  *
@@ -834,6 +958,7 @@ function renderProblems(
834
958
  importErrors,
835
959
  syntaxErrors,
836
960
  unicode = true,
961
+ includeDebt = true,
837
962
  ) {
838
963
  const attention = [];
839
964
 
@@ -876,7 +1001,22 @@ function renderProblems(
876
1001
  attention.push(...assetLines);
877
1002
  }
878
1003
 
879
- const debt = renderDeprecations(snapshot, projectDir, styler, sourceGlob);
1004
+ // Rebuilds pass includeDebt: false. Restating 190 inherited deprecations on
1005
+ // every keystroke is the noise this reporter exists to remove.
1006
+ const debt = includeDebt
1007
+ ? renderDeprecations(snapshot, projectDir, styler, sourceGlob)
1008
+ : [];
1009
+
1010
+ if (includeDebt) {
1011
+ const externalized = renderExternalizedModules(
1012
+ snapshot.externalizedModules,
1013
+ styler,
1014
+ );
1015
+ if (externalized.length > 0) {
1016
+ if (debt.length > 0) debt.push('');
1017
+ debt.push(...externalized);
1018
+ }
1019
+ }
880
1020
 
881
1021
  const lines = [];
882
1022
 
@@ -1105,6 +1245,69 @@ function renderSizeTable(rows, styler) {
1105
1245
  return lines;
1106
1246
  }
1107
1247
 
1248
+ /**
1249
+ * Render the standalone CSS asset block a one-shot build prints.
1250
+ *
1251
+ * One-shot builds are silent unless something is wrong, so this omits the
1252
+ * banner and project facts. The caller may compose it with a collected Sass
1253
+ * deprecation tally; a clean project keeps its output byte for byte.
1254
+ *
1255
+ * @param {{assetRows?: Array<object>, rebases?: Array<object>, styler: Function}} options - Render inputs.
1256
+ * @returns {string[]} Report lines.
1257
+ */
1258
+ export function renderAssetSummary({ assetRows = [], rebases = [], styler }) {
1259
+ const repaired = rebases.filter((entry) => entry.status === 'rebased');
1260
+ const ambiguous = rebases.filter((entry) => entry.status === 'ambiguous');
1261
+
1262
+ if (!assetRows.length && !repaired.length && !ambiguous.length) return [];
1263
+
1264
+ const lines = [''];
1265
+
1266
+ if (repaired.length) {
1267
+ lines.push(
1268
+ `${INDENT}${styler(
1269
+ 'gray',
1270
+ `${pluralize(repaired.length, 'css asset url')} rebased to /assets/`,
1271
+ )}`,
1272
+ '',
1273
+ );
1274
+
1275
+ for (const entry of repaired.slice(0, MAX_ASSET_ROWS)) {
1276
+ lines.push(
1277
+ `${DETAIL_INDENT}${styler('gray', `${entry.url} -> ${entry.rewritten}`)}`,
1278
+ );
1279
+ }
1280
+
1281
+ const hidden = repaired.length - MAX_ASSET_ROWS;
1282
+ if (hidden > 0) {
1283
+ lines.push(
1284
+ `${DETAIL_INDENT}${styler('gray', `+${pluralize(hidden, 'more', 'more')}`)}`,
1285
+ );
1286
+ }
1287
+
1288
+ lines.push(
1289
+ '',
1290
+ `${DETAIL_INDENT}${styler(
1291
+ 'gray',
1292
+ 'run `emulsify-audit --fix` to write these canonically in source',
1293
+ )}`,
1294
+ );
1295
+ }
1296
+
1297
+ for (const entry of ambiguous) {
1298
+ lines.push(
1299
+ `${INDENT}${styler('yellow', SYMBOLS.warning)} ${styler(
1300
+ 'yellow',
1301
+ `${entry.url} matches more than one asset root`,
1302
+ )}`,
1303
+ );
1304
+ }
1305
+
1306
+ lines.push(...renderUnresolvedAssets(assetRows, styler));
1307
+
1308
+ return lines;
1309
+ }
1310
+
1108
1311
  /**
1109
1312
  * Render the summary printed after the first successful watch build.
1110
1313
  *
@@ -1116,6 +1319,7 @@ function renderSizeTable(rows, styler) {
1116
1319
  * snapshot: object,
1117
1320
  * durationMs: number,
1118
1321
  * outDir?: string,
1322
+ * outputRows?: Array<{path: string, write?: {fileCount: number, totalBytes: number, largest?: {fileName: string, bytes: number}}}>,
1119
1323
  * projectDir?: string,
1120
1324
  * sourceGlob?: string,
1121
1325
  * assetRows?: Array<object>,
@@ -1135,6 +1339,7 @@ export function renderSummary({
1135
1339
  snapshot,
1136
1340
  durationMs,
1137
1341
  outDir = 'dist',
1342
+ outputRows = [],
1138
1343
  projectDir = '',
1139
1344
  sourceGlob = 'src/**/*.scss',
1140
1345
  assetRows = [],
@@ -1149,10 +1354,7 @@ export function renderSummary({
1149
1354
  unicode = true,
1150
1355
  styler,
1151
1356
  }) {
1152
- const failed =
1153
- snapshot.errors.length > 0 ||
1154
- (importErrors.rows || []).length > 0 ||
1155
- syntaxErrors.length > 0;
1357
+ const failed = hasCycleFailure(snapshot);
1156
1358
  const symbol = failed
1157
1359
  ? styler('red', SYMBOLS.error)
1158
1360
  : styler('green', SYMBOLS.ok);
@@ -1181,7 +1383,7 @@ export function renderSummary({
1181
1383
  '',
1182
1384
  renderDivider('project', unicode, styler),
1183
1385
  '',
1184
- ...renderFacts({ platform, inputRows, outDir, write, styler }),
1386
+ ...renderFacts({ platform, inputRows, outDir, outputRows, write, styler }),
1185
1387
  // The verbose listings expand the two rows above them, so they sit directly
1186
1388
  // under the totals they itemize rather than after the build result.
1187
1389
  ...renderInputFiles(inputFiles, unicode, styler),
@@ -1220,7 +1422,7 @@ export function renderSummary({
1220
1422
  * changedFiles?: string[],
1221
1423
  * projectDir?: string,
1222
1424
  * moduleCount?: number,
1223
- * changedOutputs?: Array<{fileName: string, bytes: number, gzipBytes?: number}>,
1425
+ * changedOutputs?: Array<{fileName: string, bytes?: number, gzipBytes?: number}>,
1224
1426
  * removedOutputs?: string[],
1225
1427
  * detailed?: boolean,
1226
1428
  * styler: (format: string|string[], text: string) => string,
@@ -1233,14 +1435,21 @@ export function renderRebuild({
1233
1435
  durationMs,
1234
1436
  changedFiles = [],
1235
1437
  projectDir = '',
1438
+ outDir = 'dist',
1439
+ sourceGlob,
1440
+ assetRows = [],
1441
+ importErrors = {},
1442
+ syntaxErrors = [],
1443
+ recovered = false,
1236
1444
  moduleCount,
1237
1445
  changedOutputs = [],
1238
1446
  removedOutputs = [],
1239
1447
  detailed = false,
1448
+ unicode = true,
1240
1449
  styler,
1241
1450
  now = new Date(),
1242
1451
  }) {
1243
- const failed = snapshot.errors.length > 0;
1452
+ const failed = hasCycleFailure(snapshot);
1244
1453
  const [firstChange] = changedFiles;
1245
1454
  const changeLabel =
1246
1455
  changedFiles.length > 1
@@ -1251,27 +1460,71 @@ export function renderRebuild({
1251
1460
 
1252
1461
  const outcome = failed
1253
1462
  ? styler('red', `rebuild failed after ${formatDuration(durationMs)}`)
1254
- : styler('gray', `rebuilt in ${formatDuration(durationMs)}`);
1255
-
1463
+ : recovered
1464
+ ? styler(
1465
+ 'green',
1466
+ `recovered${SEPARATOR}rebuilt in ${formatDuration(durationMs)}`,
1467
+ )
1468
+ : styler('gray', `rebuilt in ${formatDuration(durationMs)}`);
1469
+
1470
+ // A recovered rebuild gets the success tick rather than the change caret, so
1471
+ // the save that fixed a broken build is distinguishable from an ordinary one.
1256
1472
  const symbol = failed
1257
1473
  ? styler('red', SYMBOLS.error)
1258
- : styler('gray', SYMBOLS.change);
1474
+ : recovered
1475
+ ? styler('green', SYMBOLS.ok)
1476
+ : styler('gray', SYMBOLS.change);
1259
1477
 
1260
1478
  const lines = [
1261
1479
  `${INDENT}${styler('gray', formatClockTime(now))} ${symbol} ${changeLabel}${styler('gray', SEPARATOR)}${outcome}`,
1262
1480
  ];
1263
1481
 
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
1482
  if (failed) {
1267
- lines.push(...renderDetailRows(snapshot.errors, projectDir, styler));
1483
+ // Compile failures occur before output is written, but copy and mirror
1484
+ // failures happen after Rollup has already published some files. Preserve
1485
+ // that distinction so a partially updated theme is never described as the
1486
+ // last wholly successful build.
1487
+ const outputIncomplete = snapshot.errors?.some(
1488
+ (error) => error.outputState === 'incomplete',
1489
+ );
1490
+ const outputStatus = outputIncomplete
1491
+ ? `output may be incomplete${SEPARATOR}some output may already have changed`
1492
+ : `output not updated${SEPARATOR}${outDir} still holds the last successful build`;
1493
+ lines.push(`${DETAIL_INDENT}${styler('yellow', outputStatus)}`);
1494
+
1495
+ // The same blocks the first build renders, minus the inherited deprecation
1496
+ // debt, so a rebuild failure names its cause instead of only its verdict.
1497
+ lines.push(
1498
+ ...renderProblems(
1499
+ snapshot,
1500
+ projectDir,
1501
+ styler,
1502
+ sourceGlob,
1503
+ assetRows,
1504
+ importErrors,
1505
+ syntaxErrors,
1506
+ unicode,
1507
+ false,
1508
+ ),
1509
+ );
1510
+
1511
+ if (removedOutputs.length > 0) {
1512
+ lines.push(...renderRebuildDetail({ removedOutputs }, styler));
1513
+ }
1514
+
1268
1515
  return lines;
1269
1516
  }
1270
1517
 
1271
- if (detailed)
1518
+ // Writes follow the existing detailed-mode contract. Removals are always
1519
+ // named: a destructive cycle must never collapse to a green one-line result.
1520
+ if (detailed || removedOutputs.length > 0)
1272
1521
  lines.push(
1273
1522
  ...renderRebuildDetail(
1274
- { moduleCount, changedOutputs, removedOutputs },
1523
+ {
1524
+ moduleCount: detailed ? moduleCount : undefined,
1525
+ changedOutputs: detailed ? changedOutputs : [],
1526
+ removedOutputs,
1527
+ },
1275
1528
  styler,
1276
1529
  ),
1277
1530
  );
@@ -1284,7 +1537,7 @@ export function renderRebuild({
1284
1537
  *
1285
1538
  * @param {{
1286
1539
  * moduleCount?: number,
1287
- * changedOutputs?: Array<{fileName: string, bytes: number, gzipBytes?: number}>,
1540
+ * changedOutputs?: Array<{fileName: string, bytes?: number, gzipBytes?: number}>,
1288
1541
  * removedOutputs?: string[]
1289
1542
  * }} cycle - What the rebuild produced.
1290
1543
  * @param {(format: string|string[], text: string) => string} styler - Styling function.
@@ -1299,11 +1552,11 @@ function renderRebuildDetail(
1299
1552
  facts.push(`${pluralize(moduleCount, 'module')} transformed`);
1300
1553
  }
1301
1554
 
1302
- facts.push(
1303
- changedOutputs.length === 0
1304
- ? 'no output changed'
1305
- : `${pluralize(changedOutputs.length, 'output')} changed`,
1306
- );
1555
+ if (changedOutputs.length > 0) {
1556
+ facts.push(`${pluralize(changedOutputs.length, 'output')} changed`);
1557
+ } else if (removedOutputs.length === 0) {
1558
+ facts.push('no output changed');
1559
+ }
1307
1560
 
1308
1561
  if (removedOutputs.length > 0) {
1309
1562
  facts.push(`${pluralize(removedOutputs.length, 'output')} removed`);
@@ -235,3 +235,33 @@ export function createSassOptions(collector, options = {}) {
235
235
  verbose: true,
236
236
  };
237
237
  }
238
+
239
+ /**
240
+ * Decide whether Emulsify should replace Dart Sass's own console output.
241
+ *
242
+ * Three callers resolve the shared Vite config and each needs a different
243
+ * answer, so the rule lives here rather than as a condition inside the config:
244
+ *
245
+ * - **The develop watcher** (`vite build --watch`) always takes the logger. It
246
+ * prints the deduplicated tally, once per session rather than once per
247
+ * rebuild, which is the whole point. Raw mode does not turn this off: the
248
+ * reporter is still what prints the summary, and the firehose would bury it.
249
+ * - **Storybook** resolves with `command: 'serve'` for both `storybook dev`
250
+ * and `storybook build`. During development it compiles the same source tree
251
+ * the watcher already tallied; a standalone static build prints the
252
+ * collector's deduplicated tally at completion. Without this it printed Dart
253
+ * Sass's full formatted block per occurrence, at startup and after saves.
254
+ * - **A one-shot `vite build`** keeps Dart Sass's output. Nothing else is
255
+ * running to report the debt, so that output is the only report there is.
256
+ *
257
+ * Raw verbose mode hands Storybook's firehose back, which is what asking for
258
+ * raw output means.
259
+ *
260
+ * @param {{watching?: boolean, command?: string, verbose?: boolean}} options - Invocation.
261
+ * @returns {boolean} TRUE when the quiet logger should be installed.
262
+ */
263
+ export function shouldQuietSass({ watching, command, verbose } = {}) {
264
+ if (watching) return true;
265
+
266
+ return command === 'serve' && !verbose;
267
+ }