@emulsify/core 3.5.0 → 4.0.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/.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 +9 -6
- package/.storybook/main.js +397 -106
- package/.storybook/manager.js +9 -16
- package/.storybook/preview.js +88 -110
- package/.storybook/utils.js +69 -74
- package/README.md +110 -59
- package/config/.stylelintrc.json +2 -6
- package/config/a11y.config.js +9 -5
- package/config/babel.config.js +6 -11
- package/config/eslint.config.js +31 -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 +164 -75
- 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 +95 -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 -40
- package/.storybook/polyfills/twig-resolver.js +0 -70
- package/.storybook/polyfills/twig-source.js +0 -65
- package/.storybook/webpack.config.js +0 -269
- 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 -26
- 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 -272
- 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,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Node-safe Twig reference path helpers shared by audit and Storybook.
|
|
3
|
+
*
|
|
4
|
+
* Twig root records are memoized per environment object so repeated runtime
|
|
5
|
+
* include resolution can reuse the same normalized root list within a session.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { unique } from '../../extensions/shared/lists.js';
|
|
9
|
+
|
|
10
|
+
const ENV = (typeof __EMULSIFY_ENV__ !== 'undefined' && __EMULSIFY_ENV__) || {};
|
|
11
|
+
|
|
12
|
+
/** @type {WeakMap<object, object[]>} */
|
|
13
|
+
let rootRecordsCache = new WeakMap();
|
|
14
|
+
|
|
15
|
+
/** @type {object[]|undefined} */
|
|
16
|
+
let defaultRecords;
|
|
17
|
+
|
|
18
|
+
const normalizeGlobPath = (filePath) => filePath.replace(/\\/g, '/');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Convert an absolute project path to a Vite root-relative key.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} absolutePath - Absolute file or directory path.
|
|
24
|
+
* @param {object} env - Normalized Emulsify environment.
|
|
25
|
+
* @returns {string} Root-relative path with a leading slash.
|
|
26
|
+
*/
|
|
27
|
+
export function toRootRelativePath(absolutePath, env = ENV) {
|
|
28
|
+
if (!absolutePath) return '';
|
|
29
|
+
|
|
30
|
+
const normalizedPath = normalizeGlobPath(absolutePath);
|
|
31
|
+
const projectDir = normalizeGlobPath(env?.projectDir || '');
|
|
32
|
+
|
|
33
|
+
if (projectDir && normalizedPath.startsWith(projectDir)) {
|
|
34
|
+
const relativePath = normalizedPath.slice(projectDir.length);
|
|
35
|
+
return relativePath.startsWith('/') ? relativePath : `/${relativePath}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Normalize a Twig root declaration into resolver metadata.
|
|
43
|
+
*
|
|
44
|
+
* @param {{name?: string, directory?: string}|string} root - Root declaration.
|
|
45
|
+
* @param {object} env - Normalized Emulsify environment.
|
|
46
|
+
* @returns {{name: string|undefined, directory: string, rootRel: string}|null}
|
|
47
|
+
*/
|
|
48
|
+
function normalizeRootRecord(root, env) {
|
|
49
|
+
const directory = typeof root === 'string' ? root : root?.directory;
|
|
50
|
+
if (!directory) return null;
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
name: typeof root === 'string' ? undefined : root.name,
|
|
54
|
+
directory,
|
|
55
|
+
rootRel: toRootRelativePath(directory, env),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Compute Twig roots from the normalized project structure.
|
|
61
|
+
*
|
|
62
|
+
* @param {object} env - Normalized Emulsify environment.
|
|
63
|
+
* @returns {{name: string|undefined, directory: string, rootRel: string}[]}
|
|
64
|
+
* Twig roots in resolution order.
|
|
65
|
+
*/
|
|
66
|
+
function computeTwigRootRecords(env) {
|
|
67
|
+
const structure = env?.projectStructure || {};
|
|
68
|
+
const namespaceRoots =
|
|
69
|
+
structure.namespaceRoots && typeof structure.namespaceRoots === 'object'
|
|
70
|
+
? structure.namespaceRoots
|
|
71
|
+
: env?.namespaceRoots && typeof env.namespaceRoots === 'object'
|
|
72
|
+
? env.namespaceRoots
|
|
73
|
+
: {};
|
|
74
|
+
const namedRoots = [
|
|
75
|
+
...(Array.isArray(structure.componentRootRecords)
|
|
76
|
+
? structure.componentRootRecords
|
|
77
|
+
: []),
|
|
78
|
+
...(Array.isArray(env?.structureImplementations)
|
|
79
|
+
? env.structureImplementations
|
|
80
|
+
: []),
|
|
81
|
+
...Object.entries(namespaceRoots).map(([name, directory]) => ({
|
|
82
|
+
name,
|
|
83
|
+
directory,
|
|
84
|
+
})),
|
|
85
|
+
];
|
|
86
|
+
const unnamedRoots = [
|
|
87
|
+
...(Array.isArray(structure.twigRoots) ? structure.twigRoots : []),
|
|
88
|
+
...(Array.isArray(env?.componentRoots) ? env.componentRoots : []),
|
|
89
|
+
...(env?.srcDir ? [env.srcDir] : []),
|
|
90
|
+
];
|
|
91
|
+
const fallbackRoots = env?.projectDir
|
|
92
|
+
? [
|
|
93
|
+
`${env.projectDir}/src`,
|
|
94
|
+
`${env.projectDir}/src/components`,
|
|
95
|
+
`${env.projectDir}/components`,
|
|
96
|
+
]
|
|
97
|
+
: ['/src', '/src/components', '/components'];
|
|
98
|
+
const records = [...namedRoots, ...unnamedRoots, ...fallbackRoots]
|
|
99
|
+
.map((root) => normalizeRootRecord(root, env))
|
|
100
|
+
.filter(Boolean);
|
|
101
|
+
|
|
102
|
+
return unique(
|
|
103
|
+
records.map((record) => `${record.name || ''}|${record.rootRel}`),
|
|
104
|
+
)
|
|
105
|
+
.map((key) =>
|
|
106
|
+
records.find(
|
|
107
|
+
(record) => `${record.name || ''}|${record.rootRel}` === key,
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
.filter(Boolean);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Build Twig roots from the normalized project structure.
|
|
115
|
+
*
|
|
116
|
+
* @param {object} [env=ENV] - Normalized Emulsify environment.
|
|
117
|
+
* @returns {{name: string|undefined, directory: string, rootRel: string}[]}
|
|
118
|
+
* Twig roots in resolution order.
|
|
119
|
+
*/
|
|
120
|
+
export function buildTwigRootRecords(env = ENV) {
|
|
121
|
+
if (env === ENV) {
|
|
122
|
+
defaultRecords ||= computeTwigRootRecords(env);
|
|
123
|
+
return defaultRecords;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (env && typeof env === 'object') {
|
|
127
|
+
const cached = rootRecordsCache.get(env);
|
|
128
|
+
if (cached) return cached;
|
|
129
|
+
|
|
130
|
+
const records = computeTwigRootRecords(env);
|
|
131
|
+
rootRecordsCache.set(env, records);
|
|
132
|
+
return records;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return computeTwigRootRecords(env);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Reset memoized Twig root records for tests.
|
|
140
|
+
*
|
|
141
|
+
* @param {object} [env] - Optional environment object to clear.
|
|
142
|
+
*/
|
|
143
|
+
export function resetTwigRootRecordsCache(env) {
|
|
144
|
+
if (arguments.length === 0) {
|
|
145
|
+
rootRecordsCache = new WeakMap();
|
|
146
|
+
defaultRecords = undefined;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (env === ENV) {
|
|
151
|
+
defaultRecords = undefined;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (env && typeof env === 'object') {
|
|
156
|
+
rootRecordsCache.delete(env);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Remove a Twig file extension from a reference.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} value - Template reference.
|
|
164
|
+
* @returns {string} Reference without `.twig` or `.html.twig`.
|
|
165
|
+
*/
|
|
166
|
+
function removeTwigExtension(value) {
|
|
167
|
+
return value.replace(/\.html\.twig$/i, '').replace(/\.twig$/i, '');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Build candidate keys below one root for a template reference.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} rootRel - Root-relative Twig root.
|
|
174
|
+
* @param {string} reference - Template reference relative to the root.
|
|
175
|
+
* @returns {string[]} Candidate Vite glob keys.
|
|
176
|
+
*/
|
|
177
|
+
export function candidateKeysForRoot(rootRel, reference) {
|
|
178
|
+
const cleanReference = reference.replace(/^[./]+/, '').replace(/^\/+/, '');
|
|
179
|
+
const hasHtmlExtension = /\.html\.twig$/i.test(cleanReference);
|
|
180
|
+
const hasTwigExtension = /\.twig$/i.test(cleanReference);
|
|
181
|
+
const withoutExtension = removeTwigExtension(cleanReference);
|
|
182
|
+
const stem = withoutExtension.split('/').pop();
|
|
183
|
+
const explicitCandidates = hasHtmlExtension
|
|
184
|
+
? [
|
|
185
|
+
`${rootRel}/${withoutExtension}.html.twig`,
|
|
186
|
+
`${rootRel}/${withoutExtension}.twig`,
|
|
187
|
+
]
|
|
188
|
+
: [
|
|
189
|
+
`${rootRel}/${withoutExtension}.twig`,
|
|
190
|
+
`${rootRel}/${withoutExtension}.html.twig`,
|
|
191
|
+
];
|
|
192
|
+
const shorthandCandidates = [
|
|
193
|
+
`${rootRel}/${withoutExtension}/${stem}.twig`,
|
|
194
|
+
`${rootRel}/${withoutExtension}/${stem}.html.twig`,
|
|
195
|
+
...explicitCandidates,
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
return unique(
|
|
199
|
+
(hasTwigExtension || withoutExtension.includes('/')
|
|
200
|
+
? explicitCandidates
|
|
201
|
+
: shorthandCandidates
|
|
202
|
+
).map((key) => key.replace(/\/{2,}/g, '/')),
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Find root records for a namespace.
|
|
208
|
+
*
|
|
209
|
+
* @param {string} namespace - Namespace name.
|
|
210
|
+
* @param {{name?: string}[]} roots - Twig root records.
|
|
211
|
+
* @returns {object[]} Matching root records.
|
|
212
|
+
*/
|
|
213
|
+
function rootsForNamespace(namespace, roots) {
|
|
214
|
+
return roots.filter((root) => root.name === namespace);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Parse a Twig template reference into namespace and relative path parts.
|
|
219
|
+
*
|
|
220
|
+
* @param {string} name - Template reference.
|
|
221
|
+
* @returns {{namespace?: string, path: string, shorthand?: boolean}|null}
|
|
222
|
+
*/
|
|
223
|
+
function parseTemplateReference(name) {
|
|
224
|
+
if (typeof name !== 'string' || !name.trim()) return null;
|
|
225
|
+
|
|
226
|
+
const cleanName = name.trim();
|
|
227
|
+
const colonMatch = cleanName.match(/^([^:/.]+):(.+)$/);
|
|
228
|
+
if (colonMatch) {
|
|
229
|
+
return {
|
|
230
|
+
namespace: colonMatch[1],
|
|
231
|
+
path: colonMatch[2],
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const atMatch = cleanName.match(/^@([^/]+)\/(.+)$/);
|
|
236
|
+
if (atMatch) {
|
|
237
|
+
return {
|
|
238
|
+
namespace: atMatch[1],
|
|
239
|
+
path: atMatch[2],
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (cleanName.startsWith('@')) {
|
|
244
|
+
return {
|
|
245
|
+
path: cleanName.slice(1),
|
|
246
|
+
shorthand: true,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const slashMatch = cleanName.match(/^([^/]+)\/(.+)$/);
|
|
251
|
+
if (slashMatch) {
|
|
252
|
+
return {
|
|
253
|
+
namespace: slashMatch[1],
|
|
254
|
+
path: slashMatch[2],
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
path: cleanName,
|
|
260
|
+
shorthand: true,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Build candidate Vite glob keys for a Twig reference.
|
|
266
|
+
*
|
|
267
|
+
* @param {string} name - Template reference.
|
|
268
|
+
* @param {object} env - Normalized Emulsify environment.
|
|
269
|
+
* @returns {string[]} Candidate Vite glob keys.
|
|
270
|
+
*/
|
|
271
|
+
export function candidateKeysForReference(name, env = ENV) {
|
|
272
|
+
const roots = buildTwigRootRecords(env);
|
|
273
|
+
const parsed = parseTemplateReference(name);
|
|
274
|
+
if (!parsed) return [];
|
|
275
|
+
|
|
276
|
+
const projectNamespace = env?.machineName;
|
|
277
|
+
const namespaceRoots =
|
|
278
|
+
parsed.namespace && parsed.namespace !== projectNamespace
|
|
279
|
+
? rootsForNamespace(parsed.namespace, roots)
|
|
280
|
+
: [];
|
|
281
|
+
const searchRoots = namespaceRoots.length ? namespaceRoots : roots;
|
|
282
|
+
const searchPaths = unique([
|
|
283
|
+
parsed.path,
|
|
284
|
+
...(parsed.namespace && !namespaceRoots.length
|
|
285
|
+
? [`${parsed.namespace}/${parsed.path}`]
|
|
286
|
+
: []),
|
|
287
|
+
]);
|
|
288
|
+
|
|
289
|
+
return unique(
|
|
290
|
+
searchRoots.flatMap((root) =>
|
|
291
|
+
searchPaths.flatMap((part) => candidateKeysForRoot(root.rootRel, part)),
|
|
292
|
+
),
|
|
293
|
+
);
|
|
294
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Runtime Twig template resolver used by Storybook Twig helpers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
modules as twigModules,
|
|
7
|
+
sources as twigSources,
|
|
8
|
+
} from 'virtual:emulsify-twig-globs';
|
|
9
|
+
import {
|
|
10
|
+
buildTwigRootRecords,
|
|
11
|
+
candidateKeysForReference,
|
|
12
|
+
} from './reference-paths.js';
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
buildTwigRootRecords,
|
|
16
|
+
candidateKeysForReference,
|
|
17
|
+
candidateKeysForRoot,
|
|
18
|
+
toRootRelativePath,
|
|
19
|
+
} from './reference-paths.js';
|
|
20
|
+
|
|
21
|
+
const ENV = (typeof __EMULSIFY_ENV__ !== 'undefined' && __EMULSIFY_ENV__) || {};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve a value from a Vite glob map.
|
|
25
|
+
*
|
|
26
|
+
* @param {Record<string, *>} map - Vite glob map.
|
|
27
|
+
* @param {string[]} candidates - Candidate map keys.
|
|
28
|
+
* @returns {*} Resolved map value.
|
|
29
|
+
*/
|
|
30
|
+
function resolveFromMap(map, candidates) {
|
|
31
|
+
for (const key of candidates) {
|
|
32
|
+
// Vite glob map keys are generated from static Storybook patterns.
|
|
33
|
+
// eslint-disable-next-line security/detect-object-injection
|
|
34
|
+
const value = map[key];
|
|
35
|
+
if (value) {
|
|
36
|
+
return value.default ?? value;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build grouped component fallback suffixes from exact candidate keys.
|
|
45
|
+
*
|
|
46
|
+
* Projects can keep components under grouping directories such as
|
|
47
|
+
* `src/components/ui/heading/heading.twig`, while project-scoped component IDs
|
|
48
|
+
* use only the component name, such as `project:heading`. Exact candidates
|
|
49
|
+
* remain preferred; these suffixes only run after direct lookup misses.
|
|
50
|
+
*
|
|
51
|
+
* @param {string[]} candidates - Exact candidate Vite glob keys.
|
|
52
|
+
* @param {object} env - Normalized Emulsify environment.
|
|
53
|
+
* @returns {{rootRel: string, suffix: string}[]} Root-scoped fallback suffixes.
|
|
54
|
+
*/
|
|
55
|
+
function groupedComponentSuffixes(candidates, env) {
|
|
56
|
+
const roots = buildTwigRootRecords(env)
|
|
57
|
+
.map((root) => root.rootRel)
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.sort((left, right) => right.length - left.length);
|
|
60
|
+
const suffixes = [];
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
|
|
63
|
+
for (const candidate of candidates) {
|
|
64
|
+
for (const rootRel of roots) {
|
|
65
|
+
const normalizedRoot = rootRel.replace(/\/+$/, '');
|
|
66
|
+
const prefix = `${normalizedRoot}/`;
|
|
67
|
+
if (!candidate.startsWith(prefix)) continue;
|
|
68
|
+
|
|
69
|
+
const suffix = candidate.slice(normalizedRoot.length);
|
|
70
|
+
const key = `${normalizedRoot}|${suffix}`;
|
|
71
|
+
if (!seen.has(key)) {
|
|
72
|
+
suffixes.push({ rootRel: normalizedRoot, suffix });
|
|
73
|
+
seen.add(key);
|
|
74
|
+
}
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return suffixes;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Resolve a Twig glob entry by grouped component suffix.
|
|
84
|
+
*
|
|
85
|
+
* @param {Record<string, *>} map - Vite glob map.
|
|
86
|
+
* @param {string[]} candidates - Exact candidate Vite glob keys.
|
|
87
|
+
* @param {object} env - Normalized Emulsify environment.
|
|
88
|
+
* @returns {{key: string, value: *}|undefined} Matched glob entry.
|
|
89
|
+
*/
|
|
90
|
+
function findGroupedComponentEntry(map, candidates, env) {
|
|
91
|
+
const entries = Object.entries(map);
|
|
92
|
+
|
|
93
|
+
for (const { rootRel, suffix } of groupedComponentSuffixes(candidates, env)) {
|
|
94
|
+
const rootPrefix = `${rootRel}/`;
|
|
95
|
+
const match = entries.find(
|
|
96
|
+
([key]) => key.startsWith(rootPrefix) && key.endsWith(suffix),
|
|
97
|
+
);
|
|
98
|
+
if (match) {
|
|
99
|
+
return { key: match[0], value: match[1] };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Find a Vite glob map entry and keep its key for cache lookups.
|
|
108
|
+
*
|
|
109
|
+
* @param {Record<string, *>} map - Vite glob map.
|
|
110
|
+
* @param {string[]} candidates - Candidate map keys.
|
|
111
|
+
* @returns {{key: string, value: *}|undefined} Matched glob entry.
|
|
112
|
+
*/
|
|
113
|
+
function findGlobEntry(map, candidates) {
|
|
114
|
+
for (const key of candidates) {
|
|
115
|
+
if (Object.hasOwnProperty.call(map, key)) {
|
|
116
|
+
// Vite glob map keys are generated from static Storybook patterns.
|
|
117
|
+
// eslint-disable-next-line security/detect-object-injection
|
|
118
|
+
return { key, value: map[key] };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Find a direct or grouped component Vite glob entry.
|
|
127
|
+
*
|
|
128
|
+
* @param {Record<string, *>} map - Vite glob map.
|
|
129
|
+
* @param {string[]} candidates - Exact candidate Vite glob keys.
|
|
130
|
+
* @param {object} env - Normalized Emulsify environment.
|
|
131
|
+
* @returns {{key: string, value: *}|undefined} Matched glob entry.
|
|
132
|
+
*/
|
|
133
|
+
function findTemplateEntry(map, candidates, env) {
|
|
134
|
+
return (
|
|
135
|
+
findGlobEntry(map, candidates) ||
|
|
136
|
+
findGroupedComponentEntry(map, candidates, env)
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Normalize raw source loader output from Vite import forms.
|
|
142
|
+
*
|
|
143
|
+
* @param {*} value - Raw source value or imported module.
|
|
144
|
+
* @returns {string|undefined} Raw source text.
|
|
145
|
+
*/
|
|
146
|
+
function normalizeSourceText(value) {
|
|
147
|
+
const source = value?.default ?? value;
|
|
148
|
+
return typeof source === 'string' ? source : undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Create a Twig resolver bound to a normalized environment and Vite maps.
|
|
153
|
+
*
|
|
154
|
+
* @param {{
|
|
155
|
+
* env?: object,
|
|
156
|
+
* modules?: Record<string, *>,
|
|
157
|
+
* sources?: Record<string, string|Function>
|
|
158
|
+
* }} options - Resolver inputs.
|
|
159
|
+
* @returns {{
|
|
160
|
+
* resolveTemplate: Function,
|
|
161
|
+
* resolveTemplateSource: Function,
|
|
162
|
+
* isTemplateSourceLoading: Function,
|
|
163
|
+
* whenTemplateSourceLoaded: Function,
|
|
164
|
+
* candidateKeysForReference: Function
|
|
165
|
+
* }} Resolver functions.
|
|
166
|
+
*/
|
|
167
|
+
export function createTwigResolver({
|
|
168
|
+
env = ENV,
|
|
169
|
+
modules = twigModules,
|
|
170
|
+
sources = twigSources,
|
|
171
|
+
} = {}) {
|
|
172
|
+
const sourceTextCache = new Map();
|
|
173
|
+
const sourceLoadPromises = new Map();
|
|
174
|
+
|
|
175
|
+
const findSourceEntry = (name) => {
|
|
176
|
+
const candidates = candidateKeysForReference(name, env);
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
findGlobEntry(sources, [name]) ||
|
|
180
|
+
findTemplateEntry(sources, candidates, env)
|
|
181
|
+
);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const resolveSourceEntry = (entry) => {
|
|
185
|
+
if (!entry) return undefined;
|
|
186
|
+
if (sourceTextCache.has(entry.key)) {
|
|
187
|
+
return sourceTextCache.get(entry.key);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const sourceText = normalizeSourceText(entry.value);
|
|
191
|
+
if (typeof sourceText === 'string') {
|
|
192
|
+
sourceTextCache.set(entry.key, sourceText);
|
|
193
|
+
return sourceText;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (
|
|
197
|
+
typeof entry.value === 'function' &&
|
|
198
|
+
!sourceLoadPromises.has(entry.key)
|
|
199
|
+
) {
|
|
200
|
+
let loadedSource;
|
|
201
|
+
try {
|
|
202
|
+
loadedSource = entry.value();
|
|
203
|
+
} catch (error) {
|
|
204
|
+
loadedSource = Promise.reject(error);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const sourceLoad = Promise.resolve(loadedSource)
|
|
208
|
+
.then((loaded) => {
|
|
209
|
+
const loadedText = normalizeSourceText(loaded);
|
|
210
|
+
if (typeof loadedText === 'string') {
|
|
211
|
+
sourceTextCache.set(entry.key, loadedText);
|
|
212
|
+
}
|
|
213
|
+
return loadedText;
|
|
214
|
+
})
|
|
215
|
+
.catch((error) => {
|
|
216
|
+
console.error(`source(): failed to load ${entry.key}`, error);
|
|
217
|
+
return undefined;
|
|
218
|
+
})
|
|
219
|
+
.finally(() => {
|
|
220
|
+
sourceLoadPromises.delete(entry.key);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
sourceLoadPromises.set(entry.key, sourceLoad);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return undefined;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const isTemplateSourceLoading = (name) => {
|
|
230
|
+
const entry = findSourceEntry(name);
|
|
231
|
+
return !!entry && sourceLoadPromises.has(entry.key);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const whenTemplateSourceLoaded = (name) => {
|
|
235
|
+
const entry = findSourceEntry(name);
|
|
236
|
+
return entry ? sourceLoadPromises.get(entry.key) : undefined;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
candidateKeysForReference: (name) => candidateKeysForReference(name, env),
|
|
241
|
+
resolveTemplate(name) {
|
|
242
|
+
// Direct lookups support callers that already resolved a Vite glob key.
|
|
243
|
+
// eslint-disable-next-line security/detect-object-injection
|
|
244
|
+
const direct = modules[name];
|
|
245
|
+
if (direct) {
|
|
246
|
+
return direct.default ?? direct;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const candidates = candidateKeysForReference(name, env);
|
|
250
|
+
const template = resolveFromMap(modules, candidates);
|
|
251
|
+
if (template) {
|
|
252
|
+
return template;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const groupedEntry = findGroupedComponentEntry(modules, candidates, env);
|
|
256
|
+
if (groupedEntry) {
|
|
257
|
+
return groupedEntry.value.default ?? groupedEntry.value;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return undefined;
|
|
261
|
+
},
|
|
262
|
+
isTemplateSourceLoading,
|
|
263
|
+
resolveTemplateSource(name) {
|
|
264
|
+
return resolveSourceEntry(findSourceEntry(name));
|
|
265
|
+
},
|
|
266
|
+
whenTemplateSourceLoaded,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let defaultResolver;
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Lazily create the default resolver after virtual Twig glob bindings settle.
|
|
274
|
+
*
|
|
275
|
+
* The virtual Twig glob module eagerly imports compiled Twig modules. Those
|
|
276
|
+
* modules register source(), which imports this resolver while the virtual glob
|
|
277
|
+
* module may still be initializing. Deferring the default resolver avoids
|
|
278
|
+
* reading virtual module bindings during that circular module setup.
|
|
279
|
+
*
|
|
280
|
+
* @returns {ReturnType<typeof createTwigResolver>} Default Twig resolver.
|
|
281
|
+
*/
|
|
282
|
+
function getDefaultResolver() {
|
|
283
|
+
if (!defaultResolver) {
|
|
284
|
+
defaultResolver = createTwigResolver();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return defaultResolver;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Resolve a template identifier to a compiled Twig render function.
|
|
292
|
+
*
|
|
293
|
+
* @param {string} name - Template identifier.
|
|
294
|
+
* @returns {Function|undefined} Render function when available.
|
|
295
|
+
*/
|
|
296
|
+
export default function resolveTemplate(name) {
|
|
297
|
+
return getDefaultResolver().resolveTemplate(name);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Resolve a template identifier to raw Twig source.
|
|
302
|
+
*
|
|
303
|
+
* Lazy raw-source glob entries return `undefined` on first request while their
|
|
304
|
+
* dynamic import resolves. The loaded text is cached and returned on a later
|
|
305
|
+
* call, which usually happens after the Storybook Twig renderer re-renders.
|
|
306
|
+
*
|
|
307
|
+
* @param {string} name - Template identifier.
|
|
308
|
+
* @returns {string|undefined} Raw Twig source when available.
|
|
309
|
+
*/
|
|
310
|
+
export function resolveTemplateSource(name) {
|
|
311
|
+
return getDefaultResolver().resolveTemplateSource(name);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
resolveTemplateSource.isTemplateSourceLoading = (name) =>
|
|
315
|
+
getDefaultResolver().isTemplateSourceLoading(name);
|
|
316
|
+
|
|
317
|
+
resolveTemplateSource.whenTemplateSourceLoaded = (name) =>
|
|
318
|
+
getDefaultResolver().whenTemplateSourceLoaded(name);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Twig runtime setup for Emulsify's Storybook integration.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { registerTwigExtensions } from '../../extensions/twig/index.js';
|
|
6
|
+
import twigInclude from './include.js';
|
|
7
|
+
import twigSource from './source.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Configures and extends a standard Twig object for Storybook.
|
|
11
|
+
*
|
|
12
|
+
* Emulsify's Twig helpers are platform-agnostic. Platform adapters can pass
|
|
13
|
+
* optional Twig extension functions when a project needs CMS-specific behavior.
|
|
14
|
+
*
|
|
15
|
+
* @param {Object} twig - Twig object that should be configured and extended.
|
|
16
|
+
* @param {{ extensions?: Function[] }} [options={}] - Optional platform extensions.
|
|
17
|
+
* @returns {Object} Configured Twig object.
|
|
18
|
+
*/
|
|
19
|
+
export function setupTwig(twig, options = {}) {
|
|
20
|
+
const extensions = Array.isArray(options.extensions)
|
|
21
|
+
? options.extensions
|
|
22
|
+
: [];
|
|
23
|
+
|
|
24
|
+
twig.cache();
|
|
25
|
+
registerTwigExtensions(twig);
|
|
26
|
+
twigInclude(twig);
|
|
27
|
+
twigSource(twig);
|
|
28
|
+
|
|
29
|
+
for (const extension of extensions) {
|
|
30
|
+
if (typeof extension === 'function') {
|
|
31
|
+
extension(twig);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return twig;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export { default as twigInclude } from './include.js';
|
|
39
|
+
export { default as twigSource } from './source.js';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Shared file extension sets for Twig source() handling.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Text assets can be safely inlined; binary assets should remain URL-based.
|
|
6
|
+
export const INLINE_ASSET_EXTS = new Set([
|
|
7
|
+
'svg',
|
|
8
|
+
'html',
|
|
9
|
+
'twig',
|
|
10
|
+
'css',
|
|
11
|
+
'js',
|
|
12
|
+
'json',
|
|
13
|
+
'txt',
|
|
14
|
+
'md',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
export const IMAGE_ASSET_EXTS = new Set([
|
|
18
|
+
'png',
|
|
19
|
+
'jpg',
|
|
20
|
+
'jpeg',
|
|
21
|
+
'gif',
|
|
22
|
+
'webp',
|
|
23
|
+
'avif',
|
|
24
|
+
]);
|