@emulsify/core 3.2.0 → 3.3.1

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.
@@ -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
  };
@@ -27,13 +27,14 @@ const isDrupal = emulsifyConfig.project.platform === 'drupal';
27
27
  const ignored = ['**/dist/**'];
28
28
 
29
29
  // If it’s Drupal and there is no src/, also ignore components
30
- if (isDrupal && srcExists) {
30
+ if (isDrupal && !srcExists) {
31
31
  ignored.push('**/components/**');
32
32
  }
33
33
 
34
34
  export default merge(common, {
35
35
  mode: 'development',
36
36
  devtool: 'source-map',
37
+ watch: true,
37
38
  watchOptions: {
38
39
  ignored,
39
40
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emulsify/core",
3
- "version": "3.2.0",
3
+ "version": "3.3.1",
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