@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
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @file Storybook build output markers shared by Core plugins.
3
+ *
4
+ * Storybook copies `staticDirs` into `.out/assets` while the preview build
5
+ * runs, so Vite-generated chunks are routed to a separate directory to avoid
6
+ * concurrent writers. That directory name doubles as the one deterministic
7
+ * signal a Core plugin has for "this build is Storybook's, not the theme's" —
8
+ * no env sniffing, no plugin-name matching, no guessing at `outDir`, which a
9
+ * consumer can override with `-o`.
10
+ */
11
+
12
+ /**
13
+ * Directory Storybook's Vite build writes generated chunks to.
14
+ *
15
+ * @type {string}
16
+ */
17
+ export const STORYBOOK_VITE_ASSETS_DIR = 'storybook-assets';
18
+
19
+ /**
20
+ * Determine whether a resolved Vite config belongs to a Storybook build.
21
+ *
22
+ * @param {{build?: {assetsDir?: string}}} config - Resolved Vite config.
23
+ * @returns {boolean} TRUE when Storybook owns this output directory.
24
+ */
25
+ export function isStorybookOutput(config) {
26
+ return config?.build?.assetsDir === STORYBOOK_VITE_ASSETS_DIR;
27
+ }
@@ -12,9 +12,12 @@ import { resolveProjectStructure } from '../project-structure.js';
12
12
  import { toPosixPath } from '../utils/paths.js';
13
13
  import { copyAllSrcAssetsPlugin } from './assets/copy-src-assets.js';
14
14
  import { copyTwigFilesPlugin } from './assets/copy-twig-files.js';
15
+ import { cssAssetRebasePlugin } from './assets/css-asset-rebase.js';
15
16
  import { cssAssetUrlRelativizer } from './assets/css-asset-relativizer.js';
17
+ import { developmentCssSourceMapPlugins } from './assets/development-source-maps.js';
16
18
  import { mirrorComponentsToRoot } from './assets/mirror-components.js';
17
19
  import { createSourceFileIndex } from './assets/source-file-index.js';
20
+ import { stableWatchOutputPlugin } from './assets/stable-watch-output.js';
18
21
  import { svgSpriteFilePlugin } from './assets/svg-sprite.js';
19
22
  import { developReporterPlugin } from './reporter/index.js';
20
23
  import { requireContextCompatPlugin } from './require-context.js';
@@ -37,9 +40,10 @@ import { yamlModulePlugin } from './yaml-module.js';
37
40
  * srcDir: string,
38
41
  * srcExists: boolean,
39
42
  * structureOverrides?: boolean,
43
+ * developmentBuild?: boolean,
40
44
  * diagnostics?: object
41
- * }} env - Project environment. When `diagnostics` is present the develop
42
- * reporter is appended; it is supplied only for watch builds.
45
+ * }} env - Project environment. When `diagnostics` is present the reporter is
46
+ * appended for watch summaries and actionable one-shot diagnostics.
43
47
  * @returns {import('vite').PluginOption[]} Emulsify Vite plugins.
44
48
  */
45
49
  export function makePlugins(env) {
@@ -56,6 +60,35 @@ export function makePlugins(env) {
56
60
  const sourceFileIndex =
57
61
  env.sourceFileIndex || createSourceFileIndex(structure);
58
62
 
63
+ // In lean-output mode, filled by the rebase plugin and read by the
64
+ // relativizer: published asset path -> where that file actually lives,
65
+ // relative to the project root. It stays empty for self-contained output.
66
+ /** @type {Map<string, string>} */
67
+ const publishedAssetSources = new Map();
68
+ // The subset above that came from Vite copies. An actual CSS rewrite plus
69
+ // membership here authorizes the relativizer to remove a redundant copy.
70
+ /** @type {Set<string>} */
71
+ const removablePublishedAssets = new Set();
72
+
73
+ // Filled by the stable-output plugin, read by the reporter: emitted files it
74
+ // dropped this cycle because the bytes on disk already match. The reporter
75
+ // diffs one cycle's bundle against the last, so without this a skipped file
76
+ // would be listed as a deleted one.
77
+ /** @type {Set<string>} */
78
+ const unchangedOutputs = new Set();
79
+
80
+ // Filled by the source copy plugins, read and reset by the reporter: copied
81
+ // files never enter Rollup's bundle, so its fingerprint diff cannot see
82
+ // them. A Map deduplicates paths when more than one producer touches the
83
+ // same destination and leaves room for safe pruning to report removals if it
84
+ // is reintroduced later.
85
+ /** @type {Map<string, {kind: 'written'|'removed', bytes?: number}>|undefined} */
86
+ const copiedOutputChanges = env.diagnostics ? new Map() : undefined;
87
+ const developmentCssMaps = developmentCssSourceMapPlugins({
88
+ projectDir,
89
+ developmentBuild: env.developmentBuild,
90
+ });
91
+
59
92
  const basePlugins = [
60
93
  virtualTwigExtensionInstallersPlugin(envWithStructure),
61
94
  virtualTwigGlobsPlugin(envWithStructure),
@@ -86,29 +119,82 @@ export function makePlugins(env) {
86
119
  // Legacy Storybook stories may still enumerate assets with require.context.
87
120
  requireContextCompatPlugin(),
88
121
 
89
- // Keep CSS asset URLs relative to the emitted CSS location.
90
- cssAssetUrlRelativizer({ assetsRoot: 'assets' }),
122
+ // Capture Vite's combined Sass/PostCSS map before Core changes asset URLs.
123
+ // Vite's extracted-CSS build path discards this map unless Core retains it.
124
+ developmentCssMaps.capture,
125
+
126
+ // Repair CSS asset URLs Vite could not resolve. Ordering against the
127
+ // relativizer below is load-bearing: this normalizes URLs to `/assets/...`
128
+ // and either emits an output asset or records its source-tree location;
129
+ // only then can the relativizer select and calculate the final target.
130
+ cssAssetRebasePlugin({
131
+ env: envWithStructure,
132
+ diagnostics: env.diagnostics,
133
+ publishedAssetSources,
134
+ removablePublishedAssets,
135
+ }),
136
+
137
+ // Point CSS asset URLs at the file each one names, relative to the
138
+ // stylesheet's own location on disk.
139
+ cssAssetUrlRelativizer({
140
+ assetsRoot: 'assets',
141
+ env: envWithStructure,
142
+ publishedAssetSources,
143
+ removablePublishedAssets,
144
+ }),
145
+
146
+ // Pair each direct stylesheet entry with its finalized watch-build asset.
147
+ // This must run after URL rewriting and before stable-output comparison.
148
+ developmentCssMaps.emit,
149
+
150
+ // Last of the CSS chain: once the text is final, an unchanged stylesheet is
151
+ // dropped rather than rewritten, so a watch rebuild does not send HMR
152
+ // updates for stylesheets the edit never touched.
153
+ stableWatchOutputPlugin({
154
+ projectDir,
155
+ mirrorComponentOutput: structure.mirrorComponentOutput,
156
+ unchangedOutputs,
157
+ }),
91
158
  ];
92
159
 
93
160
  return [
94
161
  ...basePlugins,
95
162
 
96
163
  // Copy Twig templates and component metadata beside compiled assets.
97
- copyTwigFilesPlugin({ structure, sourceFileIndex }),
164
+ copyTwigFilesPlugin({
165
+ structure,
166
+ sourceFileIndex,
167
+ diagnostics: env.diagnostics,
168
+ outputChanges: copiedOutputChanges,
169
+ }),
98
170
 
99
171
  // Copy every non-code asset under src with the same routing.
100
- copyAllSrcAssetsPlugin({ structure, sourceFileIndex }),
172
+ copyAllSrcAssetsPlugin({
173
+ structure,
174
+ sourceFileIndex,
175
+ diagnostics: env.diagnostics,
176
+ outputChanges: copiedOutputChanges,
177
+ }),
101
178
 
102
179
  // Drupal projects with src mirror dist/components back to ./components.
103
180
  mirrorComponentsToRoot({
104
181
  enabled: structure.mirrorComponentOutput,
105
182
  projectDir,
183
+ developmentBuild: env.developmentBuild,
184
+ diagnostics: env.diagnostics,
106
185
  }),
107
186
 
108
- // Summarize the build for `npm run develop`. Present only when the Vite
109
- // config supplied a diagnostics collector, which it does for watch builds.
187
+ // Summarize `npm run develop`, and report actionable diagnostics collected
188
+ // during one-shot Vite or Storybook builds.
110
189
  ...(env.diagnostics
111
- ? [developReporterPlugin({ env, diagnostics: env.diagnostics })]
190
+ ? [
191
+ developReporterPlugin({
192
+ env,
193
+ diagnostics: env.diagnostics,
194
+ unchangedOutputs,
195
+ copiedOutputChanges,
196
+ }),
197
+ ]
112
198
  : []),
113
199
  ];
114
200
  }
@@ -97,7 +97,7 @@ const lineAt = (source, index) => source.slice(0, index).split('\n').length;
97
97
  * @param {{projectDir?: string}} env - Project environment.
98
98
  * @returns {{
99
99
  * locate: (url: string) => {status: string, label: string},
100
- * references: (url: string) => Array<{file: string, line: number}>
100
+ * references: (url: string) => Array<{file: string, sourceFile: string, line: number}>
101
101
  * }} Resolver.
102
102
  */
103
103
  export function createAssetResolver({ projectDir = '' } = {}) {
@@ -246,7 +246,7 @@ export function createAssetResolver({ projectDir = '' } = {}) {
246
246
  * Find the stylesheets that write this URL, with line numbers.
247
247
  *
248
248
  * @param {string} url - Unresolved asset URL.
249
- * @returns {Array<{file: string, line: number}>} References, in file order.
249
+ * @returns {Array<{file: string, sourceFile: string, line: number}>} References, in file order.
250
250
  */
251
251
  references(url) {
252
252
  const files = allFiles();
@@ -257,7 +257,7 @@ export function createAssetResolver({ projectDir = '' } = {}) {
257
257
  *
258
258
  * @param {string[]} stylesheets - Files to search.
259
259
  * @param {(source: string) => Array<number>} findOffsets - Offset finder.
260
- * @returns {Array<{file: string, line: number}>} Matches.
260
+ * @returns {Array<{file: string, sourceFile: string, line: number}>} Matches.
261
261
  */
262
262
  const scan = (stylesheets, findOffsets) => {
263
263
  const found = [];
@@ -265,17 +265,19 @@ export function createAssetResolver({ projectDir = '' } = {}) {
265
265
  for (const absPath of stylesheets) {
266
266
  const source = read(absPath);
267
267
  if (!source) continue;
268
+ const sourceFile = toProjectPath(absPath, projectDir);
268
269
 
269
270
  for (const offset of findOffsets(source)) {
270
271
  found.push({
271
- file: tailSegments(toProjectPath(absPath, projectDir)),
272
+ file: tailSegments(sourceFile),
273
+ sourceFile,
272
274
  line: lineAt(source, offset),
273
275
  });
274
276
  }
275
277
  }
276
278
 
277
279
  return found.sort(
278
- (a, b) => a.file.localeCompare(b.file) || a.line - b.line,
280
+ (a, b) => a.sourceFile.localeCompare(b.sourceFile) || a.line - b.line,
279
281
  );
280
282
  };
281
283
 
@@ -514,7 +516,7 @@ export function sharedMissingDirectory(rows, projectDir = '') {
514
516
  * every row has to be somewhere to go. A URL written in three stylesheets
515
517
  * becomes three rows rather than one row with a repeat count.
516
518
  *
517
- * @param {Array<{url: string}>} assets - Unresolved assets from the collector.
519
+ * @param {Array<{url: string, importer?: string}>} assets - Unresolved assets from the collector.
518
520
  * @param {ReturnType<createAssetResolver>} resolver - Asset resolver.
519
521
  * @returns {Array<{where: string, url: string, status: string, label: string}>} Table rows.
520
522
  */
@@ -523,6 +525,32 @@ export function buildAssetRows(assets, resolver) {
523
525
  const location = resolver.locate(asset.url);
524
526
  const references = resolver.references(asset.url);
525
527
 
528
+ if (asset.importer) {
529
+ const importer = cleanUrl(asset.importer).replaceAll('\\', '/');
530
+ const matchingReference = references.find((reference) => {
531
+ const sourceFile = reference.sourceFile || reference.file;
532
+ return sourceFile === importer || importer.endsWith(`/${sourceFile}`);
533
+ });
534
+
535
+ if (!matchingReference) {
536
+ return [
537
+ {
538
+ where: tailSegments(importer),
539
+ url: asset.url,
540
+ ...location,
541
+ },
542
+ ];
543
+ }
544
+
545
+ return [
546
+ {
547
+ where: `${matchingReference.file}:${matchingReference.line}`,
548
+ url: asset.url,
549
+ ...location,
550
+ },
551
+ ];
552
+ }
553
+
526
554
  if (references.length === 0) {
527
555
  return [{ where: '—', url: asset.url, ...location }];
528
556
  }
@@ -134,7 +134,7 @@ export function parseCssSyntaxError(error) {
134
134
  /**
135
135
  * Flatten a rolldown build error into the individual errors it wraps.
136
136
  *
137
- * @param {Error & {errors?: Array<object>}} error - Build error.
137
+ * @param {Error & {errors?: Array<unknown>}} error - Build error.
138
138
  * @returns {Array<object>} Individual errors.
139
139
  */
140
140
  export function flattenBuildErrors(error) {
@@ -143,7 +143,11 @@ export function flattenBuildErrors(error) {
143
143
  const nested = error.errors;
144
144
  if (!Array.isArray(nested) || nested.length === 0) return [error];
145
145
 
146
- return nested.flatMap((entry) => flattenBuildErrors(entry));
146
+ const flattened = nested.flatMap((entry) => flattenBuildErrors(entry));
147
+
148
+ // A malformed aggregate can contain only falsy entries. Keep its wrapper so
149
+ // the failed hook still records an error instead of turning the cycle green.
150
+ return flattened.length > 0 ? flattened : [error];
147
151
  }
148
152
 
149
153
  /**
@@ -250,7 +254,7 @@ export function describeBuildError(error) {
250
254
  * line, and a path that does not resolve — and because one deleted partial
251
255
  * commonly produces a dozen of them.
252
256
  *
253
- * @param {Error & {errors?: Array<object>}} error - Build error.
257
+ * @param {Error & {errors?: Array<unknown>}} error - Build error.
254
258
  * @returns {{
255
259
  * importErrors: Array<object>,
256
260
  * syntaxErrors: Array<object>,
@@ -35,6 +35,18 @@ const locationKey = (file, line) =>
35
35
  const entryKey = (entry) =>
36
36
  `${locationKey(entry.file, entry.line)}|${entry.message || ''}`;
37
37
 
38
+ /**
39
+ * Build the identity of one CSS asset reference site.
40
+ *
41
+ * The same URL text in two stylesheets represents two separate edits, while
42
+ * repeat notices for the same stylesheet and URL are one problem to tally.
43
+ *
44
+ * @param {string|undefined} importer - Referencing stylesheet.
45
+ * @param {string|undefined} url - Referenced asset URL.
46
+ * @returns {string} Stable asset-reference key.
47
+ */
48
+ const assetReferenceKey = (importer, url) => `${importer || ''}\0${url || ''}`;
49
+
38
50
  /**
39
51
  * Record one occurrence against a location map, incrementing when repeated.
40
52
  *
@@ -138,8 +150,10 @@ const groupDeprecationsByFile = (deprecationList) => {
138
150
  * @returns {{
139
151
  * recordDeprecation: (entry: {id?: string, file?: string, line?: number}) => void,
140
152
  * recordWarning: (entry: {message?: string, file?: string, line?: number}) => void,
141
- * recordError: (entry: {message?: string, file?: string, line?: number}) => void,
153
+ * recordError: (entry: {message?: string, file?: string, line?: number, outputState?: 'incomplete'}) => void,
142
154
  * recordUnresolvedAsset: (entry: {url?: string, importer?: string}) => void,
155
+ * recordAssetRebase: (entry: {status?: string, url?: string, rewritten?: string, importer?: string, resolvedAsset?: string, candidates?: string[]}) => void,
156
+ * recordExternalizedModule: (entry: {module?: string, importer?: string}) => void,
143
157
  * recordImportError: (entry: {file?: string, line?: number, specifier?: string}) => void,
144
158
  * recordSyntaxError: (entry: {minifier?: string, message?: string, declaration?: string}) => void,
145
159
  * hasCapturedBuildErrors: () => boolean,
@@ -147,10 +161,11 @@ const groupDeprecationsByFile = (deprecationList) => {
147
161
  * deprecations: Array<{id: string, occurrences: number, locations: Array<{file: string|undefined, line: number|undefined, count: number}>}>,
148
162
  * deprecationsByFile: Array<{file: string, occurrences: number, entries: Array<{id: string, count: number, lines: number[]}>}>,
149
163
  * unresolvedAssets: Array<{url: string, importer: string|undefined, count: number}>,
164
+ * assetRebases: Array<{status: string, url: string, rewritten: string|undefined, importer: string|undefined, resolvedAsset: string|undefined, candidates: string[]|undefined, count: number}>,
150
165
  * importErrors: Array<{file: string|undefined, line: number|undefined, specifier: string, count: number}>,
151
166
  * syntaxErrors: Array<{minifier: string|undefined, message: string, declaration: string|undefined, count: number}>,
152
167
  * warnings: Array<{message: string|undefined, file: string|undefined, line: number|undefined, count: number}>,
153
- * errors: Array<{message: string|undefined, file: string|undefined, line: number|undefined, count: number}>,
168
+ * errors: Array<{message: string|undefined, file: string|undefined, line: number|undefined, outputState?: 'incomplete', count: number}>,
154
169
  * deprecationTotal: number,
155
170
  * deprecationFileCount: number,
156
171
  * hasProblems: boolean
@@ -168,6 +183,10 @@ export function createDiagnosticsCollector() {
168
183
  /** @type {Map<string, {url: string, importer: string|undefined, count: number}>} */
169
184
  let unresolvedAssets = new Map();
170
185
  /** @type {Map<string, object>} */
186
+ let assetRebases = new Map();
187
+ /** @type {Map<string, {module: string, importer: string|undefined, count: number}>} */
188
+ let externalizedModules = new Map();
189
+ /** @type {Map<string, object>} */
171
190
  let importErrors = new Map();
172
191
  /** @type {Map<string, object>} */
173
192
  let syntaxErrors = new Map();
@@ -266,8 +285,8 @@ export function createDiagnosticsCollector() {
266
285
  /**
267
286
  * Record one CSS `url()` that Vite could not resolve at build time.
268
287
  *
269
- * Keyed by URL, because the same asset referenced from two stylesheets with
270
- * different relative paths is two separate things for an author to fix.
288
+ * Keyed by importer and URL, because the same spelling in two stylesheets
289
+ * is two separate source sites for an author to fix.
271
290
  *
272
291
  * @param {{url?: string, importer?: string}} entry - Unresolved asset.
273
292
  * @returns {void}
@@ -275,14 +294,76 @@ export function createDiagnosticsCollector() {
275
294
  recordUnresolvedAsset({ url, importer } = {}) {
276
295
  if (!url) return;
277
296
 
278
- const existing = unresolvedAssets.get(url);
297
+ const key = assetReferenceKey(importer, url);
298
+ const existing = unresolvedAssets.get(key);
299
+ if (existing) {
300
+ existing.count += 1;
301
+ return;
302
+ }
303
+
304
+ unresolvedAssets.set(key, { url, importer, count: 1 });
305
+ },
306
+
307
+ /**
308
+ * Record one CSS `url()` the build repaired, or could not choose for.
309
+ *
310
+ * A separate channel from `recordUnresolvedAsset` on purpose: Vite already
311
+ * warned about every one of these URLs, and folding them into that map
312
+ * would double the occurrence count it reports. `snapshot()` subtracts
313
+ * repaired URLs from the unresolved list instead.
314
+ *
315
+ * @param {{status?: string, url?: string, rewritten?: string, importer?: string, resolvedAsset?: string, candidates?: string[]}} entry - Rebase record.
316
+ * @returns {void}
317
+ */
318
+ recordAssetRebase({
319
+ status = 'rebased',
320
+ url,
321
+ rewritten,
322
+ importer,
323
+ resolvedAsset,
324
+ candidates,
325
+ } = {}) {
326
+ if (!url) return;
327
+
328
+ const key = assetReferenceKey(importer, url);
329
+ const existing = assetRebases.get(key);
330
+ if (existing) {
331
+ existing.count += 1;
332
+ return;
333
+ }
334
+
335
+ assetRebases.set(key, {
336
+ status,
337
+ url,
338
+ rewritten,
339
+ importer,
340
+ resolvedAsset,
341
+ candidates,
342
+ count: 1,
343
+ });
344
+ },
345
+
346
+ /**
347
+ * Record one module Vite externalized for browser compatibility.
348
+ *
349
+ * Vite emits this once per importing file per cycle, so a single dependency
350
+ * that reaches for a Node builtin prints on every keystroke. The identity
351
+ * that matters is the module, not the importer, so occurrences are tallied
352
+ * against the module name.
353
+ *
354
+ * @param {{module?: string, importer?: string}} entry - Externalized module.
355
+ * @returns {void}
356
+ */
357
+ recordExternalizedModule({ module, importer } = {}) {
358
+ if (!module) return;
359
+
360
+ const existing = externalizedModules.get(module);
279
361
  if (existing) {
280
362
  existing.count += 1;
281
- existing.importer = existing.importer || importer;
282
363
  return;
283
364
  }
284
365
 
285
- unresolvedAssets.set(url, { url, importer, count: 1 });
366
+ externalizedModules.set(module, { module, importer, count: 1 });
286
367
  },
287
368
 
288
369
  snapshot() {
@@ -306,7 +387,10 @@ export function createDiagnosticsCollector() {
306
387
  const errorList = [...errors.values()];
307
388
  const warningList = [...warnings.values()];
308
389
  const unresolvedAssetList = [...unresolvedAssets.values()].sort(
309
- (a, b) => b.count - a.count || a.url.localeCompare(b.url),
390
+ (a, b) =>
391
+ b.count - a.count ||
392
+ a.url.localeCompare(b.url) ||
393
+ String(a.importer || '').localeCompare(String(b.importer || '')),
310
394
  );
311
395
 
312
396
  const importErrorList = [...importErrors.values()].sort(
@@ -315,10 +399,54 @@ export function createDiagnosticsCollector() {
315
399
  (a.line ?? 0) - (b.line ?? 0),
316
400
  );
317
401
 
402
+ const assetRebaseList = [...assetRebases.values()];
403
+ const handledReferences = new Set(
404
+ assetRebaseList
405
+ .filter(
406
+ (entry) => entry.status === 'aliased' || entry.status === 'rebased',
407
+ )
408
+ .map((entry) => assetReferenceKey(entry.importer, entry.url)),
409
+ );
410
+ const handledUrls = new Set(
411
+ assetRebaseList
412
+ .filter(
413
+ (entry) => entry.status === 'aliased' || entry.status === 'rebased',
414
+ )
415
+ .map((entry) => entry.url),
416
+ );
417
+ // Vite warns about a URL before the rebase plugin repairs it, so without
418
+ // this a repaired reference or accepted alias is reported as an
419
+ // outstanding problem. The importer remains part of the identity:
420
+ // handling one stylesheet must not hide the same URL spelling in another
421
+ // stylesheet. When Vite's notice lacks an importer, fall back to URL
422
+ // matching because it sometimes reports the URL itself in the importer
423
+ // position.
424
+ const outstandingAssets = unresolvedAssetList.filter((asset) => {
425
+ if (
426
+ handledReferences.has(assetReferenceKey(asset.importer, asset.url))
427
+ ) {
428
+ return false;
429
+ }
430
+
431
+ if (!asset.importer) return !handledUrls.has(asset.url);
432
+ return true;
433
+ });
434
+
435
+ // `@assets/...` is an accepted authoring form. It is tracked internally
436
+ // only to suppress Vite's pre-normalization unresolved notice and must
437
+ // not appear as a repair in summaries or strict-mode failures.
438
+ const reportedAssetRebases = assetRebaseList.filter(
439
+ (entry) => entry.status !== 'aliased',
440
+ );
441
+
318
442
  return {
319
443
  deprecations: deprecationList,
320
444
  deprecationsByFile: groupDeprecationsByFile(deprecationList),
321
- unresolvedAssets: unresolvedAssetList,
445
+ unresolvedAssets: outstandingAssets,
446
+ assetRebases: reportedAssetRebases,
447
+ externalizedModules: [...externalizedModules.values()].sort(
448
+ (a, b) => b.count - a.count || a.module.localeCompare(b.module),
449
+ ),
322
450
  importErrors: importErrorList,
323
451
  syntaxErrors: [...syntaxErrors.values()],
324
452
  warnings: warningList,
@@ -332,7 +460,7 @@ export function createDiagnosticsCollector() {
332
460
  errorList.length > 0 ||
333
461
  warningList.length > 0 ||
334
462
  deprecationList.length > 0 ||
335
- unresolvedAssetList.length > 0 ||
463
+ outstandingAssets.length > 0 ||
336
464
  importErrorList.length > 0 ||
337
465
  syntaxErrors.size > 0,
338
466
  };
@@ -360,6 +488,8 @@ export function createDiagnosticsCollector() {
360
488
  warnings = new Map();
361
489
  errors = new Map();
362
490
  unresolvedAssets = new Map();
491
+ assetRebases = new Map();
492
+ externalizedModules = new Map();
363
493
  importErrors = new Map();
364
494
  syntaxErrors = new Map();
365
495
  },