@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,447 @@
1
+ /**
2
+ * @file Shared component structure and output path resolution.
3
+ *
4
+ * The helpers here keep source-root discovery, Rollup entry keys, copied asset
5
+ * destinations, Twig namespaces, and Storybook roots aligned. Resolved
6
+ * structure objects are memoized by environment object identity for one
7
+ * process.
8
+ */
9
+
10
+ import { basename, relative, resolve, sep } from 'path';
11
+ import { safeExists } from './utils/fs-safe.js';
12
+ import { replaceLastSlash, toPosixPath } from './utils/paths.js';
13
+ import { unique } from './utils/unique.js';
14
+
15
+ export { replaceLastSlash, toPosixPath };
16
+
17
+ /**
18
+ * Cache resolved structures for environment objects without keeping them alive.
19
+ *
20
+ * @type {WeakMap<object, object>}
21
+ */
22
+ let projectStructureCache = new WeakMap();
23
+
24
+ /** @type {object|null} */
25
+ let defaultProjectStructureCache = null;
26
+
27
+ /** Strip a JS, JSX, or SCSS extension from an output key. */
28
+ const stripAssetExtension = (filePath) =>
29
+ filePath.replace(/\.(scss|jsx?)$/i, '');
30
+
31
+ /** Insert "/css|js" bucket unless SDC=true; strip extension. */
32
+ export function injectBucket(rel, bucket, SDC) {
33
+ const withoutExt = stripAssetExtension(rel);
34
+ if (SDC) {
35
+ return bucket === 'css' ? `${withoutExt}__style` : withoutExt;
36
+ }
37
+ return replaceLastSlash(rel, `/${bucket}/`).replace(/\.(scss|jsx?)$/i, '');
38
+ }
39
+
40
+ /**
41
+ * Relativize an absolute path from a base directory using POSIX separators.
42
+ *
43
+ * @param {string} absPath - Absolute file path.
44
+ * @param {string} baseDir - Absolute base directory.
45
+ * @returns {string} POSIX relative path.
46
+ */
47
+ export function relativeFrom(absPath, baseDir) {
48
+ return toPosixPath(relative(baseDir, absPath));
49
+ }
50
+
51
+ /**
52
+ * Determine whether a file is inside a source root.
53
+ *
54
+ * @param {string} absPath - Absolute file path.
55
+ * @param {string} rootDir - Absolute root path.
56
+ * @returns {boolean} TRUE when the file is inside the root.
57
+ */
58
+ function isInsideRoot(absPath, rootDir) {
59
+ const rel = relative(rootDir, absPath);
60
+ return Boolean(rel) && !rel.startsWith('..') && !rel.includes(`..${sep}`);
61
+ }
62
+
63
+ /**
64
+ * Determine whether a path is the same as a root or inside it.
65
+ *
66
+ * @param {string} absPath - Absolute file path.
67
+ * @param {string} rootDir - Absolute root path.
68
+ * @returns {boolean} TRUE when the path is inside or equal to the root.
69
+ */
70
+ function isSameOrInsideRoot(absPath, rootDir) {
71
+ return absPath === rootDir || isInsideRoot(absPath, rootDir);
72
+ }
73
+
74
+ /**
75
+ * Find the first source root containing a file.
76
+ *
77
+ * @param {string} absPath - Absolute file path.
78
+ * @param {{name: string, directory: string}[]} roots - Source root records.
79
+ * @returns {{name: string, directory: string}|null} Matching root record.
80
+ */
81
+ export function findSourceRoot(absPath, roots = []) {
82
+ return roots.find((root) => isInsideRoot(absPath, root.directory)) || null;
83
+ }
84
+
85
+ /**
86
+ * Build fallback component roots for non-variant projects.
87
+ *
88
+ * The recommended `src/components` root wins when present. If a project has a
89
+ * `src/` directory but no `src/components`, root `./components` remains a valid
90
+ * canonical component source for upgrades.
91
+ *
92
+ * @param {{projectDir: string, srcDir: string, srcExists: boolean}} env
93
+ * @returns {{name: string, directory: string}[]} Component source roots.
94
+ */
95
+ function fallbackComponentRoots({ projectDir, srcDir, srcExists }) {
96
+ const primary =
97
+ basename(srcDir) === 'components' ? srcDir : resolve(srcDir, 'components');
98
+ const rootComponents = resolve(projectDir, 'components');
99
+ const candidates = unique(
100
+ [primary, ...(srcExists ? [rootComponents] : []), rootComponents].filter(
101
+ Boolean,
102
+ ),
103
+ );
104
+ const selected = candidates.find(safeExists) || primary;
105
+
106
+ return [{ name: 'components', directory: selected }];
107
+ }
108
+
109
+ /**
110
+ * Build fallback global roots for non-variant projects.
111
+ *
112
+ * @param {{srcDir: string, srcExists: boolean}} env - Project environment.
113
+ * @returns {{name: string, directory: string}[]} Global source roots.
114
+ */
115
+ function fallbackGlobalRoots({ srcDir, srcExists }) {
116
+ return srcExists ? [{ name: 'global', directory: srcDir }] : [];
117
+ }
118
+
119
+ /**
120
+ * Build Twig namespace roots for explicit structure implementations.
121
+ *
122
+ * @param {{name: string, directory: string}[]} structureImplementations
123
+ * @returns {Record<string, string>} Namespace roots.
124
+ */
125
+ function implementationNamespaceRoots(structureImplementations) {
126
+ const namespaceRoots = {};
127
+
128
+ for (const implementation of structureImplementations) {
129
+ if (!implementation.name || namespaceRoots[implementation.name]) continue;
130
+ namespaceRoots[implementation.name] = implementation.directory;
131
+ }
132
+
133
+ if (!namespaceRoots.components && structureImplementations[0]?.directory) {
134
+ namespaceRoots.components = structureImplementations[0].directory;
135
+ }
136
+
137
+ return namespaceRoots;
138
+ }
139
+
140
+ /**
141
+ * Build Twig namespace roots for legacy/non-variant projects.
142
+ *
143
+ * @param {{projectDir: string, srcDir: string, srcExists: boolean, componentRootRecords: {name: string, directory: string}[]}} env
144
+ * @returns {Record<string, string>} Namespace roots.
145
+ */
146
+ function fallbackNamespaceRoots({
147
+ projectDir,
148
+ srcDir,
149
+ srcExists,
150
+ componentRootRecords,
151
+ }) {
152
+ const namespaceRoots = {};
153
+ const componentRoot = componentRootRecords[0]?.directory;
154
+
155
+ if (componentRoot && safeExists(componentRoot)) {
156
+ namespaceRoots.components = componentRoot;
157
+ }
158
+
159
+ const layoutRoot = unique(
160
+ [
161
+ ...(srcExists ? [resolve(srcDir, 'layout')] : []),
162
+ resolve(projectDir, 'src/layout'),
163
+ resolve(projectDir, 'layout'),
164
+ ].filter(Boolean),
165
+ ).find(safeExists);
166
+ const tokensRoot = unique(
167
+ [
168
+ ...(srcExists ? [resolve(srcDir, 'tokens')] : []),
169
+ resolve(projectDir, 'src/tokens'),
170
+ resolve(projectDir, 'tokens'),
171
+ ].filter(Boolean),
172
+ ).find(safeExists);
173
+
174
+ if (layoutRoot) {
175
+ namespaceRoots.layout = layoutRoot;
176
+ }
177
+ if (tokensRoot) {
178
+ namespaceRoots.tokens = tokensRoot;
179
+ }
180
+
181
+ return namespaceRoots;
182
+ }
183
+
184
+ /**
185
+ * Resolve the serializable project structure model.
186
+ *
187
+ * @param {{
188
+ * projectDir?: string,
189
+ * srcDir?: string,
190
+ * srcExists?: boolean,
191
+ * SDC?: boolean,
192
+ * structureImplementations?: {name: string, directory: string}[],
193
+ * platformAdapter?: object
194
+ * }} [env] - Normalized project environment.
195
+ * @returns {object} Project structure model.
196
+ */
197
+ export function resolveProjectStructure(env) {
198
+ const cacheableEnv = env && typeof env === 'object' ? env : null;
199
+ if (cacheableEnv?.projectStructure) {
200
+ return cacheableEnv.projectStructure;
201
+ }
202
+ if (cacheableEnv && projectStructureCache.has(cacheableEnv)) {
203
+ return projectStructureCache.get(cacheableEnv);
204
+ }
205
+ if (!cacheableEnv && defaultProjectStructureCache) {
206
+ return defaultProjectStructureCache;
207
+ }
208
+
209
+ const defaultProjectDir = process.cwd();
210
+ const defaultSrcDir = resolve(defaultProjectDir, 'src');
211
+ const resolvedEnv = cacheableEnv || {};
212
+ const {
213
+ projectDir = defaultProjectDir,
214
+ srcDir = defaultSrcDir,
215
+ srcExists = safeExists(defaultSrcDir),
216
+ SDC = false,
217
+ platformAdapter = {},
218
+ } = resolvedEnv;
219
+ const structureImplementations =
220
+ Array.isArray(resolvedEnv.structureImplementations) &&
221
+ resolvedEnv.structureImplementations.length
222
+ ? resolvedEnv.structureImplementations
223
+ : Array.isArray(resolvedEnv.structureRoots) &&
224
+ resolvedEnv.structureOverrides
225
+ ? resolvedEnv.structureRoots.map((directory, index) => ({
226
+ name: index === 0 ? 'components' : `structure-${index + 1}`,
227
+ directory,
228
+ }))
229
+ : [];
230
+ const structureOverrides = structureImplementations.length > 0;
231
+ const componentRootRecords = structureOverrides
232
+ ? structureImplementations
233
+ : fallbackComponentRoots({ projectDir, srcDir, srcExists });
234
+ const globalRootRecords = structureOverrides
235
+ ? []
236
+ : fallbackGlobalRoots({ srcDir, srcExists });
237
+ const namespaceRoots = structureOverrides
238
+ ? implementationNamespaceRoots(structureImplementations)
239
+ : fallbackNamespaceRoots({
240
+ projectDir,
241
+ srcDir,
242
+ srcExists,
243
+ componentRootRecords,
244
+ });
245
+ const componentRoots = componentRootRecords.map((root) => root.directory);
246
+ const globalRoots = globalRootRecords.map((root) => root.directory);
247
+ const namespaceRootValues = Object.values(namespaceRoots);
248
+ const sourceRoots = unique(
249
+ [...componentRoots, ...globalRoots].filter(Boolean),
250
+ );
251
+ const sourceRootRecords = [...componentRootRecords, ...globalRootRecords];
252
+ const componentStoryRoots = srcExists
253
+ ? componentRoots.filter((root) => !isSameOrInsideRoot(root, srcDir))
254
+ : componentRoots;
255
+ const storyRoots = structureOverrides
256
+ ? componentRoots
257
+ : unique(
258
+ [...(srcExists ? [srcDir] : []), ...componentStoryRoots].filter(
259
+ Boolean,
260
+ ),
261
+ );
262
+ const twigRoots = unique(
263
+ [
264
+ ...componentRoots,
265
+ ...namespaceRootValues,
266
+ ...(structureOverrides ? [] : [srcDir]),
267
+ ].filter(Boolean),
268
+ );
269
+ const mirrorComponentOutput = Boolean(
270
+ srcExists &&
271
+ !structureOverrides &&
272
+ platformAdapter?.build?.mirrorDistComponentsToRoot,
273
+ );
274
+
275
+ const structure = {
276
+ structureOverrides,
277
+ componentRootRecords,
278
+ globalRootRecords,
279
+ componentRoots,
280
+ globalRoots,
281
+ sourceRoots,
282
+ sourceRootRecords,
283
+ storyRoots,
284
+ twigRoots,
285
+ namespaceRoots,
286
+ output: {
287
+ components: 'components',
288
+ global: 'global',
289
+ js: 'js',
290
+ css: 'css',
291
+ storybook: 'storybook',
292
+ },
293
+ outputStrategy: platformAdapter?.outputStrategy || 'dist',
294
+ outputMode: platformAdapter?.outputStrategy || 'dist',
295
+ mirrorComponentOutput,
296
+ SDC: Boolean(SDC),
297
+ };
298
+
299
+ if (cacheableEnv) {
300
+ projectStructureCache.set(cacheableEnv, structure);
301
+ } else {
302
+ defaultProjectStructureCache = structure;
303
+ }
304
+
305
+ return structure;
306
+ }
307
+
308
+ /**
309
+ * Clear the process-local project structure memoization cache.
310
+ *
311
+ * @returns {void}
312
+ */
313
+ export function resetProjectStructureCache() {
314
+ projectStructureCache = new WeakMap();
315
+ defaultProjectStructureCache = null;
316
+ }
317
+
318
+ /**
319
+ * Resolve the legacy relative key used by variant structure entries.
320
+ *
321
+ * Existing structure override builds strip the path below the first
322
+ * `components/` segment when one exists, otherwise they keep the project
323
+ * relative path. Preserve that behavior for entry-key compatibility.
324
+ *
325
+ * @param {string} filePath - Absolute source file path.
326
+ * @param {object} structure - Project structure model.
327
+ * @param {string} projectDir - Absolute project root.
328
+ * @returns {string} Project-relative key segment.
329
+ */
330
+ function legacyStructureRelative(filePath, structure, projectDir) {
331
+ const relFromProject = relativeFrom(filePath, projectDir);
332
+ const componentRoot = findSourceRoot(
333
+ filePath,
334
+ structure.componentRootRecords,
335
+ );
336
+ if (componentRoot?.name === 'components') {
337
+ return relativeFrom(filePath, componentRoot.directory);
338
+ }
339
+ return relFromProject;
340
+ }
341
+
342
+ /**
343
+ * Resolve an output key for compiled JS or CSS.
344
+ *
345
+ * @param {string} filePath - Absolute source file path.
346
+ * @param {'js'|'css'} type - Asset type.
347
+ * @param {object} structure - Project structure model.
348
+ * @param {{projectDir: string, srcDir: string, SDC?: boolean}} ctx - Build context.
349
+ * @returns {string|null} Output key without extension.
350
+ */
351
+ export function compiledAssetOutputPath(filePath, type, structure, ctx) {
352
+ const bucket = type === 'css' ? 'css' : 'js';
353
+ const outputBase =
354
+ bucket === 'css' ? structure.output.css : structure.output.js;
355
+
356
+ if (structure.structureOverrides) {
357
+ const rel = legacyStructureRelative(filePath, structure, ctx.projectDir);
358
+ return `${outputBase}/${stripAssetExtension(rel)}`;
359
+ }
360
+
361
+ const componentRoot = findSourceRoot(
362
+ filePath,
363
+ structure.componentRootRecords,
364
+ );
365
+ if (componentRoot) {
366
+ const rel = relativeFrom(filePath, componentRoot.directory);
367
+ return `${structure.output.components}/${injectBucket(
368
+ `${structure.output.components}/${rel}`,
369
+ bucket,
370
+ ctx.SDC,
371
+ ).replace(/^components\//, '')}`;
372
+ }
373
+
374
+ const globalRoot = findSourceRoot(filePath, structure.globalRootRecords);
375
+ if (globalRoot) {
376
+ const rel = relativeFrom(filePath, globalRoot.directory);
377
+ return `${structure.output.global}/${injectBucket(rel, bucket, ctx.SDC)}`;
378
+ }
379
+
380
+ return null;
381
+ }
382
+
383
+ /**
384
+ * Resolve an output key for Storybook/component-library SCSS.
385
+ *
386
+ * @param {string} filePath - Absolute source file path.
387
+ * @param {object} structure - Project structure model.
388
+ * @param {{projectDir: string, srcDir: string}} ctx - Build context.
389
+ * @returns {string} Output key without extension.
390
+ */
391
+ export function storybookStyleOutputPath(filePath, structure, ctx) {
392
+ const sourceRoot = findSourceRoot(filePath, structure.sourceRootRecords);
393
+ const relFromSrc = relativeFrom(filePath, ctx.srcDir);
394
+ const rel =
395
+ structure.structureOverrides || relFromSrc.startsWith('../')
396
+ ? structure.structureOverrides
397
+ ? relativeFrom(filePath, ctx.projectDir)
398
+ : relativeFrom(filePath, sourceRoot?.directory || ctx.srcDir)
399
+ : relFromSrc;
400
+
401
+ return `${structure.output.storybook}/${rel.replace(/\.scss$/i, '')}`;
402
+ }
403
+
404
+ /**
405
+ * Resolve copied component file destination relative to Vite outDir.
406
+ *
407
+ * @param {string} filePath - Absolute source file path.
408
+ * @param {object} structure - Project structure model.
409
+ * @returns {string|null} OutDir-relative destination.
410
+ */
411
+ export function copiedComponentOutputPath(filePath, structure) {
412
+ const componentRoot = findSourceRoot(
413
+ filePath,
414
+ structure.componentRootRecords,
415
+ );
416
+ if (!componentRoot) return null;
417
+
418
+ const rel = relativeFrom(filePath, componentRoot.directory);
419
+ const base = structure.structureOverrides
420
+ ? componentRoot.name
421
+ : structure.output.components;
422
+ return `${base}/${rel}`;
423
+ }
424
+
425
+ /**
426
+ * Resolve copied global file destination relative to Vite outDir.
427
+ *
428
+ * @param {string} filePath - Absolute source file path.
429
+ * @param {object} structure - Project structure model.
430
+ * @returns {string|null} OutDir-relative destination.
431
+ */
432
+ export function copiedGlobalOutputPath(filePath, structure) {
433
+ const globalRoot = findSourceRoot(filePath, structure.globalRootRecords);
434
+ if (!globalRoot) return null;
435
+
436
+ const rel = relativeFrom(filePath, globalRoot.directory);
437
+ if (
438
+ rel === 'components' ||
439
+ rel.startsWith('components/') ||
440
+ rel === 'util' ||
441
+ rel.startsWith('util/')
442
+ ) {
443
+ return null;
444
+ }
445
+
446
+ return `${structure.output.global}/${rel}`;
447
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * @file Optional Twig.js extension resolution for Storybook and Vite Twig rendering.
3
+ */
4
+
5
+ import { createRequire } from 'node:module';
6
+
7
+ const require = createRequire(import.meta.url);
8
+
9
+ export const DRUPAL_TWIG_FILTERS_MODULE_SPECIFIER =
10
+ '@emulsify/core/storybook/twig/drupal-filters';
11
+
12
+ /**
13
+ * Determine whether Drupal-compatible Twig.js filters should be registered.
14
+ *
15
+ * @param {object} [env={}] - Normalized Emulsify environment or Twig options.
16
+ * @returns {boolean} TRUE when Drupal filter registration is enabled.
17
+ */
18
+ export function shouldRegisterDrupalTwigFilters(env = {}) {
19
+ const configuredValue =
20
+ env?.storybook?.registerDrupalTwigFilters ??
21
+ env?.projectConfig?.storybook?.registerDrupalTwigFilters;
22
+
23
+ if (typeof configuredValue === 'boolean') {
24
+ return configuredValue;
25
+ }
26
+
27
+ return Boolean(
28
+ env?.registerDrupalTwigFilters ||
29
+ env?.platformAdapter?.storybook?.registerDrupalTwigFilters,
30
+ );
31
+ }
32
+
33
+ /**
34
+ * Build browser import specifiers for configured Twig.js extension installers.
35
+ *
36
+ * @param {object} [env={}] - Normalized Emulsify environment or Twig options.
37
+ * @returns {string[]} Import specifiers for extension installer modules.
38
+ */
39
+ export function twigExtensionModuleSpecifiers(env = {}) {
40
+ return shouldRegisterDrupalTwigFilters(env)
41
+ ? [DRUPAL_TWIG_FILTERS_MODULE_SPECIFIER]
42
+ : [];
43
+ }
44
+
45
+ /**
46
+ * Normalize a CommonJS or ESM module into an extension installer function.
47
+ *
48
+ * @param {*} moduleValue - Imported or required extension module.
49
+ * @returns {Function|undefined} Twig.js extension installer.
50
+ */
51
+ function normalizeInstaller(moduleValue) {
52
+ const installer = moduleValue?.default ?? moduleValue;
53
+ return typeof installer === 'function' ? installer : undefined;
54
+ }
55
+
56
+ /**
57
+ * Register configured Twig.js extension modules in Node-side Twig instances.
58
+ *
59
+ * @param {object} twig - Twig.js instance.
60
+ * @param {object} [env={}] - Normalized Emulsify environment or Twig options.
61
+ * @returns {object} The provided Twig.js instance.
62
+ */
63
+ export function registerConfiguredTwigExtensions(twig, env = {}) {
64
+ if (shouldRegisterDrupalTwigFilters(env)) {
65
+ const installer = normalizeInstaller(require('twig-drupal-filters'));
66
+ if (installer) {
67
+ installer(twig);
68
+ }
69
+ }
70
+
71
+ return twig;
72
+ }
73
+
74
+ /**
75
+ * Generate the browser virtual module used by Storybook and Twig modules.
76
+ *
77
+ * @param {object} [env={}] - Normalized Emulsify environment.
78
+ * @returns {string} JavaScript module source.
79
+ */
80
+ export function generateTwigExtensionInstallersModule(env = {}) {
81
+ const specifiers = twigExtensionModuleSpecifiers(env);
82
+ const imports = specifiers
83
+ .map((specifier, index) => {
84
+ const variableName = `twigExtension${index}`;
85
+ return `import ${variableName} from ${JSON.stringify(specifier)};`;
86
+ })
87
+ .join('\n');
88
+ const installerNames = specifiers
89
+ .map((_, index) => `twigExtension${index}`)
90
+ .join(', ');
91
+
92
+ return `
93
+ ${imports}
94
+
95
+ const installers = [${installerNames}].filter(
96
+ (installer) => typeof installer === 'function',
97
+ );
98
+
99
+ export const twigExtensionInstallers = installers;
100
+
101
+ export function registerConfiguredTwigExtensions(Twig) {
102
+ for (const installer of installers) {
103
+ installer(Twig);
104
+ }
105
+
106
+ return Twig;
107
+ }
108
+ `;
109
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @file Safe filesystem helpers for Vite config and scripts.
3
+ */
4
+
5
+ import { existsSync, readFileSync } from 'fs';
6
+
7
+ /**
8
+ * Determine whether a path exists without throwing on inaccessible files.
9
+ *
10
+ * @param {string} filePath - Absolute or relative filesystem path.
11
+ * @returns {boolean} TRUE when the path exists.
12
+ */
13
+ export function safeExists(filePath) {
14
+ try {
15
+ return existsSync(filePath);
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Read a file without throwing when it is missing or inaccessible.
23
+ *
24
+ * @param {string} filePath - Absolute or relative filesystem path.
25
+ * @param {BufferEncoding} [encoding='utf8'] - File encoding.
26
+ * @returns {string} File contents, or an empty string when unavailable.
27
+ */
28
+ export function safeReadFile(filePath, encoding = 'utf8') {
29
+ try {
30
+ return readFileSync(filePath, encoding);
31
+ } catch {
32
+ return '';
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Read and parse a JSON file without throwing on missing files.
38
+ *
39
+ * Missing or empty files return an empty object. Invalid JSON returns the parse
40
+ * error so callers that report diagnostics can preserve that behavior.
41
+ *
42
+ * @param {string} filePath - Absolute or relative JSON file path.
43
+ * @returns {{data?: *, error?: Error}} Parsed result or parse error.
44
+ */
45
+ export function safeReadJson(filePath) {
46
+ const source = safeReadFile(filePath);
47
+ if (!source) {
48
+ return {};
49
+ }
50
+
51
+ try {
52
+ return { data: JSON.parse(source) };
53
+ } catch (error) {
54
+ return { error };
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Return the first existing path from a candidate list.
60
+ *
61
+ * @param {string[]} candidates - Candidate filesystem paths.
62
+ * @returns {string|undefined} First existing path, when found.
63
+ */
64
+ export function firstExistingPath(candidates = []) {
65
+ return candidates.filter(Boolean).find((candidate) => safeExists(candidate));
66
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @file Shared filesystem path helpers for Vite config and scripts.
3
+ */
4
+
5
+ import { sep } from 'path';
6
+
7
+ /**
8
+ * Normalize a filesystem path to POSIX separators.
9
+ *
10
+ * Splitting on the host separator preserves existing path behavior on Windows,
11
+ * while the backslash replacement also normalizes Windows-style paths handled
12
+ * on non-Windows hosts.
13
+ *
14
+ * @param {string} filePath - Filesystem path.
15
+ * @returns {string} Path using forward slashes.
16
+ */
17
+ export function toPosix(filePath) {
18
+ return filePath.split(sep).join('/').replace(/\\/g, '/');
19
+ }
20
+
21
+ /**
22
+ * Normalize a filesystem path to POSIX separators.
23
+ *
24
+ * @param {string} filePath - Filesystem path.
25
+ * @returns {string} Path using forward slashes.
26
+ */
27
+ export const toPosixPath = toPosix;
28
+
29
+ /**
30
+ * Replace the final slash in a POSIX path with a custom segment.
31
+ *
32
+ * @param {string} filePath - POSIX-style path.
33
+ * @param {string} replacement - Replacement string for the final slash.
34
+ * @returns {string} Path with the final slash replaced, or the original path.
35
+ */
36
+ export function replaceLastSlash(filePath, replacement) {
37
+ const index = filePath.lastIndexOf('/');
38
+ if (index === -1) return filePath;
39
+ return filePath.slice(0, index) + replacement + filePath.slice(index + 1);
40
+ }