@emulsify/core 4.3.2 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.storybook/main-static-assets.js +5 -8
  2. package/.storybook/main-vite.js +11 -3
  3. package/README.md +4 -5
  4. package/config/vite/entries.js +7 -2
  5. package/config/vite/environment.js +4 -0
  6. package/config/vite/plugins/assets/asset-url-rebase.js +241 -0
  7. package/config/vite/plugins/assets/copy-src-assets.js +82 -12
  8. package/config/vite/plugins/assets/copy-twig-files.js +85 -16
  9. package/config/vite/plugins/assets/css-asset-rebase.js +306 -0
  10. package/config/vite/plugins/assets/css-asset-relativizer.js +301 -21
  11. package/config/vite/plugins/assets/development-source-maps.js +273 -0
  12. package/config/vite/plugins/assets/mirror-components.js +98 -82
  13. package/config/vite/plugins/assets/output-freshness.js +235 -0
  14. package/config/vite/plugins/assets/source-file-index.js +7 -1
  15. package/config/vite/plugins/assets/stable-watch-output.js +165 -0
  16. package/config/vite/plugins/assets/storybook-output.js +27 -0
  17. package/config/vite/plugins/index.js +95 -9
  18. package/config/vite/plugins/reporter/asset-resolver.js +34 -6
  19. package/config/vite/plugins/reporter/build-errors.js +7 -3
  20. package/config/vite/plugins/reporter/diagnostics.js +140 -10
  21. package/config/vite/plugins/reporter/index.js +380 -75
  22. package/config/vite/plugins/reporter/render.js +297 -44
  23. package/config/vite/plugins/reporter/sass-logger.js +30 -0
  24. package/config/vite/plugins/reporter/source-roots.js +101 -21
  25. package/config/vite/plugins/reporter/strict-mode.js +99 -0
  26. package/config/vite/plugins/reporter/vite-logger.js +220 -8
  27. package/config/vite/plugins/reporter/watch-mode.js +6 -2
  28. package/config/vite/plugins/twig/virtual-twig-asset-sources.js +48 -49
  29. package/config/vite/project-config.js +121 -21
  30. package/config/vite/project-structure.js +6 -0
  31. package/config/vite/utils/asset-roots.js +205 -0
  32. package/config/vite/utils/css-urls.js +350 -0
  33. package/config/vite/utils/fs-safe.js +38 -1
  34. package/config/vite/utils/source-maps.js +88 -0
  35. package/config/vite/vite.config.js +106 -42
  36. package/package.json +40 -29
  37. package/scripts/audit/checks/css-asset-references.js +256 -24
  38. package/scripts/audit/fix.js +836 -0
  39. package/scripts/audit/index.js +10 -2
  40. package/scripts/audit/lib/css.js +41 -35
  41. package/scripts/audit/lib/twig.js +11 -29
  42. package/scripts/audit/report.js +83 -5
  43. package/scripts/audit.js +87 -2
  44. package/src/storybook/twig/source-function.js +14 -10
@@ -7,16 +7,12 @@
7
7
 
8
8
  import {
9
9
  copyFileSync,
10
- closeSync,
11
10
  lstatSync,
12
11
  mkdirSync,
13
- openSync,
14
12
  readFileSync,
15
- readSync,
16
13
  readdirSync,
17
14
  renameSync,
18
15
  rmdirSync,
19
- statSync,
20
16
  unlinkSync,
21
17
  writeFileSync,
22
18
  } from 'fs';
@@ -24,10 +20,14 @@ import { basename, dirname, join, resolve } from 'path';
24
20
 
25
21
  import { safeExists, safeReadJson } from '../../utils/fs-safe.js';
26
22
  import { resolvePackageVersion } from '../../utils/package-version.js';
23
+ import {
24
+ isGeneratedSourceMap,
25
+ rebaseSourceMapForMove,
26
+ } from '../../utils/source-maps.js';
27
+ import { bytesAlreadyOnDisk, filesHaveSameBytes } from './output-freshness.js';
27
28
  import { walkFiles } from './source-file-index.js';
28
29
 
29
30
  const MIRROR_STATE_FILE = '.emulsify-mirror-state.json';
30
- const FILE_COMPARE_CHUNK_SIZE = 64 * 1024;
31
31
 
32
32
  /**
33
33
  * Remove empty parent directories from a start directory up to, but not including,
@@ -64,77 +64,6 @@ const pruneEmptyDirsUpTo = (startDir, stopAtDir) => {
64
64
  }
65
65
  };
66
66
 
67
- /**
68
- * Determine whether two files already contain the same bytes.
69
- * Small files are read directly; larger files are compared in fixed-size chunks
70
- * so the mirror phase does not transiently allocate both complete file bodies.
71
- *
72
- * @param {string} sourceFile - Source file path.
73
- * @param {string} destinationFile - Destination file path.
74
- * @returns {boolean} TRUE when both files have identical bytes.
75
- */
76
- export const filesHaveSameBytes = (sourceFile, destinationFile) => {
77
- try {
78
- const sourceStats = statSync(sourceFile);
79
- const destinationStats = statSync(destinationFile);
80
- if (!destinationStats.isFile()) return false;
81
- if (sourceStats.size !== destinationStats.size) return false;
82
- if (sourceStats.size === 0) return true;
83
-
84
- if (sourceStats.size < FILE_COMPARE_CHUNK_SIZE) {
85
- return readFileSync(sourceFile).equals(readFileSync(destinationFile));
86
- }
87
-
88
- const sourceBuffer = Buffer.allocUnsafe(FILE_COMPARE_CHUNK_SIZE);
89
- const destinationBuffer = Buffer.allocUnsafe(FILE_COMPARE_CHUNK_SIZE);
90
- const sourceHandle = openSync(sourceFile, 'r');
91
- try {
92
- const destinationHandle = openSync(destinationFile, 'r');
93
- try {
94
- let position = 0;
95
- while (position < sourceStats.size) {
96
- const bytesToRead = Math.min(
97
- FILE_COMPARE_CHUNK_SIZE,
98
- sourceStats.size - position,
99
- );
100
- const sourceBytesRead = readSync(
101
- sourceHandle,
102
- sourceBuffer,
103
- 0,
104
- bytesToRead,
105
- position,
106
- );
107
- const destinationBytesRead = readSync(
108
- destinationHandle,
109
- destinationBuffer,
110
- 0,
111
- bytesToRead,
112
- position,
113
- );
114
-
115
- if (sourceBytesRead !== destinationBytesRead) return false;
116
- if (sourceBytesRead === 0) return false;
117
- if (
118
- !sourceBuffer
119
- .subarray(0, sourceBytesRead)
120
- .equals(destinationBuffer.subarray(0, destinationBytesRead))
121
- ) {
122
- return false;
123
- }
124
- position += sourceBytesRead;
125
- }
126
- return true;
127
- } finally {
128
- closeSync(destinationHandle);
129
- }
130
- } finally {
131
- closeSync(sourceHandle);
132
- }
133
- } catch {
134
- return false;
135
- }
136
- };
137
-
138
67
  /**
139
68
  * Determine whether a filesystem path is a symbolic link.
140
69
  *
@@ -226,6 +155,40 @@ const moveFileIntoPlace = (sourceFile, destinationFile) => {
226
155
  }
227
156
  };
228
157
 
158
+ /**
159
+ * Write transformed output bytes atomically, then remove the transient source.
160
+ *
161
+ * Source maps need their relative paths changed before mirroring, so they
162
+ * cannot use the rename-only path above. The destination comparison still
163
+ * preserves stable mtimes when the rebased bytes match the previous cycle.
164
+ *
165
+ * @param {string} sourceFile - Built file under dist.
166
+ * @param {string} destinationFile - Mirrored project-root destination.
167
+ * @param {string} contents - Final destination contents.
168
+ */
169
+ const writeContentsIntoPlace = (sourceFile, destinationFile, contents) => {
170
+ mkdirSync(dirname(destinationFile), { recursive: true });
171
+
172
+ if (bytesAlreadyOnDisk(destinationFile, contents)) {
173
+ removeSourceFile(sourceFile);
174
+ return;
175
+ }
176
+
177
+ const tempDestination = createTempDestination(destinationFile);
178
+ try {
179
+ writeFileSync(tempDestination, contents);
180
+ renameSync(tempDestination, destinationFile);
181
+ removeSourceFile(sourceFile);
182
+ } catch (error) {
183
+ try {
184
+ unlinkSync(tempDestination);
185
+ } catch {
186
+ /* noop */
187
+ }
188
+ throw error;
189
+ }
190
+ };
191
+
229
192
  /**
230
193
  * Safely read the previous mirror state marker.
231
194
  *
@@ -265,17 +228,24 @@ const warnOnInterruptedMirror = (markerFile) => {
265
228
  /**
266
229
  * Mirror built component files to the project root `./components/` directory.
267
230
  *
268
- * @param {{ enabled: boolean, projectDir: string }} opts - Plugin options.
231
+ * @param {{ enabled: boolean, projectDir: string, developmentBuild?: boolean, diagnostics?: object }} opts - Plugin options.
269
232
  * @returns {import('vite').PluginOption} Drupal mirror plugin.
270
233
  */
271
- export function mirrorComponentsToRoot({ enabled, projectDir }) {
234
+ export function mirrorComponentsToRoot({
235
+ enabled,
236
+ projectDir,
237
+ developmentBuild = false,
238
+ diagnostics,
239
+ }) {
272
240
  let outDir = 'dist';
241
+ let watching = Boolean(developmentBuild);
273
242
  return {
274
243
  name: 'emulsify-mirror-components-to-root',
275
244
  apply: 'build',
276
245
  enforce: 'post',
277
246
  configResolved(cfg) {
278
247
  outDir = cfg.build?.outDir || 'dist';
248
+ watching = Boolean(developmentBuild || cfg.build?.watch);
279
249
  },
280
250
  writeBundle() {
281
251
  if (!enabled) return;
@@ -299,18 +269,64 @@ export function mirrorComponentsToRoot({ enabled, projectDir }) {
299
269
  const destFile = join(projectDir, relFromOutDir);
300
270
 
301
271
  try {
302
- moveFileIntoPlace(srcFile, destFile);
272
+ if (isGeneratedSourceMap(srcFile)) {
273
+ const sourceMap = readFileSync(srcFile, 'utf8');
274
+ writeContentsIntoPlace(
275
+ srcFile,
276
+ destFile,
277
+ rebaseSourceMapForMove(sourceMap, srcFile, destFile),
278
+ );
279
+ } else {
280
+ moveFileIntoPlace(srcFile, destFile);
281
+ }
303
282
  pruneEmptyDirsUpTo(dirname(srcFile), distComponents);
304
283
  } catch (e) {
305
- console.warn(
306
- `Mirror copy failed for ${relFromOutDir}: ${e?.message || e}`,
307
- );
284
+ const message = `Mirror copy failed for ${relFromOutDir}: ${e?.message || e}`;
285
+ diagnostics?.recordError?.({
286
+ message,
287
+ file: destFile,
288
+ outputState: 'incomplete',
289
+ });
290
+ // One-shot builds do not render the watch-cycle failure summary,
291
+ // so preserve their immediate warning through Rollup's logger.
292
+ if (typeof this.warn === 'function') this.warn(message);
293
+ else console.warn(message);
308
294
  }
309
295
  }
310
296
 
311
297
  pruneEmptyDirsUpTo(distComponents, outDir);
312
298
  }
313
299
 
300
+ // Watch builds intentionally publish source maps for browser debugging.
301
+ // A later production build cleans dist/ but not the mirrored root, so
302
+ // remove maps left under components/ by an earlier watch session.
303
+ if (!watching) {
304
+ const rootComponents = join(projectDir, 'components');
305
+ for (const sourceMap of walkFiles(rootComponents).filter(
306
+ isGeneratedSourceMap,
307
+ )) {
308
+ try {
309
+ unlinkSync(sourceMap);
310
+ const parentDir = dirname(sourceMap);
311
+ if (resolve(parentDir) !== resolve(rootComponents)) {
312
+ pruneEmptyDirsUpTo(parentDir, rootComponents);
313
+ }
314
+ } catch (e) {
315
+ const relativeSourceMap = sourceMap.slice(
316
+ join(projectDir, '').length,
317
+ );
318
+ const message = `Production source-map cleanup failed for ${relativeSourceMap}: ${e?.message || e}`;
319
+ diagnostics?.recordError?.({
320
+ message,
321
+ file: sourceMap,
322
+ outputState: 'incomplete',
323
+ });
324
+ if (typeof this.warn === 'function') this.warn(message);
325
+ else console.warn(message);
326
+ }
327
+ }
328
+ }
329
+
314
330
  writeMirrorState(markerFile, {
315
331
  ...mirrorState,
316
332
  completedAt: new Date().toISOString(),
@@ -0,0 +1,235 @@
1
+ /**
2
+ * @file Where a built file lands, and whether it is already there.
3
+ *
4
+ * Shared by every plugin that writes into the output tree during a watch build.
5
+ * Vite used to empty that tree at the start of each rebuild, so nothing was
6
+ * ever up to date and each of these plugins wrote unconditionally;
7
+ * `stableWatchOutputPlugin` stops the emptying after the first cycle, which is
8
+ * what makes a freshness check meaningful at all.
9
+ */
10
+
11
+ import {
12
+ closeSync,
13
+ lstatSync,
14
+ openSync,
15
+ readFileSync,
16
+ readSync,
17
+ statSync,
18
+ unlinkSync,
19
+ } from 'fs';
20
+ import { join } from 'path';
21
+
22
+ const FILE_COMPARE_CHUNK_SIZE = 64 * 1024;
23
+
24
+ /**
25
+ * Remove a destination symlink without following it.
26
+ *
27
+ * A stale output symlink must never survive until a copy: `copyFileSync` follows
28
+ * its destination and would overwrite the link target. Missing destinations
29
+ * are safe; inspection or unlink failures propagate so callers cannot proceed
30
+ * with an unsafe write.
31
+ *
32
+ * @param {string} filePath - Destination path to inspect.
33
+ * @returns {boolean} TRUE when a symlink was removed.
34
+ */
35
+ export const removeDestinationSymlink = (filePath) => {
36
+ let stats;
37
+
38
+ try {
39
+ stats = lstatSync(filePath);
40
+ } catch (error) {
41
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return false;
42
+ throw error;
43
+ }
44
+
45
+ if (stats.isSymbolicLink()) {
46
+ unlinkSync(filePath);
47
+ return true;
48
+ }
49
+
50
+ return false;
51
+ };
52
+
53
+ /**
54
+ * Inspect a destination without following symlinks.
55
+ *
56
+ * @param {string} filePath - Destination path to inspect.
57
+ * @returns {import('fs').Stats|null} Destination stats, or null when absent or removed.
58
+ */
59
+ const destinationStatsForComparison = (filePath) => {
60
+ if (removeDestinationSymlink(filePath)) return null;
61
+
62
+ try {
63
+ return lstatSync(filePath);
64
+ } catch (error) {
65
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return null;
66
+ throw error;
67
+ }
68
+ };
69
+
70
+ /**
71
+ * Determine whether two files already contain the same bytes.
72
+ *
73
+ * Small files are read directly; larger files are compared in fixed-size chunks
74
+ * so a build phase does not transiently allocate both complete file bodies.
75
+ *
76
+ * @param {string} sourceFile - Source file path.
77
+ * @param {string} destinationFile - Destination file path.
78
+ * @returns {boolean} TRUE when both files have identical bytes.
79
+ */
80
+ export const filesHaveSameBytes = (sourceFile, destinationFile) => {
81
+ const destinationStats = destinationStatsForComparison(destinationFile);
82
+ if (!destinationStats?.isFile()) return false;
83
+
84
+ try {
85
+ const sourceStats = statSync(sourceFile);
86
+ if (sourceStats.size !== destinationStats.size) return false;
87
+ if (sourceStats.size === 0) return true;
88
+
89
+ if (sourceStats.size < FILE_COMPARE_CHUNK_SIZE) {
90
+ return readFileSync(sourceFile).equals(readFileSync(destinationFile));
91
+ }
92
+
93
+ const sourceBuffer = Buffer.allocUnsafe(FILE_COMPARE_CHUNK_SIZE);
94
+ const destinationBuffer = Buffer.allocUnsafe(FILE_COMPARE_CHUNK_SIZE);
95
+ const sourceHandle = openSync(sourceFile, 'r');
96
+ try {
97
+ const destinationHandle = openSync(destinationFile, 'r');
98
+ try {
99
+ let position = 0;
100
+ while (position < sourceStats.size) {
101
+ const bytesToRead = Math.min(
102
+ FILE_COMPARE_CHUNK_SIZE,
103
+ sourceStats.size - position,
104
+ );
105
+ const sourceBytesRead = readSync(
106
+ sourceHandle,
107
+ sourceBuffer,
108
+ 0,
109
+ bytesToRead,
110
+ position,
111
+ );
112
+ const destinationBytesRead = readSync(
113
+ destinationHandle,
114
+ destinationBuffer,
115
+ 0,
116
+ bytesToRead,
117
+ position,
118
+ );
119
+
120
+ if (sourceBytesRead !== destinationBytesRead) return false;
121
+ if (sourceBytesRead === 0) return false;
122
+ if (
123
+ !sourceBuffer
124
+ .subarray(0, sourceBytesRead)
125
+ .equals(destinationBuffer.subarray(0, destinationBytesRead))
126
+ ) {
127
+ return false;
128
+ }
129
+ position += sourceBytesRead;
130
+ }
131
+ return true;
132
+ } finally {
133
+ closeSync(destinationHandle);
134
+ }
135
+ } finally {
136
+ closeSync(sourceHandle);
137
+ }
138
+ } catch {
139
+ return false;
140
+ }
141
+ };
142
+
143
+ /**
144
+ * Read an emitted asset source as a buffer.
145
+ *
146
+ * @param {string|Uint8Array} source - Emitted asset source.
147
+ * Uint8Array sources are returned directly: Buffer comparisons accept them,
148
+ * so copying the complete asset would only add transient memory pressure.
149
+ *
150
+ * @returns {Buffer|Uint8Array} Asset bytes.
151
+ */
152
+ const toBytes = (source) =>
153
+ typeof source === 'string' ? Buffer.from(source, 'utf8') : source;
154
+
155
+ /**
156
+ * Determine whether an in-memory source already exists on disk unchanged.
157
+ *
158
+ * @param {string} filePath - Absolute path the bytes would occupy.
159
+ * @param {string|Uint8Array} source - Bytes about to be written.
160
+ * @returns {boolean} TRUE when writing would be a no-op.
161
+ */
162
+ export function bytesAlreadyOnDisk(filePath, source) {
163
+ const destinationStats = destinationStatsForComparison(filePath);
164
+ if (!destinationStats?.isFile()) return false;
165
+
166
+ const sourceBytes = toBytes(source);
167
+ if (destinationStats.size !== sourceBytes.byteLength) return false;
168
+ if (destinationStats.size === 0) return true;
169
+
170
+ let handle;
171
+
172
+ try {
173
+ handle = openSync(filePath, 'r');
174
+ const destinationBuffer = Buffer.allocUnsafe(FILE_COMPARE_CHUNK_SIZE);
175
+ let position = 0;
176
+
177
+ while (position < destinationStats.size) {
178
+ const bytesToRead = Math.min(
179
+ FILE_COMPARE_CHUNK_SIZE,
180
+ destinationStats.size - position,
181
+ );
182
+ const bytesRead = readSync(
183
+ handle,
184
+ destinationBuffer,
185
+ 0,
186
+ bytesToRead,
187
+ position,
188
+ );
189
+
190
+ if (bytesRead === 0) return false;
191
+ if (
192
+ !destinationBuffer
193
+ .subarray(0, bytesRead)
194
+ .equals(sourceBytes.subarray(position, position + bytesRead))
195
+ ) {
196
+ return false;
197
+ }
198
+ position += bytesRead;
199
+ }
200
+
201
+ return true;
202
+ } catch {
203
+ return false;
204
+ } finally {
205
+ if (handle !== undefined) {
206
+ try {
207
+ closeSync(handle);
208
+ } catch {
209
+ /* noop */
210
+ }
211
+ }
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Resolve where an output-relative file ends up once the build finishes.
217
+ *
218
+ * Drupal projects that author under `src/` have their component output moved
219
+ * out of `dist/` by `mirrorComponentsToRoot`, so the previous cycle's copy is
220
+ * not in the output directory to compare against — it is one directory level
221
+ * up, beside the theme's other components. Comparing against the wrong location
222
+ * makes every component file look new, which both defeats the freshness check
223
+ * and leaves the transient write-then-move churn in place.
224
+ *
225
+ * @param {string} relPath - Path relative to the output directory.
226
+ * @param {{outDir: string, projectDir: string, mirrored?: boolean}} paths - Resolved locations.
227
+ * @returns {string} Absolute path the file occupies after the build.
228
+ */
229
+ export function resolveFinalPath(relPath, { outDir, projectDir, mirrored }) {
230
+ if (mirrored && relPath.startsWith('components/')) {
231
+ return join(projectDir, relPath);
232
+ }
233
+
234
+ return join(outDir, relPath);
235
+ }
@@ -131,7 +131,8 @@ const globalTraversalSkipRoots = (globalRoot, componentRoots) => {
131
131
  * @returns {{
132
132
  * all: () => Array<object>,
133
133
  * componentFiles: () => Array<object>,
134
- * globalFiles: () => Array<object>
134
+ * globalFiles: () => Array<object>,
135
+ * refresh: () => void
135
136
  * }} Indexed file accessors.
136
137
  */
137
138
  export function createSourceFileIndex(structure) {
@@ -179,5 +180,10 @@ export function createSourceFileIndex(structure) {
179
180
  build();
180
181
  return globalFilesArr;
181
182
  },
183
+ refresh: () => {
184
+ indexedFiles = null;
185
+ componentFilesArr = null;
186
+ globalFilesArr = null;
187
+ },
182
188
  };
183
189
  }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * @file Keep `vite build --watch` output stable between rebuilds.
3
+ *
4
+ * ## The problem
5
+ *
6
+ * Saving one component during `npm run develop` used to rewrite every file in
7
+ * the output tree. Measured on a real theme, one edit produced:
8
+ *
9
+ * [vite] hot updated: /dist/global/layout/layout.css
10
+ * [vite] hot updated: /dist/storybook/components/atoms/textures/cl-textures.css
11
+ *
12
+ * Neither file was touched by the edit, and the edited component's own
13
+ * stylesheet is not in the list. Storybook then reloaded the preview iframe
14
+ * instead of swapping the stylesheet in place.
15
+ *
16
+ * ## Why every file changed
17
+ *
18
+ * Two independent causes, and both have to be removed or the churn remains.
19
+ *
20
+ * 1. Vite empties the output directory on **every** watch rebuild, not just the
21
+ * first. The emptying runs from a `renderStart` hook guarded by a
22
+ * "already prepared this environment" set, and Vite's `watchChange` hook
23
+ * clears that set, so the guard is re-armed before each cycle. Every
24
+ * stylesheet is therefore deleted and recreated per keystroke. Deleting a
25
+ * file that Storybook reached through an eager `import.meta.glob` changes
26
+ * the glob's module set, which is a full-reload invalidation rather than a
27
+ * CSS swap — the flash.
28
+ *
29
+ * 2. Rollup regenerates and rewrites the whole bundle each cycle regardless.
30
+ * Even with the directory left alone, every stylesheet would get a fresh
31
+ * mtime and every watcher would fire.
32
+ *
33
+ * ## What this plugin does
34
+ *
35
+ * Turns off the per-rebuild emptying once the first cycle has had its clean
36
+ * tree, then drops bytes-identical stylesheets from the bundle so Rollup never
37
+ * rewrites them. The first cycle still empties, through Vite's own code and its
38
+ * own guards, so a develop session starts exactly as it does today.
39
+ *
40
+ * A dropped asset leaves the existing file untouched, so no watcher event
41
+ * fires and no HMR update is sent for a stylesheet the edit could not have
42
+ * affected. This mirrors what `mirror-components.js` already does for mirrored
43
+ * component output, where `filesHaveSameBytes` skips the move; plain output had
44
+ * no equivalent.
45
+ *
46
+ * Scoped to emitted assets, not JavaScript chunks: a chunk travels with a
47
+ * sourcemap and a hashed name graph, and skipping one of a pair is a harder
48
+ * claim to make. CSS is where the cost lands anyway, because stylesheets are
49
+ * what the preview enumerates by glob.
50
+ *
51
+ * Stylesheets are not the only churn source. Twig templates and static assets
52
+ * are copied straight to disk rather than emitted through the bundle, so they
53
+ * carry the same freshness check in their own plugins; a rewritten `.twig` in
54
+ * the output tree is a full preview reload rather than a style swap.
55
+ *
56
+ * One-shot builds are untouched: `npm run build`, `storybook build`, and the
57
+ * release fixture verifications all start from an emptied directory and write
58
+ * every file unconditionally.
59
+ */
60
+
61
+ import { isAbsolute, resolve } from 'path';
62
+
63
+ import { bytesAlreadyOnDisk, resolveFinalPath } from './output-freshness.js';
64
+
65
+ /**
66
+ * Keep watch-build output stable so unchanged stylesheets are not rewritten.
67
+ *
68
+ * @param {{
69
+ * projectDir?: string,
70
+ * mirrorComponentOutput?: boolean,
71
+ * unchangedOutputs?: Set<string>
72
+ * }} [opts={}] - Plugin options. `unchangedOutputs` is shared with the develop
73
+ * reporter so a skipped file is not reported as a deleted one.
74
+ * @returns {import('vite').PluginOption} Stable watch output plugin.
75
+ */
76
+ export function stableWatchOutputPlugin({
77
+ projectDir = process.cwd(),
78
+ mirrorComponentOutput = false,
79
+ unchangedOutputs = new Set(),
80
+ } = {}) {
81
+ let outDir = 'dist';
82
+ let watching = false;
83
+ let completedCycles = 0;
84
+
85
+ return {
86
+ name: 'emulsify-stable-watch-output',
87
+ apply: 'build',
88
+
89
+ // Runs after the plugins that rewrite CSS text, or an asset would be
90
+ // compared before its URLs were finalized and always look changed.
91
+ enforce: 'post',
92
+
93
+ configResolved(config) {
94
+ const configured = config?.build?.outDir || 'dist';
95
+ outDir = isAbsolute(configured)
96
+ ? configured
97
+ : resolve(projectDir, configured);
98
+ watching = Boolean(config?.build?.watch);
99
+ },
100
+
101
+ buildStart() {
102
+ unchangedOutputs.clear();
103
+ if (!watching) return;
104
+
105
+ // Leave the first successfully completed cycle entirely to Vite,
106
+ // including its refusal to empty an output directory outside the project
107
+ // root. On later cycles this hook runs before Vite's `renderStart`
108
+ // emptying hook, so the flag is disabled before Vite decides whether to
109
+ // clear the tree. A failed initial cycle never reaches `writeBundle`, so
110
+ // its retry still starts clean.
111
+ if (completedCycles === 0) return;
112
+
113
+ // The flag has to be set on `this.environment.config`: the config object
114
+ // `configResolved` receives is a different one, and mutating that has no
115
+ // effect on what Vite reads per cycle.
116
+ const buildOptions = this.environment?.config?.build;
117
+ if (!buildOptions) {
118
+ this.warn(
119
+ 'Unable to keep watch output stable because ' +
120
+ 'this.environment.config.build is unavailable; Vite may empty ' +
121
+ 'the output directory on this rebuild.',
122
+ );
123
+ return;
124
+ }
125
+
126
+ buildOptions.emptyOutDir = false;
127
+ },
128
+
129
+ // Count only cycles that made it through all normal output writers. Using
130
+ // buildStart here would mistake a transform or render failure for a
131
+ // completed first cycle and suppress cleaning on the retry.
132
+ writeBundle: {
133
+ order: 'post',
134
+ handler() {
135
+ if (watching) completedCycles += 1;
136
+ },
137
+ },
138
+
139
+ generateBundle(_options, bundle) {
140
+ // A one-shot build always starts from an emptied directory, so nothing
141
+ // would match; leaving it alone keeps release output byte for byte.
142
+ if (!watching) return;
143
+
144
+ for (const [fileName, output] of Object.entries(bundle)) {
145
+ // Assets only. A JavaScript chunk travels with a sourcemap and a hashed
146
+ // name graph, and skipping one of a pair is a harder claim to make;
147
+ // nothing in the preview enumerates chunks by glob, so there is no
148
+ // reload to win back.
149
+ if (output.type !== 'asset') continue;
150
+ if (output.source == null) continue;
151
+
152
+ const finalPath = resolveFinalPath(fileName, {
153
+ outDir,
154
+ projectDir,
155
+ mirrored: mirrorComponentOutput,
156
+ });
157
+
158
+ if (bytesAlreadyOnDisk(finalPath, output.source)) {
159
+ unchangedOutputs.add(fileName);
160
+ delete bundle[fileName];
161
+ }
162
+ }
163
+ },
164
+ };
165
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @file Storybook build output markers shared by Core plugins.
3
+ *
4
+ * Storybook copies `staticDirs` into `.out/assets` while the preview build
5
+ * runs, so Vite-generated chunks are routed to a separate directory to avoid
6
+ * concurrent writers. That directory name doubles as the one deterministic
7
+ * signal a Core plugin has for "this build is Storybook's, not the theme's" —
8
+ * no env sniffing, no plugin-name matching, no guessing at `outDir`, which a
9
+ * consumer can override with `-o`.
10
+ */
11
+
12
+ /**
13
+ * Directory Storybook's Vite build writes generated chunks to.
14
+ *
15
+ * @type {string}
16
+ */
17
+ export const STORYBOOK_VITE_ASSETS_DIR = 'storybook-assets';
18
+
19
+ /**
20
+ * Determine whether a resolved Vite config belongs to a Storybook build.
21
+ *
22
+ * @param {{build?: {assetsDir?: string}}} config - Resolved Vite config.
23
+ * @returns {boolean} TRUE when Storybook owns this output directory.
24
+ */
25
+ export function isStorybookOutput(config) {
26
+ return config?.build?.assetsDir === STORYBOOK_VITE_ASSETS_DIR;
27
+ }