@emulsify/core 4.4.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,6 +50,11 @@ See [Version Evolution](docs/version-evolution.md) for major-version history
50
50
  and the [4.3.0 release notes](docs/releases/4.3.0.md) for the compatibility
51
51
  changes and additions in that release.
52
52
 
53
+ The [compatibility and support policy](docs/version-evolution.md#compatibility-and-support-policy)
54
+ separates current requirements and the existing 4.x compatibility rule from
55
+ maintenance promises. Pending decisions live in the
56
+ [maintainer register](docs/maintainer-decisions.md).
57
+
53
58
  ## Authoring Models
54
59
 
55
60
  Emulsify Core supports Twig and React authoring workflows plus a focused
@@ -269,7 +274,11 @@ Do not add comments to JSON files, lockfiles, binary assets, generated output,
269
274
  legal documents, or dependency files. Those formats either do not support
270
275
  comments or should remain exact artifacts.
271
276
 
272
- Please also follow the issue template and pull request templates provided. See below for the correct places to post issues:
277
+ Please also follow the issue template and pull request templates provided.
278
+ The issue links below are for ordinary bugs and feature requests, not
279
+ confidential vulnerability details. See the
280
+ [security-reporting status](docs/maintainer-decisions.md#security-reporting-for-older-lines)
281
+ before preparing a security report.
273
282
 
274
283
  1. [Emulsify Drupal](https://github.com/emulsify-ds/emulsify-drupal/issues)
275
284
  2. [Emulsify Tools (Drupal module)](https://www.drupal.org/project/issues/emulsify_tools)
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Optional additions to Pa11y's axe rules for WCAG 2.2 level A and AA.
3
+ * The reviewed Pa11y axe-core 4.11.4 and Core root axe-core 4.13.0 each expose
4
+ * target-size as their only wcag22a/wcag22aa rule.
5
+ * Keep this reviewed list explicit instead of enabling future rules implicitly.
6
+ */
7
+ export default {
8
+ pa11y: {
9
+ rules: ['target-size'],
10
+ },
11
+ };
@@ -9,16 +9,7 @@ import { readdirSync } from 'fs';
9
9
  import { join, relative, sep } from 'path';
10
10
 
11
11
  import { relativeFrom } from '../../project-structure.js';
12
-
13
- const DEFAULT_SKIP_DIRS = [
14
- 'node_modules',
15
- '.git',
16
- '.cache',
17
- '.vite',
18
- '.out',
19
- '.coverage',
20
- 'dist',
21
- ];
12
+ import { DEFAULT_SKIP_DIRS } from '../../utils/source-directory-skips.js';
22
13
 
23
14
  /**
24
15
  * Depth-first walk to list every file under a given root.
@@ -91,13 +82,16 @@ export const isComponentMetadataFile = (filePath) =>
91
82
 
92
83
  /**
93
84
  * Determine whether a file should be copied by the static asset pass.
85
+ * Server-side and uncompiled module sources can share roots with static assets
86
+ * without being published by this pass. Unknown non-code extensions still copy.
94
87
  *
95
88
  * @param {string} filePath - Absolute or relative file path.
96
89
  * @returns {boolean} TRUE for non-code source assets.
97
90
  */
98
91
  export const isStaticSourceAsset = (filePath) =>
99
- !/\.(jsx?|scss|twig|map)$/i.test(filePath) &&
100
- !isComponentMetadataFile(filePath);
92
+ !/\.([cm]?[jt]sx?|scss|twig|map|php\d?|phtml|inc|module|theme|install|profile|engine)$/i.test(
93
+ filePath,
94
+ ) && !isComponentMetadataFile(filePath);
101
95
 
102
96
  /**
103
97
  * Build the roots that should not be crawled during a global source pass.
@@ -11,7 +11,7 @@
11
11
 
12
12
  import fs from 'fs';
13
13
  import { readFile, stat } from 'fs/promises';
14
- import { basename, dirname, isAbsolute, relative, resolve } from 'path';
14
+ import { basename, dirname, relative, resolve } from 'path';
15
15
  import Twig from 'twig';
16
16
 
17
17
  import {
@@ -27,6 +27,12 @@ import {
27
27
  import { firstExistingPath, safeExists } from '../../utils/fs-safe.js';
28
28
  import { createLruCache } from '../../utils/lru.js';
29
29
  import { toPosixPath } from '../../utils/paths.js';
30
+ import {
31
+ buildTemplateFileCandidates,
32
+ isWithinRoot,
33
+ parseTwigNamespaceReference,
34
+ resolveComponentReference,
35
+ } from '../../utils/twig-component-resolver.js';
30
36
  import { unique } from '../../../../src/extensions/shared/lists.js';
31
37
 
32
38
  /** Twig token types that can reference another template file. */
@@ -338,29 +344,6 @@ const collectStaticSourceReferences = (tokens = []) => [
338
344
  ),
339
345
  ];
340
346
 
341
- /**
342
- * Build likely filesystem candidates for a Twig template reference.
343
- *
344
- * @param {string} baseDir - Directory used as the resolution root.
345
- * @param {string} templatePath - Template path from Twig source.
346
- * @returns {string[]} Candidate absolute paths.
347
- */
348
- const buildTemplateFileCandidates = (baseDir, templatePath) => {
349
- const normalizedTemplatePath = toPosixPath(templatePath);
350
- const withoutTwigExt = normalizedTemplatePath.replace(/\.twig$/i, '');
351
- const stem = basename(withoutTwigExt);
352
-
353
- return unique(
354
- [
355
- resolve(baseDir, normalizedTemplatePath),
356
- resolve(baseDir, `${normalizedTemplatePath}.twig`),
357
- resolve(baseDir, `${normalizedTemplatePath}.html.twig`),
358
- resolve(baseDir, withoutTwigExt, `${stem}.twig`),
359
- resolve(baseDir, withoutTwigExt, `${stem}.html.twig`),
360
- ].filter(Boolean),
361
- );
362
- };
363
-
364
347
  /**
365
348
  * Return the first candidate that exists as a file.
366
349
  *
@@ -376,60 +359,6 @@ const findExistingTemplateFile = (paths) =>
376
359
  }
377
360
  });
378
361
 
379
- /**
380
- * Determine whether a file path is equal to or below a candidate root.
381
- *
382
- * @param {string} root - Absolute root path.
383
- * @param {string} filePath - Absolute file path.
384
- * @returns {boolean} TRUE when the file belongs to the root.
385
- */
386
- const isWithinRoot = (root, filePath) => {
387
- const rootRelativePath = relative(root, filePath);
388
- return (
389
- rootRelativePath === '' ||
390
- (!!rootRelativePath &&
391
- !rootRelativePath.startsWith('..') &&
392
- !isAbsolute(rootRelativePath))
393
- );
394
- };
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
-
433
362
  /**
434
363
  * Find the most specific configured Twig root for a template file.
435
364
  *
@@ -548,161 +477,6 @@ const rewriteTemplateReferencesToIds = (tokens = [], fromFilePath, options) =>
548
477
  return nextToken;
549
478
  });
550
479
 
551
- /**
552
- * Resolve Twig namespace syntax to a namespace root and relative path.
553
- *
554
- * @param {string} templatePath - Template reference from Twig source.
555
- * @param {Record<string, string>} [namespaces={}] - Namespace root map.
556
- * @returns {{ namespace: string, root: string, path: string }|null}
557
- * Namespace lookup result.
558
- */
559
- const parseTwigNamespaceReference = (templatePath, namespaces = {}) => {
560
- const namespaceNames = Object.keys(namespaces);
561
- const atNamespace = templatePath.match(/^@([^/]+)\/(.+)$/);
562
- if (atNamespace && namespaces[atNamespace[1]]) {
563
- return {
564
- namespace: atNamespace[1],
565
- root: namespaces[atNamespace[1]],
566
- path: atNamespace[2],
567
- };
568
- }
569
-
570
- const doubleColon = templatePath.match(/^([^:]+)::(.+)$/);
571
- if (doubleColon && namespaces[doubleColon[1]]) {
572
- return {
573
- namespace: doubleColon[1],
574
- root: namespaces[doubleColon[1]],
575
- path: doubleColon[2],
576
- };
577
- }
578
-
579
- const singleColon = templatePath.match(/^([^:/.]+):(.+)$/);
580
- if (singleColon && namespaces[singleColon[1]]) {
581
- return {
582
- namespace: singleColon[1],
583
- root: namespaces[singleColon[1]],
584
- path: singleColon[2],
585
- };
586
- }
587
-
588
- const slashNamespace = namespaceNames.find((namespace) =>
589
- templatePath.startsWith(`${namespace}/`),
590
- );
591
- if (slashNamespace) {
592
- return {
593
- namespace: slashNamespace,
594
- // Namespace names come from the normalized Twig namespace map.
595
- root: namespaces[slashNamespace],
596
- path: templatePath.slice(slashNamespace.length + 1),
597
- };
598
- }
599
-
600
- return null;
601
- };
602
-
603
- /**
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.
609
- *
610
- * @param {string} componentRoot - Absolute component root path.
611
- * @returns {string[]} Absolute grouping directory paths.
612
- */
613
- const componentGroupRoots = (componentRoot) => {
614
- if (!componentRoot) return [];
615
-
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
635
- .filter((entry) => entry.isDirectory())
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);
644
- }
645
-
646
- componentGroupRootsCache.set(absoluteRoot, groupRoots);
647
- return groupRoots;
648
- };
649
-
650
- /**
651
- * Resolve a component reference through recursively grouped directories.
652
- *
653
- * Project-scoped component IDs can use the component name (`project:button`)
654
- * even when projects organize components under grouping paths such as
655
- * `atoms/text`.
656
- *
657
- * @param {string} templatePath - Component-relative template reference.
658
- * @param {string} componentRoot - Absolute component root path.
659
- * @returns {string|null} Existing template path when found.
660
- */
661
- const resolveGroupedComponentTemplate = (templatePath, componentRoot) =>
662
- findExistingComponentTemplateFile(
663
- componentGroupRoots(componentRoot).flatMap((groupRoot) =>
664
- buildTemplateFileCandidates(groupRoot, templatePath),
665
- ),
666
- componentRoot,
667
- ) || null;
668
-
669
- /**
670
- * Resolve shorthand component references against the components namespace.
671
- *
672
- * @param {string} templatePath - Template reference from Twig source.
673
- * @param {string} componentRoot - Absolute component root path.
674
- * @returns {string|null} Existing template path when found.
675
- */
676
- const resolveComponentShorthandReference = (templatePath, componentRoot) => {
677
- if (!componentRoot || templatePath.startsWith('.')) return null;
678
-
679
- const shorthandPath =
680
- templatePath.startsWith('@') && !templatePath.includes('/')
681
- ? templatePath.slice(1)
682
- : templatePath;
683
- const directComponentPath = findExistingComponentTemplateFile(
684
- buildTemplateFileCandidates(componentRoot, shorthandPath),
685
- componentRoot,
686
- );
687
- if (directComponentPath) {
688
- return directComponentPath;
689
- }
690
-
691
- const genericNamespace = templatePath.match(/^@?[^/:]+[:/](.+)$/);
692
- if (!genericNamespace) {
693
- return null;
694
- }
695
-
696
- const genericComponentPath = genericNamespace[1];
697
-
698
- return (
699
- findExistingComponentTemplateFile(
700
- buildTemplateFileCandidates(componentRoot, genericComponentPath),
701
- componentRoot,
702
- ) || resolveGroupedComponentTemplate(genericComponentPath, componentRoot)
703
- );
704
- };
705
-
706
480
  /**
707
481
  * Build a stable key segment for include resolution cache entries.
708
482
  *
@@ -734,27 +508,19 @@ const resolveTwigTemplateWithoutCache = (templatePath, fromDir, options) => {
734
508
  options.namespaces,
735
509
  );
736
510
  if (namespaced) {
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
- );
746
- if (namespacedTemplate) {
747
- return namespacedTemplate;
748
- }
749
-
750
511
  if (namespaced.namespace === 'components') {
751
- return resolveGroupedComponentTemplate(
752
- namespaced.path,
753
- options.namespaces?.components,
512
+ return resolveComponentReference(
513
+ templatePath,
514
+ options.namespaces,
515
+ componentGroupRootsCache,
754
516
  );
755
517
  }
756
518
 
757
- return null;
519
+ return (
520
+ findExistingTemplateFile(
521
+ buildTemplateFileCandidates(namespaced.root, namespaced.path),
522
+ ) || null
523
+ );
758
524
  }
759
525
 
760
526
  const relativeTemplate = findExistingTemplateFile([
@@ -764,9 +530,10 @@ const resolveTwigTemplateWithoutCache = (templatePath, fromDir, options) => {
764
530
 
765
531
  return (
766
532
  relativeTemplate ||
767
- resolveComponentShorthandReference(
533
+ resolveComponentReference(
768
534
  templatePath,
769
- options.namespaces?.components,
535
+ options.namespaces,
536
+ componentGroupRootsCache,
770
537
  )
771
538
  );
772
539
  };
@@ -996,7 +763,7 @@ export function makeTwigPluginOptions(env) {
996
763
  namespaces: makeTwigNamespaces(env),
997
764
  functions: getTwigFunctionMap(),
998
765
  registerDrupalTwigFilters: shouldRegisterDrupalTwigFilters(env),
999
- // Twig updates are handled by emulsifyTwigModulePlugin.handleHotUpdate.
766
+ // Twig updates are handled by emulsifyTwigModulePlugin.hotUpdate.
1000
767
  // Vituum's full reload would defeat HMR by reloading the whole iframe on
1001
768
  // every Twig save before module graph invalidation can update the story.
1002
769
  reload: () => false,
@@ -1403,7 +1170,7 @@ export function emulsifyTwigModulePlugin(options) {
1403
1170
  };
1404
1171
  }
1405
1172
  },
1406
- handleHotUpdate({ file, server }) {
1173
+ hotUpdate({ type, file, modules: changedModules = [] }) {
1407
1174
  const filePath = resolve(file);
1408
1175
  const componentRoot = options.namespaces?.components
1409
1176
  ? resolve(options.namespaces.components)
@@ -1442,7 +1209,11 @@ export function emulsifyTwigModulePlugin(options) {
1442
1209
  !!projectRoot &&
1443
1210
  isWithinRoot(resolve(projectRoot), filePath) &&
1444
1211
  fileExists !== knownFile;
1445
- const structuralChange = componentDirectoryChanged || projectPathChanged;
1212
+ const structuralChange =
1213
+ type === 'create' ||
1214
+ type === 'delete' ||
1215
+ componentDirectoryChanged ||
1216
+ projectPathChanged;
1446
1217
 
1447
1218
  if (file.endsWith('.twig')) {
1448
1219
  compileCache.delete(filePath);
@@ -1459,7 +1230,7 @@ export function emulsifyTwigModulePlugin(options) {
1459
1230
  * directory invalidated. New or deleted files can change previous
1460
1231
  * resolution misses, so those events clear the full resolution cache.
1461
1232
  */
1462
- if (fileExists && knownFile) {
1233
+ if (type === 'update' && fileExists && knownFile) {
1463
1234
  invalidateKnownResolutionCacheEntries(filePath);
1464
1235
  } else {
1465
1236
  resolutionCache.clear();
@@ -1475,6 +1246,9 @@ export function emulsifyTwigModulePlugin(options) {
1475
1246
  }
1476
1247
 
1477
1248
  if (structuralChange) {
1249
+ // Every environment receives the event, including after shared known-
1250
+ // file state was cleared. Namespace roots can also be outside a project.
1251
+ resolutionCache.clear();
1478
1252
  compileCache.clear();
1479
1253
  }
1480
1254
 
@@ -1489,12 +1263,15 @@ export function emulsifyTwigModulePlugin(options) {
1489
1263
  return undefined;
1490
1264
  }
1491
1265
 
1492
- const moduleGraph = server?.moduleGraph;
1266
+ const moduleGraph = this.environment?.moduleGraph;
1493
1267
  if (!moduleGraph?.getModulesByFile) {
1494
1268
  return undefined;
1495
1269
  }
1496
1270
 
1497
- const modules = new Set(moduleGraph.getModulesByFile(filePath) || []);
1271
+ const modules = new Set([
1272
+ ...changedModules,
1273
+ ...(moduleGraph.getModulesByFile(filePath) || []),
1274
+ ]);
1498
1275
  const dependencyModule = moduleGraph.getModuleById?.(dependencyModuleId);
1499
1276
  if (dependencyModule) {
1500
1277
  moduleGraph.invalidateModule?.(dependencyModule);
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @file Directory names excluded from source discovery and component grouping.
3
+ */
4
+
5
+ export const DEFAULT_SKIP_DIRS = [
6
+ 'node_modules',
7
+ '.git',
8
+ '.cache',
9
+ '.vite',
10
+ '.out',
11
+ '.coverage',
12
+ 'dist',
13
+ ];