@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,311 @@
1
+ /**
2
+ * @file Webpack require.context compatibility for Vite.
3
+ *
4
+ * Some existing Emulsify stories still use Webpack's static
5
+ * `require.context()` helper to enumerate asset names. Vite does not define
6
+ * `require` in browser modules, so this plugin rewrites static calls to
7
+ * equivalent eager `import.meta.glob()` maps before import analysis runs.
8
+ */
9
+
10
+ import { existsSync, readdirSync } from 'fs';
11
+ import { dirname, relative, resolve } from 'path';
12
+ import { toPosixPath } from '../utils/paths.js';
13
+
14
+ const REQUIRE_CONTEXT_PATTERN =
15
+ /require\.context\(\s*(['"`])([^'"`]+)\1\s*,\s*(true|false)\s*,\s*\/((?:\\.|[^/\\])+)\/([dgimsuvy]*)\s*,?\s*\)/g;
16
+ const STATIC_ASSET_CONTEXT_EXTS = new Set([
17
+ 'avif',
18
+ 'eot',
19
+ 'gif',
20
+ 'ico',
21
+ 'jpeg',
22
+ 'jpg',
23
+ 'otf',
24
+ 'pdf',
25
+ 'png',
26
+ 'svg',
27
+ 'ttf',
28
+ 'webp',
29
+ 'woff',
30
+ 'woff2',
31
+ ]);
32
+
33
+ const REQUIRE_CONTEXT_HELPER = `
34
+ const __emulsifyRequireContext = (modules, basePath, matcher) => {
35
+ const normalizeKey = (key) => key.startsWith('./') ? key : \`./\${key}\`;
36
+ const moduleKey = (key) => basePath + normalizeKey(key).slice(2);
37
+ const context = (key) => modules[moduleKey(key)];
38
+
39
+ context.keys = () =>
40
+ Object.keys(modules)
41
+ .map((key) => \`./\${key.slice(basePath.length)}\`)
42
+ .filter((key) => matcher.test(key))
43
+ .sort();
44
+ context.resolve = (key) => moduleKey(key);
45
+ context.id = basePath;
46
+
47
+ return context;
48
+ };
49
+
50
+ const __emulsifyRequireContextFromKeys = (keys, basePath, publicBasePath) => {
51
+ const normalizedKeys = keys.slice().sort();
52
+ const keySet = new Set(normalizedKeys);
53
+ const normalizeKey = (key) => key.startsWith('./') ? key : \`./\${key}\`;
54
+ const moduleKey = (key) => basePath + normalizeKey(key).slice(2);
55
+ const publicKey = (key) =>
56
+ publicBasePath ? publicBasePath + normalizeKey(key).slice(2) : moduleKey(key);
57
+ const context = (key) => keySet.has(normalizeKey(key)) ? publicKey(key) : undefined;
58
+
59
+ context.keys = () => normalizedKeys.slice();
60
+ context.resolve = (key) => moduleKey(key);
61
+ context.id = basePath;
62
+
63
+ return context;
64
+ };
65
+ `.trim();
66
+
67
+ /**
68
+ * Determine whether a request is JavaScript-like source Vite should transform.
69
+ *
70
+ * @param {string} id - Vite module id.
71
+ * @returns {boolean} TRUE when the id is transformable JavaScript source.
72
+ */
73
+ const isJavaScriptRequest = (id) =>
74
+ /\.[cm]?[jt]sx?(?:\?|$)/.test(id) && !id.includes('/node_modules/');
75
+
76
+ /**
77
+ * Normalize a require.context directory argument for Vite glob keys.
78
+ *
79
+ * @param {string} request - Static directory request from require.context.
80
+ * @returns {string} Directory request with a trailing slash.
81
+ */
82
+ const normalizeBasePath = (request) => {
83
+ if (request.endsWith('/')) return request;
84
+ if (request === '.') return './';
85
+ return `${request}/`;
86
+ };
87
+
88
+ /**
89
+ * Build a focused glob tail for the common extension-only regex shape.
90
+ *
91
+ * @param {string} regexSource - Source from a JavaScript regex literal.
92
+ * @returns {string} Glob tail.
93
+ */
94
+ const globTailFromRegex = (regexSource) => {
95
+ const singleExtension = regexSource.match(/^\\\.([A-Za-z0-9]+)\$$/);
96
+ if (singleExtension) {
97
+ return `*.${singleExtension[1]}`;
98
+ }
99
+
100
+ const extensionGroup = regexSource.match(/^\\\.\(([-A-Za-z0-9_|]+)\)\$$/);
101
+ if (extensionGroup) {
102
+ return `*.{${extensionGroup[1].replaceAll('|', ',')}}`;
103
+ }
104
+
105
+ return '*';
106
+ };
107
+
108
+ /**
109
+ * Extract extension names from regexes that can be represented by a file glob.
110
+ *
111
+ * @param {string} regexSource - Source from a JavaScript regex literal.
112
+ * @returns {string[]} Lowercase extension names.
113
+ */
114
+ const extensionsFromRegex = (regexSource) => {
115
+ const singleExtension = regexSource.match(/^\\\.([A-Za-z0-9]+)\$$/);
116
+ if (singleExtension) {
117
+ return [singleExtension[1].toLowerCase()];
118
+ }
119
+
120
+ const extensionGroup = regexSource.match(/^\\\.\(([-A-Za-z0-9_|]+)\)\$$/);
121
+ if (extensionGroup) {
122
+ return extensionGroup[1]
123
+ .split('|')
124
+ .filter(Boolean)
125
+ .map((extension) => extension.toLowerCase());
126
+ }
127
+
128
+ return [];
129
+ };
130
+
131
+ /**
132
+ * Remove stateful regex flags so repeated matcher.test() calls stay stable.
133
+ *
134
+ * @param {string} flags - Flags from a JavaScript regex literal.
135
+ * @returns {string} Non-stateful regex flags.
136
+ */
137
+ const normalizeRegexFlags = (flags) => flags.replace(/[gy]/g, '');
138
+
139
+ /**
140
+ * Resolve a static require.context directory against the importing module.
141
+ *
142
+ * @param {string} id - Vite module id.
143
+ * @param {string} request - Static directory request from require.context.
144
+ * @returns {string} Absolute filesystem directory path.
145
+ */
146
+ const resolveContextDirectory = (id, request) => {
147
+ const importer = String(id || '').split('?')[0];
148
+ if (!importer || importer.includes('/node_modules/')) return '';
149
+
150
+ return resolve(dirname(importer), request);
151
+ };
152
+
153
+ /**
154
+ * Find files in a context directory and return Webpack-style context keys.
155
+ *
156
+ * @param {string} directory - Absolute context directory.
157
+ * @param {boolean} recursive - Whether nested directories are included.
158
+ * @param {RegExp} matcher - Context file matcher.
159
+ * @returns {string[]} Sorted `./file.ext` keys.
160
+ */
161
+ function contextKeysFromDirectory(directory, recursive, matcher) {
162
+ const keys = [];
163
+
164
+ const visit = (currentDirectory) => {
165
+ for (const entry of readdirSync(currentDirectory, {
166
+ withFileTypes: true,
167
+ })) {
168
+ const absolutePath = resolve(currentDirectory, entry.name);
169
+ if (entry.isDirectory()) {
170
+ if (recursive) visit(absolutePath);
171
+ continue;
172
+ }
173
+ if (!entry.isFile()) continue;
174
+
175
+ const key = `./${toPosixPath(relative(directory, absolutePath))}`;
176
+ if (matcher.test(key)) {
177
+ keys.push(key);
178
+ }
179
+ }
180
+ };
181
+
182
+ visit(directory);
183
+ return keys.sort();
184
+ }
185
+
186
+ /**
187
+ * Resolve a public URL base for root project asset directories.
188
+ *
189
+ * @param {string} directory - Absolute context directory.
190
+ * @returns {string} Public URL base, or an empty string when unknown.
191
+ */
192
+ const publicBasePathForDirectory = (directory) => {
193
+ const relativeDirectory = toPosixPath(relative(process.cwd(), directory));
194
+
195
+ return relativeDirectory === 'assets' ||
196
+ relativeDirectory.startsWith('assets/')
197
+ ? `/${relativeDirectory.replace(/\/?$/, '/')}`
198
+ : '';
199
+ };
200
+
201
+ /**
202
+ * Build a key-only context for static assets to avoid module-importing files
203
+ * that Storybook may also serve through staticDirs.
204
+ *
205
+ * @param {{ id?: string, request: string, recursive: boolean, regexSource: string, regexFlags: string, basePath: string }} options
206
+ * Context details from the require.context call.
207
+ * @returns {string|null} Replacement source, or null when the directory cannot be enumerated.
208
+ */
209
+ function staticAssetContextReplacement({
210
+ id,
211
+ request,
212
+ recursive,
213
+ regexSource,
214
+ regexFlags,
215
+ basePath,
216
+ }) {
217
+ const extensions = extensionsFromRegex(regexSource);
218
+ if (
219
+ !extensions.length ||
220
+ !extensions.every((extension) => STATIC_ASSET_CONTEXT_EXTS.has(extension))
221
+ ) {
222
+ return null;
223
+ }
224
+
225
+ const directory = resolveContextDirectory(id, request);
226
+ if (!directory || !existsSync(directory)) return null;
227
+
228
+ const matcher = new RegExp(regexSource, normalizeRegexFlags(regexFlags));
229
+ const keys = contextKeysFromDirectory(directory, recursive, matcher);
230
+ const publicBasePath = publicBasePathForDirectory(directory);
231
+
232
+ return (
233
+ '__emulsifyRequireContextFromKeys(' +
234
+ `${JSON.stringify(keys)}, ` +
235
+ `${JSON.stringify(basePath)}, ` +
236
+ `${JSON.stringify(publicBasePath)}` +
237
+ ')'
238
+ );
239
+ }
240
+
241
+ /**
242
+ * Transform static require.context calls into Vite import.meta.glob calls.
243
+ *
244
+ * @param {string} source - JavaScript source.
245
+ * @param {string} [id=''] - Vite module id.
246
+ * @returns {string|null} Transformed source, or null when unchanged.
247
+ */
248
+ export function transformRequireContext(source, id = '') {
249
+ const replacements = [];
250
+ let match;
251
+
252
+ REQUIRE_CONTEXT_PATTERN.lastIndex = 0;
253
+ while ((match = REQUIRE_CONTEXT_PATTERN.exec(source))) {
254
+ const [, , request, recursive, regexSource, regexFlags] = match;
255
+ const basePath = normalizeBasePath(request);
256
+ const globTail = globTailFromRegex(regexSource);
257
+ const globPattern = `${basePath}${recursive === 'true' ? '**/' : ''}${globTail}`;
258
+ const matcherFlags = normalizeRegexFlags(regexFlags);
259
+ const staticAssetReplacement = staticAssetContextReplacement({
260
+ basePath,
261
+ id,
262
+ recursive: recursive === 'true',
263
+ regexFlags,
264
+ regexSource,
265
+ request,
266
+ });
267
+ const replacement =
268
+ staticAssetReplacement ||
269
+ '__emulsifyRequireContext(' +
270
+ `import.meta.glob(${JSON.stringify(globPattern)}, { eager: true, import: 'default' }), ` +
271
+ `${JSON.stringify(basePath)}, ` +
272
+ `new RegExp(${JSON.stringify(regexSource)}, ${JSON.stringify(matcherFlags)})` +
273
+ ')';
274
+
275
+ replacements.push({
276
+ end: REQUIRE_CONTEXT_PATTERN.lastIndex,
277
+ replacement,
278
+ start: match.index,
279
+ });
280
+ }
281
+
282
+ if (!replacements.length) return null;
283
+
284
+ let transformed = source;
285
+ for (const { start, end, replacement } of replacements.reverse()) {
286
+ transformed =
287
+ transformed.slice(0, start) + replacement + transformed.slice(end);
288
+ }
289
+
290
+ return `${REQUIRE_CONTEXT_HELPER}\n\n${transformed}`;
291
+ }
292
+
293
+ /**
294
+ * Rewrite static Webpack require.context calls for Vite-powered stories.
295
+ *
296
+ * @returns {import('vite').PluginOption} Vite plugin.
297
+ */
298
+ export function requireContextCompatPlugin() {
299
+ return {
300
+ name: 'emulsify-require-context-compat',
301
+ enforce: 'pre',
302
+ transform(source, id) {
303
+ if (!isJavaScriptRequest(id) || !source.includes('require.context')) {
304
+ return null;
305
+ }
306
+
307
+ const code = transformRequireContext(source, id);
308
+ return code ? { code, map: null } : null;
309
+ },
310
+ };
311
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * @file Source file discovery helpers shared by Emulsify Vite copy plugins.
3
+ *
4
+ * This module walks resolved source roots once, then exposes filtered views for
5
+ * component and global files so copy plugins share the same filesystem model.
6
+ */
7
+
8
+ import { readdirSync } from 'fs';
9
+ import { join, relative, sep } from 'path';
10
+
11
+ import { relativeFrom } from '../project-structure.js';
12
+
13
+ const DEFAULT_SKIP_DIRS = [
14
+ 'node_modules',
15
+ '.git',
16
+ '.cache',
17
+ '.vite',
18
+ '.out',
19
+ '.coverage',
20
+ 'dist',
21
+ ];
22
+
23
+ /**
24
+ * Depth-first walk to list every file under a given root.
25
+ *
26
+ * @param {string} rootDir - Directory to traverse.
27
+ * @param {{
28
+ * shouldSkipDir?: (dir: string) => boolean,
29
+ * useDefaultSkips?: boolean
30
+ * }} [options] - Traversal options.
31
+ * @returns {string[]} Absolute file paths.
32
+ */
33
+ export function walkFiles(
34
+ rootDir,
35
+ { shouldSkipDir = () => false, useDefaultSkips = true } = {},
36
+ ) {
37
+ const files = [];
38
+ const stack = [rootDir];
39
+
40
+ while (stack.length) {
41
+ const currentDir = stack.pop();
42
+ if (!currentDir) continue;
43
+
44
+ let entryNames = [];
45
+ try {
46
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
47
+ entryNames = readdirSync(currentDir, { withFileTypes: true }).sort(
48
+ (a, b) => a.name.localeCompare(b.name),
49
+ );
50
+ } catch {
51
+ // Skip unreadable directories and keep walking the remaining stack.
52
+ continue;
53
+ }
54
+
55
+ for (const entry of entryNames) {
56
+ const fullPath = join(currentDir, entry.name);
57
+ if (entry.isDirectory()) {
58
+ if (shouldSkipDir(fullPath)) continue;
59
+ if (useDefaultSkips && DEFAULT_SKIP_DIRS.includes(entry.name)) {
60
+ continue;
61
+ }
62
+ stack.push(fullPath);
63
+ } else if (entry.isFile()) {
64
+ files.push(fullPath);
65
+ }
66
+ }
67
+ }
68
+
69
+ return files;
70
+ }
71
+
72
+ /**
73
+ * Determine whether a directory is the same as, or nested inside, another one.
74
+ *
75
+ * @param {string} candidateDir - Directory to test.
76
+ * @param {string} rootDir - Boundary directory.
77
+ * @returns {boolean} TRUE when candidateDir is the root or inside it.
78
+ */
79
+ export function isSameOrInsideDir(candidateDir, rootDir) {
80
+ const rel = relative(rootDir, candidateDir);
81
+ return !rel || (!rel.startsWith('..') && !rel.includes(`..${sep}`));
82
+ }
83
+
84
+ /**
85
+ * Determine whether a file is component metadata copied beside Twig templates.
86
+ *
87
+ * @param {string} filePath - Absolute or relative file path.
88
+ * @returns {boolean} TRUE for component metadata files.
89
+ */
90
+ export const isComponentMetadataFile = (filePath) =>
91
+ /\.component\.(yml|yaml|json)$/i.test(filePath);
92
+
93
+ /**
94
+ * Determine whether a file should be copied by the static asset pass.
95
+ *
96
+ * @param {string} filePath - Absolute or relative file path.
97
+ * @returns {boolean} TRUE for non-code source assets.
98
+ */
99
+ export const isStaticSourceAsset = (filePath) =>
100
+ !/\.(jsx?|scss|twig|map)$/i.test(filePath) &&
101
+ !isComponentMetadataFile(filePath);
102
+
103
+ /**
104
+ * Build the roots that should not be crawled during a global source pass.
105
+ *
106
+ * @param {{ directory: string }} globalRoot - Global source root record.
107
+ * @param {{ directory: string }[]} componentRoots - Component source root records.
108
+ * @returns {string[]} Directory paths to skip.
109
+ */
110
+ const globalTraversalSkipRoots = (globalRoot, componentRoots) => {
111
+ const configuredSkips = [
112
+ join(globalRoot.directory, 'components'),
113
+ join(globalRoot.directory, 'util'),
114
+ ];
115
+ const nestedComponentRoots = componentRoots
116
+ .map((root) => root.directory)
117
+ .filter(
118
+ (directory) =>
119
+ directory !== globalRoot.directory &&
120
+ isSameOrInsideDir(directory, globalRoot.directory),
121
+ );
122
+
123
+ return [...configuredSkips, ...nestedComponentRoots];
124
+ };
125
+
126
+ /**
127
+ * Create a lazy, shared index of files under the resolved project source roots.
128
+ * Accessors return the same array reference between calls for a single index
129
+ * instance once the file tree has been built.
130
+ *
131
+ * @param {object} structure - Resolved project structure.
132
+ * @returns {{
133
+ * all: () => Array<object>,
134
+ * componentFiles: () => Array<object>,
135
+ * globalFiles: () => Array<object>
136
+ * }} Indexed file accessors.
137
+ */
138
+ export function createSourceFileIndex(structure) {
139
+ let indexedFiles = null;
140
+ let componentFilesArr = null;
141
+ let globalFilesArr = null;
142
+
143
+ const indexRoot = (root, rootType, options = {}) =>
144
+ walkFiles(root.directory, options).map((absPath) => ({
145
+ absPath,
146
+ relPath: relativeFrom(absPath, root.directory),
147
+ root,
148
+ rootType,
149
+ }));
150
+
151
+ const build = () => {
152
+ if (indexedFiles) return indexedFiles;
153
+
154
+ componentFilesArr = structure.componentRootRecords.flatMap((root) =>
155
+ indexRoot(root, 'component'),
156
+ );
157
+ globalFilesArr = structure.globalRootRecords.flatMap((root) => {
158
+ const skipRoots = globalTraversalSkipRoots(
159
+ root,
160
+ structure.componentRootRecords,
161
+ );
162
+
163
+ return indexRoot(root, 'global', {
164
+ shouldSkipDir: (directory) =>
165
+ skipRoots.some((skipRoot) => isSameOrInsideDir(directory, skipRoot)),
166
+ });
167
+ });
168
+
169
+ indexedFiles = [...componentFilesArr, ...globalFilesArr];
170
+ return indexedFiles;
171
+ };
172
+
173
+ return {
174
+ all: build,
175
+ componentFiles: () => {
176
+ build();
177
+ return componentFilesArr;
178
+ },
179
+ globalFiles: () => {
180
+ build();
181
+ return globalFilesArr;
182
+ },
183
+ };
184
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * @file SVG sprite file plugin.
3
+ *
4
+ * Builds a physical `dist/assets/icons.svg` sprite from configured SVG globs so
5
+ * Drupal and static consumers can reference a stable emitted spritemap asset.
6
+ */
7
+
8
+ import { readFileSync } from 'fs';
9
+ import { basename } from 'path';
10
+ import { globSync } from 'glob';
11
+
12
+ import { toPosixPath } from '../utils/paths.js';
13
+ import { unique } from '../utils/unique.js';
14
+
15
+ /**
16
+ * Builds a single SVG sprite file from a set of icon globs.
17
+ *
18
+ * @param {{ include: string|string[], symbolId?: string }} options - Plugin options.
19
+ * @returns {import('vite').PluginOption} SVG sprite plugin.
20
+ */
21
+ export function svgSpriteFilePlugin({ include, symbolId = '[name]' }) {
22
+ const toArray = (x) => (Array.isArray(x) ? x : [x]).filter(Boolean);
23
+
24
+ /** @type {string[]} */
25
+ let patterns = [];
26
+ /** @type {string[]} */
27
+ let iconFiles = [];
28
+ let iconFilesResolved = false;
29
+
30
+ const collectIconFiles = () => {
31
+ if (iconFilesResolved) return iconFiles;
32
+ iconFiles = unique(
33
+ patterns.flatMap((p) => globSync(p)).filter(Boolean),
34
+ ).sort((a, b) => toPosixPath(a).localeCompare(toPosixPath(b)));
35
+ iconFilesResolved = true;
36
+ return iconFiles;
37
+ };
38
+
39
+ return {
40
+ name: 'emulsify-svg-sprite-file',
41
+ apply: 'build',
42
+
43
+ /** Register icons for watch. */
44
+ buildStart() {
45
+ patterns = toArray(include).map(toPosixPath);
46
+ iconFilesResolved = false;
47
+ for (const f of collectIconFiles()) {
48
+ try {
49
+ this.addWatchFile(f);
50
+ } catch {
51
+ /* noop */
52
+ }
53
+ }
54
+ },
55
+
56
+ /** Concatenate all matched SVGs into a single sprite. */
57
+ generateBundle() {
58
+ const files = collectIconFiles();
59
+
60
+ if (!files.length) return;
61
+
62
+ const used = new Set();
63
+ const makeId = (abs) => {
64
+ const stem = basename(abs).replace(/\.svg$/i, '');
65
+ let id = symbolId
66
+ .replace('[name]', stem)
67
+ .toLowerCase()
68
+ .replace(/[^a-z0-9_-]+/g, '-')
69
+ .replace(/^-+|-+$/g, '');
70
+ if (!used.has(id)) {
71
+ used.add(id);
72
+ return id;
73
+ }
74
+ let i = 2;
75
+ while (used.has(`${id}-${i}`)) i += 1;
76
+ id = `${id}-${i}`;
77
+ used.add(id);
78
+ return id;
79
+ };
80
+
81
+ const symbols = files
82
+ .map((abs) => {
83
+ let content = '';
84
+ try {
85
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
86
+ content = readFileSync(abs, 'utf8');
87
+ } catch {
88
+ return '';
89
+ }
90
+ const m = content.match(/<svg\b([^>]*)>([\s\S]*?)<\/svg>/i);
91
+ const inner = (m ? m[2] : content)
92
+ .replace(/<\/*symbol[^>]*>/gi, '')
93
+ .replace(/<\/*defs[^>]*>/gi, '')
94
+ // Drop namespace-prefixed attributes that lose their prefix in the merged sprite.
95
+ .replace(/\s+[a-zA-Z0-9_-]+:[a-zA-Z0-9_.-]+="[^"]*"/g, '')
96
+ .trim();
97
+ const attrs = m ? m[1] : '';
98
+ const vb = attrs.match(/\bviewBox="([^"]+)"/i);
99
+ const viewBoxAttr = vb ? ` viewBox="${vb[1]}"` : '';
100
+ return `<symbol id="${makeId(abs)}"${viewBoxAttr}>${inner}</symbol>`;
101
+ })
102
+ .filter(Boolean);
103
+
104
+ const sprite = [
105
+ '<svg xmlns="http://www.w3.org/2000/svg" style="display:none">',
106
+ ...symbols,
107
+ '</svg>\n',
108
+ ].join('\n');
109
+
110
+ this.emitFile({
111
+ type: 'asset',
112
+ fileName: 'assets/icons.svg',
113
+ source: sprite,
114
+ });
115
+ },
116
+ };
117
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @file Virtual Twig.js extension installer module for Storybook.
3
+ */
4
+
5
+ import { generateTwigExtensionInstallersModule } from '../twig-extensions.js';
6
+
7
+ export const VIRTUAL_TWIG_EXTENSION_INSTALLERS_ID =
8
+ 'virtual:emulsify-twig-extension-installers';
9
+
10
+ const resolvedVirtualTwigExtensionInstallersId = `\0${VIRTUAL_TWIG_EXTENSION_INSTALLERS_ID}`;
11
+
12
+ /**
13
+ * Provide configured Twig.js extension installers to browser-rendered modules.
14
+ *
15
+ * @param {object} [env={}] - Normalized Emulsify environment.
16
+ * @returns {import('vite').PluginOption} Vite virtual module plugin.
17
+ */
18
+ export function virtualTwigExtensionInstallersPlugin(env = {}) {
19
+ return {
20
+ name: 'emulsify-virtual-twig-extension-installers',
21
+ resolveId(id) {
22
+ if (id === VIRTUAL_TWIG_EXTENSION_INSTALLERS_ID) {
23
+ return resolvedVirtualTwigExtensionInstallersId;
24
+ }
25
+
26
+ return null;
27
+ },
28
+ load(id) {
29
+ if (id === resolvedVirtualTwigExtensionInstallersId) {
30
+ return generateTwigExtensionInstallersModule(env);
31
+ }
32
+
33
+ return null;
34
+ },
35
+ };
36
+ }