@emulsify/core 4.2.0 → 4.3.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 (79) hide show
  1. package/.storybook/main-vite.js +91 -36
  2. package/README.md +185 -56
  3. package/config/a11y.config.js +2 -1
  4. package/config/vite/entries.js +4 -4
  5. package/config/vite/plugins/{copy-src-assets.js → assets/copy-src-assets.js} +1 -1
  6. package/config/vite/plugins/{copy-twig-files.js → assets/copy-twig-files.js} +1 -1
  7. package/config/vite/plugins/{css-asset-relativizer.js → assets/css-asset-relativizer.js} +9 -0
  8. package/config/vite/plugins/{mirror-components.js → assets/mirror-components.js} +2 -28
  9. package/config/vite/plugins/{source-file-index.js → assets/source-file-index.js} +1 -1
  10. package/config/vite/plugins/{svg-sprite.js → assets/svg-sprite.js} +2 -2
  11. package/config/vite/plugins/index.js +23 -14
  12. package/config/vite/plugins/reporter/asset-resolver.js +541 -0
  13. package/config/vite/plugins/reporter/build-errors.js +284 -0
  14. package/config/vite/plugins/reporter/diagnostics.js +366 -0
  15. package/config/vite/plugins/reporter/format.js +199 -0
  16. package/config/vite/plugins/reporter/index.js +308 -0
  17. package/config/vite/plugins/reporter/render.js +758 -0
  18. package/config/vite/plugins/reporter/sass-logger.js +237 -0
  19. package/config/vite/plugins/reporter/vite-logger.js +188 -0
  20. package/config/vite/plugins/reporter/watch-mode.js +40 -0
  21. package/config/vite/plugins/{twig-extension-installers.js → twig/extension-installers.js} +1 -1
  22. package/config/vite/{twig-extensions.js → plugins/twig/extensions.js} +2 -2
  23. package/config/vite/plugins/{twig-module.js → twig/twig-module.js} +387 -115
  24. package/config/vite/plugins/{virtual-twig-asset-sources.js → twig/virtual-twig-asset-sources.js} +37 -136
  25. package/config/vite/plugins/{virtual-twig-globs.js → twig/virtual-twig-globs.js} +3 -32
  26. package/config/vite/plugins/{vituum-patch.js → twig/vituum-patch.js} +3 -3
  27. package/config/vite/plugins.js +1 -1
  28. package/config/vite/project-config.js +1 -1
  29. package/config/vite/project-structure.js +1 -1
  30. package/config/vite/utils/lru.js +77 -0
  31. package/config/vite/utils/package-version.js +42 -0
  32. package/config/vite/utils/paths.js +1 -9
  33. package/config/vite/utils/react-singleton.js +1 -1
  34. package/config/vite/vite.config.js +51 -5
  35. package/package.json +93 -58
  36. package/scripts/a11y.js +115 -23
  37. package/scripts/audit/checks/core-imports.js +78 -0
  38. package/scripts/audit/checks/css-asset-references.js +99 -0
  39. package/scripts/audit/checks/drupal-assumptions.js +48 -0
  40. package/scripts/audit/checks/files-outside-roots.js +53 -0
  41. package/scripts/audit/checks/generated-package-scripts.js +113 -0
  42. package/scripts/audit/checks/legacy-twig-stories.js +33 -0
  43. package/scripts/audit/checks/package-overrides.js +91 -0
  44. package/scripts/audit/checks/project-config.js +71 -0
  45. package/scripts/audit/checks/story-discovery.js +35 -0
  46. package/scripts/audit/checks/twig-references.js +69 -0
  47. package/scripts/audit/checks/twig-volume.js +54 -0
  48. package/scripts/audit/checks/webpack-patterns.js +86 -0
  49. package/scripts/audit/index.js +177 -0
  50. package/scripts/audit/lib/css.js +165 -0
  51. package/scripts/audit/lib/files.js +168 -0
  52. package/scripts/audit/lib/findings.js +31 -0
  53. package/scripts/audit/lib/package-json.js +65 -0
  54. package/scripts/audit/lib/twig.js +227 -0
  55. package/scripts/audit/report.js +273 -0
  56. package/scripts/audit-twig-stories.js +115 -78
  57. package/scripts/audit.js +150 -1632
  58. package/scripts/check-node-version.js +136 -10
  59. package/scripts/inspect-components.js +456 -0
  60. package/scripts/lib/cli.js +179 -0
  61. package/scripts/lib/fs.js +31 -0
  62. package/scripts/lib/proc.js +78 -0
  63. package/scripts/lib/text.js +14 -0
  64. package/scripts/loadYaml.js +2 -2
  65. package/src/extensions/shared/attributes.js +3 -3
  66. package/src/extensions/shared/lists.js +2 -6
  67. package/src/extensions/shared/root-relative.js +38 -0
  68. package/src/storybook/index.js +4 -0
  69. package/src/storybook/render-twig.js +1 -1
  70. package/src/storybook/render-web-component.js +459 -0
  71. package/src/storybook/twig/asset-source-runtime.js +193 -0
  72. package/src/storybook/twig/{source-extensions.js → constants.js} +3 -1
  73. package/src/storybook/twig/reference-paths.js +2 -13
  74. package/src/storybook/twig/resolver.js +20 -3
  75. package/src/storybook/twig/setup.js +12 -2
  76. package/src/storybook/twig/source-function.js +5 -2
  77. package/config/vite/utils/unique.js +0 -36
  78. package/src/storybook/twig/include.js +0 -28
  79. package/src/storybook/twig/source-events.js +0 -5
@@ -10,22 +10,24 @@
10
10
  */
11
11
 
12
12
  import fs from 'fs';
13
+ import { readFile, stat } from 'fs/promises';
13
14
  import { basename, dirname, isAbsolute, relative, resolve } from 'path';
14
15
  import Twig from 'twig';
15
16
 
16
17
  import {
17
18
  getTwigFunctionMap,
18
19
  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';
20
+ } from '../../../../src/extensions/twig/index.js';
21
+ import { toRootRelativePath } from '../../../../src/storybook/twig/reference-paths.js';
22
+ import { resolveProjectStructure } from '../../project-structure.js';
22
23
  import {
23
- registerConfiguredTwigExtensions,
24
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';
25
+ installProjectTwigExtensions,
26
+ } from './extensions.js';
27
+ import { firstExistingPath, safeExists } from '../../utils/fs-safe.js';
28
+ import { createLruCache } from '../../utils/lru.js';
29
+ import { toPosixPath } from '../../utils/paths.js';
30
+ import { unique } from '../../../../src/extensions/shared/lists.js';
29
31
 
30
32
  /** Twig token types that can reference another template file. */
31
33
  const templateReferenceTokenTypes = [
@@ -38,6 +40,10 @@ const templateReferenceTokenTypes = [
38
40
 
39
41
  const includeFunctionName = 'include';
40
42
  const sourceFunctionName = 'source';
43
+ const VIRTUAL_TWIG_DEPENDENCY_PREFIX = 'virtual:emulsify-twig-dep:';
44
+ const RESOLVED_VIRTUAL_TWIG_DEPENDENCY_PREFIX = `\0${VIRTUAL_TWIG_DEPENDENCY_PREFIX}`;
45
+ const maxCompiledTemplateCacheEntries = 2000;
46
+ const maxResolutionCacheEntries = 5000;
41
47
  const expressionTokenTypes = {
42
48
  arrayEnd: 'Twig.expression.type.array.end',
43
49
  arrayStart: 'Twig.expression.type.array.start',
@@ -63,16 +69,23 @@ const expressionClosingTokenTypes = new Set([
63
69
  /**
64
70
  * Cache compiled Twig templates by absolute path for the life of one build.
65
71
  *
66
- * @type {Map<string, { mtimeMs: number, compiled: { code: string, includes: string[], sourceReferences: string[], templateId: string, templateParams: object } }>}
72
+ * @type {ReturnType<typeof createLruCache>}
67
73
  */
68
- const compileCache = new Map();
74
+ const compileCache = createLruCache(maxCompiledTemplateCacheEntries);
69
75
 
70
76
  /**
71
77
  * Cache resolved Twig include paths by source directory, reference, and roots.
72
78
  *
73
- * @type {Map<string, string|null>}
79
+ * @type {ReturnType<typeof createLruCache>}
74
80
  */
75
- const resolutionCache = new Map();
81
+ const resolutionCache = createLruCache(maxResolutionCacheEntries);
82
+
83
+ /**
84
+ * Cache recursively discovered component grouping directories by root.
85
+ *
86
+ * @type {Map<string, string[]>}
87
+ */
88
+ const componentGroupRootsCache = new Map();
76
89
 
77
90
  /**
78
91
  * Track Twig files that have been seen during this build/session.
@@ -114,6 +127,13 @@ const canMemoizeByEnv = (env) => env && typeof env === 'object';
114
127
  * @returns {boolean} TRUE when the request is a renderable Twig module.
115
128
  */
116
129
  const isTwigModuleRequest = (id) => {
130
+ if (
131
+ id.startsWith(VIRTUAL_TWIG_DEPENDENCY_PREFIX) ||
132
+ id.startsWith(RESOLVED_VIRTUAL_TWIG_DEPENDENCY_PREFIX)
133
+ ) {
134
+ return false;
135
+ }
136
+
117
137
  const [filePath, query = ''] = id.split('?');
118
138
  if (!filePath.endsWith('.twig')) return false;
119
139
  return !query || query === 'twig' || !/(^|&)(raw|url)\b/.test(query);
@@ -127,6 +147,24 @@ const isTwigModuleRequest = (id) => {
127
147
  */
128
148
  const stripRequestQuery = (id) => id.split('?')[0];
129
149
 
150
+ const encodeVirtualTwigDependencyId = (filePath) =>
151
+ `${VIRTUAL_TWIG_DEPENDENCY_PREFIX}${encodeURIComponent(resolve(filePath))}`;
152
+
153
+ const resolvedVirtualTwigDependencyId = (filePath) =>
154
+ `${RESOLVED_VIRTUAL_TWIG_DEPENDENCY_PREFIX}${encodeURIComponent(
155
+ resolve(filePath),
156
+ )}`;
157
+
158
+ const decodeVirtualTwigDependencyId = (id) => {
159
+ const normalizedId = id.startsWith('\0') ? id.slice(1) : id;
160
+
161
+ return normalizedId.startsWith(VIRTUAL_TWIG_DEPENDENCY_PREFIX)
162
+ ? decodeURIComponent(
163
+ normalizedId.slice(VIRTUAL_TWIG_DEPENDENCY_PREFIX.length),
164
+ )
165
+ : '';
166
+ };
167
+
130
168
  /**
131
169
  * Extract the first argument from a Twig function token parameter list.
132
170
  *
@@ -355,6 +393,43 @@ const isWithinRoot = (root, filePath) => {
355
393
  );
356
394
  };
357
395
 
396
+ /**
397
+ * Return the first component template candidate contained by its configured root.
398
+ *
399
+ * Both lexical and real paths are checked so `..` segments and symlinks cannot
400
+ * escape the component root.
401
+ *
402
+ * @param {string[]} paths - Candidate absolute paths.
403
+ * @param {string} componentRoot - Absolute component root path.
404
+ * @returns {string|undefined} Existing component template path.
405
+ */
406
+ const findExistingComponentTemplateFile = (paths, componentRoot) => {
407
+ const absoluteRoot = resolve(componentRoot);
408
+ let realRoot;
409
+
410
+ try {
411
+ realRoot = fs.realpathSync(absoluteRoot);
412
+ } catch {
413
+ return undefined;
414
+ }
415
+
416
+ return paths.filter(Boolean).find((filePath) => {
417
+ const absoluteFilePath = resolve(filePath);
418
+ if (!isWithinRoot(absoluteRoot, absoluteFilePath)) {
419
+ return false;
420
+ }
421
+
422
+ try {
423
+ return (
424
+ fs.statSync(absoluteFilePath).isFile() &&
425
+ isWithinRoot(realRoot, fs.realpathSync(absoluteFilePath))
426
+ );
427
+ } catch {
428
+ return false;
429
+ }
430
+ });
431
+ };
432
+
358
433
  /**
359
434
  * Find the most specific configured Twig root for a template file.
360
435
  *
@@ -401,6 +476,21 @@ const templateIdForPath = (filePath, options) => {
401
476
  const makeTemplateInstantiationCode = (templateId, params) =>
402
477
  `Twig.twig(${JSON.stringify({ ...params, id: templateId })})`;
403
478
 
479
+ const makeTemplateInstantiationFromParamsCode = (
480
+ templateIdExpression,
481
+ paramsExpression,
482
+ ) => `Twig.twig({ ...${paramsExpression}, id: ${templateIdExpression} })`;
483
+
484
+ const generateTwigDependencyModule = async (filePath, options) => {
485
+ const compiled = await compileTwigTemplate(filePath, options, compileCache);
486
+
487
+ return [
488
+ `export const templateId = ${JSON.stringify(compiled.templateId)};`,
489
+ `export const templateParams = ${JSON.stringify(compiled.templateParams)};`,
490
+ '',
491
+ ].join('\n');
492
+ };
493
+
404
494
  /**
405
495
  * Rewrite static include/import/embed references to module-local template IDs.
406
496
  *
@@ -511,7 +601,11 @@ const parseTwigNamespaceReference = (templatePath, namespaces = {}) => {
511
601
  };
512
602
 
513
603
  /**
514
- * Return immediate directory roots that may group component folders.
604
+ * Return grouping directories below the configured component root.
605
+ *
606
+ * Breadth-first traversal preserves direct and one-level behavior before
607
+ * searching deeper groups. Siblings use code-point order so duplicate
608
+ * shorthand names resolve consistently across filesystems.
515
609
  *
516
610
  * @param {string} componentRoot - Absolute component root path.
517
611
  * @returns {string[]} Absolute grouping directory paths.
@@ -519,33 +613,57 @@ const parseTwigNamespaceReference = (templatePath, namespaces = {}) => {
519
613
  const componentGroupRoots = (componentRoot) => {
520
614
  if (!componentRoot) return [];
521
615
 
522
- try {
523
- // Component group roots come from a configured project directory.
524
- return fs
525
- .readdirSync(componentRoot, { withFileTypes: true })
616
+ const absoluteRoot = resolve(componentRoot);
617
+ if (componentGroupRootsCache.has(absoluteRoot)) {
618
+ return componentGroupRootsCache.get(absoluteRoot);
619
+ }
620
+
621
+ const groupRoots = [];
622
+ const pendingDirectories = [absoluteRoot];
623
+
624
+ for (let index = 0; index < pendingDirectories.length; index += 1) {
625
+ const directory = pendingDirectories[index];
626
+ let entries;
627
+
628
+ try {
629
+ entries = fs.readdirSync(directory, { withFileTypes: true });
630
+ } catch {
631
+ continue;
632
+ }
633
+
634
+ const childDirectories = entries
526
635
  .filter((entry) => entry.isDirectory())
527
- .map((entry) => resolve(componentRoot, entry.name));
528
- } catch {
529
- return [];
636
+ .sort(({ name: left }, { name: right }) =>
637
+ left === right ? 0 : left < right ? -1 : 1,
638
+ )
639
+ .map((entry) => resolve(directory, entry.name))
640
+ .filter((childDirectory) => isWithinRoot(absoluteRoot, childDirectory));
641
+
642
+ groupRoots.push(...childDirectories);
643
+ pendingDirectories.push(...childDirectories);
530
644
  }
645
+
646
+ componentGroupRootsCache.set(absoluteRoot, groupRoots);
647
+ return groupRoots;
531
648
  };
532
649
 
533
650
  /**
534
- * Resolve a component reference through one grouping directory level.
651
+ * Resolve a component reference through recursively grouped directories.
535
652
  *
536
653
  * Project-scoped component IDs can use the component name (`project:button`)
537
- * even when projects organize components under grouping directories such as
538
- * `ui`.
654
+ * even when projects organize components under grouping paths such as
655
+ * `atoms/text`.
539
656
  *
540
657
  * @param {string} templatePath - Component-relative template reference.
541
658
  * @param {string} componentRoot - Absolute component root path.
542
659
  * @returns {string|null} Existing template path when found.
543
660
  */
544
661
  const resolveGroupedComponentTemplate = (templatePath, componentRoot) =>
545
- findExistingTemplateFile(
662
+ findExistingComponentTemplateFile(
546
663
  componentGroupRoots(componentRoot).flatMap((groupRoot) =>
547
664
  buildTemplateFileCandidates(groupRoot, templatePath),
548
665
  ),
666
+ componentRoot,
549
667
  ) || null;
550
668
 
551
669
  /**
@@ -562,8 +680,9 @@ const resolveComponentShorthandReference = (templatePath, componentRoot) => {
562
680
  templatePath.startsWith('@') && !templatePath.includes('/')
563
681
  ? templatePath.slice(1)
564
682
  : templatePath;
565
- const directComponentPath = findExistingTemplateFile(
683
+ const directComponentPath = findExistingComponentTemplateFile(
566
684
  buildTemplateFileCandidates(componentRoot, shorthandPath),
685
+ componentRoot,
567
686
  );
568
687
  if (directComponentPath) {
569
688
  return directComponentPath;
@@ -577,8 +696,9 @@ const resolveComponentShorthandReference = (templatePath, componentRoot) => {
577
696
  const genericComponentPath = genericNamespace[1];
578
697
 
579
698
  return (
580
- findExistingTemplateFile(
699
+ findExistingComponentTemplateFile(
581
700
  buildTemplateFileCandidates(componentRoot, genericComponentPath),
701
+ componentRoot,
582
702
  ) || resolveGroupedComponentTemplate(genericComponentPath, componentRoot)
583
703
  );
584
704
  };
@@ -614,9 +734,15 @@ const resolveTwigTemplateWithoutCache = (templatePath, fromDir, options) => {
614
734
  options.namespaces,
615
735
  );
616
736
  if (namespaced) {
617
- const namespacedTemplate = findExistingTemplateFile(
618
- buildTemplateFileCandidates(namespaced.root, namespaced.path),
619
- );
737
+ const namespacedTemplate =
738
+ namespaced.namespace === 'components'
739
+ ? findExistingComponentTemplateFile(
740
+ buildTemplateFileCandidates(namespaced.root, namespaced.path),
741
+ namespaced.root,
742
+ )
743
+ : findExistingTemplateFile(
744
+ buildTemplateFileCandidates(namespaced.root, namespaced.path),
745
+ );
620
746
  if (namespacedTemplate) {
621
747
  return namespacedTemplate;
622
748
  }
@@ -699,12 +825,12 @@ const invalidateKnownResolutionCacheEntries = (filePath) => {
699
825
  * @param {string} filePath - Absolute template file path.
700
826
  * @param {ReturnType<typeof makeTwigPluginOptions>} options - Twig plugin options.
701
827
  * @param {typeof compileCache} [cache=compileCache] - Shared compile cache.
702
- * @returns {{ code: string, includes: string[], sourceReferences: string[] }} Compiled template code and references.
828
+ * @returns {Promise<{ code: string, includes: string[], sourceReferences: string[] }>} Compiled template code and references.
703
829
  */
704
- const compileTwigTemplate = (filePath, options, cache = compileCache) => {
830
+ const compileTwigTemplate = async (filePath, options, cache = compileCache) => {
705
831
  const absoluteFilePath = resolve(filePath);
706
832
  knownTwigFiles.add(absoluteFilePath);
707
- const { mtimeMs } = fs.statSync(absoluteFilePath);
833
+ const { mtimeMs } = await stat(absoluteFilePath);
708
834
  const cached = cache.get(absoluteFilePath);
709
835
  if (cached?.mtimeMs === mtimeMs) {
710
836
  return cached.compiled;
@@ -712,9 +838,9 @@ const compileTwigTemplate = (filePath, options, cache = compileCache) => {
712
838
 
713
839
  const compilerTwig = Twig.factory();
714
840
  registerTwigExtensions(compilerTwig);
715
- registerConfiguredTwigExtensions(compilerTwig, options);
841
+ installProjectTwigExtensions(compilerTwig, options);
716
842
 
717
- const source = fs.readFileSync(absoluteFilePath, 'utf8');
843
+ const source = await readFile(absoluteFilePath, 'utf8');
718
844
  const compileOptions = {
719
845
  allowInlineIncludes: true,
720
846
  namespaces: options.namespaces,
@@ -914,6 +1040,16 @@ export function emulsifyTwigModulePlugin(options) {
914
1040
  */
915
1041
  const dependencyImporters = new Map();
916
1042
 
1043
+ /**
1044
+ * Twig entry modules transformed by this plugin instance.
1045
+ *
1046
+ * Structural component changes invalidate these modules because a new or
1047
+ * removed directory can change the target of a shorthand reference.
1048
+ *
1049
+ * @type {Set<string>}
1050
+ */
1051
+ const transformedTwigModules = new Set();
1052
+
917
1053
  /**
918
1054
  * Remember that one imported Twig module depends on another Twig file.
919
1055
  *
@@ -948,9 +1084,31 @@ export function emulsifyTwigModulePlugin(options) {
948
1084
  buildStart() {
949
1085
  compileCache.clear();
950
1086
  resolutionCache.clear();
1087
+ componentGroupRootsCache.clear();
951
1088
  knownTwigFiles.clear();
1089
+ transformedTwigModules.clear();
1090
+ },
1091
+ resolveId(id) {
1092
+ if (id.startsWith(VIRTUAL_TWIG_DEPENDENCY_PREFIX)) {
1093
+ return `\0${id}`;
1094
+ }
1095
+
1096
+ return null;
952
1097
  },
953
- transform(...args) {
1098
+ async load(id) {
1099
+ if (!id.startsWith(RESOLVED_VIRTUAL_TWIG_DEPENDENCY_PREFIX)) {
1100
+ return null;
1101
+ }
1102
+
1103
+ const dependencyPath = decodeVirtualTwigDependencyId(id);
1104
+ if (!dependencyPath) {
1105
+ return null;
1106
+ }
1107
+
1108
+ this.addWatchFile(dependencyPath);
1109
+ return generateTwigDependencyModule(dependencyPath, options);
1110
+ },
1111
+ async transform(...args) {
954
1112
  const [, id] = args;
955
1113
  if (!isTwigModuleRequest(id)) {
956
1114
  return null;
@@ -958,10 +1116,12 @@ export function emulsifyTwigModulePlugin(options) {
958
1116
 
959
1117
  const filePath = stripRequestQuery(id);
960
1118
  const sourceFilePath = resolve(filePath);
961
- /** @type {Map<string, ReturnType<typeof compileTwigTemplate>>} */
1119
+ transformedTwigModules.add(sourceFilePath);
1120
+ /** @type {Map<string, Awaited<ReturnType<typeof compileTwigTemplate>>>} */
962
1121
  const compiledDependencyTemplates = new Map();
963
1122
  /** @type {Map<string, Set<string>>} */
964
1123
  const compiledDependencyReferences = new Map();
1124
+ const queuedDependencyTemplates = new Set();
965
1125
  clearDependencyImporter(sourceFilePath);
966
1126
 
967
1127
  /**
@@ -996,13 +1156,15 @@ export function emulsifyTwigModulePlugin(options) {
996
1156
  * @param {string[]} templateReferences - Raw references found in tokens.
997
1157
  * @param {string} fromFilePath - Absolute importer path.
998
1158
  * @param {typeof compileCache} cache - Shared compile cache.
999
- * @returns {void}
1159
+ * @returns {Promise<void>}
1000
1160
  */
1001
- const compileDependencyTemplates = (
1161
+ const compileDependencyTemplates = async (
1002
1162
  templateReferences,
1003
1163
  fromFilePath,
1004
1164
  cache,
1005
1165
  ) => {
1166
+ const dependencyRecords = [];
1167
+
1006
1168
  for (const templatePath of templateReferences) {
1007
1169
  const dependencyPath = resolveTwigTemplate(
1008
1170
  templatePath,
@@ -1013,22 +1175,38 @@ export function emulsifyTwigModulePlugin(options) {
1013
1175
  const absoluteDependencyPath = resolve(dependencyPath);
1014
1176
  addCompiledDependencyReference(absoluteDependencyPath, templatePath);
1015
1177
  if (absoluteDependencyPath === sourceFilePath) continue;
1178
+ if (
1179
+ compiledDependencyTemplates.has(absoluteDependencyPath) ||
1180
+ queuedDependencyTemplates.has(absoluteDependencyPath)
1181
+ ) {
1182
+ continue;
1183
+ }
1016
1184
 
1017
- const dependencyTemplate = compiledDependencyTemplates.get(
1018
- absoluteDependencyPath,
1019
- );
1020
- if (dependencyTemplate) continue;
1185
+ queuedDependencyTemplates.add(absoluteDependencyPath);
1021
1186
 
1022
1187
  addDependencyImporter(absoluteDependencyPath, sourceFilePath);
1023
1188
  this.addWatchFile(absoluteDependencyPath);
1024
1189
 
1025
- const compiled = compileTwigTemplate(
1190
+ dependencyRecords.push({ absoluteDependencyPath });
1191
+ }
1192
+
1193
+ const compiledDependencyRecords = await Promise.all(
1194
+ dependencyRecords.map(async ({ absoluteDependencyPath }) => ({
1026
1195
  absoluteDependencyPath,
1027
- options,
1028
- cache,
1029
- );
1196
+ compiled: await compileTwigTemplate(
1197
+ absoluteDependencyPath,
1198
+ options,
1199
+ cache,
1200
+ ),
1201
+ })),
1202
+ );
1203
+
1204
+ for (const {
1205
+ absoluteDependencyPath,
1206
+ compiled,
1207
+ } of compiledDependencyRecords) {
1030
1208
  compiledDependencyTemplates.set(absoluteDependencyPath, compiled);
1031
- compileDependencyTemplates(
1209
+ await compileDependencyTemplates(
1032
1210
  compiled.includes,
1033
1211
  absoluteDependencyPath,
1034
1212
  cache,
@@ -1040,51 +1218,56 @@ export function emulsifyTwigModulePlugin(options) {
1040
1218
  * Emit raw Twig source registrations for static source() references.
1041
1219
  *
1042
1220
  * @param {string} fromFilePath - Absolute file that contains the reference.
1043
- * @param {ReturnType<typeof compileTwigTemplate>} compiledTemplate - Compiled template metadata.
1044
- * @returns {string[]} Generated source map registration statements.
1221
+ * @param {Awaited<ReturnType<typeof compileTwigTemplate>>} compiledTemplate - Compiled template metadata.
1222
+ * @returns {Promise<string[]>} Generated source map registration statements.
1045
1223
  */
1046
- const makeSourceTemplateRegistrations = (
1224
+ const makeSourceTemplateRegistrations = async (
1047
1225
  fromFilePath,
1048
1226
  compiledTemplate,
1049
- ) =>
1050
- (compiledTemplate.sourceReferences || []).flatMap((reference) => {
1051
- if (
1052
- reference.startsWith('@assets/') ||
1053
- reference.startsWith('assets/')
1054
- ) {
1055
- return [];
1056
- }
1227
+ ) => {
1228
+ const registrations = await Promise.all(
1229
+ (compiledTemplate.sourceReferences || []).map(async (reference) => {
1230
+ if (
1231
+ reference.startsWith('@assets/') ||
1232
+ reference.startsWith('assets/')
1233
+ ) {
1234
+ return [];
1235
+ }
1057
1236
 
1058
- const sourcePath = resolveTwigTemplate(
1059
- reference,
1060
- dirname(fromFilePath),
1061
- options,
1062
- );
1063
- if (!sourcePath) return [];
1064
-
1065
- const absoluteSourcePath = resolve(sourcePath);
1066
- addDependencyImporter(absoluteSourcePath, sourceFilePath);
1067
- this.addWatchFile(absoluteSourcePath);
1068
- const sourceText = fs.readFileSync(absoluteSourcePath, 'utf8');
1069
-
1070
- return unique([
1071
- reference,
1072
- templateIdForPath(absoluteSourcePath, options),
1073
- ]).map(
1074
- (key) =>
1075
- `__emulsifySourceTemplates.set(${JSON.stringify(
1076
- key,
1077
- )}, ${JSON.stringify(sourceText)});`,
1078
- );
1079
- });
1237
+ const sourcePath = resolveTwigTemplate(
1238
+ reference,
1239
+ dirname(fromFilePath),
1240
+ options,
1241
+ );
1242
+ if (!sourcePath) return [];
1243
+
1244
+ const absoluteSourcePath = resolve(sourcePath);
1245
+ addDependencyImporter(absoluteSourcePath, sourceFilePath);
1246
+ this.addWatchFile(absoluteSourcePath);
1247
+ const sourceText = await readFile(absoluteSourcePath, 'utf8');
1248
+
1249
+ return unique([
1250
+ reference,
1251
+ templateIdForPath(absoluteSourcePath, options),
1252
+ ]).map(
1253
+ (key) =>
1254
+ `__emulsifySourceTemplates.set(${JSON.stringify(
1255
+ key,
1256
+ )}, ${JSON.stringify(sourceText)});`,
1257
+ );
1258
+ }),
1259
+ );
1260
+
1261
+ return registrations.flat();
1262
+ };
1080
1263
 
1081
1264
  try {
1082
- const compiled = compileTwigTemplate(
1265
+ const compiled = await compileTwigTemplate(
1083
1266
  sourceFilePath,
1084
1267
  options,
1085
1268
  compileCache,
1086
1269
  );
1087
- compileDependencyTemplates(
1270
+ await compileDependencyTemplates(
1088
1271
  compiled.includes,
1089
1272
  sourceFilePath,
1090
1273
  compileCache,
@@ -1097,28 +1280,40 @@ export function emulsifyTwigModulePlugin(options) {
1097
1280
  .map(([dependencyPath, compiledDependency], index) => ({
1098
1281
  dependencyPath,
1099
1282
  compiledDependency,
1100
- variableName: `__emulsifyDependency${index}`,
1283
+ idVariableName: `__emulsifyDependencyTemplateId${index}`,
1284
+ paramsVariableName: `__emulsifyDependencyTemplateParams${index}`,
1285
+ templateVariableName: `__emulsifyDependency${index}`,
1286
+ virtualModuleId: encodeVirtualTwigDependencyId(dependencyPath),
1101
1287
  }));
1288
+ const dependencyTemplateImports = dependencyTemplateRecords
1289
+ .map(
1290
+ ({ idVariableName, paramsVariableName, virtualModuleId }) =>
1291
+ `import { templateId as ${idVariableName}, templateParams as ${paramsVariableName} } from ${JSON.stringify(
1292
+ virtualModuleId,
1293
+ )};`,
1294
+ )
1295
+ .join('\n');
1102
1296
  const dependencyTemplateCode = dependencyTemplateRecords
1103
1297
  .map(
1104
- ({ compiledDependency, variableName }) => `
1105
- const ${variableName} = ${makeTemplateInstantiationCode(
1106
- compiledDependency.templateId,
1107
- compiledDependency.templateParams,
1298
+ ({ idVariableName, paramsVariableName, templateVariableName }) => `
1299
+ const ${templateVariableName} = ${makeTemplateInstantiationFromParamsCode(
1300
+ idVariableName,
1301
+ paramsVariableName,
1108
1302
  )};
1303
+ ${templateVariableName}.method = 'emulsify';
1109
1304
  `,
1110
1305
  )
1111
1306
  .join('\n');
1112
1307
  const includeTemplateRegistrations = [
1113
1308
  ...dependencyTemplateRecords.flatMap(
1114
- ({ dependencyPath, variableName }) =>
1309
+ ({ dependencyPath, templateVariableName }) =>
1115
1310
  Array.from(
1116
1311
  compiledDependencyReferences.get(dependencyPath) || [],
1117
1312
  ).map(
1118
1313
  (reference) =>
1119
1314
  `__emulsifyIncludeTemplates.set(${JSON.stringify(
1120
1315
  reference,
1121
- )}, (context = {}) => ${variableName}.render(context));`,
1316
+ )}, (context = {}) => ${templateVariableName}.render(context));`,
1122
1317
  ),
1123
1318
  ),
1124
1319
  ...Array.from(
@@ -1130,32 +1325,49 @@ export function emulsifyTwigModulePlugin(options) {
1130
1325
  )}, (context = {}) => __emulsifyTemplate.render(context));`,
1131
1326
  ),
1132
1327
  ].join('\n');
1133
- const sourceTemplateRegistrations = [
1134
- ...dependencyTemplateRecords.flatMap(
1135
- ({ dependencyPath, compiledDependency }) =>
1136
- makeSourceTemplateRegistrations(
1137
- dependencyPath,
1138
- compiledDependency,
1139
- ),
1140
- ),
1141
- ...makeSourceTemplateRegistrations(sourceFilePath, compiled),
1142
- ].join('\n');
1328
+ const sourceTemplateRegistrations = (
1329
+ await Promise.all([
1330
+ ...dependencyTemplateRecords.map(
1331
+ ({ dependencyPath, compiledDependency }) =>
1332
+ makeSourceTemplateRegistrations(
1333
+ dependencyPath,
1334
+ compiledDependency,
1335
+ ),
1336
+ ),
1337
+ makeSourceTemplateRegistrations(sourceFilePath, compiled),
1338
+ ])
1339
+ )
1340
+ .flat()
1341
+ .join('\n');
1143
1342
  const renderErrorPrefix = JSON.stringify(
1144
1343
  `An error occurred whilst rendering ${toPosixPath(filePath)}: `,
1145
1344
  );
1146
1345
  const moduleCode = `
1346
+ ${dependencyTemplateImports}
1147
1347
  import { factory } from 'twig';
1148
1348
  import { registerTwigExtensions } from '@emulsify/core/extensions/twig';
1149
- import { registerConfiguredTwigExtensions } from 'virtual:emulsify-twig-extension-installers';
1349
+ import { installProjectTwigExtensions } from 'virtual:emulsify-twig-extension-installers';
1150
1350
  import { createTwigIncludeFunction } from '@emulsify/core/storybook/twig/include-function';
1151
1351
  import { createTwigSourceFunction } from '@emulsify/core/storybook/twig/source-function';
1152
1352
 
1153
1353
  const Twig = factory();
1154
1354
  registerTwigExtensions(Twig);
1155
- registerConfiguredTwigExtensions(Twig);
1355
+ installProjectTwigExtensions(Twig);
1356
+ Twig.extend((TwigCore) => {
1357
+ TwigCore.Templates.registerLoader(
1358
+ 'emulsify',
1359
+ (location, params = {}) => {
1360
+ const templateName = params.path || params.id || location;
1361
+ throw new TwigCore.Error(
1362
+ 'Unable to find template ' + templateName + '.',
1363
+ );
1364
+ },
1365
+ );
1366
+ });
1156
1367
 
1157
1368
  ${dependencyTemplateCode}
1158
1369
  const __emulsifyTemplate = ${compiled.code};
1370
+ __emulsifyTemplate.method = 'emulsify';
1159
1371
  const __emulsifyIncludeTemplates = new Map();
1160
1372
  const __emulsifySourceTemplates = new Map();
1161
1373
  ${includeTemplateRegistrations}
@@ -1192,21 +1404,55 @@ export function emulsifyTwigModulePlugin(options) {
1192
1404
  }
1193
1405
  },
1194
1406
  handleHotUpdate({ file, server }) {
1195
- if (!file.endsWith('.twig')) {
1407
+ const filePath = resolve(file);
1408
+ const componentRoot = options.namespaces?.components
1409
+ ? resolve(options.namespaces.components)
1410
+ : null;
1411
+ const cachedComponentRoots = componentRoot
1412
+ ? componentGroupRootsCache.get(componentRoot)
1413
+ : undefined;
1414
+ let fileIsDirectory = false;
1415
+
1416
+ try {
1417
+ fileIsDirectory = fs.statSync(filePath).isDirectory();
1418
+ } catch {
1419
+ // Removed paths cannot be inspected.
1420
+ }
1421
+
1422
+ const componentDirectoryChanged =
1423
+ !!componentRoot &&
1424
+ isWithinRoot(componentRoot, filePath) &&
1425
+ (fileIsDirectory || cachedComponentRoots?.includes(filePath));
1426
+ if (componentDirectoryChanged) {
1427
+ componentGroupRootsCache.delete(componentRoot);
1428
+ resolutionCache.clear();
1429
+ compileCache.clear();
1430
+ }
1431
+
1432
+ if (!file.endsWith('.twig') && !componentDirectoryChanged) {
1196
1433
  return undefined;
1197
1434
  }
1198
1435
 
1199
- const filePath = resolve(file);
1200
1436
  const fileExists = safeExists(filePath);
1201
1437
  const knownFile = knownTwigFiles.has(filePath);
1202
- compileCache.delete(filePath);
1438
+ const dependencyModuleId = resolvedVirtualTwigDependencyId(filePath);
1203
1439
  const importers = dependencyImporters.get(filePath);
1204
- if (!fileExists) {
1440
+ const projectRoot = options.projectDir || options.root;
1441
+ const projectPathChanged =
1442
+ !!projectRoot &&
1443
+ isWithinRoot(resolve(projectRoot), filePath) &&
1444
+ fileExists !== knownFile;
1445
+ const structuralChange = componentDirectoryChanged || projectPathChanged;
1446
+
1447
+ if (file.endsWith('.twig')) {
1448
+ compileCache.delete(filePath);
1449
+ }
1450
+ if (!fileExists && file.endsWith('.twig')) {
1205
1451
  dependencyImporters.delete(filePath);
1206
1452
  knownTwigFiles.delete(filePath);
1453
+ transformedTwigModules.delete(filePath);
1207
1454
  }
1208
1455
 
1209
- const projectRoot = options.projectDir || options.root;
1210
1456
  if (projectRoot && isWithinRoot(resolve(projectRoot), filePath)) {
1211
1457
  /**
1212
1458
  * Existing files only need entries for their own path and source
@@ -1220,26 +1466,52 @@ export function emulsifyTwigModulePlugin(options) {
1220
1466
  }
1221
1467
  }
1222
1468
 
1223
- if (!importers?.size) {
1469
+ if (
1470
+ componentRoot &&
1471
+ isWithinRoot(componentRoot, filePath) &&
1472
+ structuralChange
1473
+ ) {
1474
+ componentGroupRootsCache.delete(componentRoot);
1475
+ }
1476
+
1477
+ if (structuralChange) {
1478
+ compileCache.clear();
1479
+ }
1480
+
1481
+ const affectedImporters = new Set(importers || []);
1482
+ if (structuralChange) {
1483
+ for (const transformedModule of transformedTwigModules) {
1484
+ affectedImporters.add(transformedModule);
1485
+ }
1486
+ }
1487
+
1488
+ if (!affectedImporters.size) {
1224
1489
  return undefined;
1225
1490
  }
1226
1491
 
1227
- const modules = new Set(
1228
- server.moduleGraph.getModulesByFile(filePath) || [],
1229
- );
1230
- for (const importer of importers) {
1492
+ const moduleGraph = server?.moduleGraph;
1493
+ if (!moduleGraph?.getModulesByFile) {
1494
+ return undefined;
1495
+ }
1496
+
1497
+ const modules = new Set(moduleGraph.getModulesByFile(filePath) || []);
1498
+ const dependencyModule = moduleGraph.getModuleById?.(dependencyModuleId);
1499
+ if (dependencyModule) {
1500
+ moduleGraph.invalidateModule?.(dependencyModule);
1501
+ modules.add(dependencyModule);
1502
+ }
1503
+ for (const importer of affectedImporters) {
1231
1504
  compileCache.delete(importer);
1232
1505
 
1233
- const importerModules =
1234
- server.moduleGraph.getModulesByFile(importer) || [];
1506
+ const importerModules = moduleGraph.getModulesByFile(importer) || [];
1235
1507
 
1236
1508
  for (const module of importerModules) {
1237
- server.moduleGraph.invalidateModule(module);
1509
+ moduleGraph.invalidateModule?.(module);
1238
1510
  modules.add(module);
1239
1511
  }
1240
1512
  }
1241
1513
 
1242
- return Array.from(modules);
1514
+ return modules.size ? Array.from(modules) : undefined;
1243
1515
  },
1244
1516
  };
1245
1517
  }