@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 +8 -7
- package/config/vite/platforms.js +37 -10
- package/config/vite/plugins/twig-module.js +191 -31
- package/package.json +16 -18
- package/scripts/a11y.js +16 -21
- package/src/storybook/twig/resolver.js +20 -3
- package/src/storybook/twig/source-function.js +1 -1
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
package/config/vite/platforms.js
CHANGED
|
@@ -5,7 +5,21 @@
|
|
|
5
5
|
* decisions can be used by Node-side Vite config and Storybook browser code.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
return
|
|
525
|
-
|
|
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
|
-
.
|
|
528
|
-
|
|
529
|
-
|
|
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
|
|
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
|
|
538
|
-
* `
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
618
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
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
|
-
|
|
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.
|
|
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": "^
|
|
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.
|
|
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.
|
|
184
|
+
"eslint-plugin-jest": "^29.15.4",
|
|
183
185
|
"eslint-plugin-prettier": "^5.5.4",
|
|
184
|
-
"eslint-plugin-security": "^4.0.
|
|
185
|
-
"eslint-plugin-storybook": "^10.
|
|
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.
|
|
195
|
+
"postcss": "^8.5.16",
|
|
196
196
|
"postcss-scss": "^4.0.9",
|
|
197
|
-
"
|
|
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
|
|
215
|
-
"@commitlint/config-conventional": "^21.0
|
|
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.
|
|
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.
|
|
224
|
-
"react": "^19.2.
|
|
225
|
-
"react-dom": "^19.2.
|
|
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 =
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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 =
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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 (
|
|
311
|
+
if (['-h', '--help'].includes(process.argv[2])) {
|
|
317
312
|
printHelp();
|
|
318
|
-
} else if (
|
|
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
|
|
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
|
|
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
|
|