@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.
- package/.storybook/main-static-assets.js +5 -8
- package/.storybook/main-vite.js +11 -3
- package/README.md +4 -5
- package/config/vite/entries.js +7 -2
- package/config/vite/environment.js +4 -0
- package/config/vite/plugins/assets/asset-url-rebase.js +241 -0
- package/config/vite/plugins/assets/copy-src-assets.js +82 -12
- package/config/vite/plugins/assets/copy-twig-files.js +85 -16
- package/config/vite/plugins/assets/css-asset-rebase.js +306 -0
- package/config/vite/plugins/assets/css-asset-relativizer.js +301 -21
- package/config/vite/plugins/assets/development-source-maps.js +273 -0
- package/config/vite/plugins/assets/mirror-components.js +98 -82
- package/config/vite/plugins/assets/output-freshness.js +235 -0
- package/config/vite/plugins/assets/source-file-index.js +7 -1
- package/config/vite/plugins/assets/stable-watch-output.js +165 -0
- package/config/vite/plugins/assets/storybook-output.js +27 -0
- package/config/vite/plugins/index.js +95 -9
- package/config/vite/plugins/reporter/asset-resolver.js +34 -6
- package/config/vite/plugins/reporter/build-errors.js +7 -3
- package/config/vite/plugins/reporter/diagnostics.js +140 -10
- package/config/vite/plugins/reporter/index.js +380 -75
- package/config/vite/plugins/reporter/render.js +297 -44
- package/config/vite/plugins/reporter/sass-logger.js +30 -0
- package/config/vite/plugins/reporter/source-roots.js +101 -21
- package/config/vite/plugins/reporter/strict-mode.js +99 -0
- package/config/vite/plugins/reporter/vite-logger.js +220 -8
- package/config/vite/plugins/reporter/watch-mode.js +6 -2
- package/config/vite/plugins/twig/virtual-twig-asset-sources.js +48 -49
- package/config/vite/project-config.js +121 -21
- package/config/vite/project-structure.js +6 -0
- package/config/vite/utils/asset-roots.js +205 -0
- package/config/vite/utils/css-urls.js +350 -0
- package/config/vite/utils/fs-safe.js +38 -1
- package/config/vite/utils/source-maps.js +88 -0
- package/config/vite/vite.config.js +106 -42
- package/package.json +40 -29
- package/scripts/audit/checks/css-asset-references.js +256 -24
- package/scripts/audit/fix.js +836 -0
- package/scripts/audit/index.js +10 -2
- package/scripts/audit/lib/css.js +41 -35
- package/scripts/audit/lib/twig.js +11 -29
- package/scripts/audit/report.js +83 -5
- package/scripts/audit.js +87 -2
- 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({
|
|
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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
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
|
+
}
|