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.
Files changed (49) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +281 -101
  3. package/assets/snappy-logo.png +0 -0
  4. package/docs/limitations.md +94 -0
  5. package/docs/react-snap-migration.md +45 -0
  6. package/docs/report.md +135 -0
  7. package/package.json +4 -1
  8. package/src/cli-init.js +205 -0
  9. package/src/cli-options.js +258 -0
  10. package/src/cli.js +128 -309
  11. package/src/core/artifacts.js +2 -0
  12. package/src/core/audit/index.js +114 -0
  13. package/src/core/audit/links.js +74 -0
  14. package/src/core/audit/metadata.js +197 -0
  15. package/src/core/audit/sitemap.js +39 -0
  16. package/src/core/clock.js +60 -0
  17. package/src/core/config-validation.js +557 -0
  18. package/src/core/config-warnings.js +150 -0
  19. package/src/core/config.js +189 -136
  20. package/src/core/crawl.js +362 -0
  21. package/src/core/csp.js +138 -0
  22. package/src/core/destination.js +23 -7
  23. package/src/core/frameworks.js +94 -0
  24. package/src/core/hydration-diff.js +93 -0
  25. package/src/core/hydration-policy.js +38 -0
  26. package/src/core/init.js +143 -30
  27. package/src/core/inline-css.js +52 -9
  28. package/src/core/locale-context.js +63 -0
  29. package/src/core/locales.js +385 -0
  30. package/src/core/normalize.js +37 -6
  31. package/src/core/page-content.js +57 -7
  32. package/src/core/page-meta.js +122 -0
  33. package/src/core/preload-links.js +133 -0
  34. package/src/core/preload-manifest.js +6 -2
  35. package/src/core/render.js +126 -35
  36. package/src/core/report.js +116 -0
  37. package/src/core/resources.js +5 -1
  38. package/src/core/route-config.js +17 -0
  39. package/src/core/route-sources.js +220 -0
  40. package/src/core/run.js +156 -131
  41. package/src/core/seo-locale.js +234 -0
  42. package/src/core/settle.js +2 -4
  43. package/src/core/sitemap.js +90 -14
  44. package/src/core/text-separators.js +11 -7
  45. package/src/core/verify.js +164 -71
  46. package/src/core/write.js +314 -52
  47. package/src/head/index.js +104 -6
  48. package/src/head/manager.js +304 -48
  49. 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
+ }
@@ -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) config.externalScripts = config.csp === 'strict';
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 options that do nothing in the current combination, so a build does not
171
- * quietly skip a gate the caller believes is active.
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 addConfigWarnings(config, userOptions) {
174
- const warn = (message) => config.warnings.push(message);
175
- for (const key of Object.keys(userOptions)) {
176
- if (!Object.hasOwn(DEFAULTS, key)) {
177
- warn(`unknown option "${key}" is ignored; check the spelling`);
178
- }
179
- }
180
- if (!config.verify) {
181
- for (const key of [
182
- 'verifyChangedOnly',
183
- 'failOnHydrationError',
184
- 'failOnRerender',
185
- 'failOnUndetected',
186
- 'failOnVerificationError',
187
- ]) {
188
- if (config[key]) warn(`${key} has no effect because verify is false`);
189
- }
190
- }
191
- if (config.verify && (config.serveCmd || config.saveAs !== 'html')) {
192
- for (const key of [
193
- 'failOnHydrationError',
194
- 'failOnRerender',
195
- 'failOnUndetected',
196
- 'failOnVerificationError',
197
- ]) {
198
- if (config[key]) {
199
- warn(
200
- `${key} has no effect because hydration verification is skipped with ${config.serveCmd ? 'serveCmd' : `saveAs: '${config.saveAs}'`}`,
201
- );
202
- }
203
- }
204
- }
205
- if (
206
- userOptions.criticalCssPreload !== undefined &&
207
- config.criticalCssPreload !== false &&
208
- config.inlineCss !== 'critical'
209
- ) {
210
- warn('criticalCssPreload has no effect unless inlineCss is "critical"');
211
- }
212
- if (config.minifyCss && !config.minifyHtml && !config.inlineCss) {
213
- warn('minifyCss has no effect unless minifyHtml or inlineCss is enabled');
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
- * Ensures the base path always carries a leading and trailing slash so route and
248
- * URL helpers can strip or append it without branching on user input shape.
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
- for (const key of ['readyFlag', 'readySelector', 'waitFor', 'notFoundRoute']) nullableString(key);
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
- }