@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.
- package/.storybook/main.js +4 -4
- package/config/webpack/loaders.js +29 -15
- package/config/webpack/optimizers.js +0 -1
- package/config/webpack/plugins.js +188 -41
- package/config/webpack/resolves.js +110 -44
- package/config/webpack/webpack.common.js +197 -156
- package/package.json +28 -29
- package/scripts/a11y.js +83 -23
- package/scripts/a11y.test.js +51 -38
package/.storybook/main.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
137
|
+
const SVGLoader = {
|
|
117
138
|
test: /icons\/.*\.svg$/,
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
165
|
+
SVGLoader,
|
|
152
166
|
TwigLoader,
|
|
153
167
|
};
|
|
@@ -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
|
|
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
|
|
16
|
-
if (process.platform === 'win32' &&
|
|
17
|
-
|
|
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
|
|
20
|
+
const _dirname = dirname(_filename);
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
|
-
*
|
|
23
|
+
* Project root (five levels up).
|
|
23
24
|
* @type {string}
|
|
24
25
|
*/
|
|
25
|
-
const projectDir = resolve(
|
|
26
|
+
const projectDir = resolve(_dirname, '../../../../..');
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
|
-
* Where
|
|
29
|
-
*
|
|
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
|
|
38
|
-
*
|
|
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
|
-
*
|
|
47
|
-
*
|
|
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
|
-
*
|
|
81
|
+
* Build CopyPlugin patterns from a glob matcher, preserving source structure.
|
|
57
82
|
*
|
|
58
|
-
* @param {string} filesMatcher Glob
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
244
|
+
* MiniCssExtractPlugin: emit CSS next to the entry key path (no hard-coded dist/).
|
|
109
245
|
*/
|
|
110
246
|
const CssExtractPlugin = new MiniCssExtractPlugin({
|
|
111
|
-
filename:
|
|
112
|
-
chunkFilename:
|
|
247
|
+
filename: ({ chunk }) => `${chunk.name}.css`,
|
|
248
|
+
chunkFilename: ({ chunk }) => `${chunk.name}.css`,
|
|
113
249
|
});
|
|
114
250
|
|
|
115
251
|
/**
|
|
116
|
-
*
|
|
252
|
+
* Generate a single SVG spritemap at `dist/icons.svg`.
|
|
117
253
|
*/
|
|
118
|
-
const SpritePlugin = new
|
|
119
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
*
|
|
92
|
+
* Read immediate subdirectories from a source directory.
|
|
34
93
|
*
|
|
35
|
-
* @param {string} source -
|
|
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
|
|
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
|
|
101
|
+
return entries.filter((d) => d.isDirectory()).map((d) => d.name);
|
|
46
102
|
}
|
|
47
103
|
|
|
48
104
|
/**
|
|
49
|
-
*
|
|
105
|
+
* Strip a leading two-digit ordering prefix from a directory name
|
|
106
|
+
* (e.g., "01-components" -> "components").
|
|
50
107
|
*
|
|
51
|
-
* @param {string} dir -
|
|
52
|
-
* @returns {string}
|
|
108
|
+
* @param {string} dir - Original directory name.
|
|
109
|
+
* @returns {string} Cleaned directory name.
|
|
53
110
|
*/
|
|
54
111
|
function cleanDirectoryName(dir) {
|
|
55
|
-
|
|
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
|
-
*
|
|
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}
|
|
65
|
-
* @returns {
|
|
120
|
+
* @param {string} twigGlob - Glob pattern to locate Twig files.
|
|
121
|
+
* @returns {Record<string, string>} Alias map ({ alias: absolutePath }).
|
|
66
122
|
*/
|
|
67
|
-
function getAliases(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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),
|