@emulsify/core 3.4.1 → 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.
- package/.cli/init.js +40 -31
- package/.storybook/_drupal.js +129 -8
- package/.storybook/css-components.js +13 -0
- package/.storybook/css-dist.js +5 -0
- package/.storybook/emulsifyTheme.js +10 -7
- package/.storybook/main.js +417 -65
- package/.storybook/manager.js +11 -18
- package/.storybook/preview.js +93 -37
- package/.storybook/utils.js +70 -69
- package/README.md +110 -59
- package/config/.stylelintrc.json +2 -6
- package/config/a11y.config.js +9 -5
- package/config/babel.config.js +5 -0
- package/config/eslint.config.js +6 -3
- package/config/postcss.config.js +5 -0
- package/config/vite/entries.js +227 -0
- package/config/vite/environment.js +39 -0
- package/config/vite/platforms.js +70 -0
- package/config/vite/plugins/copy-src-assets.js +76 -0
- package/config/vite/plugins/copy-twig-files.js +84 -0
- package/config/vite/plugins/css-asset-relativizer.js +40 -0
- package/config/vite/plugins/index.js +105 -0
- package/config/vite/plugins/mirror-components.js +358 -0
- package/config/vite/plugins/require-context.js +311 -0
- package/config/vite/plugins/source-file-index.js +184 -0
- package/config/vite/plugins/svg-sprite.js +117 -0
- package/config/vite/plugins/twig-extension-installers.js +36 -0
- package/config/vite/plugins/twig-module.js +1251 -0
- package/config/vite/plugins/virtual-twig-asset-sources.js +404 -0
- package/config/vite/plugins/virtual-twig-globs.js +136 -0
- package/config/vite/plugins/vituum-patch.js +167 -0
- package/config/vite/plugins/yaml-module.js +133 -0
- package/config/vite/plugins.js +12 -0
- package/config/vite/project-config.js +192 -0
- package/config/vite/project-extensions.js +177 -0
- package/config/vite/project-structure.js +447 -0
- package/config/vite/twig-extensions.js +109 -0
- package/config/vite/utils/fs-safe.js +66 -0
- package/config/vite/utils/paths.js +40 -0
- package/config/vite/utils/react-singleton.js +85 -0
- package/config/vite/utils/unique.js +36 -0
- package/config/vite/vite.config.js +161 -0
- package/package.json +168 -88
- package/scripts/a11y.js +70 -16
- package/scripts/audit-twig-stories.js +378 -0
- package/scripts/audit.js +1602 -0
- package/scripts/check-node-version.js +18 -0
- package/scripts/loadYaml.js +5 -1
- package/src/extensions/index.js +8 -0
- package/src/extensions/react/index.js +12 -0
- package/src/extensions/react/register.js +45 -0
- package/src/extensions/shared/attributes.js +308 -0
- package/src/extensions/shared/html.js +41 -0
- package/src/extensions/shared/lists.js +38 -0
- package/src/extensions/shared/object.js +22 -0
- package/src/extensions/twig/function-map.js +20 -0
- package/src/extensions/twig/functions/add-attributes.js +39 -0
- package/src/extensions/twig/functions/bem.js +166 -0
- package/src/extensions/twig/index.js +13 -0
- package/src/extensions/twig/register.js +52 -0
- package/src/extensions/twig/tag-map.js +16 -0
- package/src/extensions/twig/tags/switch.js +266 -0
- package/src/storybook/index.js +14 -0
- package/src/storybook/main-config.js +132 -0
- package/src/storybook/platform-behaviors.js +60 -0
- package/src/storybook/preview-parameters.js +81 -0
- package/src/storybook/render-twig.js +295 -0
- package/src/storybook/twig/drupal-filters.js +7 -0
- package/src/storybook/twig/include-function.js +109 -0
- package/src/storybook/twig/include.js +28 -0
- package/src/storybook/twig/reference-paths.js +294 -0
- package/src/storybook/twig/resolver.js +318 -0
- package/src/storybook/twig/setup.js +39 -0
- package/src/storybook/twig/source-events.js +5 -0
- package/src/storybook/twig/source-extensions.js +24 -0
- package/src/storybook/twig/source-function.js +239 -0
- package/src/storybook/twig/source.js +39 -0
- package/.all-contributorsrc +0 -45
- package/.editorconfig +0 -5
- package/.github/ISSUE_TEMPLATE/BUG_REPORT_TEMPLATE.md +0 -18
- package/.github/ISSUE_TEMPLATE/FEATURE_REQUEST_TEMPLATE.md +0 -11
- package/.github/PULL_REQUEST_TEMPLATE.md +0 -19
- package/.github/dependabot.yml +0 -6
- package/.github/workflows/addtoprojects.yml +0 -21
- package/.github/workflows/contributors.yml +0 -37
- package/.github/workflows/lint.yml +0 -22
- package/.github/workflows/semantic-release.yml +0 -24
- package/.husky/commit-msg +0 -2
- package/.husky/pre-commit +0 -2
- package/.nvmrc +0 -1
- package/.prettierignore +0 -4
- package/.storybook/polyfills/twig-include.js +0 -36
- package/.storybook/polyfills/twig-resolver.js +0 -68
- package/.storybook/polyfills/twig-source.js +0 -54
- package/.storybook/webpack.config.js +0 -193
- package/CODE_OF_CONDUCT.md +0 -56
- package/commitlint.config.js +0 -5
- package/config/jest.config.js +0 -19
- package/config/webpack/app.js +0 -1
- package/config/webpack/loaders.js +0 -167
- package/config/webpack/optimizers.js +0 -17
- package/config/webpack/plugins.js +0 -283
- package/config/webpack/resolves.js +0 -157
- package/config/webpack/sdc-loader.js +0 -16
- package/config/webpack/webpack.common.js +0 -268
- package/config/webpack/webpack.dev.js +0 -41
- package/config/webpack/webpack.prod.js +0 -6
- package/release.config.cjs +0 -30
- package/scripts/a11y.test.js +0 -172
- package/scripts/loadYaml.test.js +0 -30
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @file Enforces the supported Node.js floor for project scripts.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const REQUIRED_NODE_MAJOR = 24;
|
|
7
|
+
const [currentNodeMajor] = process.versions.node.split('.').map(Number);
|
|
8
|
+
|
|
9
|
+
if (currentNodeMajor < REQUIRED_NODE_MAJOR) {
|
|
10
|
+
// Fail before npm scripts run with an unsupported runtime.
|
|
11
|
+
console.error(
|
|
12
|
+
`Emulsify Core requires Node.js ${REQUIRED_NODE_MAJOR} or later. ` +
|
|
13
|
+
`Current version: ${process.versions.node}. Run nvm use or install Node.js 24+.`,
|
|
14
|
+
);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Keep successful checks quiet so script output belongs to the called command.
|
package/scripts/loadYaml.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file YAML fixture loader used by tests and small utility scripts.
|
|
3
|
+
*/
|
|
4
|
+
|
|
1
5
|
import { resolve } from 'path';
|
|
2
6
|
import { readFileSync } from 'fs';
|
|
3
7
|
import { parse } from 'yaml';
|
|
4
|
-
import R from 'ramda';
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* Small utility function that loads a yaml file and parses it synchronously.
|
|
@@ -12,6 +15,7 @@ import R from 'ramda';
|
|
|
12
15
|
* @returns {string} JavaScript object that results from the yaml parsing of the specified file.
|
|
13
16
|
*/
|
|
14
17
|
export default function loadYaml(relativePath) {
|
|
18
|
+
// Resolve from this script directory so tests can pass stable relative paths.
|
|
15
19
|
const fullPath = resolve(__dirname, relativePath);
|
|
16
20
|
return parse(readFileSync(fullPath, 'utf8'));
|
|
17
21
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Public exports for React extension helpers.
|
|
3
|
+
* @module extensions/react
|
|
4
|
+
* @reserved React extension registry behavior is not yet implemented. See
|
|
5
|
+
* `register.js` for the current no-op contract.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Re-export from a single entry point for consumers and future registry growth.
|
|
9
|
+
export {
|
|
10
|
+
createReactExtensionRegistry,
|
|
11
|
+
defineReactExtension,
|
|
12
|
+
} from './register.js';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file React extension registry placeholders.
|
|
3
|
+
* @module extensions/react/register
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Return the provided React extension definition unchanged.
|
|
8
|
+
*
|
|
9
|
+
* @reserved Registry behavior is not yet implemented and may change in a
|
|
10
|
+
* future minor release.
|
|
11
|
+
* @example
|
|
12
|
+
* const extension = defineReactExtension({
|
|
13
|
+
* name: 'project-react-components',
|
|
14
|
+
* components: {},
|
|
15
|
+
* });
|
|
16
|
+
* // Safe today: use `extension` directly instead of relying on registry side
|
|
17
|
+
* // effects.
|
|
18
|
+
*
|
|
19
|
+
* @param {Object} extension - React extension definition.
|
|
20
|
+
* @returns {Object} The provided extension definition.
|
|
21
|
+
*/
|
|
22
|
+
export function defineReactExtension(extension) {
|
|
23
|
+
// Keep this pass-through stable until React extensions need normalization.
|
|
24
|
+
return extension;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Return React extension definitions after filtering falsy values.
|
|
29
|
+
*
|
|
30
|
+
* @reserved Registry behavior is not yet implemented and may change in a
|
|
31
|
+
* future minor release.
|
|
32
|
+
* @example
|
|
33
|
+
* const registry = createReactExtensionRegistry([
|
|
34
|
+
* maybeExtension && defineReactExtension(maybeExtension),
|
|
35
|
+
* ]);
|
|
36
|
+
* // Safe today: read from `registry` directly instead of relying on runtime
|
|
37
|
+
* // registration.
|
|
38
|
+
*
|
|
39
|
+
* @param {Object[]} [extensions=[]] - Candidate extension definitions.
|
|
40
|
+
* @returns {Object[]} Filtered extension definitions.
|
|
41
|
+
*/
|
|
42
|
+
export function createReactExtensionRegistry(extensions = []) {
|
|
43
|
+
// Drop empty placeholders so callers can compose optional extension arrays.
|
|
44
|
+
return extensions.filter(Boolean);
|
|
45
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Attribute serialization and composition utilities.
|
|
3
|
+
* @module extensions/shared/attributes
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { escapeAttributeValue, isSafeAttributeName } from './html.js';
|
|
7
|
+
import { flattenList, uniqueList } from './lists.js';
|
|
8
|
+
import { isPlainObject } from './object.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Cache cleaned class tokens because BEM class names repeat heavily across
|
|
12
|
+
* component renders during Storybook sessions.
|
|
13
|
+
*
|
|
14
|
+
* @type {Map<string, string>}
|
|
15
|
+
*/
|
|
16
|
+
const classNameCache = new Map();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Brand AttributeBag instances without exposing a mutable marker property on
|
|
20
|
+
* the rendered object.
|
|
21
|
+
*
|
|
22
|
+
* @type {WeakSet<AttributeBag>}
|
|
23
|
+
*/
|
|
24
|
+
const attributeBags = new WeakSet();
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Clean a single value into a CSS class token compatible with Twig.js output.
|
|
28
|
+
*
|
|
29
|
+
* @param {*} value - Candidate class token.
|
|
30
|
+
* @returns {string} Cleaned class token or an empty string.
|
|
31
|
+
*/
|
|
32
|
+
function cleanClassToken(value) {
|
|
33
|
+
const raw = String(value || '').trim();
|
|
34
|
+
if (!raw) return '';
|
|
35
|
+
|
|
36
|
+
// Cache by raw input so repeated BEM renders avoid repeated regex work.
|
|
37
|
+
if (classNameCache.has(raw)) {
|
|
38
|
+
return classNameCache.get(raw);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const cleaned = raw
|
|
42
|
+
.replace(/[^_a-zA-Z0-9-]+/g, '-')
|
|
43
|
+
.replace(/^-+|-+$/g, '')
|
|
44
|
+
.replace(/^([0-9])/, '_$1');
|
|
45
|
+
|
|
46
|
+
classNameCache.set(raw, cleaned);
|
|
47
|
+
return cleaned;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Convert scalar, array, or AttributeBag values into clean class tokens.
|
|
52
|
+
*
|
|
53
|
+
* @param {*} value - Value containing one or more class names.
|
|
54
|
+
* @returns {string[]} Clean, unique class tokens.
|
|
55
|
+
*/
|
|
56
|
+
export function classTokensFromValue(value) {
|
|
57
|
+
if (isAttributeBag(value)) {
|
|
58
|
+
// AttributeBag class values are already normalized by this module.
|
|
59
|
+
return value.getClassList();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return uniqueList(
|
|
63
|
+
flattenList(value)
|
|
64
|
+
.flatMap((item) => String(item || '').split(/\s+/))
|
|
65
|
+
.map((item) => cleanClassToken(item))
|
|
66
|
+
.filter(Boolean),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Normalize non-class attribute values into serializable pieces.
|
|
72
|
+
*
|
|
73
|
+
* @param {*} value - Value to normalize.
|
|
74
|
+
* @returns {string|string[]|boolean|Object|null} Serializable value or null.
|
|
75
|
+
*/
|
|
76
|
+
function valueToAttributeParts(value) {
|
|
77
|
+
if (isAttributeBag(value)) {
|
|
78
|
+
// Preserve nested AttributeBag composition for helpers like add_attributes().
|
|
79
|
+
return value.toObject();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (value === null || typeof value === 'undefined' || value === false) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (Array.isArray(value)) {
|
|
87
|
+
return flattenList(value)
|
|
88
|
+
.filter((item) => item !== null && typeof item !== 'undefined')
|
|
89
|
+
.map((item) => String(item));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (typeof value === 'boolean') {
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (typeof value === 'number' || typeof value === 'string') {
|
|
97
|
+
return String(value);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (
|
|
101
|
+
typeof value?.toString === 'function' &&
|
|
102
|
+
value.toString !== Object.prototype.toString
|
|
103
|
+
) {
|
|
104
|
+
return String(value);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Extract class tokens from legacy `class="..."` strings.
|
|
112
|
+
*
|
|
113
|
+
* This keeps compatibility with old `bem()` string output without treating
|
|
114
|
+
* arbitrary `key=value` strings as trusted markup.
|
|
115
|
+
*
|
|
116
|
+
* @param {*} value - Potential legacy class attribute string.
|
|
117
|
+
* @returns {string|null} Raw class value when the string is class-only.
|
|
118
|
+
*/
|
|
119
|
+
function parseClassAttributeString(value) {
|
|
120
|
+
const match = String(value || '').match(/^class=(["'])(.*?)\1$/);
|
|
121
|
+
return match ? match[2] : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Determine whether a value is an AttributeBag instance.
|
|
126
|
+
*
|
|
127
|
+
* @param {*} value - Value to inspect.
|
|
128
|
+
* @returns {boolean} TRUE when the value is branded by this module.
|
|
129
|
+
*/
|
|
130
|
+
export function isAttributeBag(value) {
|
|
131
|
+
return Boolean(
|
|
132
|
+
value && typeof value === 'object' && attributeBags.has(value),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Mutable HTML attribute collection with safe serialization.
|
|
138
|
+
*
|
|
139
|
+
* The class mirrors the tiny subset of Drupal's Attribute object needed by
|
|
140
|
+
* Storybook and Vite-rendered Twig templates.
|
|
141
|
+
*/
|
|
142
|
+
export class AttributeBag {
|
|
143
|
+
/**
|
|
144
|
+
* Create an attribute collection.
|
|
145
|
+
*
|
|
146
|
+
* @param {Object} [initialAttributes={}] - Initial attributes to merge.
|
|
147
|
+
*/
|
|
148
|
+
constructor(initialAttributes = {}) {
|
|
149
|
+
attributeBags.add(this);
|
|
150
|
+
this.attributes = new Map();
|
|
151
|
+
|
|
152
|
+
this.merge(initialAttributes);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Create a copy of this collection.
|
|
157
|
+
*
|
|
158
|
+
* @returns {AttributeBag} New AttributeBag with equivalent attributes.
|
|
159
|
+
*/
|
|
160
|
+
clone() {
|
|
161
|
+
return new AttributeBag(this.toObject());
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Get the normalized class list.
|
|
166
|
+
*
|
|
167
|
+
* @returns {string[]} Current class tokens.
|
|
168
|
+
*/
|
|
169
|
+
getClassList() {
|
|
170
|
+
return this.attributes.get('class') || [];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Append one or more class tokens.
|
|
175
|
+
*
|
|
176
|
+
* @param {*} value - Class value to normalize and append.
|
|
177
|
+
* @returns {AttributeBag} Current instance for chaining.
|
|
178
|
+
*/
|
|
179
|
+
addClass(value) {
|
|
180
|
+
const tokens = classTokensFromValue(value);
|
|
181
|
+
if (!tokens.length) return this;
|
|
182
|
+
|
|
183
|
+
const existing = this.attributes.get('class') || [];
|
|
184
|
+
this.attributes.set('class', uniqueList([...existing, ...tokens]));
|
|
185
|
+
return this;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Set or merge an attribute.
|
|
190
|
+
*
|
|
191
|
+
* Class values are always merged. Other attributes replace existing values
|
|
192
|
+
* once they have been normalized and validated.
|
|
193
|
+
*
|
|
194
|
+
* @param {string} name - Attribute name.
|
|
195
|
+
* @param {*} value - Attribute value.
|
|
196
|
+
* @returns {AttributeBag} Current instance for chaining.
|
|
197
|
+
*/
|
|
198
|
+
set(name, value) {
|
|
199
|
+
const attributeName = String(name || '').trim();
|
|
200
|
+
if (!isSafeAttributeName(attributeName)) return this;
|
|
201
|
+
|
|
202
|
+
if (attributeName === 'class') {
|
|
203
|
+
// Legacy callers may still pass class="..." strings from old helpers.
|
|
204
|
+
const classString =
|
|
205
|
+
typeof value === 'string' ? parseClassAttributeString(value) : null;
|
|
206
|
+
this.addClass(classString || value);
|
|
207
|
+
return this;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const normalizedValue = valueToAttributeParts(value);
|
|
211
|
+
if (normalizedValue === null) return this;
|
|
212
|
+
|
|
213
|
+
this.attributes.set(attributeName, normalizedValue);
|
|
214
|
+
return this;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Merge an object or another AttributeBag into this collection.
|
|
219
|
+
*
|
|
220
|
+
* @param {Object|AttributeBag} value - Attribute source.
|
|
221
|
+
* @returns {AttributeBag} Current instance for chaining.
|
|
222
|
+
*/
|
|
223
|
+
merge(value) {
|
|
224
|
+
if (!value) return this;
|
|
225
|
+
|
|
226
|
+
if (isAttributeBag(value)) {
|
|
227
|
+
for (const [name, attributeValue] of value.attributes.entries()) {
|
|
228
|
+
this.set(name, attributeValue);
|
|
229
|
+
}
|
|
230
|
+
return this;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!isPlainObject(value)) {
|
|
234
|
+
return this;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
for (const [name, attributeValue] of Object.entries(value)) {
|
|
238
|
+
if (name === '_keys') continue;
|
|
239
|
+
this.set(name, attributeValue);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return this;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Convert attributes to a plain object.
|
|
247
|
+
*
|
|
248
|
+
* @returns {Object} Plain attribute map.
|
|
249
|
+
*/
|
|
250
|
+
toObject() {
|
|
251
|
+
return Object.fromEntries(this.attributes.entries());
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Serialize the attribute collection for direct Twig output.
|
|
256
|
+
*
|
|
257
|
+
* @returns {string} HTML-safe attribute string.
|
|
258
|
+
*/
|
|
259
|
+
toString() {
|
|
260
|
+
return Array.from(this.attributes.entries())
|
|
261
|
+
.map(([name, value]) => {
|
|
262
|
+
if (name === 'class' && Array.isArray(value)) {
|
|
263
|
+
if (!value.length) return '';
|
|
264
|
+
return `class="${escapeAttributeValue(value.join(' '))}"`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (value === true) {
|
|
268
|
+
return name;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (Array.isArray(value)) {
|
|
272
|
+
return `${name}="${escapeAttributeValue(value.join(' '))}"`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return `${name}="${escapeAttributeValue(value)}"`;
|
|
276
|
+
})
|
|
277
|
+
.filter(Boolean)
|
|
278
|
+
.join(' ');
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Build an AttributeBag from the current Twig invocation context.
|
|
284
|
+
*
|
|
285
|
+
* @param {Object} invocationContext - Twig.js function invocation `this`.
|
|
286
|
+
* @returns {AttributeBag} Attribute collection from context attributes.
|
|
287
|
+
*/
|
|
288
|
+
export function attributesFromContext(invocationContext) {
|
|
289
|
+
return new AttributeBag(invocationContext?.context?.attributes || {});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Clear context attributes after they have been consumed.
|
|
294
|
+
*
|
|
295
|
+
* Drupal removes attributes after printing them so they do not leak into child
|
|
296
|
+
* includes; this mirrors that behavior for Storybook and Vite rendering.
|
|
297
|
+
*
|
|
298
|
+
* @param {Object} invocationContext - Twig.js function invocation `this`.
|
|
299
|
+
* @returns {void}
|
|
300
|
+
*/
|
|
301
|
+
export function clearContextAttributes(invocationContext) {
|
|
302
|
+
if (
|
|
303
|
+
invocationContext?.context &&
|
|
304
|
+
Object.hasOwn(invocationContext.context, 'attributes')
|
|
305
|
+
) {
|
|
306
|
+
invocationContext.context.attributes = {};
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file HTML escaping and attribute-name validation helpers.
|
|
3
|
+
* @module extensions/shared/html
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const ATTRIBUTE_NAME_PATTERN = /^[A-Za-z_:][A-Za-z0-9:_.-]*$/;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Determine whether a name is safe to print as an HTML attribute name.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} name - Candidate attribute name.
|
|
12
|
+
* @returns {boolean} TRUE when the name can be safely serialized.
|
|
13
|
+
*/
|
|
14
|
+
export function isSafeAttributeName(name) {
|
|
15
|
+
// Reject spaces, quotes, and event-like malformed names before serialization.
|
|
16
|
+
return ATTRIBUTE_NAME_PATTERN.test(String(name || ''));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Escape a value for use inside a double-quoted HTML attribute.
|
|
21
|
+
*
|
|
22
|
+
* @param {*} value - Attribute value to serialize.
|
|
23
|
+
* @returns {string} Escaped value.
|
|
24
|
+
*/
|
|
25
|
+
export function escapeAttributeValue(value) {
|
|
26
|
+
return String(value).replace(/[&"<>]/g, (character) => {
|
|
27
|
+
// Return the named entity for each unsafe character.
|
|
28
|
+
switch (character) {
|
|
29
|
+
case '&':
|
|
30
|
+
return '&';
|
|
31
|
+
case '"':
|
|
32
|
+
return '"';
|
|
33
|
+
case '<':
|
|
34
|
+
return '<';
|
|
35
|
+
case '>':
|
|
36
|
+
return '>';
|
|
37
|
+
default:
|
|
38
|
+
return character;
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file List coercion utilities shared by extension implementations.
|
|
3
|
+
* @module extensions/shared/lists
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { unique } from '../../../config/vite/utils/unique.js';
|
|
7
|
+
|
|
8
|
+
export { unique };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Convert scalar or nested array values into a flat list.
|
|
12
|
+
*
|
|
13
|
+
* @param {*} value - Value to flatten.
|
|
14
|
+
* @returns {*[]} Flat list with null, undefined, and false treated as empty.
|
|
15
|
+
*/
|
|
16
|
+
export function flattenList(value) {
|
|
17
|
+
if (value === null || typeof value === 'undefined' || value === false) {
|
|
18
|
+
// Match Twig-style falsey class handling without discarding 0 or ''.
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!Array.isArray(value)) {
|
|
23
|
+
return [value];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return value.flatMap((item) => flattenList(item));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Return values in their first-seen order with duplicates removed.
|
|
31
|
+
*
|
|
32
|
+
* @param {*[]} values - Values to deduplicate.
|
|
33
|
+
* @returns {*[]} Unique values.
|
|
34
|
+
*/
|
|
35
|
+
export function uniqueList(values) {
|
|
36
|
+
// Preserve first-seen order; class order can affect utility CSS output.
|
|
37
|
+
return unique(values);
|
|
38
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Object type guards shared by extension implementations.
|
|
3
|
+
* @module extensions/shared/object
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Determine whether a value is a plain object.
|
|
8
|
+
*
|
|
9
|
+
* Objects from Twig.js context data generally have either Object.prototype or
|
|
10
|
+
* a null prototype. Class instances are intentionally excluded so extension
|
|
11
|
+
* code does not accidentally treat rich objects as attribute maps.
|
|
12
|
+
*
|
|
13
|
+
* @param {*} value - Value to inspect.
|
|
14
|
+
* @returns {boolean} TRUE when the value is a plain object.
|
|
15
|
+
*/
|
|
16
|
+
export function isPlainObject(value) {
|
|
17
|
+
if (!value || typeof value !== 'object') return false;
|
|
18
|
+
|
|
19
|
+
// Twig context maps may be created with null prototypes.
|
|
20
|
+
const prototype = Object.getPrototypeOf(value);
|
|
21
|
+
return prototype === Object.prototype || prototype === null;
|
|
22
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Native Twig function map.
|
|
3
|
+
* @module extensions/twig/function-map
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { addAttributesTwigFunction } from './functions/add-attributes.js';
|
|
7
|
+
import { bemTwigFunction } from './functions/bem.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Get Twig.js function definitions for native Emulsify helpers.
|
|
11
|
+
*
|
|
12
|
+
* @returns {Record<string, Function>} Function names keyed to Twig callbacks.
|
|
13
|
+
*/
|
|
14
|
+
export function getTwigFunctionMap() {
|
|
15
|
+
// Twig.js expects function names keyed to callable implementations.
|
|
16
|
+
return {
|
|
17
|
+
add_attributes: addAttributesTwigFunction,
|
|
18
|
+
bem: bemTwigFunction,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Native `add_attributes()` Twig function implementation.
|
|
3
|
+
* @module extensions/twig/functions/add-attributes
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
AttributeBag,
|
|
8
|
+
attributesFromContext,
|
|
9
|
+
clearContextAttributes,
|
|
10
|
+
} from '../../shared/attributes.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Merge additional attributes with attributes from the current Twig context.
|
|
14
|
+
*
|
|
15
|
+
* @param {Object} [additionalAttributes={}] - Attributes to add or merge.
|
|
16
|
+
* @param {Object} [invocationContext] - Twig.js function invocation `this`.
|
|
17
|
+
* @returns {AttributeBag} AttributeBag ready for Twig serialization.
|
|
18
|
+
*/
|
|
19
|
+
export function addAttributes(additionalAttributes = {}, invocationContext) {
|
|
20
|
+
// Context attributes are merged first so explicit additions can append safely.
|
|
21
|
+
const attributeBag = attributesFromContext(invocationContext);
|
|
22
|
+
attributeBag.merge(additionalAttributes);
|
|
23
|
+
clearContextAttributes(invocationContext);
|
|
24
|
+
|
|
25
|
+
return attributeBag;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Twig.js adapter for `add_attributes()`.
|
|
30
|
+
*
|
|
31
|
+
* @param {Object} [additionalAttributes={}] - Attributes to add or merge.
|
|
32
|
+
* @returns {AttributeBag} AttributeBag ready for Twig serialization.
|
|
33
|
+
*/
|
|
34
|
+
export function addAttributesTwigFunction(additionalAttributes = {}) {
|
|
35
|
+
// Preserve Twig.js' invocation context for Drupal-compatible attributes.
|
|
36
|
+
return addAttributes(additionalAttributes, this);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export { AttributeBag };
|