@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.
@@ -5,6 +5,7 @@ import {
5
5
  mergeReactSingletonOptimizeDeps,
6
6
  mergeReactSingletonResolve,
7
7
  } from '../config/vite/utils/react-singleton.js';
8
+ import { createDevServerLogger } from '../config/vite/plugins/reporter/vite-logger.js';
8
9
  import { makeGeneratedDistFilesPlugin } from './main-static-assets.js';
9
10
 
10
11
  // Twig glob maps are provided by config/vite/plugins/twig/virtual-twig-globs.js.
@@ -123,7 +124,7 @@ function makeTwigVirtualModuleOptimizerPlugin() {
123
124
  */
124
125
  export function createViteFinal(resolvedStorybookEnv) {
125
126
  return async function viteFinal(config) {
126
- const { mergeConfig } = await import('vite');
127
+ const { createLogger, mergeConfig } = await import('vite');
127
128
  const env = resolvedStorybookEnv;
128
129
  const storybookBuildConfig = config?.build || {};
129
130
 
@@ -249,6 +250,15 @@ export function createViteFinal(resolvedStorybookEnv) {
249
250
 
250
251
  return {
251
252
  ...mergedConfig,
253
+
254
+ // Assigned after the merge rather than inside it, because `mergeConfig`
255
+ // deep-merges plain objects and a logger is an object of functions — merging
256
+ // Storybook's with ours would produce a hybrid belonging to neither. Spread
257
+ // last, it simply wins, and it delegates to whatever was already configured
258
+ // so Storybook keeps its prefixes for every message it is allowed to print.
259
+ customLogger: createDevServerLogger({
260
+ baseLogger: mergedConfig.customLogger || createLogger(),
261
+ }),
252
262
  build: {
253
263
  ...(mergedConfig.build || {}),
254
264
  ...(storybookBuildConfig.outDir
@@ -21,6 +21,10 @@ import {
21
21
  } from '../src/storybook/main-config.js';
22
22
  import { buildAssetStaticDirs } from './main-static-assets.js';
23
23
  import { createViteFinal } from './main-vite.js';
24
+ import {
25
+ installReadyReporter,
26
+ requestedPortFromArgv,
27
+ } from './ready-reporter.js';
24
28
 
25
29
  /**
26
30
  * Minimal subset of the resolved Emulsify environment used by this file.
@@ -143,6 +147,22 @@ const safeConfigOverrides = await loadConfigOverrides();
143
147
  */
144
148
  const resolvedStorybookEnv = resolveEnvironment();
145
149
 
150
+ /**
151
+ * Render Storybook's startup announcement in the Emulsify reporter's voice.
152
+ *
153
+ * Installed here because this module is itself a Storybook preset: `loadAllPresets`
154
+ * imports it, so a top-level await runs well before the dev server announces
155
+ * itself. Only the ready box is affected, and only when the internal logger is
156
+ * reachable — see `.storybook/ready-reporter.js` for why this is interception
157
+ * rather than `--quiet`.
158
+ *
159
+ * Restricted to `storybook dev`. A static `storybook build` prints no ready box,
160
+ * and the develop reporter deliberately leaves one-shot builds alone.
161
+ */
162
+ if (process.argv.includes('dev')) {
163
+ await installReadyReporter({ requestedPort: requestedPortFromArgv() });
164
+ }
165
+
146
166
  /**
147
167
  * Primary Storybook configuration object.
148
168
  * @type {StorybookConfig}
@@ -0,0 +1,230 @@
1
+ /**
2
+ * @file Route Storybook's startup announcement through the Emulsify reporter.
3
+ *
4
+ * `npm run develop` runs Vite and Storybook side by side under `concurrently`.
5
+ * Once the reporter quiets Vite and Rolldown, Storybook's rounded box is the
6
+ * only remaining block drawn in another tool's visual language, which makes one
7
+ * command look like two.
8
+ *
9
+ * ## Why interception rather than `--quiet`
10
+ *
11
+ * Storybook has a switch for this: `outputStartupInformation()` is called behind
12
+ * `options.quiet ||`, so `storybook dev --quiet` removes the box outright. It was
13
+ * not used, for two reasons.
14
+ *
15
+ * The first is distribution. The `storybook` script lives in each consuming
16
+ * project's `package.json`, so `--quiet` would make a cosmetic improvement
17
+ * depend on every consumer editing a script — and a project that did not would
18
+ * get no improvement at all. Interception ships with the package.
19
+ *
20
+ * The second is that the box carries information nothing else does. Consumers
21
+ * run `storybook dev --ci`, and `--ci` makes Storybook fall forward to the next
22
+ * free port without prompting. The resolved port only appears in the box. A
23
+ * developer whose previous session is still holding 6006 would otherwise open
24
+ * the requested port and see a stale instance, with nothing anywhere saying so.
25
+ * Parsing the box is what makes {@link renderReady}'s port drift warning
26
+ * possible.
27
+ *
28
+ * ## Failure behavior
29
+ *
30
+ * Everything here is best-effort. `storybook/internal/node-logger` is an
31
+ * internal subpath and the box payload is a formatted string, so both can change
32
+ * between Storybook releases. Every failure path leaves Storybook's own logger
33
+ * untouched, which means the worst outcome is the box that prints today.
34
+ */
35
+
36
+ import {
37
+ createStyler,
38
+ supportsColor,
39
+ supportsUnicode,
40
+ } from '../config/vite/plugins/reporter/format.js';
41
+ import { renderReady } from '../config/vite/plugins/reporter/render.js';
42
+
43
+ /**
44
+ * Matches the URL rows inside Storybook's startup box.
45
+ *
46
+ * The box lists `- Local:`, `- On your network:`, and optionally
47
+ * `- Other allowed hosts:`, each followed by whitespace and a URL. Matching the
48
+ * label rather than the position keeps the parse working if a row is added or
49
+ * reordered.
50
+ *
51
+ * @type {RegExp}
52
+ */
53
+ const URL_ROW_PATTERN = /^\s*-\s*(Local|On your network):\s*(\S+)/gim;
54
+
55
+ /**
56
+ * Identifies the startup box among every other boxed message Storybook draws.
57
+ *
58
+ * @type {RegExp}
59
+ */
60
+ const READY_PATTERN = /Storybook\s+ready/i;
61
+
62
+ /**
63
+ * Remove ANSI escape sequences so pattern matching sees plain text.
64
+ *
65
+ * @param {string} value - Possibly styled text.
66
+ * @returns {string} Plain text.
67
+ */
68
+ const stripAnsi = (value) =>
69
+ // eslint-disable-next-line no-control-regex
70
+ String(value).replace(/\[[0-9;]*m/g, '');
71
+
72
+ /**
73
+ * Extract the local and network URLs from a startup box payload.
74
+ *
75
+ * @param {string} message - Box contents.
76
+ * @returns {{local?: string, network?: string}} Parsed URLs.
77
+ */
78
+ export function parseReadyUrls(message) {
79
+ const urls = {};
80
+ if (typeof message !== 'string') return urls;
81
+
82
+ const plain = stripAnsi(message);
83
+ URL_ROW_PATTERN.lastIndex = 0;
84
+
85
+ let match = URL_ROW_PATTERN.exec(plain);
86
+ while (match) {
87
+ const [, label, url] = match;
88
+ urls[label.toLowerCase() === 'local' ? 'local' : 'network'] = url;
89
+ match = URL_ROW_PATTERN.exec(plain);
90
+ }
91
+
92
+ return urls;
93
+ }
94
+
95
+ /**
96
+ * Read the port out of a URL.
97
+ *
98
+ * @param {string|undefined} url - URL to inspect.
99
+ * @returns {string|undefined} Port, when present.
100
+ */
101
+ export function portFromUrl(url) {
102
+ if (!url) return undefined;
103
+
104
+ try {
105
+ const { port } = new URL(url);
106
+ return port || undefined;
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Determine whether a boxed message is Storybook's startup announcement.
114
+ *
115
+ * @param {string} message - Box contents.
116
+ * @returns {boolean} TRUE when the message is the ready box.
117
+ */
118
+ export function isReadyBox(message) {
119
+ return typeof message === 'string' && READY_PATTERN.test(stripAnsi(message));
120
+ }
121
+
122
+ /**
123
+ * Replace Storybook's startup box with the Emulsify ready panel.
124
+ *
125
+ * Only the ready box is intercepted. Every other boxed message — and there are
126
+ * several, including migration notices and error summaries — passes through to
127
+ * Storybook untouched, because this exists to unify one visual element rather
128
+ * than to take over Storybook's output.
129
+ *
130
+ * @param {{
131
+ * logger?: {logBox?: Function, log?: Function},
132
+ * requestedPort?: string|number,
133
+ * write?: (line: string) => void,
134
+ * colorEnabled?: boolean,
135
+ * unicodeEnabled?: boolean
136
+ * }} [options] - Interception options.
137
+ * @returns {boolean} TRUE when the logger was wrapped.
138
+ */
139
+ export function interceptReadyBox({
140
+ logger,
141
+ requestedPort,
142
+ write = (line) => process.stdout.write(`${line}\n`),
143
+ colorEnabled,
144
+ unicodeEnabled,
145
+ } = {}) {
146
+ if (typeof logger?.logBox !== 'function') return false;
147
+
148
+ const styler = createStyler(
149
+ colorEnabled === undefined ? supportsColor() : colorEnabled,
150
+ );
151
+ const unicode =
152
+ unicodeEnabled === undefined ? supportsUnicode() : unicodeEnabled;
153
+
154
+ const originalLogBox = logger.logBox.bind(logger);
155
+
156
+ logger.logBox = (message, boxOptions) => {
157
+ if (!isReadyBox(message)) return originalLogBox(message, boxOptions);
158
+
159
+ try {
160
+ const urls = parseReadyUrls(message);
161
+ const actual = portFromUrl(urls.local);
162
+
163
+ const lines = renderReady({
164
+ service: 'storybook',
165
+ urls,
166
+ portDrift:
167
+ requestedPort && actual
168
+ ? { requested: requestedPort, actual }
169
+ : undefined,
170
+ unicode,
171
+ styler,
172
+ });
173
+
174
+ lines.forEach((line) => write(line));
175
+ return undefined;
176
+ } catch {
177
+ // A parse failure must not cost the developer the URLs they need.
178
+ return originalLogBox(message, boxOptions);
179
+ }
180
+ };
181
+
182
+ return true;
183
+ }
184
+
185
+ /**
186
+ * Install the interception against Storybook's real node logger.
187
+ *
188
+ * Called for its side effect during preset evaluation, which happens well before
189
+ * the server announces itself.
190
+ *
191
+ * @param {{requestedPort?: string|number}} [options] - Interception options.
192
+ * @returns {Promise<boolean>} TRUE when interception was installed.
193
+ */
194
+ export async function installReadyReporter({ requestedPort } = {}) {
195
+ try {
196
+ const { logger } = await import('storybook/internal/node-logger');
197
+ return interceptReadyBox({ logger, requestedPort });
198
+ } catch {
199
+ // Storybook changed the subpath or the export. Its own box still prints.
200
+ return false;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Read the port requested on the command line.
206
+ *
207
+ * Storybook resolves `-p` / `--port` itself, but by the time the box is drawn the
208
+ * value has been replaced by whatever port was actually free. The request has to
209
+ * be captured from argv to have anything to compare against.
210
+ *
211
+ * @param {string[]} [argv] - Process arguments.
212
+ * @returns {string|undefined} Requested port.
213
+ */
214
+ export function requestedPortFromArgv(argv = process.argv) {
215
+ if (!Array.isArray(argv)) return undefined;
216
+
217
+ for (let index = 0; index < argv.length; index += 1) {
218
+ const arg = argv[index];
219
+
220
+ if (arg === '-p' || arg === '--port') {
221
+ const value = argv[index + 1];
222
+ return value && /^\d+$/.test(value) ? value : undefined;
223
+ }
224
+
225
+ const inline = /^--port=(\d+)$/.exec(arg);
226
+ if (inline) return inline[1];
227
+ }
228
+
229
+ return undefined;
230
+ }
package/README.md CHANGED
@@ -166,6 +166,7 @@ The documentation is split by task:
166
166
  | [Release Pull Request Review](docs/release-review.md) | Reviewing large release pull requests by independent API, runtime, consumer, accessibility, and release risks. |
167
167
  | [Migration To 4.x](docs/migration-4x.md) | Upgrading a pre-4.x/Webpack project while preserving existing structures. |
168
168
  | [4.3.0 Release Notes](docs/releases/4.3.0.md) | Reviewing the 4.3.0 scope, compatibility changes, public APIs, limitations, and verification evidence. |
169
+ | [4.3.1 Release Notes](docs/releases/4.3.1.md) | Reviewing the 4.3.1 develop reporter changes, verbosity controls, and scope limits. |
169
170
 
170
171
  ## Known Limitations
171
172
 
@@ -29,16 +29,45 @@ export function copyAllSrcAssetsPlugin({
29
29
  sourceFileIndex = createSourceFileIndex(structure),
30
30
  }) {
31
31
  let outDir = 'dist';
32
+ let watching = false;
33
+ /** @type {Array<{absPath: string, relDest: string}>|undefined} */
34
+ let plan;
32
35
 
33
- const copyToOutDir = (absPath, relDest) => {
34
- if (!relDest) return;
35
- const destPath = join(outDir, relDest);
36
- mkdirSync(dirname(destPath), { recursive: true });
37
- try {
38
- copyFileSync(absPath, destPath);
39
- } catch {
40
- /* noop */
36
+ /**
37
+ * Resolve every asset this plugin copies, paired with where it lands.
38
+ *
39
+ * Shared by both hooks for the same reason as the Twig copier: watching and
40
+ * copying have to be driven by one list, or a file can end up copied on a full
41
+ * build and ignored on a save.
42
+ *
43
+ * @returns {Array<{absPath: string, relDest: string}>} Copy plan.
44
+ */
45
+ const copyPlan = () => {
46
+ if (plan) return plan;
47
+
48
+ plan = [];
49
+
50
+ for (const file of sourceFileIndex.componentFiles()) {
51
+ if (!isStaticSourceAsset(file.absPath)) continue;
52
+
53
+ plan.push({
54
+ absPath: file.absPath,
55
+ relDest: copiedComponentOutputPath(file.absPath, structure),
56
+ });
41
57
  }
58
+
59
+ for (const file of sourceFileIndex.globalFiles()) {
60
+ if (!isStaticSourceAsset(file.absPath)) continue;
61
+ if (findSourceRoot(file.absPath, structure.componentRootRecords))
62
+ continue;
63
+
64
+ plan.push({
65
+ absPath: file.absPath,
66
+ relDest: copiedGlobalOutputPath(file.absPath, structure),
67
+ });
68
+ }
69
+
70
+ return plan;
42
71
  };
43
72
 
44
73
  return {
@@ -49,28 +78,43 @@ export function copyAllSrcAssetsPlugin({
49
78
  /** Capture outDir. */
50
79
  configResolved(cfg) {
51
80
  outDir = cfg.build?.outDir || 'dist';
81
+ watching = Boolean(cfg.build?.watch);
82
+ },
83
+
84
+ // Static assets are copied rather than compiled, so like Twig they are absent
85
+ // from Rollup's module graph and a save would otherwise go unnoticed. Swapping
86
+ // an SVG or a font left the old bytes in `dist/` until an unrelated rebuild.
87
+ buildStart() {
88
+ if (!watching) return;
89
+ for (const { absPath } of copyPlan()) this.addWatchFile(absPath);
52
90
  },
53
91
 
54
92
  /** Copy before the mirror plugin moves dist/components to the project root. */
55
93
  writeBundle() {
56
- for (const file of sourceFileIndex.componentFiles()) {
57
- if (!isStaticSourceAsset(file.absPath)) continue;
58
- copyToOutDir(
59
- file.absPath,
60
- copiedComponentOutputPath(file.absPath, structure),
61
- );
62
- }
63
-
64
- for (const file of sourceFileIndex.globalFiles()) {
65
- if (!isStaticSourceAsset(file.absPath)) continue;
66
- if (findSourceRoot(file.absPath, structure.componentRootRecords)) {
67
- continue;
68
- }
69
- copyToOutDir(
70
- file.absPath,
71
- copiedGlobalOutputPath(file.absPath, structure),
72
- );
94
+ for (const { absPath, relDest } of copyPlan()) {
95
+ copyToOutDir(absPath, relDest);
73
96
  }
74
97
  },
75
98
  };
99
+
100
+ /**
101
+ * Copy one file into the output directory.
102
+ *
103
+ * @param {string} absPath - Absolute source path.
104
+ * @param {string} relDest - Destination relative to `outDir`.
105
+ * @returns {void}
106
+ */
107
+ function copyToOutDir(absPath, relDest) {
108
+ if (!relDest) return;
109
+
110
+ // Copied unconditionally; see the note in copy-twig-files.js — `emptyOutDir`
111
+ // clears the destination on every cycle, so nothing is ever up to date.
112
+ const destPath = join(outDir, relDest);
113
+ mkdirSync(dirname(destPath), { recursive: true });
114
+ try {
115
+ copyFileSync(absPath, destPath);
116
+ } catch {
117
+ /* noop */
118
+ }
119
+ }
76
120
  }
@@ -32,16 +32,48 @@ export function copyTwigFilesPlugin({
32
32
  sourceFileIndex = createSourceFileIndex(structure),
33
33
  }) {
34
34
  let outDir = 'dist';
35
+ let watching = false;
36
+ /** @type {Array<{absPath: string, relDest: string}>|undefined} */
37
+ let plan;
35
38
 
36
- const copyToOutDir = (absPath, relDest) => {
37
- if (!relDest) return;
38
- const destPath = join(outDir, relDest);
39
- mkdirSync(dirname(destPath), { recursive: true });
40
- try {
41
- copyFileSync(absPath, destPath);
42
- } catch {
43
- /* noop */
39
+ /**
40
+ * Resolve every file this plugin copies, paired with where it lands.
41
+ *
42
+ * Built once and reused, because the source index is resolved at config time
43
+ * and does not change across watch cycles. Both hooks below read this same
44
+ * list, which is what keeps "gets copied to dist" and "a save triggers the
45
+ * copy" from drifting apart — a file cannot be added to one without the other.
46
+ *
47
+ * @returns {Array<{absPath: string, relDest: string}>} Copy plan.
48
+ */
49
+ const copyPlan = () => {
50
+ if (plan) return plan;
51
+
52
+ plan = [];
53
+
54
+ for (const file of sourceFileIndex.componentFiles()) {
55
+ const isTwig = file.absPath.endsWith('.twig');
56
+
57
+ if (!isTwig && !isComponentMetadataFile(file.absPath)) continue;
58
+ if (isTwig && isPartial(file.relPath)) continue;
59
+
60
+ plan.push({
61
+ absPath: file.absPath,
62
+ relDest: copiedComponentOutputPath(file.absPath, structure),
63
+ });
44
64
  }
65
+
66
+ for (const file of sourceFileIndex.globalFiles()) {
67
+ if (!file.absPath.endsWith('.twig')) continue;
68
+ if (isPartial(file.relPath)) continue;
69
+
70
+ plan.push({
71
+ absPath: file.absPath,
72
+ relDest: copiedGlobalOutputPath(file.absPath, structure),
73
+ });
74
+ }
75
+
76
+ return plan;
45
77
  };
46
78
 
47
79
  return {
@@ -52,33 +84,47 @@ export function copyTwigFilesPlugin({
52
84
  /** Capture the final outDir. */
53
85
  configResolved(cfg) {
54
86
  outDir = cfg.build?.outDir || 'dist';
87
+ watching = Boolean(cfg.build?.watch);
88
+ },
89
+
90
+ // Twig is copied rather than compiled, so none of it reaches Rollup's module
91
+ // graph, and Rollup only watches what is in that graph. Without this, saving
92
+ // a template produced no rebuild at all: `dist/` kept the previous version
93
+ // until some unrelated stylesheet happened to change. Storybook renders Twig
94
+ // through its own pipeline and looked correct throughout, so the stale copy
95
+ // was only visible to whatever consumes `dist/` — which on Drupal is the site.
96
+ buildStart() {
97
+ if (!watching) return;
98
+ for (const { absPath } of copyPlan()) this.addWatchFile(absPath);
55
99
  },
56
100
 
57
101
  /** Copy before the mirror plugin moves dist/components to the project root. */
58
102
  writeBundle() {
59
- for (const file of sourceFileIndex.componentFiles()) {
60
- if (file.absPath.endsWith('.twig')) {
61
- if (isPartial(file.relPath)) continue;
62
- copyToOutDir(
63
- file.absPath,
64
- copiedComponentOutputPath(file.absPath, structure),
65
- );
66
- } else if (isComponentMetadataFile(file.absPath)) {
67
- copyToOutDir(
68
- file.absPath,
69
- copiedComponentOutputPath(file.absPath, structure),
70
- );
71
- }
72
- }
73
-
74
- for (const file of sourceFileIndex.globalFiles()) {
75
- if (!file.absPath.endsWith('.twig')) continue;
76
- if (isPartial(file.relPath)) continue;
77
- copyToOutDir(
78
- file.absPath,
79
- copiedGlobalOutputPath(file.absPath, structure),
80
- );
103
+ for (const { absPath, relDest } of copyPlan()) {
104
+ copyToOutDir(absPath, relDest);
81
105
  }
82
106
  },
83
107
  };
108
+
109
+ /**
110
+ * Copy one file into the output directory.
111
+ *
112
+ * @param {string} absPath - Absolute source path.
113
+ * @param {string} relDest - Destination relative to `outDir`.
114
+ * @returns {void}
115
+ */
116
+ function copyToOutDir(absPath, relDest) {
117
+ if (!relDest) return;
118
+
119
+ // Copied unconditionally, because `build.emptyOutDir` clears the output
120
+ // directory on every watch cycle and not just the first — a freshness check
121
+ // against the destination can never find anything to skip.
122
+ const destPath = join(outDir, relDest);
123
+ mkdirSync(dirname(destPath), { recursive: true });
124
+ try {
125
+ copyFileSync(absPath, destPath);
126
+ } catch {
127
+ /* noop */
128
+ }
129
+ }
84
130
  }
@@ -148,6 +148,7 @@ const groupDeprecationsByFile = (deprecationList) => {
148
148
  * deprecationsByFile: Array<{file: string, occurrences: number, entries: Array<{id: string, count: number, lines: number[]}>}>,
149
149
  * unresolvedAssets: Array<{url: string, importer: string|undefined, count: number}>,
150
150
  * importErrors: Array<{file: string|undefined, line: number|undefined, specifier: string, count: number}>,
151
+ * syntaxErrors: Array<{minifier: string|undefined, message: string, declaration: string|undefined, count: number}>,
151
152
  * warnings: Array<{message: string|undefined, file: string|undefined, line: number|undefined, count: number}>,
152
153
  * errors: Array<{message: string|undefined, file: string|undefined, line: number|undefined, count: number}>,
153
154
  * deprecationTotal: number,
@@ -136,6 +136,47 @@ export function formatDuration(milliseconds) {
136
136
  return `${(milliseconds / 1000).toFixed(2)}s`;
137
137
  }
138
138
 
139
+ /**
140
+ * Format a byte count for display.
141
+ *
142
+ * Rolldown's discarded asset table reported kilobytes to two decimals per file.
143
+ * The reporter states one total and one largest file instead, so it rounds to
144
+ * whole kilobytes and one decimal megabyte — enough to notice a bundle doubling,
145
+ * without implying a precision that matters at this scale.
146
+ *
147
+ * @param {number} bytes - Byte count.
148
+ * @returns {string} Formatted size.
149
+ */
150
+ export function formatBytes(bytes) {
151
+ if (!Number.isFinite(bytes) || bytes < 0) return '0 B';
152
+ if (bytes < 1024) return `${Math.round(bytes)} B`;
153
+
154
+ const kilobytes = bytes / 1024;
155
+ if (kilobytes < 1024) return `${Math.round(kilobytes)} kB`;
156
+
157
+ return `${(kilobytes / 1024).toFixed(1)} MB`;
158
+ }
159
+
160
+ /**
161
+ * Format a byte count for a column of sizes.
162
+ *
163
+ * {@link formatBytes} rounds hard, which is right for a one-line total but wrong
164
+ * for a table: rounding turns 5,660 and 3,010 bytes into `6 kB` and `3 kB`, and a
165
+ * table whose whole purpose is comparison should not round away the difference.
166
+ * A single unit keeps the column directly comparable down its length rather than
167
+ * making the reader convert between B, kB, and MB row to row.
168
+ *
169
+ * Two decimals of kilobytes is the convention Rolldown's discarded asset table
170
+ * used, so the numbers are recognizable to anyone who has read that output.
171
+ *
172
+ * @param {number} bytes - Byte count.
173
+ * @returns {string} Formatted size in kilobytes.
174
+ */
175
+ export function formatPreciseBytes(bytes) {
176
+ if (!Number.isFinite(bytes) || bytes < 0) return '0.00 kB';
177
+ return `${(bytes / 1024).toFixed(2)} kB`;
178
+ }
179
+
139
180
  /**
140
181
  * Format a wall-clock timestamp for rebuild lines.
141
182
  *