@emulsify/core 3.5.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/.cli/init.js +40 -31
  2. package/.storybook/_drupal.js +129 -8
  3. package/.storybook/css-components.js +13 -0
  4. package/.storybook/css-dist.js +5 -0
  5. package/.storybook/emulsifyTheme.js +9 -6
  6. package/.storybook/main.js +397 -106
  7. package/.storybook/manager.js +9 -16
  8. package/.storybook/preview.js +88 -110
  9. package/.storybook/utils.js +69 -74
  10. package/README.md +110 -59
  11. package/config/.stylelintrc.json +2 -6
  12. package/config/a11y.config.js +9 -5
  13. package/config/babel.config.js +6 -11
  14. package/config/eslint.config.js +31 -3
  15. package/config/postcss.config.js +5 -0
  16. package/config/vite/entries.js +227 -0
  17. package/config/vite/environment.js +39 -0
  18. package/config/vite/platforms.js +70 -0
  19. package/config/vite/plugins/copy-src-assets.js +76 -0
  20. package/config/vite/plugins/copy-twig-files.js +84 -0
  21. package/config/vite/plugins/css-asset-relativizer.js +40 -0
  22. package/config/vite/plugins/index.js +105 -0
  23. package/config/vite/plugins/mirror-components.js +358 -0
  24. package/config/vite/plugins/require-context.js +311 -0
  25. package/config/vite/plugins/source-file-index.js +184 -0
  26. package/config/vite/plugins/svg-sprite.js +117 -0
  27. package/config/vite/plugins/twig-extension-installers.js +36 -0
  28. package/config/vite/plugins/twig-module.js +1251 -0
  29. package/config/vite/plugins/virtual-twig-asset-sources.js +404 -0
  30. package/config/vite/plugins/virtual-twig-globs.js +136 -0
  31. package/config/vite/plugins/vituum-patch.js +167 -0
  32. package/config/vite/plugins/yaml-module.js +133 -0
  33. package/config/vite/plugins.js +12 -0
  34. package/config/vite/project-config.js +192 -0
  35. package/config/vite/project-extensions.js +177 -0
  36. package/config/vite/project-structure.js +447 -0
  37. package/config/vite/twig-extensions.js +109 -0
  38. package/config/vite/utils/fs-safe.js +66 -0
  39. package/config/vite/utils/paths.js +40 -0
  40. package/config/vite/utils/react-singleton.js +85 -0
  41. package/config/vite/utils/unique.js +36 -0
  42. package/config/vite/vite.config.js +161 -0
  43. package/package.json +164 -75
  44. package/scripts/a11y.js +70 -16
  45. package/scripts/audit-twig-stories.js +378 -0
  46. package/scripts/audit.js +1602 -0
  47. package/scripts/check-node-version.js +18 -0
  48. package/scripts/loadYaml.js +5 -1
  49. package/src/extensions/index.js +8 -0
  50. package/src/extensions/react/index.js +12 -0
  51. package/src/extensions/react/register.js +45 -0
  52. package/src/extensions/shared/attributes.js +308 -0
  53. package/src/extensions/shared/html.js +41 -0
  54. package/src/extensions/shared/lists.js +38 -0
  55. package/src/extensions/shared/object.js +22 -0
  56. package/src/extensions/twig/function-map.js +20 -0
  57. package/src/extensions/twig/functions/add-attributes.js +39 -0
  58. package/src/extensions/twig/functions/bem.js +166 -0
  59. package/src/extensions/twig/index.js +13 -0
  60. package/src/extensions/twig/register.js +52 -0
  61. package/src/extensions/twig/tag-map.js +16 -0
  62. package/src/extensions/twig/tags/switch.js +266 -0
  63. package/src/storybook/index.js +14 -0
  64. package/src/storybook/main-config.js +132 -0
  65. package/src/storybook/platform-behaviors.js +60 -0
  66. package/src/storybook/preview-parameters.js +81 -0
  67. package/src/storybook/render-twig.js +295 -0
  68. package/src/storybook/twig/drupal-filters.js +7 -0
  69. package/src/storybook/twig/include-function.js +109 -0
  70. package/src/storybook/twig/include.js +28 -0
  71. package/src/storybook/twig/reference-paths.js +294 -0
  72. package/src/storybook/twig/resolver.js +318 -0
  73. package/src/storybook/twig/setup.js +39 -0
  74. package/src/storybook/twig/source-events.js +5 -0
  75. package/src/storybook/twig/source-extensions.js +24 -0
  76. package/src/storybook/twig/source-function.js +239 -0
  77. package/src/storybook/twig/source.js +39 -0
  78. package/.all-contributorsrc +0 -45
  79. package/.editorconfig +0 -5
  80. package/.github/ISSUE_TEMPLATE/BUG_REPORT_TEMPLATE.md +0 -18
  81. package/.github/ISSUE_TEMPLATE/FEATURE_REQUEST_TEMPLATE.md +0 -11
  82. package/.github/PULL_REQUEST_TEMPLATE.md +0 -19
  83. package/.github/dependabot.yml +0 -6
  84. package/.github/workflows/addtoprojects.yml +0 -21
  85. package/.github/workflows/contributors.yml +0 -37
  86. package/.github/workflows/lint.yml +0 -22
  87. package/.github/workflows/semantic-release.yml +0 -24
  88. package/.husky/commit-msg +0 -2
  89. package/.husky/pre-commit +0 -2
  90. package/.nvmrc +0 -1
  91. package/.prettierignore +0 -4
  92. package/.storybook/polyfills/twig-include.js +0 -40
  93. package/.storybook/polyfills/twig-resolver.js +0 -70
  94. package/.storybook/polyfills/twig-source.js +0 -65
  95. package/.storybook/webpack.config.js +0 -269
  96. package/CODE_OF_CONDUCT.md +0 -56
  97. package/commitlint.config.js +0 -5
  98. package/config/jest.config.js +0 -19
  99. package/config/webpack/app.js +0 -1
  100. package/config/webpack/loaders.js +0 -167
  101. package/config/webpack/optimizers.js +0 -26
  102. package/config/webpack/plugins.js +0 -283
  103. package/config/webpack/resolves.js +0 -157
  104. package/config/webpack/sdc-loader.js +0 -16
  105. package/config/webpack/webpack.common.js +0 -272
  106. package/config/webpack/webpack.dev.js +0 -41
  107. package/config/webpack/webpack.prod.js +0 -6
  108. package/release.config.cjs +0 -30
  109. package/scripts/a11y.test.js +0 -172
  110. package/scripts/loadYaml.test.js +0 -30
@@ -0,0 +1,166 @@
1
+ /**
2
+ * @file Native `bem()` Twig function implementation.
3
+ * @module extensions/twig/functions/bem
4
+ */
5
+
6
+ import {
7
+ AttributeBag,
8
+ attributesFromContext,
9
+ clearContextAttributes,
10
+ } from '../../shared/attributes.js';
11
+ import { flattenList } from '../../shared/lists.js';
12
+ import { isPlainObject } from '../../shared/object.js';
13
+
14
+ /**
15
+ * Normalize positional and object-style BEM arguments into one shape.
16
+ *
17
+ * @param {string|Object} baseClass - Base class or options object.
18
+ * @param {*[]} modifiers - Positional modifiers.
19
+ * @param {string} blockname - Positional block name.
20
+ * @param {*[]} extra - Positional extra classes.
21
+ * @param {Object} attributes - Positional extra attributes.
22
+ * @returns {{
23
+ * baseClass: *,
24
+ * modifiers: *,
25
+ * blockname: *,
26
+ * extra: *,
27
+ * attributes: Object
28
+ * }} Normalized BEM options.
29
+ */
30
+ function normalizeBemOptions(
31
+ baseClass,
32
+ modifiers,
33
+ blockname,
34
+ extra,
35
+ attributes,
36
+ ) {
37
+ if (!isPlainObject(baseClass)) {
38
+ return {
39
+ baseClass,
40
+ modifiers,
41
+ blockname,
42
+ extra,
43
+ attributes,
44
+ };
45
+ }
46
+
47
+ const options = baseClass;
48
+ const hasBEMObjectShape = options.block && options.element;
49
+
50
+ // Prefer explicit keys, then map block/element object syntax.
51
+ return {
52
+ baseClass:
53
+ options.baseClass ||
54
+ options.base_class ||
55
+ options.base ||
56
+ (hasBEMObjectShape ? options.element : options.block),
57
+ modifiers: options.modifiers || [],
58
+ blockname:
59
+ options.blockname ||
60
+ options.blockName ||
61
+ (hasBEMObjectShape ? options.block : options.element) ||
62
+ '',
63
+ extra: options.extra || [],
64
+ attributes: options.attributes || {},
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Convert an argument into a clean list while preserving string contents.
70
+ *
71
+ * Class-token sanitization happens later in AttributeBag so BEM composition can
72
+ * treat classes and attributes through one path.
73
+ *
74
+ * @param {*} value - Value to normalize.
75
+ * @returns {*[]} Flattened non-empty values.
76
+ */
77
+ function normalizeList(value) {
78
+ return flattenList(value).filter((item) => {
79
+ return item !== null && typeof item !== 'undefined' && item !== '';
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Build BEM attributes.
85
+ *
86
+ * @param {string|Object} baseClass - Base class or object-style options.
87
+ * @param {*[]} [modifiers=[]] - Modifier values.
88
+ * @param {string} [blockname=''] - Block name for element output.
89
+ * @param {*[]} [extra=[]] - Non-BEM class values.
90
+ * @param {Object} [attributes={}] - Additional attributes.
91
+ * @param {Object} [invocationContext] - Twig.js function invocation `this`.
92
+ * @returns {AttributeBag} AttributeBag ready for Twig serialization.
93
+ */
94
+ export function bemAttributes(
95
+ baseClass,
96
+ modifiers = [],
97
+ blockname = '',
98
+ extra = [],
99
+ attributes = {},
100
+ invocationContext,
101
+ ) {
102
+ const options = normalizeBemOptions(
103
+ baseClass,
104
+ modifiers,
105
+ blockname,
106
+ extra,
107
+ attributes,
108
+ );
109
+ const normalizedBaseClass = String(options.baseClass || '').trim();
110
+ const normalizedBlockname = String(options.blockname || '').trim();
111
+ const classes = [];
112
+
113
+ // Generate canonical BEM class names before adding non-BEM extras.
114
+ if (normalizedBaseClass) {
115
+ const classPrefix = normalizedBlockname
116
+ ? `${normalizedBlockname}__${normalizedBaseClass}`
117
+ : normalizedBaseClass;
118
+
119
+ classes.push(classPrefix);
120
+
121
+ for (const modifier of normalizeList(options.modifiers)) {
122
+ classes.push(`${classPrefix}--${modifier}`);
123
+ }
124
+ }
125
+
126
+ classes.push(...normalizeList(options.extra));
127
+
128
+ const attributeBag = new AttributeBag(options.attributes);
129
+ attributeBag.addClass(classes);
130
+
131
+ // Merge then clear context attributes to match Drupal's print-once model.
132
+ if (invocationContext?.context?.attributes) {
133
+ const contextAttributes = attributesFromContext(invocationContext);
134
+ attributeBag.merge(contextAttributes);
135
+ clearContextAttributes(invocationContext);
136
+ }
137
+
138
+ return attributeBag;
139
+ }
140
+
141
+ /**
142
+ * Twig.js adapter for `bem()`.
143
+ *
144
+ * @param {string|Object} baseClass - Base class or object-style options.
145
+ * @param {*[]} modifiers - Modifier values.
146
+ * @param {string} blockname - Block name for element output.
147
+ * @param {*[]} extra - Non-BEM class values.
148
+ * @param {Object} attributes - Additional attributes.
149
+ * @returns {AttributeBag} AttributeBag ready for Twig serialization.
150
+ */
151
+ export function bemTwigFunction(
152
+ baseClass,
153
+ modifiers,
154
+ blockname,
155
+ extra,
156
+ attributes,
157
+ ) {
158
+ return bemAttributes(
159
+ baseClass,
160
+ modifiers,
161
+ blockname,
162
+ extra,
163
+ attributes,
164
+ this,
165
+ );
166
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @file Public exports for native Twig extensions.
3
+ * @module extensions/twig
4
+ */
5
+
6
+ // Export registry helpers before individual functions for the public API.
7
+ export { getTwigFunctionMap } from './function-map.js';
8
+ export { registerTwigExtensions } from './register.js';
9
+ export {
10
+ addAttributes,
11
+ addAttributesTwigFunction,
12
+ } from './functions/add-attributes.js';
13
+ export { bemAttributes, bemTwigFunction } from './functions/bem.js';
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @file Twig.js extension registration entry point.
3
+ * @module extensions/twig/register
4
+ */
5
+
6
+ import { getTwigFunctionMap } from './function-map.js';
7
+ import { getTwigTagDefinitions } from './tag-map.js';
8
+
9
+ /**
10
+ * Twig instances that have already received native Emulsify extensions.
11
+ *
12
+ * @type {WeakSet<Object>}
13
+ */
14
+ const registeredTwigInstances = new WeakSet();
15
+
16
+ /**
17
+ * Register native Emulsify Twig functions and logic tags with Twig.js.
18
+ *
19
+ * @param {Object} Twig - Twig.js module or compatible extension target.
20
+ * @returns {Object} The same Twig instance after registration.
21
+ * @throws {TypeError} When the provided value cannot register Twig extensions.
22
+ */
23
+ export function registerTwigExtensions(Twig) {
24
+ if (
25
+ !Twig ||
26
+ typeof Twig.extendFunction !== 'function' ||
27
+ typeof Twig.extendTag !== 'function' ||
28
+ typeof Twig.extend !== 'function'
29
+ ) {
30
+ throw new TypeError(
31
+ 'A Twig.js instance with extendFunction(), extendTag(), and extend() is required.',
32
+ );
33
+ }
34
+
35
+ if (registeredTwigInstances.has(Twig)) {
36
+ return Twig;
37
+ }
38
+
39
+ // Register once so repeated Storybook/Vite setup calls stay idempotent.
40
+ for (const [name, definition] of Object.entries(getTwigFunctionMap())) {
41
+ Twig.extendFunction(name, definition);
42
+ }
43
+
44
+ Twig.extend((InternalTwig) => {
45
+ for (const definition of getTwigTagDefinitions(InternalTwig)) {
46
+ Twig.extendTag(definition);
47
+ }
48
+ });
49
+
50
+ registeredTwigInstances.add(Twig);
51
+ return Twig;
52
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @file Native Twig logic tag definitions.
3
+ * @module extensions/twig/tag-map
4
+ */
5
+
6
+ import { getSwitchTagDefinitions } from './tags/switch.js';
7
+
8
+ /**
9
+ * Get Twig.js logic tag definitions for native Emulsify helpers.
10
+ *
11
+ * @param {Object} Twig - Twig.js module or compatible extension target.
12
+ * @returns {Object[]} Logic tag definitions for Twig.extendTag().
13
+ */
14
+ export function getTwigTagDefinitions(Twig) {
15
+ return [...getSwitchTagDefinitions(Twig)];
16
+ }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * @file Native Twig.js switch/case/default logic tags.
3
+ * @module extensions/twig/tags/switch
4
+ */
5
+
6
+ const SWITCH_TAG_TYPE = 'emulsify_switch';
7
+ const CASE_TAG_TYPE = 'emulsify_case';
8
+ const DEFAULT_TAG_TYPE = 'emulsify_default';
9
+ const ENDSWITCH_TAG_TYPE = 'emulsify_endswitch';
10
+ const DOUBLE_QUOTE = '"';
11
+ const SINGLE_QUOTE = '\u0027';
12
+
13
+ const OPENING_BRACKETS = new Set(['(', '[', '{']);
14
+ const CLOSING_BRACKETS = new Set([')', ']', '}']);
15
+
16
+ /**
17
+ * Determine whether a character can be part of a Twig identifier.
18
+ *
19
+ * @param {string} [character] - Character to inspect.
20
+ * @returns {boolean} TRUE when the character is identifier-like.
21
+ */
22
+ function isIdentifierCharacter(character) {
23
+ return Boolean(character && /[A-Za-z0-9_]/.test(character));
24
+ }
25
+
26
+ /**
27
+ * Split a case expression on top-level Twig `or` operators.
28
+ *
29
+ * Emulsify Tools uses `or` to express multiple PHP switch case values. Twig.js
30
+ * receives the full tag body as a string, so split only when `or` appears
31
+ * outside quotes and nested expressions.
32
+ *
33
+ * @param {string} expression - Raw `{% case ... %}` expression.
34
+ * @returns {string[]} One or more Twig expressions to compile as case values.
35
+ */
36
+ export function splitSwitchCaseExpressions(expression) {
37
+ const parts = [];
38
+ let quote = null;
39
+ let escaped = false;
40
+ let depth = 0;
41
+ let start = 0;
42
+
43
+ for (let index = 0; index < expression.length; index++) {
44
+ const character = expression.charAt(index);
45
+
46
+ if (quote) {
47
+ if (escaped) {
48
+ escaped = false;
49
+ continue;
50
+ }
51
+
52
+ if (character === '\\') {
53
+ escaped = true;
54
+ continue;
55
+ }
56
+
57
+ if (character === quote) {
58
+ quote = null;
59
+ }
60
+
61
+ continue;
62
+ }
63
+
64
+ if (character === DOUBLE_QUOTE || character === SINGLE_QUOTE) {
65
+ quote = character;
66
+ continue;
67
+ }
68
+
69
+ if (OPENING_BRACKETS.has(character)) {
70
+ depth += 1;
71
+ continue;
72
+ }
73
+
74
+ if (CLOSING_BRACKETS.has(character)) {
75
+ depth = Math.max(0, depth - 1);
76
+ continue;
77
+ }
78
+
79
+ if (
80
+ depth === 0 &&
81
+ expression.slice(index, index + 2) === 'or' &&
82
+ !isIdentifierCharacter(expression.charAt(index - 1)) &&
83
+ !isIdentifierCharacter(expression.charAt(index + 2))
84
+ ) {
85
+ const part = expression.slice(start, index).trim();
86
+ if (part) {
87
+ parts.push(part);
88
+ }
89
+ start = index + 2;
90
+ index += 1;
91
+ }
92
+ }
93
+
94
+ const tail = expression.slice(start).trim();
95
+ if (tail) {
96
+ parts.push(tail);
97
+ }
98
+
99
+ return parts;
100
+ }
101
+
102
+ /**
103
+ * Compile a Twig expression into a stack Twig.js can parse later.
104
+ *
105
+ * @param {Object} Twig - Twig.js module.
106
+ * @param {Object} state - Twig.js compile state.
107
+ * @param {string} value - Twig expression source.
108
+ * @returns {Object[]} Compiled expression stack.
109
+ */
110
+ function compileExpression(Twig, state, value) {
111
+ return Twig.expression.compile.call(state, {
112
+ type: Twig.expression.type.expression,
113
+ value,
114
+ }).stack;
115
+ }
116
+
117
+ /**
118
+ * Determine whether the current Twig logic chain belongs to an Emulsify switch.
119
+ *
120
+ * @param {*} chain - Twig.js logic chain value.
121
+ * @returns {boolean} TRUE when the chain was opened by `{% switch %}`.
122
+ */
123
+ function isSwitchChain(chain) {
124
+ return Boolean(chain && chain.emulsifySwitch);
125
+ }
126
+
127
+ /**
128
+ * Compare switch values using PHP-style loose switch semantics.
129
+ *
130
+ * @param {*} switchValue - Evaluated `{% switch ... %}` value.
131
+ * @param {*} caseValue - Evaluated `{% case ... %}` value.
132
+ * @returns {boolean} TRUE when the case matches.
133
+ */
134
+ function isSwitchMatch(switchValue, caseValue) {
135
+ // PHP switch statements use loose equality; mirror that for Drupal parity.
136
+ return switchValue == caseValue;
137
+ }
138
+
139
+ /**
140
+ * Render a token body and preserve the current switch chain.
141
+ *
142
+ * @param {Object} Twig - Twig.js module.
143
+ * @param {Object} state - Twig.js parse state.
144
+ * @param {Object} token - Compiled Twig.js logic token.
145
+ * @param {Object} context - Twig render context.
146
+ * @param {Object} chain - Active switch chain.
147
+ * @returns {Object|Promise<Object>} Twig.js logic parse result.
148
+ */
149
+ function renderSwitchBranch(Twig, state, token, context, chain) {
150
+ return state.parseAsync(token.output || [], context).then((output) => ({
151
+ chain,
152
+ output,
153
+ }));
154
+ }
155
+
156
+ /**
157
+ * Create Twig.js logic tag definitions for switch/case/default/endswitch.
158
+ *
159
+ * @param {Object} Twig - Twig.js module or compatible extension target.
160
+ * @returns {Object[]} Logic tag definitions.
161
+ */
162
+ export function getSwitchTagDefinitions(Twig) {
163
+ return [
164
+ {
165
+ type: SWITCH_TAG_TYPE,
166
+ regex: /^switch\s+([\s\S]+)$/,
167
+ next: [CASE_TAG_TYPE, DEFAULT_TAG_TYPE, ENDSWITCH_TAG_TYPE],
168
+ open: true,
169
+ compile(token) {
170
+ token.stack = compileExpression(Twig, this, token.match[1]);
171
+ delete token.match;
172
+ return token;
173
+ },
174
+ parse(token, context) {
175
+ const state = this;
176
+
177
+ return Twig.expression.parseAsync
178
+ .call(state, token.stack, context)
179
+ .then((value) => ({
180
+ chain: {
181
+ emulsifySwitch: true,
182
+ matched: false,
183
+ value,
184
+ },
185
+ output: '',
186
+ }));
187
+ },
188
+ },
189
+ {
190
+ type: CASE_TAG_TYPE,
191
+ regex: /^case\s+([\s\S]+)$/,
192
+ next: [CASE_TAG_TYPE, DEFAULT_TAG_TYPE, ENDSWITCH_TAG_TYPE],
193
+ open: false,
194
+ compile(token) {
195
+ token.stacks = splitSwitchCaseExpressions(token.match[1]).map(
196
+ (expression) => compileExpression(Twig, this, expression),
197
+ );
198
+ delete token.match;
199
+ return token;
200
+ },
201
+ parse(token, context, chain) {
202
+ const state = this;
203
+
204
+ if (!isSwitchChain(chain)) {
205
+ throw new Twig.Error('{% case %} must be used inside {% switch %}.');
206
+ }
207
+
208
+ if (chain.matched) {
209
+ return {
210
+ chain,
211
+ output: '',
212
+ };
213
+ }
214
+
215
+ return Twig.Promise.all(
216
+ token.stacks.map((stack) =>
217
+ Twig.expression.parseAsync.call(state, stack, context),
218
+ ),
219
+ ).then((values) => {
220
+ if (
221
+ !values.some((caseValue) => isSwitchMatch(chain.value, caseValue))
222
+ ) {
223
+ return {
224
+ chain,
225
+ output: '',
226
+ };
227
+ }
228
+
229
+ chain.matched = true;
230
+ return renderSwitchBranch(Twig, state, token, context, chain);
231
+ });
232
+ },
233
+ },
234
+ {
235
+ type: DEFAULT_TAG_TYPE,
236
+ regex: /^default$/,
237
+ next: [ENDSWITCH_TAG_TYPE],
238
+ open: false,
239
+ parse(token, context, chain) {
240
+ const state = this;
241
+
242
+ if (!isSwitchChain(chain)) {
243
+ throw new Twig.Error(
244
+ '{% default %} must be used inside {% switch %}.',
245
+ );
246
+ }
247
+
248
+ if (chain.matched) {
249
+ return {
250
+ chain,
251
+ output: '',
252
+ };
253
+ }
254
+
255
+ chain.matched = true;
256
+ return renderSwitchBranch(Twig, state, token, context, chain);
257
+ },
258
+ },
259
+ {
260
+ type: ENDSWITCH_TAG_TYPE,
261
+ regex: /^endswitch$/,
262
+ next: [],
263
+ open: false,
264
+ },
265
+ ];
266
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @file Public Storybook helpers for Emulsify Core.
3
+ * @module storybook
4
+ */
5
+
6
+ export {
7
+ getActiveStorybookAdapter,
8
+ renderHtmlStoryResult,
9
+ renderTwig,
10
+ renderTwigHtml,
11
+ renderTwigToHtml,
12
+ TwigHtmlStory,
13
+ TwigStory,
14
+ } from './render-twig.js';
@@ -0,0 +1,132 @@
1
+ /**
2
+ * @file Helpers for applying project Storybook main configuration overrides.
3
+ */
4
+
5
+ /**
6
+ * Identify a Storybook addon so project config can override default addon
7
+ * options without duplicating the addon in the final list.
8
+ *
9
+ * @param {string|{name?: string}} addon - Storybook addon entry.
10
+ * @returns {string|null} Stable addon key when available.
11
+ */
12
+ function addonKey(addon) {
13
+ if (typeof addon === 'string') return addon;
14
+ if (addon && typeof addon.name === 'string') return addon.name;
15
+ return null;
16
+ }
17
+
18
+ /**
19
+ * Merge Storybook addon lists while preserving default addon order.
20
+ *
21
+ * Project addons are appended by default. When a project provides an addon with
22
+ * the same package name as a default addon, its entry replaces the default so
23
+ * projects can configure default addons without creating duplicates.
24
+ *
25
+ * @param {Array<string|object>} defaults - Emulsify Core default addons.
26
+ * @param {Array<string|object>} overrides - Project-provided addons.
27
+ * @param {{ replace?: boolean }} [options] - Whether overrides replace defaults.
28
+ * @returns {Array<string|object>} Final addon list.
29
+ */
30
+ export function mergeStorybookAddons(
31
+ defaults = [],
32
+ overrides = [],
33
+ { replace = false } = {},
34
+ ) {
35
+ if (replace) return [...overrides];
36
+
37
+ const merged = [...defaults];
38
+ const indexesByKey = new Map();
39
+
40
+ merged.forEach((addon, index) => {
41
+ const key = addonKey(addon);
42
+ if (key) indexesByKey.set(key, index);
43
+ });
44
+
45
+ for (const addon of overrides) {
46
+ const key = addonKey(addon);
47
+ const existingIndex = key ? indexesByKey.get(key) : undefined;
48
+ if (existingIndex !== undefined) {
49
+ merged.splice(existingIndex, 1, addon);
50
+ continue;
51
+ }
52
+
53
+ if (key) indexesByKey.set(key, merged.length);
54
+ merged.push(addon);
55
+ }
56
+
57
+ return merged;
58
+ }
59
+
60
+ /**
61
+ * Normalize an optional project `config/emulsify-core/storybook/main.js` module.
62
+ *
63
+ * @param {object} [module] - ESM module namespace loaded from the project.
64
+ * @returns {{ config: object|Function, extendConfig?: Function, replaceAddons: boolean }}
65
+ * Normalized override details.
66
+ */
67
+ export function normalizeStorybookConfigOverrideModule(module = {}) {
68
+ const config = module.default || {};
69
+
70
+ return {
71
+ config,
72
+ extendConfig:
73
+ typeof module.extendConfig === 'function'
74
+ ? module.extendConfig
75
+ : undefined,
76
+ replaceAddons: module.replaceAddons === true,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Apply project Storybook main config overrides to Emulsify's default config.
82
+ *
83
+ * Default-exported override objects are shallow-merged, except `addons`, which
84
+ * are appended by default. Export `replaceAddons = true` or include
85
+ * `replaceAddons: true` in the default config object when full replacement is
86
+ * needed. Named `extendConfig()` runs last for advanced cases.
87
+ *
88
+ * @param {object} baseConfig - Emulsify Core Storybook config.
89
+ * @param {{ config?: object|Function, extendConfig?: Function, replaceAddons?: boolean }} [overrides]
90
+ * Project override details.
91
+ * @param {object} [context] - Context passed to config factories.
92
+ * @returns {Promise<object>} Final Storybook config.
93
+ */
94
+ export async function applyStorybookConfigOverrides(
95
+ baseConfig,
96
+ overrides = {},
97
+ context = {},
98
+ ) {
99
+ const rawConfig =
100
+ typeof overrides.config === 'function'
101
+ ? await overrides.config(context)
102
+ : overrides.config;
103
+ const plainConfig =
104
+ rawConfig && typeof rawConfig === 'object' ? { ...rawConfig } : {};
105
+ const configReplaceAddons = plainConfig.replaceAddons === true;
106
+ delete plainConfig.replaceAddons;
107
+ delete plainConfig.extendConfig;
108
+ const replaceAddons = overrides.replaceAddons || configReplaceAddons === true;
109
+
110
+ let merged = {
111
+ ...baseConfig,
112
+ ...plainConfig,
113
+ };
114
+
115
+ if (Array.isArray(plainConfig.addons)) {
116
+ merged = {
117
+ ...merged,
118
+ addons: mergeStorybookAddons(baseConfig.addons, plainConfig.addons, {
119
+ replace: replaceAddons,
120
+ }),
121
+ };
122
+ }
123
+
124
+ if (typeof overrides.extendConfig === 'function') {
125
+ const extended = await overrides.extendConfig(merged, context);
126
+ if (extended && typeof extended === 'object') {
127
+ merged = extended;
128
+ }
129
+ }
130
+
131
+ return merged;
132
+ }