snappy-prerender 2.6.0 → 2.8.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 (49) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +281 -101
  3. package/assets/snappy-logo.png +0 -0
  4. package/docs/limitations.md +94 -0
  5. package/docs/react-snap-migration.md +45 -0
  6. package/docs/report.md +135 -0
  7. package/package.json +4 -1
  8. package/src/cli-init.js +205 -0
  9. package/src/cli-options.js +258 -0
  10. package/src/cli.js +128 -309
  11. package/src/core/artifacts.js +2 -0
  12. package/src/core/audit/index.js +114 -0
  13. package/src/core/audit/links.js +74 -0
  14. package/src/core/audit/metadata.js +197 -0
  15. package/src/core/audit/sitemap.js +39 -0
  16. package/src/core/clock.js +60 -0
  17. package/src/core/config-validation.js +557 -0
  18. package/src/core/config-warnings.js +150 -0
  19. package/src/core/config.js +189 -136
  20. package/src/core/crawl.js +362 -0
  21. package/src/core/csp.js +138 -0
  22. package/src/core/destination.js +23 -7
  23. package/src/core/frameworks.js +94 -0
  24. package/src/core/hydration-diff.js +93 -0
  25. package/src/core/hydration-policy.js +38 -0
  26. package/src/core/init.js +143 -30
  27. package/src/core/inline-css.js +52 -9
  28. package/src/core/locale-context.js +63 -0
  29. package/src/core/locales.js +385 -0
  30. package/src/core/normalize.js +37 -6
  31. package/src/core/page-content.js +57 -7
  32. package/src/core/page-meta.js +122 -0
  33. package/src/core/preload-links.js +133 -0
  34. package/src/core/preload-manifest.js +6 -2
  35. package/src/core/render.js +126 -35
  36. package/src/core/report.js +116 -0
  37. package/src/core/resources.js +5 -1
  38. package/src/core/route-config.js +17 -0
  39. package/src/core/route-sources.js +220 -0
  40. package/src/core/run.js +156 -131
  41. package/src/core/seo-locale.js +234 -0
  42. package/src/core/settle.js +2 -4
  43. package/src/core/sitemap.js +90 -14
  44. package/src/core/text-separators.js +11 -7
  45. package/src/core/verify.js +164 -71
  46. package/src/core/write.js +314 -52
  47. package/src/head/index.js +104 -6
  48. package/src/head/manager.js +304 -48
  49. package/src/head/tags.js +349 -30
@@ -0,0 +1,133 @@
1
+ import path from 'node:path';
2
+ import { parse, serialize } from 'parse5';
3
+ import { toPublicPath } from './routes.js';
4
+
5
+ /**
6
+ * Injects static preload hints into the final HTML. Static hosts cannot send the
7
+ * preload-manifest headers, so this is the page-level equivalent for runtime chunks,
8
+ * fonts and the LCP image.
9
+ */
10
+ export function injectPreloadLinks(html, options = {}) {
11
+ if (options.scripts?.length === 0 && options.fonts?.size === 0 && !options.lcpImage) {
12
+ return html;
13
+ }
14
+ const document = parse(html);
15
+ const added = injectPreloadLinksInto(document, options);
16
+ return added > 0 ? serialize(document) : html;
17
+ }
18
+
19
+ /**
20
+ * Injects preload hints into a parsed document and reports how many were added.
21
+ * Keeping this separate lets the write pipeline parse the page once for all final
22
+ * post-processing steps.
23
+ */
24
+ export function injectPreloadLinksInto(
25
+ document,
26
+ {
27
+ scripts = [],
28
+ fonts = new Map(),
29
+ lcpImage = null,
30
+ base = '/',
31
+ ignoreForPreload = [],
32
+ skipScripts = new Set(),
33
+ } = {},
34
+ ) {
35
+ const head = findHead(document);
36
+ if (!head) return 0;
37
+ const existingScripts = new Set();
38
+ const hints = new Set();
39
+
40
+ const walk = (node) => {
41
+ if (node.tagName === 'script') {
42
+ const src = attribute(node, 'src');
43
+ if (src) existingScripts.add(src);
44
+ }
45
+ if (node.tagName === 'link') {
46
+ const href = attribute(node, 'href');
47
+ if (href && attribute(node, 'rel').split(/\s+/).includes('preload')) hints.add(href);
48
+ }
49
+ for (const child of node.childNodes ?? []) walk(child);
50
+ };
51
+ walk(document);
52
+
53
+ const ignored = new Set(ignoreForPreload);
54
+ let added = 0;
55
+ const add = (element, href) => {
56
+ if (hints.has(href) || ignored.has(path.posix.basename(href))) return;
57
+ hints.add(href);
58
+ head.childNodes.push(element);
59
+ added += 1;
60
+ };
61
+
62
+ for (const script of [...scripts].sort()) {
63
+ const href = toPublicPath(script, base);
64
+ if (existingScripts.has(href) || skipScripts.has(href)) continue;
65
+ add(
66
+ linkNode([
67
+ { name: 'rel', value: 'modulepreload' },
68
+ { name: 'href', value: href },
69
+ ]),
70
+ href,
71
+ );
72
+ }
73
+ for (const [font, contentType] of [...fonts.entries()].sort(([left], [right]) =>
74
+ left.localeCompare(right),
75
+ )) {
76
+ const href = toPublicPath(font, base);
77
+ const attrs = [
78
+ { name: 'rel', value: 'preload' },
79
+ { name: 'as', value: 'font' },
80
+ { name: 'href', value: href },
81
+ { name: 'crossorigin', value: '' },
82
+ ];
83
+ if (contentType) attrs.push({ name: 'type', value: contentType });
84
+ add(linkNode(attrs), href);
85
+ }
86
+ if (lcpImage) {
87
+ const href = toPublicPath(lcpImage, base);
88
+ add(
89
+ linkNode([
90
+ { name: 'rel', value: 'preload' },
91
+ { name: 'as', value: 'image' },
92
+ { name: 'href', value: href },
93
+ { name: 'fetchpriority', value: 'high' },
94
+ ]),
95
+ href,
96
+ );
97
+ }
98
+ return added;
99
+ }
100
+
101
+ // Collects script src values from an already parsed document.
102
+ export function collectScriptSourcesFromDocument(document) {
103
+ const sources = new Set();
104
+ const walk = (node) => {
105
+ if (node.tagName === 'script') {
106
+ const src = attribute(node, 'src');
107
+ if (src) sources.add(src);
108
+ }
109
+ for (const child of node.childNodes ?? []) walk(child);
110
+ };
111
+ walk(document);
112
+ return sources;
113
+ }
114
+
115
+ function findHead(document) {
116
+ const html = (document.childNodes ?? []).find((node) => node.tagName === 'html');
117
+ return (html?.childNodes ?? []).find((node) => node.tagName === 'head') ?? null;
118
+ }
119
+
120
+ function linkNode(attrs) {
121
+ return {
122
+ nodeName: 'link',
123
+ tagName: 'link',
124
+ attrs,
125
+ namespaceURI: 'http://www.w3.org/1999/xhtml',
126
+ childNodes: [],
127
+ parentNode: null,
128
+ };
129
+ }
130
+
131
+ function attribute(node, name) {
132
+ return node.attrs?.find((entry) => entry.name === name)?.value ?? '';
133
+ }
@@ -12,8 +12,12 @@ const PAGE_ORIGIN = 'http://snappy.local';
12
12
  * output rather than the render-time resource list keeps the manifest correct after
13
13
  * inline CSS, tag surgery, or a failed write.
14
14
  */
15
- export function collectPageResources(html, { base = '/', route = '/' } = {}) {
16
- const document = parse(html);
15
+ export function collectPageResources(html, options = {}) {
16
+ return collectPageResourcesFromDocument(parse(html), options);
17
+ }
18
+
19
+ // Reads referenced resources from an already parsed document.
20
+ export function collectPageResourcesFromDocument(document, { base = '/', route = '/' } = {}) {
17
21
  const scripts = [];
18
22
  const styles = [];
19
23
  const pageUrl = new URL(toPublicPath(route, base), PAGE_ORIGIN);
@@ -1,24 +1,13 @@
1
+ import { installClock, installClockScripts } from './clock.js';
1
2
  import { captureFormState } from './form-state.js';
2
3
  import { inlineStylesheets } from './inline-css.js';
3
- import { isPageEmpty } from './page-content.js';
4
+ import { localeContextOptions, localeStorageState, seedLocaleContext } from './locale-context.js';
5
+ import { readPageState } from './page-content.js';
4
6
  import { createResourceCollector } from './resources.js';
5
- import { toAppRoute, toServerUrl } from './routes.js';
7
+ import { normaliseRoute, toAppRoute, toServerUrl } from './routes.js';
6
8
  import { captureRuntimeStyles } from './runtime-styles.js';
7
9
  import { freezeAnimations, scrollThroughPage, settlePage, trackNetwork } from './settle.js';
8
- import { separateTextNodes } from './text-separators.js';
9
-
10
- const SCRIPT_ESCAPES = {
11
- '<': '\\u003C',
12
- '>': '\\u003E',
13
- '/': '\\u002F',
14
- '\u2028': '\\u2028',
15
- '\u2029': '\\u2029',
16
- };
17
-
18
- // Serialises a value for embedding in a script element without allowing tag breakout.
19
- function escapeForScript(value) {
20
- return JSON.stringify(value).replace(/[<>/\u2028\u2029]/g, (char) => SCRIPT_ESCAPES[char]);
21
- }
10
+ import { readRenderSnapshot } from './text-separators.js';
22
11
 
23
12
  /**
24
13
  * Renders one route in a fresh browser context and returns the serialised DOM plus
@@ -26,16 +15,36 @@ function escapeForScript(value) {
26
15
  * storage from leaking between routes. When a screenshot path is supplied the route
27
16
  * is captured as an image instead of HTML.
28
17
  */
29
- export async function renderRoute({ browser, origin, route, config, screenshot = false }) {
18
+ export async function renderRoute({
19
+ browser,
20
+ origin,
21
+ route,
22
+ locale = null,
23
+ config,
24
+ screenshot = false,
25
+ traceFile = null,
26
+ caches = null,
27
+ }) {
30
28
  const context = await browser.newContext({
31
29
  viewport: config.viewport,
32
- storageState: config.storageState ?? undefined,
30
+ storageState: localeStorageState(config, locale),
33
31
  userAgent: config.userAgent ?? undefined,
34
32
  ignoreHTTPSErrors: config.ignoreHTTPSErrors,
35
33
  reducedMotion: config.freezeAnimations ? 'reduce' : 'no-preference',
36
34
  serviceWorkers: 'block',
35
+ ...localeContextOptions(config, locale),
37
36
  });
37
+ let tracing = false;
38
+ if (traceFile) {
39
+ await context.tracing.start({ screenshots: false, snapshots: true, sources: false });
40
+ tracing = true;
41
+ }
38
42
  const page = await context.newPage();
43
+ await installClock(page, config.fixedTime ?? null);
44
+ await installClockScripts(page, {
45
+ fixedTime: config.fixedTime ?? null,
46
+ randomSeed: config.randomSeed ?? null,
47
+ });
39
48
  const pageErrors = [];
40
49
  const consoleErrors = [];
41
50
  const failedRequests = [];
@@ -63,6 +72,15 @@ export async function renderRoute({ browser, origin, route, config, screenshot =
63
72
  const metadataDefaults = config.metadata
64
73
  ? { base: config.base, flatOutput: config.flatOutput, ...config.metadata }
65
74
  : null;
75
+ await seedLocaleContext({
76
+ page,
77
+ context,
78
+ origin,
79
+ config,
80
+ route,
81
+ locale,
82
+ phase: 'render',
83
+ });
66
84
  if (metadataDefaults) {
67
85
  await page.addInitScript((defaults) => {
68
86
  window.__SNAPPY_META__ = defaults;
@@ -78,25 +96,58 @@ export async function renderRoute({ browser, origin, route, config, screenshot =
78
96
  maxCachedBytes: config.maxCachedBytes,
79
97
  });
80
98
  await collector.install(page);
99
+ await page.addInitScript(() => {
100
+ window.__snappyLcp = [];
101
+ try {
102
+ const observer = new PerformanceObserver((list) => {
103
+ for (const entry of list.getEntries()) {
104
+ const element = entry.element;
105
+ window.__snappyLcp.push({
106
+ url: entry.url || element?.currentSrc || element?.src || null,
107
+ });
108
+ }
109
+ });
110
+ observer.observe({ type: 'largest-contentful-paint', buffered: true });
111
+ } catch {
112
+ // Older engines simply do not report LCP; preload injection falls back to no hint.
113
+ }
114
+ });
81
115
  const tracker = trackNetwork(page);
82
116
  try {
83
117
  const response = await page.goto(toServerUrl(route, config.base, origin), {
84
118
  waitUntil: 'domcontentloaded',
85
119
  timeout: config.timeout,
86
120
  });
121
+ const notFoundRoute = config.notFoundRoute ? normaliseRoute(config.notFoundRoute) : null;
122
+ const isNotFoundRoute = notFoundRoute !== null && normaliseRoute(route) === notFoundRoute;
87
123
  if (response && response.status() >= 400) {
88
- throw new Error(
89
- `route responded with HTTP ${response.status()}${response.statusText() ? ` ${response.statusText()}` : ''}`,
90
- );
124
+ // A configured 404 route may legitimately answer with HTTP 404; every other status
125
+ // is a failure, and the marker cannot be read before the navigation completes.
126
+ if (!(response.status() === 404 && isNotFoundRoute)) {
127
+ const error = new Error(
128
+ `route responded with HTTP ${response.status()}${response.statusText() ? ` ${response.statusText()}` : ''}`,
129
+ );
130
+ // A server error can be transient and is retried; a client error is the app's own.
131
+ error.retryable = response.status() >= 500;
132
+ throw error;
133
+ }
91
134
  }
92
135
  await settlePage(page, config, tracker);
93
- const empty = await isPageEmpty(page);
136
+ const { empty, statusCode } = await readPageState(page);
137
+ const finalUrl = page.url();
94
138
 
95
139
  if (config.captureRuntimeStyles) await captureRuntimeStyles(page);
96
140
  if (config.captureFormState) await captureFormState(page);
97
141
  if (config.inlineCss === true || config.inlineCss === 'inline') {
98
- const inlined = await inlineStylesheets({ page, minifyCssOptions: config.minifyCss });
142
+ const inlined = await inlineStylesheets({
143
+ page,
144
+ minifyCssOptions: config.minifyCss,
145
+ cache: caches?.inlineCss ?? null,
146
+ });
99
147
  inlineCssSkipped = inlined.skipped;
148
+ if (caches?.inlineCss) {
149
+ for (const sheet of inlined.fetched ?? []) caches.inlineCss.set(sheet.href, sheet.css);
150
+ }
100
151
  }
101
152
  await collector.settled();
102
153
  const stateScript = await injectCapturedState(page, collector.json, {
@@ -106,9 +157,17 @@ export async function renderRoute({ browser, origin, route, config, screenshot =
106
157
  if (metadataDefaults) await injectMetadataDefaults(page, metadataDefaults);
107
158
  if (config.freezeAnimations) await freezeAnimations(page);
108
159
  if (config.scrollToBottom) await scrollThroughPage(page, config);
109
- await separateTextNodes(page);
110
-
111
- const links = await collectLinks(page, config.base, origin);
160
+ const { lcpUrl, hrefs } = await readRenderSnapshot(page);
161
+ let lcpImage = null;
162
+ if (lcpUrl) {
163
+ try {
164
+ const parsed = new URL(lcpUrl);
165
+ if (parsed.origin === origin) lcpImage = `${parsed.pathname}${parsed.search}`;
166
+ } catch {
167
+ // Ignore malformed performance entries.
168
+ }
169
+ }
170
+ const links = collectLinks(hrefs, config.base, origin);
112
171
 
113
172
  if (screenshot) {
114
173
  const buffer = await page.screenshot({
@@ -126,6 +185,10 @@ export async function renderRoute({ browser, origin, route, config, screenshot =
126
185
  stateScript,
127
186
  screenshot: buffer,
128
187
  empty,
188
+ statusCode,
189
+ httpStatus: response?.status() ?? null,
190
+ finalUrl,
191
+ lcpImage,
129
192
  };
130
193
  }
131
194
 
@@ -141,13 +204,37 @@ export async function renderRoute({ browser, origin, route, config, screenshot =
141
204
  inlineCssSkipped,
142
205
  stateScript,
143
206
  empty,
207
+ statusCode,
208
+ httpStatus: response?.status() ?? null,
209
+ finalUrl,
210
+ lcpImage,
144
211
  };
212
+ } catch (error) {
213
+ error.retryable ??= isRetryableRenderError(error);
214
+ if (tracing) {
215
+ tracing = false;
216
+ await context.tracing.stop({ path: traceFile }).then(
217
+ () => {
218
+ error.tracePath = traceFile;
219
+ },
220
+ // A failed trace must never replace the render error that explains the build failure.
221
+ () => {},
222
+ );
223
+ }
224
+ throw error;
145
225
  } finally {
146
226
  tracker.stop();
227
+ if (tracing) await context.tracing.stop().catch(() => {});
147
228
  await context.close();
148
229
  }
149
230
  }
150
231
 
232
+ // Navigation and network failures are transient; a bad selector or an HTTP status is not.
233
+ function isRetryableRenderError(error) {
234
+ if (error?.name === 'TimeoutError') return true;
235
+ return /net::ERR_|timed? ?out|ECONNRESET|ECONNREFUSED|socket hang up/i.test(error?.message ?? '');
236
+ }
237
+
151
238
  /**
152
239
  * Injects captured JSON responses as window.snapStore and anything the app exposes
153
240
  * through window.snapSaveState, before the first script runs. The client can then
@@ -229,16 +316,21 @@ async function injectCapturedState(page, cache, { externalScripts = false, base
229
316
  * executable script, so a strict script-src applies to nothing here.
230
317
  */
231
318
  async function injectMetadataDefaults(page, defaults) {
232
- const payload = await page.evaluate((fallback) => {
319
+ await page.evaluate((fallback) => {
233
320
  document.querySelectorAll('script[data-snappy-meta]').forEach((script) => script.remove());
234
321
  const current = typeof window === 'undefined' ? null : window.__SNAPPY_META__;
235
322
  const recorded = typeof window === 'undefined' ? null : window.__snappyHeadOriginals;
236
323
  const merged = { ...(current ?? fallback) };
237
324
  if (recorded) merged.originals = recorded;
238
- return Object.keys(merged).length > 0 ? merged : fallback;
239
- }, defaults);
240
- const element = escapeForScript(payload);
241
- await page.evaluate((text) => {
325
+ const payload = Object.keys(merged).length > 0 ? merged : fallback;
326
+ const escapes = {
327
+ '<': '\\u003C',
328
+ '>': '\\u003E',
329
+ '/': '\\u002F',
330
+ '\u2028': '\\u2028',
331
+ '\u2029': '\\u2029',
332
+ };
333
+ const text = JSON.stringify(payload).replace(/[<>/\u2028\u2029]/g, (char) => escapes[char]);
242
334
  const script = document.createElement('script');
243
335
  script.setAttribute('type', 'application/json');
244
336
  script.setAttribute('data-snappy-meta', '');
@@ -246,7 +338,7 @@ async function injectMetadataDefaults(page, defaults) {
246
338
  const first = document.scripts[0];
247
339
  if (first?.parentNode) first.parentNode.insertBefore(script, first);
248
340
  else document.head.appendChild(script);
249
- }, element);
341
+ }, defaults);
250
342
  }
251
343
 
252
344
  /**
@@ -254,8 +346,7 @@ async function injectMetadataDefaults(page, defaults) {
254
346
  * app-relative routes for the crawler. Off-origin links are ignored so external
255
347
  * URLs can never be mistaken for routes and written into the output directory.
256
348
  */
257
- async function collectLinks(page, base, origin) {
258
- const hrefs = await page.$$eval('a[href]', (anchors) => anchors.map((anchor) => anchor.href));
349
+ function collectLinks(hrefs, base, origin) {
259
350
  const routes = [];
260
351
  for (const href of hrefs) {
261
352
  let parsed;
@@ -268,7 +359,7 @@ async function collectLinks(page, base, origin) {
268
359
  const route = toAppRoute(href, base);
269
360
  if (route) routes.push(route);
270
361
  }
271
- return routes;
362
+ return [...new Set(routes)];
272
363
  }
273
364
 
274
365
  // Parses an HTTP URL, returning null for data, blob and other non-network URLs.
@@ -0,0 +1,116 @@
1
+ import path from 'node:path';
2
+ import { writeFileIfChanged } from './output.js';
3
+
4
+ /**
5
+ * Writes the machine-readable run report. The JSON is deliberately limited to route,
6
+ * audit and artifact data; configuration and captured state are never serialised.
7
+ */
8
+ export async function writeReport({ report, config, log }) {
9
+ if (!config.reportPath) return null;
10
+ const contents = `${JSON.stringify(buildReport(report, config), null, 2)}\n`;
11
+ if (config.dryRun) {
12
+ log.info(`Dry run: would write report to ${config.reportPath}`);
13
+ return { file: config.reportPath, status: 'dry-run' };
14
+ }
15
+ const target = path.resolve(config.reportPath);
16
+ const written = await writeFileIfChanged({
17
+ dir: path.dirname(target),
18
+ file: path.basename(target),
19
+ contents,
20
+ });
21
+ log.info(`Wrote report to ${config.reportPath}`);
22
+ return { file: config.reportPath, status: written.status };
23
+ }
24
+
25
+ /**
26
+ * Shapes the public report document. `routes` here is an array of per-route records;
27
+ * the programmatic `report.routes` stays a list of route strings. URLs are reduced to
28
+ * origin and path, and the configured storage-state path is redacted everywhere, so a
29
+ * report can be attached to a build log without leaking credentials or session data.
30
+ */
31
+ export function buildReport(report, config = null) {
32
+ const audit = report.audit ?? { errors: [], warnings: [], linksChecked: 0 };
33
+ const document = {
34
+ version: 1,
35
+ generatedAt: new Date().toISOString(),
36
+ durationMs: report.durationMs,
37
+ timings: report.timings,
38
+ ok: report.ok,
39
+ clock: report.clock ?? null,
40
+ locales: report.locales ?? null,
41
+ seoLocale: report.seoLocale ?? null,
42
+ summary: {
43
+ routes: report.routeDetails.length,
44
+ files: report.files.filter((file) => file.status !== 'dry-run').length,
45
+ errors: uniqueErrorCount(report, audit),
46
+ warnings: audit.warnings.length,
47
+ },
48
+ routes: report.routeDetails,
49
+ verification: report.verification
50
+ ? {
51
+ ok: report.verification.ok,
52
+ modes: report.verification.modes,
53
+ frameworks: report.verification.frameworks,
54
+ skipped: report.verification.skipped ?? 0,
55
+ policySkipped: report.verification.policySkipped ?? 0,
56
+ allowed: report.verification.allowed ?? 0,
57
+ allowedRerender: report.verification.allowedRerender ?? 0,
58
+ errors: report.verification.errors,
59
+ }
60
+ : null,
61
+ audit,
62
+ sitemap: report.sitemap,
63
+ preloadManifest: report.preloadManifest,
64
+ truncated: report.truncated,
65
+ notFoundRoutes: report.notFoundRoutes,
66
+ duplicateOutputs: report.duplicatePages,
67
+ routeSources: report.routeSources ?? [],
68
+ cspSkipped: report.cspSkipped ?? [],
69
+ traces: report.traces ?? [],
70
+ };
71
+ return sanitiseValue(document, config);
72
+ }
73
+
74
+ // Counts route errors once even when the audit mirrors them as findings.
75
+ function uniqueErrorCount(report, audit) {
76
+ const seen = new Set();
77
+ for (const error of [...(report.errors ?? []), ...(audit.errors ?? [])]) {
78
+ seen.add(`${error.route ?? ''}|${error.message ?? ''}`);
79
+ }
80
+ return seen.size;
81
+ }
82
+
83
+ // Recursively redacts URLs and the configured storage-state path from report strings.
84
+ function sanitiseValue(value, config) {
85
+ if (Array.isArray(value)) return value.map((entry) => sanitiseValue(entry, config));
86
+ if (value !== null && typeof value === 'object') {
87
+ return Object.fromEntries(
88
+ Object.entries(value).map(([key, entry]) => [key, sanitiseValue(entry, config)]),
89
+ );
90
+ }
91
+ if (typeof value !== 'string') return value;
92
+ let text = value;
93
+ if (config?.storageState) {
94
+ for (const secret of [config.storageState, path.resolve(config.storageState)]) {
95
+ if (text.includes(secret)) text = text.split(secret).join('[redacted]');
96
+ }
97
+ }
98
+ if (!/https?:\/\//i.test(text)) return text;
99
+ return text.replace(/https?:\/\/[^\s"'<>]+/gi, redactUrl);
100
+ }
101
+
102
+ // Removes credentials, query strings and fragments from an absolute URL.
103
+ function redactUrl(raw) {
104
+ const trailing = /[.,;)\]]+$/.exec(raw)?.[0] ?? '';
105
+ const urlText = trailing ? raw.slice(0, -trailing.length) : raw;
106
+ try {
107
+ const url = new URL(urlText);
108
+ url.username = '';
109
+ url.password = '';
110
+ url.search = '';
111
+ url.hash = '';
112
+ return `${url.href}${trailing}`;
113
+ } catch {
114
+ return raw;
115
+ }
116
+ }
@@ -18,6 +18,7 @@ export function createResourceCollector({
18
18
  const images = new Set();
19
19
  const scripts = new Set();
20
20
  const styles = new Set();
21
+ const fonts = new Map();
21
22
  const json = new Map();
22
23
  const pending = [];
23
24
 
@@ -26,6 +27,7 @@ export function createResourceCollector({
26
27
  images,
27
28
  scripts,
28
29
  styles,
30
+ fonts,
29
31
  json,
30
32
  // Waits for in-flight JSON body reads so the cache is complete before injection.
31
33
  async settled() {
@@ -57,7 +59,9 @@ export function createResourceCollector({
57
59
  if (resourceType === 'image') images.add(path);
58
60
  else if (resourceType === 'script') scripts.add(path);
59
61
  else if (resourceType === 'stylesheet') styles.add(path);
60
- else if (collectJson && isJson(response)) {
62
+ else if (resourceType === 'font') {
63
+ fonts.set(path, response.headers()['content-type'] ?? null);
64
+ } else if (collectJson && isJson(response)) {
61
65
  const length = Number(response.headers()['content-length'] ?? Number.NaN);
62
66
  if (Number.isFinite(length) && length > maxCachedBytes) return;
63
67
  const capture = response
@@ -0,0 +1,17 @@
1
+ import { matchesPattern } from './routes.js';
2
+
3
+ /**
4
+ * Resolves one route's effective config by applying matching routeOverrides in order.
5
+ * Later entries win per key; the base config is never mutated.
6
+ */
7
+ export function resolveRouteConfig(config, route) {
8
+ let resolved = config;
9
+ for (const override of config.routeOverrides ?? []) {
10
+ if (!matchesPattern(route, override.match)) continue;
11
+ const values = Object.fromEntries(
12
+ Object.entries(override).filter(([key, value]) => key !== 'match' && value !== undefined),
13
+ );
14
+ resolved = { ...resolved, ...values };
15
+ }
16
+ return resolved;
17
+ }