@emulsify/core 4.3.2 → 4.5.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.
Files changed (54) hide show
  1. package/.storybook/main-static-assets.js +5 -8
  2. package/.storybook/main-vite.js +11 -3
  3. package/README.md +14 -6
  4. package/config/a11y-wcag22.js +11 -0
  5. package/config/vite/entries.js +7 -2
  6. package/config/vite/environment.js +4 -0
  7. package/config/vite/plugins/assets/asset-url-rebase.js +241 -0
  8. package/config/vite/plugins/assets/copy-src-assets.js +82 -12
  9. package/config/vite/plugins/assets/copy-twig-files.js +85 -16
  10. package/config/vite/plugins/assets/css-asset-rebase.js +306 -0
  11. package/config/vite/plugins/assets/css-asset-relativizer.js +301 -21
  12. package/config/vite/plugins/assets/development-source-maps.js +273 -0
  13. package/config/vite/plugins/assets/mirror-components.js +98 -82
  14. package/config/vite/plugins/assets/output-freshness.js +235 -0
  15. package/config/vite/plugins/assets/source-file-index.js +13 -13
  16. package/config/vite/plugins/assets/stable-watch-output.js +165 -0
  17. package/config/vite/plugins/assets/storybook-output.js +27 -0
  18. package/config/vite/plugins/index.js +95 -9
  19. package/config/vite/plugins/reporter/asset-resolver.js +34 -6
  20. package/config/vite/plugins/reporter/build-errors.js +7 -3
  21. package/config/vite/plugins/reporter/diagnostics.js +140 -10
  22. package/config/vite/plugins/reporter/index.js +380 -75
  23. package/config/vite/plugins/reporter/render.js +297 -44
  24. package/config/vite/plugins/reporter/sass-logger.js +30 -0
  25. package/config/vite/plugins/reporter/source-roots.js +101 -21
  26. package/config/vite/plugins/reporter/strict-mode.js +99 -0
  27. package/config/vite/plugins/reporter/vite-logger.js +220 -8
  28. package/config/vite/plugins/reporter/watch-mode.js +6 -2
  29. package/config/vite/plugins/twig/twig-module.js +35 -258
  30. package/config/vite/plugins/twig/virtual-twig-asset-sources.js +48 -49
  31. package/config/vite/project-config.js +121 -21
  32. package/config/vite/project-structure.js +6 -0
  33. package/config/vite/utils/asset-roots.js +205 -0
  34. package/config/vite/utils/css-urls.js +350 -0
  35. package/config/vite/utils/fs-safe.js +38 -1
  36. package/config/vite/utils/source-directory-skips.js +13 -0
  37. package/config/vite/utils/source-maps.js +88 -0
  38. package/config/vite/utils/twig-component-resolver.js +316 -0
  39. package/config/vite/vite.config.js +106 -42
  40. package/package.json +54 -40
  41. package/scripts/a11y.js +88 -9
  42. package/scripts/audit/checks/css-asset-references.js +256 -24
  43. package/scripts/audit/checks/twig-references.js +16 -5
  44. package/scripts/audit/fix.js +836 -0
  45. package/scripts/audit/index.js +10 -2
  46. package/scripts/audit/lib/css.js +41 -35
  47. package/scripts/audit/lib/story-ast.js +392 -0
  48. package/scripts/audit/lib/story-render-paths.js +600 -0
  49. package/scripts/audit/lib/story-selection.js +190 -0
  50. package/scripts/audit/lib/twig.js +372 -80
  51. package/scripts/audit/report.js +83 -5
  52. package/scripts/audit-twig-stories.js +73 -3
  53. package/scripts/audit.js +87 -2
  54. package/src/storybook/twig/source-function.js +14 -10
@@ -7,7 +7,7 @@
7
7
  * per project directory and relevant environment signature for one process.
8
8
  */
9
9
 
10
- import { normalize, resolve, sep } from 'path';
10
+ import { normalize, posix, resolve, sep, win32 } 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';
@@ -47,6 +47,51 @@ function normalizeIdentifier(value) {
47
47
  return (value || '').toString().toLowerCase().trim();
48
48
  }
49
49
 
50
+ /** Match Unicode characters in the General_Category=Control class. */
51
+ const CONTROL_CHARACTER_RE = /\p{Cc}/u;
52
+
53
+ /**
54
+ * Normalize a structure implementation name without allowing path semantics.
55
+ *
56
+ * Names become output-directory segments and Twig namespace keys. Rejecting
57
+ * path-like values is safer than stripping them: two distinct configured
58
+ * names must never silently collapse onto the same output directory.
59
+ *
60
+ * @param {*} value - Candidate implementation name.
61
+ * @param {number} index - Implementation index for fallback and diagnostics.
62
+ * @returns {string} Safe normalized name.
63
+ * @throws {Error} When an explicit name is not a control-free path segment.
64
+ */
65
+ function normalizeStructureImplementationName(value, index) {
66
+ if (typeof value !== 'string') {
67
+ return `structure-${index + 1}`;
68
+ }
69
+
70
+ if (CONTROL_CHARACTER_RE.test(value)) {
71
+ throw new Error(
72
+ `Invalid variant.structureImplementations[${index}].name ${JSON.stringify(value)}: expected a single path segment without control characters.`,
73
+ );
74
+ }
75
+
76
+ if (!value.trim()) {
77
+ return `structure-${index + 1}`;
78
+ }
79
+
80
+ const name = normalizeIdentifier(value);
81
+ if (
82
+ name === '.' ||
83
+ name === '..' ||
84
+ posix.basename(name) !== name ||
85
+ win32.basename(name) !== name
86
+ ) {
87
+ throw new Error(
88
+ `Invalid variant.structureImplementations[${index}].name ${JSON.stringify(value)}: expected a single path segment.`,
89
+ );
90
+ }
91
+
92
+ return name;
93
+ }
94
+
50
95
  /**
51
96
  * Build the environment signature for config values that affect resolution.
52
97
  *
@@ -60,39 +105,88 @@ function projectConfigEnvSignature(env = {}) {
60
105
  EMULSIFY_PLATFORM: platformOverride
61
106
  ? normalizePlatformName(platformOverride)
62
107
  : '',
108
+ EMULSIFY_ASSET_REBASE: normalizeIdentifier(env.EMULSIFY_ASSET_REBASE),
109
+ EMULSIFY_SELF_CONTAINED_OUTPUT: normalizeIdentifier(
110
+ env.EMULSIFY_SELF_CONTAINED_OUTPUT,
111
+ ),
63
112
  });
64
113
  }
65
114
 
115
+ /**
116
+ * Resolve whether the build may repair unresolvable CSS asset URLs.
117
+ *
118
+ * On by default: the URLs it repairs are already broken in every output shape
119
+ * except mirrored Drupal SDC, so an opt-in would leave the defect in place for
120
+ * anyone who does not read a changelog. The env override is the bisect tool —
121
+ * a consumer can turn the repair off for one build without editing config.
122
+ *
123
+ * @param {object} rawConfig - Parsed project.emulsify.json contents.
124
+ * @param {NodeJS.ProcessEnv|Record<string,string>} env - Environment values.
125
+ * @returns {boolean} TRUE when the rebase is enabled.
126
+ */
127
+ function resolveAssetRebase(rawConfig = {}, env = {}) {
128
+ const override = normalizeIdentifier(env.EMULSIFY_ASSET_REBASE);
129
+ if (override) return !['0', 'false', 'off', 'no'].includes(override);
130
+
131
+ return rawConfig?.assets?.rebase !== false;
132
+ }
133
+
134
+ /**
135
+ * Resolve whether project assets remain inside the build output.
136
+ *
137
+ * Self-contained output preserves the existing deployment contract by default.
138
+ * Projects that deploy the complete theme directory may opt into leaner output
139
+ * through project config or a one-build environment override.
140
+ *
141
+ * @param {object} rawConfig - Parsed project.emulsify.json contents.
142
+ * @param {NodeJS.ProcessEnv|Record<string,string>} env - Environment values.
143
+ * @returns {boolean} TRUE when project assets remain in the output directory.
144
+ */
145
+ function resolveSelfContainedOutput(rawConfig = {}, env = {}) {
146
+ const override = normalizeIdentifier(env.EMULSIFY_SELF_CONTAINED_OUTPUT);
147
+ if (override) return !['0', 'false', 'off', 'no'].includes(override);
148
+
149
+ return rawConfig?.assets?.selfContainedOutput !== false;
150
+ }
151
+
66
152
  /**
67
153
  * Normalize variant structure implementation declarations.
68
154
  *
69
155
  * @param {string} projectDir - Absolute project root.
70
156
  * @param {Array} implementations - Raw implementation entries.
71
157
  * @returns {{name: string, directory: string}[]} Safe implementation entries.
158
+ * @throws {Error} When valid entries normalize to the same name.
72
159
  */
73
160
  function normalizeStructureImplementations(projectDir, implementations = []) {
74
161
  if (!Array.isArray(implementations)) return [];
75
162
 
76
- return implementations
77
- .map((item, index) => {
78
- const rawDirectory =
79
- typeof item?.directory === 'string' ? item.directory : null;
80
- const directory = rawDirectory
81
- ? coerceToProjectPath(projectDir, rawDirectory)
82
- : null;
83
- if (!directory) return null;
84
-
85
- const name =
86
- typeof item?.name === 'string' && item.name.trim()
87
- ? normalizeIdentifier(item.name)
88
- : `structure-${index + 1}`;
89
-
90
- return {
91
- name,
92
- directory: normalize(directory),
93
- };
94
- })
95
- .filter(Boolean);
163
+ const normalized = [];
164
+ const nameIndexes = new Map();
165
+
166
+ for (const [index, item] of implementations.entries()) {
167
+ const name = normalizeStructureImplementationName(item?.name, index);
168
+ const rawDirectory =
169
+ typeof item?.directory === 'string' ? item.directory : null;
170
+ const directory = rawDirectory
171
+ ? coerceToProjectPath(projectDir, rawDirectory)
172
+ : null;
173
+ if (!directory) continue;
174
+
175
+ const previousIndex = nameIndexes.get(name);
176
+ if (previousIndex !== undefined) {
177
+ throw new Error(
178
+ `Invalid variant.structureImplementations[${index}].name ${JSON.stringify(item?.name)}: normalized name ${JSON.stringify(name)} duplicates variant.structureImplementations[${previousIndex}].name.`,
179
+ );
180
+ }
181
+
182
+ nameIndexes.set(name, index);
183
+ normalized.push({
184
+ name,
185
+ directory: normalize(directory),
186
+ });
187
+ }
188
+
189
+ return normalized;
96
190
  }
97
191
 
98
192
  /**
@@ -201,6 +295,8 @@ export function resolveProjectConfig(
201
295
  rawStructureImplementations,
202
296
  );
203
297
  const assetRoots = normalizeAssetRoots(root, rawAssetRoots(rawConfig));
298
+ const assetRebase = resolveAssetRebase(rawConfig, env);
299
+ const selfContainedOutput = resolveSelfContainedOutput(rawConfig, env);
204
300
  const structureRoots = structureImplementations.map(
205
301
  (implementation) => implementation.directory,
206
302
  );
@@ -212,6 +308,8 @@ export function resolveProjectConfig(
212
308
  structureImplementations,
213
309
  assetRoots: assetRoots.roots,
214
310
  ignoredAssetRoots: assetRoots.ignored,
311
+ assetRebase,
312
+ selfContainedOutput,
215
313
  platformAdapter,
216
314
  });
217
315
 
@@ -231,6 +329,8 @@ export function resolveProjectConfig(
231
329
  structureRoots,
232
330
  assetRoots: projectStructure.assetRoots,
233
331
  ignoredAssetRoots: projectStructure.ignoredAssetRoots,
332
+ assetRebase: projectStructure.assetRebase,
333
+ selfContainedOutput: projectStructure.selfContainedOutput,
234
334
  componentRoots: projectStructure.componentRoots,
235
335
  globalRoots: projectStructure.globalRoots,
236
336
  namespaceRoots: projectStructure.namespaceRoots,
@@ -219,6 +219,8 @@ function normalizeAssetRoots(projectDir, assetRoots = []) {
219
219
  * structureImplementations?: {name: string, directory: string}[],
220
220
  * assetRoots?: string[],
221
221
  * ignoredAssetRoots?: string[],
222
+ * assetRebase?: boolean,
223
+ * selfContainedOutput?: boolean,
222
224
  * platformAdapter?: object
223
225
  * }} [env] - Normalized project environment.
224
226
  * @returns {object} Project structure model.
@@ -245,6 +247,8 @@ export function resolveProjectStructure(env) {
245
247
  SDC = false,
246
248
  assetRoots: rawAssetRoots = [],
247
249
  ignoredAssetRoots = [],
250
+ assetRebase = true,
251
+ selfContainedOutput = true,
248
252
  platformAdapter = {},
249
253
  } = resolvedEnv;
250
254
  const structureImplementations =
@@ -311,6 +315,8 @@ export function resolveProjectStructure(env) {
311
315
  componentRoots,
312
316
  globalRoots,
313
317
  assetRoots,
318
+ assetRebase: assetRebase !== false,
319
+ selfContainedOutput: selfContainedOutput !== false,
314
320
  sourceRoots,
315
321
  ignoredAssetRoots: unique(ignoredAssetRoots),
316
322
  sourceRootRecords,
@@ -0,0 +1,205 @@
1
+ /**
2
+ * @file Shared project asset root resolution.
3
+ *
4
+ * Three places used to keep their own copy of "where does `/assets/...` come
5
+ * from": the audit (`scripts/audit/lib/twig.js`), the Twig source() virtual
6
+ * module (`config/vite/plugins/twig/virtual-twig-asset-sources.js`), and
7
+ * Storybook's static mounts (`.storybook/main-static-assets.js`). They
8
+ * disagreed on precedence, so the audit could name a root that Storybook
9
+ * shadowed. This module is the single list; every caller delegates here.
10
+ *
11
+ * Precedence is configured `assets.roots` first, then root `assets/`, then
12
+ * `src/assets/` — the order Storybook actually serves at `/assets`, which is
13
+ * what an author's `url('/assets/...')` resolves against at review time.
14
+ */
15
+
16
+ import { statSync } from 'fs';
17
+ import { isAbsolute, relative, resolve, sep, win32 } from 'path';
18
+
19
+ import { safeExists, safeRealPath } from './fs-safe.js';
20
+ import { toPosixPath } from './paths.js';
21
+ import { unique } from '../../../src/extensions/shared/lists.js';
22
+
23
+ /**
24
+ * Asset roots every project gets, whether or not `assets.roots` is configured.
25
+ *
26
+ * @type {string[]}
27
+ */
28
+ export const DEFAULT_ASSET_ROOTS = ['assets', 'src/assets'];
29
+
30
+ /**
31
+ * Build output roots, opt-in because a build that resolved through its own
32
+ * previous output would not be reproducible from a clean tree.
33
+ *
34
+ * @type {string[]}
35
+ */
36
+ export const GENERATED_ASSET_ROOTS = ['dist/assets'];
37
+
38
+ /**
39
+ * Determine whether a path is the same as a directory or inside it.
40
+ *
41
+ * @param {string} candidate - Absolute candidate path.
42
+ * @param {string} directory - Absolute directory path.
43
+ * @returns {boolean} TRUE when the candidate cannot escape the directory.
44
+ */
45
+ function isSameOrInside(candidate, directory) {
46
+ if (candidate === directory) return true;
47
+ const rel = relative(directory, candidate);
48
+
49
+ // On Windows, `relative()` returns the target unchanged when it sits on
50
+ // another volume — `relative('C:\\p\\assets', 'D:\\out\\x.svg')` is
51
+ // `'D:\\out\\x.svg'`, and a UNC share behaves the same way. Neither result
52
+ // begins with `..`, so the checks below would read an escape as containment.
53
+ if (isAbsolute(rel)) return false;
54
+
55
+ return Boolean(rel) && !rel.startsWith('..') && !rel.includes(`..${sep}`);
56
+ }
57
+
58
+ /**
59
+ * Determine whether an asset tail names a volume instead of a relative path.
60
+ *
61
+ * A tail is everything after the `assets/` prefix in an authored URL, so it is
62
+ * relative by construction. A drive-qualified (`D:/x.svg`), drive-relative
63
+ * (`D:x.svg`), or UNC (`\\server\share\x.svg`) tail is therefore malformed,
64
+ * and on Windows `resolve()` would switch away from the asset root. That also
65
+ * rejects a POSIX-legal first segment such as `x:y.png`: it is syntactically
66
+ * indistinguishable from a Windows drive-relative path. Windows semantics are
67
+ * checked on every platform so the API remains portable and testable in CI.
68
+ *
69
+ * @param {string} tail - Asset path relative to an asset root.
70
+ * @returns {boolean} TRUE when the tail escapes any root it is resolved from.
71
+ */
72
+ function isVolumeQualified(tail) {
73
+ return isAbsolute(tail) || Boolean(win32.parse(tail).root);
74
+ }
75
+
76
+ /**
77
+ * Determine whether an asset candidate is a regular file.
78
+ *
79
+ * @param {string} candidate - Absolute candidate path.
80
+ * @returns {boolean} TRUE when the candidate is a file.
81
+ */
82
+ function isFile(candidate) {
83
+ try {
84
+ return statSync(candidate).isFile();
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Resolve an asset root declaration to an absolute filesystem path.
92
+ *
93
+ * Accepts the three forms consumers write: an absolute filesystem path, a
94
+ * project-relative path (`./design-system/assets`), and Vite's root-relative
95
+ * form (`/assets`), which is what `project.emulsify.json` and Storybook use.
96
+ * An absolute-looking root that neither sits inside the project nor exists on
97
+ * disk is reinterpreted as root-relative, matching the behavior both previous
98
+ * copies had.
99
+ *
100
+ * @param {string} projectDir - Absolute project root.
101
+ * @param {string} assetRoot - Absolute, project-relative, or root-relative root.
102
+ * @returns {string} Absolute filesystem path, or an empty string.
103
+ */
104
+ export function toAbsoluteAssetRoot(projectDir, assetRoot) {
105
+ if (typeof assetRoot !== 'string') return '';
106
+
107
+ const trimmed = assetRoot.trim().replace(/[/\\]+$/, '');
108
+ if (!trimmed) return '';
109
+
110
+ const base = resolve(projectDir || process.cwd());
111
+
112
+ if (isAbsolute(trimmed)) {
113
+ const absolute = resolve(trimmed);
114
+ if (isSameOrInside(absolute, base) || safeExists(absolute)) {
115
+ return absolute;
116
+ }
117
+
118
+ // Vite root-relative: "/assets" means "<projectDir>/assets".
119
+ return resolve(base, `.${toPosixPath(trimmed)}`);
120
+ }
121
+
122
+ return resolve(base, trimmed);
123
+ }
124
+
125
+ /**
126
+ * Resolve the ordered asset roots for a project.
127
+ *
128
+ * @param {{projectDir?: string, projectStructure?: {assetRoots?: string[]}}} [env={}] - Emulsify environment.
129
+ * @param {object} [options={}] - Resolution options.
130
+ * @param {boolean} [options.includeGenerated=false] - Append `dist/assets`.
131
+ * @param {boolean} [options.existingOnly=true] - Drop roots absent from disk.
132
+ * @returns {string[]} Absolute asset roots, in precedence order.
133
+ */
134
+ export function resolveAssetRoots(
135
+ env = {},
136
+ { includeGenerated = false, existingOnly = true } = {},
137
+ ) {
138
+ const projectDir = env?.projectDir || process.cwd();
139
+ const configured = Array.isArray(env?.projectStructure?.assetRoots)
140
+ ? env.projectStructure.assetRoots
141
+ : [];
142
+
143
+ const roots = unique(
144
+ [
145
+ ...configured,
146
+ ...DEFAULT_ASSET_ROOTS,
147
+ ...(includeGenerated ? GENERATED_ASSET_ROOTS : []),
148
+ ]
149
+ .map((root) => toAbsoluteAssetRoot(projectDir, root))
150
+ .filter(Boolean),
151
+ );
152
+
153
+ return existingOnly ? roots.filter((root) => safeExists(root)) : roots;
154
+ }
155
+
156
+ /**
157
+ * Resolve a published asset path (the part after `/assets/`) against the roots.
158
+ * The tail must already be relative and portable across POSIX and Windows;
159
+ * leading separators and Windows volume syntax are rejected as malformed.
160
+ *
161
+ * Overlapping roots are normal — a project can declare `./assets` explicitly
162
+ * and still pick up the implicit root — so candidates are collapsed by their
163
+ * canonical path before ambiguity is decided. A genuine ambiguity means two
164
+ * different files answer to one URL, which no caller may guess at.
165
+ *
166
+ * @param {string} tail - Asset path relative to an asset root.
167
+ * @param {string[]} [roots=[]] - Absolute asset roots, in precedence order.
168
+ * @returns {{status: 'resolved'|'ambiguous'|'missing', file?: string, root?: string, candidates: string[]}} Resolution.
169
+ */
170
+ export function resolveAssetTail(tail, roots = []) {
171
+ const cleaned = String(tail || '').trim();
172
+ if (!cleaned || isVolumeQualified(cleaned)) {
173
+ return { status: 'missing', candidates: [] };
174
+ }
175
+
176
+ const seen = new Set();
177
+ /** @type {{file: string, root: string}[]} */
178
+ const matches = [];
179
+
180
+ for (const root of roots) {
181
+ const candidate = resolve(root, cleaned);
182
+
183
+ // A tail such as `../../etc/passwd` must not escape its root.
184
+ if (!isSameOrInside(candidate, root)) continue;
185
+ if (!isFile(candidate)) continue;
186
+
187
+ const key = safeRealPath(candidate);
188
+ if (seen.has(key)) continue;
189
+
190
+ seen.add(key);
191
+ matches.push({ file: candidate, root });
192
+ }
193
+
194
+ if (!matches.length) return { status: 'missing', candidates: [] };
195
+
196
+ const candidates = matches.map((match) => match.file);
197
+ if (matches.length > 1) return { status: 'ambiguous', candidates };
198
+
199
+ return {
200
+ status: 'resolved',
201
+ file: matches[0].file,
202
+ root: matches[0].root,
203
+ candidates,
204
+ };
205
+ }