@emulsify/core 4.3.0 → 4.3.2
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 +82 -34
- 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
package/.storybook/main-vite.js
CHANGED
|
@@ -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
|
package/.storybook/main.js
CHANGED
|
@@ -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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
57
|
-
|
|
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
|
}
|
|
@@ -17,10 +17,6 @@ import {
|
|
|
17
17
|
isComponentMetadataFile,
|
|
18
18
|
} from './source-file-index.js';
|
|
19
19
|
|
|
20
|
-
/** Determine whether a Twig file is a partial (filename starts with `_`). */
|
|
21
|
-
const isPartial = (filePath) =>
|
|
22
|
-
(filePath.split('/')?.pop() || '').trim().startsWith('_');
|
|
23
|
-
|
|
24
20
|
/**
|
|
25
21
|
* Copy Twig templates and component metadata to `dist/`.
|
|
26
22
|
*
|
|
@@ -32,16 +28,54 @@ export function copyTwigFilesPlugin({
|
|
|
32
28
|
sourceFileIndex = createSourceFileIndex(structure),
|
|
33
29
|
}) {
|
|
34
30
|
let outDir = 'dist';
|
|
31
|
+
let watching = false;
|
|
32
|
+
/** @type {Array<{absPath: string, relDest: string}>|undefined} */
|
|
33
|
+
let plan;
|
|
35
34
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Resolve every file this plugin copies, paired with where it lands.
|
|
37
|
+
*
|
|
38
|
+
* Built once and reused, because the source index is resolved at config time
|
|
39
|
+
* and does not change across watch cycles. Both hooks below read this same
|
|
40
|
+
* list, which is what keeps "gets copied to dist" and "a save triggers the
|
|
41
|
+
* copy" from drifting apart — a file cannot be added to one without the other.
|
|
42
|
+
*
|
|
43
|
+
* @returns {Array<{absPath: string, relDest: string}>} Copy plan.
|
|
44
|
+
*/
|
|
45
|
+
const copyPlan = () => {
|
|
46
|
+
if (plan) return plan;
|
|
47
|
+
|
|
48
|
+
plan = [];
|
|
49
|
+
|
|
50
|
+
// A leading underscore excludes a stylesheet from compilation, because a Sass
|
|
51
|
+
// partial is inlined into whatever imports it and has no output of its own.
|
|
52
|
+
// Twig has no equivalent: `{% include %}` and `{% embed %}` resolve at render
|
|
53
|
+
// time against the emitted tree, so an underscored template is a file the site
|
|
54
|
+
// still has to find. Skipping those left the include unresolvable at runtime.
|
|
55
|
+
for (const file of sourceFileIndex.componentFiles()) {
|
|
56
|
+
if (
|
|
57
|
+
!file.absPath.endsWith('.twig') &&
|
|
58
|
+
!isComponentMetadataFile(file.absPath)
|
|
59
|
+
) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
plan.push({
|
|
64
|
+
absPath: file.absPath,
|
|
65
|
+
relDest: copiedComponentOutputPath(file.absPath, structure),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const file of sourceFileIndex.globalFiles()) {
|
|
70
|
+
if (!file.absPath.endsWith('.twig')) continue;
|
|
71
|
+
|
|
72
|
+
plan.push({
|
|
73
|
+
absPath: file.absPath,
|
|
74
|
+
relDest: copiedGlobalOutputPath(file.absPath, structure),
|
|
75
|
+
});
|
|
44
76
|
}
|
|
77
|
+
|
|
78
|
+
return plan;
|
|
45
79
|
};
|
|
46
80
|
|
|
47
81
|
return {
|
|
@@ -52,33 +86,47 @@ export function copyTwigFilesPlugin({
|
|
|
52
86
|
/** Capture the final outDir. */
|
|
53
87
|
configResolved(cfg) {
|
|
54
88
|
outDir = cfg.build?.outDir || 'dist';
|
|
89
|
+
watching = Boolean(cfg.build?.watch);
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
// Twig is copied rather than compiled, so none of it reaches Rollup's module
|
|
93
|
+
// graph, and Rollup only watches what is in that graph. Without this, saving
|
|
94
|
+
// a template produced no rebuild at all: `dist/` kept the previous version
|
|
95
|
+
// until some unrelated stylesheet happened to change. Storybook renders Twig
|
|
96
|
+
// through its own pipeline and looked correct throughout, so the stale copy
|
|
97
|
+
// was only visible to whatever consumes `dist/` — which on Drupal is the site.
|
|
98
|
+
buildStart() {
|
|
99
|
+
if (!watching) return;
|
|
100
|
+
for (const { absPath } of copyPlan()) this.addWatchFile(absPath);
|
|
55
101
|
},
|
|
56
102
|
|
|
57
103
|
/** Copy before the mirror plugin moves dist/components to the project root. */
|
|
58
104
|
writeBundle() {
|
|
59
|
-
for (const
|
|
60
|
-
|
|
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
|
-
);
|
|
105
|
+
for (const { absPath, relDest } of copyPlan()) {
|
|
106
|
+
copyToOutDir(absPath, relDest);
|
|
81
107
|
}
|
|
82
108
|
},
|
|
83
109
|
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Copy one file into the output directory.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} absPath - Absolute source path.
|
|
115
|
+
* @param {string} relDest - Destination relative to `outDir`.
|
|
116
|
+
* @returns {void}
|
|
117
|
+
*/
|
|
118
|
+
function copyToOutDir(absPath, relDest) {
|
|
119
|
+
if (!relDest) return;
|
|
120
|
+
|
|
121
|
+
// Copied unconditionally, because `build.emptyOutDir` clears the output
|
|
122
|
+
// directory on every watch cycle and not just the first — a freshness check
|
|
123
|
+
// against the destination can never find anything to skip.
|
|
124
|
+
const destPath = join(outDir, relDest);
|
|
125
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
126
|
+
try {
|
|
127
|
+
copyFileSync(absPath, destPath);
|
|
128
|
+
} catch {
|
|
129
|
+
/* noop */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
84
132
|
}
|
|
@@ -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
|
*
|