@emulsify/core 4.3.0 → 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.
- package/.storybook/main-vite.js +11 -1
- package/.storybook/main.js +20 -0
- package/.storybook/ready-reporter.js +230 -0
- package/README.md +1 -0
- package/config/vite/plugins/assets/copy-src-assets.js +69 -25
- package/config/vite/plugins/assets/copy-twig-files.js +76 -30
- package/config/vite/plugins/reporter/diagnostics.js +1 -0
- package/config/vite/plugins/reporter/format.js +41 -0
- package/config/vite/plugins/reporter/index.js +114 -5
- package/config/vite/plugins/reporter/render.js +613 -39
- package/config/vite/plugins/reporter/source-roots.js +561 -0
- package/config/vite/plugins/reporter/verbosity.js +119 -0
- package/config/vite/plugins/reporter/vite-logger.js +66 -5
- package/config/vite/vite.config.js +44 -4
- package/package.json +5 -2
|
@@ -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
|
+
}
|