@emulsify/core 4.2.1 → 4.3.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 (78) hide show
  1. package/.storybook/main-vite.js +91 -36
  2. package/README.md +185 -56
  3. package/config/a11y.config.js +2 -1
  4. package/config/vite/entries.js +4 -4
  5. package/config/vite/plugins/{copy-src-assets.js → assets/copy-src-assets.js} +1 -1
  6. package/config/vite/plugins/{copy-twig-files.js → assets/copy-twig-files.js} +1 -1
  7. package/config/vite/plugins/{css-asset-relativizer.js → assets/css-asset-relativizer.js} +9 -0
  8. package/config/vite/plugins/{mirror-components.js → assets/mirror-components.js} +2 -28
  9. package/config/vite/plugins/{source-file-index.js → assets/source-file-index.js} +1 -1
  10. package/config/vite/plugins/{svg-sprite.js → assets/svg-sprite.js} +2 -2
  11. package/config/vite/plugins/index.js +23 -14
  12. package/config/vite/plugins/reporter/asset-resolver.js +541 -0
  13. package/config/vite/plugins/reporter/build-errors.js +284 -0
  14. package/config/vite/plugins/reporter/diagnostics.js +366 -0
  15. package/config/vite/plugins/reporter/format.js +199 -0
  16. package/config/vite/plugins/reporter/index.js +308 -0
  17. package/config/vite/plugins/reporter/render.js +758 -0
  18. package/config/vite/plugins/reporter/sass-logger.js +237 -0
  19. package/config/vite/plugins/reporter/vite-logger.js +188 -0
  20. package/config/vite/plugins/reporter/watch-mode.js +40 -0
  21. package/config/vite/plugins/{twig-extension-installers.js → twig/extension-installers.js} +1 -1
  22. package/config/vite/{twig-extensions.js → plugins/twig/extensions.js} +2 -2
  23. package/config/vite/plugins/{twig-module.js → twig/twig-module.js} +197 -85
  24. package/config/vite/plugins/{virtual-twig-asset-sources.js → twig/virtual-twig-asset-sources.js} +37 -136
  25. package/config/vite/plugins/{virtual-twig-globs.js → twig/virtual-twig-globs.js} +3 -32
  26. package/config/vite/plugins/{vituum-patch.js → twig/vituum-patch.js} +3 -3
  27. package/config/vite/plugins.js +1 -1
  28. package/config/vite/project-config.js +1 -1
  29. package/config/vite/project-structure.js +1 -1
  30. package/config/vite/utils/lru.js +77 -0
  31. package/config/vite/utils/package-version.js +42 -0
  32. package/config/vite/utils/paths.js +1 -9
  33. package/config/vite/utils/react-singleton.js +1 -1
  34. package/config/vite/vite.config.js +51 -5
  35. package/package.json +93 -58
  36. package/scripts/a11y.js +115 -23
  37. package/scripts/audit/checks/core-imports.js +78 -0
  38. package/scripts/audit/checks/css-asset-references.js +99 -0
  39. package/scripts/audit/checks/drupal-assumptions.js +48 -0
  40. package/scripts/audit/checks/files-outside-roots.js +53 -0
  41. package/scripts/audit/checks/generated-package-scripts.js +113 -0
  42. package/scripts/audit/checks/legacy-twig-stories.js +33 -0
  43. package/scripts/audit/checks/package-overrides.js +91 -0
  44. package/scripts/audit/checks/project-config.js +71 -0
  45. package/scripts/audit/checks/story-discovery.js +35 -0
  46. package/scripts/audit/checks/twig-references.js +69 -0
  47. package/scripts/audit/checks/twig-volume.js +54 -0
  48. package/scripts/audit/checks/webpack-patterns.js +86 -0
  49. package/scripts/audit/index.js +177 -0
  50. package/scripts/audit/lib/css.js +165 -0
  51. package/scripts/audit/lib/files.js +168 -0
  52. package/scripts/audit/lib/findings.js +31 -0
  53. package/scripts/audit/lib/package-json.js +65 -0
  54. package/scripts/audit/lib/twig.js +227 -0
  55. package/scripts/audit/report.js +273 -0
  56. package/scripts/audit-twig-stories.js +115 -78
  57. package/scripts/audit.js +150 -1632
  58. package/scripts/check-node-version.js +136 -10
  59. package/scripts/inspect-components.js +456 -0
  60. package/scripts/lib/cli.js +179 -0
  61. package/scripts/lib/fs.js +31 -0
  62. package/scripts/lib/proc.js +78 -0
  63. package/scripts/lib/text.js +14 -0
  64. package/scripts/loadYaml.js +2 -2
  65. package/src/extensions/shared/attributes.js +3 -3
  66. package/src/extensions/shared/lists.js +2 -6
  67. package/src/extensions/shared/root-relative.js +38 -0
  68. package/src/storybook/index.js +4 -0
  69. package/src/storybook/render-twig.js +1 -1
  70. package/src/storybook/render-web-component.js +459 -0
  71. package/src/storybook/twig/asset-source-runtime.js +193 -0
  72. package/src/storybook/twig/{source-extensions.js → constants.js} +3 -1
  73. package/src/storybook/twig/reference-paths.js +2 -13
  74. package/src/storybook/twig/setup.js +12 -2
  75. package/src/storybook/twig/source-function.js +5 -2
  76. package/config/vite/utils/unique.js +0 -36
  77. package/src/storybook/twig/include.js +0 -28
  78. package/src/storybook/twig/source-events.js +0 -5
@@ -0,0 +1,284 @@
1
+ /**
2
+ * @file Build error extraction for the Emulsify develop reporter.
3
+ *
4
+ * Rolldown reports a failed build as a single wrapper error whose message is
5
+ * `Build failed with N errors:` followed by every error rendered as text. Read
6
+ * naively, the reporter shows one error with a message that names a count and
7
+ * nothing else.
8
+ *
9
+ * The individual errors are reachable through the wrapper's `errors` property,
10
+ * and each one carries a `cause` chain that ends at the original Sass
11
+ * exception. That exception is fully structured — `span.url`, `span.start.line`
12
+ * and `span.text` give the file, the line, and the offending statement
13
+ * verbatim — so nothing has to be scraped out of formatted output.
14
+ *
15
+ * @see https://github.com/rolldown/rolldown - aggregateBindingErrorsIntoJsError
16
+ */
17
+
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ /**
21
+ * Matches Sass's "missing import" failure.
22
+ *
23
+ * @type {RegExp}
24
+ */
25
+ const MISSING_IMPORT = /Can't find stylesheet to import/i;
26
+
27
+ /**
28
+ * Captures the specifier from a `@use`, `@forward`, or `@import` statement.
29
+ *
30
+ * @type {RegExp}
31
+ */
32
+ const IMPORT_SPECIFIER = /@(?:use|forward|import)\s+['"]([^'"]+)['"]/;
33
+
34
+ /**
35
+ * Captures a line number and its source from a Sass code frame.
36
+ *
37
+ * Used only when the structured span is unavailable.
38
+ *
39
+ * @type {RegExp}
40
+ */
41
+ const FRAME_LINE = /^\s*(\d+)\s*│\s?(.*)$/m;
42
+
43
+ /**
44
+ * Maximum depth walked through `cause` chains.
45
+ *
46
+ * @type {number}
47
+ */
48
+ const MAX_CAUSE_DEPTH = 10;
49
+
50
+ /**
51
+ * Captures the minifier name Vite prefixes onto a CSS minification failure.
52
+ *
53
+ * Both the lightningcss and esbuild paths tag the message this way before
54
+ * rethrowing, and both attach `loc` and `frame` alongside it.
55
+ *
56
+ * @type {RegExp}
57
+ */
58
+ const MINIFIER_PREFIX = /^\[((?:lightningcss|esbuild css) minify)\]\s*/;
59
+
60
+ /**
61
+ * Captures one numbered row of a Vite code frame.
62
+ *
63
+ * @type {RegExp}
64
+ */
65
+ const FRAME_ROW = /^\s*(\d+)\s*\|\s?(.*)$/;
66
+
67
+ /**
68
+ * Captures the caret row that follows the offending row of a code frame.
69
+ *
70
+ * @type {RegExp}
71
+ */
72
+ const FRAME_CARET = /^\s*\|\s?(\s*)\^/;
73
+
74
+ /**
75
+ * Parse a CSS minifier syntax failure.
76
+ *
77
+ * These are unlike every other error the reporter handles: the minifier runs
78
+ * on the concatenated bundle, so `loc.line` is a line of generated CSS and
79
+ * points at no file anyone wrote. What is salvageable is the offending
80
+ * declaration itself, which is enough to search the project for.
81
+ *
82
+ * @param {object} error - Individual build error.
83
+ * @returns {{
84
+ * minifier: string,
85
+ * message: string,
86
+ * bundleLine: number|undefined,
87
+ * declaration: string|undefined,
88
+ * caretColumn: number|undefined
89
+ * }|undefined} Parsed syntax error.
90
+ */
91
+ export function parseCssSyntaxError(error) {
92
+ const rawMessage = String(error?.message || '');
93
+ const prefix = MINIFIER_PREFIX.exec(rawMessage);
94
+ if (!prefix) return undefined;
95
+
96
+ const [firstLine] = rawMessage.replace(MINIFIER_PREFIX, '').split('\n');
97
+ const parsed = {
98
+ minifier: prefix[1],
99
+ message: firstLine.trim(),
100
+ bundleLine:
101
+ typeof error?.loc?.line === 'number' ? error.loc.line : undefined,
102
+ declaration: undefined,
103
+ caretColumn: undefined,
104
+ };
105
+
106
+ const rows = String(error?.frame || '').split('\n');
107
+
108
+ for (let index = 0; index < rows.length; index += 1) {
109
+ const row = FRAME_ROW.exec(rows[index]);
110
+ if (!row) continue;
111
+
112
+ // Without a bundle line, the row carrying a caret beneath it is the one.
113
+ const isOffending =
114
+ parsed.bundleLine == null
115
+ ? FRAME_CARET.test(rows[index + 1] || '')
116
+ : Number(row[1]) === parsed.bundleLine;
117
+ if (!isOffending) continue;
118
+
119
+ const leading = row[2].length - row[2].trimStart().length;
120
+ parsed.declaration = row[2].trim();
121
+
122
+ const caret = FRAME_CARET.exec(rows[index + 1] || '');
123
+ if (caret) {
124
+ // Re-base the caret against the trimmed declaration.
125
+ parsed.caretColumn = Math.max(0, caret[1].length - leading);
126
+ }
127
+
128
+ break;
129
+ }
130
+
131
+ return parsed;
132
+ }
133
+
134
+ /**
135
+ * Flatten a rolldown build error into the individual errors it wraps.
136
+ *
137
+ * @param {Error & {errors?: Array<object>}} error - Build error.
138
+ * @returns {Array<object>} Individual errors.
139
+ */
140
+ export function flattenBuildErrors(error) {
141
+ if (!error) return [];
142
+
143
+ const nested = error.errors;
144
+ if (!Array.isArray(nested) || nested.length === 0) return [error];
145
+
146
+ return nested.flatMap((entry) => flattenBuildErrors(entry));
147
+ }
148
+
149
+ /**
150
+ * Walk an error's `cause` chain looking for an entry with a Sass span.
151
+ *
152
+ * @param {object} error - Error to inspect.
153
+ * @returns {object|undefined} Error carrying a Sass span.
154
+ */
155
+ function findSpanned(error) {
156
+ let current = error;
157
+
158
+ for (let depth = 0; current && depth < MAX_CAUSE_DEPTH; depth += 1) {
159
+ if (current.span?.url || current.span?.start) return current;
160
+ current = current.cause;
161
+ }
162
+
163
+ return undefined;
164
+ }
165
+
166
+ /**
167
+ * Convert a Sass span URL to a filesystem path.
168
+ *
169
+ * @param {URL|string|undefined} url - Span URL.
170
+ * @returns {string|undefined} Filesystem path.
171
+ */
172
+ function spanPath(url) {
173
+ if (!url) return undefined;
174
+ const href = typeof url === 'string' ? url : url.href;
175
+ if (!href?.startsWith('file:')) return href || undefined;
176
+
177
+ try {
178
+ return fileURLToPath(href);
179
+ } catch {
180
+ return href;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Reduce a message to its first line, without the plugin prefix.
186
+ *
187
+ * @param {string|undefined} message - Raw message.
188
+ * @returns {string} Condensed message.
189
+ */
190
+ function condense(message) {
191
+ const [first] = String(message || '').split('\n');
192
+ return first.replace(/^\[[^\]]+\]\s*/, '').trim();
193
+ }
194
+
195
+ /**
196
+ * Describe one build error in the terms the reporter renders.
197
+ *
198
+ * Prefers the structured Sass span and falls back to the rendered code frame,
199
+ * which is the only source of a line number when a plugin re-throws without
200
+ * preserving the original exception.
201
+ *
202
+ * @param {object} error - Individual build error.
203
+ * @returns {{
204
+ * message: string,
205
+ * file: string|undefined,
206
+ * line: number|undefined,
207
+ * statement: string|undefined,
208
+ * specifier: string|undefined,
209
+ * isMissingImport: boolean
210
+ * }} Description.
211
+ */
212
+ export function describeBuildError(error) {
213
+ const spanned = findSpanned(error);
214
+ const message = condense(spanned?.message || error?.message);
215
+
216
+ let file = spanPath(spanned?.span?.url);
217
+ let line =
218
+ typeof spanned?.span?.start?.line === 'number'
219
+ ? spanned.span.start.line + 1
220
+ : undefined;
221
+ let statement = spanned?.span?.text?.trim();
222
+
223
+ if (!file) file = error?.loc?.file || error?.id || undefined;
224
+ if (line == null && typeof error?.loc?.line === 'number') {
225
+ line = error.loc.line;
226
+ }
227
+
228
+ if (!statement && error?.frame) {
229
+ const framed = FRAME_LINE.exec(error.frame);
230
+ if (framed) {
231
+ statement = framed[2].trim();
232
+ if (line == null) line = Number(framed[1]);
233
+ }
234
+ }
235
+
236
+ return {
237
+ message,
238
+ file,
239
+ line,
240
+ statement,
241
+ specifier: statement ? IMPORT_SPECIFIER.exec(statement)?.[1] : undefined,
242
+ isMissingImport: MISSING_IMPORT.test(message),
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Split a build failure into missing imports and everything else.
248
+ *
249
+ * Missing imports get their own table because they share a shape — a file, a
250
+ * line, and a path that does not resolve — and because one deleted partial
251
+ * commonly produces a dozen of them.
252
+ *
253
+ * @param {Error & {errors?: Array<object>}} error - Build error.
254
+ * @returns {{
255
+ * importErrors: Array<object>,
256
+ * syntaxErrors: Array<object>,
257
+ * otherErrors: Array<object>
258
+ * }} Classified errors.
259
+ */
260
+ export function classifyBuildError(error) {
261
+ const importErrors = [];
262
+ const syntaxErrors = [];
263
+ const otherErrors = [];
264
+
265
+ for (const entry of flattenBuildErrors(error)) {
266
+ const syntaxError = parseCssSyntaxError(entry);
267
+ if (syntaxError) {
268
+ syntaxErrors.push(syntaxError);
269
+ continue;
270
+ }
271
+
272
+ const described = describeBuildError(entry);
273
+
274
+ // A missing import with no specifier cannot be tabulated, so it falls
275
+ // through to the general error list rather than showing a blank row.
276
+ if (described.isMissingImport && described.specifier) {
277
+ importErrors.push(described);
278
+ } else {
279
+ otherErrors.push(described);
280
+ }
281
+ }
282
+
283
+ return { importErrors, syntaxErrors, otherErrors };
284
+ }
@@ -0,0 +1,366 @@
1
+ /**
2
+ * @file Diagnostic collection for the Emulsify develop reporter.
3
+ *
4
+ * Vite, Sass, and Rollup each report problems on their own schedule and in
5
+ * their own format. A single build cycle can emit the same Sass deprecation
6
+ * dozens of times, once per entry that transitively imports the offending
7
+ * partial, which is what makes the default `npm run develop` output unreadable.
8
+ *
9
+ * This module collects those reports into one per-cycle model that counts
10
+ * repeats instead of reprinting them. Deprecations are bucketed by deprecation
11
+ * ID, then by source location within that ID, so the reporter can say
12
+ * "slash-div, 20 occurrences across 1 file" rather than emitting 20 stack
13
+ * traces. Errors and plain warnings are deduplicated on the same key so a
14
+ * failure surfaced by two channels only reads as one problem.
15
+ */
16
+
17
+ const UNKNOWN_DEPRECATION_ID = 'unknown';
18
+
19
+ /**
20
+ * Build the deduplication key for a single reported source location.
21
+ *
22
+ * @param {string|undefined} file - Source file path, when known.
23
+ * @param {number|undefined} line - 1-based line number, when known.
24
+ * @returns {string} Stable location key.
25
+ */
26
+ const locationKey = (file, line) =>
27
+ `${file || '<unknown>'}:${line == null ? '?' : line}`;
28
+
29
+ /**
30
+ * Build the deduplication key for an error or plain warning.
31
+ *
32
+ * @param {{message?: string, file?: string, line?: number}} entry - Reported entry.
33
+ * @returns {string} Stable entry key.
34
+ */
35
+ const entryKey = (entry) =>
36
+ `${locationKey(entry.file, entry.line)}|${entry.message || ''}`;
37
+
38
+ /**
39
+ * Record one occurrence against a location map, incrementing when repeated.
40
+ *
41
+ * @param {Map<string, {file: string|undefined, line: number|undefined, count: number}>} locations - Location map.
42
+ * @param {string|undefined} file - Source file path.
43
+ * @param {number|undefined} line - 1-based line number.
44
+ * @returns {void}
45
+ */
46
+ const tallyLocation = (locations, file, line) => {
47
+ const key = locationKey(file, line);
48
+ const existing = locations.get(key);
49
+
50
+ if (existing) {
51
+ existing.count += 1;
52
+ return;
53
+ }
54
+
55
+ locations.set(key, { file, line, count: 1 });
56
+ };
57
+
58
+ /**
59
+ * Sort locations by descending occurrence count, then by path for stability.
60
+ *
61
+ * @param {{file: string|undefined, line: number|undefined, count: number}} a - First location.
62
+ * @param {{file: string|undefined, line: number|undefined, count: number}} b - Second location.
63
+ * @returns {number} Comparator result.
64
+ */
65
+ const byCountThenPath = (a, b) =>
66
+ b.count - a.count ||
67
+ locationKey(a.file, a.line).localeCompare(locationKey(b.file, b.line));
68
+
69
+ /**
70
+ * Invert the deprecation buckets into a per-file worklist.
71
+ *
72
+ * The collector groups by deprecation ID because that is how Sass reports, but
73
+ * fixing the debt is a per-file activity: you open one partial and change the
74
+ * handful of lines inside it. This projection produces that view — each file
75
+ * with the offending lines beneath it — without disturbing the ID-keyed model
76
+ * the totals are computed from.
77
+ *
78
+ * Within a file the same deprecation is collapsed into one entry carrying every
79
+ * affected line. Ten `slash-div` hits across ten lines of one partial is one
80
+ * edit to make, not ten findings to read.
81
+ *
82
+ * @param {Array<{id: string, locations: Array<{file: string|undefined, line: number|undefined, count: number}>}>} deprecationList - ID-keyed buckets.
83
+ * @returns {Array<{file: string, occurrences: number, entries: Array<{id: string, count: number, lines: number[]}>}>} File-keyed worklist.
84
+ */
85
+ const groupDeprecationsByFile = (deprecationList) => {
86
+ /** @type {Map<string, {file: string, occurrences: number, entries: Map<string, object>}>} */
87
+ const files = new Map();
88
+
89
+ for (const bucket of deprecationList) {
90
+ for (const location of bucket.locations) {
91
+ // A location with no file cannot be opened, so it has no place in a
92
+ // worklist. Those still count toward the headline totals.
93
+ if (!location.file) continue;
94
+
95
+ let group = files.get(location.file);
96
+ if (!group) {
97
+ group = { file: location.file, occurrences: 0, entries: new Map() };
98
+ files.set(location.file, group);
99
+ }
100
+
101
+ let entry = group.entries.get(bucket.id);
102
+ if (!entry) {
103
+ entry = { id: bucket.id, count: 0, lines: [] };
104
+ group.entries.set(bucket.id, entry);
105
+ }
106
+
107
+ group.occurrences += location.count;
108
+ entry.count += location.count;
109
+ if (location.line != null && !entry.lines.includes(location.line)) {
110
+ entry.lines.push(location.line);
111
+ }
112
+ }
113
+ }
114
+
115
+ return [...files.values()]
116
+ .map((group) => ({
117
+ file: group.file,
118
+ occurrences: group.occurrences,
119
+ entries: [...group.entries.values()]
120
+ .map((entry) => ({
121
+ ...entry,
122
+ lines: [...entry.lines].sort((a, b) => a - b),
123
+ }))
124
+ .sort((a, b) => b.count - a.count || a.id.localeCompare(b.id)),
125
+ }))
126
+ .sort(
127
+ (a, b) => b.occurrences - a.occurrences || a.file.localeCompare(b.file),
128
+ );
129
+ };
130
+
131
+ /**
132
+ * Create a per-cycle diagnostic collector.
133
+ *
134
+ * The collector is intentionally synchronous and process-local. The Sass logger
135
+ * and the reporter plugin both run inside the same Vite process, so they can
136
+ * share one instance directly without any cross-process coordination.
137
+ *
138
+ * @returns {{
139
+ * recordDeprecation: (entry: {id?: string, file?: string, line?: number}) => void,
140
+ * recordWarning: (entry: {message?: string, file?: string, line?: number}) => void,
141
+ * recordError: (entry: {message?: string, file?: string, line?: number}) => void,
142
+ * recordUnresolvedAsset: (entry: {url?: string, importer?: string}) => void,
143
+ * recordImportError: (entry: {file?: string, line?: number, specifier?: string}) => void,
144
+ * recordSyntaxError: (entry: {minifier?: string, message?: string, declaration?: string}) => void,
145
+ * hasCapturedBuildErrors: () => boolean,
146
+ * snapshot: () => {
147
+ * deprecations: Array<{id: string, occurrences: number, locations: Array<{file: string|undefined, line: number|undefined, count: number}>}>,
148
+ * deprecationsByFile: Array<{file: string, occurrences: number, entries: Array<{id: string, count: number, lines: number[]}>}>,
149
+ * unresolvedAssets: Array<{url: string, importer: string|undefined, count: number}>,
150
+ * importErrors: Array<{file: string|undefined, line: number|undefined, specifier: string, count: number}>,
151
+ * warnings: Array<{message: string|undefined, file: string|undefined, line: number|undefined, count: number}>,
152
+ * errors: Array<{message: string|undefined, file: string|undefined, line: number|undefined, count: number}>,
153
+ * deprecationTotal: number,
154
+ * deprecationFileCount: number,
155
+ * hasProblems: boolean
156
+ * },
157
+ * reset: () => void
158
+ * }} Diagnostic collector.
159
+ */
160
+ export function createDiagnosticsCollector() {
161
+ /** @type {Map<string, {id: string, occurrences: number, locations: Map<string, object>}>} */
162
+ let deprecations = new Map();
163
+ /** @type {Map<string, object>} */
164
+ let warnings = new Map();
165
+ /** @type {Map<string, object>} */
166
+ let errors = new Map();
167
+ /** @type {Map<string, {url: string, importer: string|undefined, count: number}>} */
168
+ let unresolvedAssets = new Map();
169
+ /** @type {Map<string, object>} */
170
+ let importErrors = new Map();
171
+ /** @type {Map<string, object>} */
172
+ let syntaxErrors = new Map();
173
+
174
+ /**
175
+ * Record one deprecation occurrence.
176
+ *
177
+ * @param {{id?: string, file?: string, line?: number}} entry - Deprecation details.
178
+ * @returns {void}
179
+ */
180
+ const recordDeprecation = ({ id, file, line } = {}) => {
181
+ const deprecationId = id || UNKNOWN_DEPRECATION_ID;
182
+ let bucket = deprecations.get(deprecationId);
183
+
184
+ if (!bucket) {
185
+ bucket = { id: deprecationId, occurrences: 0, locations: new Map() };
186
+ deprecations.set(deprecationId, bucket);
187
+ }
188
+
189
+ bucket.occurrences += 1;
190
+ tallyLocation(bucket.locations, file, line);
191
+ };
192
+
193
+ /**
194
+ * Record one entry against a deduplicating map.
195
+ *
196
+ * @param {Map<string, object>} target - Destination map.
197
+ * @param {{message?: string, file?: string, line?: number}} entry - Entry details.
198
+ * @returns {void}
199
+ */
200
+ const recordEntry = (target, entry = {}) => {
201
+ const key = entryKey(entry);
202
+ const existing = target.get(key);
203
+
204
+ if (existing) {
205
+ existing.count += 1;
206
+ return;
207
+ }
208
+
209
+ target.set(key, { ...entry, count: 1 });
210
+ };
211
+
212
+ return {
213
+ recordDeprecation,
214
+
215
+ recordWarning: (entry) => recordEntry(warnings, entry),
216
+
217
+ recordError: (entry) => recordEntry(errors, entry),
218
+
219
+ /**
220
+ * Record one Sass import that could not be resolved.
221
+ *
222
+ * Keyed by the importing site and the specifier, because the same missing
223
+ * partial reported through two entrypoints is one thing to fix.
224
+ *
225
+ * @param {{file?: string, line?: number, specifier?: string, statement?: string}} entry - Import error.
226
+ * @returns {void}
227
+ */
228
+ recordImportError(entry = {}) {
229
+ if (!entry.specifier) return;
230
+
231
+ const key = `${entry.file || '?'}:${entry.line ?? '?'}|${entry.specifier}`;
232
+ const existing = importErrors.get(key);
233
+
234
+ if (existing) {
235
+ existing.count += 1;
236
+ return;
237
+ }
238
+
239
+ importErrors.set(key, { ...entry, count: 1 });
240
+ },
241
+
242
+ /**
243
+ * Record one CSS minifier syntax failure.
244
+ *
245
+ * Keyed by the offending declaration, since the same broken rule reached
246
+ * through several entrypoints is one thing to fix.
247
+ *
248
+ * @param {{minifier?: string, message?: string, declaration?: string}} entry - Syntax error.
249
+ * @returns {void}
250
+ */
251
+ recordSyntaxError(entry = {}) {
252
+ if (!entry.message) return;
253
+
254
+ const key = `${entry.declaration || ''}|${entry.message}`;
255
+ const existing = syntaxErrors.get(key);
256
+
257
+ if (existing) {
258
+ existing.count += 1;
259
+ return;
260
+ }
261
+
262
+ syntaxErrors.set(key, { ...entry, count: 1 });
263
+ },
264
+
265
+ /**
266
+ * Record one CSS `url()` that Vite could not resolve at build time.
267
+ *
268
+ * Keyed by URL, because the same asset referenced from two stylesheets with
269
+ * different relative paths is two separate things for an author to fix.
270
+ *
271
+ * @param {{url?: string, importer?: string}} entry - Unresolved asset.
272
+ * @returns {void}
273
+ */
274
+ recordUnresolvedAsset({ url, importer } = {}) {
275
+ if (!url) return;
276
+
277
+ const existing = unresolvedAssets.get(url);
278
+ if (existing) {
279
+ existing.count += 1;
280
+ existing.importer = existing.importer || importer;
281
+ return;
282
+ }
283
+
284
+ unresolvedAssets.set(url, { url, importer, count: 1 });
285
+ },
286
+
287
+ snapshot() {
288
+ const deprecationList = [...deprecations.values()]
289
+ .map((bucket) => ({
290
+ id: bucket.id,
291
+ occurrences: bucket.occurrences,
292
+ locations: [...bucket.locations.values()].sort(byCountThenPath),
293
+ }))
294
+ .sort(
295
+ (a, b) => b.occurrences - a.occurrences || a.id.localeCompare(b.id),
296
+ );
297
+
298
+ const distinctFiles = new Set();
299
+ for (const bucket of deprecationList) {
300
+ for (const location of bucket.locations) {
301
+ if (location.file) distinctFiles.add(location.file);
302
+ }
303
+ }
304
+
305
+ const errorList = [...errors.values()];
306
+ const warningList = [...warnings.values()];
307
+ const unresolvedAssetList = [...unresolvedAssets.values()].sort(
308
+ (a, b) => b.count - a.count || a.url.localeCompare(b.url),
309
+ );
310
+
311
+ const importErrorList = [...importErrors.values()].sort(
312
+ (a, b) =>
313
+ String(a.file).localeCompare(String(b.file)) ||
314
+ (a.line ?? 0) - (b.line ?? 0),
315
+ );
316
+
317
+ return {
318
+ deprecations: deprecationList,
319
+ deprecationsByFile: groupDeprecationsByFile(deprecationList),
320
+ unresolvedAssets: unresolvedAssetList,
321
+ importErrors: importErrorList,
322
+ syntaxErrors: [...syntaxErrors.values()],
323
+ warnings: warningList,
324
+ errors: errorList,
325
+ deprecationTotal: deprecationList.reduce(
326
+ (total, bucket) => total + bucket.occurrences,
327
+ 0,
328
+ ),
329
+ deprecationFileCount: distinctFiles.size,
330
+ hasProblems:
331
+ errorList.length > 0 ||
332
+ warningList.length > 0 ||
333
+ deprecationList.length > 0 ||
334
+ unresolvedAssetList.length > 0 ||
335
+ importErrorList.length > 0 ||
336
+ syntaxErrors.size > 0,
337
+ };
338
+ },
339
+
340
+ /**
341
+ * Whether any build error has been captured this cycle.
342
+ *
343
+ * The logger consults this before swallowing Rolldown's raw error dump, so
344
+ * output is only suppressed once the reporter has something to show in its
345
+ * place.
346
+ *
347
+ * Deliberately covers ordinary errors as well as missing imports. Scoping
348
+ * it to imports alone meant a failure of any other kind — an undefined
349
+ * mixin, say — was reported by the summary *and* dumped raw, so the one
350
+ * error appeared twice.
351
+ *
352
+ * @returns {boolean} TRUE when any build error was captured.
353
+ */
354
+ hasCapturedBuildErrors: () =>
355
+ importErrors.size > 0 || errors.size > 0 || syntaxErrors.size > 0,
356
+
357
+ reset() {
358
+ deprecations = new Map();
359
+ warnings = new Map();
360
+ errors = new Map();
361
+ unresolvedAssets = new Map();
362
+ importErrors = new Map();
363
+ syntaxErrors = new Map();
364
+ },
365
+ };
366
+ }