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,220 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { isSafeRoute, normaliseRoute, toAppRoute } from './routes.js';
4
+ import { mapLimit } from './scheduler.js';
5
+
6
+ const MAX_SOURCE_ROUTES = 100000;
7
+ const FETCH_TIMEOUT = 10000;
8
+ const SITEMAP_FETCH_CONCURRENCY = 4;
9
+ const XML_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
10
+
11
+ /**
12
+ * Loads concrete route seeds from a text/JSON file and/or a sitemap. These are seeds,
13
+ * not a router: directory-style app paths still have to be handled by the app.
14
+ */
15
+ export async function loadRouteSeeds(config) {
16
+ const routes = [];
17
+ const sources = [];
18
+ if (config.routesFile) {
19
+ const read = await readRouteFile(config.routesFile, config);
20
+ routes.push(...read.routes);
21
+ sources.push({
22
+ source: config.routesFile,
23
+ type: 'file',
24
+ routes: read.routes.length,
25
+ skipped: read.skipped,
26
+ });
27
+ }
28
+ if (config.routesFromSitemap) {
29
+ const read = await readRouteSitemap(config.routesFromSitemap, config);
30
+ routes.push(...read.routes);
31
+ sources.push({
32
+ source: config.routesFromSitemap,
33
+ type: 'sitemap',
34
+ routes: read.routes.length,
35
+ skipped: read.skipped,
36
+ });
37
+ }
38
+ const unique = [...new Set(routes)];
39
+ if (unique.length > MAX_SOURCE_ROUTES) {
40
+ throw new Error(`route sources produced more than ${MAX_SOURCE_ROUTES} routes`);
41
+ }
42
+ return { routes: unique, sources };
43
+ }
44
+
45
+ // Reads a text or JSON route list. Text accepts one route per line and # comments.
46
+ async function readRouteFile(file, config) {
47
+ const contents = await fs.readFile(path.resolve(file), 'utf8');
48
+ let values;
49
+ const trimmed = contents.trim();
50
+ if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
51
+ let parsed;
52
+ try {
53
+ parsed = JSON.parse(trimmed);
54
+ } catch (error) {
55
+ throw new Error(`routesFile is not valid JSON: ${error.message}`);
56
+ }
57
+ values = Array.isArray(parsed) ? parsed : parsed?.routes;
58
+ if (!Array.isArray(values)) {
59
+ throw new Error('routesFile JSON must be an array or an object with a routes array');
60
+ }
61
+ } else {
62
+ values = trimmed
63
+ .split(/\r?\n/)
64
+ .map((line) => line.replace(/#.*$/, '').trim())
65
+ .filter((line) => line.length > 0);
66
+ }
67
+ return normaliseAndFilter(values, 'routesFile', config);
68
+ }
69
+
70
+ // Reads a sitemap, following a sitemap index one level deep.
71
+ async function readRouteSitemap(location, config) {
72
+ const pageUrls = [];
73
+ await readSitemapLevel(location, pageUrls, 0);
74
+ const sourceOrigin = /^https?:\/\//i.test(location) ? new URL(location).origin : null;
75
+ return normaliseAndFilter(pageUrls, 'routesFromSitemap', config, sourceOrigin, true);
76
+ }
77
+
78
+ async function readSitemapLevel(location, pageUrls, depth) {
79
+ if (depth > 1) return;
80
+ const { text, base } = await readSource(location);
81
+ if (!/<(?:urlset|sitemapindex)\b/i.test(text)) {
82
+ throw new Error(`${location} is not a sitemap: no urlset or sitemapindex element found`);
83
+ }
84
+ if (/<sitemapindex\b/i.test(text)) {
85
+ const children = extractLocs(text);
86
+ const { results, errors } = await mapLimit(
87
+ children,
88
+ SITEMAP_FETCH_CONCURRENCY,
89
+ async (child) => {
90
+ const routes = [];
91
+ await readSitemapLevel(resolveChild(child, base), routes, depth + 1);
92
+ return routes;
93
+ },
94
+ );
95
+ if (errors.length > 0) throw errors[0].error;
96
+ for (const routes of results) pageUrls.push(...routes);
97
+ return;
98
+ }
99
+ pageUrls.push(...extractLocs(text));
100
+ }
101
+
102
+ // Reads a local path or an http(s) sitemap.
103
+ async function readSource(location) {
104
+ if (/^https?:\/\//i.test(location)) {
105
+ const response = await fetch(location, {
106
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
107
+ headers: { accept: 'application/xml,text/xml,*/*' },
108
+ });
109
+ if (!response.ok) throw new Error(`sitemap ${location} answered HTTP ${response.status}`);
110
+ return { text: await response.text(), base: location };
111
+ }
112
+ const file = path.resolve(location);
113
+ return { text: await fs.readFile(file, 'utf8'), base: file };
114
+ }
115
+
116
+ // Resolves a child sitemap location relative to its parent.
117
+ function resolveChild(child, base) {
118
+ if (/^https?:\/\//i.test(child)) return child;
119
+ if (/^https?:\/\//i.test(base)) return new URL(child, base).href;
120
+ return path.resolve(path.dirname(base), child);
121
+ }
122
+
123
+ // Extracts and unescapes every <loc> value.
124
+ function extractLocs(xml) {
125
+ const values = [];
126
+ for (const match of xml.matchAll(/<loc\b[^>]*>([\s\S]*?)<\/loc>/gi)) {
127
+ values.push(unescapeXml(match[1].trim()));
128
+ }
129
+ return values;
130
+ }
131
+
132
+ function unescapeXml(value) {
133
+ return value.replace(/&(amp|lt|gt|quot|apos);/g, (_, name) => XML_ENTITIES[name]);
134
+ }
135
+
136
+ // Converts source values to app-relative routes and drops unsafe or off-origin entries.
137
+ function normaliseAndFilter(
138
+ values,
139
+ source,
140
+ config = null,
141
+ sourceOrigin = null,
142
+ enforceOrigin = false,
143
+ ) {
144
+ const siteOrigin = config?.metadata?.siteUrl ? new URL(config.metadata.siteUrl).origin : null;
145
+ const referenceOrigin = siteOrigin ?? sourceOrigin;
146
+ const base = config?.base ?? '/';
147
+ const routes = [];
148
+ const seen = new Set();
149
+ let skipped = 0;
150
+ for (const value of values) {
151
+ if (typeof value !== 'string') {
152
+ skipped += 1;
153
+ continue;
154
+ }
155
+ const absolute = parseAbsolute(value, referenceOrigin);
156
+ if (/^https?:\/\//i.test(value) && !absolute) {
157
+ skipped += 1;
158
+ continue;
159
+ }
160
+ let route;
161
+ if (absolute) {
162
+ if (!referenceOrigin) {
163
+ if (enforceOrigin) {
164
+ skipped += 1;
165
+ continue;
166
+ }
167
+ } else if (absolute.origin !== referenceOrigin) {
168
+ skipped += 1;
169
+ continue;
170
+ }
171
+ route = toAppRoute(absolute.href, base);
172
+ } else if (value.startsWith('//')) {
173
+ // A protocol-relative URL needs an origin to resolve against; without one it is
174
+ // not safe to guess which host it belongs to.
175
+ skipped += 1;
176
+ continue;
177
+ } else {
178
+ route = normaliseRoute(stripBase(value, base));
179
+ }
180
+ if (!route || !isSafeRoute(route) || seen.has(route)) {
181
+ skipped += 1;
182
+ continue;
183
+ }
184
+ seen.add(route);
185
+ routes.push(route);
186
+ }
187
+ if (routes.length === 0 && values.length > 0) {
188
+ throw new Error(`${source} did not contain any usable routes`);
189
+ }
190
+ return { routes, skipped };
191
+ }
192
+
193
+ // Resolves an absolute or protocol-relative URL, returning null for paths.
194
+ function parseAbsolute(value, referenceOrigin) {
195
+ if (/^https?:\/\//i.test(value)) {
196
+ try {
197
+ return new URL(value);
198
+ } catch {
199
+ return null;
200
+ }
201
+ }
202
+ if (value.startsWith('//') && referenceOrigin) {
203
+ try {
204
+ return new URL(value, referenceOrigin);
205
+ } catch {
206
+ return null;
207
+ }
208
+ }
209
+ return null;
210
+ }
211
+
212
+ // Removes the public base prefix from a root-relative path, leaving app-relative paths
213
+ // alone so a sitemap can list either form.
214
+ function stripBase(value, base) {
215
+ if (base === '/') return value;
216
+ const prefix = base.slice(0, -1);
217
+ if (value === prefix) return '/';
218
+ if (value.startsWith(`${prefix}/`)) return value.slice(prefix.length);
219
+ return value;
220
+ }
package/src/core/run.js CHANGED
@@ -1,21 +1,19 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { writePreloadManifest, writeSitemap } from './artifacts.js';
4
+ import { auditRun } from './audit/index.js';
4
5
  import { launchBrowser } from './browser.js';
6
+ import { resolveFixedTime } from './clock.js';
5
7
  import { resolveConfig } from './config.js';
8
+ import { renderAllRoutes } from './crawl.js';
6
9
  import { prepareDestination } from './destination.js';
10
+ import { buildLocalePlan } from './locales.js';
7
11
  import { createLogger } from './log.js';
8
12
  import { routeToFile } from './output.js';
9
13
  import { pruneStaleFiles, readPruneManifest, writePruneManifest } from './prune.js';
10
- import { renderRoute } from './render.js';
11
- import {
12
- crawlRoots,
13
- filterRoutes,
14
- isIncludedRoute,
15
- normaliseRoute,
16
- withinDepth,
17
- } from './routes.js';
18
- import { createPool } from './scheduler.js';
14
+ import { writeReport } from './report.js';
15
+ import { loadRouteSeeds } from './route-sources.js';
16
+ import { normaliseRoute } from './routes.js';
19
17
  import { startCommandServer, startStaticServer } from './server.js';
20
18
  import { verifyRoutes } from './verify.js';
21
19
  import { writeAllRoutes } from './write.js';
@@ -33,24 +31,41 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
33
31
  for (const warning of config.warnings ?? []) log.warn(warning);
34
32
  const sourceDir = path.resolve(config.sourceDir);
35
33
  const startedAt = Date.now();
34
+ const fixedTime = resolveFixedTime(config, startedAt);
35
+ config.fixedTime = fixedTime;
36
+ const localePlan = buildLocalePlan(config);
36
37
 
37
38
  await assertSourceDir(sourceDir, config);
38
- const outputDir = await resolveOutputDir({ sourceDir, config, log });
39
+ const [outputDir, routeSources] = await Promise.all([
40
+ resolveOutputDir({ sourceDir, config, log }),
41
+ loadRouteSeeds(config),
42
+ ]);
39
43
  const previousManifest =
40
44
  config.pruneStaleRoutes && !config.dryRun ? await readPruneManifest(outputDir) : null;
45
+ for (const source of routeSources.sources) {
46
+ const skipped = source.skipped > 0 ? `, ${source.skipped} skipped` : '';
47
+ log.info(`Route source ${source.source}: ${source.routes} route(s)${skipped}`);
48
+ }
41
49
 
42
50
  if (config.saveAs !== 'html' && config.verify) {
43
51
  log.warn(`saveAs: '${config.saveAs}' writes images, so hydration verification is skipped`);
44
52
  }
45
53
 
46
- const server = config.serveCmd
47
- ? await startCommandServer({
48
- command: config.serveCmd,
49
- url: config.url,
50
- timeout: config.serveCmdTimeout,
51
- shutdownTimeout: config.shutdownTimeout,
52
- })
53
- : await startStaticServer({ dir: outputDir, base: config.base });
54
+ const browserPromise = launchBrowser(config, log);
55
+ let server;
56
+ try {
57
+ server = config.serveCmd
58
+ ? await startCommandServer({
59
+ command: config.serveCmd,
60
+ url: config.url,
61
+ timeout: config.serveCmdTimeout,
62
+ shutdownTimeout: config.shutdownTimeout,
63
+ })
64
+ : await startStaticServer({ dir: outputDir, base: config.base });
65
+ } catch (error) {
66
+ browserPromise.then(({ browser }) => browser.close()).catch(() => {});
67
+ throw error;
68
+ }
54
69
 
55
70
  const report = {
56
71
  routes: [],
@@ -62,30 +77,65 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
62
77
  emptyPages: [],
63
78
  duplicateTitles: [],
64
79
  duplicatePages: [],
80
+ links: [],
81
+ notFoundRoutes: [],
82
+ routeSources: routeSources.sources,
83
+ routeDetails: [],
84
+ clock: fixedTime === null ? null : { fixedTime, timezone: config.timezone ?? null },
85
+ locales: localePlan
86
+ ? { mode: localePlan.mode, default: localePlan.default, active: localePlan.active ?? null }
87
+ : null,
88
+ seoLocale: config.seoLocale
89
+ ? {
90
+ code: config.seoLocale.code ?? config.locales?.active ?? null,
91
+ source: config.seoLocale.source,
92
+ inject: config.seoLocale.inject,
93
+ }
94
+ : null,
95
+ cspSkipped: [],
96
+ traces: [],
65
97
  verification: null,
98
+ audit: null,
66
99
  preloadManifest: null,
67
100
  sitemap: null,
68
101
  pruned: [],
69
102
  truncated: null,
70
103
  ok: false,
71
104
  durationMs: 0,
105
+ timings: { render: 0, write: 0, artifacts: 0, verify: 0, audit: 0 },
72
106
  };
73
107
  const manifestEntries = [];
74
108
  const state = { warnedBuildOriginMetadata: false, warnedCriticalCssSkipped: false };
75
109
  let browser = null;
76
110
 
77
111
  try {
78
- const launched = await launchBrowser(config, log);
112
+ const launched = await browserPromise;
79
113
  browser = launched.browser;
80
114
  log.info(`Browser: ${launched.source}`);
115
+ const renderCaches = { inlineCss: new Map(), criticalCss: new Map() };
81
116
 
117
+ let phaseStartedAt = Date.now();
82
118
  const rendered = await renderAllRoutes({
83
119
  browser,
84
120
  server,
85
121
  config,
86
122
  log,
87
123
  report,
124
+ outputDir,
125
+ sourceSeeds: routeSources.routes,
126
+ caches: renderCaches,
127
+ localePlan,
88
128
  });
129
+ report.timings.render = Date.now() - phaseStartedAt;
130
+ const { rendered: renderedRoutes, routeLocales } = rendered;
131
+ if (localePlan) {
132
+ const renderedByLocale = {};
133
+ for (const route of report.routes) {
134
+ const code = routeLocales.get(route)?.locale?.code ?? localePlan.default;
135
+ renderedByLocale[code] = (renderedByLocale[code] ?? 0) + 1;
136
+ }
137
+ report.locales.rendered = renderedByLocale;
138
+ }
89
139
  if (report.routes.length === 0) {
90
140
  report.errors.push({ route: null, message: 'no routes were rendered' });
91
141
  log.error('No routes were rendered; check include, exclude and sourceDir');
@@ -100,19 +150,32 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
100
150
  );
101
151
  }
102
152
 
153
+ phaseStartedAt = Date.now();
103
154
  const verifiable = await writeAllRoutes({
104
- rendered,
155
+ rendered: renderedRoutes,
156
+ routeLocales,
157
+ localePlan,
105
158
  outputDir,
106
159
  config,
107
160
  log,
108
161
  report,
109
162
  state,
110
163
  manifestEntries,
164
+ caches: renderCaches,
111
165
  });
166
+ report.timings.write = Date.now() - phaseStartedAt;
167
+ const localeScripts = report.files
168
+ .filter((file) => file.file?.startsWith('snappy-seo-locale-'))
169
+ .map((file) => file.file);
170
+ if (report.seoLocale) {
171
+ report.seoLocale.script = localeScripts[0] ?? null;
172
+ report.seoLocale.scripts = [...new Set(localeScripts)];
173
+ }
112
174
  logFileSummary(report, log);
113
175
  logNotFoundNotice(report, config, log);
114
176
  logDuplicateTitles(report, log);
115
177
 
178
+ phaseStartedAt = Date.now();
116
179
  if (config.sitemap) {
117
180
  try {
118
181
  await writeSitemap({ outputDir, config, log, report });
@@ -145,6 +208,7 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
145
208
  log.error(`preload manifest write failed: ${error.message}`);
146
209
  }
147
210
  }
211
+ report.timings.artifacts = Date.now() - phaseStartedAt;
148
212
 
149
213
  const canVerify = config.verify && !config.dryRun && config.saveAs === 'html';
150
214
  if (canVerify && config.serveCmd) {
@@ -154,13 +218,13 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
154
218
  }
155
219
  // With verifyChangedOnly, a route whose HTML file was left untouched is not reloaded: the HTML
156
220
  // and the bundle are the same as the last time it was verified.
221
+ const fileStatus = new Map(
222
+ report.files.map((file) => [`${file.route ?? ''}|${file.file ?? ''}`, file.status]),
223
+ );
157
224
  const toVerify = config.verifyChangedOnly
158
225
  ? verifiable.filter((route) => {
159
226
  const file = routeToFile(route, config);
160
- const entry = report.files.find(
161
- (candidate) => candidate.route === route && candidate.file === file,
162
- );
163
- return entry?.status !== 'unchanged';
227
+ return fileStatus.get(`${route}|${file}`) !== 'unchanged';
164
228
  })
165
229
  : verifiable;
166
230
  const skipped = verifiable.length - toVerify.length;
@@ -168,14 +232,20 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
168
232
  log.info(`Verification skipped for ${skipped} unchanged route(s), as verifyChangedOnly asks`);
169
233
  }
170
234
  if (canVerify && !config.serveCmd && toVerify.length > 0) {
235
+ phaseStartedAt = Date.now();
171
236
  report.verification = await verifyRoutes({
172
237
  browser,
173
238
  origin: server.origin,
174
239
  routes: toVerify.sort(),
175
240
  config,
241
+ outputDir,
242
+ routeLocales,
176
243
  });
244
+ report.timings.verify = Date.now() - phaseStartedAt;
177
245
  report.verification.skipped = skipped;
246
+ report.verification.policySkipped = report.verification.skippedRoutes.length;
178
247
  logVerification(report.verification, config, log);
248
+ mergeVerification(report);
179
249
  }
180
250
 
181
251
  if (
@@ -211,7 +281,20 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
211
281
  }
212
282
 
213
283
  report.durationMs = Date.now() - startedAt;
284
+ if (config.audit) {
285
+ const auditStartedAt = Date.now();
286
+ try {
287
+ report.audit = await auditRun({ report, config, outputDir });
288
+ logAudit(report.audit, log);
289
+ } catch (error) {
290
+ report.errors.push({ route: null, message: `audit failed: ${error.message}` });
291
+ log.error(`audit failed: ${error.message}`);
292
+ }
293
+ report.timings.audit = Date.now() - auditStartedAt;
294
+ }
214
295
  const reRendered = report.verification?.modes['re-rendered'] ?? 0;
296
+ const allowedRerender = report.verification?.allowedRerender ?? 0;
297
+ const notAllowedRerender = Math.max(0, reRendered - allowedRerender);
215
298
  const undetected = report.verification
216
299
  ? (report.verification.modes.undetected ?? 0) + (report.verification.modes.unknown ?? 0)
217
300
  : 0;
@@ -222,10 +305,18 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
222
305
  !(config.failOnConsoleError && report.consoleErrors.length > 0) &&
223
306
  !(config.failOnFailedRequest && report.failedRequests.length > 0) &&
224
307
  !(config.failOnDuplicateContent && report.duplicatePages.length > 0) &&
308
+ !(config.failOnAuditError && (report.audit?.errors.length ?? 0) > 0) &&
225
309
  !(config.failOnHydrationError && report.verification && !report.verification.ok) &&
226
310
  !(config.failOnVerificationError && report.verification?.errors.length > 0) &&
227
311
  !(config.failOnUndetected && undetected > 0) &&
228
- !(config.failOnRerender && reRendered > 0);
312
+ !(config.failOnRerender && notAllowedRerender > 0);
313
+ try {
314
+ report.reportFile = await writeReport({ report, config, log });
315
+ } catch (error) {
316
+ report.errors.push({ route: null, message: `report write failed: ${error.message}` });
317
+ log.error(`report write failed: ${error.message}`);
318
+ if (config.failOnError) report.ok = false;
319
+ }
229
320
  return report;
230
321
  }
231
322
 
@@ -269,114 +360,44 @@ async function resolveOutputDir({ sourceDir, config, log }) {
269
360
  });
270
361
  }
271
362
 
272
- /**
273
- * Runs the crawl and render pool. Each rendered page contributes its output, any new
274
- * same-origin routes, and the resources recorded for the link-hint features.
275
- */
276
- async function renderAllRoutes({ browser, server, config, log, report }) {
277
- const visited = new Set();
278
- const scheduled = new Set();
279
- const rendered = new Map();
280
- const cap = config.maxRoutes ?? Number.POSITIVE_INFINITY;
281
-
282
- // Queues routes up to maxRoutes, flagging the report the first time the cap bites.
283
- // A seed used only to reach wildcard routes is given output: false and is rendered
284
- // for its links without being written.
285
- const accept = (routes, outputFor = () => true) => {
286
- const accepted = [];
287
- for (const route of routes) {
288
- if (scheduled.has(route)) continue;
289
- if (scheduled.size >= cap) {
290
- report.truncated = { limit: config.maxRoutes, routes: scheduled.size };
291
- break;
292
- }
293
- scheduled.add(route);
294
- accepted.push({ route, output: outputFor(route) });
295
- }
296
- return accepted;
297
- };
298
-
299
- const pool = createPool({
300
- concurrency: config.concurrency,
301
- handler: async ({ route, depth, output }) => {
302
- if (visited.has(route)) return;
303
- visited.add(route);
304
- log.debug(`rendering ${route}${output ? '' : ' as a crawl seed'}`);
305
- try {
306
- const result = await renderRoute({
307
- browser,
308
- origin: server.origin,
309
- route,
310
- config,
311
- screenshot: config.saveAs !== 'html',
312
- });
313
- if (output && result.empty) {
314
- report.emptyPages.push(route);
315
- if (config.failOnEmptyPage) {
316
- report.errors.push({ route, message: 'rendered an empty page' });
317
- log.error(` empty page ${route}: no app content was found`);
318
- return;
319
- }
320
- log.warn(` empty page ${route}: no app content was found`);
321
- }
322
- if (output) {
323
- rendered.set(route, result);
324
- report.routes.push(route);
325
- }
326
- for (const message of result.pageErrors) {
327
- report.pageErrors.push({ route, message });
328
- log.warn(` page error on ${route}: ${message}`);
329
- }
330
- for (const message of result.consoleErrors) {
331
- report.consoleErrors.push({ route, message });
332
- }
333
- if (result.consoleErrors.length > 0) {
334
- log.debug(` console error on ${route}: ${result.consoleErrors[0]}`);
335
- if (config.failOnConsoleError) {
336
- report.errors.push({ route, message: `console error: ${result.consoleErrors[0]}` });
337
- }
338
- }
339
- for (const failure of result.failedRequests) {
340
- report.failedRequests.push({ route, ...failure });
341
- const detail = failure.status ? `HTTP ${failure.status}` : failure.error;
342
- log.warn(` failed request on ${route}: ${failure.url} (${detail})`);
343
- }
344
- if (result.failedRequests.length > 0 && config.failOnFailedRequest) {
345
- const first = result.failedRequests[0];
346
- report.errors.push({
347
- route,
348
- message: `failed request: ${first.url}${first.status ? ` (HTTP ${first.status})` : ''}`,
349
- });
350
- }
351
- if (config.crawl && withinDepth(depth + 1, config.maxDepth)) {
352
- const discovered = filterRoutes(result.links, {
353
- include: config.includeProvided ? config.include : [],
354
- exclude: config.exclude,
355
- }).filter((candidate) => !scheduled.has(candidate));
356
- pool.push(accept(discovered).map((item) => ({ ...item, depth: depth + 1 })));
357
- }
358
- } catch (error) {
359
- report.errors.push({ route, message: error.message });
360
- log.error(` failed ${route}: ${error.message}`);
361
- }
362
- },
363
- });
363
+ // Adds the verification result to each route record for the JSON report.
364
+ function mergeVerification(report) {
365
+ const details = new Map(report.routeDetails.map((record) => [record.route, record]));
366
+ for (const entry of report.verification?.routes ?? []) {
367
+ const detail = details.get(entry.route);
368
+ if (!detail) continue;
369
+ detail.mode = entry.mode;
370
+ detail.framework = entry.framework ?? null;
371
+ detail.hydrationErrors = entry.hydrationErrors;
372
+ detail.ignoredHydrationErrors = entry.ignoredHydrationErrors ?? [];
373
+ detail.hydrationPolicy = entry.hydrationPolicy ?? null;
374
+ detail.hydrationAllowed = entry.allowed === true;
375
+ detail.pageErrors = [...new Set([...(detail.pageErrors ?? []), ...entry.pageErrors])];
376
+ detail.consoleErrors = [...new Set([...(detail.consoleErrors ?? []), ...entry.consoleErrors])];
377
+ detail.verificationStatus = entry.status;
378
+ detail.empty = entry.empty || detail.empty;
379
+ detail.firstMismatch = entry.firstMismatch ?? null;
380
+ }
381
+ }
364
382
 
365
- const roots = accept(
366
- crawlRoots(config),
367
- (route) => !config.includeProvided || isIncludedRoute(route, config.include),
368
- );
369
- pool.push(roots.map((item) => ({ ...item, depth: 0 })));
370
- const { errors } = await pool.run();
371
- for (const { item, error } of errors) {
372
- report.errors.push({ route: item.route, message: error.message });
373
- log.error(` pool error on ${item.route}: ${error.message}`);
383
+ // Prints an audit summary and a bounded sample of findings.
384
+ function logAudit(audit, log) {
385
+ const errors = audit?.errors?.length ?? 0;
386
+ const warnings = audit?.warnings?.length ?? 0;
387
+ if (errors === 0 && warnings === 0) {
388
+ log.success(`Audit: no issues (${audit?.linksChecked ?? 0} links checked)`);
389
+ return;
390
+ }
391
+ log.info(`Audit: ${errors} error(s), ${warnings} warning(s)`);
392
+ for (const finding of [...(audit?.errors ?? []), ...(audit?.warnings ?? [])].slice(0, 20)) {
393
+ const message = `${finding.rule}: ${finding.message}`;
394
+ if (finding.severity === 'error') log.error(` ${message}`);
395
+ else log.warn(` ${message}`);
374
396
  }
375
- return rendered;
376
397
  }
377
398
 
378
- // Notes routes whose output carries more than one title, which is what React 19 native
379
- // metadata and the head layer together produce.
399
+ // Notes duplicate title tags, which React 19 native metadata and the head layer can
400
+ // each produce.
380
401
  function logDuplicateTitles(report, log) {
381
402
  if (report.duplicateTitles.length === 0) return;
382
403
  const sample = report.duplicateTitles.slice(0, 5).join(', ');
@@ -417,8 +438,10 @@ function logFileSummary(report, log) {
417
438
  // were reported but not enforced.
418
439
  function logVerification(verification, config, log) {
419
440
  const modes = verification.modes ?? {};
441
+ const allowed = verification.allowed ?? 0;
442
+ const policySkipped = verification.policySkipped ?? 0;
420
443
  log.info(
421
- `Hydration modes: ${modes.hydrated ?? 0} hydrated, ${modes['re-rendered'] ?? 0} re-rendered, ${modes.undetected ?? 0} undetected, ${modes.unknown ?? 0} unknown`,
444
+ `Hydration modes: ${modes.hydrated ?? 0} hydrated, ${modes['re-rendered'] ?? 0} re-rendered, ${modes.undetected ?? 0} undetected, ${modes.unknown ?? 0} unknown${allowed > 0 ? `, ${allowed} allowed` : ''}${policySkipped > 0 ? `, ${policySkipped} skipped by policy` : ''}`,
422
445
  );
423
446
  const notDetected = (modes.undetected ?? 0) + (modes.unknown ?? 0);
424
447
  if (notDetected > 0) {
@@ -460,7 +483,9 @@ function logVerification(verification, config, log) {
460
483
  }
461
484
  }
462
485
 
463
- const reRendered = verification.routes.filter((entry) => entry.mode === 're-rendered');
486
+ const reRendered = verification.routes.filter(
487
+ (entry) => entry.mode === 're-rendered' && !entry.allowed,
488
+ );
464
489
  if (reRendered.length > 0) {
465
490
  const sample = reRendered
466
491
  .slice(0, 5)