@emulsify/core 4.3.2 → 4.4.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 (44) hide show
  1. package/.storybook/main-static-assets.js +5 -8
  2. package/.storybook/main-vite.js +11 -3
  3. package/README.md +4 -5
  4. package/config/vite/entries.js +7 -2
  5. package/config/vite/environment.js +4 -0
  6. package/config/vite/plugins/assets/asset-url-rebase.js +241 -0
  7. package/config/vite/plugins/assets/copy-src-assets.js +82 -12
  8. package/config/vite/plugins/assets/copy-twig-files.js +85 -16
  9. package/config/vite/plugins/assets/css-asset-rebase.js +306 -0
  10. package/config/vite/plugins/assets/css-asset-relativizer.js +301 -21
  11. package/config/vite/plugins/assets/development-source-maps.js +273 -0
  12. package/config/vite/plugins/assets/mirror-components.js +98 -82
  13. package/config/vite/plugins/assets/output-freshness.js +235 -0
  14. package/config/vite/plugins/assets/source-file-index.js +7 -1
  15. package/config/vite/plugins/assets/stable-watch-output.js +165 -0
  16. package/config/vite/plugins/assets/storybook-output.js +27 -0
  17. package/config/vite/plugins/index.js +95 -9
  18. package/config/vite/plugins/reporter/asset-resolver.js +34 -6
  19. package/config/vite/plugins/reporter/build-errors.js +7 -3
  20. package/config/vite/plugins/reporter/diagnostics.js +140 -10
  21. package/config/vite/plugins/reporter/index.js +380 -75
  22. package/config/vite/plugins/reporter/render.js +297 -44
  23. package/config/vite/plugins/reporter/sass-logger.js +30 -0
  24. package/config/vite/plugins/reporter/source-roots.js +101 -21
  25. package/config/vite/plugins/reporter/strict-mode.js +99 -0
  26. package/config/vite/plugins/reporter/vite-logger.js +220 -8
  27. package/config/vite/plugins/reporter/watch-mode.js +6 -2
  28. package/config/vite/plugins/twig/virtual-twig-asset-sources.js +48 -49
  29. package/config/vite/project-config.js +121 -21
  30. package/config/vite/project-structure.js +6 -0
  31. package/config/vite/utils/asset-roots.js +205 -0
  32. package/config/vite/utils/css-urls.js +350 -0
  33. package/config/vite/utils/fs-safe.js +38 -1
  34. package/config/vite/utils/source-maps.js +88 -0
  35. package/config/vite/vite.config.js +106 -42
  36. package/package.json +40 -29
  37. package/scripts/audit/checks/css-asset-references.js +256 -24
  38. package/scripts/audit/fix.js +836 -0
  39. package/scripts/audit/index.js +10 -2
  40. package/scripts/audit/lib/css.js +41 -35
  41. package/scripts/audit/lib/twig.js +11 -29
  42. package/scripts/audit/report.js +83 -5
  43. package/scripts/audit.js +87 -2
  44. package/src/storybook/twig/source-function.js +14 -10
@@ -18,6 +18,7 @@ import { gzipSync } from 'node:zlib';
18
18
  import { statSync } from 'node:fs';
19
19
 
20
20
  import { findSourceRoot, relativeFrom } from '../../project-structure.js';
21
+ import { isGeneratedSourceMap } from '../../utils/source-maps.js';
21
22
 
22
23
  /**
23
24
  * Render one source root as a display path.
@@ -303,9 +304,8 @@ export function buildInputRows({
303
304
  * Gzipping is the expensive part of a per-file table — it is the whole of
304
305
  * Rolldown's `computing gzip size...` pause — so it is spent only where the
305
306
  * number means something. Fonts and raster images are already compressed and
306
- * their gzip figure is noise; sourcemaps compress well but are a diagnostic
307
- * artifact nobody ships to a browser, and they are among the largest files in a
308
- * typical `dist/`.
307
+ * their gzip figure is noise. Source maps are omitted from reporting entirely,
308
+ * so they never reach this list.
309
309
  *
310
310
  * @type {string[]}
311
311
  */
@@ -321,6 +321,20 @@ const COMPRESSIBLE_EXTENSIONS = [
321
321
  '.txt',
322
322
  ];
323
323
 
324
+ /**
325
+ * Decide whether a generated file belongs in developer-facing output reports.
326
+ *
327
+ * Source maps remain available in watch builds for devtools, but their size is
328
+ * dominated by debugging metadata and obscures the files a developer can act
329
+ * on. The production build disables their emission separately.
330
+ *
331
+ * @param {string} fileName - Output file name.
332
+ * @returns {boolean} TRUE when the file should appear in reporter output.
333
+ */
334
+ export function isReportableOutput(fileName) {
335
+ return !isGeneratedSourceMap(fileName);
336
+ }
337
+
324
338
  /**
325
339
  * Determine whether a file's compressed size is worth computing.
326
340
  *
@@ -329,7 +343,7 @@ const COMPRESSIBLE_EXTENSIONS = [
329
343
  */
330
344
  function isCompressible(fileName) {
331
345
  const lower = String(fileName).toLowerCase();
332
- if (lower.endsWith('.map')) return false;
346
+ if (!isReportableOutput(lower)) return false;
333
347
 
334
348
  return COMPRESSIBLE_EXTENSIONS.some((extension) => lower.endsWith(extension));
335
349
  }
@@ -413,7 +427,8 @@ function displayEntry(sourceFile, projectDir) {
413
427
  }
414
428
 
415
429
  /**
416
- * List every file a build wrote, with its size and, where useful, its gzip size.
430
+ * List every reportable file a build wrote, with its size and, where useful,
431
+ * its gzip size. Source maps stay on disk in watch mode but are omitted here.
417
432
  *
418
433
  * Ordered by size descending. Unlike the input listing, the question here is
419
434
  * "what is heavy" — the output row already reports the single largest file, and
@@ -426,21 +441,23 @@ function displayEntry(sourceFile, projectDir) {
426
441
  export function buildOutputFileRows(bundle, { gzip = true } = {}) {
427
442
  if (!bundle || typeof bundle !== 'object') return [];
428
443
 
429
- const rows = Object.entries(bundle).map(([fileName, output]) => {
430
- const content = outputBuffer(output);
431
- const bytes = content ? content.byteLength : 0;
432
-
433
- let gzipBytes;
434
- if (gzip && content && isCompressible(fileName)) {
435
- try {
436
- gzipBytes = gzipSync(content).byteLength;
437
- } catch {
438
- gzipBytes = undefined;
444
+ const rows = Object.entries(bundle)
445
+ .filter(([fileName]) => isReportableOutput(fileName))
446
+ .map(([fileName, output]) => {
447
+ const content = outputBuffer(output);
448
+ const bytes = content ? content.byteLength : 0;
449
+
450
+ let gzipBytes;
451
+ if (gzip && content && isCompressible(fileName)) {
452
+ try {
453
+ gzipBytes = gzipSync(content).byteLength;
454
+ } catch {
455
+ gzipBytes = undefined;
456
+ }
439
457
  }
440
- }
441
458
 
442
- return { fileName, bytes, gzipBytes };
443
- });
459
+ return { fileName, bytes, gzipBytes };
460
+ });
444
461
 
445
462
  return rows.sort(
446
463
  (a, b) => b.bytes - a.bytes || a.fileName.localeCompare(b.fileName, 'en'),
@@ -448,7 +465,7 @@ export function buildOutputFileRows(bundle, { gzip = true } = {}) {
448
465
  }
449
466
 
450
467
  /**
451
- * Fingerprint every file in a bundle by content.
468
+ * Fingerprint every reportable file in a bundle by content.
452
469
  *
453
470
  * Rollup regenerates the whole bundle on every watch cycle, so "which files were
454
471
  * written" is always "all of them" and says nothing. Comparing content hashes
@@ -464,6 +481,8 @@ export function fingerprintBundle(bundle) {
464
481
  if (!bundle || typeof bundle !== 'object') return fingerprints;
465
482
 
466
483
  for (const [fileName, output] of Object.entries(bundle)) {
484
+ if (!isReportableOutput(fileName)) continue;
485
+
467
486
  const content = outputBuffer(output);
468
487
  if (!content) continue;
469
488
 
@@ -507,7 +526,8 @@ export function diffFingerprints(previous = new Map(), current = new Map()) {
507
526
  * Raising `logLevel` to quiet the develop loop also discards Rolldown's per-file
508
527
  * asset report, which is around seventy lines on a real project. Three of its
509
528
  * facts are worth keeping — how many files landed, how much they weigh, and
510
- * which one is heaviest — and those fit on one line.
529
+ * which one is heaviest — and those fit on one line. Source maps are excluded
530
+ * from all three because they are watch-only debugging artifacts.
511
531
  *
512
532
  * Sizes are computed from the emitted content rather than by reading `dist/`
513
533
  * back off disk, so this adds no I/O to the cycle.
@@ -518,7 +538,9 @@ export function diffFingerprints(previous = new Map(), current = new Map()) {
518
538
  export function summarizeBundle(bundle) {
519
539
  if (!bundle || typeof bundle !== 'object') return undefined;
520
540
 
521
- const files = Object.entries(bundle);
541
+ const files = Object.entries(bundle).filter(([fileName]) =>
542
+ isReportableOutput(fileName),
543
+ );
522
544
  if (files.length === 0) return undefined;
523
545
 
524
546
  let totalBytes = 0;
@@ -536,6 +558,64 @@ export function summarizeBundle(bundle) {
536
558
  return { fileCount: files.length, totalBytes, largest };
537
559
  }
538
560
 
561
+ /**
562
+ * Attribute bundle output to the directories that retain it after publishing.
563
+ *
564
+ * Rollup writes every file through `outDir`, but Drupal SDC projects then move
565
+ * `components/**` to the project-root component directory. Partitioning the
566
+ * in-memory bundle preserves the reporter's no-I/O tally while naming the
567
+ * directories developers actually inspect after the mirror completes.
568
+ *
569
+ * Largest component paths are made relative to `components/`; the directory
570
+ * row already supplies that prefix, so repeating it would obscure the useful
571
+ * part of long component paths.
572
+ *
573
+ * @param {{
574
+ * bundle?: Record<string, {type?: string, code?: string, source?: string|Uint8Array}>,
575
+ * outDir?: string,
576
+ * mirrorComponentOutput?: boolean,
577
+ * componentOutput?: string
578
+ * }} [options] - Output routing inputs.
579
+ * @returns {Array<{path: string, write?: {fileCount: number, totalBytes: number, largest?: {fileName: string, bytes: number}}}>} Destination rows.
580
+ */
581
+ export function buildOutputSummaryRows({
582
+ bundle,
583
+ outDir = 'dist',
584
+ mirrorComponentOutput = false,
585
+ componentOutput = 'components',
586
+ } = {}) {
587
+ if (!mirrorComponentOutput) {
588
+ return [{ path: outDir, write: summarizeBundle(bundle) }];
589
+ }
590
+
591
+ const componentPath = displayRoot(componentOutput);
592
+ const distBundle = {};
593
+ const componentBundle = {};
594
+
595
+ for (const [fileName, output] of Object.entries(bundle || {})) {
596
+ const normalizedFileName = fileName.split('\\').join('/');
597
+
598
+ if (normalizedFileName.startsWith(componentPath)) {
599
+ componentBundle[normalizedFileName.slice(componentPath.length)] = output;
600
+ } else {
601
+ distBundle[fileName] = output;
602
+ }
603
+ }
604
+
605
+ const emptyWrite =
606
+ bundle && typeof bundle === 'object'
607
+ ? { fileCount: 0, totalBytes: 0 }
608
+ : undefined;
609
+
610
+ return [
611
+ { path: outDir, write: summarizeBundle(distBundle) || emptyWrite },
612
+ {
613
+ path: componentPath,
614
+ write: summarizeBundle(componentBundle) || emptyWrite,
615
+ },
616
+ ];
617
+ }
618
+
539
619
  /**
540
620
  * Measure one bundle output in bytes.
541
621
  *
@@ -0,0 +1,99 @@
1
+ /**
2
+ * @file Strict asset URL resolution for CI.
3
+ *
4
+ * An unresolvable CSS `url()` is a broken image at runtime, but it has never
5
+ * failed a build — Vite prints one line and exits 0, so the breakage ships. A
6
+ * project that wants that treated as an error opts in here.
7
+ *
8
+ * ## Why an environment variable and not a CLI flag
9
+ *
10
+ * `vite build --strict-assets` cannot work: cac rejects unknown options unless
11
+ * the command opts into `allowUnknownOptions()`, and Storybook's commander is
12
+ * the same. `verbosity.js` already documents the npm bridge that gets around
13
+ * that — `npm run build --strict-assets` exports `npm_config_strict_assets`
14
+ * into the script environment — so both triggers are honored here for the same
15
+ * reason.
16
+ */
17
+
18
+ /**
19
+ * Strictness levels for CSS asset URL resolution.
20
+ *
21
+ * @type {{off: string, unresolved: string, all: string}}
22
+ */
23
+ export const STRICTNESS = {
24
+ off: 'off',
25
+ unresolved: 'unresolved',
26
+ all: 'all',
27
+ };
28
+
29
+ /** Values that read as "off" regardless of which trigger set them. */
30
+ const OFF_VALUES = ['', '0', 'false', 'off', 'no'];
31
+
32
+ /** Values that enable failures only for unresolved URLs. */
33
+ const UNRESOLVED_VALUES = ['1', 'true'];
34
+
35
+ /** Values that also fail when the build repairs a URL. */
36
+ const ALL_VALUES = ['2', 'all'];
37
+
38
+ /** Values accepted from either strict-assets environment trigger. */
39
+ const ACCEPTED_VALUES = [
40
+ ...OFF_VALUES.filter(Boolean),
41
+ ...UNRESOLVED_VALUES,
42
+ ...ALL_VALUES,
43
+ ];
44
+
45
+ /** Normalize a possibly-unset environment value for comparison. */
46
+ const normalizeValue = (value) =>
47
+ String(value ?? '')
48
+ .toLowerCase()
49
+ .trim();
50
+
51
+ /**
52
+ * Resolve how strictly the build should treat CSS asset URL problems.
53
+ *
54
+ * `1` fails on URLs nothing could resolve. `2` also fails on URLs the build had
55
+ * to repair, for projects that want the canonical form written in source rather
56
+ * than fixed up at build time.
57
+ *
58
+ * @param {{EMULSIFY_STRICT_ASSETS?: string, npm_config_strict_assets?: string}} [env] - Environment variables.
59
+ * @param {(message: string) => void} [warn] - Warning sink.
60
+ * @returns {string} One of {@link STRICTNESS}.
61
+ */
62
+ export function resolveAssetStrictness(env = process.env, warn = console.warn) {
63
+ const direct = normalizeValue(env?.EMULSIFY_STRICT_ASSETS);
64
+ const bridged = normalizeValue(env?.npm_config_strict_assets);
65
+ const requested = direct || bridged;
66
+
67
+ if (OFF_VALUES.includes(requested)) return STRICTNESS.off;
68
+ if (UNRESOLVED_VALUES.includes(requested)) return STRICTNESS.unresolved;
69
+ if (ALL_VALUES.includes(requested)) return STRICTNESS.all;
70
+
71
+ const source = direct ? 'EMULSIFY_STRICT_ASSETS' : 'npm_config_strict_assets';
72
+ warn(
73
+ `Emulsify: ${source} has unrecognized value "${requested}". ` +
74
+ `Accepted values: ${ACCEPTED_VALUES.join(', ')}. ` +
75
+ 'Treating it as level 1 (unresolved URLs only).',
76
+ );
77
+
78
+ return STRICTNESS.unresolved;
79
+ }
80
+
81
+ /**
82
+ * Count the asset problems that should fail the build at a strictness level.
83
+ *
84
+ * @param {{unresolvedAssets?: object[], assetRebases?: object[]}} snapshot - Diagnostics snapshot.
85
+ * @param {string} strictness - One of {@link STRICTNESS}.
86
+ * @returns {number} Failing problem count.
87
+ */
88
+ export function countStrictAssetFailures(snapshot = {}, strictness) {
89
+ if (strictness === STRICTNESS.off) return 0;
90
+
91
+ const unresolved = snapshot.unresolvedAssets?.length || 0;
92
+ if (strictness !== STRICTNESS.all) return unresolved;
93
+
94
+ const repairFailures = (snapshot.assetRebases || []).filter(
95
+ (entry) => entry.status === 'rebased' || entry.status === 'ambiguous',
96
+ ).length;
97
+
98
+ return unresolved + repairFailures;
99
+ }
@@ -18,6 +18,10 @@
18
18
  * message passes straight through to Vite's own logger untouched.
19
19
  */
20
20
 
21
+ import {
22
+ isAssetAliasPath,
23
+ splitUrlSuffix,
24
+ } from '../assets/asset-url-rebase.js';
21
25
  import { isQuiet, isVerbose } from './verbosity.js';
22
26
 
23
27
  // Re-exported because the Vite config and the reporter both branch on it, and
@@ -33,6 +37,18 @@ export { isVerbose };
33
37
  const UNRESOLVED_ASSET_PATTERN =
34
38
  /^(.+?) referenced in (.+?) didn't resolve at build time/;
35
39
 
40
+ /**
41
+ * Matches Vite's browser-compatibility externalization notice.
42
+ *
43
+ * One dependency reaching for a Node builtin emits this once per importing file
44
+ * on every cycle. It says nothing new after the first build, and in a Twig theme
45
+ * it is never actionable, so the reporter tallies it rather than reprinting it.
46
+ *
47
+ * @type {RegExp}
48
+ */
49
+ const EXTERNALIZED_MODULE_PATTERN =
50
+ /Module "(.+?)" has been externalized for browser compatibility(?:, imported by "(.+?)")?/;
51
+
36
52
  /**
37
53
  * Remove ANSI escape sequences so pattern matching sees plain text.
38
54
  *
@@ -91,6 +107,143 @@ function isBareStackTrace(message) {
91
107
  */
92
108
  const HMR_UPDATE_PATTERN = /(^|\s)hmr update\s/;
93
109
 
110
+ /**
111
+ * Matches the `File:` line Vite appends to a transform failure.
112
+ *
113
+ * `buildErrorMessage` composes a dev-server error as the message, `Plugin:` and
114
+ * `File:`, the source frame, then `err.stack`. Sass is a special case: its
115
+ * multiline message already contains the source frame, so the body after
116
+ * `File:` repeats what was printed before the metadata. Other transformers put
117
+ * their only caret excerpt after `File:`.
118
+ *
119
+ * @type {RegExp}
120
+ */
121
+ const ERROR_FILE_LINE = /^\s*File:\s/;
122
+
123
+ /**
124
+ * Matches a JavaScript stack frame with a source location.
125
+ *
126
+ * Requiring a `line:column` suffix keeps ordinary diagnostic prose such as
127
+ * "at least one value is required" from being mistaken for a stack frame.
128
+ *
129
+ * @type {RegExp}
130
+ */
131
+ const STACK_FRAME =
132
+ /^\s*at\s+(?:(?:async|new)\s+)?(?:.+\s+\()?[^()\s]+:\d+:\d+\)?\s*$/;
133
+
134
+ /**
135
+ * Find the last line matching a pattern.
136
+ *
137
+ * A diagnostic's own text can contain a `File:`-looking line. Vite's metadata
138
+ * follows the diagnostic body, so the final match is the useful anchor.
139
+ *
140
+ * @param {string[]} lines - Message lines.
141
+ * @param {RegExp} pattern - Pattern to match.
142
+ * @returns {number} Matching index, or -1.
143
+ */
144
+ function findLastLine(lines, pattern) {
145
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
146
+ if (pattern.test(stripAnsi(lines[index]))) return index;
147
+ }
148
+
149
+ return -1;
150
+ }
151
+
152
+ /**
153
+ * Normalize one copy of an error body for duplicate comparison.
154
+ *
155
+ * Vite prefixes the first copy with `Internal server error:` and Sass's stack
156
+ * can prefix the repeated copy with `Error:`. Sass also changes indentation
157
+ * between the two copies, so only leading/trailing whitespace is discarded.
158
+ *
159
+ * @param {string[]} lines - Error-body lines.
160
+ * @returns {string} Normalized body.
161
+ */
162
+ function normalizeErrorBody(lines) {
163
+ const normalized = lines
164
+ .map((line) => stripAnsi(line).trim())
165
+ .filter(Boolean);
166
+
167
+ if (normalized.length > 0) {
168
+ normalized[0] = normalized[0]
169
+ .replace(/^Internal server error:\s*/, '')
170
+ .replace(/^Error:\s*/, '');
171
+ }
172
+
173
+ return normalized.join('\n');
174
+ }
175
+
176
+ /**
177
+ * Determine whether Sass's post-`File:` frame repeats its message.
178
+ *
179
+ * Vite assigns Sass's multiline message to `error.frame`. Its first rendered
180
+ * block therefore already contains the excerpt, while the text after `File:`
181
+ * repeats that same body from the stack. Other transformers put their only
182
+ * code frame after `File:`, so those frames must be retained.
183
+ *
184
+ * @param {string[]} lines - Complete dev-server error.
185
+ * @param {number} fileLine - Index of Vite's final `File:` metadata line.
186
+ * @param {number} firstStackFrame - Index of the first real stack frame.
187
+ * @returns {boolean} TRUE when the post-file body is a Sass duplicate.
188
+ */
189
+ function hasDuplicateSassFrame(lines, fileLine, firstStackFrame) {
190
+ const pluginLine = findLastLine(
191
+ lines.slice(0, fileLine),
192
+ /^\s*Plugin:\s+vite:css\s*$/,
193
+ );
194
+ if (pluginLine === -1) return false;
195
+
196
+ const messageBody = normalizeErrorBody(lines.slice(0, pluginLine));
197
+ const repeatedBody = normalizeErrorBody(
198
+ lines.slice(
199
+ fileLine + 1,
200
+ firstStackFrame === -1 ? lines.length : firstStackFrame,
201
+ ),
202
+ );
203
+
204
+ return (
205
+ messageBody.startsWith('[sass]') &&
206
+ repeatedBody.startsWith('[sass]') &&
207
+ messageBody === repeatedBody
208
+ );
209
+ }
210
+
211
+ /**
212
+ * Reduce a dev-server error to the part that names the problem.
213
+ *
214
+ * One mistyped semicolon in a stylesheet prints around fifty lines: the Sass
215
+ * error, the same error again out of `err.stack`, and thirty-odd frames inside
216
+ * `sass.dart.js` that point at the compiler rather than at the stylesheet. The
217
+ * first block — message, excerpt, caret, import chain, and the file it came
218
+ * from — is the whole of what a themer can act on. Other transformers keep
219
+ * their unique source frame; only the JavaScript stack beneath it is removed.
220
+ *
221
+ * The stack is only dropped at a recognizable frame. Sass's post-`File:` body
222
+ * is dropped earlier only when it is demonstrably a duplicate of the message,
223
+ * so an error shaped differently than expected is not truncated on a guess.
224
+ *
225
+ * @param {string} message - Raw error text.
226
+ * @returns {string} Message without the repeated body and the stack.
227
+ */
228
+ export function compactDevServerError(message) {
229
+ const lines = String(message).split('\n');
230
+ const fileLine = findLastLine(lines, ERROR_FILE_LINE);
231
+ const stackSearchStart = fileLine === -1 ? 0 : fileLine + 1;
232
+ const relativeStackFrame = lines
233
+ .slice(stackSearchStart)
234
+ .findIndex((line) => STACK_FRAME.test(stripAnsi(line)));
235
+ const firstFrame =
236
+ relativeStackFrame === -1 ? -1 : stackSearchStart + relativeStackFrame;
237
+
238
+ if (fileLine !== -1 && hasDuplicateSassFrame(lines, fileLine, firstFrame)) {
239
+ return lines.slice(0, fileLine + 1).join('\n');
240
+ }
241
+
242
+ if (firstFrame > 0) return lines.slice(0, firstFrame).join('\n').trimEnd();
243
+
244
+ return String(message);
245
+ }
246
+
94
247
  /**
95
248
  * Wrap the Storybook dev server's logger to drop HMR notices.
96
249
  *
@@ -109,6 +262,11 @@ const HMR_UPDATE_PATTERN = /(^|\s)hmr update\s/;
109
262
  * processes share one pipe, so these interleave with the build's output and are
110
263
  * the last thing making one command look like two.
111
264
  *
265
+ * Transform failures are compacted for the same reason. The dev server prints
266
+ * the error, then repeats it out of `err.stack`, then lists thirty frames
267
+ * inside `sass.dart.js`; only the first block names anything in the project.
268
+ * See {@link compactDevServerError}.
269
+ *
112
270
  * The wrapper delegates to whatever logger is already configured rather than
113
271
  * replacing it, so Storybook keeps its own prefixes and styling for every other
114
272
  * message. Verbose modes pass everything through, because someone who asked for
@@ -141,7 +299,14 @@ export function createDevServerLogger({ baseLogger, verbose } = {}) {
141
299
 
142
300
  warn: (message, options) => baseLogger.warn(message, options),
143
301
  warnOnce: (message, options) => baseLogger.warnOnce(message, options),
144
- error: (message, options) => baseLogger.error(message, options),
302
+
303
+ error(message, options) {
304
+ baseLogger.error(
305
+ passThrough ? message : compactDevServerError(message),
306
+ options,
307
+ );
308
+ },
309
+
145
310
  clearScreen: (type) => baseLogger.clearScreen(type),
146
311
  hasErrorLogged: (error) => baseLogger.hasErrorLogged(error),
147
312
  };
@@ -165,7 +330,10 @@ export function parseUnresolvedAsset(message) {
165
330
  url,
166
331
  // Vite reports the URL as its own importer when the referencing stylesheet
167
332
  // is not known. Recording that adds nothing, so it is dropped.
168
- importer: importer === url ? undefined : importer,
333
+ // Vite shadows the stylesheet id while resolving CSS URLs. The value after
334
+ // `referenced in` is therefore usually the URL itself; for fragments it is
335
+ // the same URL with the fragment removed. Neither identifies an importer.
336
+ importer: importer === url.split('#')[0] ? undefined : importer,
169
337
  };
170
338
  }
171
339
 
@@ -179,9 +347,39 @@ export function parseUnresolvedAsset(message) {
179
347
  * @param {import('vite').Logger} baseLogger - Logger to delegate to.
180
348
  * @returns {import('vite').Logger} Wrapped logger.
181
349
  */
350
+ /**
351
+ * Parse Vite's externalization notice into a module and its importer.
352
+ *
353
+ * @param {string} message - Raw log message.
354
+ * @returns {{module: string, importer: string|undefined}|null} Parsed notice.
355
+ */
356
+ export function parseExternalizedModule(message) {
357
+ const match = EXTERNALIZED_MODULE_PATTERN.exec(stripAnsi(String(message)));
358
+ if (!match) return null;
359
+
360
+ return { module: match[1], importer: match[2] || undefined };
361
+ }
362
+
182
363
  export function createReporterLogger(collector, baseLogger, { verbose } = {}) {
183
364
  const passRawThrough = verbose === undefined ? isVerbose() : verbose;
184
365
 
366
+ /**
367
+ * Valid aliases deliberately reach Vite as unresolved literals so Core can
368
+ * apply configured-root resolution after Sass compilation. Their raw Vite
369
+ * notice is therefore implementation noise even in verbose mode. Missing or
370
+ * ambiguous aliases remain in the collector and are reported in Core's final
371
+ * diagnostic summary.
372
+ *
373
+ * @param {string} message - Raw log message.
374
+ * @returns {boolean} TRUE for an unresolved reserved-alias notice.
375
+ */
376
+ const isAssetAliasNotice = (message) => {
377
+ const unresolved = parseUnresolvedAsset(message);
378
+ if (!unresolved) return false;
379
+
380
+ return isAssetAliasPath(splitUrlSuffix(unresolved.url).path);
381
+ };
382
+
185
383
  /**
186
384
  * Record a message if it is one the reporter owns.
187
385
  *
@@ -190,10 +388,18 @@ export function createReporterLogger(collector, baseLogger, { verbose } = {}) {
190
388
  */
191
389
  const capture = (message) => {
192
390
  const unresolvedAsset = parseUnresolvedAsset(message);
193
- if (!unresolvedAsset) return false;
194
-
195
- collector.recordUnresolvedAsset(unresolvedAsset);
196
- return true;
391
+ if (unresolvedAsset) {
392
+ collector.recordUnresolvedAsset(unresolvedAsset);
393
+ return true;
394
+ }
395
+
396
+ const externalized = parseExternalizedModule(message);
397
+ if (externalized) {
398
+ collector.recordExternalizedModule?.(externalized);
399
+ return true;
400
+ }
401
+
402
+ return false;
197
403
  };
198
404
 
199
405
  /**
@@ -230,11 +436,17 @@ export function createReporterLogger(collector, baseLogger, { verbose } = {}) {
230
436
  info: (message, options) => baseLogger.info(message, options),
231
437
 
232
438
  warn(message, options) {
233
- if (!capture(message)) baseLogger.warn(message, options);
439
+ const captured = capture(message);
440
+ if (!captured || (passRawThrough && !isAssetAliasNotice(message))) {
441
+ baseLogger.warn(message, options);
442
+ }
234
443
  },
235
444
 
236
445
  warnOnce(message, options) {
237
- if (!capture(message)) baseLogger.warnOnce(message, options);
446
+ const captured = capture(message);
447
+ if (!captured || (passRawThrough && !isAssetAliasNotice(message))) {
448
+ baseLogger.warnOnce(message, options);
449
+ }
238
450
  },
239
451
 
240
452
  error(message, options) {
@@ -27,7 +27,7 @@ const WATCH_FLAGS = ['--watch', '-w'];
27
27
  * Determine whether the current process was invoked as a Vite watch build.
28
28
  *
29
29
  * @param {string[]} [argv] - Process arguments.
30
- * @returns {boolean} TRUE when a watch flag is present.
30
+ * @returns {boolean} TRUE when a watch flag enables watch mode.
31
31
  */
32
32
  export function isWatchInvocation(argv = process.argv) {
33
33
  if (!Array.isArray(argv)) return false;
@@ -35,6 +35,10 @@ export function isWatchInvocation(argv = process.argv) {
35
35
  return argv.some(
36
36
  (arg) =>
37
37
  WATCH_FLAGS.includes(arg) ||
38
- WATCH_FLAGS.some((flag) => arg.startsWith(`${flag}=`)),
38
+ WATCH_FLAGS.some(
39
+ (flag) =>
40
+ arg.startsWith(`${flag}=`) &&
41
+ arg.slice(flag.length + 1).toLowerCase() !== 'false',
42
+ ),
39
43
  );
40
44
  }