@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,1251 @@
1
+ /**
2
+ * @file Twig module plugin and Twig namespace option helpers.
3
+ *
4
+ * The plugin turns Twig file imports into render functions for Storybook and
5
+ * Vite consumers while recursively compiling referenced Twig dependencies.
6
+ * It memoizes template compilation and include-path resolution for the active
7
+ * build so shared Twig trees do not repeatedly hit the filesystem. Twig option
8
+ * and namespace lookups are also memoized by environment object identity for
9
+ * one process.
10
+ */
11
+
12
+ import fs from 'fs';
13
+ import { basename, dirname, isAbsolute, relative, resolve } from 'path';
14
+ import Twig from 'twig';
15
+
16
+ import {
17
+ getTwigFunctionMap,
18
+ registerTwigExtensions,
19
+ } from '../../../src/extensions/twig/index.js';
20
+ import { toRootRelativePath } from '../../../src/storybook/twig/reference-paths.js';
21
+ import { resolveProjectStructure } from '../project-structure.js';
22
+ import {
23
+ registerConfiguredTwigExtensions,
24
+ shouldRegisterDrupalTwigFilters,
25
+ } from '../twig-extensions.js';
26
+ import { firstExistingPath, safeExists } from '../utils/fs-safe.js';
27
+ import { toPosixPath } from '../utils/paths.js';
28
+ import { unique } from '../utils/unique.js';
29
+
30
+ /** Twig token types that can reference another template file. */
31
+ const templateReferenceTokenTypes = [
32
+ 'Twig.logic.type.embed',
33
+ 'Twig.logic.type.extends',
34
+ 'Twig.logic.type.from',
35
+ 'Twig.logic.type.import',
36
+ 'Twig.logic.type.include',
37
+ ];
38
+
39
+ const includeFunctionName = 'include';
40
+ const sourceFunctionName = 'source';
41
+ const expressionTokenTypes = {
42
+ arrayEnd: 'Twig.expression.type.array.end',
43
+ arrayStart: 'Twig.expression.type.array.start',
44
+ comma: 'Twig.expression.type.comma',
45
+ function: 'Twig.expression.type._function',
46
+ objectEnd: 'Twig.expression.type.object.end',
47
+ objectStart: 'Twig.expression.type.object.start',
48
+ parameterEnd: 'Twig.expression.type.parameter.end',
49
+ parameterStart: 'Twig.expression.type.parameter.start',
50
+ string: 'Twig.expression.type.string',
51
+ };
52
+ const expressionOpeningTokenTypes = new Set([
53
+ expressionTokenTypes.arrayStart,
54
+ expressionTokenTypes.objectStart,
55
+ expressionTokenTypes.parameterStart,
56
+ ]);
57
+ const expressionClosingTokenTypes = new Set([
58
+ expressionTokenTypes.arrayEnd,
59
+ expressionTokenTypes.objectEnd,
60
+ expressionTokenTypes.parameterEnd,
61
+ ]);
62
+
63
+ /**
64
+ * Cache compiled Twig templates by absolute path for the life of one build.
65
+ *
66
+ * @type {Map<string, { mtimeMs: number, compiled: { code: string, includes: string[], sourceReferences: string[], templateId: string, templateParams: object } }>}
67
+ */
68
+ const compileCache = new Map();
69
+
70
+ /**
71
+ * Cache resolved Twig include paths by source directory, reference, and roots.
72
+ *
73
+ * @type {Map<string, string|null>}
74
+ */
75
+ const resolutionCache = new Map();
76
+
77
+ /**
78
+ * Track Twig files that have been seen during this build/session.
79
+ *
80
+ * Known files can keep include-resolution cache entries across ordinary content
81
+ * edits because their filesystem location has not changed. Unknown creates or
82
+ * unlinks still clear path resolution broadly to avoid stale miss entries.
83
+ *
84
+ * @type {Set<string>}
85
+ */
86
+ const knownTwigFiles = new Set();
87
+
88
+ /**
89
+ * Cache Twig namespace maps by environment object identity.
90
+ *
91
+ * @type {WeakMap<object, Record<string, string>>}
92
+ */
93
+ let twigNamespacesCache = new WeakMap();
94
+
95
+ /**
96
+ * Cache Twig plugin options by environment object identity.
97
+ *
98
+ * @type {WeakMap<object, import('@vituum/vite-plugin-twig/types').PluginUserConfig>}
99
+ */
100
+ let twigPluginOptionsCache = new WeakMap();
101
+
102
+ /**
103
+ * Determine whether a value can be used as a WeakMap key.
104
+ *
105
+ * @param {*} env - Candidate environment value.
106
+ * @returns {boolean} TRUE when env is a non-null object.
107
+ */
108
+ const canMemoizeByEnv = (env) => env && typeof env === 'object';
109
+
110
+ /**
111
+ * Determine whether a Vite request should compile as a Twig render module.
112
+ *
113
+ * @param {string} id - Vite module id, including an optional query string.
114
+ * @returns {boolean} TRUE when the request is a renderable Twig module.
115
+ */
116
+ const isTwigModuleRequest = (id) => {
117
+ const [filePath, query = ''] = id.split('?');
118
+ if (!filePath.endsWith('.twig')) return false;
119
+ return !query || query === 'twig' || !/(^|&)(raw|url)\b/.test(query);
120
+ };
121
+
122
+ /**
123
+ * Remove the Vite query string from a module id.
124
+ *
125
+ * @param {string} id - Vite module id.
126
+ * @returns {string} Filesystem path without query parameters.
127
+ */
128
+ const stripRequestQuery = (id) => id.split('?')[0];
129
+
130
+ /**
131
+ * Extract the first argument from a Twig function token parameter list.
132
+ *
133
+ * Twig.js represents function arguments as flat expression tokens. Tracking
134
+ * nested delimiters keeps commas inside array or object literals from ending
135
+ * the first argument too early.
136
+ *
137
+ * @param {Array} [params=[]] - Twig function token parameters.
138
+ * @returns {Array} Expression tokens for the first argument.
139
+ */
140
+ const firstFunctionArgumentTokens = (params = []) => {
141
+ const argumentTokens = [];
142
+ let functionStarted = false;
143
+ let nestedDepth = 0;
144
+
145
+ for (const token of params) {
146
+ if (!functionStarted) {
147
+ if (token.type === expressionTokenTypes.parameterStart) {
148
+ functionStarted = true;
149
+ }
150
+ continue;
151
+ }
152
+
153
+ if (token.type === expressionTokenTypes.comma && nestedDepth === 0) {
154
+ break;
155
+ }
156
+ if (token.type === expressionTokenTypes.parameterEnd && nestedDepth === 0) {
157
+ break;
158
+ }
159
+
160
+ argumentTokens.push(token);
161
+
162
+ if (expressionOpeningTokenTypes.has(token.type)) {
163
+ nestedDepth += 1;
164
+ } else if (expressionClosingTokenTypes.has(token.type)) {
165
+ nestedDepth = Math.max(0, nestedDepth - 1);
166
+ }
167
+ }
168
+
169
+ return argumentTokens;
170
+ };
171
+
172
+ /**
173
+ * Collect static template references from an include() function token.
174
+ *
175
+ * The first argument can be a literal template name or an array of literal
176
+ * candidates. Dynamic values are left alone and will resolve at runtime only
177
+ * if a resolver knows how to handle them.
178
+ *
179
+ * @param {Array} [params=[]] - Twig include() function token parameters.
180
+ * @returns {string[]} Static template references.
181
+ */
182
+ const collectIncludeFunctionArgumentReferences = (params = []) => {
183
+ const argumentTokens = firstFunctionArgumentTokens(params);
184
+ const firstToken = argumentTokens[0];
185
+ if (!firstToken) return [];
186
+
187
+ if (firstToken.type === expressionTokenTypes.string) {
188
+ return [firstToken.value].filter(Boolean);
189
+ }
190
+
191
+ if (firstToken.type !== expressionTokenTypes.arrayStart) {
192
+ return [];
193
+ }
194
+
195
+ return argumentTokens
196
+ .filter((token) => token.type === expressionTokenTypes.string)
197
+ .map((token) => token.value)
198
+ .filter(Boolean);
199
+ };
200
+
201
+ /**
202
+ * Collect static template references from a source() function token.
203
+ *
204
+ * Dynamic values are left for runtime asset resolution. Literal template
205
+ * references are embedded into generated Twig modules as raw source strings.
206
+ *
207
+ * @param {Array} [params=[]] - Twig source() function token parameters.
208
+ * @returns {string[]} Static template references.
209
+ */
210
+ const collectSourceFunctionArgumentReferences = (params = []) => {
211
+ const argumentTokens = firstFunctionArgumentTokens(params);
212
+ const firstToken = argumentTokens[0];
213
+
214
+ return firstToken?.type === expressionTokenTypes.string
215
+ ? [firstToken.value].filter(Boolean)
216
+ : [];
217
+ };
218
+
219
+ /**
220
+ * Extract static references from Twig expression function calls.
221
+ *
222
+ * @param {Array} [tokens=[]] - Twig expression token list.
223
+ * @param {string} functionName - Twig function name.
224
+ * @param {Function} collectArgumentReferences - Function argument collector.
225
+ * @returns {string[]} Static function argument references.
226
+ */
227
+ const collectFunctionReferences = (
228
+ tokens = [],
229
+ functionName,
230
+ collectArgumentReferences,
231
+ ) =>
232
+ tokens.flatMap((token) => [
233
+ ...(token.type === expressionTokenTypes.function &&
234
+ token.fn === functionName
235
+ ? collectArgumentReferences(token.params || [])
236
+ : []),
237
+ ...collectFunctionReferences(
238
+ token.params || [],
239
+ functionName,
240
+ collectArgumentReferences,
241
+ ),
242
+ ]);
243
+
244
+ /**
245
+ * Extract include() references from Twig expression tokens.
246
+ *
247
+ * @param {Array} [tokens=[]] - Twig expression token list.
248
+ * @returns {string[]} Static include() template references.
249
+ */
250
+ const collectIncludeFunctionReferences = (tokens = []) =>
251
+ collectFunctionReferences(
252
+ tokens,
253
+ includeFunctionName,
254
+ collectIncludeFunctionArgumentReferences,
255
+ );
256
+
257
+ /**
258
+ * Extract source() references from Twig expression tokens.
259
+ *
260
+ * @param {Array} [tokens=[]] - Twig expression token list.
261
+ * @returns {string[]} Static source() template references.
262
+ */
263
+ const collectSourceFunctionReferences = (tokens = []) =>
264
+ collectFunctionReferences(
265
+ tokens,
266
+ sourceFunctionName,
267
+ collectSourceFunctionArgumentReferences,
268
+ );
269
+
270
+ /**
271
+ * Extract referenced Twig templates from compiled Twig token trees.
272
+ *
273
+ * @param {Array} [tokens=[]] - Twig token tree.
274
+ * @returns {string[]} Referenced template paths.
275
+ */
276
+ const collectStaticTemplateReferences = (tokens = []) => [
277
+ ...tokens
278
+ .filter((token) => templateReferenceTokenTypes.includes(token.token?.type))
279
+ .flatMap((token) =>
280
+ (token.token?.stack || [])
281
+ .map((stack) => stack.value)
282
+ .filter((value) => typeof value === 'string'),
283
+ ),
284
+ ...tokens.flatMap((token) => collectIncludeFunctionReferences(token.stack)),
285
+ ...tokens.flatMap((token) =>
286
+ collectStaticTemplateReferences(token.token?.output || []),
287
+ ),
288
+ ];
289
+
290
+ /**
291
+ * Extract raw source template references from compiled Twig token trees.
292
+ *
293
+ * @param {Array} [tokens=[]] - Twig token tree.
294
+ * @returns {string[]} Referenced Twig source paths.
295
+ */
296
+ const collectStaticSourceReferences = (tokens = []) => [
297
+ ...tokens.flatMap((token) => collectSourceFunctionReferences(token.stack)),
298
+ ...tokens.flatMap((token) =>
299
+ collectStaticSourceReferences(token.token?.output || []),
300
+ ),
301
+ ];
302
+
303
+ /**
304
+ * Build likely filesystem candidates for a Twig template reference.
305
+ *
306
+ * @param {string} baseDir - Directory used as the resolution root.
307
+ * @param {string} templatePath - Template path from Twig source.
308
+ * @returns {string[]} Candidate absolute paths.
309
+ */
310
+ const buildTemplateFileCandidates = (baseDir, templatePath) => {
311
+ const normalizedTemplatePath = toPosixPath(templatePath);
312
+ const withoutTwigExt = normalizedTemplatePath.replace(/\.twig$/i, '');
313
+ const stem = basename(withoutTwigExt);
314
+
315
+ return unique(
316
+ [
317
+ resolve(baseDir, normalizedTemplatePath),
318
+ resolve(baseDir, `${normalizedTemplatePath}.twig`),
319
+ resolve(baseDir, `${normalizedTemplatePath}.html.twig`),
320
+ resolve(baseDir, withoutTwigExt, `${stem}.twig`),
321
+ resolve(baseDir, withoutTwigExt, `${stem}.html.twig`),
322
+ ].filter(Boolean),
323
+ );
324
+ };
325
+
326
+ /**
327
+ * Return the first candidate that exists as a file.
328
+ *
329
+ * @param {string[]} paths - Candidate absolute paths.
330
+ * @returns {string|undefined} Existing file path.
331
+ */
332
+ const findExistingTemplateFile = (paths) =>
333
+ paths.filter(Boolean).find((filePath) => {
334
+ try {
335
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
336
+ return fs.statSync(filePath).isFile();
337
+ } catch {
338
+ return false;
339
+ }
340
+ });
341
+
342
+ /**
343
+ * Determine whether a file path is equal to or below a candidate root.
344
+ *
345
+ * @param {string} root - Absolute root path.
346
+ * @param {string} filePath - Absolute file path.
347
+ * @returns {boolean} TRUE when the file belongs to the root.
348
+ */
349
+ const isWithinRoot = (root, filePath) => {
350
+ const rootRelativePath = relative(root, filePath);
351
+ return (
352
+ rootRelativePath === '' ||
353
+ (!!rootRelativePath &&
354
+ !rootRelativePath.startsWith('..') &&
355
+ !isAbsolute(rootRelativePath))
356
+ );
357
+ };
358
+
359
+ /**
360
+ * Find the most specific configured Twig root for a template file.
361
+ *
362
+ * @param {string} filePath - Absolute template file path.
363
+ * @param {ReturnType<typeof makeTwigPluginOptions>} options - Twig plugin options.
364
+ * @returns {string} Absolute Twig root path.
365
+ */
366
+ const templateRootForPath = (filePath, options) => {
367
+ const roots = unique(
368
+ [options.root, ...Object.values(options.namespaces || {})]
369
+ .filter(Boolean)
370
+ .map((root) => resolve(root)),
371
+ ).sort((left, right) => right.length - left.length);
372
+
373
+ return (
374
+ roots.find((root) => isWithinRoot(root, filePath)) ||
375
+ options.root ||
376
+ dirname(filePath)
377
+ );
378
+ };
379
+
380
+ /**
381
+ * Build a stable Twig template id from the configured root and relative path.
382
+ *
383
+ * @param {string} filePath - Absolute template file path.
384
+ * @param {ReturnType<typeof makeTwigPluginOptions>} options - Twig plugin options.
385
+ * @returns {string} Stable Twig template id.
386
+ */
387
+ const templateIdForPath = (filePath, options) => {
388
+ const templateRoot = templateRootForPath(filePath, options);
389
+ const rootRel = toRootRelativePath(templateRoot, options);
390
+ const relPath = toPosixPath(relative(templateRoot, filePath));
391
+
392
+ return `${rootRel}::${relPath}`;
393
+ };
394
+
395
+ /**
396
+ * Build a generated-module expression that instantiates one Twig template.
397
+ *
398
+ * @param {string} templateId - Stable Twig template id.
399
+ * @param {object} params - Twig template parameters without the id.
400
+ * @returns {string} Compiled template expression.
401
+ */
402
+ const makeTemplateInstantiationCode = (templateId, params) =>
403
+ `Twig.twig(${JSON.stringify({ ...params, id: templateId })})`;
404
+
405
+ /**
406
+ * Rewrite static include/import/embed references to module-local template IDs.
407
+ *
408
+ * Twig.js falls back to its filesystem loader when an inline include misses the
409
+ * registry. Browser modules cannot use that loader, so emitted templates point
410
+ * static dependency tokens directly at the pre-registered module-local IDs.
411
+ *
412
+ * @param {Array} [tokens=[]] - Twig token tree.
413
+ * @param {string} fromFilePath - Absolute path of the template being compiled.
414
+ * @param {ReturnType<typeof makeTwigPluginOptions>} options - Twig plugin options.
415
+ * @returns {Array} Cloned token tree with static dependency references rewritten.
416
+ */
417
+ const rewriteTemplateReferencesToIds = (tokens = [], fromFilePath, options) =>
418
+ tokens.map((token) => {
419
+ const nextToken = { ...token };
420
+
421
+ if (token.token) {
422
+ nextToken.token = { ...token.token };
423
+
424
+ if (
425
+ templateReferenceTokenTypes.includes(token.token.type) &&
426
+ Array.isArray(token.token.stack)
427
+ ) {
428
+ nextToken.token.stack = token.token.stack.map((stackToken) => {
429
+ if (typeof stackToken.value !== 'string') {
430
+ return stackToken;
431
+ }
432
+
433
+ const includePath = resolveTwigTemplate(
434
+ stackToken.value,
435
+ dirname(fromFilePath),
436
+ options,
437
+ );
438
+
439
+ if (!includePath) {
440
+ return stackToken;
441
+ }
442
+
443
+ return {
444
+ ...stackToken,
445
+ value: templateIdForPath(includePath, options),
446
+ };
447
+ });
448
+ }
449
+
450
+ if (Array.isArray(token.token.output)) {
451
+ nextToken.token.output = rewriteTemplateReferencesToIds(
452
+ token.token.output,
453
+ fromFilePath,
454
+ options,
455
+ );
456
+ }
457
+ }
458
+
459
+ return nextToken;
460
+ });
461
+
462
+ /**
463
+ * Resolve Twig namespace syntax to a namespace root and relative path.
464
+ *
465
+ * @param {string} templatePath - Template reference from Twig source.
466
+ * @param {Record<string, string>} [namespaces={}] - Namespace root map.
467
+ * @returns {{ namespace: string, root: string, path: string }|null}
468
+ * Namespace lookup result.
469
+ */
470
+ const parseTwigNamespaceReference = (templatePath, namespaces = {}) => {
471
+ const namespaceNames = Object.keys(namespaces);
472
+ const atNamespace = templatePath.match(/^@([^/]+)\/(.+)$/);
473
+ if (atNamespace && namespaces[atNamespace[1]]) {
474
+ return {
475
+ namespace: atNamespace[1],
476
+ root: namespaces[atNamespace[1]],
477
+ path: atNamespace[2],
478
+ };
479
+ }
480
+
481
+ const doubleColon = templatePath.match(/^([^:]+)::(.+)$/);
482
+ if (doubleColon && namespaces[doubleColon[1]]) {
483
+ return {
484
+ namespace: doubleColon[1],
485
+ root: namespaces[doubleColon[1]],
486
+ path: doubleColon[2],
487
+ };
488
+ }
489
+
490
+ const singleColon = templatePath.match(/^([^:/.]+):(.+)$/);
491
+ if (singleColon && namespaces[singleColon[1]]) {
492
+ return {
493
+ namespace: singleColon[1],
494
+ root: namespaces[singleColon[1]],
495
+ path: singleColon[2],
496
+ };
497
+ }
498
+
499
+ const slashNamespace = namespaceNames.find((namespace) =>
500
+ templatePath.startsWith(`${namespace}/`),
501
+ );
502
+ if (slashNamespace) {
503
+ return {
504
+ namespace: slashNamespace,
505
+ // Namespace names come from the normalized Twig namespace map.
506
+ // eslint-disable-next-line security/detect-object-injection
507
+ root: namespaces[slashNamespace],
508
+ path: templatePath.slice(slashNamespace.length + 1),
509
+ };
510
+ }
511
+
512
+ return null;
513
+ };
514
+
515
+ /**
516
+ * Return immediate directory roots that may group component folders.
517
+ *
518
+ * @param {string} componentRoot - Absolute component root path.
519
+ * @returns {string[]} Absolute grouping directory paths.
520
+ */
521
+ const componentGroupRoots = (componentRoot) => {
522
+ if (!componentRoot) return [];
523
+
524
+ try {
525
+ // Component group roots come from a configured project directory.
526
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
527
+ return fs
528
+ .readdirSync(componentRoot, { withFileTypes: true })
529
+ .filter((entry) => entry.isDirectory())
530
+ .map((entry) => resolve(componentRoot, entry.name));
531
+ } catch {
532
+ return [];
533
+ }
534
+ };
535
+
536
+ /**
537
+ * Resolve a component reference through one grouping directory level.
538
+ *
539
+ * Project-scoped component IDs can use the component name (`project:button`)
540
+ * even when projects organize components under grouping directories such as
541
+ * `ui`.
542
+ *
543
+ * @param {string} templatePath - Component-relative template reference.
544
+ * @param {string} componentRoot - Absolute component root path.
545
+ * @returns {string|null} Existing template path when found.
546
+ */
547
+ const resolveGroupedComponentTemplate = (templatePath, componentRoot) =>
548
+ findExistingTemplateFile(
549
+ componentGroupRoots(componentRoot).flatMap((groupRoot) =>
550
+ buildTemplateFileCandidates(groupRoot, templatePath),
551
+ ),
552
+ ) || null;
553
+
554
+ /**
555
+ * Resolve shorthand component references against the components namespace.
556
+ *
557
+ * @param {string} templatePath - Template reference from Twig source.
558
+ * @param {string} componentRoot - Absolute component root path.
559
+ * @returns {string|null} Existing template path when found.
560
+ */
561
+ const resolveComponentShorthandReference = (templatePath, componentRoot) => {
562
+ if (!componentRoot || templatePath.startsWith('.')) return null;
563
+
564
+ const shorthandPath =
565
+ templatePath.startsWith('@') && !templatePath.includes('/')
566
+ ? templatePath.slice(1)
567
+ : templatePath;
568
+ const directComponentPath = findExistingTemplateFile(
569
+ buildTemplateFileCandidates(componentRoot, shorthandPath),
570
+ );
571
+ if (directComponentPath) {
572
+ return directComponentPath;
573
+ }
574
+
575
+ const genericNamespace = templatePath.match(/^@?[^/:]+[:/](.+)$/);
576
+ if (!genericNamespace) {
577
+ return null;
578
+ }
579
+
580
+ const genericComponentPath = genericNamespace[1];
581
+
582
+ return (
583
+ findExistingTemplateFile(
584
+ buildTemplateFileCandidates(componentRoot, genericComponentPath),
585
+ ) || resolveGroupedComponentTemplate(genericComponentPath, componentRoot)
586
+ );
587
+ };
588
+
589
+ /**
590
+ * Build a stable key segment for include resolution cache entries.
591
+ *
592
+ * @param {ReturnType<typeof makeTwigPluginOptions>} options - Twig plugin options.
593
+ * @returns {string} Stable root and namespace cache key.
594
+ */
595
+ const buildResolutionRootCacheKey = (options) => {
596
+ const namespaceKey = Object.entries(options.namespaces || {})
597
+ .sort(([left], [right]) => left.localeCompare(right))
598
+ .map(([namespace, root]) => `${namespace}=${root}`)
599
+ .join(',');
600
+
601
+ return `${options.root || ''}|${namespaceKey}`;
602
+ };
603
+
604
+ /**
605
+ * Resolve a Twig include/import/extends reference from a source directory.
606
+ *
607
+ * @param {string} templatePath - Template reference from Twig source.
608
+ * @param {string} fromDir - Directory of the importing template.
609
+ * @param {{ root: string, namespaces: Record<string, string> }} options - Twig plugin options.
610
+ * @returns {string|null} Existing template path when found.
611
+ */
612
+ const resolveTwigTemplateWithoutCache = (templatePath, fromDir, options) => {
613
+ if (templatePath === '_self') return null;
614
+
615
+ const namespaced = parseTwigNamespaceReference(
616
+ templatePath,
617
+ options.namespaces,
618
+ );
619
+ if (namespaced) {
620
+ const namespacedTemplate = findExistingTemplateFile(
621
+ buildTemplateFileCandidates(namespaced.root, namespaced.path),
622
+ );
623
+ if (namespacedTemplate) {
624
+ return namespacedTemplate;
625
+ }
626
+
627
+ if (namespaced.namespace === 'components') {
628
+ return resolveGroupedComponentTemplate(
629
+ namespaced.path,
630
+ options.namespaces?.components,
631
+ );
632
+ }
633
+
634
+ return null;
635
+ }
636
+
637
+ const relativeTemplate = findExistingTemplateFile([
638
+ ...buildTemplateFileCandidates(fromDir, templatePath),
639
+ ...buildTemplateFileCandidates(options.root, templatePath),
640
+ ]);
641
+
642
+ return (
643
+ relativeTemplate ||
644
+ resolveComponentShorthandReference(
645
+ templatePath,
646
+ options.namespaces?.components,
647
+ )
648
+ );
649
+ };
650
+
651
+ /**
652
+ * Resolve a Twig reference with build-scoped filesystem probe memoization.
653
+ *
654
+ * @param {string} templatePath - Template reference from Twig source.
655
+ * @param {string} fromDir - Directory of the importing template.
656
+ * @param {ReturnType<typeof makeTwigPluginOptions>} options - Twig plugin options.
657
+ * @returns {string|null} Existing template path when found.
658
+ */
659
+ const resolveTwigTemplate = (templatePath, fromDir, options) => {
660
+ const cacheKey = [
661
+ resolve(fromDir),
662
+ templatePath,
663
+ buildResolutionRootCacheKey(options),
664
+ ].join('|');
665
+
666
+ if (resolutionCache.has(cacheKey)) {
667
+ return resolutionCache.get(cacheKey);
668
+ }
669
+
670
+ const resolvedPath =
671
+ resolveTwigTemplateWithoutCache(templatePath, fromDir, options) || null;
672
+ resolutionCache.set(cacheKey, resolvedPath);
673
+ if (resolvedPath) {
674
+ knownTwigFiles.add(resolve(resolvedPath));
675
+ }
676
+ return resolvedPath;
677
+ };
678
+
679
+ /**
680
+ * Clear include-resolution cache entries tied to one known Twig file.
681
+ *
682
+ * @param {string} filePath - Absolute Twig file path.
683
+ * @returns {void}
684
+ */
685
+ const invalidateKnownResolutionCacheEntries = (filePath) => {
686
+ const absoluteFilePath = resolve(filePath);
687
+ const fromDirPrefix = `${dirname(absoluteFilePath)}|`;
688
+
689
+ for (const [cacheKey, resolvedPath] of resolutionCache) {
690
+ if (
691
+ resolvedPath === absoluteFilePath ||
692
+ cacheKey.startsWith(fromDirPrefix)
693
+ ) {
694
+ resolutionCache.delete(cacheKey);
695
+ }
696
+ }
697
+ };
698
+
699
+ /**
700
+ * Compile a Twig template and collect its nested template references.
701
+ *
702
+ * @param {string} filePath - Absolute template file path.
703
+ * @param {ReturnType<typeof makeTwigPluginOptions>} options - Twig plugin options.
704
+ * @param {typeof compileCache} [cache=compileCache] - Shared compile cache.
705
+ * @returns {{ code: string, includes: string[], sourceReferences: string[] }} Compiled template code and references.
706
+ */
707
+ const compileTwigTemplate = (filePath, options, cache = compileCache) => {
708
+ const absoluteFilePath = resolve(filePath);
709
+ knownTwigFiles.add(absoluteFilePath);
710
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
711
+ const { mtimeMs } = fs.statSync(absoluteFilePath);
712
+ const cached = cache.get(absoluteFilePath);
713
+ if (cached?.mtimeMs === mtimeMs) {
714
+ return cached.compiled;
715
+ }
716
+
717
+ const compilerTwig = Twig.factory();
718
+ registerTwigExtensions(compilerTwig);
719
+ registerConfiguredTwigExtensions(compilerTwig, options);
720
+
721
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
722
+ const source = fs.readFileSync(absoluteFilePath, 'utf8');
723
+ const compileOptions = {
724
+ allowInlineIncludes: true,
725
+ namespaces: options.namespaces,
726
+ rethrow: true,
727
+ ...(options.options?.compileOptions || {}),
728
+ };
729
+ const templateId = templateIdForPath(absoluteFilePath, options);
730
+ const template = compilerTwig.twig({
731
+ ...compileOptions,
732
+ data: source,
733
+ id: templateId,
734
+ path: absoluteFilePath,
735
+ });
736
+ const includes = unique(
737
+ collectStaticTemplateReferences(template.tokens).filter(Boolean),
738
+ );
739
+ const sourceReferences = unique(
740
+ collectStaticSourceReferences(template.tokens).filter(Boolean),
741
+ );
742
+ const runtimeTokens = rewriteTemplateReferencesToIds(
743
+ template.tokens,
744
+ absoluteFilePath,
745
+ options,
746
+ );
747
+ const templateParams = {
748
+ allowInlineIncludes: true,
749
+ data: runtimeTokens,
750
+ namespaces: options.namespaces,
751
+ precompiled: true,
752
+ rethrow: true,
753
+ };
754
+ const compiled = {
755
+ code: makeTemplateInstantiationCode(templateId, templateParams),
756
+ includes,
757
+ sourceReferences,
758
+ templateId,
759
+ templateParams,
760
+ };
761
+
762
+ cache.set(absoluteFilePath, { mtimeMs, compiled });
763
+ return compiled;
764
+ };
765
+
766
+ /**
767
+ * Build platform-neutral Twig namespaces for the resolved project structure.
768
+ *
769
+ * @param {{
770
+ * projectDir: string,
771
+ * srcDir: string,
772
+ * srcExists: boolean,
773
+ * structureOverrides?: boolean,
774
+ * structureRoots?: string[]
775
+ * }} env
776
+ * @returns {Record<string, string>}
777
+ */
778
+ export function makeTwigNamespaces(env) {
779
+ if (canMemoizeByEnv(env) && twigNamespacesCache.has(env)) {
780
+ return twigNamespacesCache.get(env);
781
+ }
782
+
783
+ const structure = env.projectStructure || resolveProjectStructure(env);
784
+ let namespaces;
785
+ if (
786
+ structure.namespaceRoots &&
787
+ typeof structure.namespaceRoots === 'object'
788
+ ) {
789
+ namespaces = { ...structure.namespaceRoots };
790
+ } else {
791
+ const {
792
+ projectDir,
793
+ srcDir,
794
+ srcExists,
795
+ structureOverrides,
796
+ structureRoots = [],
797
+ } = env;
798
+
799
+ namespaces = {};
800
+ const overrideRoots = structureOverrides ? structureRoots : [];
801
+ const componentRoot =
802
+ basename(srcDir) === 'components'
803
+ ? srcDir
804
+ : resolve(srcDir, 'components');
805
+ const componentsNamespace = firstExistingPath([
806
+ ...new Set([
807
+ ...overrideRoots,
808
+ componentRoot,
809
+ resolve(projectDir, 'src/components'),
810
+ resolve(projectDir, 'components'),
811
+ ]),
812
+ ]);
813
+ const layoutNamespace = firstExistingPath([
814
+ ...new Set([
815
+ ...(srcExists ? [resolve(srcDir, 'layout')] : []),
816
+ resolve(projectDir, 'src/layout'),
817
+ resolve(projectDir, 'layout'),
818
+ ]),
819
+ ]);
820
+ const tokensNamespace = firstExistingPath([
821
+ ...new Set([
822
+ ...(srcExists ? [resolve(srcDir, 'tokens')] : []),
823
+ resolve(projectDir, 'src/tokens'),
824
+ resolve(projectDir, 'tokens'),
825
+ ]),
826
+ ]);
827
+
828
+ if (componentsNamespace) {
829
+ namespaces.components = componentsNamespace;
830
+ }
831
+ if (layoutNamespace) {
832
+ namespaces.layout = layoutNamespace;
833
+ }
834
+ if (tokensNamespace) {
835
+ namespaces.tokens = tokensNamespace;
836
+ }
837
+ }
838
+
839
+ if (canMemoizeByEnv(env)) {
840
+ twigNamespacesCache.set(env, namespaces);
841
+ }
842
+
843
+ return namespaces;
844
+ }
845
+
846
+ /**
847
+ * Build the generic Twig plugin options shared by Vite and Storybook.
848
+ *
849
+ * @param {{
850
+ * projectDir: string,
851
+ * srcDir: string,
852
+ * structureOverrides?: boolean,
853
+ * structureRoots?: string[]
854
+ * }} env
855
+ * @returns {import('@vituum/vite-plugin-twig/types').PluginUserConfig}
856
+ */
857
+ export function makeTwigPluginOptions(env) {
858
+ if (canMemoizeByEnv(env) && twigPluginOptionsCache.has(env)) {
859
+ return twigPluginOptionsCache.get(env);
860
+ }
861
+
862
+ const { projectDir, srcDir, structureOverrides, structureRoots = [] } = env;
863
+ const structure = env.projectStructure || resolveProjectStructure(env);
864
+ const overrideRoots = structureOverrides ? structureRoots : [];
865
+ const root = firstExistingPath(
866
+ structure.twigRoots?.length
867
+ ? [...structure.twigRoots, srcDir, projectDir]
868
+ : structureOverrides
869
+ ? [...overrideRoots, srcDir, projectDir]
870
+ : [srcDir, ...overrideRoots, projectDir],
871
+ );
872
+
873
+ const twigOptions = {
874
+ root: root || srcDir || projectDir,
875
+ namespaces: makeTwigNamespaces(env),
876
+ functions: getTwigFunctionMap(),
877
+ registerDrupalTwigFilters: shouldRegisterDrupalTwigFilters(env),
878
+ // Twig updates are handled by emulsifyTwigModulePlugin.handleHotUpdate.
879
+ // Vituum's full reload would defeat HMR by reloading the whole iframe on
880
+ // every Twig save before module graph invalidation can update the story.
881
+ reload: () => false,
882
+ };
883
+
884
+ Object.defineProperty(twigOptions, 'projectDir', {
885
+ value: projectDir,
886
+ });
887
+
888
+ if (canMemoizeByEnv(env)) {
889
+ twigPluginOptionsCache.set(env, twigOptions);
890
+ }
891
+
892
+ return twigOptions;
893
+ }
894
+
895
+ /**
896
+ * Clear process-local Twig namespace and plugin option memoization caches.
897
+ *
898
+ * @returns {void}
899
+ */
900
+ export function resetTwigOptionCaches() {
901
+ twigNamespacesCache = new WeakMap();
902
+ twigPluginOptionsCache = new WeakMap();
903
+ }
904
+
905
+ /**
906
+ * Transform Twig imports into render functions for Storybook and Vite consumers.
907
+ *
908
+ * @param {ReturnType<typeof makeTwigPluginOptions>} options - Twig options.
909
+ * @returns {import('vite').PluginOption} Twig module plugin.
910
+ */
911
+ export function emulsifyTwigModulePlugin(options) {
912
+ /**
913
+ * Reverse dependency graph used by HMR.
914
+ *
915
+ * Keys are included Twig files, and values are the imported Twig modules that
916
+ * need recompilation when that dependency changes.
917
+ *
918
+ * @type {Map<string, Set<string>>}
919
+ */
920
+ const dependencyImporters = new Map();
921
+
922
+ /**
923
+ * Remember that one imported Twig module depends on another Twig file.
924
+ *
925
+ * @param {string} dependency - Absolute dependency template path.
926
+ * @param {string} importer - Absolute importing module path.
927
+ * @returns {void}
928
+ */
929
+ const addDependencyImporter = (dependency, importer) => {
930
+ const importers = dependencyImporters.get(dependency) || new Set();
931
+ importers.add(importer);
932
+ dependencyImporters.set(dependency, importers);
933
+ };
934
+
935
+ /**
936
+ * Remove stale reverse-dependency links for a module before recompiling it.
937
+ *
938
+ * @param {string} importer - Absolute importing module path.
939
+ * @returns {void}
940
+ */
941
+ const clearDependencyImporter = (importer) => {
942
+ for (const [dependency, importers] of dependencyImporters) {
943
+ importers.delete(importer);
944
+ if (!importers.size) {
945
+ dependencyImporters.delete(dependency);
946
+ }
947
+ }
948
+ };
949
+
950
+ return {
951
+ name: 'emulsify-twig-module',
952
+ enforce: 'pre',
953
+ buildStart() {
954
+ compileCache.clear();
955
+ resolutionCache.clear();
956
+ knownTwigFiles.clear();
957
+ },
958
+ transform(...args) {
959
+ const [, id] = args;
960
+ if (!isTwigModuleRequest(id)) {
961
+ return null;
962
+ }
963
+
964
+ const filePath = stripRequestQuery(id);
965
+ const sourceFilePath = resolve(filePath);
966
+ /** @type {Map<string, ReturnType<typeof compileTwigTemplate>>} */
967
+ const compiledDependencyTemplates = new Map();
968
+ /** @type {Map<string, Set<string>>} */
969
+ const compiledDependencyReferences = new Map();
970
+ clearDependencyImporter(sourceFilePath);
971
+
972
+ /**
973
+ * Record one raw Twig reference against its resolved template file.
974
+ *
975
+ * include() function calls are not rewritten to template IDs, so the
976
+ * generated runtime resolver needs the original strings as lookup keys.
977
+ *
978
+ * @param {string} dependencyPath - Absolute resolved dependency path.
979
+ * @param {string} templateReference - Raw reference found in Twig tokens.
980
+ * @returns {void}
981
+ */
982
+ const addCompiledDependencyReference = (
983
+ dependencyPath,
984
+ templateReference,
985
+ ) => {
986
+ const absoluteDependencyPath = resolve(dependencyPath);
987
+ const references =
988
+ compiledDependencyReferences.get(absoluteDependencyPath) || new Set();
989
+ references.add(templateReference);
990
+ references.add(templateIdForPath(absoluteDependencyPath, options));
991
+ compiledDependencyReferences.set(absoluteDependencyPath, references);
992
+ };
993
+
994
+ /**
995
+ * Compile the transitive Twig dependencies needed by one imported module.
996
+ *
997
+ * Dependencies are stored once per absolute path and later emitted in
998
+ * deepest-first order so Twig.js can resolve inline include tokens from
999
+ * the module-local factory without using its filesystem loader.
1000
+ *
1001
+ * @param {string[]} templateReferences - Raw references found in tokens.
1002
+ * @param {string} fromFilePath - Absolute importer path.
1003
+ * @param {typeof compileCache} cache - Shared compile cache.
1004
+ * @returns {void}
1005
+ */
1006
+ const compileDependencyTemplates = (
1007
+ templateReferences,
1008
+ fromFilePath,
1009
+ cache,
1010
+ ) => {
1011
+ for (const templatePath of templateReferences) {
1012
+ const dependencyPath = resolveTwigTemplate(
1013
+ templatePath,
1014
+ dirname(fromFilePath),
1015
+ options,
1016
+ );
1017
+ if (!dependencyPath) continue;
1018
+ const absoluteDependencyPath = resolve(dependencyPath);
1019
+ addCompiledDependencyReference(absoluteDependencyPath, templatePath);
1020
+ if (absoluteDependencyPath === sourceFilePath) continue;
1021
+
1022
+ const dependencyTemplate = compiledDependencyTemplates.get(
1023
+ absoluteDependencyPath,
1024
+ );
1025
+ if (dependencyTemplate) continue;
1026
+
1027
+ addDependencyImporter(absoluteDependencyPath, sourceFilePath);
1028
+ this.addWatchFile(absoluteDependencyPath);
1029
+
1030
+ const compiled = compileTwigTemplate(
1031
+ absoluteDependencyPath,
1032
+ options,
1033
+ cache,
1034
+ );
1035
+ compiledDependencyTemplates.set(absoluteDependencyPath, compiled);
1036
+ compileDependencyTemplates(
1037
+ compiled.includes,
1038
+ absoluteDependencyPath,
1039
+ cache,
1040
+ );
1041
+ }
1042
+ };
1043
+
1044
+ /**
1045
+ * Emit raw Twig source registrations for static source() references.
1046
+ *
1047
+ * @param {string} fromFilePath - Absolute file that contains the reference.
1048
+ * @param {ReturnType<typeof compileTwigTemplate>} compiledTemplate - Compiled template metadata.
1049
+ * @returns {string[]} Generated source map registration statements.
1050
+ */
1051
+ const makeSourceTemplateRegistrations = (
1052
+ fromFilePath,
1053
+ compiledTemplate,
1054
+ ) =>
1055
+ (compiledTemplate.sourceReferences || []).flatMap((reference) => {
1056
+ if (
1057
+ reference.startsWith('@assets/') ||
1058
+ reference.startsWith('assets/')
1059
+ ) {
1060
+ return [];
1061
+ }
1062
+
1063
+ const sourcePath = resolveTwigTemplate(
1064
+ reference,
1065
+ dirname(fromFilePath),
1066
+ options,
1067
+ );
1068
+ if (!sourcePath) return [];
1069
+
1070
+ const absoluteSourcePath = resolve(sourcePath);
1071
+ addDependencyImporter(absoluteSourcePath, sourceFilePath);
1072
+ this.addWatchFile(absoluteSourcePath);
1073
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
1074
+ const sourceText = fs.readFileSync(absoluteSourcePath, 'utf8');
1075
+
1076
+ return unique([
1077
+ reference,
1078
+ templateIdForPath(absoluteSourcePath, options),
1079
+ ]).map(
1080
+ (key) =>
1081
+ `__emulsifySourceTemplates.set(${JSON.stringify(
1082
+ key,
1083
+ )}, ${JSON.stringify(sourceText)});`,
1084
+ );
1085
+ });
1086
+
1087
+ try {
1088
+ const compiled = compileTwigTemplate(
1089
+ sourceFilePath,
1090
+ options,
1091
+ compileCache,
1092
+ );
1093
+ compileDependencyTemplates(
1094
+ compiled.includes,
1095
+ sourceFilePath,
1096
+ compileCache,
1097
+ );
1098
+
1099
+ const dependencyTemplateRecords = Array.from(
1100
+ compiledDependencyTemplates.entries(),
1101
+ )
1102
+ .reverse()
1103
+ .map(([dependencyPath, compiledDependency], index) => ({
1104
+ dependencyPath,
1105
+ compiledDependency,
1106
+ variableName: `__emulsifyDependency${index}`,
1107
+ }));
1108
+ const dependencyTemplateCode = dependencyTemplateRecords
1109
+ .map(
1110
+ ({ compiledDependency, variableName }) => `
1111
+ const ${variableName} = ${makeTemplateInstantiationCode(
1112
+ compiledDependency.templateId,
1113
+ compiledDependency.templateParams,
1114
+ )};
1115
+ `,
1116
+ )
1117
+ .join('\n');
1118
+ const includeTemplateRegistrations = [
1119
+ ...dependencyTemplateRecords.flatMap(
1120
+ ({ dependencyPath, variableName }) =>
1121
+ Array.from(
1122
+ compiledDependencyReferences.get(dependencyPath) || [],
1123
+ ).map(
1124
+ (reference) =>
1125
+ `__emulsifyIncludeTemplates.set(${JSON.stringify(
1126
+ reference,
1127
+ )}, (context = {}) => ${variableName}.render(context));`,
1128
+ ),
1129
+ ),
1130
+ ...Array.from(
1131
+ compiledDependencyReferences.get(sourceFilePath) || [],
1132
+ ).map(
1133
+ (reference) =>
1134
+ `__emulsifyIncludeTemplates.set(${JSON.stringify(
1135
+ reference,
1136
+ )}, (context = {}) => __emulsifyTemplate.render(context));`,
1137
+ ),
1138
+ ].join('\n');
1139
+ const sourceTemplateRegistrations = [
1140
+ ...dependencyTemplateRecords.flatMap(
1141
+ ({ dependencyPath, compiledDependency }) =>
1142
+ makeSourceTemplateRegistrations(
1143
+ dependencyPath,
1144
+ compiledDependency,
1145
+ ),
1146
+ ),
1147
+ ...makeSourceTemplateRegistrations(sourceFilePath, compiled),
1148
+ ].join('\n');
1149
+ const renderErrorPrefix = JSON.stringify(
1150
+ `An error occurred whilst rendering ${toPosixPath(filePath)}: `,
1151
+ );
1152
+ const moduleCode = `
1153
+ import { factory } from 'twig';
1154
+ import { registerTwigExtensions } from '@emulsify/core/extensions/twig';
1155
+ import { registerConfiguredTwigExtensions } from 'virtual:emulsify-twig-extension-installers';
1156
+ import { createTwigIncludeFunction } from '@emulsify/core/storybook/twig/include-function';
1157
+ import { createTwigSourceFunction } from '@emulsify/core/storybook/twig/source-function';
1158
+
1159
+ const Twig = factory();
1160
+ registerTwigExtensions(Twig);
1161
+ registerConfiguredTwigExtensions(Twig);
1162
+
1163
+ ${dependencyTemplateCode}
1164
+ const __emulsifyTemplate = ${compiled.code};
1165
+ const __emulsifyIncludeTemplates = new Map();
1166
+ const __emulsifySourceTemplates = new Map();
1167
+ ${includeTemplateRegistrations}
1168
+ ${sourceTemplateRegistrations}
1169
+ const __emulsifyResolveInclude = (templateName) =>
1170
+ __emulsifyIncludeTemplates.get(templateName);
1171
+ const __emulsifyResolveSource = (templateName) =>
1172
+ __emulsifySourceTemplates.get(templateName);
1173
+ Twig.extendFunction('include', createTwigIncludeFunction(__emulsifyResolveInclude));
1174
+ Twig.extendFunction('source', createTwigSourceFunction(__emulsifyResolveSource));
1175
+
1176
+ export default (context = {}) => {
1177
+ try {
1178
+ return __emulsifyTemplate.render(context);
1179
+ } catch (error) {
1180
+ return ${renderErrorPrefix} + error.toString();
1181
+ }
1182
+ };
1183
+ `;
1184
+
1185
+ return {
1186
+ code: moduleCode,
1187
+ map: null,
1188
+ };
1189
+ } catch (error) {
1190
+ const message = `An error occurred whilst compiling ${toPosixPath(
1191
+ filePath,
1192
+ )}: ${error.toString()}`;
1193
+
1194
+ return {
1195
+ code: `export default () => ${JSON.stringify(message)};`,
1196
+ map: null,
1197
+ };
1198
+ }
1199
+ },
1200
+ handleHotUpdate({ file, server }) {
1201
+ if (!file.endsWith('.twig')) {
1202
+ return undefined;
1203
+ }
1204
+
1205
+ const filePath = resolve(file);
1206
+ const fileExists = safeExists(filePath);
1207
+ const knownFile = knownTwigFiles.has(filePath);
1208
+ compileCache.delete(filePath);
1209
+ const importers = dependencyImporters.get(filePath);
1210
+ if (!fileExists) {
1211
+ dependencyImporters.delete(filePath);
1212
+ knownTwigFiles.delete(filePath);
1213
+ }
1214
+
1215
+ const projectRoot = options.projectDir || options.root;
1216
+ if (projectRoot && isWithinRoot(resolve(projectRoot), filePath)) {
1217
+ /**
1218
+ * Existing files only need entries for their own path and source
1219
+ * directory invalidated. New or deleted files can change previous
1220
+ * resolution misses, so those events clear the full resolution cache.
1221
+ */
1222
+ if (fileExists && knownFile) {
1223
+ invalidateKnownResolutionCacheEntries(filePath);
1224
+ } else {
1225
+ resolutionCache.clear();
1226
+ }
1227
+ }
1228
+
1229
+ if (!importers?.size) {
1230
+ return undefined;
1231
+ }
1232
+
1233
+ const modules = new Set(
1234
+ server.moduleGraph.getModulesByFile(filePath) || [],
1235
+ );
1236
+ for (const importer of importers) {
1237
+ compileCache.delete(importer);
1238
+
1239
+ const importerModules =
1240
+ server.moduleGraph.getModulesByFile(importer) || [];
1241
+
1242
+ for (const module of importerModules) {
1243
+ server.moduleGraph.invalidateModule(module);
1244
+ modules.add(module);
1245
+ }
1246
+ }
1247
+
1248
+ return Array.from(modules);
1249
+ },
1250
+ };
1251
+ }