@emulsify/core 3.2.0 → 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/config/webpack/loaders.js +29 -15
- package/config/webpack/plugins.js +183 -36
- package/config/webpack/resolves.js +110 -44
- package/config/webpack/webpack.common.js +197 -156
- package/package.json +7 -7
- package/scripts/a11y.js +83 -23
- package/scripts/a11y.test.js +51 -38
|
@@ -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';
|
|
@@ -19,14 +20,14 @@ if (process.platform === 'win32' && _filename.startsWith('/')) {
|
|
|
19
20
|
const _dirname = dirname(_filename);
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
|
-
*
|
|
23
|
+
* Project root (five levels up).
|
|
23
24
|
* @type {string}
|
|
24
25
|
*/
|
|
25
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),
|
|
@@ -1,26 +1,99 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @fileoverview Webpack
|
|
3
|
-
*
|
|
2
|
+
* @fileoverview Build Webpack entries and export the configuration.
|
|
3
|
+
* - Discovers JS/SCSS assets (base + component) via glob patterns
|
|
4
|
+
* - Shapes output paths based on platform and SDC (singleDirectoryComponents)
|
|
5
|
+
* - Wires up loaders, plugins, and optimizations
|
|
4
6
|
*/
|
|
5
7
|
|
|
6
|
-
import {
|
|
8
|
+
import { posix as path } from 'node:path';
|
|
7
9
|
import { sync as globSync } from 'glob';
|
|
8
10
|
import fs from 'fs-extra';
|
|
11
|
+
|
|
9
12
|
import loaders from './loaders.js';
|
|
10
13
|
import plugins from './plugins.js';
|
|
11
14
|
import resolves from './resolves.js';
|
|
12
15
|
import optimizers from './optimizers.js';
|
|
13
16
|
import emulsifyConfig from '../../../../../project.emulsify.json' with { type: 'json' };
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the directory of this file (without fileURLToPath).
|
|
20
|
+
* @type {string}
|
|
21
|
+
*/
|
|
16
22
|
let _filename = decodeURIComponent(new URL(import.meta.url).pathname);
|
|
17
|
-
|
|
18
|
-
// On Windows, remove the leading slash (e.g. "/C:/path" -> "C:/path")
|
|
19
23
|
if (process.platform === 'win32' && _filename.startsWith('/')) {
|
|
20
24
|
_filename = _filename.slice(1);
|
|
21
25
|
}
|
|
26
|
+
const _dirname = path.dirname(_filename);
|
|
27
|
+
|
|
28
|
+
/** @type {string} Absolute project root (five levels up from this file). */
|
|
29
|
+
const projectDir = path.resolve(_dirname, '../../../../..');
|
|
30
|
+
|
|
31
|
+
/** @type {boolean} True when a "src/" directory exists (WP layout). */
|
|
32
|
+
const hasSrc = fs.pathExistsSync(path.resolve(projectDir, 'src'));
|
|
33
|
+
|
|
34
|
+
/** @type {string} The canonical source directory ("src" if present, else "components"). */
|
|
35
|
+
const srcDir = hasSrc
|
|
36
|
+
? path.resolve(projectDir, 'src')
|
|
37
|
+
: path.resolve(projectDir, 'components');
|
|
38
|
+
|
|
39
|
+
/** @type {boolean} True when platform is Drupal (affects component output root). */
|
|
40
|
+
const isDrupal = emulsifyConfig?.project?.platform === 'drupal';
|
|
41
|
+
|
|
42
|
+
/** @type {boolean} Respect SDC (single-directory-components) layout if explicitly true. */
|
|
43
|
+
const SDC = Boolean(emulsifyConfig?.project?.singleDirectoryComponents);
|
|
44
|
+
|
|
45
|
+
/** @type {string} Output base for "global" assets. */
|
|
46
|
+
const globalOutBase = hasSrc ? 'dist/global' : 'dist';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Create a path under the component output root.
|
|
50
|
+
* - In Drupal + src layout, components resolve to "components/…"
|
|
51
|
+
* - Otherwise, they resolve to "dist/components/…"
|
|
52
|
+
* @param {string} subpath - Component-local subpath (no extension).
|
|
53
|
+
* @returns {string} Component output path segment.
|
|
54
|
+
*/
|
|
55
|
+
const componentOutPath = (subpath) =>
|
|
56
|
+
(isDrupal && hasSrc ? 'components' : 'dist/components') + '/' + subpath;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Join segments with POSIX semantics (forward slashes), trimming empties.
|
|
60
|
+
* @param {...string} segs - Path segments.
|
|
61
|
+
* @returns {string} POSIX-joined path.
|
|
62
|
+
*/
|
|
63
|
+
const pj = (...segs) => path.join(...segs.filter(Boolean));
|
|
22
64
|
|
|
23
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Compute the “dist subpath” for a non-component asset.
|
|
67
|
+
* Inserts a type folder ("js" or "css") when SDC = false.
|
|
68
|
+
* Drops the original file extension.
|
|
69
|
+
* @param {string} absFile - Absolute file path.
|
|
70
|
+
* @param {'js'|'css'} type - Asset type.
|
|
71
|
+
* @returns {string} Subpath under the global output base (no extension).
|
|
72
|
+
*/
|
|
73
|
+
const distSubpathForBase = (absFile, type) => {
|
|
74
|
+
const rel = path.relative(srcDir, absFile);
|
|
75
|
+
const dir = path.dirname(rel);
|
|
76
|
+
const name = path.basename(rel, '.' + type);
|
|
77
|
+
return SDC ? pj(dir, name) : pj(dir, type, name);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Compute the “dist subpath” for a component asset located under "…/components".
|
|
82
|
+
* Inserts a type folder ("js" or "css") when SDC = false.
|
|
83
|
+
* Drops the original file extension.
|
|
84
|
+
* @param {string} absFile - Absolute file path.
|
|
85
|
+
* @param {'js'|'scss'} type - Source type (scss maps to 'css').
|
|
86
|
+
* @returns {string} Component-local subpath (no extension).
|
|
87
|
+
*/
|
|
88
|
+
const distSubpathForComponent = (absFile, type) => {
|
|
89
|
+
const relFromComponents = path.relative(pj(srcDir, 'components'), absFile);
|
|
90
|
+
const dir = path.dirname(relFromComponents);
|
|
91
|
+
const isStyle = type === 'scss';
|
|
92
|
+
const outTypeDir = isStyle ? 'css' : 'js';
|
|
93
|
+
const ext = isStyle ? '.scss' : '.js';
|
|
94
|
+
const name = path.basename(relFromComponents, ext);
|
|
95
|
+
return SDC ? pj(dir, name) : pj(dir, outTypeDir, name);
|
|
96
|
+
};
|
|
24
97
|
|
|
25
98
|
/**
|
|
26
99
|
* Sanitize a file path by removing unwanted characters.
|
|
@@ -30,170 +103,134 @@ const _dirname = dirname(_filename);
|
|
|
30
103
|
*/
|
|
31
104
|
const sanitizePath = (inputPath) => inputPath.replace(/[^a-zA-Z0-9/_-]/g, '');
|
|
32
105
|
|
|
33
|
-
|
|
34
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Reject keys that could touch object internals even after sanitization.
|
|
108
|
+
* @param {string} k
|
|
109
|
+
* @returns {boolean}
|
|
110
|
+
*/
|
|
111
|
+
const isDangerousKey = (k) =>
|
|
112
|
+
k.includes('__proto__') || k.includes('prototype') || k === 'constructor';
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Add a file under an entry key; if the key exists, merge to an array.
|
|
116
|
+
* Keeps JS before SCSS for deterministic order.
|
|
117
|
+
*
|
|
118
|
+
* @param {Map<string, string | string[]>} map
|
|
119
|
+
* @param {string} key
|
|
120
|
+
* @param {string} file
|
|
121
|
+
* @returns {void}
|
|
122
|
+
*/
|
|
123
|
+
const addEntry = (map, key, file) => {
|
|
124
|
+
const safeKey = sanitizePath(String(key));
|
|
125
|
+
if (!safeKey || isDangerousKey(safeKey)) return;
|
|
35
126
|
|
|
36
|
-
const
|
|
37
|
-
const isSrcExists = fs.pathExistsSync(srcPath);
|
|
38
|
-
const srcDir = isSrcExists ? srcPath : resolve(projectDir, 'components');
|
|
39
|
-
const isDrupal = emulsifyConfig.project.platform === 'drupal';
|
|
127
|
+
const current = map.get(safeKey);
|
|
40
128
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const ComponentScssPattern = fs.pathExistsSync(resolve(projectDir, 'src'))
|
|
46
|
-
? resolve(srcDir, 'components/**/!(_*|cl-*|sb-*).scss')
|
|
47
|
-
: resolve(srcDir, '**/!(_*|cl-*|sb-*).scss');
|
|
48
|
-
const ComponentLibraryScssPattern = resolve(srcDir, '**/*{cl-*,sb-*}.scss');
|
|
49
|
-
|
|
50
|
-
// Glob pattern for JS files.
|
|
51
|
-
const BaseJsPattern = fs.pathExistsSync(resolve(projectDir, 'src'))
|
|
52
|
-
? resolve(
|
|
53
|
-
srcDir,
|
|
54
|
-
'!(components|util)/**/!(*.stories|*.component|*.min|*.test).js',
|
|
55
|
-
)
|
|
56
|
-
: '';
|
|
57
|
-
const ComponentJsPattern = fs.pathExistsSync(resolve(projectDir, 'src'))
|
|
58
|
-
? resolve(srcDir, 'components/**/!(*.stories|*.component|*.min|*.test).js')
|
|
59
|
-
: resolve(srcDir, '**/!(*.stories|*.component|*.min|*.test).js');
|
|
129
|
+
if (!current) {
|
|
130
|
+
map.set(safeKey, file);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
60
133
|
|
|
61
|
-
|
|
62
|
-
|
|
134
|
+
const arr = Array.isArray(current) ? current : [current];
|
|
135
|
+
if (!arr.includes(file)) arr.push(file);
|
|
136
|
+
|
|
137
|
+
// Optional: ensure JS comes before SCSS
|
|
138
|
+
arr.sort((a, b) => {
|
|
139
|
+
const ax = a.endsWith('.js') ? 0 : 1;
|
|
140
|
+
const bx = b.endsWith('.js') ? 0 : 1;
|
|
141
|
+
return ax - bx || a.localeCompare(b);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
map.set(safeKey, arr);
|
|
145
|
+
};
|
|
63
146
|
|
|
64
147
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* @
|
|
68
|
-
* @param {string} replacement - The string to replace the last slash with.
|
|
69
|
-
* @returns {string} The modified string.
|
|
148
|
+
* Safe glob wrapper: returns [] if the pattern is falsy.
|
|
149
|
+
* @param {string} pattern - Glob pattern.
|
|
150
|
+
* @returns {string[]} Matching file paths.
|
|
70
151
|
*/
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
)
|
|
79
|
-
|
|
152
|
+
const glob = (pattern) => (pattern ? globSync(pattern) : []);
|
|
153
|
+
|
|
154
|
+
/* -------------------------------------------------------------------------- */
|
|
155
|
+
/* GLOBS */
|
|
156
|
+
/* -------------------------------------------------------------------------- */
|
|
157
|
+
|
|
158
|
+
const BaseScssPattern = hasSrc
|
|
159
|
+
? pj(srcDir, '!(components|util)/**/!(_*|cl-*|sb-*).scss')
|
|
160
|
+
: '';
|
|
161
|
+
|
|
162
|
+
const ComponentScssPattern = hasSrc
|
|
163
|
+
? pj(srcDir, 'components/**/!(_*|cl-*|sb-*).scss')
|
|
164
|
+
: pj(srcDir, '**/!(_*|cl-*|sb-*).scss');
|
|
165
|
+
|
|
166
|
+
const ComponentLibraryScssPattern = pj(srcDir, '**/*{cl-*,sb-*}.scss');
|
|
167
|
+
|
|
168
|
+
const BaseJsPattern = hasSrc
|
|
169
|
+
? pj(srcDir, '!(components|util)/**/!(*.stories|*.component|*.min|*.test).js')
|
|
170
|
+
: '';
|
|
171
|
+
|
|
172
|
+
const ComponentJsPattern = hasSrc
|
|
173
|
+
? pj(srcDir, 'components/**/!(*.stories|*.component|*.min|*.test).js')
|
|
174
|
+
: pj(srcDir, '**/!(*.stories|*.component|*.min|*.test).js');
|
|
175
|
+
|
|
176
|
+
/* -------------------------------------------------------------------------- */
|
|
177
|
+
/* ENTRY BUILD */
|
|
178
|
+
/* -------------------------------------------------------------------------- */
|
|
80
179
|
|
|
81
180
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* @param {string} BaseJsMatcher - Glob pattern for base JS files.
|
|
85
|
-
* @param {string} jsMatcher - Glob pattern for component JS files.
|
|
86
|
-
* @param {string} BaseScssMatcher - Glob pattern for base SCSS files.
|
|
87
|
-
* @param {string} ComponentScssMatcher - Glob pattern for component SCSS files.
|
|
88
|
-
* @param {string} ComponentLibraryScssMatcher - Glob pattern for component library SCSS files.
|
|
89
|
-
* @param {string} spriteMatcher - Glob pattern for SVG sprite configuration.
|
|
90
|
-
* @returns {Object} An object containing the Webpack entries.
|
|
181
|
+
* Build the complete Webpack entries map.
|
|
182
|
+
* @returns {Record<string,string>} Webpack entries.
|
|
91
183
|
*/
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
BaseScssMatcher,
|
|
96
|
-
ComponentScssMatcher,
|
|
97
|
-
ComponentLibraryScssMatcher,
|
|
98
|
-
spriteMatcher,
|
|
99
|
-
) {
|
|
100
|
-
const entries = {};
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Add an entry to the entries object after sanitizing the key.
|
|
104
|
-
*
|
|
105
|
-
* @param {string} key - The key for the entry.
|
|
106
|
-
* @param {string} file - The file path to associate with the entry.
|
|
107
|
-
*/
|
|
108
|
-
const addEntry = (key, file) => {
|
|
109
|
-
const sanitizedKey = sanitizePath(key);
|
|
110
|
-
if (
|
|
111
|
-
sanitizedKey &&
|
|
112
|
-
!Object.prototype.hasOwnProperty.call(entries, sanitizedKey)
|
|
113
|
-
) {
|
|
114
|
-
// eslint-disable-next-line security/detect-object-injection
|
|
115
|
-
entries[sanitizedKey] = file;
|
|
116
|
-
}
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
// Non-component or global JS entries.
|
|
120
|
-
globSync(BaseJsMatcher).forEach((file) => {
|
|
121
|
-
const filePath = file.split(`${srcDir}/`)[1];
|
|
122
|
-
const pathParts = filePath.split('/');
|
|
123
|
-
const filePathDist = `${pathParts.slice(0, -1).join('/')}/js/${pathParts
|
|
124
|
-
.at(-1)
|
|
125
|
-
.replace('.js', '')}`;
|
|
126
|
-
const newFilePath = fs.pathExistsSync(resolve(projectDir, 'src'))
|
|
127
|
-
? `dist/global/${filePathDist}`
|
|
128
|
-
: `dist/js/${filePathDist}`;
|
|
129
|
-
addEntry(newFilePath, file);
|
|
130
|
-
});
|
|
184
|
+
const buildEntries = () => {
|
|
185
|
+
/** @type {Map<string, string | string[]>} */
|
|
186
|
+
const entries = new Map();
|
|
131
187
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const newFilePath = `${prefix}/${filePathDist}`;
|
|
140
|
-
addEntry(newFilePath, file);
|
|
141
|
-
}
|
|
142
|
-
});
|
|
188
|
+
/* ----------------------------- Base / Global JS ----------------------------- */
|
|
189
|
+
for (const file of glob(BaseJsPattern)) {
|
|
190
|
+
const sub = distSubpathForBase(file, 'js');
|
|
191
|
+
// If no "src/", legacy layout puts global JS directly under "dist/js".
|
|
192
|
+
const outRoot = hasSrc ? pj(globalOutBase) : pj('dist', 'js');
|
|
193
|
+
addEntry(entries, pj(outRoot, sub), file);
|
|
194
|
+
}
|
|
143
195
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
.replace('.scss', '')}`;
|
|
151
|
-
const newFilePath = fs.pathExistsSync(resolve(projectDir, 'src'))
|
|
152
|
-
? `dist/global/${filePathDist}`
|
|
153
|
-
: `dist/css/${filePathDist}`;
|
|
154
|
-
addEntry(newFilePath, file);
|
|
155
|
-
});
|
|
196
|
+
/* --------------------------- Component JS (no dist) -------------------------- */
|
|
197
|
+
for (const file of glob(ComponentJsPattern)) {
|
|
198
|
+
if (file.includes('/dist/')) continue; // guard against accidental recursion
|
|
199
|
+
const sub = distSubpathForComponent(file, 'js');
|
|
200
|
+
addEntry(entries, componentOutPath(sub), file);
|
|
201
|
+
}
|
|
156
202
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
addEntry(newFilePath, file);
|
|
165
|
-
});
|
|
203
|
+
/* ------------------------------ Base / Global CSS --------------------------- */
|
|
204
|
+
for (const file of glob(BaseScssPattern)) {
|
|
205
|
+
const sub = distSubpathForBase(file, 'css');
|
|
206
|
+
// If no "src/", legacy layout puts global CSS directly under "dist/css".
|
|
207
|
+
const outRoot = hasSrc ? pj(globalOutBase) : pj('dist', 'css');
|
|
208
|
+
addEntry(entries, pj(outRoot, sub), file);
|
|
209
|
+
}
|
|
166
210
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
});
|
|
211
|
+
/* ---------------------------- Component CSS (SCSS) --------------------------- */
|
|
212
|
+
for (const file of glob(ComponentScssPattern)) {
|
|
213
|
+
const sub = distSubpathForComponent(file, 'scss'); // maps to css
|
|
214
|
+
addEntry(entries, componentOutPath(sub), file);
|
|
215
|
+
}
|
|
173
216
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
});
|
|
217
|
+
/* -------------------------- Component Library (Storybook) -------------------- */
|
|
218
|
+
for (const file of glob(ComponentLibraryScssPattern)) {
|
|
219
|
+
const rel = path.relative(srcDir, file).replace(/\.scss$/, '');
|
|
220
|
+
addEntry(entries, pj('dist', 'storybook', rel), file);
|
|
221
|
+
}
|
|
180
222
|
|
|
181
|
-
return entries;
|
|
182
|
-
}
|
|
223
|
+
return Object.fromEntries(entries);
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/* -------------------------------------------------------------------------- */
|
|
227
|
+
/* WEBPACK CONFIG EXPORT */
|
|
228
|
+
/* -------------------------------------------------------------------------- */
|
|
183
229
|
|
|
184
230
|
export default {
|
|
185
231
|
target: 'web',
|
|
186
|
-
stats: {
|
|
187
|
-
|
|
188
|
-
},
|
|
189
|
-
entry: getEntries(
|
|
190
|
-
BaseJsPattern,
|
|
191
|
-
ComponentJsPattern,
|
|
192
|
-
BaseScssPattern,
|
|
193
|
-
ComponentScssPattern,
|
|
194
|
-
ComponentLibraryScssPattern,
|
|
195
|
-
spritePattern,
|
|
196
|
-
),
|
|
232
|
+
stats: { errorDetails: true },
|
|
233
|
+
entry: buildEntries(),
|
|
197
234
|
module: {
|
|
198
235
|
rules: [
|
|
199
236
|
loaders.CSSLoader,
|
|
@@ -204,22 +241,26 @@ export default {
|
|
|
204
241
|
],
|
|
205
242
|
},
|
|
206
243
|
plugins: [
|
|
244
|
+
plugins.RemoveEmptyJS,
|
|
207
245
|
plugins.MiniCssExtractPlugin,
|
|
208
246
|
plugins.ImageminPlugin,
|
|
209
|
-
plugins.
|
|
247
|
+
plugins.SpritePlugin,
|
|
210
248
|
plugins.ProgressPlugin,
|
|
211
249
|
plugins.CopyTwigPlugin,
|
|
250
|
+
plugins.CopyComponentAssetsPlugin,
|
|
251
|
+
...(plugins.CopyGlobalAssetsPlugin ? [plugins.CopyGlobalAssetsPlugin] : []),
|
|
212
252
|
plugins.CleanWebpackPlugin,
|
|
213
253
|
],
|
|
214
254
|
output: {
|
|
215
|
-
path:
|
|
255
|
+
path: projectDir,
|
|
216
256
|
filename: '[name].js',
|
|
217
257
|
},
|
|
218
258
|
resolve: resolves.TwigResolve,
|
|
219
259
|
optimization: optimizers,
|
|
260
|
+
// Quiet deprecation noise from Sass @import warnings
|
|
220
261
|
ignoreWarnings: [
|
|
221
262
|
(warning) =>
|
|
222
|
-
warning
|
|
263
|
+
Boolean(warning?.message) &&
|
|
223
264
|
/Sass @import rules are deprecated/.test(warning.message),
|
|
224
265
|
],
|
|
225
266
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@emulsify/core",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"description": "Bundled tooling for Storybook development + Webpack Build",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"component library",
|
|
@@ -72,7 +72,6 @@
|
|
|
72
72
|
"babel-preset-minify": "^0.5.2",
|
|
73
73
|
"bem-twig-extension": "^0.1.1",
|
|
74
74
|
"breakpoint-sass": "^3.0.0",
|
|
75
|
-
"chalk": "^5.6.0",
|
|
76
75
|
"clean-webpack-plugin": "^4.0.0",
|
|
77
76
|
"concurrently": "^9.2.1",
|
|
78
77
|
"copy-webpack-plugin": "^13.0.1",
|
|
@@ -108,16 +107,16 @@
|
|
|
108
107
|
"postcss-scss": "^4.0.9",
|
|
109
108
|
"ramda": "^0.31.3",
|
|
110
109
|
"regenerator-runtime": "^0.14.1",
|
|
111
|
-
"sass": "^1.92.
|
|
110
|
+
"sass": "^1.92.1",
|
|
112
111
|
"sass-loader": "^16.0.5",
|
|
113
112
|
"storybook": "^8.6.14",
|
|
114
|
-
"style-dictionary": "^
|
|
115
|
-
"stylelint": "^16.
|
|
113
|
+
"style-dictionary": "^5.0.4",
|
|
114
|
+
"stylelint": "^16.24.0",
|
|
116
115
|
"stylelint-config-standard-scss": "^15.0.1",
|
|
117
116
|
"stylelint-prettier": "^5.0.3",
|
|
118
117
|
"stylelint-selector-bem-pattern": "^4.0.1",
|
|
119
118
|
"stylelint-webpack-plugin": "^5.0.1",
|
|
120
|
-
"svg-
|
|
119
|
+
"svg-spritemap-webpack-plugin": "^5.0.1",
|
|
121
120
|
"token-transformer": "^0.0.33",
|
|
122
121
|
"twig-drupal-filters": "^3.2.0",
|
|
123
122
|
"twig-testing-library": "^1.2.0",
|
|
@@ -125,6 +124,7 @@
|
|
|
125
124
|
"webpack": "^5.101.3",
|
|
126
125
|
"webpack-cli": "^6.0.1",
|
|
127
126
|
"webpack-merge": "^6.0.1",
|
|
127
|
+
"webpack-remove-empty-scripts": "^1.1.1",
|
|
128
128
|
"yaml": "^2.8.1"
|
|
129
129
|
},
|
|
130
130
|
"devDependencies": {
|
|
@@ -134,7 +134,7 @@
|
|
|
134
134
|
"@semantic-release/commit-analyzer": "^13.0.1",
|
|
135
135
|
"@semantic-release/git": "^10.0.1",
|
|
136
136
|
"@semantic-release/github": "^11.0.5",
|
|
137
|
-
"@semantic-release/release-notes-generator": "^14.0
|
|
137
|
+
"@semantic-release/release-notes-generator": "^14.1.0",
|
|
138
138
|
"all-contributors-cli": "^6.26.1",
|
|
139
139
|
"husky": "^9.1.7",
|
|
140
140
|
"lint-staged": "^16.1.6",
|
package/scripts/a11y.js
CHANGED
|
@@ -1,61 +1,113 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* @
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* @fileoverview a11y.js
|
|
4
|
+
* Runs accessibility linting (pa11y/axe) against a Storybook build
|
|
5
|
+
* and reports issues.
|
|
6
6
|
*/
|
|
7
|
-
const chalk = import("chalk").then(m => m.default);
|
|
8
7
|
|
|
9
8
|
const R = require('ramda');
|
|
10
9
|
const path = require('path');
|
|
11
10
|
const pa11y = require('pa11y');
|
|
11
|
+
|
|
12
12
|
const {
|
|
13
13
|
storybookBuildDir,
|
|
14
14
|
pa11y: pa11yConfig,
|
|
15
15
|
} = require('../config/a11y.config.js');
|
|
16
|
-
|
|
16
|
+
|
|
17
|
+
// Project-specific configuration.
|
|
17
18
|
const {
|
|
18
19
|
ignore,
|
|
19
20
|
components,
|
|
20
21
|
} = require('../../../config/emulsify-core/a11y.config.js');
|
|
21
22
|
|
|
23
|
+
/** Absolute path to Storybook build directory. */
|
|
22
24
|
const STORYBOOK_BUILD_DIR = path.resolve(__dirname, '../', storybookBuildDir);
|
|
25
|
+
/** Absolute path to Storybook iframe file used for per-story rendering. */
|
|
23
26
|
const STORYBOOK_IFRAME = path.join(STORYBOOK_BUILD_DIR, 'iframe.html');
|
|
24
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Map pa11y/axe severity to a label (historically a color name).
|
|
30
|
+
* Retained for backward compatibility, but not used for styling anymore.
|
|
31
|
+
* @deprecated Colors are no longer used; this function returns a label only.
|
|
32
|
+
* @param {'error'|'warning'|'notice'} severity
|
|
33
|
+
* @returns {'red'|'yellow'|'blue'|undefined}
|
|
34
|
+
*/
|
|
25
35
|
const severityToColor = R.cond([
|
|
26
36
|
[R.equals('error'), R.always('red')],
|
|
27
37
|
[R.equals('warning'), R.always('yellow')],
|
|
28
38
|
[R.equals('notice'), R.always('blue')],
|
|
29
39
|
]);
|
|
30
40
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {Object} Pa11yIssue
|
|
43
|
+
* @property {string} code - Rule identifier.
|
|
44
|
+
* @property {'error'|'warning'|'notice'} type - Severity level.
|
|
45
|
+
* @property {string} message - Human-readable description.
|
|
46
|
+
* @property {string} context - HTML context snippet.
|
|
47
|
+
* @property {string} selector - CSS selector for the node.
|
|
48
|
+
* @property {{ description?: string }} [runnerExtras] - Extra data from the runner.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Determine whether an issue should be reported (not ignored).
|
|
53
|
+
* @param {Pa11yIssue} issue
|
|
54
|
+
* @returns {boolean} True if the issue is NOT ignored and should be logged.
|
|
55
|
+
*/
|
|
56
|
+
const issueIsValid = (issue) => {
|
|
57
|
+
const code = issue?.code;
|
|
58
|
+
const description = issue?.runnerExtras?.description;
|
|
59
|
+
const codeIgnored = Array.isArray(ignore?.codes) && ignore.codes.includes(code);
|
|
60
|
+
const descIgnored =
|
|
61
|
+
description &&
|
|
62
|
+
Array.isArray(ignore?.descriptions) &&
|
|
63
|
+
ignore.descriptions.includes(description);
|
|
64
|
+
return !(codeIgnored || descIgnored);
|
|
65
|
+
};
|
|
35
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Log a single accessibility issue in a readable, colorless block.
|
|
69
|
+
* @param {Pa11yIssue} issue
|
|
70
|
+
* @returns {void}
|
|
71
|
+
*/
|
|
36
72
|
const logIssue = ({ type: severity, message, context, selector }) => {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
73
|
+
const lines = [
|
|
74
|
+
'', // leading blank for readability
|
|
75
|
+
`severity: ${severity}`,
|
|
76
|
+
`message: ${message}`,
|
|
77
|
+
`context: ${context}`,
|
|
78
|
+
`selector: ${selector}`,
|
|
79
|
+
'',
|
|
80
|
+
];
|
|
81
|
+
// eslint-disable-next-line no-console
|
|
82
|
+
console.log(lines.join('\n'));
|
|
43
83
|
};
|
|
44
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Log a report for a single component/page and return whether it had issues.
|
|
87
|
+
* @param {{ issues: Pa11yIssue[], pageUrl: string }} report
|
|
88
|
+
* @returns {boolean} True if the component has at least one non-ignored issue.
|
|
89
|
+
*/
|
|
45
90
|
const logReport = ({ issues, pageUrl }) => {
|
|
46
|
-
const validIssues = issues.filter(issueIsValid);
|
|
91
|
+
const validIssues = (issues || []).filter(issueIsValid);
|
|
47
92
|
const hasIssues = validIssues.length > 0;
|
|
48
93
|
|
|
49
94
|
if (hasIssues) {
|
|
50
|
-
|
|
51
|
-
|
|
95
|
+
// eslint-disable-next-line no-console
|
|
96
|
+
console.log(`Issues found in component: ${pageUrl}`);
|
|
97
|
+
validIssues.forEach(logIssue);
|
|
52
98
|
} else {
|
|
53
|
-
|
|
99
|
+
// eslint-disable-next-line no-console
|
|
100
|
+
console.log(`No issues found in component: ${pageUrl}`);
|
|
54
101
|
}
|
|
55
102
|
|
|
56
103
|
return hasIssues;
|
|
57
104
|
};
|
|
58
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Run pa11y on a single Storybook story by its ID.
|
|
108
|
+
* @param {string} name - Story ID (e.g., "components-button--primary").
|
|
109
|
+
* @returns {Promise<{ issues: Pa11yIssue[], pageUrl: string }>} Pa11y result.
|
|
110
|
+
*/
|
|
59
111
|
const lintComponent = async (name) =>
|
|
60
112
|
pa11y(`${STORYBOOK_IFRAME}?id=${name}`, {
|
|
61
113
|
includeNotices: true,
|
|
@@ -64,21 +116,29 @@ const lintComponent = async (name) =>
|
|
|
64
116
|
...pa11yConfig,
|
|
65
117
|
});
|
|
66
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Lint a list of components, log reports, and exit(1) if any have issues.
|
|
121
|
+
* @param {string[]} names - List of Storybook story IDs.
|
|
122
|
+
* @returns {Promise<void>}
|
|
123
|
+
*/
|
|
67
124
|
const lintReportAndExit = R.pipe(
|
|
68
|
-
|
|
69
|
-
(
|
|
125
|
+
/** @param {string[]} list */
|
|
126
|
+
(list) => list.map(lintComponent),
|
|
127
|
+
(promises) => Promise.all(promises),
|
|
70
128
|
R.andThen(
|
|
71
129
|
R.pipe(
|
|
72
|
-
|
|
130
|
+
/** @param {Array<{issues: Pa11yIssue[], pageUrl: string}>} results */
|
|
131
|
+
(results) => results.map(logReport),
|
|
73
132
|
R.reject(R.equals(false)),
|
|
74
133
|
R.unless(R.isEmpty, () => process.exit(1)),
|
|
75
134
|
),
|
|
76
135
|
),
|
|
77
136
|
);
|
|
78
137
|
|
|
79
|
-
// Only perform linting/reporting when instructed.
|
|
138
|
+
// Only perform linting/reporting when instructed via "-r".
|
|
80
139
|
/* istanbul ignore next */
|
|
81
140
|
if (R.pathEq(['argv', 2], '-r')(process)) {
|
|
141
|
+
// eslint-disable-next-line promise/catch-or-return
|
|
82
142
|
lintReportAndExit(components);
|
|
83
143
|
}
|
|
84
144
|
|
package/scripts/a11y.test.js
CHANGED
|
@@ -31,7 +31,9 @@ describe('a11y', () => {
|
|
|
31
31
|
global.console.log.mockClear();
|
|
32
32
|
global.process.exit.mockClear();
|
|
33
33
|
});
|
|
34
|
-
|
|
34
|
+
|
|
35
|
+
it('maps axe issue severity to a label', () => {
|
|
36
|
+
// (Name no longer mentions "chalk")
|
|
35
37
|
expect.assertions(3);
|
|
36
38
|
expect(severityToColor('error')).toBe('red');
|
|
37
39
|
expect(severityToColor('warning')).toBe('yellow');
|
|
@@ -56,7 +58,7 @@ describe('a11y', () => {
|
|
|
56
58
|
expect(issueIsValid({ code: 'chicken', runnerExtras: {} })).toBe(true);
|
|
57
59
|
});
|
|
58
60
|
|
|
59
|
-
it('
|
|
61
|
+
it('logs a single issue without color codes', () => {
|
|
60
62
|
expect.assertions(1);
|
|
61
63
|
logIssue({
|
|
62
64
|
type: 'error',
|
|
@@ -65,16 +67,16 @@ describe('a11y', () => {
|
|
|
65
67
|
selector: 'kfc > popeyes > .chicken',
|
|
66
68
|
});
|
|
67
69
|
expect(global.console.log.mock.calls[0][0]).toMatchInlineSnapshot(`
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
70
|
+
"
|
|
71
|
+
severity: error
|
|
72
|
+
message: this chicken is not fried enough.
|
|
73
|
+
context: https://example.com
|
|
74
|
+
selector: kfc > popeyes > .chicken
|
|
75
|
+
"
|
|
76
|
+
`);
|
|
75
77
|
});
|
|
76
78
|
|
|
77
|
-
it('
|
|
79
|
+
it('logs a whole report without color codes', () => {
|
|
78
80
|
const report = {
|
|
79
81
|
issues: [
|
|
80
82
|
{
|
|
@@ -96,45 +98,56 @@ describe('a11y', () => {
|
|
|
96
98
|
};
|
|
97
99
|
expect(logReport(report)).toBe(true);
|
|
98
100
|
expect(global.console.log.mock.calls).toMatchInlineSnapshot(`
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
101
|
+
Array [
|
|
102
|
+
Array [
|
|
103
|
+
"Issues found in component: https://example/component.html",
|
|
104
|
+
],
|
|
105
|
+
Array [
|
|
106
|
+
"
|
|
107
|
+
severity: error
|
|
108
|
+
message: this pizza is too soggy
|
|
109
|
+
context: https://example.com
|
|
110
|
+
selector: pizza > .hut
|
|
111
|
+
",
|
|
112
|
+
],
|
|
113
|
+
Array [
|
|
114
|
+
"
|
|
115
|
+
severity: error
|
|
116
|
+
message: this pasta is undercooked
|
|
117
|
+
context: https://example.com
|
|
118
|
+
selector: olive > .garden
|
|
119
|
+
",
|
|
120
|
+
],
|
|
121
|
+
]
|
|
122
|
+
`);
|
|
121
123
|
});
|
|
122
124
|
|
|
123
|
-
it('logs
|
|
125
|
+
it('logs that a component has no issues when a report is empty', () => {
|
|
124
126
|
expect(logReport({ issues: [], pageUrl: 'papa-johns' })).toBe(false);
|
|
125
127
|
expect(global.console.log.mock.calls[0][0]).toMatchInlineSnapshot(
|
|
126
|
-
`"
|
|
128
|
+
`"No issues found in component: papa-johns"`,
|
|
127
129
|
);
|
|
128
130
|
});
|
|
129
131
|
|
|
130
|
-
it('
|
|
131
|
-
expect.assertions(
|
|
132
|
+
it('calls pa11y with the full path to a component', async () => {
|
|
133
|
+
expect.assertions(3);
|
|
132
134
|
await expect(lintComponent('chicken-strips')).resolves.toBe(
|
|
133
135
|
'very official report',
|
|
134
136
|
);
|
|
135
|
-
|
|
137
|
+
|
|
138
|
+
// First arg: URL
|
|
139
|
+
expect(pa11y.mock.calls[0][0]).toBe(
|
|
136
140
|
`${STORYBOOK_IFRAME}?id=chicken-strips`,
|
|
137
|
-
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
// Second arg: options merged with defaults in a11y.js
|
|
144
|
+
expect(pa11y.mock.calls[0][1]).toEqual(
|
|
145
|
+
expect.objectContaining({
|
|
146
|
+
includeNotices: true,
|
|
147
|
+
includeWarnings: true,
|
|
148
|
+
runners: ['axe'],
|
|
149
|
+
...pa11yConfig,
|
|
150
|
+
}),
|
|
138
151
|
);
|
|
139
152
|
});
|
|
140
153
|
|