@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.
- package/.storybook/main.js +50 -19
- package/.storybook/preview.js +8 -13
- package/README.md +11 -3
- package/config/a11y.config.js +2 -1
- package/config/vite/environment.js +2 -0
- package/config/vite/project-config.js +65 -0
- package/config/vite/project-structure.js +35 -1
- package/package.json +5 -4
- package/scripts/a11y.js +150 -13
- package/scripts/audit.js +92 -5
- package/src/storybook/preview-decorator.js +45 -0
package/.storybook/main.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
package/.storybook/preview.js
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
17
|
-
} from '../src/storybook/
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
package/config/a11y.config.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
+
"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.
|
|
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.
|
|
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.
|
|
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 {
|
|
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
|
|
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
|
|
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(`${
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
+
}
|