@emulsify/core 4.2.1 → 4.3.1

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 (84) hide show
  1. package/.storybook/main-vite.js +102 -37
  2. package/.storybook/main.js +20 -0
  3. package/.storybook/ready-reporter.js +230 -0
  4. package/README.md +186 -56
  5. package/config/a11y.config.js +2 -1
  6. package/config/vite/entries.js +4 -4
  7. package/config/vite/plugins/assets/copy-src-assets.js +120 -0
  8. package/config/vite/plugins/assets/copy-twig-files.js +130 -0
  9. package/config/vite/plugins/{css-asset-relativizer.js → assets/css-asset-relativizer.js} +9 -0
  10. package/config/vite/plugins/{mirror-components.js → assets/mirror-components.js} +2 -28
  11. package/config/vite/plugins/{source-file-index.js → assets/source-file-index.js} +1 -1
  12. package/config/vite/plugins/{svg-sprite.js → assets/svg-sprite.js} +2 -2
  13. package/config/vite/plugins/index.js +23 -14
  14. package/config/vite/plugins/reporter/asset-resolver.js +541 -0
  15. package/config/vite/plugins/reporter/build-errors.js +284 -0
  16. package/config/vite/plugins/reporter/diagnostics.js +367 -0
  17. package/config/vite/plugins/reporter/format.js +240 -0
  18. package/config/vite/plugins/reporter/index.js +417 -0
  19. package/config/vite/plugins/reporter/render.js +1332 -0
  20. package/config/vite/plugins/reporter/sass-logger.js +237 -0
  21. package/config/vite/plugins/reporter/source-roots.js +561 -0
  22. package/config/vite/plugins/reporter/verbosity.js +119 -0
  23. package/config/vite/plugins/reporter/vite-logger.js +249 -0
  24. package/config/vite/plugins/reporter/watch-mode.js +40 -0
  25. package/config/vite/plugins/{twig-extension-installers.js → twig/extension-installers.js} +1 -1
  26. package/config/vite/{twig-extensions.js → plugins/twig/extensions.js} +2 -2
  27. package/config/vite/plugins/{twig-module.js → twig/twig-module.js} +197 -85
  28. package/config/vite/plugins/{virtual-twig-asset-sources.js → twig/virtual-twig-asset-sources.js} +37 -136
  29. package/config/vite/plugins/{virtual-twig-globs.js → twig/virtual-twig-globs.js} +3 -32
  30. package/config/vite/plugins/{vituum-patch.js → twig/vituum-patch.js} +3 -3
  31. package/config/vite/plugins.js +1 -1
  32. package/config/vite/project-config.js +1 -1
  33. package/config/vite/project-structure.js +1 -1
  34. package/config/vite/utils/lru.js +77 -0
  35. package/config/vite/utils/package-version.js +42 -0
  36. package/config/vite/utils/paths.js +1 -9
  37. package/config/vite/utils/react-singleton.js +1 -1
  38. package/config/vite/vite.config.js +91 -5
  39. package/package.json +95 -57
  40. package/scripts/a11y.js +115 -23
  41. package/scripts/audit/checks/core-imports.js +78 -0
  42. package/scripts/audit/checks/css-asset-references.js +99 -0
  43. package/scripts/audit/checks/drupal-assumptions.js +48 -0
  44. package/scripts/audit/checks/files-outside-roots.js +53 -0
  45. package/scripts/audit/checks/generated-package-scripts.js +113 -0
  46. package/scripts/audit/checks/legacy-twig-stories.js +33 -0
  47. package/scripts/audit/checks/package-overrides.js +91 -0
  48. package/scripts/audit/checks/project-config.js +71 -0
  49. package/scripts/audit/checks/story-discovery.js +35 -0
  50. package/scripts/audit/checks/twig-references.js +69 -0
  51. package/scripts/audit/checks/twig-volume.js +54 -0
  52. package/scripts/audit/checks/webpack-patterns.js +86 -0
  53. package/scripts/audit/index.js +177 -0
  54. package/scripts/audit/lib/css.js +165 -0
  55. package/scripts/audit/lib/files.js +168 -0
  56. package/scripts/audit/lib/findings.js +31 -0
  57. package/scripts/audit/lib/package-json.js +65 -0
  58. package/scripts/audit/lib/twig.js +227 -0
  59. package/scripts/audit/report.js +273 -0
  60. package/scripts/audit-twig-stories.js +115 -78
  61. package/scripts/audit.js +150 -1632
  62. package/scripts/check-node-version.js +136 -10
  63. package/scripts/inspect-components.js +456 -0
  64. package/scripts/lib/cli.js +179 -0
  65. package/scripts/lib/fs.js +31 -0
  66. package/scripts/lib/proc.js +78 -0
  67. package/scripts/lib/text.js +14 -0
  68. package/scripts/loadYaml.js +2 -2
  69. package/src/extensions/shared/attributes.js +3 -3
  70. package/src/extensions/shared/lists.js +2 -6
  71. package/src/extensions/shared/root-relative.js +38 -0
  72. package/src/storybook/index.js +4 -0
  73. package/src/storybook/render-twig.js +1 -1
  74. package/src/storybook/render-web-component.js +459 -0
  75. package/src/storybook/twig/asset-source-runtime.js +193 -0
  76. package/src/storybook/twig/{source-extensions.js → constants.js} +3 -1
  77. package/src/storybook/twig/reference-paths.js +2 -13
  78. package/src/storybook/twig/setup.js +12 -2
  79. package/src/storybook/twig/source-function.js +5 -2
  80. package/config/vite/plugins/copy-src-assets.js +0 -76
  81. package/config/vite/plugins/copy-twig-files.js +0 -84
  82. package/config/vite/utils/unique.js +0 -36
  83. package/src/storybook/twig/include.js +0 -28
  84. package/src/storybook/twig/source-events.js +0 -5
@@ -0,0 +1,561 @@
1
+ /**
2
+ * @file Source root attribution for the Emulsify develop reporter.
3
+ *
4
+ * The reporter's facts block answers a question nothing in the previous output
5
+ * answered: which directories is Emulsify actually reading, and how much did it
6
+ * find in each. On a misconfigured project that is the first thing worth
7
+ * knowing, and a total entry count cannot tell you — 39 entries looks healthy
8
+ * whether or not `src/layout/` was discovered at all.
9
+ *
10
+ * Everything here is derived from data the build already resolved. The entry map
11
+ * is built before the reporter runs, and `sourceRootRecords` comes from the
12
+ * project structure, so attribution costs one pass over the entries and no
13
+ * filesystem access.
14
+ */
15
+
16
+ import { createHash } from 'node:crypto';
17
+ import { gzipSync } from 'node:zlib';
18
+ import { statSync } from 'node:fs';
19
+
20
+ import { findSourceRoot, relativeFrom } from '../../project-structure.js';
21
+
22
+ /**
23
+ * Render one source root as a display path.
24
+ *
25
+ * Roots are shown relative to the project with a trailing slash, because the
26
+ * trailing slash is what makes a bare name like `components` read as a
27
+ * directory rather than a namespace. Roots resolving outside the project keep
28
+ * their absolute path rather than a `../../` climb, which is unreadable.
29
+ *
30
+ * @param {string} directory - Absolute root directory.
31
+ * @param {string} [projectDir] - Absolute project root.
32
+ * @returns {string} Display path with trailing slash.
33
+ */
34
+ export function displayRoot(directory, projectDir) {
35
+ if (!directory) return '';
36
+ if (!projectDir) return `${directory.replace(/\/+$/, '')}/`;
37
+
38
+ const relative = relativeFrom(directory, projectDir);
39
+ if (!relative || relative.startsWith('..')) {
40
+ return `${directory.split('\\').join('/').replace(/\/+$/, '')}/`;
41
+ }
42
+
43
+ return `${relative.replace(/\/+$/, '')}/`;
44
+ }
45
+
46
+ /**
47
+ * Name the directory the watcher is actually watching.
48
+ *
49
+ * The summary used to close with `watching dist/`, which named the wrong end of
50
+ * the pipeline: `dist/` is written, not watched. Rollup watches the module graph,
51
+ * whose roots are the source roots reported directly above in the input rows, so
52
+ * the honest label is the directory those roots share.
53
+ *
54
+ * A shared parent is preferred over listing each root because it is both shorter
55
+ * and still true — watching `src/` covers `src/components/` and `src/base/`. When
56
+ * the roots share nothing above the project itself, no path describes the set and
57
+ * the generic label is used rather than an inaccurate one.
58
+ *
59
+ * @param {{
60
+ * sourceRootRecords?: Array<{directory: string}>,
61
+ * projectDir?: string,
62
+ * fallback?: string
63
+ * }} [options] - Watch label inputs.
64
+ * @returns {string} Display path of the watched directory.
65
+ */
66
+ export function watchedRootLabel({
67
+ sourceRootRecords = [],
68
+ projectDir,
69
+ fallback = 'sources',
70
+ } = {}) {
71
+ const paths = sourceRootRecords
72
+ .map((root) => displayRoot(root.directory, projectDir))
73
+ .filter(Boolean);
74
+
75
+ return sharedRootPath(paths) || fallback;
76
+ }
77
+
78
+ /**
79
+ * Reduce a set of display paths to the deepest directory all of them sit inside.
80
+ *
81
+ * @param {string[]} paths - Display paths with trailing slashes.
82
+ * @returns {string|undefined} Shared path with a trailing slash, when one exists.
83
+ */
84
+ export function sharedRootPath(paths = []) {
85
+ if (paths.length === 0) return undefined;
86
+
87
+ const segmentLists = paths.map((path) =>
88
+ path.split('/').filter((segment) => segment !== ''),
89
+ );
90
+
91
+ const [first, ...rest] = segmentLists;
92
+ const shared = [];
93
+
94
+ for (let index = 0; index < first.length; index += 1) {
95
+ const segment = first[index];
96
+ if (!rest.every((list) => list[index] === segment)) break;
97
+ shared.push(segment);
98
+ }
99
+
100
+ // Every root being the same directory leaves that directory shared in full,
101
+ // which is the correct answer. Sharing nothing means the roots sit in unrelated
102
+ // trees, and the caller decides what to say about that.
103
+ if (shared.length === 0) return undefined;
104
+
105
+ // A root resolving outside the project keeps its absolute path, and dropping
106
+ // the leading slash off that would name a directory that does not exist.
107
+ const prefix = paths.every((path) => path.startsWith('/')) ? '/' : '';
108
+
109
+ return `${prefix}${shared.join('/')}/`;
110
+ }
111
+
112
+ /**
113
+ * Conventional global directory names, in the order they are listed.
114
+ *
115
+ * A project without `variant.structureImplementations` gets one global root, and
116
+ * it is the source directory itself — not a `global/` subdirectory of it. So
117
+ * every entry outside the component roots would attribute to a single `src/` row,
118
+ * which reports a number without saying where any of it came from. Every
119
+ * directory one level inside the root is therefore given its own row.
120
+ *
121
+ * These names sort first so the conventional layout reads the same way across
122
+ * projects; everything else follows alphabetically. Ordering is the only thing
123
+ * this list controls — an unlisted directory still gets a row.
124
+ *
125
+ * This is a reporting distinction only. The build already treats every directory
126
+ * under a global root the same way, emitting each to `dist/global/<name>/`, and
127
+ * nothing here changes that.
128
+ *
129
+ * @type {string[]}
130
+ */
131
+ export const GLOBAL_DIRECTORY_ORDER = ['foundation', 'base', 'global'];
132
+
133
+ /**
134
+ * Maximum number of directories broken out of one global root.
135
+ *
136
+ * Only directories that produced build entries get a row, which on a real
137
+ * project is a handful. The cap is what keeps that a guarantee rather than an
138
+ * observation, so an unconventional `src/` cannot push the build result off the
139
+ * top of the terminal.
140
+ *
141
+ * @type {number}
142
+ */
143
+ export const MAX_GLOBAL_DIRECTORY_ROWS = 8;
144
+
145
+ /**
146
+ * Resolve the directory one level inside a global root that an entry sits in.
147
+ *
148
+ * @param {string} sourceFile - Absolute source file path.
149
+ * @param {string} rootDirectory - Absolute global root directory.
150
+ * @returns {string|undefined} Directory name, when the entry is inside one.
151
+ */
152
+ function globalAssetDirectory(sourceFile, rootDirectory) {
153
+ const relative = relativeFrom(sourceFile, rootDirectory);
154
+ if (!relative || relative.startsWith('..')) return undefined;
155
+
156
+ const [segment, ...rest] = relative.split('/');
157
+
158
+ // A bare file directly inside the root has no directory to attribute to, so it
159
+ // belongs on the root's own row rather than inventing one from the filename.
160
+ if (rest.length === 0) return undefined;
161
+
162
+ return segment || undefined;
163
+ }
164
+
165
+ /**
166
+ * Order the directories broken out of one global root.
167
+ *
168
+ * @param {Iterable<string>} names - Discovered directory names.
169
+ * @returns {string[]} Names in display order.
170
+ */
171
+ function orderGlobalDirectories(names) {
172
+ const rank = (name) => {
173
+ const index = GLOBAL_DIRECTORY_ORDER.indexOf(name);
174
+ return index === -1 ? GLOBAL_DIRECTORY_ORDER.length : index;
175
+ };
176
+
177
+ return [...names].sort(
178
+ (a, b) => rank(a) - rank(b) || a.localeCompare(b, 'en'),
179
+ );
180
+ }
181
+
182
+ /**
183
+ * Attribute build entries to the source roots that produced them.
184
+ *
185
+ * Roots are reported in `sourceRootRecords` order so a project's configured
186
+ * `variant.structureImplementations` order is preserved rather than sorted into
187
+ * something the author did not write.
188
+ *
189
+ * A root that matched nothing is still reported, with a count of zero. That is
190
+ * the single most useful row in the block: a configured root sitting at zero is
191
+ * either misspelled in `project.emulsify.json` or empty on disk, and hiding it
192
+ * would hide the bug.
193
+ *
194
+ * Global roots additionally break out the directories one level inside them,
195
+ * because a global root is the source directory itself and a bare `src/` row
196
+ * reports a number without saying where any of it came from. Files sitting
197
+ * directly in the root have no directory to attribute to and stay on the root's
198
+ * own row. {@link MAX_GLOBAL_DIRECTORY_ROWS} bounds the split.
199
+ *
200
+ * @param {{
201
+ * entries?: Record<string, string>,
202
+ * sourceRootRecords?: Array<{name: string, directory: string}>,
203
+ * globalRootDirectories?: string[],
204
+ * projectDir?: string
205
+ * }} options - Attribution inputs.
206
+ * @returns {Array<{name: string, path: string, count: number, overflow?: boolean}>} Input rows.
207
+ */
208
+ export function buildInputRows({
209
+ entries = {},
210
+ sourceRootRecords = [],
211
+ globalRootDirectories = [],
212
+ projectDir,
213
+ } = {}) {
214
+ if (sourceRootRecords.length === 0) return [];
215
+
216
+ // Only roots the project structure reported as global are split. A project
217
+ // whose `structureImplementations` happens to name a root `global` has it as a
218
+ // component root, and splitting that would invent rows it did not ask for.
219
+ const globalRoots = new Set(globalRootDirectories);
220
+
221
+ const counts = new Map(sourceRootRecords.map((root) => [root.directory, 0]));
222
+ /** @type {Map<string, Map<string, number>>} */
223
+ const globalCounts = new Map();
224
+
225
+ for (const sourceFile of Object.values(entries)) {
226
+ if (typeof sourceFile !== 'string') continue;
227
+
228
+ // `findSourceRoot` returns the first containing root, and component roots
229
+ // precede global roots in `sourceRootRecords`. That ordering is what keeps a
230
+ // component stylesheet attributed to `components` rather than to the `src`
231
+ // directory that also contains it.
232
+ const root = findSourceRoot(sourceFile, sourceRootRecords);
233
+ if (!root) continue;
234
+
235
+ const directory = globalRoots.has(root.directory)
236
+ ? globalAssetDirectory(sourceFile, root.directory)
237
+ : undefined;
238
+
239
+ if (!directory) {
240
+ counts.set(root.directory, (counts.get(root.directory) || 0) + 1);
241
+ continue;
242
+ }
243
+
244
+ if (!globalCounts.has(root.directory)) {
245
+ globalCounts.set(root.directory, new Map());
246
+ }
247
+
248
+ const byDirectory = globalCounts.get(root.directory);
249
+ byDirectory.set(directory, (byDirectory.get(directory) || 0) + 1);
250
+ }
251
+
252
+ return sourceRootRecords.flatMap((root) => {
253
+ const byDirectory = globalCounts.get(root.directory);
254
+ const rootCount = counts.get(root.directory) || 0;
255
+
256
+ const rows = [];
257
+
258
+ // Directories are listed in convention order rather than by count, so the
259
+ // block reads the same way across projects. Anything past the cap collapses
260
+ // into one row that still carries its entries, so the counts reconcile
261
+ // against the total however many directories a project has.
262
+ if (byDirectory) {
263
+ const names = orderGlobalDirectories(byDirectory.keys());
264
+ const shown = names.slice(0, MAX_GLOBAL_DIRECTORY_ROWS);
265
+ const hidden = names.slice(MAX_GLOBAL_DIRECTORY_ROWS);
266
+
267
+ for (const name of shown) {
268
+ rows.push({
269
+ name,
270
+ path: displayRoot(`${root.directory}/${name}`, projectDir),
271
+ count: byDirectory.get(name),
272
+ });
273
+ }
274
+
275
+ if (hidden.length > 0) {
276
+ rows.push({
277
+ name: root.name,
278
+ path: `+${hidden.length} more ${hidden.length === 1 ? 'directory' : 'directories'}`,
279
+ count: hidden.reduce((sum, name) => sum + byDirectory.get(name), 0),
280
+ overflow: true,
281
+ });
282
+ }
283
+ }
284
+
285
+ // The root keeps a row when it holds entries of its own, and when it holds
286
+ // nothing at all — a zero there is still worth seeing. It is dropped only
287
+ // when everything it contained has been attributed to a row above.
288
+ if (rootCount > 0 || rows.length === 0) {
289
+ rows.push({
290
+ name: root.name,
291
+ path: displayRoot(root.directory, projectDir),
292
+ count: rootCount,
293
+ });
294
+ }
295
+
296
+ return rows;
297
+ });
298
+ }
299
+
300
+ /**
301
+ * Extensions worth measuring compressed.
302
+ *
303
+ * Gzipping is the expensive part of a per-file table — it is the whole of
304
+ * Rolldown's `computing gzip size...` pause — so it is spent only where the
305
+ * 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/`.
309
+ *
310
+ * @type {string[]}
311
+ */
312
+ const COMPRESSIBLE_EXTENSIONS = [
313
+ '.css',
314
+ '.js',
315
+ '.mjs',
316
+ '.cjs',
317
+ '.json',
318
+ '.svg',
319
+ '.html',
320
+ '.xml',
321
+ '.txt',
322
+ ];
323
+
324
+ /**
325
+ * Determine whether a file's compressed size is worth computing.
326
+ *
327
+ * @param {string} fileName - Output file name.
328
+ * @returns {boolean} TRUE when the file should be gzipped for reporting.
329
+ */
330
+ function isCompressible(fileName) {
331
+ const lower = String(fileName).toLowerCase();
332
+ if (lower.endsWith('.map')) return false;
333
+
334
+ return COMPRESSIBLE_EXTENSIONS.some((extension) => lower.endsWith(extension));
335
+ }
336
+
337
+ /**
338
+ * Read one bundle output as bytes.
339
+ *
340
+ * @param {{code?: string, source?: string|Uint8Array}} output - Bundle output.
341
+ * @returns {Buffer|undefined} Content, when the output carries any.
342
+ */
343
+ function outputBuffer(output) {
344
+ if (!output) return undefined;
345
+ if (typeof output.code === 'string') return Buffer.from(output.code);
346
+
347
+ const { source } = output;
348
+ if (typeof source === 'string') return Buffer.from(source);
349
+ if (source && typeof source.byteLength === 'number') {
350
+ return Buffer.from(
351
+ source.buffer || source,
352
+ source.byteOffset,
353
+ source.byteLength,
354
+ );
355
+ }
356
+
357
+ return undefined;
358
+ }
359
+
360
+ /**
361
+ * List every entry the build will read, with the size of its source.
362
+ *
363
+ * The quiet reporter answers how many entries each root contributed; this answers
364
+ * which ones. Rows are ordered by path rather than by size because the question
365
+ * a full input listing gets asked is "is everything I expect being compiled" —
366
+ * and that is answered by scanning a tree, not a ranking.
367
+ *
368
+ * Sizes come from one `stat` per entry at config resolution, so this costs
369
+ * nothing on rebuilds and nothing at all outside detailed mode.
370
+ *
371
+ * @param {{
372
+ * entries?: Record<string, string>,
373
+ * projectDir?: string
374
+ * }} [options] - Listing inputs.
375
+ * @returns {Array<{path: string, bytes?: number}>} Input file rows.
376
+ */
377
+ export function buildInputFileRows({ entries = {}, projectDir } = {}) {
378
+ const rows = [];
379
+
380
+ for (const sourceFile of Object.values(entries)) {
381
+ if (typeof sourceFile !== 'string') continue;
382
+
383
+ let bytes;
384
+ try {
385
+ bytes = statSync(sourceFile).size;
386
+ } catch {
387
+ // An entry that cannot be stat'd is still worth listing — a path the build
388
+ // resolved but the filesystem does not have is exactly the kind of thing a
389
+ // verbose listing is being read to find.
390
+ bytes = undefined;
391
+ }
392
+
393
+ rows.push({ path: displayEntry(sourceFile, projectDir), bytes });
394
+ }
395
+
396
+ return rows.sort((a, b) => a.path.localeCompare(b.path, 'en'));
397
+ }
398
+
399
+ /**
400
+ * Render one entry as a display path.
401
+ *
402
+ * @param {string} sourceFile - Absolute source path.
403
+ * @param {string} [projectDir] - Absolute project root.
404
+ * @returns {string} Display path.
405
+ */
406
+ function displayEntry(sourceFile, projectDir) {
407
+ const posix = sourceFile.split('\\').join('/');
408
+ if (!projectDir) return posix;
409
+
410
+ const relative = relativeFrom(sourceFile, projectDir);
411
+
412
+ return !relative || relative.startsWith('..') ? posix : relative;
413
+ }
414
+
415
+ /**
416
+ * List every file a build wrote, with its size and, where useful, its gzip size.
417
+ *
418
+ * Ordered by size descending. Unlike the input listing, the question here is
419
+ * "what is heavy" — the output row already reports the single largest file, and
420
+ * this is that row expanded into the full ranking.
421
+ *
422
+ * @param {Record<string, object>} [bundle] - Rollup output bundle.
423
+ * @param {{gzip?: boolean}} [options] - Listing options.
424
+ * @returns {Array<{fileName: string, bytes: number, gzipBytes?: number}>} Output file rows.
425
+ */
426
+ export function buildOutputFileRows(bundle, { gzip = true } = {}) {
427
+ if (!bundle || typeof bundle !== 'object') return [];
428
+
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;
439
+ }
440
+ }
441
+
442
+ return { fileName, bytes, gzipBytes };
443
+ });
444
+
445
+ return rows.sort(
446
+ (a, b) => b.bytes - a.bytes || a.fileName.localeCompare(b.fileName, 'en'),
447
+ );
448
+ }
449
+
450
+ /**
451
+ * Fingerprint every file in a bundle by content.
452
+ *
453
+ * Rollup regenerates the whole bundle on every watch cycle, so "which files were
454
+ * written" is always "all of them" and says nothing. Comparing content hashes
455
+ * between cycles answers the question actually being asked after an edit: which
456
+ * outputs are different now. It also makes the useful negative reportable — an
457
+ * edit that compiles to byte-identical CSS is worth knowing about.
458
+ *
459
+ * @param {Record<string, object>} [bundle] - Rollup output bundle.
460
+ * @returns {Map<string, string>} File name to content hash.
461
+ */
462
+ export function fingerprintBundle(bundle) {
463
+ const fingerprints = new Map();
464
+ if (!bundle || typeof bundle !== 'object') return fingerprints;
465
+
466
+ for (const [fileName, output] of Object.entries(bundle)) {
467
+ const content = outputBuffer(output);
468
+ if (!content) continue;
469
+
470
+ fingerprints.set(
471
+ fileName,
472
+ createHash('sha1').update(content).digest('hex'),
473
+ );
474
+ }
475
+
476
+ return fingerprints;
477
+ }
478
+
479
+ /**
480
+ * Reduce two fingerprint maps to the files that differ.
481
+ *
482
+ * @param {Map<string, string>} previous - Fingerprints from the last cycle.
483
+ * @param {Map<string, string>} current - Fingerprints from this cycle.
484
+ * @returns {{changed: string[], removed: string[]}} Differing file names.
485
+ */
486
+ export function diffFingerprints(previous = new Map(), current = new Map()) {
487
+ const changed = [];
488
+
489
+ for (const [fileName, hash] of current) {
490
+ // The rule fires on any comparison against a value named like a digest. These
491
+ // hashes identify build output for a terminal listing and guard nothing, so
492
+ // there is no secret to leak through comparison timing.
493
+ // eslint-disable-next-line security/detect-possible-timing-attacks
494
+ if (previous.get(fileName) !== hash) changed.push(fileName);
495
+ }
496
+
497
+ const removed = [...previous.keys()].filter(
498
+ (fileName) => !current.has(fileName),
499
+ );
500
+
501
+ return { changed: changed.sort(), removed: removed.sort() };
502
+ }
503
+
504
+ /**
505
+ * Reduce a written bundle to the facts worth keeping from Rolldown's table.
506
+ *
507
+ * Raising `logLevel` to quiet the develop loop also discards Rolldown's per-file
508
+ * asset report, which is around seventy lines on a real project. Three of its
509
+ * facts are worth keeping — how many files landed, how much they weigh, and
510
+ * which one is heaviest — and those fit on one line.
511
+ *
512
+ * Sizes are computed from the emitted content rather than by reading `dist/`
513
+ * back off disk, so this adds no I/O to the cycle.
514
+ *
515
+ * @param {Record<string, {type?: string, code?: string, source?: string|Uint8Array}>} [bundle] - Rollup output bundle.
516
+ * @returns {{fileCount: number, totalBytes: number, largest?: {fileName: string, bytes: number}}|undefined} Write tally.
517
+ */
518
+ export function summarizeBundle(bundle) {
519
+ if (!bundle || typeof bundle !== 'object') return undefined;
520
+
521
+ const files = Object.entries(bundle);
522
+ if (files.length === 0) return undefined;
523
+
524
+ let totalBytes = 0;
525
+ let largest;
526
+
527
+ for (const [fileName, output] of files) {
528
+ const bytes = outputByteLength(output);
529
+ totalBytes += bytes;
530
+
531
+ if (!largest || bytes > largest.bytes) {
532
+ largest = { fileName, bytes };
533
+ }
534
+ }
535
+
536
+ return { fileCount: files.length, totalBytes, largest };
537
+ }
538
+
539
+ /**
540
+ * Measure one bundle output in bytes.
541
+ *
542
+ * Chunks carry `code`, assets carry `source`, and an asset source may already be
543
+ * binary. `Buffer.byteLength` is used for strings so multi-byte characters are
544
+ * not undercounted as one byte each.
545
+ *
546
+ * @param {{code?: string, source?: string|Uint8Array}} output - Bundle output.
547
+ * @returns {number} Byte length.
548
+ */
549
+ function outputByteLength(output) {
550
+ if (!output) return 0;
551
+
552
+ if (typeof output.code === 'string') {
553
+ return Buffer.byteLength(output.code);
554
+ }
555
+
556
+ const { source } = output;
557
+ if (typeof source === 'string') return Buffer.byteLength(source);
558
+ if (source && typeof source.byteLength === 'number') return source.byteLength;
559
+
560
+ return 0;
561
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * @file Verbosity resolution for the Emulsify develop reporter.
3
+ *
4
+ * The reporter has three modes rather than two, because "show me more" and "get
5
+ * out of the way" are different requests:
6
+ *
7
+ * - `quiet` — the default. One summary per build, one line per rebuild.
8
+ * - `detailed` — the reporter still owns the output, but prints every input and
9
+ * output file with its size, and names what each rebuild actually changed.
10
+ * - `raw` — the reporter stands aside and restores Vite's and Rolldown's own
11
+ * output, including the transform progress line and the gzip table.
12
+ *
13
+ * `detailed` exists because `raw` answers the question badly. Rolldown writes its
14
+ * progress line from Rust with a `\x1b[2K\r` prefix and no trailing newline, and
15
+ * under `concurrently` that carriage return collides with Storybook's output on
16
+ * the shared pipe. So the mode that shows the most detail is also the mode whose
17
+ * detail is hardest to read. `detailed` keeps `logLevel` low — which is what
18
+ * stops Rolldown instrumenting transforms at all — and renders the same facts
19
+ * append-only, from data the plugin already holds.
20
+ *
21
+ * ## Why two triggers
22
+ *
23
+ * `npm run develop --verbose` is the form people reach for, but npm claims
24
+ * `--verbose` as an alias for `--loglevel verbose` and never passes it to the
25
+ * script. What it does do is export `npm_config_loglevel=verbose`, which
26
+ * propagates through `concurrently` into the `vite` child, so the flag is
27
+ * detectable even though it never arrives as an argument. The cost is npm's own
28
+ * `npm verbose` chatter, which is why the environment variable is offered too.
29
+ */
30
+
31
+ /**
32
+ * Reporter verbosity levels.
33
+ *
34
+ * @type {{quiet: string, detailed: string, raw: string}}
35
+ */
36
+ export const VERBOSITY = {
37
+ quiet: 'quiet',
38
+ detailed: 'detailed',
39
+ raw: 'raw',
40
+ };
41
+
42
+ /**
43
+ * `EMULSIFY_VERBOSE` value that selects the detailed reporter instead of raw
44
+ * passthrough.
45
+ *
46
+ * @type {string}
47
+ */
48
+ const DETAILED_VALUE = '2';
49
+
50
+ /**
51
+ * npm log levels that mean the developer asked for more output.
52
+ *
53
+ * @type {string[]}
54
+ */
55
+ const VERBOSE_NPM_LEVELS = ['verbose', 'silly'];
56
+
57
+ /**
58
+ * Resolve the reporter's verbosity from the environment.
59
+ *
60
+ * `EMULSIFY_VERBOSE` wins over the npm log level, so a project whose `.npmrc`
61
+ * raises `loglevel` permanently can still pin the reporter back down.
62
+ *
63
+ * Any truthy `EMULSIFY_VERBOSE` other than the detailed value keeps meaning raw
64
+ * passthrough, which is what it has always meant.
65
+ *
66
+ * @param {{EMULSIFY_VERBOSE?: string, npm_config_loglevel?: string}} [env] - Environment variables.
67
+ * @returns {string} One of {@link VERBOSITY}.
68
+ */
69
+ export function resolveVerbosity(env = process.env) {
70
+ const requested = env?.EMULSIFY_VERBOSE;
71
+
72
+ // Any explicit value settles it, `0` included. Falling through to the npm log
73
+ // level on an explicit `0` would leave a project whose `.npmrc` raises
74
+ // `loglevel` permanently with no way to quiet the reporter back down. An empty
75
+ // string is how a shell clears a variable, so it counts as unset.
76
+ if (requested !== undefined && requested !== '') {
77
+ if (requested === '0') return VERBOSITY.quiet;
78
+ return requested === DETAILED_VALUE ? VERBOSITY.detailed : VERBOSITY.raw;
79
+ }
80
+
81
+ if (VERBOSE_NPM_LEVELS.includes(env?.npm_config_loglevel)) {
82
+ return VERBOSITY.detailed;
83
+ }
84
+
85
+ return VERBOSITY.quiet;
86
+ }
87
+
88
+ /**
89
+ * Determine whether the reporter should stand aside and let Vite speak.
90
+ *
91
+ * @param {object} [env] - Environment variables.
92
+ * @returns {boolean} TRUE when raw output should pass through.
93
+ */
94
+ export function isVerbose(env = process.env) {
95
+ return resolveVerbosity(env) === VERBOSITY.raw;
96
+ }
97
+
98
+ /**
99
+ * Determine whether the reporter should print per-file detail.
100
+ *
101
+ * @param {object} [env] - Environment variables.
102
+ * @returns {boolean} TRUE when the detailed reporter is requested.
103
+ */
104
+ export function isDetailed(env = process.env) {
105
+ return resolveVerbosity(env) === VERBOSITY.detailed;
106
+ }
107
+
108
+ /**
109
+ * Determine whether output the reporter replaces should be suppressed.
110
+ *
111
+ * True only at the default level. Both verbose modes asked for more output, so
112
+ * neither should have anything filtered out of it.
113
+ *
114
+ * @param {object} [env] - Environment variables.
115
+ * @returns {boolean} TRUE when suppression applies.
116
+ */
117
+ export function isQuiet(env = process.env) {
118
+ return resolveVerbosity(env) === VERBOSITY.quiet;
119
+ }