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.
- package/CHANGELOG.md +100 -0
- package/README.md +281 -101
- package/assets/snappy-logo.png +0 -0
- package/docs/limitations.md +94 -0
- package/docs/react-snap-migration.md +45 -0
- package/docs/report.md +135 -0
- package/package.json +4 -1
- package/src/cli-init.js +205 -0
- package/src/cli-options.js +258 -0
- package/src/cli.js +128 -309
- package/src/core/artifacts.js +2 -0
- package/src/core/audit/index.js +114 -0
- package/src/core/audit/links.js +74 -0
- package/src/core/audit/metadata.js +197 -0
- package/src/core/audit/sitemap.js +39 -0
- package/src/core/clock.js +60 -0
- package/src/core/config-validation.js +557 -0
- package/src/core/config-warnings.js +150 -0
- package/src/core/config.js +189 -136
- package/src/core/crawl.js +362 -0
- package/src/core/csp.js +138 -0
- package/src/core/destination.js +23 -7
- package/src/core/frameworks.js +94 -0
- package/src/core/hydration-diff.js +93 -0
- package/src/core/hydration-policy.js +38 -0
- package/src/core/init.js +143 -30
- package/src/core/inline-css.js +52 -9
- package/src/core/locale-context.js +63 -0
- package/src/core/locales.js +385 -0
- package/src/core/normalize.js +37 -6
- package/src/core/page-content.js +57 -7
- package/src/core/page-meta.js +122 -0
- package/src/core/preload-links.js +133 -0
- package/src/core/preload-manifest.js +6 -2
- package/src/core/render.js +126 -35
- package/src/core/report.js +116 -0
- package/src/core/resources.js +5 -1
- package/src/core/route-config.js +17 -0
- package/src/core/route-sources.js +220 -0
- package/src/core/run.js +156 -131
- package/src/core/seo-locale.js +234 -0
- package/src/core/settle.js +2 -4
- package/src/core/sitemap.js +90 -14
- package/src/core/text-separators.js +11 -7
- package/src/core/verify.js +164 -71
- package/src/core/write.js +314 -52
- package/src/head/index.js +104 -6
- package/src/head/manager.js +304 -48
- package/src/head/tags.js +349 -30
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reports options that do nothing in the current combination, so a build does not
|
|
3
|
+
* quietly skip a gate the caller believes is active.
|
|
4
|
+
* @param {object} config
|
|
5
|
+
* @param {object} userOptions
|
|
6
|
+
* @param {object} defaults
|
|
7
|
+
*/
|
|
8
|
+
export function addConfigWarnings(config, userOptions, defaults) {
|
|
9
|
+
const warn = (message) => config.warnings.push(message);
|
|
10
|
+
for (const key of Object.keys(userOptions)) {
|
|
11
|
+
if (!Object.hasOwn(defaults, key)) {
|
|
12
|
+
warn(`unknown option "${key}" is ignored; check the spelling`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
if (!config.verify) {
|
|
16
|
+
for (const key of [
|
|
17
|
+
'verifyChangedOnly',
|
|
18
|
+
'failOnHydrationError',
|
|
19
|
+
'failOnRerender',
|
|
20
|
+
'failOnUndetected',
|
|
21
|
+
'failOnVerificationError',
|
|
22
|
+
]) {
|
|
23
|
+
if (config[key]) warn(`${key} has no effect because verify is false`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (config.verify && (config.serveCmd || config.saveAs !== 'html')) {
|
|
27
|
+
for (const key of [
|
|
28
|
+
'failOnHydrationError',
|
|
29
|
+
'failOnRerender',
|
|
30
|
+
'failOnUndetected',
|
|
31
|
+
'failOnVerificationError',
|
|
32
|
+
]) {
|
|
33
|
+
if (config[key]) {
|
|
34
|
+
warn(
|
|
35
|
+
`${key} has no effect because hydration verification is skipped with ${config.serveCmd ? 'serveCmd' : `saveAs: '${config.saveAs}'`}`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (userOptions.framework !== undefined) {
|
|
40
|
+
warn(
|
|
41
|
+
`framework has no effect because hydration verification is skipped with ${config.serveCmd ? 'serveCmd' : `saveAs: '${config.saveAs}'`}`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (!config.verify && userOptions.framework !== undefined) {
|
|
46
|
+
warn('framework has no effect because verify is false');
|
|
47
|
+
}
|
|
48
|
+
if (!config.verify && config.reuseVerificationContext) {
|
|
49
|
+
warn('reuseVerificationContext has no effect because verify is false');
|
|
50
|
+
}
|
|
51
|
+
if (config.reuseVerificationContext && config.storageState) {
|
|
52
|
+
warn(
|
|
53
|
+
'reuseVerificationContext shares browser storage across routes; authenticated routes may see state from another route',
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (!config.audit && config.failOnAuditError) {
|
|
57
|
+
warn('failOnAuditError has no effect because audit is false');
|
|
58
|
+
}
|
|
59
|
+
if (!config.audit && config.auditIgnoreLinks.length > 0) {
|
|
60
|
+
warn('auditIgnoreLinks has no effect because audit is false');
|
|
61
|
+
}
|
|
62
|
+
if (config.preloadLinks && config.saveAs !== 'html') {
|
|
63
|
+
warn(`preloadLinks has no effect when saveAs is '${config.saveAs}'`);
|
|
64
|
+
}
|
|
65
|
+
if (config.preloadLinks && config.removeScriptTags) {
|
|
66
|
+
warn('preloadLinks skips script hints because removeScriptTags strips every script');
|
|
67
|
+
}
|
|
68
|
+
if (config.imageSitemap && !config.sitemap) {
|
|
69
|
+
warn('imageSitemap has no effect because sitemap is false');
|
|
70
|
+
}
|
|
71
|
+
if (config.traceOnError && config.dryRun) {
|
|
72
|
+
warn('traceOnError has no effect in dry-run mode');
|
|
73
|
+
}
|
|
74
|
+
if (
|
|
75
|
+
userOptions.criticalCssPreload !== undefined &&
|
|
76
|
+
config.criticalCssPreload !== false &&
|
|
77
|
+
config.inlineCss !== 'critical'
|
|
78
|
+
) {
|
|
79
|
+
warn('criticalCssPreload has no effect unless inlineCss is "critical"');
|
|
80
|
+
}
|
|
81
|
+
if (config.minifyCss && !config.minifyHtml && !config.inlineCss) {
|
|
82
|
+
warn('minifyCss has no effect unless minifyHtml or inlineCss is enabled');
|
|
83
|
+
}
|
|
84
|
+
if (config.allowedHosts.length > 0 && !config.blockThirdParty) {
|
|
85
|
+
warn('allowedHosts has no effect unless blockThirdParty is true');
|
|
86
|
+
}
|
|
87
|
+
if (config.csp === 'strict' && config.inlineCss) {
|
|
88
|
+
warn(
|
|
89
|
+
"csp controls script-src; inlineCss emits inline styles, which need style-src 'unsafe-inline' or hashes",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
if (
|
|
93
|
+
(config.csp === 'strict' || config.csp === 'hash') &&
|
|
94
|
+
userOptions.criticalCssPreload !== undefined &&
|
|
95
|
+
config.criticalCssPreload !== false
|
|
96
|
+
) {
|
|
97
|
+
warn(
|
|
98
|
+
"criticalCssPreload emits an inline onload handler that a script-src 'self' policy blocks",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if (config.removeStyleTags && config.inlineCss === 'critical') {
|
|
102
|
+
warn('removeStyleTags removes CSS-in-JS styles before critical CSS is extracted');
|
|
103
|
+
}
|
|
104
|
+
if (config.sitemap && config.saveAs !== 'html') {
|
|
105
|
+
warn(`sitemap has no effect when saveAs is '${config.saveAs}'`);
|
|
106
|
+
}
|
|
107
|
+
if (config.freezeTime && config.verifyChangedOnly) {
|
|
108
|
+
warn(
|
|
109
|
+
'freezeTime with verifyChangedOnly makes output timestamps vary per run; use an explicit ISO time or SOURCE_DATE_EPOCH for stable files',
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
if (config.reuseVerificationContext && (config.locales || config.seoLocale)) {
|
|
113
|
+
warn(
|
|
114
|
+
'reuseVerificationContext is ignored when locales or seoLocale change the browser context between routes',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (config.locales?.mode === 'path' && config.seoLocale?.code) {
|
|
118
|
+
warn('seoLocale.code is ignored in path mode; each locale supplies its own code');
|
|
119
|
+
}
|
|
120
|
+
if (config.locales?.mode === 'path' && config.locales.active) {
|
|
121
|
+
warn('locales.active is ignored in path mode; each route supplies its own locale');
|
|
122
|
+
}
|
|
123
|
+
if (config.seoLocale && config.seoLocale.inject === false) {
|
|
124
|
+
warn(
|
|
125
|
+
'seoLocale.inject is false: the generated locale is in the HTML, but visitors with different storage may see another locale after hydration',
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (config.seoLocale?.overwrite === false && config.storageState) {
|
|
129
|
+
warn(
|
|
130
|
+
'seoLocale.overwrite is false with storageState: an existing locale value may win and the rendered locale may differ',
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (config.seoLocale?.cookie?.secure && config.metadata?.siteUrl?.startsWith('http://')) {
|
|
134
|
+
warn(
|
|
135
|
+
'seoLocale.cookie.secure is true while metadata.siteUrl is HTTP; preview browsers will not set the cookie',
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (
|
|
139
|
+
config.seoLocale?.cookie?.sameSite?.toLowerCase() === 'none' &&
|
|
140
|
+
!config.seoLocale.cookie.secure
|
|
141
|
+
) {
|
|
142
|
+
warn('seoLocale.cookie.sameSite is None without Secure; browsers reject that cookie');
|
|
143
|
+
}
|
|
144
|
+
const metadataBase = config.metadata?.base;
|
|
145
|
+
if (metadataBase && metadataBase !== config.base) {
|
|
146
|
+
warn(
|
|
147
|
+
`metadata.base (${metadataBase}) differs from base (${config.base}); canonical, social URLs and routing will disagree`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
package/src/core/config.js
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
|
+
import {
|
|
4
|
+
validateClock,
|
|
5
|
+
validateHydrationConfig,
|
|
6
|
+
validateLocales,
|
|
7
|
+
validateMetadata,
|
|
8
|
+
validateRouteOverrides,
|
|
9
|
+
validateSeoLocale,
|
|
10
|
+
} from './config-validation.js';
|
|
11
|
+
import { addConfigWarnings } from './config-warnings.js';
|
|
3
12
|
|
|
4
13
|
/**
|
|
5
14
|
* Upper bound applied to the derived default concurrency. Browser pages are memory
|
|
@@ -11,7 +20,8 @@ export const MAX_DEFAULT_CONCURRENCY = 8;
|
|
|
11
20
|
const LOG_LEVELS = ['silent', 'error', 'warn', 'info', 'debug'];
|
|
12
21
|
const BROWSER_CHOICES = ['auto', 'chrome', 'msedge', 'chromium'];
|
|
13
22
|
const INLINE_CSS_STRATEGIES = [false, true, 'inline', 'critical'];
|
|
14
|
-
const CSP_MODES = ['off', 'strict'];
|
|
23
|
+
const CSP_MODES = ['off', 'strict', 'hash'];
|
|
24
|
+
const FRAMEWORK_CHOICES = ['auto', 'react', 'vue', 'svelte', 'preact'];
|
|
15
25
|
const SAVE_AS_CHOICES = ['html', 'png', 'jpeg'];
|
|
16
26
|
const CRITICAL_CSS_PRELOAD = ['body', 'media', 'swap', 'swap-high', 'swap-low', 'js', 'js-lazy'];
|
|
17
27
|
const NULLABLE_KEYS = [
|
|
@@ -27,6 +37,13 @@ const NULLABLE_KEYS = [
|
|
|
27
37
|
'browserDownloadVersion',
|
|
28
38
|
'url',
|
|
29
39
|
'serveCmd',
|
|
40
|
+
'reportPath',
|
|
41
|
+
'routesFile',
|
|
42
|
+
'routesFromSitemap',
|
|
43
|
+
'transformHtml',
|
|
44
|
+
'timezone',
|
|
45
|
+
'locales',
|
|
46
|
+
'seoLocale',
|
|
30
47
|
];
|
|
31
48
|
const ARRAY_KEYS = [
|
|
32
49
|
'include',
|
|
@@ -35,6 +52,9 @@ const ARRAY_KEYS = [
|
|
|
35
52
|
'browserArgs',
|
|
36
53
|
'ignoreForPreload',
|
|
37
54
|
'removeAttributes',
|
|
55
|
+
'auditIgnoreLinks',
|
|
56
|
+
'routePriority',
|
|
57
|
+
'routeOverrides',
|
|
38
58
|
];
|
|
39
59
|
|
|
40
60
|
export const DEFAULTS = Object.freeze({
|
|
@@ -87,13 +107,35 @@ export const DEFAULTS = Object.freeze({
|
|
|
87
107
|
preloadImages: false,
|
|
88
108
|
preloadManifest: false,
|
|
89
109
|
sitemap: false,
|
|
110
|
+
imageSitemap: false,
|
|
90
111
|
ignoreForPreload: ['service-worker.js'],
|
|
91
112
|
cacheAjaxRequests: false,
|
|
92
113
|
maxCachedBytes: 5 * 1024 * 1024,
|
|
93
114
|
flatOutput: false,
|
|
94
115
|
notFoundRoute: '/404',
|
|
116
|
+
audit: true,
|
|
117
|
+
failOnAuditError: false,
|
|
118
|
+
auditIgnoreLinks: [],
|
|
119
|
+
reportPath: null,
|
|
120
|
+
routePriority: [],
|
|
121
|
+
routeOverrides: [],
|
|
122
|
+
transformHtml: null,
|
|
123
|
+
routesFile: null,
|
|
124
|
+
routesFromSitemap: null,
|
|
125
|
+
preloadLinks: false,
|
|
126
|
+
retries: 0,
|
|
127
|
+
retryDelay: 500,
|
|
128
|
+
traceOnError: false,
|
|
129
|
+
framework: 'auto',
|
|
95
130
|
verify: true,
|
|
96
131
|
verifyChangedOnly: false,
|
|
132
|
+
reuseVerificationContext: false,
|
|
133
|
+
freezeTime: false,
|
|
134
|
+
timezone: null,
|
|
135
|
+
randomSeed: null,
|
|
136
|
+
hydration: Object.freeze({ allowRerender: Object.freeze([]), skip: Object.freeze([]) }),
|
|
137
|
+
locales: null,
|
|
138
|
+
seoLocale: null,
|
|
97
139
|
failOnHydrationError: false,
|
|
98
140
|
failOnRerender: false,
|
|
99
141
|
failOnPageError: false,
|
|
@@ -117,7 +159,7 @@ export const DEFAULTS = Object.freeze({
|
|
|
117
159
|
|
|
118
160
|
// Options whose default is boolean but which accept other shapes, so their own validators
|
|
119
161
|
// below decide what is allowed.
|
|
120
|
-
const MULTI_TYPE_KEYS = new Set(['inlineCss', 'minifyHtml', 'minifyCss']);
|
|
162
|
+
const MULTI_TYPE_KEYS = new Set(['inlineCss', 'minifyHtml', 'minifyCss', 'freezeTime']);
|
|
121
163
|
|
|
122
164
|
// Freeze the nested list defaults as well, so a consumer cannot mutate the defaults
|
|
123
165
|
// used by every later run in the same process.
|
|
@@ -128,6 +170,9 @@ for (const key of [
|
|
|
128
170
|
'browserArgs',
|
|
129
171
|
'ignoreForPreload',
|
|
130
172
|
'removeAttributes',
|
|
173
|
+
'auditIgnoreLinks',
|
|
174
|
+
'routePriority',
|
|
175
|
+
'routeOverrides',
|
|
131
176
|
]) {
|
|
132
177
|
Object.freeze(DEFAULTS[key]);
|
|
133
178
|
}
|
|
@@ -146,6 +191,18 @@ export function resolveConfig(userOptions = {}) {
|
|
|
146
191
|
const config = { ...DEFAULTS, ...userOptions };
|
|
147
192
|
for (const key of NULLABLE_KEYS) config[key] = config[key] ?? null;
|
|
148
193
|
for (const key of ARRAY_KEYS) config[key] = toArray(config[key]);
|
|
194
|
+
if (
|
|
195
|
+
userOptions.hydration !== undefined &&
|
|
196
|
+
(typeof userOptions.hydration !== 'object' ||
|
|
197
|
+
userOptions.hydration === null ||
|
|
198
|
+
Array.isArray(userOptions.hydration))
|
|
199
|
+
) {
|
|
200
|
+
throw new TypeError('hydration must be an object');
|
|
201
|
+
}
|
|
202
|
+
config.hydration = {
|
|
203
|
+
allowRerender: toArray(userOptions.hydration?.allowRerender),
|
|
204
|
+
skip: toArray(userOptions.hydration?.skip),
|
|
205
|
+
};
|
|
149
206
|
config.viewport = { ...DEFAULTS.viewport, ...(userOptions.viewport ?? {}) };
|
|
150
207
|
config.base = normaliseBase(config.base);
|
|
151
208
|
config.concurrency =
|
|
@@ -156,97 +213,115 @@ export function resolveConfig(userOptions = {}) {
|
|
|
156
213
|
!(config.include.length === 1 && config.include[0] === '/');
|
|
157
214
|
// The CSP dial decides the defaults for the pieces that can move. Explicit options still
|
|
158
215
|
// win, so a project can take 'strict' and put one part back.
|
|
159
|
-
if (userOptions.externalScripts === undefined)
|
|
216
|
+
if (userOptions.externalScripts === undefined) {
|
|
217
|
+
config.externalScripts = config.csp === 'strict' || config.csp === 'hash';
|
|
218
|
+
}
|
|
160
219
|
if (userOptions.criticalCssPreload === undefined) {
|
|
161
|
-
config.criticalCssPreload = config.csp === 'strict' ? false : 'media';
|
|
220
|
+
config.criticalCssPreload = config.csp === 'strict' || config.csp === 'hash' ? false : 'media';
|
|
162
221
|
}
|
|
163
222
|
config.warnings = [];
|
|
223
|
+
normaliseLocales(config);
|
|
224
|
+
normaliseSeoLocale(config);
|
|
164
225
|
validateConfig(config);
|
|
165
|
-
addConfigWarnings(config, userOptions);
|
|
226
|
+
addConfigWarnings(config, userOptions, DEFAULTS);
|
|
166
227
|
return config;
|
|
167
228
|
}
|
|
168
229
|
|
|
169
230
|
/**
|
|
170
|
-
* Reports
|
|
171
|
-
*
|
|
231
|
+
* Reports whether a value is a plain object rather than an array or null.
|
|
232
|
+
* @param {unknown} value
|
|
233
|
+
* @returns {boolean}
|
|
172
234
|
*/
|
|
173
|
-
function
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
])
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if (config.allowedHosts.length > 0 && !config.blockThirdParty) {
|
|
216
|
-
warn('allowedHosts has no effect unless blockThirdParty is true');
|
|
217
|
-
}
|
|
218
|
-
if (config.csp === 'strict' && config.inlineCss) {
|
|
219
|
-
warn(
|
|
220
|
-
"csp controls script-src; inlineCss emits inline styles, which need style-src 'unsafe-inline' or hashes",
|
|
221
|
-
);
|
|
222
|
-
}
|
|
223
|
-
if (
|
|
224
|
-
config.csp === 'strict' &&
|
|
225
|
-
userOptions.criticalCssPreload !== undefined &&
|
|
226
|
-
config.criticalCssPreload !== false
|
|
227
|
-
) {
|
|
228
|
-
warn(
|
|
229
|
-
"criticalCssPreload emits an inline onload handler that a script-src 'self' policy blocks",
|
|
230
|
-
);
|
|
231
|
-
}
|
|
232
|
-
if (config.removeStyleTags && config.inlineCss === 'critical') {
|
|
233
|
-
warn('removeStyleTags removes CSS-in-JS styles before critical CSS is extracted');
|
|
234
|
-
}
|
|
235
|
-
if (config.sitemap && config.saveAs !== 'html') {
|
|
236
|
-
warn(`sitemap has no effect when saveAs is '${config.saveAs}'`);
|
|
237
|
-
}
|
|
238
|
-
const metadataBase = config.metadata?.base;
|
|
239
|
-
if (metadataBase && metadataBase !== config.base) {
|
|
240
|
-
warn(
|
|
241
|
-
`metadata.base (${metadataBase}) differs from base (${config.base}); canonical, social URLs and routing will disagree`,
|
|
242
|
-
);
|
|
243
|
-
}
|
|
235
|
+
function isPlainObject(value) {
|
|
236
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Fills locale defaults so downstream code can rely on a complete matrix: the default
|
|
241
|
+
* code, x-default, prefixes, html lang and browser locale. Validation still rejects bad
|
|
242
|
+
* shapes after this normalisation.
|
|
243
|
+
*/
|
|
244
|
+
function normaliseLocales(config) {
|
|
245
|
+
if (!isPlainObject(config.locales)) return;
|
|
246
|
+
const locales = config.locales;
|
|
247
|
+
const list = Array.isArray(locales.list) ? locales.list : [];
|
|
248
|
+
const defaultCode =
|
|
249
|
+
locales.default ?? (list.length === 1 && isPlainObject(list[0]) ? list[0].code : undefined);
|
|
250
|
+
config.locales = {
|
|
251
|
+
...locales,
|
|
252
|
+
mode: locales.mode,
|
|
253
|
+
default: defaultCode,
|
|
254
|
+
xDefault: locales.xDefault === undefined ? defaultCode : locales.xDefault,
|
|
255
|
+
hreflang: locales.hreflang === undefined ? true : locales.hreflang,
|
|
256
|
+
list: list.map((locale) => {
|
|
257
|
+
if (!isPlainObject(locale)) return locale;
|
|
258
|
+
const prefix =
|
|
259
|
+
locale.prefix === undefined
|
|
260
|
+
? locales.mode === 'single' || locale.code === defaultCode
|
|
261
|
+
? ''
|
|
262
|
+
: locale.code
|
|
263
|
+
? `/${locale.code}`
|
|
264
|
+
: undefined
|
|
265
|
+
: locale.prefix === '/'
|
|
266
|
+
? ''
|
|
267
|
+
: locale.prefix;
|
|
268
|
+
return {
|
|
269
|
+
...locale,
|
|
270
|
+
prefix,
|
|
271
|
+
htmlLang: locale.htmlLang ?? locale.code,
|
|
272
|
+
locale: locale.locale ?? locale.code,
|
|
273
|
+
};
|
|
274
|
+
}),
|
|
275
|
+
};
|
|
276
|
+
if (locales.active !== undefined) config.locales.active = locales.active;
|
|
244
277
|
}
|
|
245
278
|
|
|
246
279
|
/**
|
|
247
|
-
*
|
|
248
|
-
*
|
|
280
|
+
* Fills the SEO locale recipe defaults. The feature stays off unless seoLocale was set,
|
|
281
|
+
* and the code falls back to the active locale in single mode.
|
|
249
282
|
*/
|
|
283
|
+
function normaliseSeoLocale(config) {
|
|
284
|
+
if (!isPlainObject(config.seoLocale)) return;
|
|
285
|
+
const seo = config.seoLocale;
|
|
286
|
+
config.seoLocale = {
|
|
287
|
+
...seo,
|
|
288
|
+
code: seo.code ?? (config.locales?.mode === 'single' ? config.locales.active : undefined),
|
|
289
|
+
source: seo.source ?? 'both',
|
|
290
|
+
value: seo.value ?? null,
|
|
291
|
+
localStorageKey: seo.localStorageKey ?? 'locale',
|
|
292
|
+
cookie:
|
|
293
|
+
seo.cookie === undefined
|
|
294
|
+
? {
|
|
295
|
+
name: 'locale',
|
|
296
|
+
path: '/',
|
|
297
|
+
domain: null,
|
|
298
|
+
sameSite: 'Lax',
|
|
299
|
+
secure: false,
|
|
300
|
+
maxAge: null,
|
|
301
|
+
}
|
|
302
|
+
: isPlainObject(seo.cookie)
|
|
303
|
+
? {
|
|
304
|
+
name: 'locale',
|
|
305
|
+
path: '/',
|
|
306
|
+
domain: null,
|
|
307
|
+
sameSite: 'Lax',
|
|
308
|
+
secure: false,
|
|
309
|
+
maxAge: null,
|
|
310
|
+
...seo.cookie,
|
|
311
|
+
}
|
|
312
|
+
: seo.cookie,
|
|
313
|
+
seedRender: seo.seedRender === undefined ? true : seo.seedRender,
|
|
314
|
+
seedVerification: seo.seedVerification === undefined ? true : seo.seedVerification,
|
|
315
|
+
inject: seo.inject === undefined ? true : seo.inject,
|
|
316
|
+
overwrite: seo.overwrite === undefined ? true : seo.overwrite,
|
|
317
|
+
navigator: seo.navigator === undefined ? false : seo.navigator,
|
|
318
|
+
htmlLang: seo.htmlLang === undefined ? true : seo.htmlLang,
|
|
319
|
+
forceHtmlLang: seo.forceHtmlLang === undefined ? false : seo.forceHtmlLang,
|
|
320
|
+
dir: seo.dir ?? null,
|
|
321
|
+
routes: toArray(seo.routes ?? ['/*']),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
250
325
|
export function normaliseBase(base) {
|
|
251
326
|
if (typeof base !== 'string' || base.length === 0) {
|
|
252
327
|
throw new TypeError('base must be a non-empty string');
|
|
@@ -301,13 +376,37 @@ export function validateConfig(config) {
|
|
|
301
376
|
|
|
302
377
|
patternList(config.include, 'include');
|
|
303
378
|
patternList(config.exclude, 'exclude');
|
|
379
|
+
patternList(config.hydration.allowRerender, 'hydration.allowRerender');
|
|
380
|
+
patternList(config.hydration.skip, 'hydration.skip');
|
|
304
381
|
stringList(config.allowedHosts, 'allowedHosts');
|
|
305
382
|
stringList(config.browserArgs, 'browserArgs');
|
|
306
383
|
stringList(config.ignoreForPreload, 'ignoreForPreload');
|
|
307
384
|
patternList(config.removeAttributes, 'removeAttributes');
|
|
308
|
-
|
|
385
|
+
patternList(config.auditIgnoreLinks, 'auditIgnoreLinks');
|
|
386
|
+
patternList(config.routePriority, 'routePriority');
|
|
387
|
+
for (const key of [
|
|
388
|
+
'readyFlag',
|
|
389
|
+
'readySelector',
|
|
390
|
+
'waitFor',
|
|
391
|
+
'notFoundRoute',
|
|
392
|
+
'reportPath',
|
|
393
|
+
'routesFile',
|
|
394
|
+
'routesFromSitemap',
|
|
395
|
+
'timezone',
|
|
396
|
+
]) {
|
|
397
|
+
nullableString(key);
|
|
398
|
+
}
|
|
309
399
|
for (const key of ['userAgent', 'destination', 'browserDownloadVersion']) nullableString(key);
|
|
400
|
+
if (config.transformHtml !== null && typeof config.transformHtml !== 'function') {
|
|
401
|
+
throw new TypeError('transformHtml must be a function or null');
|
|
402
|
+
}
|
|
310
403
|
for (const key of BOOLEAN_KEYS) booleanOption(key);
|
|
404
|
+
validateClock(config);
|
|
405
|
+
validateHydrationConfig(config);
|
|
406
|
+
validateMetadata(config);
|
|
407
|
+
validateLocales(config);
|
|
408
|
+
validateSeoLocale(config);
|
|
409
|
+
validateRouteOverrides(config.routeOverrides);
|
|
311
410
|
|
|
312
411
|
if (config.browserDownloadHash !== null && !/^[a-f0-9]{64}$/i.test(config.browserDownloadHash)) {
|
|
313
412
|
throw new TypeError('browserDownloadHash must be a 64 character SHA-256 hex digest');
|
|
@@ -333,6 +432,12 @@ export function validateConfig(config) {
|
|
|
333
432
|
if (config.maxRoutes !== null && (!Number.isInteger(config.maxRoutes) || config.maxRoutes < 1)) {
|
|
334
433
|
throw new TypeError('maxRoutes must be null or a positive integer');
|
|
335
434
|
}
|
|
435
|
+
if (!Number.isInteger(config.retries) || config.retries < 0) {
|
|
436
|
+
throw new TypeError('retries must be a non-negative integer');
|
|
437
|
+
}
|
|
438
|
+
if (!Number.isFinite(config.retryDelay) || config.retryDelay <= 0) {
|
|
439
|
+
throw new TypeError('retryDelay must be a positive number of milliseconds');
|
|
440
|
+
}
|
|
336
441
|
if (!Number.isFinite(config.maxCachedBytes) || config.maxCachedBytes <= 0) {
|
|
337
442
|
throw new TypeError('maxCachedBytes must be a positive number of bytes');
|
|
338
443
|
}
|
|
@@ -349,6 +454,9 @@ export function validateConfig(config) {
|
|
|
349
454
|
`browser must be one of: ${BROWSER_CHOICES.join(', ')} or a path to an executable`,
|
|
350
455
|
);
|
|
351
456
|
}
|
|
457
|
+
if (!FRAMEWORK_CHOICES.includes(config.framework)) {
|
|
458
|
+
throw new TypeError(`framework must be one of: ${FRAMEWORK_CHOICES.join(', ')}`);
|
|
459
|
+
}
|
|
352
460
|
if (config.storageState !== null) {
|
|
353
461
|
if (typeof config.storageState !== 'string' || !fs.existsSync(config.storageState)) {
|
|
354
462
|
throw new TypeError('storageState must point to an existing Playwright storage state file');
|
|
@@ -399,62 +507,7 @@ export function validateConfig(config) {
|
|
|
399
507
|
"removeStyleTags cannot be combined with inlineCss 'inline' or true: it would strip the CSS the inline step just added",
|
|
400
508
|
);
|
|
401
509
|
}
|
|
402
|
-
validateMetadata(config);
|
|
403
510
|
if (config.sitemap && !config.metadata?.siteUrl) {
|
|
404
511
|
throw new TypeError('sitemap needs metadata.siteUrl so the sitemap can list absolute URLs');
|
|
405
512
|
}
|
|
406
513
|
}
|
|
407
|
-
|
|
408
|
-
// The metadata keys the head component reads, so a typo is reported instead of ignored.
|
|
409
|
-
const METADATA_KEYS = new Set([
|
|
410
|
-
'siteUrl',
|
|
411
|
-
'siteName',
|
|
412
|
-
'titleTemplate',
|
|
413
|
-
'defaultImage',
|
|
414
|
-
'trailingSlash',
|
|
415
|
-
'base',
|
|
416
|
-
]);
|
|
417
|
-
|
|
418
|
-
/**
|
|
419
|
-
* Validates the defaults handed to the head component, so a typo surfaces at config time
|
|
420
|
-
* rather than as metadata that silently points at the build server.
|
|
421
|
-
*/
|
|
422
|
-
function validateMetadata(config) {
|
|
423
|
-
const metadata = config.metadata;
|
|
424
|
-
if (metadata === null) return;
|
|
425
|
-
if (typeof metadata !== 'object' || Array.isArray(metadata)) {
|
|
426
|
-
throw new TypeError('metadata must be null or an options object');
|
|
427
|
-
}
|
|
428
|
-
for (const key of Object.keys(metadata)) {
|
|
429
|
-
if (!METADATA_KEYS.has(key)) throw new TypeError(`metadata.${key} is not a recognised option`);
|
|
430
|
-
}
|
|
431
|
-
for (const key of METADATA_KEYS) {
|
|
432
|
-
const value = metadata[key];
|
|
433
|
-
if (value !== undefined && value !== null && typeof value !== 'string') {
|
|
434
|
-
throw new TypeError(`metadata.${key} must be a string`);
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
if (metadata.siteUrl && !/^https?:\/\//.test(metadata.siteUrl)) {
|
|
438
|
-
throw new TypeError('metadata.siteUrl must be an absolute http(s) URL');
|
|
439
|
-
}
|
|
440
|
-
if (metadata.siteUrl) {
|
|
441
|
-
const parsed = new URL(metadata.siteUrl);
|
|
442
|
-
if (parsed.pathname !== '/' && parsed.pathname !== '') {
|
|
443
|
-
throw new TypeError(
|
|
444
|
-
'metadata.siteUrl must be an origin without a path; set base for a sub-path deployment',
|
|
445
|
-
);
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
if (metadata.base && !metadata.base.startsWith('/')) {
|
|
449
|
-
throw new TypeError('metadata.base must start with a slash, for example /app/');
|
|
450
|
-
}
|
|
451
|
-
if (metadata.trailingSlash && !['preserve', 'always', 'never'].includes(metadata.trailingSlash)) {
|
|
452
|
-
throw new TypeError("metadata.trailingSlash must be 'preserve', 'always' or 'never'");
|
|
453
|
-
}
|
|
454
|
-
if (metadata.titleTemplate && !metadata.titleTemplate.includes('%s')) {
|
|
455
|
-
config.warnings ??= [];
|
|
456
|
-
config.warnings.push(
|
|
457
|
-
'metadata.titleTemplate has no %s placeholder, so the title is left as the page set it',
|
|
458
|
-
);
|
|
459
|
-
}
|
|
460
|
-
}
|