@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.
- package/.storybook/main-static-assets.js +5 -8
- package/.storybook/main-vite.js +11 -3
- package/README.md +14 -6
- package/config/a11y-wcag22.js +11 -0
- package/config/vite/entries.js +7 -2
- package/config/vite/environment.js +4 -0
- package/config/vite/plugins/assets/asset-url-rebase.js +241 -0
- package/config/vite/plugins/assets/copy-src-assets.js +82 -12
- package/config/vite/plugins/assets/copy-twig-files.js +85 -16
- package/config/vite/plugins/assets/css-asset-rebase.js +306 -0
- package/config/vite/plugins/assets/css-asset-relativizer.js +301 -21
- package/config/vite/plugins/assets/development-source-maps.js +273 -0
- package/config/vite/plugins/assets/mirror-components.js +98 -82
- package/config/vite/plugins/assets/output-freshness.js +235 -0
- package/config/vite/plugins/assets/source-file-index.js +13 -13
- package/config/vite/plugins/assets/stable-watch-output.js +165 -0
- package/config/vite/plugins/assets/storybook-output.js +27 -0
- package/config/vite/plugins/index.js +95 -9
- package/config/vite/plugins/reporter/asset-resolver.js +34 -6
- package/config/vite/plugins/reporter/build-errors.js +7 -3
- package/config/vite/plugins/reporter/diagnostics.js +140 -10
- package/config/vite/plugins/reporter/index.js +380 -75
- package/config/vite/plugins/reporter/render.js +297 -44
- package/config/vite/plugins/reporter/sass-logger.js +30 -0
- package/config/vite/plugins/reporter/source-roots.js +101 -21
- package/config/vite/plugins/reporter/strict-mode.js +99 -0
- package/config/vite/plugins/reporter/vite-logger.js +220 -8
- package/config/vite/plugins/reporter/watch-mode.js +6 -2
- package/config/vite/plugins/twig/twig-module.js +35 -258
- package/config/vite/plugins/twig/virtual-twig-asset-sources.js +48 -49
- package/config/vite/project-config.js +121 -21
- package/config/vite/project-structure.js +6 -0
- package/config/vite/utils/asset-roots.js +205 -0
- package/config/vite/utils/css-urls.js +350 -0
- package/config/vite/utils/fs-safe.js +38 -1
- package/config/vite/utils/source-directory-skips.js +13 -0
- package/config/vite/utils/source-maps.js +88 -0
- package/config/vite/utils/twig-component-resolver.js +316 -0
- package/config/vite/vite.config.js +106 -42
- package/package.json +54 -40
- package/scripts/a11y.js +88 -9
- package/scripts/audit/checks/css-asset-references.js +256 -24
- package/scripts/audit/checks/twig-references.js +16 -5
- package/scripts/audit/fix.js +836 -0
- package/scripts/audit/index.js +10 -2
- package/scripts/audit/lib/css.js +41 -35
- package/scripts/audit/lib/story-ast.js +392 -0
- package/scripts/audit/lib/story-render-paths.js +600 -0
- package/scripts/audit/lib/story-selection.js +190 -0
- package/scripts/audit/lib/twig.js +372 -80
- package/scripts/audit/report.js +83 -5
- package/scripts/audit-twig-stories.js +73 -3
- package/scripts/audit.js +87 -2
- 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
|
|
307
|
-
*
|
|
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
|
|
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,
|
|
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)
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
38
|
+
WATCH_FLAGS.some(
|
|
39
|
+
(flag) =>
|
|
40
|
+
arg.startsWith(`${flag}=`) &&
|
|
41
|
+
arg.slice(flag.length + 1).toLowerCase() !== 'false',
|
|
42
|
+
),
|
|
39
43
|
);
|
|
40
44
|
}
|