@emulsify/core 3.5.0 → 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.
Files changed (110) hide show
  1. package/.cli/init.js +40 -31
  2. package/.storybook/_drupal.js +129 -8
  3. package/.storybook/css-components.js +13 -0
  4. package/.storybook/css-dist.js +5 -0
  5. package/.storybook/emulsifyTheme.js +9 -6
  6. package/.storybook/main.js +397 -106
  7. package/.storybook/manager.js +9 -16
  8. package/.storybook/preview.js +88 -110
  9. package/.storybook/utils.js +69 -74
  10. package/README.md +110 -59
  11. package/config/.stylelintrc.json +2 -6
  12. package/config/a11y.config.js +9 -5
  13. package/config/babel.config.js +6 -11
  14. package/config/eslint.config.js +31 -3
  15. package/config/postcss.config.js +5 -0
  16. package/config/vite/entries.js +227 -0
  17. package/config/vite/environment.js +39 -0
  18. package/config/vite/platforms.js +70 -0
  19. package/config/vite/plugins/copy-src-assets.js +76 -0
  20. package/config/vite/plugins/copy-twig-files.js +84 -0
  21. package/config/vite/plugins/css-asset-relativizer.js +40 -0
  22. package/config/vite/plugins/index.js +105 -0
  23. package/config/vite/plugins/mirror-components.js +358 -0
  24. package/config/vite/plugins/require-context.js +311 -0
  25. package/config/vite/plugins/source-file-index.js +184 -0
  26. package/config/vite/plugins/svg-sprite.js +117 -0
  27. package/config/vite/plugins/twig-extension-installers.js +36 -0
  28. package/config/vite/plugins/twig-module.js +1251 -0
  29. package/config/vite/plugins/virtual-twig-asset-sources.js +404 -0
  30. package/config/vite/plugins/virtual-twig-globs.js +136 -0
  31. package/config/vite/plugins/vituum-patch.js +167 -0
  32. package/config/vite/plugins/yaml-module.js +133 -0
  33. package/config/vite/plugins.js +12 -0
  34. package/config/vite/project-config.js +192 -0
  35. package/config/vite/project-extensions.js +177 -0
  36. package/config/vite/project-structure.js +447 -0
  37. package/config/vite/twig-extensions.js +109 -0
  38. package/config/vite/utils/fs-safe.js +66 -0
  39. package/config/vite/utils/paths.js +40 -0
  40. package/config/vite/utils/react-singleton.js +85 -0
  41. package/config/vite/utils/unique.js +36 -0
  42. package/config/vite/vite.config.js +161 -0
  43. package/package.json +164 -75
  44. package/scripts/a11y.js +70 -16
  45. package/scripts/audit-twig-stories.js +378 -0
  46. package/scripts/audit.js +1602 -0
  47. package/scripts/check-node-version.js +18 -0
  48. package/scripts/loadYaml.js +5 -1
  49. package/src/extensions/index.js +8 -0
  50. package/src/extensions/react/index.js +12 -0
  51. package/src/extensions/react/register.js +45 -0
  52. package/src/extensions/shared/attributes.js +308 -0
  53. package/src/extensions/shared/html.js +41 -0
  54. package/src/extensions/shared/lists.js +38 -0
  55. package/src/extensions/shared/object.js +22 -0
  56. package/src/extensions/twig/function-map.js +20 -0
  57. package/src/extensions/twig/functions/add-attributes.js +39 -0
  58. package/src/extensions/twig/functions/bem.js +166 -0
  59. package/src/extensions/twig/index.js +13 -0
  60. package/src/extensions/twig/register.js +52 -0
  61. package/src/extensions/twig/tag-map.js +16 -0
  62. package/src/extensions/twig/tags/switch.js +266 -0
  63. package/src/storybook/index.js +14 -0
  64. package/src/storybook/main-config.js +132 -0
  65. package/src/storybook/platform-behaviors.js +60 -0
  66. package/src/storybook/preview-parameters.js +81 -0
  67. package/src/storybook/render-twig.js +295 -0
  68. package/src/storybook/twig/drupal-filters.js +7 -0
  69. package/src/storybook/twig/include-function.js +109 -0
  70. package/src/storybook/twig/include.js +28 -0
  71. package/src/storybook/twig/reference-paths.js +294 -0
  72. package/src/storybook/twig/resolver.js +318 -0
  73. package/src/storybook/twig/setup.js +39 -0
  74. package/src/storybook/twig/source-events.js +5 -0
  75. package/src/storybook/twig/source-extensions.js +24 -0
  76. package/src/storybook/twig/source-function.js +239 -0
  77. package/src/storybook/twig/source.js +39 -0
  78. package/.all-contributorsrc +0 -45
  79. package/.editorconfig +0 -5
  80. package/.github/ISSUE_TEMPLATE/BUG_REPORT_TEMPLATE.md +0 -18
  81. package/.github/ISSUE_TEMPLATE/FEATURE_REQUEST_TEMPLATE.md +0 -11
  82. package/.github/PULL_REQUEST_TEMPLATE.md +0 -19
  83. package/.github/dependabot.yml +0 -6
  84. package/.github/workflows/addtoprojects.yml +0 -21
  85. package/.github/workflows/contributors.yml +0 -37
  86. package/.github/workflows/lint.yml +0 -22
  87. package/.github/workflows/semantic-release.yml +0 -24
  88. package/.husky/commit-msg +0 -2
  89. package/.husky/pre-commit +0 -2
  90. package/.nvmrc +0 -1
  91. package/.prettierignore +0 -4
  92. package/.storybook/polyfills/twig-include.js +0 -40
  93. package/.storybook/polyfills/twig-resolver.js +0 -70
  94. package/.storybook/polyfills/twig-source.js +0 -65
  95. package/.storybook/webpack.config.js +0 -269
  96. package/CODE_OF_CONDUCT.md +0 -56
  97. package/commitlint.config.js +0 -5
  98. package/config/jest.config.js +0 -19
  99. package/config/webpack/app.js +0 -1
  100. package/config/webpack/loaders.js +0 -167
  101. package/config/webpack/optimizers.js +0 -26
  102. package/config/webpack/plugins.js +0 -283
  103. package/config/webpack/resolves.js +0 -157
  104. package/config/webpack/sdc-loader.js +0 -16
  105. package/config/webpack/webpack.common.js +0 -272
  106. package/config/webpack/webpack.dev.js +0 -41
  107. package/config/webpack/webpack.prod.js +0 -6
  108. package/release.config.cjs +0 -30
  109. package/scripts/a11y.test.js +0 -172
  110. package/scripts/loadYaml.test.js +0 -30
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @file Vite plugin composition for Emulsify.
3
+ *
4
+ * Assembles the shared plugin chain used by Vite and Storybook while delegating
5
+ * each individual plugin concern to focused internal modules.
6
+ */
7
+
8
+ import sassGlobImports from 'vite-plugin-sass-glob-import';
9
+
10
+ import { getPlatformAdapter } from '../platforms.js';
11
+ import { resolveProjectStructure } from '../project-structure.js';
12
+ import { toPosixPath } from '../utils/paths.js';
13
+ import { copyAllSrcAssetsPlugin } from './copy-src-assets.js';
14
+ import { copyTwigFilesPlugin } from './copy-twig-files.js';
15
+ import { cssAssetUrlRelativizer } from './css-asset-relativizer.js';
16
+ import { mirrorComponentsToRoot } from './mirror-components.js';
17
+ import { requireContextCompatPlugin } from './require-context.js';
18
+ import { createSourceFileIndex } from './source-file-index.js';
19
+ import { svgSpriteFilePlugin } from './svg-sprite.js';
20
+ import { virtualTwigExtensionInstallersPlugin } from './twig-extension-installers.js';
21
+ import {
22
+ emulsifyTwigModulePlugin,
23
+ makeTwigPluginOptions,
24
+ } from './twig-module.js';
25
+ import { virtualTwigAssetSourcesPlugin } from './virtual-twig-asset-sources.js';
26
+ import { virtualTwigGlobsPlugin } from './virtual-twig-globs.js';
27
+ import { makeTwigPlugins } from './vituum-patch.js';
28
+ import { yamlModulePlugin } from './yaml-module.js';
29
+
30
+ /**
31
+ * Create the Vite plugin array used by Emulsify builds.
32
+ *
33
+ * @param {{
34
+ * projectDir: string,
35
+ * platform: string,
36
+ * srcDir: string,
37
+ * srcExists: boolean,
38
+ * structureOverrides?: boolean
39
+ * }} env - Project environment.
40
+ * @returns {import('vite').PluginOption[]} Emulsify Vite plugins.
41
+ */
42
+ export function makePlugins(env) {
43
+ const { projectDir, platform } = env;
44
+ const platformAdapter = env.platformAdapter || getPlatformAdapter(platform);
45
+ const structure =
46
+ env.projectStructure ||
47
+ resolveProjectStructure({
48
+ ...env,
49
+ platformAdapter,
50
+ });
51
+ const envWithStructure = { ...env, projectStructure: structure };
52
+ const twigOptions = makeTwigPluginOptions(env);
53
+ const sourceFileIndex =
54
+ env.sourceFileIndex || createSourceFileIndex(structure);
55
+
56
+ const basePlugins = [
57
+ virtualTwigExtensionInstallersPlugin(envWithStructure),
58
+ virtualTwigGlobsPlugin(envWithStructure),
59
+ virtualTwigAssetSourcesPlugin(envWithStructure),
60
+
61
+ emulsifyTwigModulePlugin(twigOptions),
62
+
63
+ // Generic Twig rendering for dev/preview.
64
+ ...makeTwigPlugins(env, twigOptions),
65
+
66
+ // Emit a physical dist/assets/icons.svg sprite.
67
+ svgSpriteFilePlugin({
68
+ include: [
69
+ `${toPosixPath(projectDir)}/assets/icons/**/*.svg`,
70
+ 'assets/icons/**/*.svg',
71
+ 'src/assets/icons/**/*.svg',
72
+ 'src/**/icons/**/*.svg',
73
+ ],
74
+ symbolId: '[name]',
75
+ }),
76
+
77
+ // Sass glob imports preserve existing component stylesheet patterns.
78
+ sassGlobImports(),
79
+
80
+ // YAML support lets component metadata import into Vite modules.
81
+ yamlModulePlugin(),
82
+
83
+ // Legacy Storybook stories may still enumerate assets with require.context.
84
+ requireContextCompatPlugin(),
85
+
86
+ // Keep CSS asset URLs relative to the emitted CSS location.
87
+ cssAssetUrlRelativizer({ assetsRoot: 'assets' }),
88
+ ];
89
+
90
+ return [
91
+ ...basePlugins,
92
+
93
+ // Copy Twig templates and component metadata beside compiled assets.
94
+ copyTwigFilesPlugin({ structure, sourceFileIndex }),
95
+
96
+ // Copy every non-code asset under src with the same routing.
97
+ copyAllSrcAssetsPlugin({ structure, sourceFileIndex }),
98
+
99
+ // Drupal projects with src mirror dist/components back to ./components.
100
+ mirrorComponentsToRoot({
101
+ enabled: structure.mirrorComponentOutput,
102
+ projectDir,
103
+ }),
104
+ ];
105
+ }
@@ -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
+ }