snappy-prerender 2.6.0 → 2.7.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.
@@ -0,0 +1,108 @@
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
+ ok: report.ok,
38
+ summary: {
39
+ routes: report.routeDetails.length,
40
+ files: report.files.filter((file) => file.status !== 'dry-run').length,
41
+ errors: uniqueErrorCount(report, audit),
42
+ warnings: audit.warnings.length,
43
+ },
44
+ routes: report.routeDetails,
45
+ verification: report.verification
46
+ ? {
47
+ ok: report.verification.ok,
48
+ modes: report.verification.modes,
49
+ frameworks: report.verification.frameworks,
50
+ skipped: report.verification.skipped ?? 0,
51
+ errors: report.verification.errors,
52
+ }
53
+ : null,
54
+ audit,
55
+ sitemap: report.sitemap,
56
+ preloadManifest: report.preloadManifest,
57
+ truncated: report.truncated,
58
+ notFoundRoutes: report.notFoundRoutes,
59
+ duplicateOutputs: report.duplicatePages,
60
+ routeSources: report.routeSources ?? [],
61
+ cspSkipped: report.cspSkipped ?? [],
62
+ traces: report.traces ?? [],
63
+ };
64
+ return sanitiseValue(document, config);
65
+ }
66
+
67
+ // Counts route errors once even when the audit mirrors them as findings.
68
+ function uniqueErrorCount(report, audit) {
69
+ const seen = new Set();
70
+ for (const error of [...(report.errors ?? []), ...(audit.errors ?? [])]) {
71
+ seen.add(`${error.route ?? ''}|${error.message ?? ''}`);
72
+ }
73
+ return seen.size;
74
+ }
75
+
76
+ // Recursively redacts URLs and the configured storage-state path from report strings.
77
+ function sanitiseValue(value, config) {
78
+ if (Array.isArray(value)) return value.map((entry) => sanitiseValue(entry, config));
79
+ if (value !== null && typeof value === 'object') {
80
+ return Object.fromEntries(
81
+ Object.entries(value).map(([key, entry]) => [key, sanitiseValue(entry, config)]),
82
+ );
83
+ }
84
+ if (typeof value !== 'string') return value;
85
+ let text = value;
86
+ if (config?.storageState) {
87
+ for (const secret of [config.storageState, path.resolve(config.storageState)]) {
88
+ text = text.split(secret).join('[redacted]');
89
+ }
90
+ }
91
+ return text.replace(/https?:\/\/[^\s"'<>]+/gi, redactUrl);
92
+ }
93
+
94
+ // Removes credentials, query strings and fragments from an absolute URL.
95
+ function redactUrl(raw) {
96
+ const trailing = /[.,;)\]]+$/.exec(raw)?.[0] ?? '';
97
+ const urlText = trailing ? raw.slice(0, -trailing.length) : raw;
98
+ try {
99
+ const url = new URL(urlText);
100
+ url.username = '';
101
+ url.password = '';
102
+ url.search = '';
103
+ url.hash = '';
104
+ return `${url.href}${trailing}`;
105
+ } catch {
106
+ return raw;
107
+ }
108
+ }
@@ -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
+ }
@@ -0,0 +1,209 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { isSafeRoute, normaliseRoute, toAppRoute } from './routes.js';
4
+
5
+ const MAX_SOURCE_ROUTES = 100000;
6
+ const FETCH_TIMEOUT = 10000;
7
+ const XML_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
8
+
9
+ /**
10
+ * Loads concrete route seeds from a text/JSON file and/or a sitemap. These are seeds,
11
+ * not a router: directory-style app paths still have to be handled by the app.
12
+ */
13
+ export async function loadRouteSeeds(config) {
14
+ const routes = [];
15
+ const sources = [];
16
+ if (config.routesFile) {
17
+ const read = await readRouteFile(config.routesFile, config);
18
+ routes.push(...read.routes);
19
+ sources.push({
20
+ source: config.routesFile,
21
+ type: 'file',
22
+ routes: read.routes.length,
23
+ skipped: read.skipped,
24
+ });
25
+ }
26
+ if (config.routesFromSitemap) {
27
+ const read = await readRouteSitemap(config.routesFromSitemap, config);
28
+ routes.push(...read.routes);
29
+ sources.push({
30
+ source: config.routesFromSitemap,
31
+ type: 'sitemap',
32
+ routes: read.routes.length,
33
+ skipped: read.skipped,
34
+ });
35
+ }
36
+ const unique = [...new Set(routes)];
37
+ if (unique.length > MAX_SOURCE_ROUTES) {
38
+ throw new Error(`route sources produced more than ${MAX_SOURCE_ROUTES} routes`);
39
+ }
40
+ return { routes: unique, sources };
41
+ }
42
+
43
+ // Reads a text or JSON route list. Text accepts one route per line and # comments.
44
+ async function readRouteFile(file, config) {
45
+ const contents = await fs.readFile(path.resolve(file), 'utf8');
46
+ let values;
47
+ const trimmed = contents.trim();
48
+ if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
49
+ let parsed;
50
+ try {
51
+ parsed = JSON.parse(trimmed);
52
+ } catch (error) {
53
+ throw new Error(`routesFile is not valid JSON: ${error.message}`);
54
+ }
55
+ values = Array.isArray(parsed) ? parsed : parsed?.routes;
56
+ if (!Array.isArray(values)) {
57
+ throw new Error('routesFile JSON must be an array or an object with a routes array');
58
+ }
59
+ } else {
60
+ values = trimmed
61
+ .split(/\r?\n/)
62
+ .map((line) => line.replace(/#.*$/, '').trim())
63
+ .filter((line) => line.length > 0);
64
+ }
65
+ return normaliseAndFilter(values, 'routesFile', config);
66
+ }
67
+
68
+ // Reads a sitemap, following a sitemap index one level deep.
69
+ async function readRouteSitemap(location, config) {
70
+ const pageUrls = [];
71
+ await readSitemapLevel(location, pageUrls, 0);
72
+ const sourceOrigin = /^https?:\/\//i.test(location) ? new URL(location).origin : null;
73
+ return normaliseAndFilter(pageUrls, 'routesFromSitemap', config, sourceOrigin, true);
74
+ }
75
+
76
+ async function readSitemapLevel(location, pageUrls, depth) {
77
+ if (depth > 1) return;
78
+ const { text, base } = await readSource(location);
79
+ if (!/<(?:urlset|sitemapindex)\b/i.test(text)) {
80
+ throw new Error(`${location} is not a sitemap: no urlset or sitemapindex element found`);
81
+ }
82
+ if (/<sitemapindex\b/i.test(text)) {
83
+ for (const child of extractLocs(text)) {
84
+ await readSitemapLevel(resolveChild(child, base), pageUrls, depth + 1);
85
+ }
86
+ return;
87
+ }
88
+ pageUrls.push(...extractLocs(text));
89
+ }
90
+
91
+ // Reads a local path or an http(s) sitemap.
92
+ async function readSource(location) {
93
+ if (/^https?:\/\//i.test(location)) {
94
+ const response = await fetch(location, {
95
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
96
+ headers: { accept: 'application/xml,text/xml,*/*' },
97
+ });
98
+ if (!response.ok) throw new Error(`sitemap ${location} answered HTTP ${response.status}`);
99
+ return { text: await response.text(), base: location };
100
+ }
101
+ const file = path.resolve(location);
102
+ return { text: await fs.readFile(file, 'utf8'), base: file };
103
+ }
104
+
105
+ // Resolves a child sitemap location relative to its parent.
106
+ function resolveChild(child, base) {
107
+ if (/^https?:\/\//i.test(child)) return child;
108
+ if (/^https?:\/\//i.test(base)) return new URL(child, base).href;
109
+ return path.resolve(path.dirname(base), child);
110
+ }
111
+
112
+ // Extracts and unescapes every <loc> value.
113
+ function extractLocs(xml) {
114
+ const values = [];
115
+ for (const match of xml.matchAll(/<loc\b[^>]*>([\s\S]*?)<\/loc>/gi)) {
116
+ values.push(unescapeXml(match[1].trim()));
117
+ }
118
+ return values;
119
+ }
120
+
121
+ function unescapeXml(value) {
122
+ return value.replace(/&(amp|lt|gt|quot|apos);/g, (_, name) => XML_ENTITIES[name]);
123
+ }
124
+
125
+ // Converts source values to app-relative routes and drops unsafe or off-origin entries.
126
+ function normaliseAndFilter(
127
+ values,
128
+ source,
129
+ config = null,
130
+ sourceOrigin = null,
131
+ enforceOrigin = false,
132
+ ) {
133
+ const siteOrigin = config?.metadata?.siteUrl ? new URL(config.metadata.siteUrl).origin : null;
134
+ const referenceOrigin = siteOrigin ?? sourceOrigin;
135
+ const base = config?.base ?? '/';
136
+ const routes = [];
137
+ const seen = new Set();
138
+ let skipped = 0;
139
+ for (const value of values) {
140
+ if (typeof value !== 'string') {
141
+ skipped += 1;
142
+ continue;
143
+ }
144
+ const absolute = parseAbsolute(value, referenceOrigin);
145
+ if (/^https?:\/\//i.test(value) && !absolute) {
146
+ skipped += 1;
147
+ continue;
148
+ }
149
+ let route;
150
+ if (absolute) {
151
+ if (!referenceOrigin) {
152
+ if (enforceOrigin) {
153
+ skipped += 1;
154
+ continue;
155
+ }
156
+ } else if (absolute.origin !== referenceOrigin) {
157
+ skipped += 1;
158
+ continue;
159
+ }
160
+ route = toAppRoute(absolute.href, base);
161
+ } else if (value.startsWith('//')) {
162
+ // A protocol-relative URL needs an origin to resolve against; without one it is
163
+ // not safe to guess which host it belongs to.
164
+ skipped += 1;
165
+ continue;
166
+ } else {
167
+ route = normaliseRoute(stripBase(value, base));
168
+ }
169
+ if (!route || !isSafeRoute(route) || seen.has(route)) {
170
+ skipped += 1;
171
+ continue;
172
+ }
173
+ seen.add(route);
174
+ routes.push(route);
175
+ }
176
+ if (routes.length === 0 && values.length > 0) {
177
+ throw new Error(`${source} did not contain any usable routes`);
178
+ }
179
+ return { routes, skipped };
180
+ }
181
+
182
+ // Resolves an absolute or protocol-relative URL, returning null for paths.
183
+ function parseAbsolute(value, referenceOrigin) {
184
+ if (/^https?:\/\//i.test(value)) {
185
+ try {
186
+ return new URL(value);
187
+ } catch {
188
+ return null;
189
+ }
190
+ }
191
+ if (value.startsWith('//') && referenceOrigin) {
192
+ try {
193
+ return new URL(value, referenceOrigin);
194
+ } catch {
195
+ return null;
196
+ }
197
+ }
198
+ return null;
199
+ }
200
+
201
+ // Removes the public base prefix from a root-relative path, leaving app-relative paths
202
+ // alone so a sitemap can list either form.
203
+ function stripBase(value, base) {
204
+ if (base === '/') return value;
205
+ const prefix = base.slice(0, -1);
206
+ if (value === prefix) return '/';
207
+ if (value.startsWith(`${prefix}/`)) return value.slice(prefix.length);
208
+ return value;
209
+ }
package/src/core/run.js CHANGED
@@ -1,21 +1,17 @@
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';
5
6
  import { resolveConfig } from './config.js';
7
+ import { renderAllRoutes } from './crawl.js';
6
8
  import { prepareDestination } from './destination.js';
7
9
  import { createLogger } from './log.js';
8
10
  import { routeToFile } from './output.js';
9
11
  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';
12
+ import { writeReport } from './report.js';
13
+ import { loadRouteSeeds } from './route-sources.js';
14
+ import { normaliseRoute } from './routes.js';
19
15
  import { startCommandServer, startStaticServer } from './server.js';
20
16
  import { verifyRoutes } from './verify.js';
21
17
  import { writeAllRoutes } from './write.js';
@@ -38,6 +34,11 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
38
34
  const outputDir = await resolveOutputDir({ sourceDir, config, log });
39
35
  const previousManifest =
40
36
  config.pruneStaleRoutes && !config.dryRun ? await readPruneManifest(outputDir) : null;
37
+ const routeSources = await loadRouteSeeds(config);
38
+ for (const source of routeSources.sources) {
39
+ const skipped = source.skipped > 0 ? `, ${source.skipped} skipped` : '';
40
+ log.info(`Route source ${source.source}: ${source.routes} route(s)${skipped}`);
41
+ }
41
42
 
42
43
  if (config.saveAs !== 'html' && config.verify) {
43
44
  log.warn(`saveAs: '${config.saveAs}' writes images, so hydration verification is skipped`);
@@ -62,7 +63,14 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
62
63
  emptyPages: [],
63
64
  duplicateTitles: [],
64
65
  duplicatePages: [],
66
+ links: [],
67
+ notFoundRoutes: [],
68
+ routeSources: routeSources.sources,
69
+ routeDetails: [],
70
+ cspSkipped: [],
71
+ traces: [],
65
72
  verification: null,
73
+ audit: null,
66
74
  preloadManifest: null,
67
75
  sitemap: null,
68
76
  pruned: [],
@@ -85,6 +93,8 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
85
93
  config,
86
94
  log,
87
95
  report,
96
+ outputDir,
97
+ sourceSeeds: routeSources.routes,
88
98
  });
89
99
  if (report.routes.length === 0) {
90
100
  report.errors.push({ route: null, message: 'no routes were rendered' });
@@ -173,9 +183,11 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
173
183
  origin: server.origin,
174
184
  routes: toVerify.sort(),
175
185
  config,
186
+ outputDir,
176
187
  });
177
188
  report.verification.skipped = skipped;
178
189
  logVerification(report.verification, config, log);
190
+ mergeVerification(report);
179
191
  }
180
192
 
181
193
  if (
@@ -211,6 +223,15 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
211
223
  }
212
224
 
213
225
  report.durationMs = Date.now() - startedAt;
226
+ if (config.audit) {
227
+ try {
228
+ report.audit = await auditRun({ report, config, outputDir });
229
+ logAudit(report.audit, log);
230
+ } catch (error) {
231
+ report.errors.push({ route: null, message: `audit failed: ${error.message}` });
232
+ log.error(`audit failed: ${error.message}`);
233
+ }
234
+ }
214
235
  const reRendered = report.verification?.modes['re-rendered'] ?? 0;
215
236
  const undetected = report.verification
216
237
  ? (report.verification.modes.undetected ?? 0) + (report.verification.modes.unknown ?? 0)
@@ -222,10 +243,18 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
222
243
  !(config.failOnConsoleError && report.consoleErrors.length > 0) &&
223
244
  !(config.failOnFailedRequest && report.failedRequests.length > 0) &&
224
245
  !(config.failOnDuplicateContent && report.duplicatePages.length > 0) &&
246
+ !(config.failOnAuditError && (report.audit?.errors.length ?? 0) > 0) &&
225
247
  !(config.failOnHydrationError && report.verification && !report.verification.ok) &&
226
248
  !(config.failOnVerificationError && report.verification?.errors.length > 0) &&
227
249
  !(config.failOnUndetected && undetected > 0) &&
228
250
  !(config.failOnRerender && reRendered > 0);
251
+ try {
252
+ report.reportFile = await writeReport({ report, config, log });
253
+ } catch (error) {
254
+ report.errors.push({ route: null, message: `report write failed: ${error.message}` });
255
+ log.error(`report write failed: ${error.message}`);
256
+ if (config.failOnError) report.ok = false;
257
+ }
229
258
  return report;
230
259
  }
231
260
 
@@ -269,114 +298,40 @@ async function resolveOutputDir({ sourceDir, config, log }) {
269
298
  });
270
299
  }
271
300
 
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
- });
301
+ // Adds the verification result to each route record for the JSON report.
302
+ function mergeVerification(report) {
303
+ for (const entry of report.verification?.routes ?? []) {
304
+ const detail = report.routeDetails.find((record) => record.route === entry.route);
305
+ if (!detail) continue;
306
+ detail.mode = entry.mode;
307
+ detail.framework = entry.framework ?? null;
308
+ detail.hydrationErrors = entry.hydrationErrors;
309
+ detail.pageErrors = [...new Set([...(detail.pageErrors ?? []), ...entry.pageErrors])];
310
+ detail.consoleErrors = [...new Set([...(detail.consoleErrors ?? []), ...entry.consoleErrors])];
311
+ detail.verificationStatus = entry.status;
312
+ detail.empty = entry.empty || detail.empty;
313
+ detail.firstMismatch = entry.firstMismatch ?? null;
314
+ }
315
+ }
364
316
 
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}`);
317
+ // Prints an audit summary and a bounded sample of findings.
318
+ function logAudit(audit, log) {
319
+ const errors = audit?.errors?.length ?? 0;
320
+ const warnings = audit?.warnings?.length ?? 0;
321
+ if (errors === 0 && warnings === 0) {
322
+ log.success(`Audit: no issues (${audit?.linksChecked ?? 0} links checked)`);
323
+ return;
324
+ }
325
+ log.info(`Audit: ${errors} error(s), ${warnings} warning(s)`);
326
+ for (const finding of [...(audit?.errors ?? []), ...(audit?.warnings ?? [])].slice(0, 20)) {
327
+ const message = `${finding.rule}: ${finding.message}`;
328
+ if (finding.severity === 'error') log.error(` ${message}`);
329
+ else log.warn(` ${message}`);
374
330
  }
375
- return rendered;
376
331
  }
377
332
 
378
- // Notes routes whose output carries more than one title, which is what React 19 native
379
- // metadata and the head layer together produce.
333
+ // Notes duplicate title tags, which React 19 native metadata and the head layer can
334
+ // each produce.
380
335
  function logDuplicateTitles(report, log) {
381
336
  if (report.duplicateTitles.length === 0) return;
382
337
  const sample = report.duplicateTitles.slice(0, 5).join(', ');