@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,404 @@
1
+ /**
2
+ * @file Virtual Twig text asset source module for Storybook source().
3
+ *
4
+ * Text asset globs are lazy so Storybook does not keep every raw asset string
5
+ * resident unless a Twig template calls `source('@assets/...')`.
6
+ */
7
+
8
+ import { readdirSync } from 'fs';
9
+ import { relative, resolve } from 'path';
10
+ import { safeExists } from '../utils/fs-safe.js';
11
+ import { toPosixPath } from '../utils/paths.js';
12
+ import { unique } from '../utils/unique.js';
13
+ import { INLINE_ASSET_EXTS } from '../../../src/storybook/twig/source-extensions.js';
14
+
15
+ export const VIRTUAL_TWIG_ASSET_SOURCES_ID =
16
+ 'virtual:emulsify-twig-asset-sources';
17
+ const RESOLVED_VIRTUAL_TWIG_ASSET_SOURCES_ID = `\0${VIRTUAL_TWIG_ASSET_SOURCES_ID}`;
18
+ const GENERATED_ASSET_ALIASES = new Set(['icons.svg']);
19
+ const PUBLIC_ASSET_ROOTS = new Map([
20
+ ['/assets', '/assets'],
21
+ ['/dist/assets', '/assets'],
22
+ ]);
23
+
24
+ /**
25
+ * Convert an absolute project path to a Vite root-relative glob base.
26
+ *
27
+ * @param {string} projectDir - Absolute project root.
28
+ * @param {string} absolutePath - Absolute asset root path.
29
+ * @returns {string} Vite root-relative path.
30
+ */
31
+ function toRootRelativePath(projectDir, absolutePath) {
32
+ if (!absolutePath) return '';
33
+
34
+ const normalizedProjectDir = toPosixPath(projectDir || '').replace(
35
+ /\/+$/,
36
+ '',
37
+ );
38
+ const normalizedPath = toPosixPath(absolutePath).replace(/\/+$/, '');
39
+
40
+ if (
41
+ normalizedProjectDir &&
42
+ normalizedPath.startsWith(`${normalizedProjectDir}/`)
43
+ ) {
44
+ return `/${normalizedPath.slice(normalizedProjectDir.length + 1)}`.replace(
45
+ /\/{2,}/g,
46
+ '/',
47
+ );
48
+ }
49
+
50
+ return `/${normalizedPath.replace(/^\/+/, '')}`.replace(/\/{2,}/g, '/');
51
+ }
52
+
53
+ /**
54
+ * Resolve a configured asset root to an absolute filesystem path.
55
+ *
56
+ * @param {string} projectDir - Absolute project root.
57
+ * @param {string} assetRoot - Absolute, project-relative, or Vite root-relative asset root.
58
+ * @returns {string} Absolute filesystem path.
59
+ */
60
+ function toAbsoluteAssetRoot(projectDir, assetRoot) {
61
+ const normalizedProjectDir = toPosixPath(projectDir || '').replace(
62
+ /\/+$/,
63
+ '',
64
+ );
65
+ const normalizedRoot = toPosixPath(assetRoot || '').replace(/\/+$/, '');
66
+
67
+ if (!normalizedRoot) return '';
68
+ if (
69
+ normalizedProjectDir &&
70
+ (normalizedRoot === normalizedProjectDir ||
71
+ normalizedRoot.startsWith(`${normalizedProjectDir}/`))
72
+ ) {
73
+ return normalizedRoot;
74
+ }
75
+ if (normalizedRoot.startsWith('/') && normalizedProjectDir) {
76
+ if (safeExists(normalizedRoot)) {
77
+ return normalizedRoot;
78
+ }
79
+ return `${normalizedProjectDir}${normalizedRoot}`;
80
+ }
81
+
82
+ return toPosixPath(resolve(projectDir || '.', normalizedRoot));
83
+ }
84
+
85
+ /**
86
+ * Resolve existing project asset roots for Storybook source() text imports.
87
+ *
88
+ * @param {{ projectDir?: string, projectStructure?: { assetRoots?: string[] } }} env - Emulsify environment.
89
+ * @returns {string[]} Existing Vite root-relative asset root paths.
90
+ */
91
+ export function assetSourceRoots(env) {
92
+ const configuredRoots =
93
+ Array.isArray(env?.projectStructure?.assetRoots) &&
94
+ env.projectStructure.assetRoots.length
95
+ ? env.projectStructure.assetRoots
96
+ : [];
97
+ const fallbackRoots = ['/assets', '/src/assets'];
98
+
99
+ return unique(
100
+ [...configuredRoots, ...fallbackRoots]
101
+ .map((root) => toAbsoluteAssetRoot(env?.projectDir, root))
102
+ .filter((root) => root && safeExists(root))
103
+ .map((root) => toRootRelativePath(env?.projectDir, root))
104
+ .filter(Boolean),
105
+ );
106
+ }
107
+
108
+ /**
109
+ * Resolve generated asset roots for Storybook source() text imports.
110
+ *
111
+ * Generated aliases such as `@assets/icons.svg` resolve through these roots
112
+ * before checking project-authored root assets.
113
+ *
114
+ * @param {{ projectDir?: string }} env - Emulsify environment.
115
+ * @returns {string[]} Existing Vite root-relative generated asset roots.
116
+ */
117
+ export function generatedAssetSourceRoots(env) {
118
+ return unique(
119
+ ['/dist/assets']
120
+ .map((root) => toAbsoluteAssetRoot(env?.projectDir, root))
121
+ .filter((root) => root && safeExists(root))
122
+ .map((root) => toRootRelativePath(env?.projectDir, root))
123
+ .filter(Boolean),
124
+ );
125
+ }
126
+
127
+ /**
128
+ * Build Vite glob patterns from text asset roots.
129
+ *
130
+ * @param {{ projectDir?: string, projectStructure?: { assetRoots?: string[] } }} env - Emulsify environment.
131
+ * @returns {string[]} Root-relative text asset glob patterns.
132
+ */
133
+ export function assetSourceGlobPatterns(env) {
134
+ const extensions = Array.from(INLINE_ASSET_EXTS).join(',');
135
+
136
+ return [...assetSourceRoots(env), ...generatedAssetSourceRoots(env)].map(
137
+ (root) => `${root === '/' ? '' : root}/**/*.{${extensions}}`,
138
+ );
139
+ }
140
+
141
+ /**
142
+ * Return a public URL base for asset roots served by Storybook staticDirs.
143
+ *
144
+ * @param {string} root - Vite root-relative asset source root.
145
+ * @returns {string} Public URL base, or an empty string for non-public roots.
146
+ */
147
+ function publicAssetBaseForRoot(root) {
148
+ const normalizedRoot = `/${String(root || '').replace(/^\/+/, '')}`.replace(
149
+ /\/+$/,
150
+ '',
151
+ );
152
+ const publicBase = PUBLIC_ASSET_ROOTS.get(normalizedRoot);
153
+
154
+ return publicBase ? `${publicBase.replace(/\/+$/, '')}/` : '';
155
+ }
156
+
157
+ /**
158
+ * Collect inlineable text assets from a filesystem root.
159
+ *
160
+ * @param {string} absoluteRoot - Absolute filesystem asset root.
161
+ * @returns {string[]} Root-relative file paths.
162
+ */
163
+ function collectInlineAssetFiles(absoluteRoot) {
164
+ const files = [];
165
+
166
+ const visit = (currentDirectory) => {
167
+ for (const entry of readdirSync(currentDirectory, {
168
+ withFileTypes: true,
169
+ })) {
170
+ const absolutePath = resolve(currentDirectory, entry.name);
171
+ if (entry.isDirectory()) {
172
+ visit(absolutePath);
173
+ continue;
174
+ }
175
+ if (!entry.isFile()) continue;
176
+
177
+ const extension = entry.name.split('.').pop().toLowerCase();
178
+ if (INLINE_ASSET_EXTS.has(extension)) {
179
+ files.push(toPosixPath(relative(absoluteRoot, absolutePath)));
180
+ }
181
+ }
182
+ };
183
+
184
+ visit(absoluteRoot);
185
+ return files.sort();
186
+ }
187
+
188
+ /**
189
+ * Build fetch-backed entries for public assets that can shadow Vite imports.
190
+ *
191
+ * @param {{ projectDir?: string, projectStructure?: { assetRoots?: string[] } }} env - Emulsify environment.
192
+ * @returns {Array<{ key: string, url: string }>} Source map entries.
193
+ */
194
+ export function publicAssetSourceEntries(env) {
195
+ const entries = new Map();
196
+ const roots = unique([
197
+ ...assetSourceRoots(env),
198
+ ...generatedAssetSourceRoots(env),
199
+ ]);
200
+
201
+ for (const root of roots) {
202
+ const publicBase = publicAssetBaseForRoot(root);
203
+ if (!publicBase) continue;
204
+
205
+ const absoluteRoot = toAbsoluteAssetRoot(env?.projectDir, root);
206
+ if (!absoluteRoot || !safeExists(absoluteRoot)) continue;
207
+
208
+ for (const relativeFile of collectInlineAssetFiles(absoluteRoot)) {
209
+ const normalizedFile = relativeFile.replace(/^\/+/, '');
210
+ const key = `${root.replace(/\/+$/, '')}/${normalizedFile}`.replace(
211
+ /\/{2,}/g,
212
+ '/',
213
+ );
214
+ const url = `${publicBase}${normalizedFile}`.replace(/\/{2,}/g, '/');
215
+ entries.set(key, { key, url });
216
+ }
217
+ }
218
+
219
+ return Array.from(entries.values());
220
+ }
221
+
222
+ /**
223
+ * Generate the virtual module source for lazy text asset maps.
224
+ *
225
+ * @param {{ projectDir?: string, projectStructure?: { assetRoots?: string[] } }} env - Emulsify environment.
226
+ * @returns {string} JavaScript module source.
227
+ */
228
+ export function generateVirtualTwigAssetSourcesModule(env) {
229
+ const rootPrefixes = assetSourceRoots(env).map((root) =>
230
+ `${root === '/' ? '' : root}/`.replace(/\/{2,}/g, '/'),
231
+ );
232
+ const generatedRootPrefixes = generatedAssetSourceRoots(env).map((root) =>
233
+ `${root === '/' ? '' : root}/`.replace(/\/{2,}/g, '/'),
234
+ );
235
+ const patterns = assetSourceGlobPatterns(env);
236
+ const globEntries = patterns.length
237
+ ? patterns
238
+ .map(
239
+ (pattern) =>
240
+ ` import.meta.glob(${JSON.stringify(pattern)}, { eager: false, query: '?raw', import: 'default' })`,
241
+ )
242
+ .join(',\n')
243
+ : '';
244
+ const fetchEntries = publicAssetSourceEntries(env)
245
+ .map(
246
+ ({ key, url }) =>
247
+ ` ${JSON.stringify(key)}: fetchAssetText(${JSON.stringify(url)})`,
248
+ )
249
+ .join(',\n');
250
+
251
+ return `/**
252
+ * Virtual module generated by config/vite/plugins/virtual-twig-asset-sources.js.
253
+ *
254
+ * Raw text assets stay lazy and load only when Twig source() requests them.
255
+ */
256
+
257
+ export const assetRootPrefixes = ${JSON.stringify(rootPrefixes)};
258
+ export const generatedAssetRootPrefixes = ${JSON.stringify(generatedRootPrefixes)};
259
+ export const generatedAssetAliases = ${JSON.stringify(
260
+ Array.from(GENERATED_ASSET_ALIASES),
261
+ )};
262
+
263
+ const fetchAssetText = (url) => () =>
264
+ fetch(url).then((response) => {
265
+ if (!response.ok) {
266
+ throw new Error(\`\${response.status} while fetching \${url}\`);
267
+ }
268
+ return response.text();
269
+ });
270
+
271
+ export const assets = Object.assign({}, ...[
272
+ ${globEntries}
273
+ ${fetchEntries ? `${globEntries ? ',\n' : ''}{\n${fetchEntries}\n}` : ''}
274
+ ]);
275
+
276
+ const sourceTextCache = new Map();
277
+ const sourceLoadPromises = new Map();
278
+
279
+ const unique = (values) => Array.from(new Set(values.filter(Boolean)));
280
+
281
+ const normalizeAssetPath = (assetPath) =>
282
+ String(assetPath || '')
283
+ .replace(/^@assets\\//, '')
284
+ .replace(/^\\/?assets\\//, '')
285
+ .replace(/^\\/+/, '');
286
+
287
+ const candidateKeysForAssetPath = (assetPath) => {
288
+ const rawPath = String(assetPath || '');
289
+ const normalized = normalizeAssetPath(rawPath);
290
+ const directPath = rawPath.startsWith('/') ? rawPath : \`/\${rawPath}\`;
291
+ const generatedCandidates = generatedAssetAliases.includes(normalized)
292
+ ? generatedAssetRootPrefixes.map((root) =>
293
+ \`\${root.replace(/\\/+$/, '')}/\${normalized}\`,
294
+ )
295
+ : [];
296
+
297
+ return unique([
298
+ rawPath,
299
+ directPath,
300
+ normalized ? \`/\${normalized}\` : '',
301
+ ...generatedCandidates,
302
+ ...assetRootPrefixes.map((root) =>
303
+ \`\${root.replace(/\\/+$/, '')}/\${normalized}\`,
304
+ ),
305
+ ]);
306
+ };
307
+
308
+ const findAssetKey = (assetPath) =>
309
+ candidateKeysForAssetPath(assetPath).find((key) =>
310
+ Object.hasOwnProperty.call(assets, key),
311
+ );
312
+
313
+ const normalizeSourceText = (value) => {
314
+ const source = value?.default ?? value;
315
+ return typeof source === 'string' ? source : undefined;
316
+ };
317
+
318
+ export const coversAssetPath = (assetPath) =>
319
+ (assetRootPrefixes.length > 0 || generatedAssetRootPrefixes.length > 0) &&
320
+ normalizeAssetPath(assetPath).length > 0;
321
+
322
+ export const hasAssetText = (assetPath) => Boolean(findAssetKey(assetPath));
323
+
324
+ export const isAssetTextLoading = (assetPath) => {
325
+ const key = findAssetKey(assetPath);
326
+ return Boolean(key && sourceLoadPromises.has(key));
327
+ };
328
+
329
+ export const whenAssetTextLoaded = (assetPath) => {
330
+ const key = findAssetKey(assetPath);
331
+ return key ? sourceLoadPromises.get(key) : undefined;
332
+ };
333
+
334
+ export const getAssetText = (assetPath) => {
335
+ const key = findAssetKey(assetPath);
336
+ if (!key) return undefined;
337
+ if (sourceTextCache.has(key)) {
338
+ return sourceTextCache.get(key);
339
+ }
340
+
341
+ const loader = assets[key];
342
+ const sourceText = normalizeSourceText(loader);
343
+ if (typeof sourceText === 'string') {
344
+ sourceTextCache.set(key, sourceText);
345
+ return sourceText;
346
+ }
347
+
348
+ if (typeof loader === 'function' && !sourceLoadPromises.has(key)) {
349
+ let loadedSource;
350
+ try {
351
+ loadedSource = loader();
352
+ } catch (error) {
353
+ loadedSource = Promise.reject(error);
354
+ }
355
+
356
+ const sourceLoad = Promise.resolve(loadedSource)
357
+ .then((loaded) => {
358
+ const loadedText = normalizeSourceText(loaded);
359
+ if (typeof loadedText === 'string') {
360
+ sourceTextCache.set(key, loadedText);
361
+ }
362
+ return loadedText;
363
+ })
364
+ .catch((error) => {
365
+ console.error(\`source(): failed to load asset \${key}\`, error);
366
+ return undefined;
367
+ })
368
+ .finally(() => {
369
+ sourceLoadPromises.delete(key);
370
+ });
371
+
372
+ sourceLoadPromises.set(key, sourceLoad);
373
+ }
374
+
375
+ return undefined;
376
+ };
377
+ `;
378
+ }
379
+
380
+ /**
381
+ * Provide `virtual:emulsify-twig-asset-sources` for Storybook source().
382
+ *
383
+ * @param {{ projectDir?: string, projectStructure?: { assetRoots?: string[] } }} env - Emulsify environment.
384
+ * @returns {import('vite').PluginOption} Virtual module plugin.
385
+ */
386
+ export function virtualTwigAssetSourcesPlugin(env) {
387
+ return {
388
+ name: 'emulsify-virtual-twig-asset-sources',
389
+ resolveId(id) {
390
+ if (id === VIRTUAL_TWIG_ASSET_SOURCES_ID) {
391
+ return RESOLVED_VIRTUAL_TWIG_ASSET_SOURCES_ID;
392
+ }
393
+
394
+ return null;
395
+ },
396
+ load(id) {
397
+ if (id === RESOLVED_VIRTUAL_TWIG_ASSET_SOURCES_ID) {
398
+ return generateVirtualTwigAssetSourcesModule(env);
399
+ }
400
+
401
+ return null;
402
+ },
403
+ };
404
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * @file Virtual Twig glob map module for Storybook runtime resolution.
3
+ *
4
+ * The plugin exposes project-specific Twig template imports through a stable
5
+ * virtual module instead of replacing placeholder strings in resolver source.
6
+ */
7
+
8
+ import { toPosixPath } from '../utils/paths.js';
9
+ import { unique } from '../utils/unique.js';
10
+
11
+ export const VIRTUAL_TWIG_GLOBS_ID = 'virtual:emulsify-twig-globs';
12
+ const RESOLVED_VIRTUAL_TWIG_GLOBS_ID = `\0${VIRTUAL_TWIG_GLOBS_ID}`;
13
+
14
+ /**
15
+ * @typedef {object} TwigGlobModule
16
+ * @property {Record<string, unknown>} modules - Compiled Twig module map.
17
+ * @property {Record<string, Function>} sources - Lazy raw Twig source loaders.
18
+ */
19
+
20
+ /**
21
+ * Convert an absolute project path to a Vite root-relative glob base.
22
+ *
23
+ * @param {string} projectDir - Absolute project root.
24
+ * @param {string} absolutePath - Absolute Twig root path.
25
+ * @returns {string} Vite root-relative path.
26
+ */
27
+ function toRootRelativePath(projectDir, absolutePath) {
28
+ if (!absolutePath) return '';
29
+
30
+ const normalizedProjectDir = toPosixPath(projectDir || '').replace(
31
+ /\/+$/,
32
+ '',
33
+ );
34
+ const normalizedPath = toPosixPath(absolutePath).replace(/\/+$/, '');
35
+
36
+ if (
37
+ normalizedProjectDir &&
38
+ normalizedPath.startsWith(`${normalizedProjectDir}/`)
39
+ ) {
40
+ return `/${normalizedPath.slice(normalizedProjectDir.length + 1)}`.replace(
41
+ /\/{2,}/g,
42
+ '/',
43
+ );
44
+ }
45
+
46
+ return `/${normalizedPath.replace(/^\/+/, '')}`.replace(/\/{2,}/g, '/');
47
+ }
48
+
49
+ /**
50
+ * Build Vite glob patterns from a resolved Emulsify environment.
51
+ *
52
+ * @param {{ projectDir?: string, projectStructure?: { twigRoots?: string[] } }} env - Emulsify environment.
53
+ * @returns {string[]} Root-relative Twig glob patterns.
54
+ */
55
+ export function twigGlobPatterns(env) {
56
+ const roots = Array.isArray(env?.projectStructure?.twigRoots)
57
+ ? env.projectStructure.twigRoots
58
+ : [];
59
+
60
+ return unique(
61
+ roots
62
+ .map((root) => toRootRelativePath(env?.projectDir, root))
63
+ .filter(Boolean)
64
+ .map((root) => `${root === '/' ? '' : root}/**/*.twig`),
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Generate the virtual module source for Twig glob maps.
70
+ *
71
+ * @param {{ projectDir?: string, projectStructure?: { twigRoots?: string[] } }} env - Emulsify environment.
72
+ * @returns {string} JavaScript module source.
73
+ */
74
+ export function generateVirtualTwigGlobsModule(env) {
75
+ const patterns = twigGlobPatterns(env);
76
+ const globEntries = patterns.length
77
+ ? patterns
78
+ .map(
79
+ (pattern) => ` {
80
+ modules: import.meta.glob(${JSON.stringify(pattern)}, { eager: true }),
81
+ sources: import.meta.glob(${JSON.stringify(pattern)}, { query: '?raw', import: 'default' }),
82
+ }`,
83
+ )
84
+ .join(',\n')
85
+ : '';
86
+
87
+ return `/**
88
+ * Virtual module generated by config/vite/plugins/virtual-twig-globs.js.
89
+ *
90
+ * Compiled Twig modules stay eager for synchronous story rendering. Raw source
91
+ * entries stay lazy and load only when Twig source() requests a template.
92
+ */
93
+
94
+ const mergeGlobMaps = (groups) =>
95
+ groups.reduce(
96
+ (merged, group) => ({
97
+ modules: { ...merged.modules, ...group.modules },
98
+ sources: { ...merged.sources, ...group.sources },
99
+ }),
100
+ { modules: {}, sources: {} },
101
+ );
102
+
103
+ const globMaps = mergeGlobMaps([
104
+ ${globEntries}
105
+ ]);
106
+
107
+ export const modules = globMaps.modules;
108
+ export const sources = globMaps.sources;
109
+ `;
110
+ }
111
+
112
+ /**
113
+ * Provide `virtual:emulsify-twig-globs` for Storybook Twig runtime helpers.
114
+ *
115
+ * @param {{ projectDir?: string, projectStructure?: { twigRoots?: string[] } }} env - Emulsify environment.
116
+ * @returns {import('vite').PluginOption} Virtual module plugin.
117
+ */
118
+ export function virtualTwigGlobsPlugin(env) {
119
+ return {
120
+ name: 'emulsify-virtual-twig-globs',
121
+ resolveId(id) {
122
+ if (id === VIRTUAL_TWIG_GLOBS_ID) {
123
+ return RESOLVED_VIRTUAL_TWIG_GLOBS_ID;
124
+ }
125
+
126
+ return null;
127
+ },
128
+ load(id) {
129
+ if (id === RESOLVED_VIRTUAL_TWIG_GLOBS_ID) {
130
+ return generateVirtualTwigGlobsModule(env);
131
+ }
132
+
133
+ return null;
134
+ },
135
+ };
136
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * @file Vituum Twig plugin adapter for Emulsify Vite builds.
3
+ *
4
+ * Emulsify keeps Vituum's Twig rendering, middleware, and reload behavior while
5
+ * removing incompatible page-entry rename hooks from Vite build output.
6
+ */
7
+
8
+ import twig from '@vituum/vite-plugin-twig';
9
+ import Twig from 'twig';
10
+
11
+ import { registerTwigExtensions } from '../../../src/extensions/twig/index.js';
12
+ import { makeTwigPluginOptions } from './twig-module.js';
13
+ import { registerConfiguredTwigExtensions } from '../twig-extensions.js';
14
+
15
+ const EXPECTED_PLUGIN_NAMES = [
16
+ '@vituum/vite-plugin-core:bundle',
17
+ '@vituum/vite-plugin-twig',
18
+ ];
19
+ const EXPECTED_HOOKS_TO_STRIP = ['buildStart', 'buildEnd'];
20
+ const VITUUM_TWIG_PLUGIN_NAME = '@vituum/vite-plugin-twig';
21
+ const SHAPE_CHANGE_GUIDANCE =
22
+ 'Pin @vituum/vite-plugin-twig to a known-good version or update vituum-patch.js.';
23
+
24
+ /**
25
+ * Inspect Vituum plugin output before patching so shape changes fail loudly.
26
+ *
27
+ * @param {import('vite').PluginOption[]} plugins - Normalized Vituum plugins.
28
+ * @returns {{
29
+ * detectedPluginNames: string[],
30
+ * foundPluginNames: Set<string>,
31
+ * twigHooksPresent: Set<string>
32
+ * }} Detected plugin shape details.
33
+ */
34
+ function inspectVituumPluginShape(plugins) {
35
+ const detectedPluginNames = [];
36
+ const foundPluginNames = new Set();
37
+ const twigHooksPresent = new Set();
38
+
39
+ for (const pluginOption of plugins) {
40
+ const pluginName = pluginOption?.name;
41
+ detectedPluginNames.push(pluginName || '<unnamed>');
42
+
43
+ if (EXPECTED_PLUGIN_NAMES.includes(pluginName)) {
44
+ foundPluginNames.add(pluginName);
45
+ }
46
+
47
+ if (pluginName === VITUUM_TWIG_PLUGIN_NAME) {
48
+ for (const hookName of EXPECTED_HOOKS_TO_STRIP) {
49
+ if (hookName in pluginOption) {
50
+ twigHooksPresent.add(hookName);
51
+ }
52
+ }
53
+ }
54
+ }
55
+
56
+ return { detectedPluginNames, foundPluginNames, twigHooksPresent };
57
+ }
58
+
59
+ /**
60
+ * Format plugin names for shape-change diagnostics.
61
+ *
62
+ * @param {string[]} pluginNames - Plugin names to report.
63
+ * @returns {string} JSON-formatted plugin list.
64
+ */
65
+ function formatPluginNames(pluginNames) {
66
+ return JSON.stringify(pluginNames);
67
+ }
68
+
69
+ /**
70
+ * Assert Vituum exposed the plugin names and hooks Emulsify patches.
71
+ *
72
+ * @param {ReturnType<typeof inspectVituumPluginShape>} shape - Detected shape.
73
+ */
74
+ function assertExpectedVituumShape(shape) {
75
+ const missingPluginNames = EXPECTED_PLUGIN_NAMES.filter(
76
+ (pluginName) => !shape.foundPluginNames.has(pluginName),
77
+ );
78
+
79
+ if (missingPluginNames.length) {
80
+ throw new Error(
81
+ [
82
+ `Vituum plugin shape changed: expected ${missingPluginNames
83
+ .map((pluginName) => `'${pluginName}'`)
84
+ .join(', ')} not found.`,
85
+ `Detected plugins: ${formatPluginNames(shape.detectedPluginNames)}.`,
86
+ SHAPE_CHANGE_GUIDANCE,
87
+ ].join(' '),
88
+ );
89
+ }
90
+
91
+ if (!shape.twigHooksPresent.size) {
92
+ throw new Error(
93
+ [
94
+ `Vituum plugin shape changed: '${VITUUM_TWIG_PLUGIN_NAME}' did not expose any targeted hooks to strip.`,
95
+ `Expected at least one of: ${formatPluginNames(EXPECTED_HOOKS_TO_STRIP)}.`,
96
+ `Detected plugins: ${formatPluginNames(shape.detectedPluginNames)}.`,
97
+ SHAPE_CHANGE_GUIDANCE,
98
+ ].join(' '),
99
+ );
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Assert stripped Vituum hooks are absent after patching.
105
+ *
106
+ * @param {import('vite').PluginOption} pluginOption - Patched plugin.
107
+ */
108
+ function assertHooksStripped(pluginOption) {
109
+ const remainingHooks = EXPECTED_HOOKS_TO_STRIP.filter(
110
+ (hookName) => hookName in pluginOption,
111
+ );
112
+
113
+ if (remainingHooks.length) {
114
+ throw new Error(
115
+ [
116
+ `Vituum plugin patch failed: '${VITUUM_TWIG_PLUGIN_NAME}' still exposes targeted hooks after stripping.`,
117
+ `Remaining hooks: ${formatPluginNames(remainingHooks)}.`,
118
+ SHAPE_CHANGE_GUIDANCE,
119
+ ].join(' '),
120
+ );
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Strip the Vituum hooks that conflict with Emulsify build output.
126
+ *
127
+ * @param {import('vite').PluginOption} pluginOption - Cloned Twig plugin.
128
+ */
129
+ function stripExpectedHooks(pluginOption) {
130
+ delete pluginOption.buildStart;
131
+ delete pluginOption.buildEnd;
132
+ }
133
+
134
+ /**
135
+ * Instantiate Vituum's Twig renderer without its entry-renaming build hooks.
136
+ *
137
+ * @param {Parameters<typeof makeTwigPluginOptions>[0]} env - Project environment.
138
+ * @param {ReturnType<typeof makeTwigPluginOptions>} [options] - Twig plugin options.
139
+ * @returns {import('vite').PluginOption[]} Vituum Twig plugin options.
140
+ */
141
+ export function makeTwigPlugins(env, options = makeTwigPluginOptions(env)) {
142
+ registerTwigExtensions(Twig);
143
+ registerConfiguredTwigExtensions(Twig, options);
144
+
145
+ const twigPlugins = twig(options);
146
+ const normalizedPlugins = Array.isArray(twigPlugins)
147
+ ? twigPlugins
148
+ : [twigPlugins];
149
+ const shape = inspectVituumPluginShape(normalizedPlugins);
150
+ assertExpectedVituumShape(shape);
151
+
152
+ return normalizedPlugins
153
+ .filter(
154
+ (pluginOption) =>
155
+ pluginOption?.name !== '@vituum/vite-plugin-core:bundle',
156
+ )
157
+ .map((pluginOption) => {
158
+ if (pluginOption?.name !== '@vituum/vite-plugin-twig') {
159
+ return pluginOption;
160
+ }
161
+
162
+ const renderPlugin = { ...pluginOption };
163
+ stripExpectedHooks(renderPlugin);
164
+ assertHooksStripped(renderPlugin);
165
+ return renderPlugin;
166
+ });
167
+ }