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.
- package/CHANGELOG.md +51 -0
- package/README.md +122 -101
- package/docs/limitations.md +58 -0
- package/docs/react-snap-migration.md +45 -0
- package/docs/report.md +102 -0
- package/package.json +2 -1
- package/src/cli-init.js +205 -0
- package/src/cli.js +73 -168
- package/src/core/artifacts.js +2 -0
- package/src/core/audit/index.js +114 -0
- package/src/core/audit/links.js +63 -0
- package/src/core/audit/metadata.js +88 -0
- package/src/core/audit/sitemap.js +39 -0
- package/src/core/config.js +129 -5
- package/src/core/crawl.js +300 -0
- package/src/core/csp.js +127 -0
- package/src/core/frameworks.js +94 -0
- package/src/core/hydration-diff.js +93 -0
- package/src/core/init.js +143 -30
- package/src/core/normalize.js +7 -0
- package/src/core/page-meta.js +108 -0
- package/src/core/preload-links.js +120 -0
- package/src/core/render.js +87 -5
- package/src/core/report.js +108 -0
- package/src/core/resources.js +5 -1
- package/src/core/route-config.js +17 -0
- package/src/core/route-sources.js +209 -0
- package/src/core/run.js +68 -113
- package/src/core/sitemap.js +90 -14
- package/src/core/verify.js +92 -48
- package/src/core/write.js +92 -7
- package/src/head/index.js +2 -2
- package/src/head/manager.js +54 -13
- package/src/head/tags.js +52 -6
|
@@ -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
|
+
}
|
package/src/core/resources.js
CHANGED
|
@@ -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 (
|
|
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 {
|
|
11
|
-
import {
|
|
12
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
|
379
|
-
//
|
|
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(', ');
|