@emulsify/core 4.0.3 → 4.1.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.
@@ -120,12 +120,58 @@ function readOptionalHtmlFragment(relativePath) {
120
120
  * @returns {Array<string|{from: string, to: string}>} Existing static directory entries.
121
121
  */
122
122
  function existingStaticDirs(staticDirs) {
123
- return staticDirs.filter((staticDir) => {
123
+ const seen = new Set();
124
+ const existing = [];
125
+
126
+ for (const staticDir of staticDirs) {
124
127
  const directory =
125
128
  typeof staticDir === 'string' ? staticDir : staticDir.from;
126
129
 
127
- return directory && fs.existsSync(directory);
128
- });
130
+ if (!directory || !fs.existsSync(directory)) continue;
131
+
132
+ const key =
133
+ typeof staticDir === 'string'
134
+ ? staticDir
135
+ : `${staticDir.from || ''}\0${staticDir.to || ''}`;
136
+ if (seen.has(key)) continue;
137
+
138
+ seen.add(key);
139
+ existing.push(staticDir);
140
+ }
141
+
142
+ return existing;
143
+ }
144
+
145
+ /**
146
+ * Build static directory mounts for normalized project asset roots.
147
+ *
148
+ * @param {StorybookEnvironment} env - Resolved project paths used by Storybook.
149
+ * @returns {Array<string|{from: string, to: string}>} Static directory entries.
150
+ */
151
+ function buildAssetStaticDirs(env) {
152
+ const configuredAssetRoots = Array.isArray(env.projectStructure?.assetRoots)
153
+ ? env.projectStructure.assetRoots
154
+ : [];
155
+ const assetRoots = [
156
+ ...configuredAssetRoots,
157
+ path.resolve(projectRoot, 'assets'),
158
+ path.resolve(projectRoot, 'src/assets'),
159
+ ];
160
+
161
+ return existingStaticDirs([
162
+ ...assetRoots.map((root) => ({
163
+ from: root,
164
+ to: '/assets',
165
+ })),
166
+ {
167
+ from: path.resolve(projectRoot, 'dist/assets'),
168
+ to: '/assets',
169
+ },
170
+ {
171
+ from: path.resolve(projectRoot, 'dist'),
172
+ to: '/dist',
173
+ },
174
+ ]);
129
175
  }
130
176
 
131
177
  /**
@@ -265,22 +311,7 @@ const baseConfig = {
265
311
  *
266
312
  * @type {Array<string|{from: string, to: string}>}
267
313
  */
268
- staticDirs: [
269
- ...existingStaticDirs([
270
- {
271
- from: path.resolve(projectRoot, 'assets'),
272
- to: '/assets',
273
- },
274
- {
275
- from: path.resolve(projectRoot, 'dist/assets'),
276
- to: '/assets',
277
- },
278
- {
279
- from: path.resolve(projectRoot, 'dist'),
280
- to: '/dist',
281
- },
282
- ]),
283
- ],
314
+ staticDirs: buildAssetStaticDirs(resolvedStorybookEnv),
284
315
 
285
316
  /**
286
317
  * Enable the default addon set used by Emulsify.
@@ -3,7 +3,6 @@
3
3
  */
4
4
 
5
5
  import { getRules } from 'axe-core';
6
- import React from 'react';
7
6
  import { defaultDecorateStory, useEffect } from 'storybook/preview-api';
8
7
  import Twig from 'twig';
9
8
  import { twigExtensionInstallers } from 'virtual:emulsify-twig-extension-installers';
@@ -12,9 +11,9 @@ import {
12
11
  normalizePreviewOverrideModule,
13
12
  } from '../src/storybook/preview-parameters.js';
14
13
  import {
15
- renderHtmlStoryResult,
16
- withLegacyStoryToString,
17
- } from '../src/storybook/render-twig.js';
14
+ applyStoryDecorators,
15
+ renderPreviewStory,
16
+ } from '../src/storybook/preview-decorator.js';
18
17
  import {
19
18
  attachStorybookBehaviors,
20
19
  fetchCSSFiles,
@@ -93,13 +92,7 @@ const AxeRules = enableRulesByTag([
93
92
  * @returns {Function} Decorated story function.
94
93
  */
95
94
  export const applyDecorators = (storyFn, decorators) =>
96
- defaultDecorateStory(
97
- (context) =>
98
- withLegacyStoryToString(React.createElement(storyFn, context), () =>
99
- storyFn(context),
100
- ),
101
- decorators,
102
- );
95
+ applyStoryDecorators(defaultDecorateStory, storyFn, decorators);
103
96
 
104
97
  /**
105
98
  * Storybook decorators to apply platform-specific behavior after each story render.
@@ -115,7 +108,9 @@ export const decorators = [
115
108
  * @param {object} context Story context including args.
116
109
  * @returns {*} Rendered story.
117
110
  */
118
- (Story, { args }) => {
111
+ (Story, context) => {
112
+ const { args } = context;
113
+
119
114
  useEffect(() => {
120
115
  void attachStorybookBehaviors({
121
116
  adapter: platformAdapter,
@@ -123,7 +118,7 @@ export const decorators = [
123
118
  });
124
119
  }, [args]);
125
120
 
126
- return renderHtmlStoryResult(Story({ args }), {
121
+ return renderPreviewStory(Story, context, {
127
122
  platformAdapter,
128
123
  });
129
124
  },
package/README.md CHANGED
@@ -34,7 +34,7 @@ See [Version Evolution](docs/version-evolution.md) for more release history.
34
34
 
35
35
  Twig and React are equally valid ways to build component libraries with Emulsify Core. The right authoring model depends on the consuming project:
36
36
 
37
- - Use Twig for CMS themes and server-rendered template systems. Drupal has a dedicated adapter today; Craft CMS and WordPress + Timber can use the `none` adapter unless a project adds platform-specific behavior.
37
+ - Use Twig for CMS themes and server-rendered template systems. Drupal has a dedicated adapter today. WordPress and Timber projects should currently use `platform: "none"` unless a project adds its own platform-specific behavior.
38
38
  - Use React for standalone UI libraries, application components, or projects that already use React.
39
39
  - Use mixed Twig and React when a design system needs to document both CMS-rendered and JavaScript-rendered components in the same Storybook instance.
40
40
 
@@ -56,10 +56,18 @@ Every project should provide a `project.emulsify.json` file at the project root:
56
56
  "platform": "none",
57
57
  "name": "example",
58
58
  "machineName": "example"
59
+ },
60
+ "assets": {
61
+ "roots": ["./design/assets"]
59
62
  }
60
63
  }
61
64
  ```
62
65
 
66
+ Asset files are discovered from the default asset roots and any additional
67
+ roots configured in `project.emulsify.json`. Use asset roots when a project
68
+ stores fonts, images, icons, or other static files outside the default
69
+ locations.
70
+
63
71
  Common project scripts call the shared Emulsify Core Vite and Storybook config:
64
72
 
65
73
  - `storybook`: starts Storybook development.
@@ -86,7 +94,7 @@ The documentation is split by task:
86
94
 
87
95
  ## Known Limitations
88
96
 
89
- - Implemented platform adapters are currently `none` and `drupal`. WordPress + Timber and Craft CMS are supported as Twig-oriented use cases through the `none` adapter today. Dedicated adapters are future opportunities. See [Platform Adapters](docs/platform-adapters.md).
97
+ - Implemented platform adapters are currently `none` and `drupal`. WordPress and Timber projects should currently use `platform: "none"`. This keeps Emulsify Core in platform-neutral mode while still supporting Twig-oriented component development. A dedicated WordPress adapter may be added later when WordPress-specific behavior is introduced. See [Platform Adapters](docs/platform-adapters.md).
90
98
  - Storybook's Twig resolver eagerly imports Twig modules and raw Twig source. This is reliable for `include()` and `source()`, but large Twig libraries should keep Storybook source roots intentional. See [Performance](docs/performance.md).
91
99
  - Production sourcemaps are enabled by default unless a project overrides Vite config through `config/emulsify-core/vite/plugins.*`. See [Performance](docs/performance.md).
92
100
  - Project extensions use the public `config/emulsify-core` directory: `config/emulsify-core/vite/plugins.*` for Vite, `config/emulsify-core/storybook/...` for Storybook, and `config/emulsify-core/a11y.config.js` for a11y. See [Extension Points](docs/extension-points.md).
@@ -103,7 +111,7 @@ Release-readiness coverage validates:
103
111
  - Projects using multiple `variant.structureImplementations`.
104
112
  - Mixed Twig + React Storybook projects.
105
113
 
106
- WordPress + Timber and Craft CMS are Twig-based project use cases that can use the `none` adapter today. Dedicated adapters for those platforms are future opportunities. The implemented adapters in this package are currently `none` and `drupal`.
114
+ WordPress and Timber projects should currently use `platform: "none"`. This keeps Emulsify Core in platform-neutral mode while still supporting Twig-oriented component development. A dedicated WordPress adapter may be added later when WordPress-specific behavior is introduced. The implemented adapters in this package are currently `none` and `drupal`.
107
115
 
108
116
  ## Public Imports
109
117
 
@@ -7,6 +7,7 @@
7
7
 
8
8
  export default {
9
9
  storybookBuildDir: '../../../../.out',
10
+ discoverStories: true,
10
11
  pa11y: {
11
12
  includeNotices: false,
12
13
  includeWarnings: false,
@@ -17,7 +18,7 @@ export default {
17
18
  codes: ['landmark-one-main', 'page-has-heading-one'],
18
19
  descriptions: ['Ensures all page content is contained by landmarks'],
19
20
  },
20
- // List of storybook component IDs defined and used in this project.
21
+ // Manual Storybook IDs merged with IDs discovered from built Storybook output.
21
22
  components: [
22
23
  'base-colors--palettes',
23
24
  'base-motion--usage',
@@ -9,6 +9,7 @@
9
9
  * - `SDC`: boolean from project.emulsify.json `project.singleDirectoryComponents`.
10
10
  * - `structureOverrides`: true when safe `variant.structureImplementations` exist.
11
11
  * - `structureRoots`: array of directories from `variant.structureImplementations`.
12
+ * - `assetRoots`: array of directories from safe `assets.roots` config.
12
13
  * - `platformAdapter`: active adapter for platform-specific behavior.
13
14
  */
14
15
 
@@ -26,6 +27,7 @@ import { resolveProjectConfig } from './project-config.js';
26
27
  * structureOverrides: boolean,
27
28
  * structureRoots: string[],
28
29
  * structureImplementations: Array<{name: string, directory: string}>,
30
+ * assetRoots: string[],
29
31
  * componentRoots: string[],
30
32
  * globalRoots: string[],
31
33
  * namespaceRoots: Record<string, string>,
@@ -11,6 +11,7 @@ import { normalize, resolve, sep } from 'path';
11
11
  import { getPlatformAdapter, normalizePlatformName } from './platforms.js';
12
12
  import { resolveProjectStructure } from './project-structure.js';
13
13
  import { safeExists, safeReadJson } from './utils/fs-safe.js';
14
+ import { unique } from './utils/unique.js';
14
15
 
15
16
  /**
16
17
  * Cache normalized project config by project root and relevant env signature.
@@ -94,6 +95,65 @@ function normalizeStructureImplementations(projectDir, implementations = []) {
94
95
  .filter(Boolean);
95
96
  }
96
97
 
98
+ /**
99
+ * Read public asset root configuration from supported project config paths.
100
+ *
101
+ * `assets.roots` is the documented public path.
102
+ * `projectStructure.assetRoots` and `project.assetRoots` remain supported for
103
+ * compatibility with earlier follow-up work and local project experiments.
104
+ *
105
+ * @param {object} rawConfig - Parsed project.emulsify.json contents.
106
+ * @returns {Array} Raw configured asset roots.
107
+ */
108
+ function rawAssetRoots(rawConfig = {}) {
109
+ return [
110
+ ...(Array.isArray(rawConfig?.assets?.roots) ? rawConfig.assets.roots : []),
111
+ ...(Array.isArray(rawConfig?.projectStructure?.assetRoots)
112
+ ? rawConfig.projectStructure.assetRoots
113
+ : []),
114
+ ...(Array.isArray(rawConfig?.project?.assetRoots)
115
+ ? rawConfig.project.assetRoots
116
+ : []),
117
+ ];
118
+ }
119
+
120
+ /**
121
+ * Normalize public asset root declarations.
122
+ *
123
+ * Paths must resolve inside the project root. Invalid entries are retained as
124
+ * diagnostics so the audit command can report them without making Storybook or
125
+ * Vite builds fail for existing projects.
126
+ *
127
+ * @param {string} projectDir - Absolute project root.
128
+ * @param {Array} roots - Raw configured asset root entries.
129
+ * @returns {{roots: string[], ignored: string[]}} Normalized asset root state.
130
+ */
131
+ function normalizeAssetRoots(projectDir, roots = []) {
132
+ if (!Array.isArray(roots)) return { roots: [], ignored: [] };
133
+
134
+ const normalizedRoots = [];
135
+ const ignored = [];
136
+
137
+ for (const item of roots) {
138
+ if (typeof item !== 'string' || !item.trim()) {
139
+ continue;
140
+ }
141
+
142
+ const directory = coerceToProjectPath(projectDir, item);
143
+ if (!directory) {
144
+ ignored.push(item);
145
+ continue;
146
+ }
147
+
148
+ normalizedRoots.push(normalize(directory));
149
+ }
150
+
151
+ return {
152
+ roots: unique(normalizedRoots),
153
+ ignored: unique(ignored),
154
+ };
155
+ }
156
+
97
157
  /**
98
158
  * Normalize project config for current tooling consumers.
99
159
  *
@@ -140,6 +200,7 @@ export function resolveProjectConfig(
140
200
  root,
141
201
  rawStructureImplementations,
142
202
  );
203
+ const assetRoots = normalizeAssetRoots(root, rawAssetRoots(rawConfig));
143
204
  const structureRoots = structureImplementations.map(
144
205
  (implementation) => implementation.directory,
145
206
  );
@@ -149,6 +210,8 @@ export function resolveProjectConfig(
149
210
  srcExists,
150
211
  SDC: singleDirectoryComponents,
151
212
  structureImplementations,
213
+ assetRoots: assetRoots.roots,
214
+ ignoredAssetRoots: assetRoots.ignored,
152
215
  platformAdapter,
153
216
  });
154
217
 
@@ -166,6 +229,8 @@ export function resolveProjectConfig(
166
229
  structureOverrides: projectStructure.structureOverrides,
167
230
  structureImplementations,
168
231
  structureRoots,
232
+ assetRoots: projectStructure.assetRoots,
233
+ ignoredAssetRoots: projectStructure.ignoredAssetRoots,
169
234
  componentRoots: projectStructure.componentRoots,
170
235
  globalRoots: projectStructure.globalRoots,
171
236
  namespaceRoots: projectStructure.namespaceRoots,
@@ -7,7 +7,7 @@
7
7
  * process.
8
8
  */
9
9
 
10
- import { basename, relative, resolve, sep } from 'path';
10
+ import { basename, normalize, relative, resolve, sep } from 'path';
11
11
  import { safeExists } from './utils/fs-safe.js';
12
12
  import { replaceLastSlash, toPosixPath } from './utils/paths.js';
13
13
  import { unique } from './utils/unique.js';
@@ -181,6 +181,33 @@ function fallbackNamespaceRoots({
181
181
  return namespaceRoots;
182
182
  }
183
183
 
184
+ /**
185
+ * Normalize project-scoped asset roots.
186
+ *
187
+ * @param {string} projectDir - Absolute project root.
188
+ * @param {string[]} assetRoots - Raw asset root paths.
189
+ * @returns {string[]} Safe absolute asset root paths.
190
+ */
191
+ function normalizeAssetRoots(projectDir, assetRoots = []) {
192
+ if (!Array.isArray(assetRoots)) return [];
193
+
194
+ const projectRoot = resolve(projectDir);
195
+
196
+ return unique(
197
+ assetRoots
198
+ .map((root) =>
199
+ typeof root === 'string' && root.trim()
200
+ ? normalize(resolve(projectRoot, root))
201
+ : '',
202
+ )
203
+ .filter(
204
+ (root) =>
205
+ root &&
206
+ (root === projectRoot || root.startsWith(`${projectRoot}${sep}`)),
207
+ ),
208
+ );
209
+ }
210
+
184
211
  /**
185
212
  * Resolve the serializable project structure model.
186
213
  *
@@ -190,6 +217,8 @@ function fallbackNamespaceRoots({
190
217
  * srcExists?: boolean,
191
218
  * SDC?: boolean,
192
219
  * structureImplementations?: {name: string, directory: string}[],
220
+ * assetRoots?: string[],
221
+ * ignoredAssetRoots?: string[],
193
222
  * platformAdapter?: object
194
223
  * }} [env] - Normalized project environment.
195
224
  * @returns {object} Project structure model.
@@ -214,6 +243,8 @@ export function resolveProjectStructure(env) {
214
243
  srcDir = defaultSrcDir,
215
244
  srcExists = safeExists(defaultSrcDir),
216
245
  SDC = false,
246
+ assetRoots: rawAssetRoots = [],
247
+ ignoredAssetRoots = [],
217
248
  platformAdapter = {},
218
249
  } = resolvedEnv;
219
250
  const structureImplementations =
@@ -244,6 +275,7 @@ export function resolveProjectStructure(env) {
244
275
  });
245
276
  const componentRoots = componentRootRecords.map((root) => root.directory);
246
277
  const globalRoots = globalRootRecords.map((root) => root.directory);
278
+ const assetRoots = normalizeAssetRoots(projectDir, rawAssetRoots);
247
279
  const namespaceRootValues = Object.values(namespaceRoots);
248
280
  const sourceRoots = unique(
249
281
  [...componentRoots, ...globalRoots].filter(Boolean),
@@ -278,7 +310,9 @@ export function resolveProjectStructure(env) {
278
310
  globalRootRecords,
279
311
  componentRoots,
280
312
  globalRoots,
313
+ assetRoots,
281
314
  sourceRoots,
315
+ ignoredAssetRoots: unique(ignoredAssetRoots),
282
316
  sourceRootRecords,
283
317
  storyRoots,
284
318
  twigRoots,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emulsify/core",
3
- "version": "4.0.3",
3
+ "version": "4.1.0",
4
4
  "description": "Bundled tooling for Storybook development + Vite Build",
5
5
  "keywords": [
6
6
  "component library",
@@ -98,6 +98,7 @@
98
98
  "src/storybook/index.js",
99
99
  "src/storybook/main-config.js",
100
100
  "src/storybook/platform-behaviors.js",
101
+ "src/storybook/preview-decorator.js",
101
102
  "src/storybook/preview-parameters.js",
102
103
  "src/storybook/render-twig.js",
103
104
  "src/storybook/twig/include-function.js",
@@ -157,7 +158,7 @@
157
158
  "twatch": "npm run check-node-version && jest --no-coverage --watch --verbose"
158
159
  },
159
160
  "dependencies": {
160
- "@babel/core": "^7.28.4",
161
+ "@babel/core": "^7.29.7",
161
162
  "@babel/eslint-parser": "^7.28.6",
162
163
  "@babel/preset-env": "^7.28.3",
163
164
  "@emulsify/cli": "^1.11.4",
@@ -171,7 +172,7 @@
171
172
  "autoprefixer": "^10.4.21",
172
173
  "axe-core": "^4.11.4",
173
174
  "babel-preset-minify": "^0.5.2",
174
- "concurrently": "^9.2.1",
175
+ "concurrently": "^9.2.3",
175
176
  "eslint": "^9.39.4",
176
177
  "eslint-config-prettier": "^10.1.8",
177
178
  "eslint-plugin-import": "^2.32.0",
@@ -200,7 +201,7 @@
200
201
  "stylelint-selector-bem-pattern": "^5.0.0",
201
202
  "twig": "^3.0.0",
202
203
  "twig-drupal-filters": "^3.2.0",
203
- "vite": "^7.3.3",
204
+ "vite": "^7.3.5",
204
205
  "vite-plugin-sass-glob-import": "^6.0.0",
205
206
  "vite-plugin-static-copy": "^4.1.0",
206
207
  "vite-plugin-svg-sprite": "^0.7.0",
package/scripts/a11y.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * and reports issues.
6
6
  */
7
7
 
8
- import { existsSync } from 'fs';
8
+ import { existsSync, readFileSync } from 'fs';
9
9
  import path from 'path';
10
10
  import { fileURLToPath, pathToFileURL } from 'url';
11
11
  import * as R from 'ramda';
@@ -16,15 +16,15 @@ import a11yConfig from '../config/a11y.config.js';
16
16
  const __filename = fileURLToPath(import.meta.url);
17
17
  const __dirname = path.dirname(__filename);
18
18
 
19
- const { storybookBuildDir, pa11y: pa11yConfig } = a11yConfig;
20
-
21
19
  // Project-specific configuration.
22
- let { ignore = {}, components = [] } = a11yConfig;
20
+ let {
21
+ ignore = {},
22
+ components = [],
23
+ discoverStories = true,
24
+ storybookBuildDir,
25
+ pa11y: pa11yConfig = {},
26
+ } = a11yConfig;
23
27
 
24
- /** Absolute path to Storybook build directory. */
25
- const STORYBOOK_BUILD_DIR = path.resolve(__dirname, '../', storybookBuildDir);
26
- /** Absolute path to Storybook iframe file used for per-story rendering. */
27
- const STORYBOOK_IFRAME = path.join(STORYBOOK_BUILD_DIR, 'iframe.html');
28
28
  /** Project-specific accessibility config path used by generated themes. */
29
29
  const PROJECT_A11Y_CONFIG = path.resolve(
30
30
  __dirname,
@@ -48,12 +48,26 @@ const loadProjectA11yConfig = async () => {
48
48
  /**
49
49
  * Apply project-specific a11y config values over shared defaults.
50
50
  *
51
- * @param {{ignore?: object, components?: string[]}} config - Project config.
51
+ * @param {{ignore?: object, components?: string[], discoverStories?: boolean, storybookBuildDir?: string, pa11y?: object}} config - Project config.
52
52
  * @returns {void}
53
53
  */
54
54
  const applyProjectA11yConfig = (config = {}) => {
55
55
  ignore = config.ignore || ignore;
56
- components = config.components || components;
56
+ components = Array.isArray(config.components)
57
+ ? config.components
58
+ : components;
59
+ discoverStories =
60
+ typeof config.discoverStories === 'boolean'
61
+ ? config.discoverStories
62
+ : discoverStories;
63
+ storybookBuildDir =
64
+ typeof config.storybookBuildDir === 'string' && config.storybookBuildDir
65
+ ? config.storybookBuildDir
66
+ : storybookBuildDir;
67
+ pa11yConfig =
68
+ config.pa11y && typeof config.pa11y === 'object'
69
+ ? { ...pa11yConfig, ...config.pa11y }
70
+ : pa11yConfig;
57
71
  };
58
72
 
59
73
  /**
@@ -67,12 +81,128 @@ const printHelp = () => {
67
81
  'Usage: node scripts/a11y.js [options]',
68
82
  '',
69
83
  'Options:',
70
- ' -r Run pa11y against configured Storybook component IDs.',
84
+ ' -r Run pa11y against discovered and configured Storybook story IDs.',
71
85
  ' -h, --help Print this help text.',
72
86
  ].join('\n'),
73
87
  );
74
88
  };
75
89
 
90
+ /**
91
+ * Resolve the configured Storybook build directory.
92
+ *
93
+ * @param {string} [buildDir=storybookBuildDir] - Configured build directory.
94
+ * @returns {string} Absolute Storybook build directory.
95
+ */
96
+ const resolveStorybookBuildDir = (buildDir = storybookBuildDir) =>
97
+ path.resolve(__dirname, '../', buildDir);
98
+
99
+ /**
100
+ * Resolve Storybook's iframe file used for per-story rendering.
101
+ *
102
+ * @param {string} [buildDir=storybookBuildDir] - Configured build directory.
103
+ * @returns {string} Absolute iframe.html path.
104
+ */
105
+ const resolveStorybookIframe = (buildDir = storybookBuildDir) =>
106
+ path.join(resolveStorybookBuildDir(buildDir), 'iframe.html');
107
+
108
+ /**
109
+ * Return unique non-empty Storybook IDs in first-seen order.
110
+ *
111
+ * @param {Array} values - Candidate story IDs.
112
+ * @returns {string[]} Unique story IDs.
113
+ */
114
+ const normalizeStoryIds = (values = []) =>
115
+ Array.from(
116
+ new Set(
117
+ values
118
+ .filter((value) => typeof value === 'string')
119
+ .map((value) => value.trim())
120
+ .filter(Boolean),
121
+ ),
122
+ );
123
+
124
+ /**
125
+ * Extract runnable story IDs from a Storybook index object.
126
+ *
127
+ * @param {object} index - Parsed Storybook index.json or stories.json.
128
+ * @returns {string[]} Story IDs.
129
+ */
130
+ const storyIdsFromStorybookIndex = (index = {}) => {
131
+ const entries =
132
+ index?.entries && typeof index.entries === 'object'
133
+ ? index.entries
134
+ : index?.stories && typeof index.stories === 'object'
135
+ ? index.stories
136
+ : {};
137
+
138
+ return normalizeStoryIds(
139
+ Object.entries(entries)
140
+ .filter(([, entry]) => !entry?.type || entry.type === 'story')
141
+ .map(([id, entry]) =>
142
+ typeof entry?.id === 'string' && entry.id ? entry.id : id,
143
+ ),
144
+ );
145
+ };
146
+
147
+ /**
148
+ * Discover runnable Storybook story IDs from built Storybook output.
149
+ *
150
+ * @param {string} [buildDir=storybookBuildDir] - Configured Storybook build directory.
151
+ * @param {{warn?: Function}} [options] - Reporting options.
152
+ * @returns {string[]} Story IDs discovered from Storybook's generated index.
153
+ */
154
+ const discoverStoryIds = (
155
+ buildDir = storybookBuildDir,
156
+ { warn = console.warn } = {},
157
+ ) => {
158
+ const indexPath = path.join(resolveStorybookBuildDir(buildDir), 'index.json');
159
+
160
+ if (!existsSync(indexPath)) {
161
+ warn(
162
+ `Storybook index not found at ${indexPath}; falling back to configured Pa11y story IDs.`,
163
+ );
164
+ return [];
165
+ }
166
+
167
+ try {
168
+ return storyIdsFromStorybookIndex(
169
+ JSON.parse(readFileSync(indexPath, 'utf8')),
170
+ );
171
+ } catch (error) {
172
+ warn(
173
+ `Unable to read Storybook index at ${indexPath}: ${
174
+ error.message || error
175
+ }; falling back to configured Pa11y story IDs.`,
176
+ );
177
+ return [];
178
+ }
179
+ };
180
+
181
+ /**
182
+ * Resolve the final Pa11y story ID list.
183
+ *
184
+ * @param {object} [options={}] - Resolution options.
185
+ * @param {string[]} [options.manualIds=components] - Manually configured IDs.
186
+ * @param {boolean} [options.discover=discoverStories] - Whether discovery is enabled.
187
+ * @param {string} [options.buildDir=storybookBuildDir] - Storybook build directory.
188
+ * @param {Function} [options.warn=console.warn] - Warning sink.
189
+ * @returns {string[]} Story IDs to lint.
190
+ */
191
+ const resolvePa11yStoryIds = ({
192
+ manualIds = components,
193
+ discover = discoverStories,
194
+ buildDir = storybookBuildDir,
195
+ warn = console.warn,
196
+ } = {}) => {
197
+ const manual = normalizeStoryIds(manualIds);
198
+ if (discover === false) return manual;
199
+
200
+ return normalizeStoryIds([
201
+ ...manual,
202
+ ...discoverStoryIds(buildDir, { warn }),
203
+ ]);
204
+ };
205
+
76
206
  /**
77
207
  * Map pa11y/axe severity to a label (historically a color name).
78
208
  * Retained for backward compatibility, but not used for styling anymore.
@@ -155,7 +285,7 @@ const logReport = ({ issues, pageUrl }) => {
155
285
  * @returns {Promise<{ issues: Pa11yIssue[], pageUrl: string }>} Pa11y result.
156
286
  */
157
287
  const lintComponent = async (name) =>
158
- pa11y(`${STORYBOOK_IFRAME}?id=${name}`, {
288
+ pa11y(`${resolveStorybookIframe()}?id=${name}`, {
159
289
  includeNotices: true,
160
290
  includeWarnings: true,
161
291
  runners: ['axe'],
@@ -188,15 +318,22 @@ if (R.includes(process.argv[2], ['-h', '--help'])) {
188
318
  } else if (R.pathEq(['argv', 2], '-r')(process)) {
189
319
  loadProjectA11yConfig().then((projectConfig) => {
190
320
  applyProjectA11yConfig(projectConfig);
191
- return lintReportAndExit(components);
321
+ return lintReportAndExit(resolvePa11yStoryIds());
192
322
  });
193
323
  }
194
324
 
195
325
  export {
196
326
  severityToColor,
327
+ applyProjectA11yConfig,
328
+ discoverStoryIds,
197
329
  issueIsValid,
198
330
  logIssue,
199
331
  logReport,
200
332
  lintComponent,
201
333
  lintReportAndExit,
334
+ normalizeStoryIds,
335
+ resolvePa11yStoryIds,
336
+ resolveStorybookBuildDir,
337
+ resolveStorybookIframe,
338
+ storyIdsFromStorybookIndex,
202
339
  };
package/scripts/audit.js CHANGED
@@ -5,7 +5,14 @@
5
5
  */
6
6
 
7
7
  import { lstatSync, readdirSync, statSync } from 'node:fs';
8
- import { basename, dirname, relative, resolve, sep } from 'node:path';
8
+ import {
9
+ basename,
10
+ dirname,
11
+ isAbsolute,
12
+ relative,
13
+ resolve,
14
+ sep,
15
+ } from 'node:path';
9
16
  import { globSync } from 'glob';
10
17
  import { resolveProjectConfig } from '../config/vite/project-config.js';
11
18
  import {
@@ -68,6 +75,7 @@ const RECOMMENDED_PACKAGE_OVERRIDES = [
68
75
  ];
69
76
  const GENERATED_PACKAGE_SCRIPT_DOCS =
70
77
  'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/migration-4x.md#manual-packagejson-updates';
78
+ const GENERATED_ASSET_ALIASES = new Set(['icons.svg']);
71
79
 
72
80
  /**
73
81
  * Cache source file reads for one top-level audit run.
@@ -388,6 +396,18 @@ function auditProjectConfig(context) {
388
396
  }
389
397
  }
390
398
 
399
+ for (const root of env.ignoredAssetRoots || []) {
400
+ findings.push(
401
+ makeFinding({
402
+ id: 'invalid-asset-root',
403
+ severity: 'warn',
404
+ filePath: resolve(projectDir, 'project.emulsify.json'),
405
+ message: `Configured asset root "${root}" was ignored because it resolves outside the project root.`,
406
+ docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/project-structure.md#asset-roots',
407
+ }),
408
+ );
409
+ }
410
+
391
411
  return findings;
392
412
  }
393
413
 
@@ -726,6 +746,74 @@ function candidateKeysToFiles(keys, env) {
726
746
  );
727
747
  }
728
748
 
749
+ /**
750
+ * Resolve an audit asset root using Storybook's root-relative convention.
751
+ *
752
+ * @param {string} projectDir - Absolute project root.
753
+ * @param {string} assetRoot - Configured, absolute, or project-relative root.
754
+ * @returns {string} Absolute filesystem path, or an empty string.
755
+ */
756
+ function resolveAuditAssetRoot(projectDir, assetRoot) {
757
+ if (typeof assetRoot !== 'string' || !assetRoot.trim()) return '';
758
+
759
+ const normalizedProjectDir = resolve(projectDir || process.cwd());
760
+ const normalizedRoot = assetRoot.trim();
761
+
762
+ if (isAbsolute(normalizedRoot)) {
763
+ const absoluteRoot = resolve(normalizedRoot);
764
+
765
+ return safeExists(absoluteRoot)
766
+ ? absoluteRoot
767
+ : resolve(normalizedProjectDir, `.${normalizedRoot}`);
768
+ }
769
+
770
+ return resolve(normalizedProjectDir, normalizedRoot);
771
+ }
772
+
773
+ /**
774
+ * Return filesystem roots that Storybook can use for @assets source() calls.
775
+ *
776
+ * @param {object} env - Normalized environment.
777
+ * @param {object} [options={}] - Asset root options.
778
+ * @param {boolean} [options.includeGenerated=false] - Include generated roots.
779
+ * @returns {string[]} Absolute asset roots.
780
+ */
781
+ function auditAssetRoots(env = {}, { includeGenerated = false } = {}) {
782
+ const projectDir = env.projectDir || process.cwd();
783
+ const configuredRoots = Array.isArray(env?.projectStructure?.assetRoots)
784
+ ? env.projectStructure.assetRoots
785
+ : [];
786
+ const fallbackRoots = ['assets', 'src/assets'];
787
+ const generatedRoots = includeGenerated ? ['dist/assets'] : [];
788
+
789
+ return Array.from(
790
+ new Set(
791
+ [...fallbackRoots, ...configuredRoots, ...generatedRoots]
792
+ .map((root) => resolveAuditAssetRoot(projectDir, root))
793
+ .filter(Boolean),
794
+ ),
795
+ );
796
+ }
797
+
798
+ /**
799
+ * Determine whether an @assets reference resolves through Storybook asset roots.
800
+ *
801
+ * @param {string} reference - Twig @assets reference.
802
+ * @param {object} env - Normalized environment.
803
+ * @returns {boolean} TRUE when a candidate exists.
804
+ */
805
+ function resolvesAssetReference(reference, env) {
806
+ const relAsset = reference.replace(/^@assets\//, '');
807
+ if (!relAsset) return false;
808
+ const includeGenerated = GENERATED_ASSET_ALIASES.has(relAsset);
809
+
810
+ return auditAssetRoots(env, { includeGenerated }).some((root) => {
811
+ const candidate = resolve(root, relAsset);
812
+
813
+ return isSameOrInside(candidate, root) && safeExists(candidate);
814
+ });
815
+ }
816
+
729
817
  /**
730
818
  * Determine whether a Twig include/source reference resolves.
731
819
  *
@@ -738,8 +826,7 @@ export function resolvesTwigReference(reference, filePath, env) {
738
826
  if (!reference || /^https?:\/\//i.test(reference)) return true;
739
827
 
740
828
  if (reference.startsWith('@assets/')) {
741
- const relAsset = reference.replace(/^@assets\//, '');
742
- return safeExists(resolve(env.projectDir, 'assets', relAsset));
829
+ return resolvesAssetReference(reference, env);
743
830
  }
744
831
 
745
832
  const candidates =
@@ -971,7 +1058,7 @@ function styleRuntimeDirectories(filePath, env, projectDir) {
971
1058
  function auditCssAssetReferences(context) {
972
1059
  const { env, projectDir, styleFiles } = context;
973
1060
  const findings = [];
974
- const projectAssetsDir = resolve(projectDir, 'assets');
1061
+ const projectAssetRoots = auditAssetRoots(env).filter(safeIsDirectory);
975
1062
  const styleSourceRoots = env.projectStructure?.sourceRoots || [];
976
1063
 
977
1064
  for (const filePath of styleFiles) {
@@ -1017,7 +1104,7 @@ function auditCssAssetReferences(context) {
1017
1104
  }
1018
1105
 
1019
1106
  if (
1020
- isSameOrInside(resolvedAsset, projectAssetsDir) &&
1107
+ projectAssetRoots.some((root) => isSameOrInside(resolvedAsset, root)) &&
1021
1108
  (!sourceAsset || runtimeAsset || assetPath.startsWith('..'))
1022
1109
  ) {
1023
1110
  findings.push(
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @file Shared helpers for Storybook preview decorator wiring.
3
+ */
4
+
5
+ import React from 'react';
6
+ import {
7
+ renderHtmlStoryResult,
8
+ withLegacyStoryToString,
9
+ } from './render-twig.js';
10
+
11
+ /**
12
+ * Render a Storybook story function as a React element with full context props.
13
+ *
14
+ * @param {Function} storyFn - Storybook story function.
15
+ * @param {object} context - Full Storybook story context.
16
+ * @returns {React.ReactElement} React story element.
17
+ */
18
+ export function createStoryElement(storyFn, context) {
19
+ return withLegacyStoryToString(React.createElement(storyFn, context), () =>
20
+ storyFn(context),
21
+ );
22
+ }
23
+
24
+ /**
25
+ * Apply Storybook decorators while preserving full story context.
26
+ *
27
+ * @param {Function} decorateStory - Storybook decorator applier.
28
+ * @param {Function} storyFn - Storybook story function.
29
+ * @param {Function[]} decorators - Storybook decorators.
30
+ * @returns {Function} Decorated story function.
31
+ */
32
+ export const applyStoryDecorators = (decorateStory, storyFn, decorators) =>
33
+ decorateStory((context) => createStoryElement(storyFn, context), decorators);
34
+
35
+ /**
36
+ * Render a preview Story callback through Emulsify's HTML compatibility layer.
37
+ *
38
+ * @param {Function} Story - Decorated Storybook story callback.
39
+ * @param {object} context - Full Storybook story context.
40
+ * @param {object} [options={}] - Render options.
41
+ * @returns {*} Rendered Storybook story result.
42
+ */
43
+ export function renderPreviewStory(Story, context, options = {}) {
44
+ return renderHtmlStoryResult(Story(context), options);
45
+ }