@emulsify/core 3.1.1 → 3.3.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.
@@ -17,13 +17,13 @@ import configOverrides from '../../../../config/emulsify-core/storybook/main.js'
17
17
  * The full path to the current file (ESM compatible).
18
18
  * @type {string}
19
19
  */
20
- const __filename = fileURLToPath(import.meta.url);
20
+ const _filename = fileURLToPath(import.meta.url);
21
21
 
22
22
  /**
23
23
  * The directory name of the current module file.
24
24
  * @type {string}
25
25
  */
26
- const __dirname = path.dirname(__filename);
26
+ const _dirname = path.dirname(_filename);
27
27
 
28
28
  /**
29
29
  * Safely apply any user-provided overrides or fall back to an empty object.
@@ -204,7 +204,7 @@ const config = {
204
204
 
205
205
  // load external manager-head.html if present
206
206
  const externalManagerHeadPath = resolve(
207
- __dirname,
207
+ _dirname,
208
208
  '../../../../config/emulsify-core/storybook/manager-head.html'
209
209
  );
210
210
  let externalManagerHtml = '';
@@ -224,7 +224,7 @@ ${externalManagerHtml}`;
224
224
  */
225
225
  previewHead: (head) => {
226
226
  const externalHeadPath = resolve(
227
- __dirname,
227
+ _dirname,
228
228
  '../../../../config/emulsify-core/storybook/preview-head.html'
229
229
  );
230
230
 
@@ -37,6 +37,25 @@ const postcssConfigPath = fs.existsSync(
37
37
  ? path.resolve('config/emulsify-core/webpack/postcss.config.cjs')
38
38
  : require.resolve('@emulsify/core/config/postcss.config.js');
39
39
 
40
+ /**
41
+ * Resolve the directory of this file (without fileURLToPath).
42
+ * @type {string}
43
+ */
44
+ let _filename = decodeURIComponent(new URL(import.meta.url).pathname);
45
+ if (process.platform === 'win32' && _filename.startsWith('/')) {
46
+ _filename = _filename.slice(1);
47
+ }
48
+ const _dirname = path.dirname(_filename);
49
+
50
+ /**
51
+ * Root of the project (three levels up from this file).
52
+ * @type {string}
53
+ */
54
+ const projectDir = path.resolve(_dirname, '../../../../..');
55
+
56
+ /** Absolute path to the folder that contains sprite source icons. */
57
+ const ICONS_DIR = path.resolve(projectDir, 'assets/icons');
58
+
40
59
  /**
41
60
  * @type {import('webpack').RuleSetRule}
42
61
  * JavaScript loader: transpile with Babel.
@@ -111,22 +130,17 @@ const ImageLoader = {
111
130
 
112
131
  /**
113
132
  * @type {import('webpack').RuleSetRule}
114
- * SVG sprite loader: collects all /icons/*.svg into one sprite.
133
+ * General SVG loader for non-sprite SVGs (logos, illustrations, etc.).
134
+ * IMPORTANT: Excludes `assets/icons/` so `svg-spritemap-webpack-plugin`
135
+ * can consume those files without being intercepted by this rule.
115
136
  */
116
- const SVGSpriteLoader = {
137
+ const SVGLoader = {
117
138
  test: /icons\/.*\.svg$/,
118
- use: [
119
- {
120
- loader: 'svg-sprite-loader',
121
- options: {
122
- extract: true,
123
- esModule: true,
124
- runtimeCompat: true,
125
- outputPath: 'dist/',
126
- spriteFilename: './icons.svg',
127
- },
128
- },
129
- ],
139
+ type: 'asset/resource',
140
+ generator: {
141
+ filename: 'icons.svg',
142
+ },
143
+ exclude: [ICONS_DIR],
130
144
  };
131
145
 
132
146
  /**
@@ -148,6 +162,6 @@ export default {
148
162
  JSLoader,
149
163
  CSSLoader,
150
164
  ImageLoader,
151
- SVGSpriteLoader,
165
+ SVGLoader,
152
166
  TwigLoader,
153
167
  };
@@ -5,7 +5,6 @@ const ImageMinimizer = new ImageMinimizerPlugin({
5
5
  implementation: ImageMinimizerPlugin.imageminMinify,
6
6
  options: {
7
7
  plugins: [
8
- ['gifsicle', { interlaced: true }],
9
8
  ['jpegtran', { progressive: true }],
10
9
  ['optipng', { optimizationLevel: 5 }],
11
10
  ],
@@ -1,8 +1,9 @@
1
- import { resolve, dirname } from 'path';
1
+ import { resolve, dirname, relative } from 'path';
2
2
  import webpack from 'webpack';
3
3
  import { CleanWebpackPlugin } from 'clean-webpack-plugin';
4
+ import RemoveEmptyScriptsPlugin from 'webpack-remove-empty-scripts';
4
5
  import MiniCssExtractPlugin from 'mini-css-extract-plugin';
5
- import SpriteLoaderPlugin from 'svg-sprite-loader/plugin.js';
6
+ import SVGSpritemapPlugin from 'svg-spritemap-webpack-plugin';
6
7
  import CopyPlugin from 'copy-webpack-plugin';
7
8
  import { sync as globSync } from 'glob';
8
9
  import fs from 'fs-extra';
@@ -12,21 +13,21 @@ import emulsifyConfig from '../../../../../project.emulsify.json' with { type: '
12
13
  * Resolve the directory of this file (without fileURLToPath).
13
14
  * @type {string}
14
15
  */
15
- let __filename = decodeURIComponent(new URL(import.meta.url).pathname);
16
- if (process.platform === 'win32' && __filename.startsWith('/')) {
17
- __filename = __filename.slice(1);
16
+ let _filename = decodeURIComponent(new URL(import.meta.url).pathname);
17
+ if (process.platform === 'win32' && _filename.startsWith('/')) {
18
+ _filename = _filename.slice(1);
18
19
  }
19
- const __dirname = dirname(__filename);
20
+ const _dirname = dirname(_filename);
20
21
 
21
22
  /**
22
- * Root of the project (three levels up from this file).
23
+ * Project root (five levels up).
23
24
  * @type {string}
24
25
  */
25
- const projectDir = resolve(__dirname, '../../../../..');
26
+ const projectDir = resolve(_dirname, '../../../../..');
26
27
 
27
28
  /**
28
- * Where your source files live (if you have a `/src` folder).
29
- * Falls back to `components/` if `src/` does not exist.
29
+ * Where source files live.
30
+ * Prefer `<project>/src`; fall back to `<project>/components` (legacy layout).
30
31
  * @type {string}
31
32
  */
32
33
  const srcPath = resolve(projectDir, 'src');
@@ -34,8 +35,8 @@ const isSrcExists = fs.pathExistsSync(srcPath);
34
35
  const srcDir = isSrcExists ? srcPath : resolve(projectDir, 'components');
35
36
 
36
37
  /**
37
- * Where your built assets should live.
38
- * Mirrors the `srcDir` logic: prefer `dist/` if you have `src/`, else `components/`.
38
+ * Where built assets live.
39
+ * If `src/` exists, use `<project>/dist`; else write into `<project>/components`.
39
40
  * @type {string}
40
41
  */
41
42
  const distPath = isSrcExists
@@ -43,8 +44,32 @@ const distPath = isSrcExists
43
44
  : resolve(projectDir, 'components');
44
45
 
45
46
  /**
46
- * Glob pattern for all Twig & component files in your source.
47
- * We copy these through CopyPlugin so your PHP/Drupal theme sees them.
47
+ * Platform switch (affects component output roots).
48
+ * @type {boolean}
49
+ */
50
+ const isDrupal = emulsifyConfig?.project?.platform === 'drupal';
51
+
52
+ /**
53
+ * Component source root:
54
+ * - with src/: `<project>/src/components`
55
+ * - without src/: `<project>/components`
56
+ * @type {string}
57
+ */
58
+ const componentsSrcRoot = isSrcExists ? resolve(srcDir, 'components') : srcDir;
59
+
60
+ /**
61
+ * Component output root (where compiled component assets go):
62
+ * - Drupal + src/: `components/…`
63
+ * - Otherwise: `dist/components/…`
64
+ * (Relative to `projectDir`; used by CopyPlugins `to:` path.)
65
+ * @type {string}
66
+ */
67
+ const componentsOutRoot =
68
+ isDrupal && isSrcExists ? 'components' : 'dist/components';
69
+
70
+ /**
71
+ * Glob pattern for Twig & component meta files. These are copied as-is so
72
+ * Drupal/WordPress themes can consume them alongside compiled assets.
48
73
  * @type {string}
49
74
  */
50
75
  const componentFilesPattern = resolve(
@@ -53,50 +78,158 @@ const componentFilesPattern = resolve(
53
78
  );
54
79
 
55
80
  /**
56
- * Turn a globbed source list into copy patterns.
81
+ * Build CopyPlugin patterns from a glob matcher, preserving source structure.
57
82
  *
58
- * @param {string} filesMatcher Glob pattern.
59
- * @returns {Array<{from:string,to:string}>}
83
+ * @param {string} filesMatcher - Glob for files to mirror.
84
+ * @returns {Array<{from:string,to:string}>} Copy patterns for CopyPlugin.
60
85
  */
61
86
  function getPatterns(filesMatcher) {
62
87
  return globSync(filesMatcher).map((file) => {
63
- const projectPath = file.split('/src/')[0];
88
+ const projectPath = file.split('/src/')[0]; // base path before /src/
64
89
  const srcStructure = file.split(`${srcDir}/`)[1];
65
90
  const parentDir = srcStructure.split('/')[0];
66
- // Consolidate foundation/layout under components for Drupal.
91
+
92
+ // Consolidate foundation/layout under "components" for Drupal.
67
93
  const consolidateDirs =
68
94
  parentDir === 'layout' || parentDir === 'foundation'
69
95
  ? '/components/'
70
96
  : '/';
97
+
71
98
  const filePath = file.split(/(foundation\/|components\/|layout\/)/)[2];
72
- const destDir =
73
- emulsifyConfig.project.platform === 'drupal'
74
- ? `${projectPath}${consolidateDirs}${parentDir}/${filePath}`
75
- : `${projectPath}/dist/${parentDir}/${filePath}`;
76
- return { from: file, to: destDir };
99
+
100
+ const to = isDrupal
101
+ ? `${projectPath}${consolidateDirs}${parentDir}/${filePath}`
102
+ : `${projectPath}/dist/${parentDir}/${filePath}`;
103
+
104
+ return { from: file, to };
77
105
  });
78
106
  }
79
107
 
80
108
  /**
81
- * Only include CopyPlugin if we actually have a `src/` folder.
109
+ * CopyPlugin instance (only when `src/` exists):
110
+ * copies Twig and component meta files 1:1 into their expected destinations.
82
111
  * @type {CopyPlugin|false}
83
112
  */
84
113
  const CopyTwigPlugin = isSrcExists
85
114
  ? new CopyPlugin({ patterns: getPatterns(componentFilesPattern) })
86
115
  : false;
87
116
 
117
+ /* -------------------------------------------------------------------------- */
118
+ /* COMPONENT & GLOBAL ASSETS */
119
+ /* -------------------------------------------------------------------------- */
120
+
121
+ /**
122
+ * Asset allow-list (extensions we consider "static assets" to mirror).
123
+ * Extend to suit your project (e.g., add `pdf`, `txt`, `xml`, etc.).
124
+ * NOTE: We purposefully exclude code-like files via the filter below.
125
+ * @type {RegExp}
126
+ */
127
+ const ASSET_EXT_RE =
128
+ /\.(?:png|jpe?g|gif|svg|webp|avif|ico|bmp|heic|heif|mp4|webm|mp3|ogg|wav|aac|woff2?|ttf|otf|eot|json|webmanifest|manifest|pdf)$/i;
129
+
130
+ /**
131
+ * Exclude code & tooling files (don’t mirror these).
132
+ * @type {RegExp}
133
+ */
134
+ const EXCLUDE_CODE_RE =
135
+ /\.(?:jsx?|tsx?|mjs|cjs|vue|svelte|scss|sass|less|styl|css|map|twig|php|yml|yaml|md|markdown|story(?:book)?\.[jt]sx?|stories\.[jt]sx?|test\.[jt]sx?)$/i;
136
+
137
+ /**
138
+ * Shared filter for CopyPlugin patterns.
139
+ * Decides whether a file should be copied as a "static asset".
140
+ *
141
+ * @param {string} resourcePath - Absolute file path on disk.
142
+ * @param {string} base - The context directory for the pattern.
143
+ * @returns {boolean} True if we should copy the file.
144
+ */
145
+ const assetFilter = (resourcePath, base) => {
146
+ const rel = relative(base, resourcePath);
147
+ // Guard: stay inside context
148
+ if (rel.startsWith('..')) return false;
149
+ // Exclude typical code/tooling files
150
+ if (EXCLUDE_CODE_RE.test(rel)) return false;
151
+ // Include known asset extensions
152
+ return ASSET_EXT_RE.test(rel);
153
+ };
154
+
155
+ /**
156
+ * Copy **all static assets inside components**, regardless of folder labels.
157
+ *
158
+ * Examples (all preserved under the component’s output root):
159
+ * src/components/accordion/assets/dropdown-icon.svg
160
+ * src/components/accordion/images/icons/chevron.svg
161
+ * src/components/accordion/icon.svg (root-level asset)
162
+ *
163
+ * @type {CopyPlugin}
164
+ */
165
+ const CopyComponentAssetsPlugin = new CopyPlugin({
166
+ patterns: [
167
+ {
168
+ // Start at the components root and evaluate every file
169
+ from: '**/*',
170
+ context: componentsSrcRoot,
171
+ to: resolve(projectDir, componentsOutRoot, '[path][name][ext]'),
172
+ noErrorOnMissing: true,
173
+ globOptions: {
174
+ dot: false,
175
+ ignore: [
176
+ '**/.DS_Store',
177
+ '**/Thumbs.db',
178
+ '**/node_modules/**',
179
+ '**/dist/**',
180
+ ],
181
+ },
182
+ // Only copy files that match our asset allow-list and are not code
183
+ filter: (resourcePath) => assetFilter(resourcePath, componentsSrcRoot),
184
+ },
185
+ ],
186
+ });
187
+
188
+ /**
189
+ * OPTIONAL: Copy **global (non-component) assets** that live under `src/`
190
+ * but outside `src/components/` (e.g. layout/site assets).
191
+ *
192
+ * Mirrors them under `dist/global/…`.
193
+ * Disabled when there is no `src/` directory.
194
+ *
195
+ * @type {CopyPlugin|false}
196
+ */
197
+ const CopyGlobalAssetsPlugin = isSrcExists
198
+ ? new CopyPlugin({
199
+ patterns: [
200
+ {
201
+ from: '!(components|util)/**/*',
202
+ context: srcDir,
203
+ to: resolve(projectDir, 'dist', 'global', '[path][name][ext]'),
204
+ noErrorOnMissing: true,
205
+ globOptions: {
206
+ dot: false,
207
+ ignore: [
208
+ '**/.DS_Store',
209
+ '**/Thumbs.db',
210
+ '**/node_modules/**',
211
+ '**/dist/**',
212
+ ],
213
+ },
214
+ filter: (resourcePath) => assetFilter(resourcePath, srcDir),
215
+ },
216
+ ],
217
+ })
218
+ : false;
219
+
220
+ /* -------------------------------------------------------------------------- */
221
+ /* OTHER PLUGINS */
222
+ /* -------------------------------------------------------------------------- */
223
+
88
224
  /**
89
225
  * CleanWebpackPlugin configuration.
90
- * Wipes out everything in `distPath` before a build,
91
- * except image files (we whitelist common image extensions).
226
+ * Wipes out compiled CSS/JS in `distPath` before a build; keeps images.
92
227
  */
93
228
  const CleanPlugin = new CleanWebpackPlugin({
94
229
  protectWebpackAssets: false,
95
230
  cleanOnceBeforeBuildPatterns: [
96
- // wipe all compiled assets
97
231
  `${distPath}/**/*.css`,
98
232
  `${distPath}/**/*.js`,
99
- // but keep any images
100
233
  `!${distPath}/**/*.png`,
101
234
  `!${distPath}/**/*.jpg`,
102
235
  `!${distPath}/**/*.gif`,
@@ -104,33 +237,47 @@ const CleanPlugin = new CleanWebpackPlugin({
104
237
  ],
105
238
  });
106
239
 
240
+ /** Removes empty JS files generated for style-only entries. */
241
+ const RemoveEmptyJS = new RemoveEmptyScriptsPlugin();
242
+
107
243
  /**
108
- * MiniCssExtractPlugin instance: writes `[name].css` into your dist.
244
+ * MiniCssExtractPlugin: emit CSS next to the entry key path (no hard-coded dist/).
109
245
  */
110
246
  const CssExtractPlugin = new MiniCssExtractPlugin({
111
- filename: '[name].css',
112
- chunkFilename: '[id].css',
247
+ filename: ({ chunk }) => `${chunk.name}.css`,
248
+ chunkFilename: ({ chunk }) => `${chunk.name}.css`,
113
249
  });
114
250
 
115
251
  /**
116
- * svg-sprite-loader plugin: bundles all /icons/*.svg.
252
+ * Generate a single SVG spritemap at `dist/icons.svg`.
117
253
  */
118
- const SpritePlugin = new SpriteLoaderPlugin({
119
- plainSprite: true,
120
- });
254
+ const SpritePlugin = new SVGSpritemapPlugin(
255
+ resolve(projectDir, 'assets/icons/**/*.svg'),
256
+ {
257
+ output: {
258
+ filename: 'dist/icons.svg',
259
+ chunk: { keep: true },
260
+ },
261
+ sprite: {
262
+ prefix: '',
263
+ generate: { title: false },
264
+ },
265
+ },
266
+ );
121
267
 
122
- /**
123
- * webpack.ProgressPlugin for nice build progress output.
124
- */
268
+ /** Build progress output. */
125
269
  const ProgressPlugin = new webpack.ProgressPlugin();
126
270
 
127
271
  /**
128
- * Export all plugins keyed for easy inclusion in your final Webpack config.
272
+ * Export plugin instances keyed for easy inclusion in your Webpack config.
129
273
  */
130
274
  export default {
131
275
  ProgressPlugin,
132
276
  CleanWebpackPlugin: CleanPlugin,
277
+ RemoveEmptyJS,
133
278
  MiniCssExtractPlugin: CssExtractPlugin,
134
- SpriteLoaderPlugin: SpritePlugin,
279
+ SpritePlugin,
135
280
  CopyTwigPlugin,
281
+ CopyComponentAssetsPlugin,
282
+ CopyGlobalAssetsPlugin,
136
283
  };
@@ -1,88 +1,154 @@
1
1
  /**
2
2
  * @fileoverview Configures Twig alias resolution for the project.
3
+ * - Builds Twig alias map from files under the source directory
4
+ * - Exposes a Webpack-style `resolve.alias` object for `.twig` files
3
5
  */
4
6
 
5
- import { basename, dirname, resolve } from 'path';
7
+ import {
8
+ basename,
9
+ resolve,
10
+ relative,
11
+ isAbsolute,
12
+ join,
13
+ posix as path,
14
+ } from 'node:path';
6
15
  import { sync as globSync } from 'glob';
7
16
  import fs from 'fs-extra';
8
17
  import emulsifyConfig from '../../../../../project.emulsify.json' with { type: 'json' };
9
18
 
10
- // Create __filename from import.meta.url without fileURLToPath
19
+ /**
20
+ * Resolve the directory of this file (without fileURLToPath).
21
+ * @type {string}
22
+ */
11
23
  let _filename = decodeURIComponent(new URL(import.meta.url).pathname);
12
-
13
- // On Windows, remove the leading slash (e.g. "/C:/path" -> "C:/path")
14
24
  if (process.platform === 'win32' && _filename.startsWith('/')) {
15
25
  _filename = _filename.slice(1);
16
26
  }
27
+ const _dirname = path.dirname(_filename);
17
28
 
18
- const _dirname = dirname(_filename);
19
-
29
+ /** @type {string} Absolute project root (five levels up). */
20
30
  const projectDir = resolve(_dirname, '../../../../..');
21
- const projectName = emulsifyConfig.project.name;
22
- const srcDir = fs.pathExistsSync(resolve(projectDir, 'src'))
23
- ? resolve(projectDir, 'src')
24
- : resolve(projectDir, 'components');
25
31
 
26
- if (!fs.pathExistsSync(resolve(srcDir))) {
27
- fs.mkdirSync(srcDir, { recursive: true });
32
+ /** @type {string} Project machine name used to prefix Drupal aliases. */
33
+ const projectName = String(emulsifyConfig?.project?.name || '').trim();
34
+
35
+ /**
36
+ * Determine the source directory: prefer `<project>/src` if it exists,
37
+ * otherwise use `<project>/components`. If we choose `components` and it
38
+ * does not exist, create it safely inside the project.
39
+ *
40
+ * @returns {string} Absolute path to the source directory.
41
+ */
42
+ function resolveOrCreateSrcDir() {
43
+ const srcPreferred = resolve(projectDir, 'src');
44
+ if (fs.pathExistsSync(srcPreferred)) return srcPreferred;
45
+
46
+ const componentsFallback = resolve(projectDir, 'components');
47
+ if (!fs.pathExistsSync(componentsFallback)) {
48
+ ensureDirSafe(componentsFallback, {
49
+ base: projectDir,
50
+ allowedBasenames: new Set(['components']),
51
+ });
52
+ }
53
+ return componentsFallback;
54
+ }
55
+
56
+ /**
57
+ * Safely create a directory after validating it is a subpath of `base`
58
+ * and its basename is explicitly allowed. This addresses
59
+ * `security/detect-non-literal-fs-filename`.
60
+ *
61
+ * @param {string} dir - Absolute path to create.
62
+ * @param {{ base: string, allowedBasenames: Set<string> }} opts - Safety options.
63
+ * @returns {void}
64
+ * @throws {Error} If the path is outside `base` or not allowed.
65
+ */
66
+ function ensureDirSafe(dir, { base, allowedBasenames }) {
67
+ const rel = relative(base, dir);
68
+ const name = basename(dir);
69
+
70
+ // Block absolute or escaping paths (outside of base)
71
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) {
72
+ throw new Error(`Refusing to create directory outside project: "${dir}"`);
73
+ }
74
+
75
+ // Only allow known, expected directory names
76
+ if (!allowedBasenames.has(name)) {
77
+ throw new Error(`Refusing to create unexpected directory: "${name}"`);
78
+ }
79
+
80
+ // The argument is validated; create the directory.
81
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
82
+ fs.mkdirSync(dir, { recursive: true });
28
83
  }
29
84
 
85
+ /** @type {string} Absolute source directory. */
86
+ const srcDir = resolveOrCreateSrcDir();
87
+
88
+ /** @type {string} Glob pattern for all non-partial Twig files (skip leading underscores). */
30
89
  const aliasPattern = resolve(srcDir, '**/!(_*).twig');
31
90
 
32
91
  /**
33
- * Get all top-level directory names from a source directory.
92
+ * Read immediate subdirectories from a source directory.
34
93
  *
35
- * @param {string} source - The source directory path.
36
- * @returns {string[]} Array of directory names.
94
+ * @param {string} source - Absolute directory to scan.
95
+ * @returns {string[]} Array of directory names (basenames only).
37
96
  */
38
97
  function getDirectories(source) {
39
98
  /* eslint-disable security/detect-non-literal-fs-filename */
40
- const dirs = fs
41
- .readdirSync(source, { withFileTypes: true })
42
- .filter((dirent) => dirent.isDirectory())
43
- .map((dirent) => dirent.name);
99
+ const entries = fs.readdirSync(source, { withFileTypes: true });
44
100
  /* eslint-enable security/detect-non-literal-fs-filename */
45
- return dirs;
101
+ return entries.filter((d) => d.isDirectory()).map((d) => d.name);
46
102
  }
47
103
 
48
104
  /**
49
- * Remove numbering from a directory name if present.
105
+ * Strip a leading two-digit ordering prefix from a directory name
106
+ * (e.g., "01-components" -> "components").
50
107
  *
51
- * @param {string} dir - The original directory name.
52
- * @returns {string} The cleaned directory name.
108
+ * @param {string} dir - Original directory name.
109
+ * @returns {string} Cleaned directory name.
53
110
  */
54
111
  function cleanDirectoryName(dir) {
55
- if (/^\d{2}/.test(dir)) {
56
- return dir.slice(3);
57
- }
58
- return dir;
112
+ return /^\d{2}-/.test(dir) ? dir.slice(3) : dir;
59
113
  }
60
114
 
61
115
  /**
62
- * Generate a set of Twig aliases from a glob pattern.
116
+ * Build a Twig alias object by:
117
+ * - Adding per-file aliases for Drupal (e.g., "mytheme/button")
118
+ * - Adding top-level section aliases (e.g., "@components", "@layout")
63
119
  *
64
- * @param {string} aliasMatcher - The glob pattern to match Twig files.
65
- * @returns {Object} An object containing Twig aliases.
120
+ * @param {string} twigGlob - Glob pattern to locate Twig files.
121
+ * @returns {Record<string, string>} Alias map ({ alias: absolutePath }).
66
122
  */
67
- function getAliases(aliasMatcher) {
68
- let aliases = {};
69
- globSync(aliasMatcher).forEach((file) => {
70
- const filePath = file.split(`${srcDir}/`)[1];
71
- const fileName = basename(filePath);
72
- if (emulsifyConfig.project.platform === 'drupal') {
73
- aliases[`${projectName}/${fileName.replace('.twig', '')}`] = file;
123
+ function getAliases(twigGlob) {
124
+ /** @type {Record<string, string>} */
125
+ const aliases = {};
126
+
127
+ // Per-file aliases for Drupal only: "<projectName>/<filename>"
128
+ if (emulsifyConfig?.project?.platform === 'drupal' && projectName) {
129
+ for (const file of globSync(twigGlob)) {
130
+ const relToSrc = relative(srcDir, file);
131
+ const fileName = basename(relToSrc).replace(/\.twig$/, '');
132
+ aliases[`${projectName}/${fileName}`] = file;
74
133
  }
75
- });
76
- const dirs = getDirectories(srcDir);
77
- dirs.forEach((dir) => {
134
+ }
135
+
136
+ // Top-level "@section" aliases for easier imports
137
+ const topDirs = getDirectories(srcDir);
138
+ for (const dir of topDirs) {
78
139
  const name = cleanDirectoryName(dir);
79
- Object.assign(aliases, {
80
- [`@${name}`]: `${projectDir}/${basename(srcDir)}/${dir}`,
81
- });
82
- });
140
+ aliases[`@${name}`] = join(projectDir, basename(srcDir), dir);
141
+ }
142
+
83
143
  return aliases;
84
144
  }
85
145
 
146
+ /**
147
+ * Webpack-style `resolve` config for Twig files.
148
+ * @typedef {{ extensions: string[], alias: Record<string, string> }} TwigResolveConfig
149
+ */
150
+
151
+ /** @type {TwigResolveConfig} */
86
152
  const TwigResolve = {
87
153
  extensions: ['.twig'],
88
154
  alias: getAliases(aliasPattern),