@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.
- package/.storybook/main-static-assets.js +5 -8
- package/.storybook/main-vite.js +11 -3
- package/README.md +14 -6
- package/config/a11y-wcag22.js +11 -0
- package/config/vite/entries.js +7 -2
- package/config/vite/environment.js +4 -0
- package/config/vite/plugins/assets/asset-url-rebase.js +241 -0
- package/config/vite/plugins/assets/copy-src-assets.js +82 -12
- package/config/vite/plugins/assets/copy-twig-files.js +85 -16
- package/config/vite/plugins/assets/css-asset-rebase.js +306 -0
- package/config/vite/plugins/assets/css-asset-relativizer.js +301 -21
- package/config/vite/plugins/assets/development-source-maps.js +273 -0
- package/config/vite/plugins/assets/mirror-components.js +98 -82
- package/config/vite/plugins/assets/output-freshness.js +235 -0
- package/config/vite/plugins/assets/source-file-index.js +13 -13
- package/config/vite/plugins/assets/stable-watch-output.js +165 -0
- package/config/vite/plugins/assets/storybook-output.js +27 -0
- package/config/vite/plugins/index.js +95 -9
- package/config/vite/plugins/reporter/asset-resolver.js +34 -6
- package/config/vite/plugins/reporter/build-errors.js +7 -3
- package/config/vite/plugins/reporter/diagnostics.js +140 -10
- package/config/vite/plugins/reporter/index.js +380 -75
- package/config/vite/plugins/reporter/render.js +297 -44
- package/config/vite/plugins/reporter/sass-logger.js +30 -0
- package/config/vite/plugins/reporter/source-roots.js +101 -21
- package/config/vite/plugins/reporter/strict-mode.js +99 -0
- package/config/vite/plugins/reporter/vite-logger.js +220 -8
- package/config/vite/plugins/reporter/watch-mode.js +6 -2
- package/config/vite/plugins/twig/twig-module.js +35 -258
- package/config/vite/plugins/twig/virtual-twig-asset-sources.js +48 -49
- package/config/vite/project-config.js +121 -21
- package/config/vite/project-structure.js +6 -0
- package/config/vite/utils/asset-roots.js +205 -0
- package/config/vite/utils/css-urls.js +350 -0
- package/config/vite/utils/fs-safe.js +38 -1
- package/config/vite/utils/source-directory-skips.js +13 -0
- package/config/vite/utils/source-maps.js +88 -0
- package/config/vite/utils/twig-component-resolver.js +316 -0
- package/config/vite/vite.config.js +106 -42
- package/package.json +54 -40
- package/scripts/a11y.js +88 -9
- package/scripts/audit/checks/css-asset-references.js +256 -24
- package/scripts/audit/checks/twig-references.js +16 -5
- package/scripts/audit/fix.js +836 -0
- package/scripts/audit/index.js +10 -2
- package/scripts/audit/lib/css.js +41 -35
- package/scripts/audit/lib/story-ast.js +392 -0
- package/scripts/audit/lib/story-render-paths.js +600 -0
- package/scripts/audit/lib/story-selection.js +190 -0
- package/scripts/audit/lib/twig.js +372 -80
- package/scripts/audit/report.js +83 -5
- package/scripts/audit-twig-stories.js +73 -3
- package/scripts/audit.js +87 -2
- 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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
name
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
.
|
|
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
|
+
}
|