@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.
@@ -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
  };
@@ -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';
@@ -19,14 +20,14 @@ if (process.platform === 'win32' && _filename.startsWith('/')) {
19
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
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),
@@ -1,26 +1,99 @@
1
1
  /**
2
- * @fileoverview Webpack configuration entry file.
3
- * This file generates Webpack entries for JS, SCSS, and SVG assets.
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 { resolve, dirname } from 'path';
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
- // Create __filename from import.meta.url without fileURLToPath
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
- const _dirname = dirname(_filename);
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
- // Get directories for file contexts.
34
- const projectDir = resolve(_dirname, '../../../../..');
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 srcPath = resolve(projectDir, 'src');
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
- // Glob pattern for SCSS files that ignore file names prefixed with underscore.
42
- const BaseScssPattern = fs.pathExistsSync(resolve(projectDir, 'src'))
43
- ? resolve(srcDir, '!(components|util)/**/!(_*|cl-*|sb-*).scss')
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
- // Glob pattern for SVG sprite config.
62
- const spritePattern = resolve(projectDir, 'assets/icons/**/*.svg');
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
- * Replace the last occurrence of a slash in a string with a replacement.
66
- *
67
- * @param {string} str - The original string.
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
- function replaceLastSlash(str, replacement) {
72
- const lastSlashIndex = str.lastIndexOf('/');
73
- if (lastSlashIndex === -1) {
74
- return str;
75
- }
76
- return (
77
- str.slice(0, lastSlashIndex) + replacement + str.slice(lastSlashIndex + 1)
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
- * Generate Webpack entries for JS, SCSS, and SVG files.
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
- function getEntries(
93
- BaseJsMatcher,
94
- jsMatcher,
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
- // Component JS entries.
133
- globSync(jsMatcher).forEach((file) => {
134
- if (!file.includes('dist/')) {
135
- const filePath = file.split(`${srcDir}/components/`)[1];
136
- const filePathDistRaw = replaceLastSlash(filePath, '/js/');
137
- const filePathDist = filePathDistRaw.replace(/\.js$/, '');
138
- const prefix = isDrupal && isSrcExists ? 'components' : 'dist/components';
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
- // Non-component or global SCSS entries.
145
- globSync(BaseScssMatcher).forEach((file) => {
146
- const filePath = file.split(`${srcDir}/`)[1];
147
- const pathParts = filePath.split('/');
148
- const filePathDist = `${pathParts.slice(0, -1).join('/')}/css/${pathParts
149
- .at(-1)
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
- // Component SCSS entries.
158
- globSync(ComponentScssMatcher).forEach((file) => {
159
- const filePath = file.split(`${srcDir}/components/`)[1];
160
- const filePathDistRaw = replaceLastSlash(filePath, '/css/');
161
- const filePathDist = filePathDistRaw.replace(/\.scss$/, '');
162
- const prefix = isDrupal && isSrcExists ? 'components' : 'dist/components';
163
- const newFilePath = `${prefix}/${filePathDist}`;
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
- // Component Library SCSS entries.
168
- globSync(ComponentLibraryScssMatcher).forEach((file) => {
169
- const filePath = file.split(`${srcDir}/`)[1];
170
- const newFilePath = `dist/storybook/${filePath.replace('.scss', '')}`;
171
- addEntry(newFilePath, file);
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
- // SVG sprite config entries.
175
- globSync(spriteMatcher).forEach((file) => {
176
- const filePath = file.split('/assets/')[1];
177
- const newEntry = `dist/${filePath}`;
178
- addEntry(newEntry, file);
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
- errorDetails: true,
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.SpriteLoaderPlugin,
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: `${projectDir}`,
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.message &&
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.2.0",
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.0",
110
+ "sass": "^1.92.1",
112
111
  "sass-loader": "^16.0.5",
113
112
  "storybook": "^8.6.14",
114
- "style-dictionary": "^4.4.0",
115
- "stylelint": "^16.23.1",
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-sprite-loader": "^6.0.11",
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.3",
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
- * @file a11y.js
4
- * Contains a script that, when executed, will execute a11y linting tools
5
- * against the storybook build.
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
- // project specific configuration.
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
- const issueIsValid = ({ code, runnerExtras: { description } }) =>
32
- ignore.codes.includes(code) || ignore.descriptions.includes(description)
33
- ? false
34
- : true;
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
- console.log(`
38
- severity: ${chalk[severityToColor(severity)](severity)}
39
- message: ${message}
40
- context: ${context}
41
- selector: ${selector}
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
- console.log(chalk.red(`Issues found in component: ${pageUrl}`));
51
- validIssues.map(logIssue);
95
+ // eslint-disable-next-line no-console
96
+ console.log(`Issues found in component: ${pageUrl}`);
97
+ validIssues.forEach(logIssue);
52
98
  } else {
53
- console.log(chalk.green(`No issues found in component: ${pageUrl}`));
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
- R.map(lintComponent),
69
- (p) => Promise.all(p),
125
+ /** @param {string[]} list */
126
+ (list) => list.map(lintComponent),
127
+ (promises) => Promise.all(promises),
70
128
  R.andThen(
71
129
  R.pipe(
72
- R.map(logReport),
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
 
@@ -31,7 +31,9 @@ describe('a11y', () => {
31
31
  global.console.log.mockClear();
32
32
  global.process.exit.mockClear();
33
33
  });
34
- it('can map axe issue severity to the correct chalk color', () => {
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('can use an axe issue to generate a single log message about the issue', () => {
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
- severity: error
70
- message: this chicken is not fried enough.
71
- context: https://example.com
72
- selector: kfc > popeyes > .chicken
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('can log a whole axe report', () => {
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
- Array [
100
- Array [
101
- "Issues found in component: https://example/component.html",
102
- ],
103
- Array [
104
- "
105
- severity: error
106
- message: this pizza is too soggy
107
- context: https://example.com
108
- selector: pizza > .hut
109
- ",
110
- ],
111
- Array [
112
- "
113
- severity: error
114
- message: this pasta is undercooked
115
- context: https://example.com
116
- selector: olive > .garden
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 about a component having no issue if a report comes back empty', () => {
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
- `"No issues found in component: papa-johns"`,
128
+ `"No issues found in component: papa-johns"`,
127
129
  );
128
130
  });
129
131
 
130
- it('can call pa11y with the full path to a component', async () => {
131
- expect.assertions(2);
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
- expect(pa11y).toHaveBeenCalledWith(
137
+
138
+ // First arg: URL
139
+ expect(pa11y.mock.calls[0][0]).toBe(
136
140
  `${STORYBOOK_IFRAME}?id=chicken-strips`,
137
- pa11yConfig,
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