@emulsify/core 4.3.1 → 4.4.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 (44) hide show
  1. package/.storybook/main-static-assets.js +5 -8
  2. package/.storybook/main-vite.js +11 -3
  3. package/README.md +4 -5
  4. package/config/vite/entries.js +7 -2
  5. package/config/vite/environment.js +4 -0
  6. package/config/vite/plugins/assets/asset-url-rebase.js +241 -0
  7. package/config/vite/plugins/assets/copy-src-assets.js +82 -12
  8. package/config/vite/plugins/assets/copy-twig-files.js +96 -25
  9. package/config/vite/plugins/assets/css-asset-rebase.js +306 -0
  10. package/config/vite/plugins/assets/css-asset-relativizer.js +301 -21
  11. package/config/vite/plugins/assets/development-source-maps.js +273 -0
  12. package/config/vite/plugins/assets/mirror-components.js +98 -82
  13. package/config/vite/plugins/assets/output-freshness.js +235 -0
  14. package/config/vite/plugins/assets/source-file-index.js +7 -1
  15. package/config/vite/plugins/assets/stable-watch-output.js +165 -0
  16. package/config/vite/plugins/assets/storybook-output.js +27 -0
  17. package/config/vite/plugins/index.js +95 -9
  18. package/config/vite/plugins/reporter/asset-resolver.js +34 -6
  19. package/config/vite/plugins/reporter/build-errors.js +7 -3
  20. package/config/vite/plugins/reporter/diagnostics.js +140 -10
  21. package/config/vite/plugins/reporter/index.js +380 -75
  22. package/config/vite/plugins/reporter/render.js +297 -44
  23. package/config/vite/plugins/reporter/sass-logger.js +30 -0
  24. package/config/vite/plugins/reporter/source-roots.js +101 -21
  25. package/config/vite/plugins/reporter/strict-mode.js +99 -0
  26. package/config/vite/plugins/reporter/vite-logger.js +220 -8
  27. package/config/vite/plugins/reporter/watch-mode.js +6 -2
  28. package/config/vite/plugins/twig/virtual-twig-asset-sources.js +48 -49
  29. package/config/vite/project-config.js +121 -21
  30. package/config/vite/project-structure.js +6 -0
  31. package/config/vite/utils/asset-roots.js +205 -0
  32. package/config/vite/utils/css-urls.js +350 -0
  33. package/config/vite/utils/fs-safe.js +38 -1
  34. package/config/vite/utils/source-maps.js +88 -0
  35. package/config/vite/vite.config.js +106 -42
  36. package/package.json +40 -29
  37. package/scripts/audit/checks/css-asset-references.js +256 -24
  38. package/scripts/audit/fix.js +836 -0
  39. package/scripts/audit/index.js +10 -2
  40. package/scripts/audit/lib/css.js +41 -35
  41. package/scripts/audit/lib/twig.js +11 -29
  42. package/scripts/audit/report.js +83 -5
  43. package/scripts/audit.js +87 -2
  44. package/src/storybook/twig/source-function.js +14 -10
@@ -8,6 +8,11 @@
8
8
  import { readFileSync, readdirSync } from 'fs';
9
9
  import { relative, resolve } from 'path';
10
10
  import { fileURLToPath } from 'url';
11
+ import {
12
+ resolveAssetRoots,
13
+ toAbsoluteAssetRoot,
14
+ } from '../../utils/asset-roots.js';
15
+ import { isStorybookOutput } from '../assets/storybook-output.js';
11
16
  import { safeExists } from '../../utils/fs-safe.js';
12
17
  import { toPosixPath } from '../../utils/paths.js';
13
18
  import { unique } from '../../../../src/extensions/shared/lists.js';
@@ -27,43 +32,15 @@ const ASSET_SOURCE_RUNTIME_URL = new URL(
27
32
  const ASSET_SOURCE_RUNTIME_PATH = fileURLToPath(ASSET_SOURCE_RUNTIME_URL);
28
33
  const GENERATED_ASSET_ALIASES = new Set(['icons.svg']);
29
34
  const GENERATED_ASSET_ROOTS = ['/dist/assets'];
35
+ // Keys stay Vite root-relative because the runtime looks asset sources up by
36
+ // root-relative key. The values are the public URLs Storybook fetches, and they
37
+ // stay relative to the preview document so a static build works at a domain
38
+ // root and under any deployment subpath.
30
39
  const PUBLIC_ASSET_ROOTS = new Map([
31
- ['/assets', '/assets'],
32
- ['/dist/assets', '/assets'],
40
+ ['/assets', './assets'],
41
+ ['/dist/assets', './assets'],
33
42
  ]);
34
43
 
35
- /**
36
- * Resolve a configured asset root to an absolute filesystem path.
37
- *
38
- * @param {string} projectDir - Absolute project root.
39
- * @param {string} assetRoot - Absolute, project-relative, or Vite root-relative asset root.
40
- * @returns {string} Absolute filesystem path.
41
- */
42
- function toAbsoluteAssetRoot(projectDir, assetRoot) {
43
- const normalizedProjectDir = toPosixPath(projectDir || '').replace(
44
- /\/+$/,
45
- '',
46
- );
47
- const normalizedRoot = toPosixPath(assetRoot || '').replace(/\/+$/, '');
48
-
49
- if (!normalizedRoot) return '';
50
- if (
51
- normalizedProjectDir &&
52
- (normalizedRoot === normalizedProjectDir ||
53
- normalizedRoot.startsWith(`${normalizedProjectDir}/`))
54
- ) {
55
- return normalizedRoot;
56
- }
57
- if (normalizedRoot.startsWith('/') && normalizedProjectDir) {
58
- if (safeExists(normalizedRoot)) {
59
- return normalizedRoot;
60
- }
61
- return `${normalizedProjectDir}${normalizedRoot}`;
62
- }
63
-
64
- return toPosixPath(resolve(projectDir || '.', normalizedRoot));
65
- }
66
-
67
44
  /**
68
45
  * Resolve existing project asset roots for Storybook source() text imports.
69
46
  *
@@ -71,17 +48,8 @@ function toAbsoluteAssetRoot(projectDir, assetRoot) {
71
48
  * @returns {string[]} Existing Vite root-relative asset root paths.
72
49
  */
73
50
  export function assetSourceRoots(env) {
74
- const configuredRoots =
75
- Array.isArray(env?.projectStructure?.assetRoots) &&
76
- env.projectStructure.assetRoots.length
77
- ? env.projectStructure.assetRoots
78
- : [];
79
- const fallbackRoots = ['/assets', '/src/assets'];
80
-
81
51
  return unique(
82
- [...configuredRoots, ...fallbackRoots]
83
- .map((root) => toAbsoluteAssetRoot(env?.projectDir, root))
84
- .filter((root) => root && safeExists(root))
52
+ resolveAssetRoots(env)
85
53
  .map((root) => toRootRelativePath(env?.projectDir, root))
86
54
  .filter(Boolean),
87
55
  );
@@ -116,20 +84,33 @@ function generatedAssetRootPrefixes() {
116
84
  /**
117
85
  * Build Vite glob patterns from text asset roots.
118
86
  *
87
+ * Project roots stay recursive. Generated roots contain build output, so only
88
+ * the aliases Twig can resolve from those roots belong in the source map.
89
+ *
119
90
  * @param {{ projectDir?: string, projectStructure?: { assetRoots?: string[] } }} env - Emulsify environment.
120
91
  * @returns {string[]} Root-relative text asset glob patterns.
121
92
  */
122
93
  export function assetSourceGlobPatterns(env) {
123
94
  const extensions = Array.from(INLINE_ASSET_EXTS).join(',');
124
-
125
- return [...assetSourceRoots(env), ...generatedAssetSourceRoots(env)].map(
95
+ const projectPatterns = assetSourceRoots(env).map(
126
96
  (root) => `${root === '/' ? '' : root}/**/*.{${extensions}}`,
127
97
  );
98
+ const generatedPatterns = generatedAssetSourceRoots(env).flatMap((root) =>
99
+ Array.from(
100
+ GENERATED_ASSET_ALIASES,
101
+ (alias) => `${root === '/' ? '' : root}/${alias}`,
102
+ ),
103
+ );
104
+
105
+ return unique([...projectPatterns, ...generatedPatterns]);
128
106
  }
129
107
 
130
108
  /**
131
109
  * Return a public URL base for asset roots served by Storybook staticDirs.
132
110
  *
111
+ * The base is relative to Storybook's preview document rather than the domain
112
+ * root, so generated fetch URLs resolve under any deployment subpath.
113
+ *
133
114
  * @param {string} root - Vite root-relative asset source root.
134
115
  * @returns {string} Public URL base, or an empty string for non-public roots.
135
116
  */
@@ -226,9 +207,13 @@ export function publicAssetSourceEntries(env) {
226
207
  * Generate the virtual module source for lazy text asset maps.
227
208
  *
228
209
  * @param {{ projectDir?: string, projectStructure?: { assetRoots?: string[] } }} env - Emulsify environment.
210
+ * @param {{ inlineTextAssets?: boolean }} [options={}] - Generation options.
229
211
  * @returns {string} JavaScript module source.
230
212
  */
231
- export function generateVirtualTwigAssetSourcesModule(env) {
213
+ export function generateVirtualTwigAssetSourcesModule(
214
+ env,
215
+ { inlineTextAssets = true } = {},
216
+ ) {
232
217
  const rootPrefixes = assetSourceRoots(env).map((root) =>
233
218
  `${root === '/' ? '' : root}/`.replace(/\/{2,}/g, '/'),
234
219
  );
@@ -239,7 +224,12 @@ export function generateVirtualTwigAssetSourcesModule(env) {
239
224
  ...generatedRootPrefixes,
240
225
  ...generatedAssetRootPrefixes(),
241
226
  ]);
242
- const patterns = assetSourceGlobPatterns(env);
227
+ // Each glob entry becomes a lazy chunk carrying one asset's bytes as a
228
+ // JavaScript string. Storybook needs them so source('@assets/...') can inline
229
+ // an SVG synchronously; a theme build does not, and emitting them there just
230
+ // copies the asset tree into dist/ in another form. The fetch entries below
231
+ // keep source() working at runtime without bundling anything.
232
+ const patterns = inlineTextAssets ? assetSourceGlobPatterns(env) : [];
243
233
  const globEntries = patterns.length
244
234
  ? patterns
245
235
  .map(
@@ -304,8 +294,15 @@ export const getAssetText = assetSourceRuntime.getAssetText;
304
294
  * @returns {import('vite').PluginOption} Virtual module plugin.
305
295
  */
306
296
  export function virtualTwigAssetSourcesPlugin(env) {
297
+ let inlineTextAssets = true;
298
+
307
299
  return {
308
300
  name: 'emulsify-virtual-twig-asset-sources',
301
+
302
+ configResolved(config) {
303
+ inlineTextAssets = isStorybookOutput(config);
304
+ },
305
+
309
306
  resolveId(id) {
310
307
  if (id === VIRTUAL_TWIG_ASSET_SOURCES_ID) {
311
308
  return RESOLVED_VIRTUAL_TWIG_ASSET_SOURCES_ID;
@@ -318,7 +315,9 @@ export function virtualTwigAssetSourcesPlugin(env) {
318
315
  },
319
316
  load(id) {
320
317
  if (id === RESOLVED_VIRTUAL_TWIG_ASSET_SOURCES_ID) {
321
- return generateVirtualTwigAssetSourcesModule(env);
318
+ return generateVirtualTwigAssetSourcesModule(env, {
319
+ inlineTextAssets,
320
+ });
322
321
  }
323
322
  if (id === RESOLVED_VIRTUAL_TWIG_ASSET_SOURCE_RUNTIME_ID) {
324
323
  this.addWatchFile(ASSET_SOURCE_RUNTIME_PATH);
@@ -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
+ }