@emulsify/core 4.2.0 → 4.2.1

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.
@@ -74,6 +74,13 @@ const compileCache = new Map();
74
74
  */
75
75
  const resolutionCache = new Map();
76
76
 
77
+ /**
78
+ * Cache recursively discovered component grouping directories by root.
79
+ *
80
+ * @type {Map<string, string[]>}
81
+ */
82
+ const componentGroupRootsCache = new Map();
83
+
77
84
  /**
78
85
  * Track Twig files that have been seen during this build/session.
79
86
  *
@@ -355,6 +362,43 @@ const isWithinRoot = (root, filePath) => {
355
362
  );
356
363
  };
357
364
 
365
+ /**
366
+ * Return the first component template candidate contained by its configured root.
367
+ *
368
+ * Both lexical and real paths are checked so `..` segments and symlinks cannot
369
+ * escape the component root.
370
+ *
371
+ * @param {string[]} paths - Candidate absolute paths.
372
+ * @param {string} componentRoot - Absolute component root path.
373
+ * @returns {string|undefined} Existing component template path.
374
+ */
375
+ const findExistingComponentTemplateFile = (paths, componentRoot) => {
376
+ const absoluteRoot = resolve(componentRoot);
377
+ let realRoot;
378
+
379
+ try {
380
+ realRoot = fs.realpathSync(absoluteRoot);
381
+ } catch {
382
+ return undefined;
383
+ }
384
+
385
+ return paths.filter(Boolean).find((filePath) => {
386
+ const absoluteFilePath = resolve(filePath);
387
+ if (!isWithinRoot(absoluteRoot, absoluteFilePath)) {
388
+ return false;
389
+ }
390
+
391
+ try {
392
+ return (
393
+ fs.statSync(absoluteFilePath).isFile() &&
394
+ isWithinRoot(realRoot, fs.realpathSync(absoluteFilePath))
395
+ );
396
+ } catch {
397
+ return false;
398
+ }
399
+ });
400
+ };
401
+
358
402
  /**
359
403
  * Find the most specific configured Twig root for a template file.
360
404
  *
@@ -511,7 +555,11 @@ const parseTwigNamespaceReference = (templatePath, namespaces = {}) => {
511
555
  };
512
556
 
513
557
  /**
514
- * Return immediate directory roots that may group component folders.
558
+ * Return grouping directories below the configured component root.
559
+ *
560
+ * Breadth-first traversal preserves direct and one-level behavior before
561
+ * searching deeper groups. Siblings use code-point order so duplicate
562
+ * shorthand names resolve consistently across filesystems.
515
563
  *
516
564
  * @param {string} componentRoot - Absolute component root path.
517
565
  * @returns {string[]} Absolute grouping directory paths.
@@ -519,33 +567,57 @@ const parseTwigNamespaceReference = (templatePath, namespaces = {}) => {
519
567
  const componentGroupRoots = (componentRoot) => {
520
568
  if (!componentRoot) return [];
521
569
 
522
- try {
523
- // Component group roots come from a configured project directory.
524
- return fs
525
- .readdirSync(componentRoot, { withFileTypes: true })
570
+ const absoluteRoot = resolve(componentRoot);
571
+ if (componentGroupRootsCache.has(absoluteRoot)) {
572
+ return componentGroupRootsCache.get(absoluteRoot);
573
+ }
574
+
575
+ const groupRoots = [];
576
+ const pendingDirectories = [absoluteRoot];
577
+
578
+ for (let index = 0; index < pendingDirectories.length; index += 1) {
579
+ const directory = pendingDirectories[index];
580
+ let entries;
581
+
582
+ try {
583
+ entries = fs.readdirSync(directory, { withFileTypes: true });
584
+ } catch {
585
+ continue;
586
+ }
587
+
588
+ const childDirectories = entries
526
589
  .filter((entry) => entry.isDirectory())
527
- .map((entry) => resolve(componentRoot, entry.name));
528
- } catch {
529
- return [];
590
+ .sort(({ name: left }, { name: right }) =>
591
+ left === right ? 0 : left < right ? -1 : 1,
592
+ )
593
+ .map((entry) => resolve(directory, entry.name))
594
+ .filter((childDirectory) => isWithinRoot(absoluteRoot, childDirectory));
595
+
596
+ groupRoots.push(...childDirectories);
597
+ pendingDirectories.push(...childDirectories);
530
598
  }
599
+
600
+ componentGroupRootsCache.set(absoluteRoot, groupRoots);
601
+ return groupRoots;
531
602
  };
532
603
 
533
604
  /**
534
- * Resolve a component reference through one grouping directory level.
605
+ * Resolve a component reference through recursively grouped directories.
535
606
  *
536
607
  * Project-scoped component IDs can use the component name (`project:button`)
537
- * even when projects organize components under grouping directories such as
538
- * `ui`.
608
+ * even when projects organize components under grouping paths such as
609
+ * `atoms/text`.
539
610
  *
540
611
  * @param {string} templatePath - Component-relative template reference.
541
612
  * @param {string} componentRoot - Absolute component root path.
542
613
  * @returns {string|null} Existing template path when found.
543
614
  */
544
615
  const resolveGroupedComponentTemplate = (templatePath, componentRoot) =>
545
- findExistingTemplateFile(
616
+ findExistingComponentTemplateFile(
546
617
  componentGroupRoots(componentRoot).flatMap((groupRoot) =>
547
618
  buildTemplateFileCandidates(groupRoot, templatePath),
548
619
  ),
620
+ componentRoot,
549
621
  ) || null;
550
622
 
551
623
  /**
@@ -562,8 +634,9 @@ const resolveComponentShorthandReference = (templatePath, componentRoot) => {
562
634
  templatePath.startsWith('@') && !templatePath.includes('/')
563
635
  ? templatePath.slice(1)
564
636
  : templatePath;
565
- const directComponentPath = findExistingTemplateFile(
637
+ const directComponentPath = findExistingComponentTemplateFile(
566
638
  buildTemplateFileCandidates(componentRoot, shorthandPath),
639
+ componentRoot,
567
640
  );
568
641
  if (directComponentPath) {
569
642
  return directComponentPath;
@@ -577,8 +650,9 @@ const resolveComponentShorthandReference = (templatePath, componentRoot) => {
577
650
  const genericComponentPath = genericNamespace[1];
578
651
 
579
652
  return (
580
- findExistingTemplateFile(
653
+ findExistingComponentTemplateFile(
581
654
  buildTemplateFileCandidates(componentRoot, genericComponentPath),
655
+ componentRoot,
582
656
  ) || resolveGroupedComponentTemplate(genericComponentPath, componentRoot)
583
657
  );
584
658
  };
@@ -614,9 +688,15 @@ const resolveTwigTemplateWithoutCache = (templatePath, fromDir, options) => {
614
688
  options.namespaces,
615
689
  );
616
690
  if (namespaced) {
617
- const namespacedTemplate = findExistingTemplateFile(
618
- buildTemplateFileCandidates(namespaced.root, namespaced.path),
619
- );
691
+ const namespacedTemplate =
692
+ namespaced.namespace === 'components'
693
+ ? findExistingComponentTemplateFile(
694
+ buildTemplateFileCandidates(namespaced.root, namespaced.path),
695
+ namespaced.root,
696
+ )
697
+ : findExistingTemplateFile(
698
+ buildTemplateFileCandidates(namespaced.root, namespaced.path),
699
+ );
620
700
  if (namespacedTemplate) {
621
701
  return namespacedTemplate;
622
702
  }
@@ -914,6 +994,16 @@ export function emulsifyTwigModulePlugin(options) {
914
994
  */
915
995
  const dependencyImporters = new Map();
916
996
 
997
+ /**
998
+ * Twig entry modules transformed by this plugin instance.
999
+ *
1000
+ * Structural component changes invalidate these modules because a new or
1001
+ * removed directory can change the target of a shorthand reference.
1002
+ *
1003
+ * @type {Set<string>}
1004
+ */
1005
+ const transformedTwigModules = new Set();
1006
+
917
1007
  /**
918
1008
  * Remember that one imported Twig module depends on another Twig file.
919
1009
  *
@@ -948,7 +1038,9 @@ export function emulsifyTwigModulePlugin(options) {
948
1038
  buildStart() {
949
1039
  compileCache.clear();
950
1040
  resolutionCache.clear();
1041
+ componentGroupRootsCache.clear();
951
1042
  knownTwigFiles.clear();
1043
+ transformedTwigModules.clear();
952
1044
  },
953
1045
  transform(...args) {
954
1046
  const [, id] = args;
@@ -958,6 +1050,7 @@ export function emulsifyTwigModulePlugin(options) {
958
1050
 
959
1051
  const filePath = stripRequestQuery(id);
960
1052
  const sourceFilePath = resolve(filePath);
1053
+ transformedTwigModules.add(sourceFilePath);
961
1054
  /** @type {Map<string, ReturnType<typeof compileTwigTemplate>>} */
962
1055
  const compiledDependencyTemplates = new Map();
963
1056
  /** @type {Map<string, Set<string>>} */
@@ -1106,6 +1199,7 @@ export function emulsifyTwigModulePlugin(options) {
1106
1199
  compiledDependency.templateId,
1107
1200
  compiledDependency.templateParams,
1108
1201
  )};
1202
+ ${variableName}.method = 'emulsify';
1109
1203
  `,
1110
1204
  )
1111
1205
  .join('\n');
@@ -1153,9 +1247,21 @@ export function emulsifyTwigModulePlugin(options) {
1153
1247
  const Twig = factory();
1154
1248
  registerTwigExtensions(Twig);
1155
1249
  registerConfiguredTwigExtensions(Twig);
1250
+ Twig.extend((TwigCore) => {
1251
+ TwigCore.Templates.registerLoader(
1252
+ 'emulsify',
1253
+ (location, params = {}) => {
1254
+ const templateName = params.path || params.id || location;
1255
+ throw new TwigCore.Error(
1256
+ 'Unable to find template ' + templateName + '.',
1257
+ );
1258
+ },
1259
+ );
1260
+ });
1156
1261
 
1157
1262
  ${dependencyTemplateCode}
1158
1263
  const __emulsifyTemplate = ${compiled.code};
1264
+ __emulsifyTemplate.method = 'emulsify';
1159
1265
  const __emulsifyIncludeTemplates = new Map();
1160
1266
  const __emulsifySourceTemplates = new Map();
1161
1267
  ${includeTemplateRegistrations}
@@ -1192,21 +1298,54 @@ export function emulsifyTwigModulePlugin(options) {
1192
1298
  }
1193
1299
  },
1194
1300
  handleHotUpdate({ file, server }) {
1195
- if (!file.endsWith('.twig')) {
1301
+ const filePath = resolve(file);
1302
+ const componentRoot = options.namespaces?.components
1303
+ ? resolve(options.namespaces.components)
1304
+ : null;
1305
+ const cachedComponentRoots = componentRoot
1306
+ ? componentGroupRootsCache.get(componentRoot)
1307
+ : undefined;
1308
+ let fileIsDirectory = false;
1309
+
1310
+ try {
1311
+ fileIsDirectory = fs.statSync(filePath).isDirectory();
1312
+ } catch {
1313
+ // Removed paths cannot be inspected.
1314
+ }
1315
+
1316
+ const componentDirectoryChanged =
1317
+ !!componentRoot &&
1318
+ isWithinRoot(componentRoot, filePath) &&
1319
+ (fileIsDirectory || cachedComponentRoots?.includes(filePath));
1320
+ if (componentDirectoryChanged) {
1321
+ componentGroupRootsCache.delete(componentRoot);
1322
+ resolutionCache.clear();
1323
+ compileCache.clear();
1324
+ }
1325
+
1326
+ if (!file.endsWith('.twig') && !componentDirectoryChanged) {
1196
1327
  return undefined;
1197
1328
  }
1198
1329
 
1199
- const filePath = resolve(file);
1200
1330
  const fileExists = safeExists(filePath);
1201
1331
  const knownFile = knownTwigFiles.has(filePath);
1202
- compileCache.delete(filePath);
1203
1332
  const importers = dependencyImporters.get(filePath);
1204
- if (!fileExists) {
1333
+ const projectRoot = options.projectDir || options.root;
1334
+ const projectPathChanged =
1335
+ !!projectRoot &&
1336
+ isWithinRoot(resolve(projectRoot), filePath) &&
1337
+ fileExists !== knownFile;
1338
+ const structuralChange = componentDirectoryChanged || projectPathChanged;
1339
+
1340
+ if (file.endsWith('.twig')) {
1341
+ compileCache.delete(filePath);
1342
+ }
1343
+ if (!fileExists && file.endsWith('.twig')) {
1205
1344
  dependencyImporters.delete(filePath);
1206
1345
  knownTwigFiles.delete(filePath);
1346
+ transformedTwigModules.delete(filePath);
1207
1347
  }
1208
1348
 
1209
- const projectRoot = options.projectDir || options.root;
1210
1349
  if (projectRoot && isWithinRoot(resolve(projectRoot), filePath)) {
1211
1350
  /**
1212
1351
  * Existing files only need entries for their own path and source
@@ -1220,26 +1359,47 @@ export function emulsifyTwigModulePlugin(options) {
1220
1359
  }
1221
1360
  }
1222
1361
 
1223
- if (!importers?.size) {
1362
+ if (
1363
+ componentRoot &&
1364
+ isWithinRoot(componentRoot, filePath) &&
1365
+ structuralChange
1366
+ ) {
1367
+ componentGroupRootsCache.delete(componentRoot);
1368
+ }
1369
+
1370
+ if (structuralChange) {
1371
+ compileCache.clear();
1372
+ }
1373
+
1374
+ const affectedImporters = new Set(importers || []);
1375
+ if (structuralChange) {
1376
+ for (const transformedModule of transformedTwigModules) {
1377
+ affectedImporters.add(transformedModule);
1378
+ }
1379
+ }
1380
+
1381
+ if (!affectedImporters.size) {
1224
1382
  return undefined;
1225
1383
  }
1226
1384
 
1227
- const modules = new Set(
1228
- server.moduleGraph.getModulesByFile(filePath) || [],
1229
- );
1230
- for (const importer of importers) {
1385
+ const moduleGraph = server?.moduleGraph;
1386
+ if (!moduleGraph?.getModulesByFile) {
1387
+ return undefined;
1388
+ }
1389
+
1390
+ const modules = new Set(moduleGraph.getModulesByFile(filePath) || []);
1391
+ for (const importer of affectedImporters) {
1231
1392
  compileCache.delete(importer);
1232
1393
 
1233
- const importerModules =
1234
- server.moduleGraph.getModulesByFile(importer) || [];
1394
+ const importerModules = moduleGraph.getModulesByFile(importer) || [];
1235
1395
 
1236
1396
  for (const module of importerModules) {
1237
- server.moduleGraph.invalidateModule(module);
1397
+ moduleGraph.invalidateModule?.(module);
1238
1398
  modules.add(module);
1239
1399
  }
1240
1400
  }
1241
1401
 
1242
- return Array.from(modules);
1402
+ return modules.size ? Array.from(modules) : undefined;
1243
1403
  },
1244
1404
  };
1245
1405
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emulsify/core",
3
- "version": "4.2.0",
3
+ "version": "4.2.1",
4
4
  "description": "Bundled tooling for Storybook development + Vite Build",
5
5
  "keywords": [
6
6
  "component library",
@@ -91,9 +91,26 @@ function findGroupedComponentEntry(map, candidates, env) {
91
91
 
92
92
  for (const { rootRel, suffix } of groupedComponentSuffixes(candidates, env)) {
93
93
  const rootPrefix = `${rootRel}/`;
94
- const match = entries.find(
95
- ([key]) => key.startsWith(rootPrefix) && key.endsWith(suffix),
96
- );
94
+ const matches = entries
95
+ .filter(([key]) => key.startsWith(rootPrefix) && key.endsWith(suffix))
96
+ .sort(([leftKey], [rightKey]) => {
97
+ const leftGroupingPath = leftKey.slice(
98
+ rootPrefix.length,
99
+ -suffix.length,
100
+ );
101
+ const rightGroupingPath = rightKey.slice(
102
+ rootPrefix.length,
103
+ -suffix.length,
104
+ );
105
+ const leftDepth = leftGroupingPath.split('/').filter(Boolean).length;
106
+ const rightDepth = rightGroupingPath.split('/').filter(Boolean).length;
107
+
108
+ if (leftDepth !== rightDepth) {
109
+ return leftDepth - rightDepth;
110
+ }
111
+ return leftKey === rightKey ? 0 : leftKey < rightKey ? -1 : 1;
112
+ });
113
+ const match = matches[0];
97
114
  if (match) {
98
115
  return { key: match[0], value: match[1] };
99
116
  }