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.
@@ -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
@@ -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 options that do nothing in the current combination, so a build does not
199
- * 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}
200
234
  */
201
- function addConfigWarnings(config, userOptions) {
202
- const warn = (message) => config.warnings.push(message);
203
- for (const key of Object.keys(userOptions)) {
204
- if (!Object.hasOwn(DEFAULTS, key)) {
205
- warn(`unknown option "${key}" is ignored; check the spelling`);
206
- }
207
- }
208
- if (!config.verify) {
209
- for (const key of [
210
- 'verifyChangedOnly',
211
- 'failOnHydrationError',
212
- 'failOnRerender',
213
- 'failOnUndetected',
214
- 'failOnVerificationError',
215
- ]) {
216
- if (config[key]) warn(`${key} has no effect because verify is false`);
217
- }
218
- }
219
- if (config.verify && (config.serveCmd || config.saveAs !== 'html')) {
220
- for (const key of [
221
- 'failOnHydrationError',
222
- 'failOnRerender',
223
- 'failOnUndetected',
224
- 'failOnVerificationError',
225
- ]) {
226
- if (config[key]) {
227
- warn(
228
- `${key} has no effect because hydration verification is skipped with ${config.serveCmd ? 'serveCmd' : `saveAs: '${config.saveAs}'`}`,
229
- );
230
- }
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');
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
- * Ensures the base path always carries a leading and trailing slash so route and
302
- * 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.
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
- }