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,114 @@
|
|
|
1
|
+
import { auditLinks, auditOrphans } from './links.js';
|
|
2
|
+
import { auditMetadata } from './metadata.js';
|
|
3
|
+
import { auditSitemap } from './sitemap.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Audits the written site and returns report-only findings. Errors are the things a
|
|
7
|
+
* static site should not ship (broken links, sitemap disagreement, duplicate titles);
|
|
8
|
+
* warnings cover metadata that is useful but legitimately optional.
|
|
9
|
+
*/
|
|
10
|
+
export async function auditRun({ report, config, outputDir }) {
|
|
11
|
+
const errors = [];
|
|
12
|
+
const warnings = [];
|
|
13
|
+
const add = (severity, rule, message, details = {}) => {
|
|
14
|
+
const finding = { rule, severity, message, ...details };
|
|
15
|
+
if (severity === 'error') errors.push(finding);
|
|
16
|
+
else warnings.push(finding);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const pages = report.routeDetails.filter((detail) => detail.file?.endsWith('.html'));
|
|
20
|
+
auditRouteHealth({ report, add });
|
|
21
|
+
auditMetadata({ pages, config, add });
|
|
22
|
+
auditSitemap({ pages, config, report, add });
|
|
23
|
+
const linkAudit = await auditLinks({ report, config, outputDir, add });
|
|
24
|
+
auditOrphans({ pages, report, add });
|
|
25
|
+
auditCspHygiene({ pages, config, report, add });
|
|
26
|
+
removeDuplicateFindings({ errors, warnings });
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
errors,
|
|
30
|
+
warnings,
|
|
31
|
+
linksChecked: linkAudit.checked,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Mirrors route-level failures into the audit so failOnAuditError covers route health
|
|
36
|
+
// without requiring callers to inspect a second collection.
|
|
37
|
+
function auditRouteHealth({ report, add }) {
|
|
38
|
+
for (const error of report.errors ?? []) {
|
|
39
|
+
add('error', 'route-error', error.message, { route: error.route ?? undefined });
|
|
40
|
+
}
|
|
41
|
+
if (report.truncated) {
|
|
42
|
+
add(
|
|
43
|
+
'error',
|
|
44
|
+
'route-cap',
|
|
45
|
+
`maxRoutes (${report.truncated.limit}) stopped the crawl after ${report.truncated.routes} route(s)`,
|
|
46
|
+
{ droppedRoutes: report.truncated.droppedRoutes ?? [] },
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
for (const route of report.emptyPages ?? []) {
|
|
50
|
+
add('error', 'empty-page', 'rendered an empty page', { route });
|
|
51
|
+
}
|
|
52
|
+
for (const group of report.duplicatePages ?? []) {
|
|
53
|
+
for (const route of group.routes) {
|
|
54
|
+
const others = group.routes.filter((other) => other !== route).join(', ');
|
|
55
|
+
add('error', 'duplicate-output', `identical output to ${others}`, { route });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Inline style attributes cannot be covered by a CSP hash, and browser console errors
|
|
61
|
+
// reveal assets the generated policy blocked.
|
|
62
|
+
function auditCspHygiene({ pages, config, report, add }) {
|
|
63
|
+
if (config.csp !== 'hash') return;
|
|
64
|
+
for (const route of report.cspSkipped ?? []) {
|
|
65
|
+
add(
|
|
66
|
+
'warning',
|
|
67
|
+
'existing-csp-meta',
|
|
68
|
+
`${route} already has a CSP meta, so no hashes were injected`,
|
|
69
|
+
{ route },
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const violations = new Map();
|
|
73
|
+
const collect = (route, message) => {
|
|
74
|
+
if (!/content security policy|violates the following/i.test(message)) return;
|
|
75
|
+
const messages = violations.get(route) ?? new Set();
|
|
76
|
+
messages.add(message);
|
|
77
|
+
violations.set(route, messages);
|
|
78
|
+
};
|
|
79
|
+
for (const { route, message } of report.consoleErrors ?? []) collect(route, message);
|
|
80
|
+
for (const page of pages) {
|
|
81
|
+
for (const message of page.consoleErrors ?? []) collect(page.route, message);
|
|
82
|
+
}
|
|
83
|
+
for (const [route, messages] of violations) {
|
|
84
|
+
add(
|
|
85
|
+
'warning',
|
|
86
|
+
'csp-console-violation',
|
|
87
|
+
`${route} logged ${messages.size} Content-Security-Policy violation(s)`,
|
|
88
|
+
{ route },
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
for (const page of pages) {
|
|
92
|
+
if (page.meta?.inlineStyleAttributes > 0) {
|
|
93
|
+
add(
|
|
94
|
+
'warning',
|
|
95
|
+
'inline-style-attributes',
|
|
96
|
+
`${page.route} has ${page.meta.inlineStyleAttributes} inline style attribute(s); CSP hashes cannot cover them`,
|
|
97
|
+
{ route: page.route },
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// A finding can be produced by more than one check; keep the first copy.
|
|
104
|
+
function removeDuplicateFindings({ errors, warnings }) {
|
|
105
|
+
for (const list of [errors, warnings]) {
|
|
106
|
+
const seen = new Set();
|
|
107
|
+
for (let index = list.length - 1; index >= 0; index -= 1) {
|
|
108
|
+
const finding = list[index];
|
|
109
|
+
const key = `${finding.rule}|${finding.route ?? ''}|${finding.message}`;
|
|
110
|
+
if (seen.has(key)) list.splice(index, 1);
|
|
111
|
+
else seen.add(key);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { routeToFile } from '../output.js';
|
|
4
|
+
import { matchesPattern, normaliseRoute } from '../routes.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Audits every internal link the rendered pages emitted and warns about indexable pages
|
|
8
|
+
* no other page links to.
|
|
9
|
+
*/
|
|
10
|
+
export async function auditLinks({ report, config, outputDir, add }) {
|
|
11
|
+
const routes = new Set(report.routeDetails.map((detail) => detail.route));
|
|
12
|
+
const files = new Set(report.files.map((file) => file.file).filter(Boolean));
|
|
13
|
+
const cache = new Map();
|
|
14
|
+
const seenPairs = new Set();
|
|
15
|
+
let checkedLinks = 0;
|
|
16
|
+
|
|
17
|
+
const exists = async (target) => {
|
|
18
|
+
if (cache.has(target)) return cache.get(target);
|
|
19
|
+
const result = await fs
|
|
20
|
+
.stat(path.join(outputDir, target))
|
|
21
|
+
.then((stats) => stats.isFile())
|
|
22
|
+
.catch(() => false);
|
|
23
|
+
cache.set(target, result);
|
|
24
|
+
return result;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
for (const link of report.links) {
|
|
28
|
+
const key = `${link.from}|${link.to}`;
|
|
29
|
+
if (seenPairs.has(key)) continue;
|
|
30
|
+
seenPairs.add(key);
|
|
31
|
+
if (config.auditIgnoreLinks.some((pattern) => matchesPattern(link.to, pattern))) continue;
|
|
32
|
+
checkedLinks += 1;
|
|
33
|
+
const route = normaliseRoute(link.to);
|
|
34
|
+
if (routes.has(route)) continue;
|
|
35
|
+
const file = routeToFile(route, config);
|
|
36
|
+
if (files.has(file)) continue;
|
|
37
|
+
const asset = route.startsWith('/') ? route.slice(1) : route;
|
|
38
|
+
if (asset && (await exists(asset))) continue;
|
|
39
|
+
add('error', 'broken-internal-link', `${link.from} links to missing ${link.to}`, {
|
|
40
|
+
from: link.from,
|
|
41
|
+
to: link.to,
|
|
42
|
+
reason: 'missing',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return { checked: checkedLinks };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Warns about indexable pages no rendered page links to. A link from a page outside the
|
|
50
|
+
* include list is legitimately absent, so this stays a warning.
|
|
51
|
+
*/
|
|
52
|
+
export function auditOrphans({ pages, report, add }) {
|
|
53
|
+
const linked = new Set();
|
|
54
|
+
for (const link of report.links) linked.add(normaliseRoute(link.to));
|
|
55
|
+
for (const page of pages) {
|
|
56
|
+
if (page.route === '/' || page.notFound || page.noindex) continue;
|
|
57
|
+
if (!linked.has(page.route)) {
|
|
58
|
+
add('warning', 'orphan-page', `${page.route} is not linked from another page`, {
|
|
59
|
+
route: page.route,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { toPublicPath } from '../routes.js';
|
|
2
|
+
import { pagePath } from '../url-policy.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Checks the metadata a crawler reads on every written page: titles, descriptions,
|
|
6
|
+
* canonicals, headings, build-origin leakage and duplicate values across routes.
|
|
7
|
+
*/
|
|
8
|
+
export function auditMetadata({ pages, config, add }) {
|
|
9
|
+
const titles = new Map();
|
|
10
|
+
const descriptions = new Map();
|
|
11
|
+
for (const page of pages) {
|
|
12
|
+
const meta = page.meta ?? {};
|
|
13
|
+
if (!meta.title) {
|
|
14
|
+
add('warning', 'missing-title', `no <title> on ${page.route}`, { route: page.route });
|
|
15
|
+
}
|
|
16
|
+
if (!meta.description) {
|
|
17
|
+
add('warning', 'missing-description', `no meta description on ${page.route}`, {
|
|
18
|
+
route: page.route,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
if (!meta.canonical) {
|
|
22
|
+
add('warning', 'missing-canonical', `no canonical link on ${page.route}`, {
|
|
23
|
+
route: page.route,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
if (meta.titleCount > 1) {
|
|
27
|
+
add('error', 'multiple-titles', `${page.route} contains more than one <title>`, {
|
|
28
|
+
route: page.route,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
if (meta.h1Count !== 1) {
|
|
32
|
+
add('warning', 'heading-count', `${page.route} has ${meta.h1Count} <h1> element(s)`, {
|
|
33
|
+
route: page.route,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
if ((page.buildOriginUrls ?? []).length > 0) {
|
|
37
|
+
add(
|
|
38
|
+
'warning',
|
|
39
|
+
'build-origin-metadata',
|
|
40
|
+
`${page.route} still points at the build server: ${page.buildOriginUrls.join(', ')}`,
|
|
41
|
+
{ route: page.route },
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
if (meta.title) titles.set(meta.title, [...(titles.get(meta.title) ?? []), page.route]);
|
|
45
|
+
if (meta.description) {
|
|
46
|
+
descriptions.set(meta.description, [
|
|
47
|
+
...(descriptions.get(meta.description) ?? []),
|
|
48
|
+
page.route,
|
|
49
|
+
]);
|
|
50
|
+
}
|
|
51
|
+
if (config.metadata?.siteUrl && meta.canonical) {
|
|
52
|
+
const expected = expectedPageUrl(page.route, config);
|
|
53
|
+
if (expected && meta.canonical !== expected) {
|
|
54
|
+
add(
|
|
55
|
+
'warning',
|
|
56
|
+
'canonical-mismatch',
|
|
57
|
+
`${page.route} canonical is ${meta.canonical}, expected ${expected}`,
|
|
58
|
+
{ route: page.route },
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
for (const [title, routes] of titles) {
|
|
64
|
+
if (routes.length < 2) continue;
|
|
65
|
+
add('error', 'duplicate-title', `routes share the title "${title}": ${routes.join(', ')}`, {
|
|
66
|
+
routes,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
for (const [description, routes] of descriptions) {
|
|
70
|
+
if (routes.length < 2) continue;
|
|
71
|
+
add(
|
|
72
|
+
'warning',
|
|
73
|
+
'duplicate-description',
|
|
74
|
+
`routes share the meta description "${description}": ${routes.join(', ')}`,
|
|
75
|
+
{ routes },
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Resolves the public URL a page should canonicalise and be listed at.
|
|
81
|
+
export function expectedPageUrl(route, config) {
|
|
82
|
+
if (!config.metadata?.siteUrl) return null;
|
|
83
|
+
const path = pagePath(route, {
|
|
84
|
+
flatOutput: config.flatOutput,
|
|
85
|
+
trailingSlash: config.metadata.trailingSlash,
|
|
86
|
+
});
|
|
87
|
+
return `${config.metadata.siteUrl.replace(/\/$/, '')}${toPublicPath(path, config.base)}`;
|
|
88
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { expectedPageUrl } from './metadata.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ensures the sitemap and the indexable pages describe the same set of URLs. A page
|
|
5
|
+
* missing from the sitemap, a loc with no page, or a noindex page listed are all errors.
|
|
6
|
+
*/
|
|
7
|
+
export function auditSitemap({ pages, config, report, add }) {
|
|
8
|
+
if (!config.sitemap) return;
|
|
9
|
+
const indexable = new Map();
|
|
10
|
+
const noindex = new Map();
|
|
11
|
+
for (const page of pages) {
|
|
12
|
+
const loc = expectedPageUrl(page.route, config);
|
|
13
|
+
if (!loc) continue;
|
|
14
|
+
if (page.noindex || page.notFound) {
|
|
15
|
+
noindex.set(loc, page.route);
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
indexable.set(loc, page.route);
|
|
19
|
+
}
|
|
20
|
+
const locs = new Set(report.sitemap?.locs ?? []);
|
|
21
|
+
for (const [loc, route] of indexable) {
|
|
22
|
+
if (!locs.has(loc)) {
|
|
23
|
+
add('error', 'sitemap-missing-page', `${route} is missing from the sitemap`, { route });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
for (const loc of locs) {
|
|
27
|
+
if (noindex.has(loc)) {
|
|
28
|
+
add('error', 'sitemap-noindex-page', `sitemap lists noindex ${noindex.get(loc)}`, {
|
|
29
|
+
route: noindex.get(loc),
|
|
30
|
+
});
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (!indexable.has(loc)) {
|
|
34
|
+
add('error', 'sitemap-unknown-page', `sitemap lists ${loc} with no indexable page`, {
|
|
35
|
+
to: loc,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/core/config.js
CHANGED
|
@@ -11,7 +11,8 @@ export const MAX_DEFAULT_CONCURRENCY = 8;
|
|
|
11
11
|
const LOG_LEVELS = ['silent', 'error', 'warn', 'info', 'debug'];
|
|
12
12
|
const BROWSER_CHOICES = ['auto', 'chrome', 'msedge', 'chromium'];
|
|
13
13
|
const INLINE_CSS_STRATEGIES = [false, true, 'inline', 'critical'];
|
|
14
|
-
const CSP_MODES = ['off', 'strict'];
|
|
14
|
+
const CSP_MODES = ['off', 'strict', 'hash'];
|
|
15
|
+
const FRAMEWORK_CHOICES = ['auto', 'react', 'vue', 'svelte', 'preact'];
|
|
15
16
|
const SAVE_AS_CHOICES = ['html', 'png', 'jpeg'];
|
|
16
17
|
const CRITICAL_CSS_PRELOAD = ['body', 'media', 'swap', 'swap-high', 'swap-low', 'js', 'js-lazy'];
|
|
17
18
|
const NULLABLE_KEYS = [
|
|
@@ -27,6 +28,10 @@ const NULLABLE_KEYS = [
|
|
|
27
28
|
'browserDownloadVersion',
|
|
28
29
|
'url',
|
|
29
30
|
'serveCmd',
|
|
31
|
+
'reportPath',
|
|
32
|
+
'routesFile',
|
|
33
|
+
'routesFromSitemap',
|
|
34
|
+
'transformHtml',
|
|
30
35
|
];
|
|
31
36
|
const ARRAY_KEYS = [
|
|
32
37
|
'include',
|
|
@@ -35,6 +40,9 @@ const ARRAY_KEYS = [
|
|
|
35
40
|
'browserArgs',
|
|
36
41
|
'ignoreForPreload',
|
|
37
42
|
'removeAttributes',
|
|
43
|
+
'auditIgnoreLinks',
|
|
44
|
+
'routePriority',
|
|
45
|
+
'routeOverrides',
|
|
38
46
|
];
|
|
39
47
|
|
|
40
48
|
export const DEFAULTS = Object.freeze({
|
|
@@ -87,11 +95,26 @@ export const DEFAULTS = Object.freeze({
|
|
|
87
95
|
preloadImages: false,
|
|
88
96
|
preloadManifest: false,
|
|
89
97
|
sitemap: false,
|
|
98
|
+
imageSitemap: false,
|
|
90
99
|
ignoreForPreload: ['service-worker.js'],
|
|
91
100
|
cacheAjaxRequests: false,
|
|
92
101
|
maxCachedBytes: 5 * 1024 * 1024,
|
|
93
102
|
flatOutput: false,
|
|
94
103
|
notFoundRoute: '/404',
|
|
104
|
+
audit: true,
|
|
105
|
+
failOnAuditError: false,
|
|
106
|
+
auditIgnoreLinks: [],
|
|
107
|
+
reportPath: null,
|
|
108
|
+
routePriority: [],
|
|
109
|
+
routeOverrides: [],
|
|
110
|
+
transformHtml: null,
|
|
111
|
+
routesFile: null,
|
|
112
|
+
routesFromSitemap: null,
|
|
113
|
+
preloadLinks: false,
|
|
114
|
+
retries: 0,
|
|
115
|
+
retryDelay: 500,
|
|
116
|
+
traceOnError: false,
|
|
117
|
+
framework: 'auto',
|
|
95
118
|
verify: true,
|
|
96
119
|
verifyChangedOnly: false,
|
|
97
120
|
failOnHydrationError: false,
|
|
@@ -128,6 +151,9 @@ for (const key of [
|
|
|
128
151
|
'browserArgs',
|
|
129
152
|
'ignoreForPreload',
|
|
130
153
|
'removeAttributes',
|
|
154
|
+
'auditIgnoreLinks',
|
|
155
|
+
'routePriority',
|
|
156
|
+
'routeOverrides',
|
|
131
157
|
]) {
|
|
132
158
|
Object.freeze(DEFAULTS[key]);
|
|
133
159
|
}
|
|
@@ -156,9 +182,11 @@ export function resolveConfig(userOptions = {}) {
|
|
|
156
182
|
!(config.include.length === 1 && config.include[0] === '/');
|
|
157
183
|
// The CSP dial decides the defaults for the pieces that can move. Explicit options still
|
|
158
184
|
// win, so a project can take 'strict' and put one part back.
|
|
159
|
-
if (userOptions.externalScripts === undefined)
|
|
185
|
+
if (userOptions.externalScripts === undefined) {
|
|
186
|
+
config.externalScripts = config.csp === 'strict' || config.csp === 'hash';
|
|
187
|
+
}
|
|
160
188
|
if (userOptions.criticalCssPreload === undefined) {
|
|
161
|
-
config.criticalCssPreload = config.csp === 'strict' ? false : 'media';
|
|
189
|
+
config.criticalCssPreload = config.csp === 'strict' || config.csp === 'hash' ? false : 'media';
|
|
162
190
|
}
|
|
163
191
|
config.warnings = [];
|
|
164
192
|
validateConfig(config);
|
|
@@ -201,6 +229,32 @@ function addConfigWarnings(config, userOptions) {
|
|
|
201
229
|
);
|
|
202
230
|
}
|
|
203
231
|
}
|
|
232
|
+
if (userOptions.framework !== undefined) {
|
|
233
|
+
warn(
|
|
234
|
+
`framework has no effect because hydration verification is skipped with ${config.serveCmd ? 'serveCmd' : `saveAs: '${config.saveAs}'`}`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (!config.verify && userOptions.framework !== undefined) {
|
|
239
|
+
warn('framework has no effect because verify is false');
|
|
240
|
+
}
|
|
241
|
+
if (!config.audit && config.failOnAuditError) {
|
|
242
|
+
warn('failOnAuditError has no effect because audit is false');
|
|
243
|
+
}
|
|
244
|
+
if (!config.audit && config.auditIgnoreLinks.length > 0) {
|
|
245
|
+
warn('auditIgnoreLinks has no effect because audit is false');
|
|
246
|
+
}
|
|
247
|
+
if (config.preloadLinks && config.saveAs !== 'html') {
|
|
248
|
+
warn(`preloadLinks has no effect when saveAs is '${config.saveAs}'`);
|
|
249
|
+
}
|
|
250
|
+
if (config.preloadLinks && config.removeScriptTags) {
|
|
251
|
+
warn('preloadLinks skips script hints because removeScriptTags strips every script');
|
|
252
|
+
}
|
|
253
|
+
if (config.imageSitemap && !config.sitemap) {
|
|
254
|
+
warn('imageSitemap has no effect because sitemap is false');
|
|
255
|
+
}
|
|
256
|
+
if (config.traceOnError && config.dryRun) {
|
|
257
|
+
warn('traceOnError has no effect in dry-run mode');
|
|
204
258
|
}
|
|
205
259
|
if (
|
|
206
260
|
userOptions.criticalCssPreload !== undefined &&
|
|
@@ -221,7 +275,7 @@ function addConfigWarnings(config, userOptions) {
|
|
|
221
275
|
);
|
|
222
276
|
}
|
|
223
277
|
if (
|
|
224
|
-
config.csp === 'strict' &&
|
|
278
|
+
(config.csp === 'strict' || config.csp === 'hash') &&
|
|
225
279
|
userOptions.criticalCssPreload !== undefined &&
|
|
226
280
|
config.criticalCssPreload !== false
|
|
227
281
|
) {
|
|
@@ -305,9 +359,25 @@ export function validateConfig(config) {
|
|
|
305
359
|
stringList(config.browserArgs, 'browserArgs');
|
|
306
360
|
stringList(config.ignoreForPreload, 'ignoreForPreload');
|
|
307
361
|
patternList(config.removeAttributes, 'removeAttributes');
|
|
308
|
-
|
|
362
|
+
patternList(config.auditIgnoreLinks, 'auditIgnoreLinks');
|
|
363
|
+
patternList(config.routePriority, 'routePriority');
|
|
364
|
+
for (const key of [
|
|
365
|
+
'readyFlag',
|
|
366
|
+
'readySelector',
|
|
367
|
+
'waitFor',
|
|
368
|
+
'notFoundRoute',
|
|
369
|
+
'reportPath',
|
|
370
|
+
'routesFile',
|
|
371
|
+
'routesFromSitemap',
|
|
372
|
+
]) {
|
|
373
|
+
nullableString(key);
|
|
374
|
+
}
|
|
309
375
|
for (const key of ['userAgent', 'destination', 'browserDownloadVersion']) nullableString(key);
|
|
376
|
+
if (config.transformHtml !== null && typeof config.transformHtml !== 'function') {
|
|
377
|
+
throw new TypeError('transformHtml must be a function or null');
|
|
378
|
+
}
|
|
310
379
|
for (const key of BOOLEAN_KEYS) booleanOption(key);
|
|
380
|
+
validateRouteOverrides(config.routeOverrides);
|
|
311
381
|
|
|
312
382
|
if (config.browserDownloadHash !== null && !/^[a-f0-9]{64}$/i.test(config.browserDownloadHash)) {
|
|
313
383
|
throw new TypeError('browserDownloadHash must be a 64 character SHA-256 hex digest');
|
|
@@ -333,6 +403,12 @@ export function validateConfig(config) {
|
|
|
333
403
|
if (config.maxRoutes !== null && (!Number.isInteger(config.maxRoutes) || config.maxRoutes < 1)) {
|
|
334
404
|
throw new TypeError('maxRoutes must be null or a positive integer');
|
|
335
405
|
}
|
|
406
|
+
if (!Number.isInteger(config.retries) || config.retries < 0) {
|
|
407
|
+
throw new TypeError('retries must be a non-negative integer');
|
|
408
|
+
}
|
|
409
|
+
if (!Number.isFinite(config.retryDelay) || config.retryDelay <= 0) {
|
|
410
|
+
throw new TypeError('retryDelay must be a positive number of milliseconds');
|
|
411
|
+
}
|
|
336
412
|
if (!Number.isFinite(config.maxCachedBytes) || config.maxCachedBytes <= 0) {
|
|
337
413
|
throw new TypeError('maxCachedBytes must be a positive number of bytes');
|
|
338
414
|
}
|
|
@@ -349,6 +425,9 @@ export function validateConfig(config) {
|
|
|
349
425
|
`browser must be one of: ${BROWSER_CHOICES.join(', ')} or a path to an executable`,
|
|
350
426
|
);
|
|
351
427
|
}
|
|
428
|
+
if (!FRAMEWORK_CHOICES.includes(config.framework)) {
|
|
429
|
+
throw new TypeError(`framework must be one of: ${FRAMEWORK_CHOICES.join(', ')}`);
|
|
430
|
+
}
|
|
352
431
|
if (config.storageState !== null) {
|
|
353
432
|
if (typeof config.storageState !== 'string' || !fs.existsSync(config.storageState)) {
|
|
354
433
|
throw new TypeError('storageState must point to an existing Playwright storage state file');
|
|
@@ -405,6 +484,51 @@ export function validateConfig(config) {
|
|
|
405
484
|
}
|
|
406
485
|
}
|
|
407
486
|
|
|
487
|
+
// Per-route overrides may only change the settings that make sense for one route.
|
|
488
|
+
const ROUTE_OVERRIDE_KEYS = new Set([
|
|
489
|
+
'match',
|
|
490
|
+
'timeout',
|
|
491
|
+
'waitFor',
|
|
492
|
+
'readySelector',
|
|
493
|
+
'readyFlag',
|
|
494
|
+
'crawl',
|
|
495
|
+
'storageState',
|
|
496
|
+
]);
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Validates per-route overrides. Matching patterns stay strings or RegExp; every
|
|
500
|
+
* override key is checked so a typo cannot silently leave the base config in place.
|
|
501
|
+
*/
|
|
502
|
+
function validateRouteOverrides(overrides) {
|
|
503
|
+
for (const override of overrides) {
|
|
504
|
+
if (typeof override !== 'object' || override === null || Array.isArray(override)) {
|
|
505
|
+
throw new TypeError('routeOverrides entries must be objects');
|
|
506
|
+
}
|
|
507
|
+
for (const key of Object.keys(override)) {
|
|
508
|
+
if (!ROUTE_OVERRIDE_KEYS.has(key)) {
|
|
509
|
+
throw new TypeError(`routeOverrides.${key} is not a recognised option`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (!(typeof override.match === 'string' || override.match instanceof RegExp)) {
|
|
513
|
+
throw new TypeError('routeOverrides entries need a string or RegExp match');
|
|
514
|
+
}
|
|
515
|
+
if (override.timeout !== undefined) {
|
|
516
|
+
if (!Number.isFinite(override.timeout) || override.timeout <= 0) {
|
|
517
|
+
throw new TypeError('routeOverrides.timeout must be a positive number of milliseconds');
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
for (const key of ['waitFor', 'readySelector', 'readyFlag', 'storageState']) {
|
|
521
|
+
const value = override[key];
|
|
522
|
+
if (value !== undefined && typeof value !== 'string') {
|
|
523
|
+
throw new TypeError(`routeOverrides.${key} must be a string`);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (override.crawl !== undefined && typeof override.crawl !== 'boolean') {
|
|
527
|
+
throw new TypeError('routeOverrides.crawl must be a boolean');
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
408
532
|
// The metadata keys the head component reads, so a typo is reported instead of ignored.
|
|
409
533
|
const METADATA_KEYS = new Set([
|
|
410
534
|
'siteUrl',
|