@emulsify/core 4.3.2 → 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.
- package/.storybook/main-static-assets.js +5 -8
- package/.storybook/main-vite.js +11 -3
- package/README.md +4 -5
- 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 +7 -1
- 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/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-maps.js +88 -0
- package/config/vite/vite.config.js +106 -42
- package/package.json +40 -29
- package/scripts/audit/checks/css-asset-references.js +256 -24
- 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/twig.js +11 -29
- package/scripts/audit/report.js +83 -5
- package/scripts/audit.js +87 -2
- 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', '
|
|
32
|
-
['/dist/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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|