@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
@@ -142,7 +142,7 @@ export function runAuditChecks(context) {
142
142
  * Run the combined Emulsify audit.
143
143
  *
144
144
  * @param {{projectDir?: string, twigThreshold?: number}} [options={}] - Options.
145
- * @returns {{projectDir: string, summary: object, files: object, findings: object[]}} Audit result.
145
+ * @returns {{projectDir: string, sourceRoots: string[], summary: object, files: object, findings: object[]}} Audit result.
146
146
  */
147
147
  export function runAudits(options = {}) {
148
148
  resetFileReadCache();
@@ -161,7 +161,7 @@ export function runAudits(options = {}) {
161
161
  },
162
162
  );
163
163
 
164
- return {
164
+ const result = {
165
165
  projectDir: context.projectDir,
166
166
  summary,
167
167
  files: {
@@ -172,6 +172,14 @@ export function runAudits(options = {}) {
172
172
  },
173
173
  findings,
174
174
  };
175
+
176
+ // The fix scope is CLI-internal plumbing. Keep raw audit serialization
177
+ // stable while still sharing the normalized roots with the fix phase.
178
+ Object.defineProperty(result, 'sourceRoots', {
179
+ value: context.sourceRoots,
180
+ });
181
+
182
+ return result;
175
183
  }
176
184
 
177
185
  export { runAudits as auditProject };
@@ -3,6 +3,11 @@
3
3
  */
4
4
 
5
5
  import { basename, dirname, resolve } from 'node:path';
6
+ import {
7
+ assetTailFor,
8
+ isAssetAliasPath,
9
+ } from '../../../config/vite/plugins/assets/asset-url-rebase.js';
10
+ import { tokenizeStylesheetUrls } from '../../../config/vite/utils/css-urls.js';
6
11
  import {
7
12
  compiledAssetOutputPath,
8
13
  storybookStyleOutputPath,
@@ -43,40 +48,32 @@ function resolveSassUrlValue(value, variables) {
43
48
  );
44
49
  }
45
50
 
46
- /**
47
- * Mask style comments while preserving line and character positions.
48
- *
49
- * @param {string} source - Stylesheet source.
50
- * @returns {string} Source with comments replaced by whitespace.
51
- */
52
- function maskStyleComments(source) {
53
- const blank = (match) => match.replace(/[^\n]/g, ' ');
54
-
55
- return source
56
- .replace(/\/\*[\s\S]*?\*\//g, blank)
57
- .replace(/^[\t ]*\/\/.*$/gm, blank);
58
- }
59
-
60
51
  /**
61
52
  * Extract URL references from CSS or Sass source.
62
53
  *
54
+ * `start` and `end` bracket the specifier *without* its quotes, so an autofix
55
+ * can splice a replacement in without disturbing quote style. The shared
56
+ * tokenizer preserves original positions, so `source.slice(start, end) === raw`.
57
+ *
63
58
  * @param {string} source - Stylesheet source.
64
- * @returns {{value: string, raw: string, line: number}[]} URL references.
59
+ * @returns {{value: string, raw: string, quote: string, line: number, start: number, end: number}[]} URL references.
65
60
  */
66
61
  export function findCssUrlReferences(source) {
67
- const scanSource = maskStyleComments(source);
68
- const variables = findSassStringVariables(scanSource);
62
+ const { urls, sourceWithoutComments } = tokenizeStylesheetUrls(source);
63
+ const variables = findSassStringVariables(sourceWithoutComments);
69
64
  const references = [];
70
- const pattern = /url\(\s*(?:(['"])(.*?)\1|([^'")][^)]*?))\s*\)/g;
71
65
 
72
- for (const match of scanSource.matchAll(pattern)) {
73
- const raw = (match[2] ?? match[3] ?? '').trim();
66
+ for (const token of urls) {
67
+ const raw = token.value;
74
68
  const value = resolveSassUrlValue(raw, variables).trim();
75
69
 
76
70
  references.push({
77
71
  value,
78
72
  raw,
79
- line: lineNumberAt(source, match.index || 0),
73
+ quote: token.quote,
74
+ line: lineNumberAt(source, token.start),
75
+ start: token.valueStart,
76
+ end: token.valueEnd,
80
77
  });
81
78
  }
82
79
 
@@ -84,23 +81,32 @@ export function findCssUrlReferences(source) {
84
81
  }
85
82
 
86
83
  /**
87
- * Determine whether a CSS URL should be skipped by filesystem checks.
84
+ * Classify how a filesystem-ish CSS URL should be resolved.
85
+ *
86
+ * - `asset-root` — `/assets/...`, `@assets/...`, or legacy `assets/...`.
87
+ * Resolved against the project asset roots, which is what Storybook serves
88
+ * and what the build rebases to.
89
+ * - `runtime` — some other absolute URL (`/sites/default/files/...`). The
90
+ * platform serves it; the audit has nothing to check.
91
+ * - `relative` — resolved from the stylesheet's own directory.
88
92
  *
89
93
  * @param {string} value - URL value.
90
- * @returns {boolean} TRUE when the URL is not a local relative asset path.
94
+ * @returns {'asset-root'|'runtime'|'relative'} Resolution strategy.
91
95
  */
92
- export function isNonFilesystemCssUrl(value) {
93
- return (
94
- !value ||
95
- value.startsWith('#') ||
96
- value.startsWith('/') ||
97
- value.startsWith('//') ||
98
- value.startsWith('$') ||
99
- value.startsWith('#{') ||
100
- /^[a-z][a-z0-9+.-]*:/i.test(value) ||
101
- /^var\(/i.test(value) ||
102
- /^env\(/i.test(value)
103
- );
96
+ export function classifyCssAssetUrl(value) {
97
+ if (assetTailFor(cssUrlPath(value))) return 'asset-root';
98
+
99
+ return value.startsWith('/') ? 'runtime' : 'relative';
100
+ }
101
+
102
+ /**
103
+ * Determine whether a CSS URL uses the exact namespaced asset alias.
104
+ *
105
+ * @param {string} value - URL path without query or hash.
106
+ * @returns {boolean} TRUE for `@assets/...` paths.
107
+ */
108
+ export function isCssAssetAlias(value) {
109
+ return isAssetAliasPath(String(value));
104
110
  }
105
111
 
106
112
  /**
@@ -2,7 +2,11 @@
2
2
  * @file Twig reference parsing and resolution helpers for the project audit.
3
3
  */
4
4
 
5
- import { dirname, isAbsolute, resolve } from 'node:path';
5
+ import { dirname, resolve } from 'node:path';
6
+ import {
7
+ resolveAssetRoots,
8
+ toAbsoluteAssetRoot,
9
+ } from '../../../config/vite/utils/asset-roots.js';
6
10
  import { safeExists } from '../../../config/vite/utils/fs-safe.js';
7
11
  import { candidateKeysForReference } from '../../../src/storybook/twig/reference-paths.js';
8
12
  import { lineNumberAt } from '../../lib/text.js';
@@ -143,45 +147,23 @@ function candidateKeysToFiles(keys, env) {
143
147
  * @returns {string} Absolute filesystem path, or an empty string.
144
148
  */
145
149
  export function resolveAuditAssetRoot(projectDir, assetRoot) {
146
- if (typeof assetRoot !== 'string' || !assetRoot.trim()) return '';
147
-
148
- const normalizedProjectDir = resolve(projectDir || process.cwd());
149
- const normalizedRoot = assetRoot.trim();
150
-
151
- if (isAbsolute(normalizedRoot)) {
152
- const absoluteRoot = resolve(normalizedRoot);
153
-
154
- return safeExists(absoluteRoot)
155
- ? absoluteRoot
156
- : resolve(normalizedProjectDir, `.${normalizedRoot}`);
157
- }
158
-
159
- return resolve(normalizedProjectDir, normalizedRoot);
150
+ return toAbsoluteAssetRoot(projectDir, assetRoot);
160
151
  }
161
152
 
162
153
  /**
163
154
  * Return filesystem roots that Storybook can use for @assets source() calls.
164
155
  *
156
+ * Existence filtering stays off here because callers do their own directory
157
+ * check, and a configured-but-missing root is worth reporting rather than
158
+ * silently dropping.
159
+ *
165
160
  * @param {object} env - Normalized environment.
166
161
  * @param {object} [options={}] - Asset root options.
167
162
  * @param {boolean} [options.includeGenerated=false] - Include generated roots.
168
163
  * @returns {string[]} Absolute asset roots.
169
164
  */
170
165
  export function auditAssetRoots(env = {}, { includeGenerated = false } = {}) {
171
- const projectDir = env.projectDir || process.cwd();
172
- const configuredRoots = Array.isArray(env?.projectStructure?.assetRoots)
173
- ? env.projectStructure.assetRoots
174
- : [];
175
- const fallbackRoots = ['assets', 'src/assets'];
176
- const generatedRoots = includeGenerated ? ['dist/assets'] : [];
177
-
178
- return Array.from(
179
- new Set(
180
- [...fallbackRoots, ...configuredRoots, ...generatedRoots]
181
- .map((root) => resolveAuditAssetRoot(projectDir, root))
182
- .filter(Boolean),
183
- ),
184
- );
166
+ return resolveAssetRoots(env, { includeGenerated, existingOnly: false });
185
167
  }
186
168
 
187
169
  /**
@@ -58,16 +58,51 @@ export function formatAuditReport(result) {
58
58
 
59
59
  if (!result.findings.length) {
60
60
  lines.push('No audit findings found.');
61
- return lines.join('\n');
62
61
  }
63
62
 
64
63
  for (const finding of result.findings) {
65
64
  lines.push('', ...formatFinding(finding, result.projectDir));
66
65
  }
67
66
 
67
+ lines.push(...formatFixSection(result.fixes, result.projectDir));
68
+
68
69
  return lines.join('\n');
69
70
  }
70
71
 
72
+ /**
73
+ * Format the autofix section appended by `--fix`.
74
+ *
75
+ * @param {{dryRun: boolean, applied: object[], skipped: object[]}} [fixes] - Fix result.
76
+ * @param {string} projectDir - Absolute scanned root.
77
+ * @returns {string[]} Report lines.
78
+ */
79
+ function formatFixSection(fixes, projectDir) {
80
+ if (!fixes) return [];
81
+
82
+ const lines = ['', 'Fixes'];
83
+ const verb = fixes.dryRun ? 'Would apply' : 'Applied';
84
+
85
+ if (!fixes.applied.length) {
86
+ lines.push(`${verb} 0 fix(es).`);
87
+ } else {
88
+ lines.push(`${verb} ${fixes.applied.length} fix(es):`);
89
+ for (const { finding, from, to } of fixes.applied) {
90
+ const where = `${displayPath(projectDir, finding.filePath)}:${finding.line}`;
91
+ lines.push(` ${where} ${from} -> ${to}`);
92
+ }
93
+ }
94
+
95
+ if (fixes.skipped.length) {
96
+ lines.push(`Skipped ${fixes.skipped.length} fixable finding(s):`);
97
+ for (const { finding, reason } of fixes.skipped) {
98
+ const where = `${displayPath(projectDir, finding.filePath)}:${finding.line}`;
99
+ lines.push(` ${where} ${reason}`);
100
+ }
101
+ }
102
+
103
+ return lines;
104
+ }
105
+
71
106
  /**
72
107
  * Count findings by severity for machine-readable reports.
73
108
  *
@@ -219,7 +254,7 @@ export function createAuditJsonReport(result, options = {}) {
219
254
  normalizeAuditFinding(finding, result.projectDir, options.defaultSeverity),
220
255
  );
221
256
 
222
- return {
257
+ const document = {
223
258
  schemaVersion: AUDIT_REPORT_SCHEMA_VERSION,
224
259
  tool: createToolIdentity(),
225
260
  root: '.',
@@ -227,19 +262,56 @@ export function createAuditJsonReport(result, options = {}) {
227
262
  files: normalizeFileCounts(result.files),
228
263
  findings,
229
264
  };
265
+
266
+ // Present only when --fix ran, so the document shape is unchanged for every
267
+ // existing consumer.
268
+ if (result.fixes) {
269
+ document.fixes = normalizeFixes(result.fixes, result.projectDir);
270
+ }
271
+
272
+ return document;
273
+ }
274
+
275
+ /**
276
+ * Normalize the autofix result for the machine-readable report.
277
+ *
278
+ * @param {{dryRun: boolean, applied: object[], skipped: object[]}} fixes - Fix result.
279
+ * @param {string} projectDir - Absolute scanned root.
280
+ * @returns {object} JSON fix block.
281
+ */
282
+ function normalizeFixes(fixes, projectDir) {
283
+ const locate = (finding) => ({
284
+ path: displayPath(projectDir, finding.filePath) || '.',
285
+ ...(Number.isInteger(finding.line) && finding.line > 0
286
+ ? { line: finding.line }
287
+ : {}),
288
+ });
289
+
290
+ return {
291
+ dryRun: Boolean(fixes.dryRun),
292
+ applied: fixes.applied.map(({ finding, from, to }) => ({
293
+ ...locate(finding),
294
+ from,
295
+ to,
296
+ })),
297
+ skipped: fixes.skipped.map(({ finding, reason }) => ({
298
+ ...locate(finding),
299
+ reason: normalizeReportText(reason, projectDir),
300
+ })),
301
+ };
230
302
  }
231
303
 
232
304
  /**
233
305
  * Create a structured machine-readable CLI or audit failure.
234
306
  *
235
307
  * @param {*} error - Failure value.
236
- * @param {{code?: string, projectDir?: string}} [options={}] - Error options.
308
+ * @param {{code?: string, projectDir?: string, fixes?: object}} [options={}] - Error options.
237
309
  * @returns {object} JSON error document.
238
310
  */
239
311
  export function createAuditJsonErrorReport(error, options = {}) {
240
312
  const message = error?.message || error;
241
313
 
242
- return {
314
+ const document = {
243
315
  schemaVersion: AUDIT_REPORT_SCHEMA_VERSION,
244
316
  tool: createToolIdentity(),
245
317
  error: {
@@ -247,6 +319,12 @@ export function createAuditJsonErrorReport(error, options = {}) {
247
319
  message: normalizeReportText(message, options.projectDir || ''),
248
320
  },
249
321
  };
322
+
323
+ if (options.fixes) {
324
+ document.fixes = normalizeFixes(options.fixes, options.projectDir || '');
325
+ }
326
+
327
+ return document;
250
328
  }
251
329
 
252
330
  /**
@@ -265,7 +343,7 @@ export function formatAuditJsonReport(result, options = {}) {
265
343
  * Format a CLI or audit failure as machine-readable JSON.
266
344
  *
267
345
  * @param {*} error - Failure value.
268
- * @param {{code?: string, projectDir?: string}} [options={}] - Error options.
346
+ * @param {{code?: string, projectDir?: string, fixes?: object}} [options={}] - Error options.
269
347
  * @returns {string} JSON error document.
270
348
  */
271
349
  export function formatAuditJsonErrorReport(error, options = {}) {
package/scripts/audit.js CHANGED
@@ -11,11 +11,14 @@ import {
11
11
  parseArgs as parseCliArgs,
12
12
  } from './lib/cli.js';
13
13
  import { DEFAULT_TWIG_THRESHOLD, runAudits } from './audit/index.js';
14
+ import { applyAuditFixes, remainingFindings } from './audit/fix.js';
14
15
  import {
15
16
  formatAuditJsonErrorReport,
16
17
  formatAuditJsonReport,
17
18
  formatAuditReport,
19
+ summarizeFindings,
18
20
  } from './audit/report.js';
21
+ import { displayPath } from './audit/lib/findings.js';
19
22
 
20
23
  export { auditProject, runAudits } from './audit/index.js';
21
24
  export {
@@ -27,6 +30,7 @@ export {
27
30
  formatAuditReport,
28
31
  } from './audit/report.js';
29
32
  export { collectProjectFiles } from './audit/lib/files.js';
33
+ export { applyAuditFixes, remainingFindings } from './audit/fix.js';
30
34
  export { findCssUrlReferences } from './audit/lib/css.js';
31
35
  export {
32
36
  findTwigIncludeSourceReferences,
@@ -45,10 +49,12 @@ const cliFailureExitCode = 2;
45
49
  */
46
50
  function usage() {
47
51
  return createUsage(
48
- 'Usage: emulsify-audit [--root <dir>] [--json] [--fail-on <severity>] [--fail-on-found] [--twig-threshold <count>]',
52
+ 'Usage: emulsify-audit [--root <dir>] [--json] [--fix] [--dry-run] [--fail-on <severity>] [--fail-on-found] [--twig-threshold <count>]',
49
53
  [
50
54
  ' --root <dir> Project root to scan. Defaults to the current directory.',
51
55
  ' --json Print machine-readable JSON.',
56
+ ' --fix Rewrite unambiguous CSS asset URLs to the canonical /assets/... form.',
57
+ ' --dry-run With --fix, report the rewrites without touching files.',
52
58
  ' --fail-on <severity> Exit with code 1 for error, warn, info, or any findings at that threshold.',
53
59
  ' --fail-on-found Compatibility alias for --fail-on any.',
54
60
  ` --twig-threshold <count> Warn when Storybook roots contain more than this many Twig files. Default: ${DEFAULT_TWIG_THRESHOLD}.`,
@@ -70,6 +76,8 @@ function parseArgs(argv) {
70
76
  failOn: null,
71
77
  json: false,
72
78
  help: false,
79
+ fix: false,
80
+ dryRun: false,
73
81
  twigThreshold: DEFAULT_TWIG_THRESHOLD,
74
82
  },
75
83
  flags: {
@@ -78,6 +86,8 @@ function parseArgs(argv) {
78
86
  value: 'any',
79
87
  },
80
88
  '--json': 'json',
89
+ '--fix': 'fix',
90
+ '--dry-run': 'dryRun',
81
91
  },
82
92
  options: {
83
93
  '--fail-on': {
@@ -167,6 +177,9 @@ export function runCli(argv = process.argv.slice(2)) {
167
177
  if (options.help && options.json) {
168
178
  throw new Error('--json cannot be combined with --help.');
169
179
  }
180
+ if (options.dryRun && !options.fix) {
181
+ throw new Error('--dry-run requires --fix.');
182
+ }
170
183
  } catch (error) {
171
184
  return reportArgumentFailure(error, jsonRequested);
172
185
  }
@@ -178,6 +191,28 @@ export function runCli(argv = process.argv.slice(2)) {
178
191
 
179
192
  try {
180
193
  const result = runAudits(options);
194
+ let findings = result.findings;
195
+
196
+ if (options.fix) {
197
+ try {
198
+ result.fixes = applyAuditFixes(findings, {
199
+ dryRun: options.dryRun,
200
+ projectDir: result.projectDir,
201
+ sourceRoots: result.sourceRoots,
202
+ });
203
+ } catch (error) {
204
+ return reportFixFailure(error, options);
205
+ }
206
+
207
+ // A dry run changes nothing on disk, so nothing is subtracted. A real
208
+ // run removed the findings it fixed from the source, so the threshold
209
+ // must be judged on what is left.
210
+ if (!options.dryRun) {
211
+ result.findings = remainingFindings(findings, result.fixes.applied);
212
+ result.summary = summarizeFindings(result.findings);
213
+ findings = result.findings;
214
+ }
215
+ }
181
216
 
182
217
  if (options.json) {
183
218
  console.log(formatAuditJsonReport(result));
@@ -185,7 +220,7 @@ export function runCli(argv = process.argv.slice(2)) {
185
220
  console.log(formatAuditReport(result));
186
221
  }
187
222
 
188
- return shouldFailAudit(result.findings, options.failOn) ? 1 : 0;
223
+ return shouldFailAudit(findings, options.failOn) ? 1 : 0;
189
224
  } catch (error) {
190
225
  if (options.json) {
191
226
  console.log(
@@ -202,6 +237,56 @@ export function runCli(argv = process.argv.slice(2)) {
202
237
  }
203
238
  }
204
239
 
240
+ /**
241
+ * Print a failure that happened while writing fixes.
242
+ *
243
+ * @param {*} error - Write failure.
244
+ * @param {object} options - Parsed CLI options.
245
+ * @returns {number} Exit code.
246
+ */
247
+ function reportFixFailure(error, options) {
248
+ const projectDir = resolve(options.projectDir);
249
+
250
+ if (options.json) {
251
+ console.log(
252
+ formatAuditJsonErrorReport(error, {
253
+ code: 'fix-failed',
254
+ projectDir,
255
+ fixes: error?.fixes,
256
+ }),
257
+ );
258
+ } else {
259
+ const lines = [`Audit fix failed: ${error.message || error}`];
260
+ const applied = error?.fixes?.applied || [];
261
+ const rewrittenFiles = Array.from(
262
+ new Set(applied.map(({ finding }) => finding.filePath)),
263
+ ).sort();
264
+
265
+ if (rewrittenFiles.length) {
266
+ const verb = error?.fixes?.dryRun ? 'Would apply' : 'Applied';
267
+ lines.push(
268
+ `${verb} ${applied.length} fix(es) across ${rewrittenFiles.length} file(s) before the failure:`,
269
+ ...rewrittenFiles.map(
270
+ (filePath) => ` ${displayPath(projectDir, filePath)}`,
271
+ ),
272
+ );
273
+ }
274
+
275
+ const skipped = error?.fixes?.skipped || [];
276
+ if (skipped.length) {
277
+ lines.push(`Skipped ${skipped.length} fixable finding(s):`);
278
+ for (const { finding, reason } of skipped) {
279
+ const where = `${displayPath(projectDir, finding.filePath)}:${finding.line}`;
280
+ lines.push(` ${where} ${reason}`);
281
+ }
282
+ }
283
+
284
+ console.error(lines.join('\n'));
285
+ }
286
+
287
+ return cliFailureExitCode;
288
+ }
289
+
205
290
  if (isCliEntrypoint(['audit.js', 'emulsify-audit'])) {
206
291
  process.exitCode = runCli();
207
292
  }
@@ -22,14 +22,15 @@ function getRuntimeEnv() {
22
22
  return globalThis.__EMULSIFY_ENV__ || DEFAULT_ENV;
23
23
  }
24
24
 
25
- // GitHub Pages serves static assets from a repository-prefixed base path.
26
- const PUBLIC_ASSET_BASE =
27
- typeof window !== 'undefined' &&
28
- window.location &&
29
- window.location.hostname &&
30
- window.location.hostname.endsWith('github.io')
31
- ? `/${getRuntimeEnv().machineName || ''}/assets/`
32
- : '/assets/';
25
+ // Storybook copies staticDirs beside the preview document, so `iframe.html`
26
+ // and the public `assets/` directory are always siblings in a static build. A
27
+ // document-relative base is resolved against the preview document's own URL:
28
+ // `/iframe.html` at a domain root resolves to `/assets/...`, and
29
+ // `/project/iframe.html` under a deployment subpath resolves to
30
+ // `/project/assets/...`. That covers root deployments, project Pages URLs on
31
+ // any host, custom domains, and arbitrary nested paths without hostname
32
+ // detection or a configured base path.
33
+ const PUBLIC_ASSET_BASE = './assets/';
33
34
 
34
35
  const pendingSourceLoads = new Set();
35
36
  const warnedAssetSources = new Set();
@@ -54,7 +55,10 @@ function normalizeAssetPath(assetPath) {
54
55
  /**
55
56
  * Read a text asset from Storybook's static server.
56
57
  *
57
- * @param {string} relPath - Public asset path below `/assets`.
58
+ * The request URL stays relative to the preview document, so the fallback
59
+ * resolves the same way at a domain root and under a deployment subpath.
60
+ *
61
+ * @param {string} relPath - Public asset path below the public asset base.
58
62
  * @returns {string|undefined} Fetched text when available.
59
63
  */
60
64
  function fetchTextAsset(relPath) {
@@ -77,7 +81,7 @@ function fetchTextAsset(relPath) {
77
81
  /**
78
82
  * Warn once when a text asset cannot use the lazy virtual source map.
79
83
  *
80
- * @param {string} relPath - Public asset path below `/assets`.
84
+ * @param {string} relPath - Public asset path below the public asset base.
81
85
  * @param {string} reason - Short explanation of the missing source.
82
86
  */
83
87
  function warnTextAssetSource(relPath, reason) {