@emulsify/core 4.1.1 → 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.
package/README.md CHANGED
@@ -34,7 +34,7 @@ See [Version Evolution](docs/version-evolution.md) for more release history.
34
34
 
35
35
  Twig and React are equally valid ways to build component libraries with Emulsify Core. The right authoring model depends on the consuming project:
36
36
 
37
- - Use Twig for CMS themes and server-rendered template systems. Drupal has a dedicated adapter today. WordPress and Timber projects should currently use `platform: "none"` unless a project adds its own platform-specific behavior.
37
+ - Use Twig for CMS themes and server-rendered template systems. Drupal has a Drupal-specific adapter, and WordPress/Timber projects can use the intentionally neutral `wordpress` adapter. WordPress runtime integration belongs in `emulsify-wordpress-theme`.
38
38
  - Use React for standalone UI libraries, application components, or projects that already use React.
39
39
  - Use mixed Twig and React when a design system needs to document both CMS-rendered and JavaScript-rendered components in the same Storybook instance.
40
40
 
@@ -85,7 +85,7 @@ The documentation is split by task:
85
85
  | [Component Authoring](docs/component-authoring.md) | Choosing Twig, React, or mixed Storybook authoring and comparing component examples. |
86
86
  | [Storybook](docs/storybook.md) | Rendering Twig stories, using `renderTwig()`, understanding Twig runtime helpers, and mixing Twig with React stories. |
87
87
  | [Project Structure And Output](docs/project-structure.md) | Configuring `src/components`, root `./components`, `variant.structureImplementations`, and expected output paths. |
88
- | [Platform Adapters](docs/platform-adapters.md) | Understanding `none`, `drupal`, platform resolution order, and Drupal SDC behavior. |
88
+ | [Platform Adapters](docs/platform-adapters.md) | Understanding `none`, `wordpress`, `drupal`, platform resolution order, and Drupal SDC behavior. |
89
89
  | [Extension Points](docs/extension-points.md) | Adding Vite plugins, Tailwind CSS, Storybook preview overrides, and other framework tooling. |
90
90
  | [Performance](docs/performance.md) | Understanding sourcemaps, eager Twig imports, Tailwind scanning, copied files, and fixture validation. |
91
91
  | [Native Twig Extensions](docs/native-twig-extensions.md) | Using `bem()`, `add_attributes()`, and `switch/case/default/endswitch` in Twig.js. |
@@ -94,24 +94,25 @@ The documentation is split by task:
94
94
 
95
95
  ## Known Limitations
96
96
 
97
- - Implemented platform adapters are currently `none` and `drupal`. WordPress and Timber projects should currently use `platform: "none"`. This keeps Emulsify Core in platform-neutral mode while still supporting Twig-oriented component development. A dedicated WordPress adapter may be added later when WordPress-specific behavior is introduced. See [Platform Adapters](docs/platform-adapters.md).
97
+ - Implemented platform adapters are `none`, `wordpress`, and `drupal`. The `wordpress` adapter is intentionally neutral: it supports Core Twig authoring, Storybook, Vite, `bem()`, `add_attributes()`, `include()`, and `source()`, but it does not emulate WordPress or Timber PHP runtime behavior. Runtime integration belongs in `emulsify-wordpress-theme`. See [Platform Adapters](docs/platform-adapters.md).
98
98
  - Storybook's Twig resolver eagerly imports Twig modules and raw Twig source. This is reliable for `include()` and `source()`, but large Twig libraries should keep Storybook source roots intentional. See [Performance](docs/performance.md).
99
99
  - Production sourcemaps are enabled by default unless a project overrides Vite config through `config/emulsify-core/vite/plugins.*`. See [Performance](docs/performance.md).
100
100
  - Project extensions use the public `config/emulsify-core` directory: `config/emulsify-core/vite/plugins.*` for Vite, `config/emulsify-core/storybook/...` for Storybook, and `config/emulsify-core/a11y.config.js` for a11y. See [Extension Points](docs/extension-points.md).
101
101
  - Webpack-specific customizations must be migrated manually to Vite plugins or `extendConfig()`. See [Migration](docs/migration-4x.md).
102
- - Drupal SDC mirroring only applies when the Drupal adapter and SDC settings are enabled. `none` projects should expect output to remain in `dist/`. See [Platform Adapters](docs/platform-adapters.md).
102
+ - Drupal SDC mirroring only applies when the Drupal adapter and SDC settings are enabled. `none` and `wordpress` projects should expect output to remain in `dist/`. See [Platform Adapters](docs/platform-adapters.md).
103
103
 
104
104
  ## Supported Project Shapes
105
105
 
106
- Release-readiness coverage validates:
106
+ Core supports these project shapes:
107
107
 
108
108
  - Drupal SDC projects using `src/components`.
109
109
  - `none` platform Twig projects using `src/components`.
110
+ - `wordpress` platform Twig projects using `src/components`.
110
111
  - Root `./components` projects.
111
112
  - Projects using multiple `variant.structureImplementations`.
112
113
  - Mixed Twig + React Storybook projects.
113
114
 
114
- WordPress and Timber projects should currently use `platform: "none"`. This keeps Emulsify Core in platform-neutral mode while still supporting Twig-oriented component development. A dedicated WordPress adapter may be added later when WordPress-specific behavior is introduced. The implemented adapters in this package are currently `none` and `drupal`.
115
+ WordPress and Timber projects should use `platform: "wordpress"` when they want Core's neutral WordPress adapter. The adapter keeps output in `dist/`, loads Storybook CSS from `dist/**/*.css`, and leaves WordPress runtime behavior to `emulsify-wordpress-theme`.
115
116
 
116
117
  ## Public Imports
117
118
 
@@ -125,7 +126,7 @@ import { defineReactExtension } from '@emulsify/core/extensions/react';
125
126
 
126
127
  `defineReactExtension` is reserved for future React extension support. It currently returns the input unchanged. Adopting the import path is safe; the runtime is intentionally a no-op until the registry lands. See [Extension Points](docs/extension-points.md#public-imports).
127
128
 
128
- Vite consumers can import the shared config from `@emulsify/core/vite` and public Vite plugin helpers from `@emulsify/core/vite/plugins`.
129
+ Vite consumers can import the shared config from `@emulsify/core/vite`, public Vite plugin helpers from `@emulsify/core/vite/plugins`, and platform adapter helpers from `@emulsify/core/vite/platforms`.
129
130
 
130
131
  ## Contributing
131
132
 
@@ -5,7 +5,21 @@
5
5
  * decisions can be used by Node-side Vite config and Storybook browser code.
6
6
  */
7
7
 
8
- const noneAdapter = {
8
+ /**
9
+ * Freeze adapter definition data while preserving cloneable plain objects.
10
+ *
11
+ * @param {object} adapter - Adapter definition.
12
+ * @returns {object} Frozen adapter definition.
13
+ */
14
+ function freezeAdapter(adapter) {
15
+ return Object.freeze({
16
+ ...adapter,
17
+ storybook: Object.freeze({ ...adapter.storybook }),
18
+ build: Object.freeze({ ...adapter.build }),
19
+ });
20
+ }
21
+
22
+ const noneAdapter = freezeAdapter({
9
23
  name: 'none',
10
24
  outputStrategy: 'dist',
11
25
  storybook: {
@@ -18,9 +32,9 @@ const noneAdapter = {
18
32
  build: {
19
33
  mirrorDistComponentsToRoot: false,
20
34
  },
21
- };
35
+ });
22
36
 
23
- const drupalAdapter = {
37
+ const drupalAdapter = freezeAdapter({
24
38
  name: 'drupal',
25
39
  outputStrategy: 'drupal-sdc',
26
40
  storybook: {
@@ -33,13 +47,29 @@ const drupalAdapter = {
33
47
  build: {
34
48
  mirrorDistComponentsToRoot: true,
35
49
  },
36
- };
50
+ });
37
51
 
38
- const adapters = {
52
+ const wordpressAdapter = freezeAdapter({
53
+ name: 'wordpress',
54
+ outputStrategy: 'dist',
55
+ storybook: {
56
+ loadDrupalBehaviorShim: false,
57
+ attachDrupalBehaviors: false,
58
+ registerDrupalTwigFilters: false,
59
+ loadMirroredComponentCss: false,
60
+ allowSyncXhrSource: false,
61
+ },
62
+ build: {
63
+ mirrorDistComponentsToRoot: false,
64
+ },
65
+ });
66
+
67
+ const adapters = Object.freeze({
39
68
  none: noneAdapter,
40
69
  generic: noneAdapter,
41
70
  drupal: drupalAdapter,
42
- };
71
+ wordpress: wordpressAdapter,
72
+ });
43
73
 
44
74
  /**
45
75
  * Deep-clone an adapter so callers can safely serialize or extend it.
@@ -80,10 +110,7 @@ export function normalizePlatformName(platform = 'none') {
80
110
  */
81
111
  export function getPlatformAdapter(platform = 'none') {
82
112
  const key = normalizePlatformName(platform);
83
- if (key === 'drupal') {
84
- return cloneAdapter(drupalAdapter);
85
- }
86
- return cloneAdapter(noneAdapter);
113
+ return cloneAdapter(adapters[key] || noneAdapter);
87
114
  }
88
115
 
89
116
  export { adapters };
@@ -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.1.1",
3
+ "version": "4.2.1",
4
4
  "description": "Bundled tooling for Storybook development + Vite Build",
5
5
  "keywords": [
6
6
  "component library",
@@ -127,6 +127,7 @@
127
127
  "./storybook/twig/source": "./src/storybook/twig/source.js",
128
128
  "./vite": "./config/vite/vite.config.js",
129
129
  "./vite/plugins": "./config/vite/plugins.js",
130
+ "./vite/platforms": "./config/vite/platforms.js",
130
131
  "./package.json": "./package.json"
131
132
  },
132
133
  "publishConfig": {
@@ -153,6 +154,7 @@
153
154
  "prettier": "npm run check-node-version && prettier --check --config config/.prettierrc.json --ignore-unknown \"**/*.{js,mjs,cjs,jsx,json,yml,yaml,scss,md,twig}\"",
154
155
  "prettier-fix": "npm run check-node-version && prettier --config config/.prettierrc.json --write --ignore-unknown \"**/*.{js,mjs,cjs,jsx,json,yml,yaml,scss,md,twig}\"",
155
156
  "semantic-release": "npm run check-node-version && semantic-release --config ./release.config.cjs",
157
+ "smoke:pack": "npm run check-node-version && node scripts/smoke-pack.js",
156
158
  "version:develop": "npm run check-node-version && node scripts/bump-version-from-commits.js",
157
159
  "storybook": "npm run check-node-version && NODE_OPTIONS=--no-deprecation storybook dev -p 6006 --no-open --exact-port",
158
160
  "storybook-build": "npm run check-node-version && storybook build -o .out",
@@ -164,7 +166,7 @@
164
166
  "@babel/core": "^7.29.7",
165
167
  "@babel/eslint-parser": "^7.28.6",
166
168
  "@babel/preset-env": "^7.28.3",
167
- "@emulsify/cli": "^1.11.4",
169
+ "@emulsify/cli": "^2.2.0",
168
170
  "@eslint/js": "^9.39.4",
169
171
  "@storybook/addon-a11y": "^10.1.4",
170
172
  "@storybook/addon-links": "^10.1.4",
@@ -172,31 +174,27 @@
172
174
  "@storybook/react": "^10.1.4",
173
175
  "@storybook/react-vite": "^10.1.4",
174
176
  "@vituum/vite-plugin-twig": "^1.1.0",
175
- "autoprefixer": "^10.4.21",
177
+ "autoprefixer": "^10.5.2",
176
178
  "axe-core": "^4.11.4",
177
179
  "babel-preset-minify": "^0.5.2",
178
180
  "concurrently": "^9.2.3",
179
181
  "eslint": "^9.39.4",
180
182
  "eslint-config-prettier": "^10.1.8",
181
183
  "eslint-plugin-import": "^2.32.0",
182
- "eslint-plugin-jest": "^29.0.1",
184
+ "eslint-plugin-jest": "^29.15.4",
183
185
  "eslint-plugin-prettier": "^5.5.4",
184
- "eslint-plugin-security": "^4.0.0",
185
- "eslint-plugin-storybook": "^10.1.4",
186
- "fs-extra": "^11.3.1",
186
+ "eslint-plugin-security": "^4.0.1",
187
+ "eslint-plugin-storybook": "^10.4.6",
187
188
  "glob": "^13.0.6",
188
- "graceful-fs": "^4.2.11",
189
189
  "jest": "^30.2.0",
190
190
  "jest-environment-jsdom": "^30.2.0",
191
191
  "js-yaml": "^4.1.0",
192
192
  "normalize.css": "^8.0.1",
193
193
  "open-cli": "^9.0.0",
194
194
  "pa11y": "^9.0.1",
195
- "postcss": "^8.5.4",
195
+ "postcss": "^8.5.16",
196
196
  "postcss-scss": "^4.0.9",
197
- "ramda": "^0.32.0",
198
- "regenerator-runtime": "^0.14.1",
199
- "sass": "^1.93.2",
197
+ "sass": "^1.101.0",
200
198
  "storybook": "^10.1.4",
201
199
  "stylelint": "^17.12.0",
202
200
  "stylelint-config-standard-scss": "^17.0.0",
@@ -211,18 +209,18 @@
211
209
  "yaml": "^2.8.1"
212
210
  },
213
211
  "devDependencies": {
214
- "@commitlint/cli": "^21.0.1",
215
- "@commitlint/config-conventional": "^21.0.1",
212
+ "@commitlint/cli": "^21.2.0",
213
+ "@commitlint/config-conventional": "^21.2.0",
216
214
  "@semantic-release/changelog": "^6.0.2",
217
215
  "@semantic-release/commit-analyzer": "^13.0.1",
218
216
  "@semantic-release/git": "^10.0.1",
219
- "@semantic-release/github": "^12.0.8",
217
+ "@semantic-release/github": "^12.0.9",
220
218
  "@semantic-release/npm": "^13.1.5",
221
219
  "@semantic-release/release-notes-generator": "^14.1.0",
222
220
  "husky": "^9.1.7",
223
- "lint-staged": "^17.0.5",
224
- "react": "^19.2.0",
225
- "react-dom": "^19.2.0",
221
+ "lint-staged": "^17.0.8",
222
+ "react": "^19.2.7",
223
+ "react-dom": "^19.2.7",
226
224
  "semantic-release": "^25.0.3"
227
225
  },
228
226
  "peerDependencies": {
package/scripts/a11y.js CHANGED
@@ -8,7 +8,6 @@
8
8
  import { existsSync, readFileSync } from 'fs';
9
9
  import path from 'path';
10
10
  import { fileURLToPath, pathToFileURL } from 'url';
11
- import * as R from 'ramda';
12
11
  import pa11y from 'pa11y';
13
12
 
14
13
  import a11yConfig from '../config/a11y.config.js';
@@ -210,11 +209,12 @@ const resolvePa11yStoryIds = ({
210
209
  * @param {'error'|'warning'|'notice'} severity
211
210
  * @returns {'red'|'yellow'|'blue'|undefined}
212
211
  */
213
- const severityToColor = R.cond([
214
- [R.equals('error'), R.always('red')],
215
- [R.equals('warning'), R.always('yellow')],
216
- [R.equals('notice'), R.always('blue')],
217
- ]);
212
+ const severityToColor = (severity) =>
213
+ ({
214
+ error: 'red',
215
+ warning: 'yellow',
216
+ notice: 'blue',
217
+ })[severity];
218
218
 
219
219
  /**
220
220
  * @typedef {Object} Pa11yIssue
@@ -297,25 +297,20 @@ const lintComponent = async (name) =>
297
297
  * @param {string[]} names - List of Storybook story IDs.
298
298
  * @returns {Promise<void>}
299
299
  */
300
- const lintReportAndExit = R.pipe(
301
- /** @param {string[]} list */
302
- (list) => list.map(lintComponent),
303
- (promises) => Promise.all(promises),
304
- R.andThen(
305
- R.pipe(
306
- /** @param {Array<{issues: Pa11yIssue[], pageUrl: string}>} results */
307
- (results) => results.map(logReport),
308
- R.reject(R.equals(false)),
309
- R.unless(R.isEmpty, () => process.exit(1)),
310
- ),
311
- ),
312
- );
300
+ const lintReportAndExit = async (names) => {
301
+ const results = await Promise.all(names.map(lintComponent));
302
+ const hasIssues = results.map(logReport).some(Boolean);
303
+
304
+ if (hasIssues) {
305
+ process.exit(1);
306
+ }
307
+ };
313
308
 
314
309
  // Only perform linting/reporting when instructed via "-r".
315
310
  /* istanbul ignore next */
316
- if (R.includes(process.argv[2], ['-h', '--help'])) {
311
+ if (['-h', '--help'].includes(process.argv[2])) {
317
312
  printHelp();
318
- } else if (R.pathEq(['argv', 2], '-r')(process)) {
313
+ } else if (process.argv[2] === '-r') {
319
314
  loadProjectA11yConfig().then((projectConfig) => {
320
315
  applyProjectA11yConfig(projectConfig);
321
316
  return lintReportAndExit(resolvePa11yStoryIds());
@@ -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
  }
@@ -84,7 +84,7 @@ function warnTextAssetSource(relPath, reason) {
84
84
 
85
85
  warnedAssetSources.add(relPath);
86
86
  console.warn(
87
- `source(): ${reason} for @assets/${relPath}. Synchronous XHR fallback is disabled by default because it blocks Storybook rendering. Move the asset under a configured asset root such as src/assets or assets, or temporarily enable platformAdapter.storybook.allowSyncXhrSource. The sync-XHR fallback is deprecated and will be removed in 4.2.`,
87
+ `source(): ${reason} for @assets/${relPath}. Synchronous XHR fallback is disabled by default because it blocks Storybook rendering. Move the asset under a configured asset root such as src/assets or assets, or temporarily enable platformAdapter.storybook.allowSyncXhrSource. The sync-XHR fallback is deprecated and scheduled for removal in a future major release.`,
88
88
  );
89
89
  }
90
90