snappy-prerender 2.7.0 → 2.8.1
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 +55 -0
- package/README.md +168 -7
- package/assets/snappy-logo.png +0 -0
- package/docs/limitations.md +40 -4
- package/docs/report.md +36 -3
- package/package.json +3 -1
- package/src/cli-options.js +258 -0
- package/src/cli.js +75 -161
- package/src/core/audit/links.js +12 -1
- package/src/core/audit/metadata.js +113 -4
- package/src/core/audit/sitemap.js +1 -1
- 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 +132 -203
- package/src/core/crawl.js +76 -14
- package/src/core/csp.js +14 -3
- package/src/core/destination.js +23 -7
- package/src/core/hydration-policy.js +38 -0
- 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 +30 -6
- package/src/core/page-content.js +57 -7
- package/src/core/page-meta.js +18 -4
- package/src/core/preload-links.js +24 -11
- package/src/core/preload-manifest.js +6 -2
- package/src/core/render.js +46 -37
- package/src/core/report.js +9 -1
- package/src/core/route-sources.js +14 -3
- package/src/core/run.js +90 -20
- package/src/core/seo-locale.js +234 -0
- package/src/core/settle.js +2 -4
- package/src/core/sitemap.js +1 -1
- package/src/core/text-separators.js +11 -7
- package/src/core/verify.js +108 -59
- package/src/core/write.js +276 -99
- package/src/head/index.js +103 -5
- package/src/head/manager.js +290 -75
- package/src/head/tags.js +305 -32
|
@@ -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
|
|
@@ -32,6 +41,9 @@ const NULLABLE_KEYS = [
|
|
|
32
41
|
'routesFile',
|
|
33
42
|
'routesFromSitemap',
|
|
34
43
|
'transformHtml',
|
|
44
|
+
'timezone',
|
|
45
|
+
'locales',
|
|
46
|
+
'seoLocale',
|
|
35
47
|
];
|
|
36
48
|
const ARRAY_KEYS = [
|
|
37
49
|
'include',
|
|
@@ -117,6 +129,13 @@ export const DEFAULTS = Object.freeze({
|
|
|
117
129
|
framework: 'auto',
|
|
118
130
|
verify: true,
|
|
119
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,
|
|
120
139
|
failOnHydrationError: false,
|
|
121
140
|
failOnRerender: false,
|
|
122
141
|
failOnPageError: false,
|
|
@@ -140,7 +159,7 @@ export const DEFAULTS = Object.freeze({
|
|
|
140
159
|
|
|
141
160
|
// Options whose default is boolean but which accept other shapes, so their own validators
|
|
142
161
|
// below decide what is allowed.
|
|
143
|
-
const MULTI_TYPE_KEYS = new Set(['inlineCss', 'minifyHtml', 'minifyCss']);
|
|
162
|
+
const MULTI_TYPE_KEYS = new Set(['inlineCss', 'minifyHtml', 'minifyCss', 'freezeTime']);
|
|
144
163
|
|
|
145
164
|
// Freeze the nested list defaults as well, so a consumer cannot mutate the defaults
|
|
146
165
|
// used by every later run in the same process.
|
|
@@ -172,6 +191,18 @@ export function resolveConfig(userOptions = {}) {
|
|
|
172
191
|
const config = { ...DEFAULTS, ...userOptions };
|
|
173
192
|
for (const key of NULLABLE_KEYS) config[key] = config[key] ?? null;
|
|
174
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
|
+
};
|
|
175
206
|
config.viewport = { ...DEFAULTS.viewport, ...(userOptions.viewport ?? {}) };
|
|
176
207
|
config.base = normaliseBase(config.base);
|
|
177
208
|
config.concurrency =
|
|
@@ -189,118 +220,108 @@ export function resolveConfig(userOptions = {}) {
|
|
|
189
220
|
config.criticalCssPreload = config.csp === 'strict' || config.csp === 'hash' ? false : 'media';
|
|
190
221
|
}
|
|
191
222
|
config.warnings = [];
|
|
223
|
+
normaliseLocales(config);
|
|
224
|
+
normaliseSeoLocale(config);
|
|
192
225
|
validateConfig(config);
|
|
193
|
-
addConfigWarnings(config, userOptions);
|
|
226
|
+
addConfigWarnings(config, userOptions, DEFAULTS);
|
|
194
227
|
return config;
|
|
195
228
|
}
|
|
196
229
|
|
|
197
230
|
/**
|
|
198
|
-
* Reports
|
|
199
|
-
*
|
|
231
|
+
* Reports whether a value is a plain object rather than an array or null.
|
|
232
|
+
* @param {unknown} value
|
|
233
|
+
* @returns {boolean}
|
|
200
234
|
*/
|
|
201
|
-
function
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
])
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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');
|
|
258
|
-
}
|
|
259
|
-
if (
|
|
260
|
-
userOptions.criticalCssPreload !== undefined &&
|
|
261
|
-
config.criticalCssPreload !== false &&
|
|
262
|
-
config.inlineCss !== 'critical'
|
|
263
|
-
) {
|
|
264
|
-
warn('criticalCssPreload has no effect unless inlineCss is "critical"');
|
|
265
|
-
}
|
|
266
|
-
if (config.minifyCss && !config.minifyHtml && !config.inlineCss) {
|
|
267
|
-
warn('minifyCss has no effect unless minifyHtml or inlineCss is enabled');
|
|
268
|
-
}
|
|
269
|
-
if (config.allowedHosts.length > 0 && !config.blockThirdParty) {
|
|
270
|
-
warn('allowedHosts has no effect unless blockThirdParty is true');
|
|
271
|
-
}
|
|
272
|
-
if (config.csp === 'strict' && config.inlineCss) {
|
|
273
|
-
warn(
|
|
274
|
-
"csp controls script-src; inlineCss emits inline styles, which need style-src 'unsafe-inline' or hashes",
|
|
275
|
-
);
|
|
276
|
-
}
|
|
277
|
-
if (
|
|
278
|
-
(config.csp === 'strict' || config.csp === 'hash') &&
|
|
279
|
-
userOptions.criticalCssPreload !== undefined &&
|
|
280
|
-
config.criticalCssPreload !== false
|
|
281
|
-
) {
|
|
282
|
-
warn(
|
|
283
|
-
"criticalCssPreload emits an inline onload handler that a script-src 'self' policy blocks",
|
|
284
|
-
);
|
|
285
|
-
}
|
|
286
|
-
if (config.removeStyleTags && config.inlineCss === 'critical') {
|
|
287
|
-
warn('removeStyleTags removes CSS-in-JS styles before critical CSS is extracted');
|
|
288
|
-
}
|
|
289
|
-
if (config.sitemap && config.saveAs !== 'html') {
|
|
290
|
-
warn(`sitemap has no effect when saveAs is '${config.saveAs}'`);
|
|
291
|
-
}
|
|
292
|
-
const metadataBase = config.metadata?.base;
|
|
293
|
-
if (metadataBase && metadataBase !== config.base) {
|
|
294
|
-
warn(
|
|
295
|
-
`metadata.base (${metadataBase}) differs from base (${config.base}); canonical, social URLs and routing will disagree`,
|
|
296
|
-
);
|
|
297
|
-
}
|
|
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;
|
|
298
277
|
}
|
|
299
278
|
|
|
300
279
|
/**
|
|
301
|
-
*
|
|
302
|
-
*
|
|
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.
|
|
303
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
|
+
|
|
304
325
|
export function normaliseBase(base) {
|
|
305
326
|
if (typeof base !== 'string' || base.length === 0) {
|
|
306
327
|
throw new TypeError('base must be a non-empty string');
|
|
@@ -355,6 +376,8 @@ export function validateConfig(config) {
|
|
|
355
376
|
|
|
356
377
|
patternList(config.include, 'include');
|
|
357
378
|
patternList(config.exclude, 'exclude');
|
|
379
|
+
patternList(config.hydration.allowRerender, 'hydration.allowRerender');
|
|
380
|
+
patternList(config.hydration.skip, 'hydration.skip');
|
|
358
381
|
stringList(config.allowedHosts, 'allowedHosts');
|
|
359
382
|
stringList(config.browserArgs, 'browserArgs');
|
|
360
383
|
stringList(config.ignoreForPreload, 'ignoreForPreload');
|
|
@@ -369,6 +392,7 @@ export function validateConfig(config) {
|
|
|
369
392
|
'reportPath',
|
|
370
393
|
'routesFile',
|
|
371
394
|
'routesFromSitemap',
|
|
395
|
+
'timezone',
|
|
372
396
|
]) {
|
|
373
397
|
nullableString(key);
|
|
374
398
|
}
|
|
@@ -377,6 +401,11 @@ export function validateConfig(config) {
|
|
|
377
401
|
throw new TypeError('transformHtml must be a function or null');
|
|
378
402
|
}
|
|
379
403
|
for (const key of BOOLEAN_KEYS) booleanOption(key);
|
|
404
|
+
validateClock(config);
|
|
405
|
+
validateHydrationConfig(config);
|
|
406
|
+
validateMetadata(config);
|
|
407
|
+
validateLocales(config);
|
|
408
|
+
validateSeoLocale(config);
|
|
380
409
|
validateRouteOverrides(config.routeOverrides);
|
|
381
410
|
|
|
382
411
|
if (config.browserDownloadHash !== null && !/^[a-f0-9]{64}$/i.test(config.browserDownloadHash)) {
|
|
@@ -478,107 +507,7 @@ export function validateConfig(config) {
|
|
|
478
507
|
"removeStyleTags cannot be combined with inlineCss 'inline' or true: it would strip the CSS the inline step just added",
|
|
479
508
|
);
|
|
480
509
|
}
|
|
481
|
-
validateMetadata(config);
|
|
482
510
|
if (config.sitemap && !config.metadata?.siteUrl) {
|
|
483
511
|
throw new TypeError('sitemap needs metadata.siteUrl so the sitemap can list absolute URLs');
|
|
484
512
|
}
|
|
485
513
|
}
|
|
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
|
-
|
|
532
|
-
// The metadata keys the head component reads, so a typo is reported instead of ignored.
|
|
533
|
-
const METADATA_KEYS = new Set([
|
|
534
|
-
'siteUrl',
|
|
535
|
-
'siteName',
|
|
536
|
-
'titleTemplate',
|
|
537
|
-
'defaultImage',
|
|
538
|
-
'trailingSlash',
|
|
539
|
-
'base',
|
|
540
|
-
]);
|
|
541
|
-
|
|
542
|
-
/**
|
|
543
|
-
* Validates the defaults handed to the head component, so a typo surfaces at config time
|
|
544
|
-
* rather than as metadata that silently points at the build server.
|
|
545
|
-
*/
|
|
546
|
-
function validateMetadata(config) {
|
|
547
|
-
const metadata = config.metadata;
|
|
548
|
-
if (metadata === null) return;
|
|
549
|
-
if (typeof metadata !== 'object' || Array.isArray(metadata)) {
|
|
550
|
-
throw new TypeError('metadata must be null or an options object');
|
|
551
|
-
}
|
|
552
|
-
for (const key of Object.keys(metadata)) {
|
|
553
|
-
if (!METADATA_KEYS.has(key)) throw new TypeError(`metadata.${key} is not a recognised option`);
|
|
554
|
-
}
|
|
555
|
-
for (const key of METADATA_KEYS) {
|
|
556
|
-
const value = metadata[key];
|
|
557
|
-
if (value !== undefined && value !== null && typeof value !== 'string') {
|
|
558
|
-
throw new TypeError(`metadata.${key} must be a string`);
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
if (metadata.siteUrl && !/^https?:\/\//.test(metadata.siteUrl)) {
|
|
562
|
-
throw new TypeError('metadata.siteUrl must be an absolute http(s) URL');
|
|
563
|
-
}
|
|
564
|
-
if (metadata.siteUrl) {
|
|
565
|
-
const parsed = new URL(metadata.siteUrl);
|
|
566
|
-
if (parsed.pathname !== '/' && parsed.pathname !== '') {
|
|
567
|
-
throw new TypeError(
|
|
568
|
-
'metadata.siteUrl must be an origin without a path; set base for a sub-path deployment',
|
|
569
|
-
);
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
if (metadata.base && !metadata.base.startsWith('/')) {
|
|
573
|
-
throw new TypeError('metadata.base must start with a slash, for example /app/');
|
|
574
|
-
}
|
|
575
|
-
if (metadata.trailingSlash && !['preserve', 'always', 'never'].includes(metadata.trailingSlash)) {
|
|
576
|
-
throw new TypeError("metadata.trailingSlash must be 'preserve', 'always' or 'never'");
|
|
577
|
-
}
|
|
578
|
-
if (metadata.titleTemplate && !metadata.titleTemplate.includes('%s')) {
|
|
579
|
-
config.warnings ??= [];
|
|
580
|
-
config.warnings.push(
|
|
581
|
-
'metadata.titleTemplate has no %s placeholder, so the title is left as the page set it',
|
|
582
|
-
);
|
|
583
|
-
}
|
|
584
|
-
}
|