@emulsify/core 3.4.1 → 4.0.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/.cli/init.js +40 -31
- package/.storybook/_drupal.js +129 -8
- package/.storybook/css-components.js +13 -0
- package/.storybook/css-dist.js +5 -0
- package/.storybook/emulsifyTheme.js +10 -7
- package/.storybook/main.js +417 -65
- package/.storybook/manager.js +11 -18
- package/.storybook/preview.js +93 -37
- package/.storybook/utils.js +70 -69
- package/README.md +110 -59
- package/config/.stylelintrc.json +2 -6
- package/config/a11y.config.js +9 -5
- package/config/babel.config.js +5 -0
- package/config/eslint.config.js +6 -3
- package/config/postcss.config.js +5 -0
- package/config/vite/entries.js +227 -0
- package/config/vite/environment.js +39 -0
- package/config/vite/platforms.js +70 -0
- package/config/vite/plugins/copy-src-assets.js +76 -0
- package/config/vite/plugins/copy-twig-files.js +84 -0
- package/config/vite/plugins/css-asset-relativizer.js +40 -0
- package/config/vite/plugins/index.js +105 -0
- package/config/vite/plugins/mirror-components.js +358 -0
- package/config/vite/plugins/require-context.js +311 -0
- package/config/vite/plugins/source-file-index.js +184 -0
- package/config/vite/plugins/svg-sprite.js +117 -0
- package/config/vite/plugins/twig-extension-installers.js +36 -0
- package/config/vite/plugins/twig-module.js +1251 -0
- package/config/vite/plugins/virtual-twig-asset-sources.js +404 -0
- package/config/vite/plugins/virtual-twig-globs.js +136 -0
- package/config/vite/plugins/vituum-patch.js +167 -0
- package/config/vite/plugins/yaml-module.js +133 -0
- package/config/vite/plugins.js +12 -0
- package/config/vite/project-config.js +192 -0
- package/config/vite/project-extensions.js +177 -0
- package/config/vite/project-structure.js +447 -0
- package/config/vite/twig-extensions.js +109 -0
- package/config/vite/utils/fs-safe.js +66 -0
- package/config/vite/utils/paths.js +40 -0
- package/config/vite/utils/react-singleton.js +85 -0
- package/config/vite/utils/unique.js +36 -0
- package/config/vite/vite.config.js +161 -0
- package/package.json +168 -88
- package/scripts/a11y.js +70 -16
- package/scripts/audit-twig-stories.js +378 -0
- package/scripts/audit.js +1602 -0
- package/scripts/check-node-version.js +18 -0
- package/scripts/loadYaml.js +5 -1
- package/src/extensions/index.js +8 -0
- package/src/extensions/react/index.js +12 -0
- package/src/extensions/react/register.js +45 -0
- package/src/extensions/shared/attributes.js +308 -0
- package/src/extensions/shared/html.js +41 -0
- package/src/extensions/shared/lists.js +38 -0
- package/src/extensions/shared/object.js +22 -0
- package/src/extensions/twig/function-map.js +20 -0
- package/src/extensions/twig/functions/add-attributes.js +39 -0
- package/src/extensions/twig/functions/bem.js +166 -0
- package/src/extensions/twig/index.js +13 -0
- package/src/extensions/twig/register.js +52 -0
- package/src/extensions/twig/tag-map.js +16 -0
- package/src/extensions/twig/tags/switch.js +266 -0
- package/src/storybook/index.js +14 -0
- package/src/storybook/main-config.js +132 -0
- package/src/storybook/platform-behaviors.js +60 -0
- package/src/storybook/preview-parameters.js +81 -0
- package/src/storybook/render-twig.js +295 -0
- package/src/storybook/twig/drupal-filters.js +7 -0
- package/src/storybook/twig/include-function.js +109 -0
- package/src/storybook/twig/include.js +28 -0
- package/src/storybook/twig/reference-paths.js +294 -0
- package/src/storybook/twig/resolver.js +318 -0
- package/src/storybook/twig/setup.js +39 -0
- package/src/storybook/twig/source-events.js +5 -0
- package/src/storybook/twig/source-extensions.js +24 -0
- package/src/storybook/twig/source-function.js +239 -0
- package/src/storybook/twig/source.js +39 -0
- package/.all-contributorsrc +0 -45
- package/.editorconfig +0 -5
- package/.github/ISSUE_TEMPLATE/BUG_REPORT_TEMPLATE.md +0 -18
- package/.github/ISSUE_TEMPLATE/FEATURE_REQUEST_TEMPLATE.md +0 -11
- package/.github/PULL_REQUEST_TEMPLATE.md +0 -19
- package/.github/dependabot.yml +0 -6
- package/.github/workflows/addtoprojects.yml +0 -21
- package/.github/workflows/contributors.yml +0 -37
- package/.github/workflows/lint.yml +0 -22
- package/.github/workflows/semantic-release.yml +0 -24
- package/.husky/commit-msg +0 -2
- package/.husky/pre-commit +0 -2
- package/.nvmrc +0 -1
- package/.prettierignore +0 -4
- package/.storybook/polyfills/twig-include.js +0 -36
- package/.storybook/polyfills/twig-resolver.js +0 -68
- package/.storybook/polyfills/twig-source.js +0 -54
- package/.storybook/webpack.config.js +0 -193
- package/CODE_OF_CONDUCT.md +0 -56
- package/commitlint.config.js +0 -5
- package/config/jest.config.js +0 -19
- package/config/webpack/app.js +0 -1
- package/config/webpack/loaders.js +0 -167
- package/config/webpack/optimizers.js +0 -17
- package/config/webpack/plugins.js +0 -283
- package/config/webpack/resolves.js +0 -157
- package/config/webpack/sdc-loader.js +0 -16
- package/config/webpack/webpack.common.js +0 -268
- package/config/webpack/webpack.dev.js +0 -41
- package/config/webpack/webpack.prod.js +0 -6
- package/release.config.cjs +0 -30
- package/scripts/a11y.test.js +0 -172
- package/scripts/loadYaml.test.js +0 -30
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Drupal component mirror plugin.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors built `dist/components/**` files back to project-root `components/**`
|
|
5
|
+
* for Drupal SDC projects that author canonical components under `src/`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
copyFileSync,
|
|
10
|
+
closeSync,
|
|
11
|
+
lstatSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
openSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
readSync,
|
|
16
|
+
readdirSync,
|
|
17
|
+
renameSync,
|
|
18
|
+
rmdirSync,
|
|
19
|
+
statSync,
|
|
20
|
+
unlinkSync,
|
|
21
|
+
writeFileSync,
|
|
22
|
+
} from 'fs';
|
|
23
|
+
import { basename, dirname, join, resolve } from 'path';
|
|
24
|
+
|
|
25
|
+
import { safeExists, safeReadJson } from '../utils/fs-safe.js';
|
|
26
|
+
import { walkFiles } from './source-file-index.js';
|
|
27
|
+
|
|
28
|
+
const MIRROR_STATE_FILE = '.emulsify-mirror-state.json';
|
|
29
|
+
const FILE_COMPARE_CHUNK_SIZE = 64 * 1024;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the installed Core package version without relying on import.meta so
|
|
33
|
+
* Jest's CommonJS transform can load this Vite plugin module.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} projectDir - Project directory running the build.
|
|
36
|
+
* @returns {string} Emulsify Core package version.
|
|
37
|
+
*/
|
|
38
|
+
const resolvePackageVersion = (projectDir) => {
|
|
39
|
+
const candidates = [
|
|
40
|
+
join(projectDir, 'node_modules/@emulsify/core/package.json'),
|
|
41
|
+
join(process.cwd(), 'node_modules/@emulsify/core/package.json'),
|
|
42
|
+
join(process.cwd(), 'package.json'),
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
for (const candidate of candidates) {
|
|
46
|
+
const candidatePackage = safeReadJson(candidate).data;
|
|
47
|
+
if (
|
|
48
|
+
candidatePackage?.name === '@emulsify/core' &&
|
|
49
|
+
candidatePackage.version
|
|
50
|
+
) {
|
|
51
|
+
return candidatePackage.version;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return '0.0.0';
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Remove empty parent directories from a start directory up to, but not including,
|
|
60
|
+
* a stopping boundary directory.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} startDir - Directory to prune from.
|
|
63
|
+
* @param {string} stopAtDir - Boundary directory.
|
|
64
|
+
*/
|
|
65
|
+
const pruneEmptyDirsUpTo = (startDir, stopAtDir) => {
|
|
66
|
+
const stopAbs = resolve(stopAtDir);
|
|
67
|
+
let cursor = resolve(startDir);
|
|
68
|
+
|
|
69
|
+
const isEmpty = (dir) => {
|
|
70
|
+
try {
|
|
71
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
72
|
+
return readdirSync(dir).length === 0;
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
while (cursor.startsWith(stopAbs)) {
|
|
79
|
+
if (!isEmpty(cursor)) break;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
83
|
+
rmdirSync(cursor);
|
|
84
|
+
} catch {
|
|
85
|
+
// Stop at the first directory that cannot be removed.
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const parent = dirname(cursor);
|
|
90
|
+
if (parent === cursor || parent === stopAbs) break;
|
|
91
|
+
cursor = parent;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Determine whether two files already contain the same bytes.
|
|
97
|
+
* Small files are read directly; larger files are compared in fixed-size chunks
|
|
98
|
+
* so the mirror phase does not transiently allocate both complete file bodies.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} sourceFile - Source file path.
|
|
101
|
+
* @param {string} destinationFile - Destination file path.
|
|
102
|
+
* @returns {boolean} TRUE when both files have identical bytes.
|
|
103
|
+
*/
|
|
104
|
+
export const filesHaveSameBytes = (sourceFile, destinationFile) => {
|
|
105
|
+
try {
|
|
106
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
107
|
+
const sourceStats = statSync(sourceFile);
|
|
108
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
109
|
+
const destinationStats = statSync(destinationFile);
|
|
110
|
+
if (!destinationStats.isFile()) return false;
|
|
111
|
+
if (sourceStats.size !== destinationStats.size) return false;
|
|
112
|
+
if (sourceStats.size === 0) return true;
|
|
113
|
+
|
|
114
|
+
if (sourceStats.size < FILE_COMPARE_CHUNK_SIZE) {
|
|
115
|
+
return readFileSync(sourceFile).equals(readFileSync(destinationFile));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const sourceBuffer = Buffer.allocUnsafe(FILE_COMPARE_CHUNK_SIZE);
|
|
119
|
+
const destinationBuffer = Buffer.allocUnsafe(FILE_COMPARE_CHUNK_SIZE);
|
|
120
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
121
|
+
const sourceHandle = openSync(sourceFile, 'r');
|
|
122
|
+
try {
|
|
123
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
124
|
+
const destinationHandle = openSync(destinationFile, 'r');
|
|
125
|
+
try {
|
|
126
|
+
let position = 0;
|
|
127
|
+
while (position < sourceStats.size) {
|
|
128
|
+
const bytesToRead = Math.min(
|
|
129
|
+
FILE_COMPARE_CHUNK_SIZE,
|
|
130
|
+
sourceStats.size - position,
|
|
131
|
+
);
|
|
132
|
+
const sourceBytesRead = readSync(
|
|
133
|
+
sourceHandle,
|
|
134
|
+
sourceBuffer,
|
|
135
|
+
0,
|
|
136
|
+
bytesToRead,
|
|
137
|
+
position,
|
|
138
|
+
);
|
|
139
|
+
const destinationBytesRead = readSync(
|
|
140
|
+
destinationHandle,
|
|
141
|
+
destinationBuffer,
|
|
142
|
+
0,
|
|
143
|
+
bytesToRead,
|
|
144
|
+
position,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
if (sourceBytesRead !== destinationBytesRead) return false;
|
|
148
|
+
if (sourceBytesRead === 0) return false;
|
|
149
|
+
if (
|
|
150
|
+
!sourceBuffer
|
|
151
|
+
.subarray(0, sourceBytesRead)
|
|
152
|
+
.equals(destinationBuffer.subarray(0, destinationBytesRead))
|
|
153
|
+
) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
position += sourceBytesRead;
|
|
157
|
+
}
|
|
158
|
+
return true;
|
|
159
|
+
} finally {
|
|
160
|
+
closeSync(destinationHandle);
|
|
161
|
+
}
|
|
162
|
+
} finally {
|
|
163
|
+
closeSync(sourceHandle);
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Determine whether a filesystem path is a symbolic link.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} filePath - File path to inspect.
|
|
174
|
+
* @returns {boolean} TRUE when the path exists and is a symlink.
|
|
175
|
+
*/
|
|
176
|
+
const isSymlink = (filePath) => {
|
|
177
|
+
try {
|
|
178
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
179
|
+
return lstatSync(filePath).isSymbolicLink();
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Remove a source file, ignoring races where it was already removed.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} sourceFile - Source file path.
|
|
189
|
+
*/
|
|
190
|
+
const removeSourceFile = (sourceFile) => {
|
|
191
|
+
try {
|
|
192
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
193
|
+
unlinkSync(sourceFile);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Create a temporary path beside the final destination so rename is atomic.
|
|
201
|
+
*
|
|
202
|
+
* @param {string} destinationFile - Destination file path.
|
|
203
|
+
* @returns {string} Adjacent temporary path.
|
|
204
|
+
*/
|
|
205
|
+
const createTempDestination = (destinationFile) =>
|
|
206
|
+
join(
|
|
207
|
+
dirname(destinationFile),
|
|
208
|
+
`.${basename(destinationFile)}.${process.pid}.${Date.now()}.${Math.random()
|
|
209
|
+
.toString(36)
|
|
210
|
+
.slice(2)}.tmp`,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Copy across filesystems or symlink boundaries, then rename into place.
|
|
215
|
+
*
|
|
216
|
+
* @param {string} sourceFile - Source file path.
|
|
217
|
+
* @param {string} destinationFile - Destination file path.
|
|
218
|
+
*/
|
|
219
|
+
const copyFileIntoPlace = (sourceFile, destinationFile) => {
|
|
220
|
+
const tempDestination = createTempDestination(destinationFile);
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
copyFileSync(sourceFile, tempDestination);
|
|
224
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
225
|
+
renameSync(tempDestination, destinationFile);
|
|
226
|
+
removeSourceFile(sourceFile);
|
|
227
|
+
} catch (error) {
|
|
228
|
+
try {
|
|
229
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
230
|
+
unlinkSync(tempDestination);
|
|
231
|
+
} catch {
|
|
232
|
+
/* noop */
|
|
233
|
+
}
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Move a mirrored file into place without exposing copy-then-unlink state.
|
|
240
|
+
*
|
|
241
|
+
* @param {string} sourceFile - Built file under dist.
|
|
242
|
+
* @param {string} destinationFile - Mirrored project-root destination.
|
|
243
|
+
*/
|
|
244
|
+
const moveFileIntoPlace = (sourceFile, destinationFile) => {
|
|
245
|
+
mkdirSync(dirname(destinationFile), { recursive: true });
|
|
246
|
+
|
|
247
|
+
if (filesHaveSameBytes(sourceFile, destinationFile)) {
|
|
248
|
+
removeSourceFile(sourceFile);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (isSymlink(sourceFile) || isSymlink(destinationFile)) {
|
|
253
|
+
copyFileIntoPlace(sourceFile, destinationFile);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
259
|
+
renameSync(sourceFile, destinationFile);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (error?.code !== 'EXDEV') throw error;
|
|
262
|
+
copyFileIntoPlace(sourceFile, destinationFile);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Safely read the previous mirror state marker.
|
|
268
|
+
*
|
|
269
|
+
* @param {string} markerFile - Marker file path.
|
|
270
|
+
* @returns {object|undefined} Parsed marker state.
|
|
271
|
+
*/
|
|
272
|
+
const readMirrorState = (markerFile) => {
|
|
273
|
+
const result = safeReadJson(markerFile);
|
|
274
|
+
return result.data;
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Write a mirror state marker.
|
|
279
|
+
*
|
|
280
|
+
* @param {string} markerFile - Marker file path.
|
|
281
|
+
* @param {{ startedAt: string, completedAt: string|null, version: string }} state - Marker state.
|
|
282
|
+
*/
|
|
283
|
+
const writeMirrorState = (markerFile, state) => {
|
|
284
|
+
mkdirSync(dirname(markerFile), { recursive: true });
|
|
285
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
286
|
+
writeFileSync(markerFile, `${JSON.stringify(state, null, 2)}\n`);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Warn if the previous mirror pass did not complete.
|
|
291
|
+
*
|
|
292
|
+
* @param {string} markerFile - Marker file path.
|
|
293
|
+
*/
|
|
294
|
+
const warnOnInterruptedMirror = (markerFile) => {
|
|
295
|
+
const previousState = readMirrorState(markerFile);
|
|
296
|
+
if (previousState?.completedAt !== null) return;
|
|
297
|
+
|
|
298
|
+
console.warn(
|
|
299
|
+
`Previous Emulsify component mirror build was interrupted before completion; stale mirrored files may exist. Marker: ${markerFile}`,
|
|
300
|
+
);
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Mirror built component files to the project root `./components/` directory.
|
|
305
|
+
*
|
|
306
|
+
* @param {{ enabled: boolean, projectDir: string }} opts - Plugin options.
|
|
307
|
+
* @returns {import('vite').PluginOption} Drupal mirror plugin.
|
|
308
|
+
*/
|
|
309
|
+
export function mirrorComponentsToRoot({ enabled, projectDir }) {
|
|
310
|
+
let outDir = 'dist';
|
|
311
|
+
return {
|
|
312
|
+
name: 'emulsify-mirror-components-to-root',
|
|
313
|
+
apply: 'build',
|
|
314
|
+
enforce: 'post',
|
|
315
|
+
configResolved(cfg) {
|
|
316
|
+
outDir = cfg.build?.outDir || 'dist';
|
|
317
|
+
},
|
|
318
|
+
writeBundle() {
|
|
319
|
+
if (!enabled) return;
|
|
320
|
+
const markerFile = join(outDir, MIRROR_STATE_FILE);
|
|
321
|
+
warnOnInterruptedMirror(markerFile);
|
|
322
|
+
|
|
323
|
+
const startedAt = new Date().toISOString();
|
|
324
|
+
const mirrorState = {
|
|
325
|
+
startedAt,
|
|
326
|
+
completedAt: null,
|
|
327
|
+
version: resolvePackageVersion(projectDir),
|
|
328
|
+
};
|
|
329
|
+
writeMirrorState(markerFile, mirrorState);
|
|
330
|
+
|
|
331
|
+
// Vite has written files by writeBundle, while closeBundle can overlap
|
|
332
|
+
// with the next watch cycle observing a partially mirrored dist tree.
|
|
333
|
+
const distComponents = join(outDir, 'components');
|
|
334
|
+
if (safeExists(distComponents)) {
|
|
335
|
+
for (const srcFile of walkFiles(distComponents)) {
|
|
336
|
+
const relFromOutDir = srcFile.slice(join(outDir, '').length);
|
|
337
|
+
const destFile = join(projectDir, relFromOutDir);
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
moveFileIntoPlace(srcFile, destFile);
|
|
341
|
+
pruneEmptyDirsUpTo(dirname(srcFile), distComponents);
|
|
342
|
+
} catch (e) {
|
|
343
|
+
console.warn(
|
|
344
|
+
`Mirror copy failed for ${relFromOutDir}: ${e?.message || e}`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
pruneEmptyDirsUpTo(distComponents, outDir);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
writeMirrorState(markerFile, {
|
|
353
|
+
...mirrorState,
|
|
354
|
+
completedAt: new Date().toISOString(),
|
|
355
|
+
});
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Webpack require.context compatibility for Vite.
|
|
3
|
+
*
|
|
4
|
+
* Some existing Emulsify stories still use Webpack's static
|
|
5
|
+
* `require.context()` helper to enumerate asset names. Vite does not define
|
|
6
|
+
* `require` in browser modules, so this plugin rewrites static calls to
|
|
7
|
+
* equivalent eager `import.meta.glob()` maps before import analysis runs.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readdirSync } from 'fs';
|
|
11
|
+
import { dirname, relative, resolve } from 'path';
|
|
12
|
+
import { toPosixPath } from '../utils/paths.js';
|
|
13
|
+
|
|
14
|
+
const REQUIRE_CONTEXT_PATTERN =
|
|
15
|
+
/require\.context\(\s*(['"`])([^'"`]+)\1\s*,\s*(true|false)\s*,\s*\/((?:\\.|[^/\\])+)\/([dgimsuvy]*)\s*,?\s*\)/g;
|
|
16
|
+
const STATIC_ASSET_CONTEXT_EXTS = new Set([
|
|
17
|
+
'avif',
|
|
18
|
+
'eot',
|
|
19
|
+
'gif',
|
|
20
|
+
'ico',
|
|
21
|
+
'jpeg',
|
|
22
|
+
'jpg',
|
|
23
|
+
'otf',
|
|
24
|
+
'pdf',
|
|
25
|
+
'png',
|
|
26
|
+
'svg',
|
|
27
|
+
'ttf',
|
|
28
|
+
'webp',
|
|
29
|
+
'woff',
|
|
30
|
+
'woff2',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const REQUIRE_CONTEXT_HELPER = `
|
|
34
|
+
const __emulsifyRequireContext = (modules, basePath, matcher) => {
|
|
35
|
+
const normalizeKey = (key) => key.startsWith('./') ? key : \`./\${key}\`;
|
|
36
|
+
const moduleKey = (key) => basePath + normalizeKey(key).slice(2);
|
|
37
|
+
const context = (key) => modules[moduleKey(key)];
|
|
38
|
+
|
|
39
|
+
context.keys = () =>
|
|
40
|
+
Object.keys(modules)
|
|
41
|
+
.map((key) => \`./\${key.slice(basePath.length)}\`)
|
|
42
|
+
.filter((key) => matcher.test(key))
|
|
43
|
+
.sort();
|
|
44
|
+
context.resolve = (key) => moduleKey(key);
|
|
45
|
+
context.id = basePath;
|
|
46
|
+
|
|
47
|
+
return context;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const __emulsifyRequireContextFromKeys = (keys, basePath, publicBasePath) => {
|
|
51
|
+
const normalizedKeys = keys.slice().sort();
|
|
52
|
+
const keySet = new Set(normalizedKeys);
|
|
53
|
+
const normalizeKey = (key) => key.startsWith('./') ? key : \`./\${key}\`;
|
|
54
|
+
const moduleKey = (key) => basePath + normalizeKey(key).slice(2);
|
|
55
|
+
const publicKey = (key) =>
|
|
56
|
+
publicBasePath ? publicBasePath + normalizeKey(key).slice(2) : moduleKey(key);
|
|
57
|
+
const context = (key) => keySet.has(normalizeKey(key)) ? publicKey(key) : undefined;
|
|
58
|
+
|
|
59
|
+
context.keys = () => normalizedKeys.slice();
|
|
60
|
+
context.resolve = (key) => moduleKey(key);
|
|
61
|
+
context.id = basePath;
|
|
62
|
+
|
|
63
|
+
return context;
|
|
64
|
+
};
|
|
65
|
+
`.trim();
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Determine whether a request is JavaScript-like source Vite should transform.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} id - Vite module id.
|
|
71
|
+
* @returns {boolean} TRUE when the id is transformable JavaScript source.
|
|
72
|
+
*/
|
|
73
|
+
const isJavaScriptRequest = (id) =>
|
|
74
|
+
/\.[cm]?[jt]sx?(?:\?|$)/.test(id) && !id.includes('/node_modules/');
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Normalize a require.context directory argument for Vite glob keys.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} request - Static directory request from require.context.
|
|
80
|
+
* @returns {string} Directory request with a trailing slash.
|
|
81
|
+
*/
|
|
82
|
+
const normalizeBasePath = (request) => {
|
|
83
|
+
if (request.endsWith('/')) return request;
|
|
84
|
+
if (request === '.') return './';
|
|
85
|
+
return `${request}/`;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build a focused glob tail for the common extension-only regex shape.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} regexSource - Source from a JavaScript regex literal.
|
|
92
|
+
* @returns {string} Glob tail.
|
|
93
|
+
*/
|
|
94
|
+
const globTailFromRegex = (regexSource) => {
|
|
95
|
+
const singleExtension = regexSource.match(/^\\\.([A-Za-z0-9]+)\$$/);
|
|
96
|
+
if (singleExtension) {
|
|
97
|
+
return `*.${singleExtension[1]}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const extensionGroup = regexSource.match(/^\\\.\(([-A-Za-z0-9_|]+)\)\$$/);
|
|
101
|
+
if (extensionGroup) {
|
|
102
|
+
return `*.{${extensionGroup[1].replaceAll('|', ',')}}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return '*';
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Extract extension names from regexes that can be represented by a file glob.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} regexSource - Source from a JavaScript regex literal.
|
|
112
|
+
* @returns {string[]} Lowercase extension names.
|
|
113
|
+
*/
|
|
114
|
+
const extensionsFromRegex = (regexSource) => {
|
|
115
|
+
const singleExtension = regexSource.match(/^\\\.([A-Za-z0-9]+)\$$/);
|
|
116
|
+
if (singleExtension) {
|
|
117
|
+
return [singleExtension[1].toLowerCase()];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const extensionGroup = regexSource.match(/^\\\.\(([-A-Za-z0-9_|]+)\)\$$/);
|
|
121
|
+
if (extensionGroup) {
|
|
122
|
+
return extensionGroup[1]
|
|
123
|
+
.split('|')
|
|
124
|
+
.filter(Boolean)
|
|
125
|
+
.map((extension) => extension.toLowerCase());
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return [];
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Remove stateful regex flags so repeated matcher.test() calls stay stable.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} flags - Flags from a JavaScript regex literal.
|
|
135
|
+
* @returns {string} Non-stateful regex flags.
|
|
136
|
+
*/
|
|
137
|
+
const normalizeRegexFlags = (flags) => flags.replace(/[gy]/g, '');
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Resolve a static require.context directory against the importing module.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} id - Vite module id.
|
|
143
|
+
* @param {string} request - Static directory request from require.context.
|
|
144
|
+
* @returns {string} Absolute filesystem directory path.
|
|
145
|
+
*/
|
|
146
|
+
const resolveContextDirectory = (id, request) => {
|
|
147
|
+
const importer = String(id || '').split('?')[0];
|
|
148
|
+
if (!importer || importer.includes('/node_modules/')) return '';
|
|
149
|
+
|
|
150
|
+
return resolve(dirname(importer), request);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Find files in a context directory and return Webpack-style context keys.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} directory - Absolute context directory.
|
|
157
|
+
* @param {boolean} recursive - Whether nested directories are included.
|
|
158
|
+
* @param {RegExp} matcher - Context file matcher.
|
|
159
|
+
* @returns {string[]} Sorted `./file.ext` keys.
|
|
160
|
+
*/
|
|
161
|
+
function contextKeysFromDirectory(directory, recursive, matcher) {
|
|
162
|
+
const keys = [];
|
|
163
|
+
|
|
164
|
+
const visit = (currentDirectory) => {
|
|
165
|
+
for (const entry of readdirSync(currentDirectory, {
|
|
166
|
+
withFileTypes: true,
|
|
167
|
+
})) {
|
|
168
|
+
const absolutePath = resolve(currentDirectory, entry.name);
|
|
169
|
+
if (entry.isDirectory()) {
|
|
170
|
+
if (recursive) visit(absolutePath);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (!entry.isFile()) continue;
|
|
174
|
+
|
|
175
|
+
const key = `./${toPosixPath(relative(directory, absolutePath))}`;
|
|
176
|
+
if (matcher.test(key)) {
|
|
177
|
+
keys.push(key);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
visit(directory);
|
|
183
|
+
return keys.sort();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Resolve a public URL base for root project asset directories.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} directory - Absolute context directory.
|
|
190
|
+
* @returns {string} Public URL base, or an empty string when unknown.
|
|
191
|
+
*/
|
|
192
|
+
const publicBasePathForDirectory = (directory) => {
|
|
193
|
+
const relativeDirectory = toPosixPath(relative(process.cwd(), directory));
|
|
194
|
+
|
|
195
|
+
return relativeDirectory === 'assets' ||
|
|
196
|
+
relativeDirectory.startsWith('assets/')
|
|
197
|
+
? `/${relativeDirectory.replace(/\/?$/, '/')}`
|
|
198
|
+
: '';
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Build a key-only context for static assets to avoid module-importing files
|
|
203
|
+
* that Storybook may also serve through staticDirs.
|
|
204
|
+
*
|
|
205
|
+
* @param {{ id?: string, request: string, recursive: boolean, regexSource: string, regexFlags: string, basePath: string }} options
|
|
206
|
+
* Context details from the require.context call.
|
|
207
|
+
* @returns {string|null} Replacement source, or null when the directory cannot be enumerated.
|
|
208
|
+
*/
|
|
209
|
+
function staticAssetContextReplacement({
|
|
210
|
+
id,
|
|
211
|
+
request,
|
|
212
|
+
recursive,
|
|
213
|
+
regexSource,
|
|
214
|
+
regexFlags,
|
|
215
|
+
basePath,
|
|
216
|
+
}) {
|
|
217
|
+
const extensions = extensionsFromRegex(regexSource);
|
|
218
|
+
if (
|
|
219
|
+
!extensions.length ||
|
|
220
|
+
!extensions.every((extension) => STATIC_ASSET_CONTEXT_EXTS.has(extension))
|
|
221
|
+
) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const directory = resolveContextDirectory(id, request);
|
|
226
|
+
if (!directory || !existsSync(directory)) return null;
|
|
227
|
+
|
|
228
|
+
const matcher = new RegExp(regexSource, normalizeRegexFlags(regexFlags));
|
|
229
|
+
const keys = contextKeysFromDirectory(directory, recursive, matcher);
|
|
230
|
+
const publicBasePath = publicBasePathForDirectory(directory);
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
'__emulsifyRequireContextFromKeys(' +
|
|
234
|
+
`${JSON.stringify(keys)}, ` +
|
|
235
|
+
`${JSON.stringify(basePath)}, ` +
|
|
236
|
+
`${JSON.stringify(publicBasePath)}` +
|
|
237
|
+
')'
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Transform static require.context calls into Vite import.meta.glob calls.
|
|
243
|
+
*
|
|
244
|
+
* @param {string} source - JavaScript source.
|
|
245
|
+
* @param {string} [id=''] - Vite module id.
|
|
246
|
+
* @returns {string|null} Transformed source, or null when unchanged.
|
|
247
|
+
*/
|
|
248
|
+
export function transformRequireContext(source, id = '') {
|
|
249
|
+
const replacements = [];
|
|
250
|
+
let match;
|
|
251
|
+
|
|
252
|
+
REQUIRE_CONTEXT_PATTERN.lastIndex = 0;
|
|
253
|
+
while ((match = REQUIRE_CONTEXT_PATTERN.exec(source))) {
|
|
254
|
+
const [, , request, recursive, regexSource, regexFlags] = match;
|
|
255
|
+
const basePath = normalizeBasePath(request);
|
|
256
|
+
const globTail = globTailFromRegex(regexSource);
|
|
257
|
+
const globPattern = `${basePath}${recursive === 'true' ? '**/' : ''}${globTail}`;
|
|
258
|
+
const matcherFlags = normalizeRegexFlags(regexFlags);
|
|
259
|
+
const staticAssetReplacement = staticAssetContextReplacement({
|
|
260
|
+
basePath,
|
|
261
|
+
id,
|
|
262
|
+
recursive: recursive === 'true',
|
|
263
|
+
regexFlags,
|
|
264
|
+
regexSource,
|
|
265
|
+
request,
|
|
266
|
+
});
|
|
267
|
+
const replacement =
|
|
268
|
+
staticAssetReplacement ||
|
|
269
|
+
'__emulsifyRequireContext(' +
|
|
270
|
+
`import.meta.glob(${JSON.stringify(globPattern)}, { eager: true, import: 'default' }), ` +
|
|
271
|
+
`${JSON.stringify(basePath)}, ` +
|
|
272
|
+
`new RegExp(${JSON.stringify(regexSource)}, ${JSON.stringify(matcherFlags)})` +
|
|
273
|
+
')';
|
|
274
|
+
|
|
275
|
+
replacements.push({
|
|
276
|
+
end: REQUIRE_CONTEXT_PATTERN.lastIndex,
|
|
277
|
+
replacement,
|
|
278
|
+
start: match.index,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!replacements.length) return null;
|
|
283
|
+
|
|
284
|
+
let transformed = source;
|
|
285
|
+
for (const { start, end, replacement } of replacements.reverse()) {
|
|
286
|
+
transformed =
|
|
287
|
+
transformed.slice(0, start) + replacement + transformed.slice(end);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return `${REQUIRE_CONTEXT_HELPER}\n\n${transformed}`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Rewrite static Webpack require.context calls for Vite-powered stories.
|
|
295
|
+
*
|
|
296
|
+
* @returns {import('vite').PluginOption} Vite plugin.
|
|
297
|
+
*/
|
|
298
|
+
export function requireContextCompatPlugin() {
|
|
299
|
+
return {
|
|
300
|
+
name: 'emulsify-require-context-compat',
|
|
301
|
+
enforce: 'pre',
|
|
302
|
+
transform(source, id) {
|
|
303
|
+
if (!isJavaScriptRequest(id) || !source.includes('require.context')) {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const code = transformRequireContext(source, id);
|
|
308
|
+
return code ? { code, map: null } : null;
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
}
|