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,557 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
|
|
3
|
+
const ROUTE_OVERRIDE_KEYS = new Set([
|
|
4
|
+
'match',
|
|
5
|
+
'timeout',
|
|
6
|
+
'waitFor',
|
|
7
|
+
'readySelector',
|
|
8
|
+
'readyFlag',
|
|
9
|
+
'crawl',
|
|
10
|
+
'storageState',
|
|
11
|
+
'hydration',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Validates per-route overrides. Matching patterns stay strings or RegExp; every
|
|
16
|
+
* override key is checked so a typo cannot silently leave the base config in place.
|
|
17
|
+
*/
|
|
18
|
+
export function validateRouteOverrides(overrides) {
|
|
19
|
+
for (const override of overrides) {
|
|
20
|
+
if (typeof override !== 'object' || override === null || Array.isArray(override)) {
|
|
21
|
+
throw new TypeError('routeOverrides entries must be objects');
|
|
22
|
+
}
|
|
23
|
+
for (const key of Object.keys(override)) {
|
|
24
|
+
if (!ROUTE_OVERRIDE_KEYS.has(key)) {
|
|
25
|
+
throw new TypeError(`routeOverrides.${key} is not a recognised option`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (!(typeof override.match === 'string' || override.match instanceof RegExp)) {
|
|
29
|
+
throw new TypeError('routeOverrides entries need a string or RegExp match');
|
|
30
|
+
}
|
|
31
|
+
if (override.timeout !== undefined) {
|
|
32
|
+
if (!Number.isFinite(override.timeout) || override.timeout <= 0) {
|
|
33
|
+
throw new TypeError('routeOverrides.timeout must be a positive number of milliseconds');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
for (const key of ['waitFor', 'readySelector', 'readyFlag', 'storageState']) {
|
|
37
|
+
const value = override[key];
|
|
38
|
+
if (value !== undefined && typeof value !== 'string') {
|
|
39
|
+
throw new TypeError(`routeOverrides.${key} must be a string`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (override.crawl !== undefined && typeof override.crawl !== 'boolean') {
|
|
43
|
+
throw new TypeError('routeOverrides.crawl must be a boolean');
|
|
44
|
+
}
|
|
45
|
+
if (
|
|
46
|
+
override.hydration !== undefined &&
|
|
47
|
+
!['strict', 'allow-rerender', 'skip'].includes(override.hydration)
|
|
48
|
+
) {
|
|
49
|
+
throw new TypeError("routeOverrides.hydration must be 'strict', 'allow-rerender' or 'skip'");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Reports whether a value is a plain object rather than an array or null.
|
|
56
|
+
* @param {unknown} value
|
|
57
|
+
* @returns {boolean}
|
|
58
|
+
*/
|
|
59
|
+
function isPlainObject(value) {
|
|
60
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Reports whether a value is a valid language tag.
|
|
65
|
+
* @param {unknown} code
|
|
66
|
+
* @returns {boolean}
|
|
67
|
+
*/
|
|
68
|
+
function validLocaleCode(code) {
|
|
69
|
+
if (typeof code !== 'string' || code.length === 0) return false;
|
|
70
|
+
try {
|
|
71
|
+
return Intl.getCanonicalLocales(code).length === 1;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Reports whether a value is a valid IANA timezone.
|
|
79
|
+
* @param {unknown} timezone
|
|
80
|
+
* @returns {boolean}
|
|
81
|
+
*/
|
|
82
|
+
function validTimezone(timezone) {
|
|
83
|
+
if (typeof timezone !== 'string' || timezone.length === 0) return false;
|
|
84
|
+
try {
|
|
85
|
+
new Intl.DateTimeFormat('en-US', { timeZone: timezone });
|
|
86
|
+
return true;
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Validates a list of route patterns.
|
|
94
|
+
* @param {Array<string | RegExp>} list
|
|
95
|
+
* @param {string} name
|
|
96
|
+
*/
|
|
97
|
+
function validPatternList(list, name) {
|
|
98
|
+
for (const entry of list) {
|
|
99
|
+
if (typeof entry !== 'string' && !(entry instanceof RegExp)) {
|
|
100
|
+
throw new TypeError(`${name} entries must be strings or RegExp instances`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Validates the deterministic build clock and timezone controls.
|
|
106
|
+
export function validateClock(config) {
|
|
107
|
+
const value = config.freezeTime;
|
|
108
|
+
if (value !== false && value !== true) {
|
|
109
|
+
if (typeof value === 'number') {
|
|
110
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
111
|
+
throw new TypeError('freezeTime must be false, true, a non-negative epoch or an ISO date');
|
|
112
|
+
}
|
|
113
|
+
} else if (typeof value === 'string') {
|
|
114
|
+
if (Number.isNaN(Date.parse(value))) {
|
|
115
|
+
throw new TypeError('freezeTime must be a valid ISO date string');
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
throw new TypeError('freezeTime must be false, true, a non-negative epoch or an ISO date');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (config.timezone !== null && !validTimezone(config.timezone)) {
|
|
122
|
+
throw new TypeError('timezone must be a valid IANA timezone');
|
|
123
|
+
}
|
|
124
|
+
if (config.randomSeed !== null) {
|
|
125
|
+
if (
|
|
126
|
+
!Number.isInteger(config.randomSeed) ||
|
|
127
|
+
config.randomSeed < 0 ||
|
|
128
|
+
config.randomSeed > 0xffffffff
|
|
129
|
+
) {
|
|
130
|
+
throw new TypeError('randomSeed must be null or an integer between 0 and 4294967295');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Validates the global hydration policy lists.
|
|
137
|
+
* @param {object} config
|
|
138
|
+
*/
|
|
139
|
+
export function validateHydrationConfig(config) {
|
|
140
|
+
if (!isPlainObject(config.hydration)) {
|
|
141
|
+
throw new TypeError('hydration must be an object');
|
|
142
|
+
}
|
|
143
|
+
validPatternList(config.hydration.allowRerender ?? [], 'hydration.allowRerender');
|
|
144
|
+
validPatternList(config.hydration.skip ?? [], 'hydration.skip');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Validates the locale matrix and the path/single mode invariants.
|
|
148
|
+
export function validateLocales(config) {
|
|
149
|
+
const locales = config.locales;
|
|
150
|
+
if (locales === null) return;
|
|
151
|
+
if (!isPlainObject(locales)) throw new TypeError('locales must be null or an options object');
|
|
152
|
+
for (const key of Object.keys(locales)) {
|
|
153
|
+
if (!['mode', 'default', 'xDefault', 'hreflang', 'list', 'active'].includes(key)) {
|
|
154
|
+
throw new TypeError(`locales.${key} is not a recognised option`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (!['path', 'single'].includes(locales.mode)) {
|
|
158
|
+
throw new TypeError("locales.mode must be 'path' or 'single'");
|
|
159
|
+
}
|
|
160
|
+
if (locales.hreflang !== undefined && typeof locales.hreflang !== 'boolean') {
|
|
161
|
+
throw new TypeError('locales.hreflang must be a boolean');
|
|
162
|
+
}
|
|
163
|
+
if (!Array.isArray(locales.list) || locales.list.length === 0) {
|
|
164
|
+
throw new TypeError('locales.list must be a non-empty array');
|
|
165
|
+
}
|
|
166
|
+
const codes = new Set();
|
|
167
|
+
const prefixes = new Set();
|
|
168
|
+
for (const locale of locales.list) {
|
|
169
|
+
if (!isPlainObject(locale)) throw new TypeError('locales.list entries must be objects');
|
|
170
|
+
for (const key of Object.keys(locale)) {
|
|
171
|
+
if (
|
|
172
|
+
![
|
|
173
|
+
'code',
|
|
174
|
+
'prefix',
|
|
175
|
+
'url',
|
|
176
|
+
'htmlLang',
|
|
177
|
+
'dir',
|
|
178
|
+
'ogLocale',
|
|
179
|
+
'locale',
|
|
180
|
+
'timezone',
|
|
181
|
+
'routes',
|
|
182
|
+
'storageState',
|
|
183
|
+
'cookies',
|
|
184
|
+
'localStorage',
|
|
185
|
+
].includes(key)
|
|
186
|
+
) {
|
|
187
|
+
throw new TypeError(`locales.list.${key} is not a recognised option`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (!validLocaleCode(locale.code)) {
|
|
191
|
+
throw new TypeError(`locales.list.code must be a valid language tag: ${locale.code}`);
|
|
192
|
+
}
|
|
193
|
+
if (codes.has(locale.code)) throw new TypeError(`duplicate locale code: ${locale.code}`);
|
|
194
|
+
codes.add(locale.code);
|
|
195
|
+
if (locales.mode === 'path' && locale.prefix !== undefined) {
|
|
196
|
+
if (
|
|
197
|
+
typeof locale.prefix !== 'string' ||
|
|
198
|
+
(locale.prefix !== '' && !locale.prefix.startsWith('/'))
|
|
199
|
+
) {
|
|
200
|
+
throw new TypeError('locales.list.prefix must be an empty string or start with /');
|
|
201
|
+
}
|
|
202
|
+
if (locale.prefix.endsWith('/') && locale.prefix !== '/') {
|
|
203
|
+
throw new TypeError('locales.list.prefix must not end with /');
|
|
204
|
+
}
|
|
205
|
+
if (locale.prefix.includes('..')) throw new TypeError('locales.list.prefix must be safe');
|
|
206
|
+
if (prefixes.has(locale.prefix)) {
|
|
207
|
+
throw new TypeError(`duplicate locale prefix: ${locale.prefix}`);
|
|
208
|
+
}
|
|
209
|
+
prefixes.add(locale.prefix);
|
|
210
|
+
}
|
|
211
|
+
if (locale.url !== undefined && locale.url !== null) {
|
|
212
|
+
if (typeof locale.url !== 'string' || !/^https?:\/\//.test(locale.url)) {
|
|
213
|
+
throw new TypeError('locales.list.url must be an absolute http(s) URL');
|
|
214
|
+
}
|
|
215
|
+
const parsed = new URL(locale.url);
|
|
216
|
+
if (parsed.pathname !== '/' && parsed.pathname !== '') {
|
|
217
|
+
throw new TypeError('locales.list.url must be an origin without a path');
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
for (const key of ['htmlLang', 'dir', 'ogLocale', 'locale', 'timezone']) {
|
|
221
|
+
const value = locale[key];
|
|
222
|
+
if (value !== undefined && value !== null && typeof value !== 'string') {
|
|
223
|
+
throw new TypeError(`locales.list.${key} must be a string or null`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (locale.dir && !['ltr', 'rtl', 'auto'].includes(locale.dir)) {
|
|
227
|
+
throw new TypeError("locales.list.dir must be 'ltr', 'rtl' or 'auto'");
|
|
228
|
+
}
|
|
229
|
+
if (locale.timezone && !validTimezone(locale.timezone)) {
|
|
230
|
+
throw new TypeError('locales.list.timezone must be a valid IANA timezone');
|
|
231
|
+
}
|
|
232
|
+
if (locale.routes !== undefined) {
|
|
233
|
+
if (!isPlainObject(locale.routes)) {
|
|
234
|
+
throw new TypeError('locales.list.routes must be an object');
|
|
235
|
+
}
|
|
236
|
+
for (const [from, to] of Object.entries(locale.routes)) {
|
|
237
|
+
if (
|
|
238
|
+
typeof from !== 'string' ||
|
|
239
|
+
!from.startsWith('/') ||
|
|
240
|
+
typeof to !== 'string' ||
|
|
241
|
+
!to.startsWith('/') ||
|
|
242
|
+
from.includes('..') ||
|
|
243
|
+
to.includes('..')
|
|
244
|
+
) {
|
|
245
|
+
throw new TypeError('locales.list.routes must map safe /paths to safe /paths');
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (locale.storageState !== undefined && locale.storageState !== null) {
|
|
250
|
+
if (typeof locale.storageState !== 'string' || !fs.existsSync(locale.storageState)) {
|
|
251
|
+
throw new TypeError(
|
|
252
|
+
'locales.list.storageState must point to an existing Playwright storage state file',
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
validateLocaleCookies(locale.cookies, 'locales.list.cookies');
|
|
257
|
+
if (locale.localStorage !== undefined) {
|
|
258
|
+
if (!isPlainObject(locale.localStorage)) {
|
|
259
|
+
throw new TypeError('locales.list.localStorage must be an object');
|
|
260
|
+
}
|
|
261
|
+
for (const value of Object.values(locale.localStorage)) {
|
|
262
|
+
if (typeof value !== 'string') {
|
|
263
|
+
throw new TypeError('locales.list.localStorage values must be strings');
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (locales.default !== undefined && !codes.has(locales.default)) {
|
|
269
|
+
throw new TypeError('locales.default must name a locale in locales.list');
|
|
270
|
+
}
|
|
271
|
+
if (locales.active !== undefined && !codes.has(locales.active)) {
|
|
272
|
+
throw new TypeError('locales.active must name a locale in locales.list');
|
|
273
|
+
}
|
|
274
|
+
if (locales.default === undefined && locales.list.length > 1) {
|
|
275
|
+
throw new TypeError('locales.default is required when multiple locales are configured');
|
|
276
|
+
}
|
|
277
|
+
if (locales.xDefault !== undefined && locales.xDefault !== false) {
|
|
278
|
+
if (typeof locales.xDefault !== 'string') {
|
|
279
|
+
throw new TypeError('locales.xDefault must be false, a locale code or an absolute URL');
|
|
280
|
+
}
|
|
281
|
+
if (!codes.has(locales.xDefault) && !/^https?:\/\//.test(locales.xDefault)) {
|
|
282
|
+
throw new TypeError('locales.xDefault must name a locale or be an absolute URL');
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (locales.mode === 'single') {
|
|
286
|
+
if (locales.active === undefined || !codes.has(locales.active)) {
|
|
287
|
+
throw new TypeError('locales.active is required in single mode and must name a locale');
|
|
288
|
+
}
|
|
289
|
+
if (locales.list.length > 1 && locales.hreflang !== false) {
|
|
290
|
+
for (const locale of locales.list) {
|
|
291
|
+
if (!locale.url && !config.metadata?.siteUrl) {
|
|
292
|
+
throw new TypeError(
|
|
293
|
+
'single-mode hreflang needs locales.list.url or metadata.siteUrl for every locale',
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (locales.mode === 'path' && locales.hreflang !== false) {
|
|
300
|
+
if (!config.metadata?.siteUrl && locales.list.some((locale) => !locale.url)) {
|
|
301
|
+
throw new TypeError('path-mode hreflang needs metadata.siteUrl or a url for every locale');
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (locales.mode === 'path') {
|
|
305
|
+
const origins = new Set(
|
|
306
|
+
locales.list
|
|
307
|
+
.map((locale) => (typeof locale.url === 'string' ? new URL(locale.url).origin : null))
|
|
308
|
+
.filter(Boolean),
|
|
309
|
+
);
|
|
310
|
+
if (origins.size > 1) {
|
|
311
|
+
throw new TypeError(
|
|
312
|
+
'path mode uses one public origin; use single mode for separate locale domains',
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
if (config.metadata?.siteUrl && origins.size === 1) {
|
|
316
|
+
const configured = [...origins][0];
|
|
317
|
+
if (configured !== new URL(config.metadata.siteUrl).origin) {
|
|
318
|
+
throw new TypeError(
|
|
319
|
+
'path mode locale urls must match metadata.siteUrl; use single mode for separate domains',
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Validates locale cookie records used by the render and verification contexts.
|
|
328
|
+
* @param {unknown} cookies
|
|
329
|
+
* @param {string} name
|
|
330
|
+
*/
|
|
331
|
+
function validateLocaleCookies(cookies, name) {
|
|
332
|
+
if (cookies === undefined) return;
|
|
333
|
+
if (!Array.isArray(cookies)) throw new TypeError(`${name} must be an array`);
|
|
334
|
+
for (const cookie of cookies) {
|
|
335
|
+
if (!isPlainObject(cookie)) throw new TypeError(`${name} entries must be objects`);
|
|
336
|
+
if (typeof cookie.name !== 'string' || !/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(cookie.name)) {
|
|
337
|
+
throw new TypeError(`${name}.name must be a valid cookie name`);
|
|
338
|
+
}
|
|
339
|
+
if (typeof cookie.value !== 'string') {
|
|
340
|
+
throw new TypeError(`${name}.value must be a string`);
|
|
341
|
+
}
|
|
342
|
+
if (
|
|
343
|
+
cookie.path !== undefined &&
|
|
344
|
+
(typeof cookie.path !== 'string' || !cookie.path.startsWith('/'))
|
|
345
|
+
) {
|
|
346
|
+
throw new TypeError(`${name}.path must start with /`);
|
|
347
|
+
}
|
|
348
|
+
if (
|
|
349
|
+
cookie.domain !== undefined &&
|
|
350
|
+
cookie.domain !== null &&
|
|
351
|
+
typeof cookie.domain !== 'string'
|
|
352
|
+
) {
|
|
353
|
+
throw new TypeError(`${name}.domain must be a string or null`);
|
|
354
|
+
}
|
|
355
|
+
if (
|
|
356
|
+
cookie.sameSite !== undefined &&
|
|
357
|
+
!['Strict', 'Lax', 'None', 'strict', 'lax', 'none'].includes(cookie.sameSite)
|
|
358
|
+
) {
|
|
359
|
+
throw new TypeError(`${name}.sameSite must be Strict, Lax or None`);
|
|
360
|
+
}
|
|
361
|
+
if (cookie.secure !== undefined && typeof cookie.secure !== 'boolean') {
|
|
362
|
+
throw new TypeError(`${name}.secure must be a boolean`);
|
|
363
|
+
}
|
|
364
|
+
if (cookie.httpOnly !== undefined && typeof cookie.httpOnly !== 'boolean') {
|
|
365
|
+
throw new TypeError(`${name}.httpOnly must be a boolean`);
|
|
366
|
+
}
|
|
367
|
+
if (
|
|
368
|
+
cookie.maxAge !== undefined &&
|
|
369
|
+
cookie.maxAge !== null &&
|
|
370
|
+
(!Number.isInteger(cookie.maxAge) || cookie.maxAge < 0)
|
|
371
|
+
) {
|
|
372
|
+
throw new TypeError(`${name}.maxAge must be a non-negative integer`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Validates the optional cookie/localStorage SEO locale recipe.
|
|
378
|
+
export function validateSeoLocale(config) {
|
|
379
|
+
const seo = config.seoLocale;
|
|
380
|
+
if (seo === null) return;
|
|
381
|
+
if (!isPlainObject(seo)) throw new TypeError('seoLocale must be null or an options object');
|
|
382
|
+
for (const key of Object.keys(seo)) {
|
|
383
|
+
if (
|
|
384
|
+
![
|
|
385
|
+
'code',
|
|
386
|
+
'source',
|
|
387
|
+
'value',
|
|
388
|
+
'localStorageKey',
|
|
389
|
+
'cookie',
|
|
390
|
+
'seedRender',
|
|
391
|
+
'seedVerification',
|
|
392
|
+
'inject',
|
|
393
|
+
'overwrite',
|
|
394
|
+
'navigator',
|
|
395
|
+
'htmlLang',
|
|
396
|
+
'forceHtmlLang',
|
|
397
|
+
'dir',
|
|
398
|
+
'routes',
|
|
399
|
+
].includes(key)
|
|
400
|
+
) {
|
|
401
|
+
throw new TypeError(`seoLocale.${key} is not a recognised option`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const singleActive = config.locales?.mode === 'single' ? config.locales.active : null;
|
|
405
|
+
const pathMode = config.locales?.mode === 'path';
|
|
406
|
+
if (seo.code === undefined && !singleActive && !pathMode) {
|
|
407
|
+
throw new TypeError('seoLocale.code is required unless locales.active is set');
|
|
408
|
+
}
|
|
409
|
+
if (seo.code !== undefined) {
|
|
410
|
+
if (!validLocaleCode(seo.code)) {
|
|
411
|
+
throw new TypeError(`seoLocale.code must be a valid language tag: ${seo.code}`);
|
|
412
|
+
}
|
|
413
|
+
if (singleActive && seo.code !== singleActive) {
|
|
414
|
+
throw new TypeError('seoLocale.code must match locales.active in single mode');
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (!['localStorage', 'cookie', 'both'].includes(seo.source ?? 'both')) {
|
|
418
|
+
throw new TypeError("seoLocale.source must be 'localStorage', 'cookie' or 'both'");
|
|
419
|
+
}
|
|
420
|
+
if (seo.value !== undefined && seo.value !== null && typeof seo.value !== 'string') {
|
|
421
|
+
throw new TypeError('seoLocale.value must be a string or null');
|
|
422
|
+
}
|
|
423
|
+
if (
|
|
424
|
+
seo.localStorageKey !== undefined &&
|
|
425
|
+
(typeof seo.localStorageKey !== 'string' || seo.localStorageKey.length === 0)
|
|
426
|
+
) {
|
|
427
|
+
throw new TypeError('seoLocale.localStorageKey must be a non-empty string');
|
|
428
|
+
}
|
|
429
|
+
if (seo.cookie !== undefined) {
|
|
430
|
+
if (!isPlainObject(seo.cookie)) throw new TypeError('seoLocale.cookie must be an object');
|
|
431
|
+
const cookie = seo.cookie;
|
|
432
|
+
if (cookie.name !== undefined && typeof cookie.name !== 'string') {
|
|
433
|
+
throw new TypeError('seoLocale.cookie.name must be a string');
|
|
434
|
+
}
|
|
435
|
+
if (cookie.name !== undefined && !/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(cookie.name)) {
|
|
436
|
+
throw new TypeError('seoLocale.cookie.name must be a valid cookie name');
|
|
437
|
+
}
|
|
438
|
+
if (
|
|
439
|
+
cookie.path !== undefined &&
|
|
440
|
+
(typeof cookie.path !== 'string' || !cookie.path.startsWith('/'))
|
|
441
|
+
) {
|
|
442
|
+
throw new TypeError('seoLocale.cookie.path must start with /');
|
|
443
|
+
}
|
|
444
|
+
if (cookie.path !== undefined && /[;,\s]/.test(cookie.path)) {
|
|
445
|
+
throw new TypeError('seoLocale.cookie.path contains invalid characters');
|
|
446
|
+
}
|
|
447
|
+
if (
|
|
448
|
+
cookie.domain !== undefined &&
|
|
449
|
+
cookie.domain !== null &&
|
|
450
|
+
typeof cookie.domain !== 'string'
|
|
451
|
+
) {
|
|
452
|
+
throw new TypeError('seoLocale.cookie.domain must be a string or null');
|
|
453
|
+
}
|
|
454
|
+
if (cookie.domain && /[;,\s/]/.test(cookie.domain)) {
|
|
455
|
+
throw new TypeError('seoLocale.cookie.domain contains invalid characters');
|
|
456
|
+
}
|
|
457
|
+
if (
|
|
458
|
+
cookie.sameSite !== undefined &&
|
|
459
|
+
!['Strict', 'Lax', 'None', 'strict', 'lax', 'none'].includes(cookie.sameSite)
|
|
460
|
+
) {
|
|
461
|
+
throw new TypeError('seoLocale.cookie.sameSite must be Strict, Lax or None');
|
|
462
|
+
}
|
|
463
|
+
if (cookie.secure !== undefined && typeof cookie.secure !== 'boolean') {
|
|
464
|
+
throw new TypeError('seoLocale.cookie.secure must be a boolean');
|
|
465
|
+
}
|
|
466
|
+
if (
|
|
467
|
+
cookie.maxAge !== undefined &&
|
|
468
|
+
cookie.maxAge !== null &&
|
|
469
|
+
(!Number.isInteger(cookie.maxAge) || cookie.maxAge < 0)
|
|
470
|
+
) {
|
|
471
|
+
throw new TypeError('seoLocale.cookie.maxAge must be a non-negative integer');
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
for (const key of [
|
|
475
|
+
'seedRender',
|
|
476
|
+
'seedVerification',
|
|
477
|
+
'inject',
|
|
478
|
+
'overwrite',
|
|
479
|
+
'navigator',
|
|
480
|
+
'forceHtmlLang',
|
|
481
|
+
]) {
|
|
482
|
+
if (seo[key] !== undefined && typeof seo[key] !== 'boolean') {
|
|
483
|
+
throw new TypeError(`seoLocale.${key} must be a boolean`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (
|
|
487
|
+
seo.htmlLang !== undefined &&
|
|
488
|
+
typeof seo.htmlLang !== 'boolean' &&
|
|
489
|
+
typeof seo.htmlLang !== 'string'
|
|
490
|
+
) {
|
|
491
|
+
throw new TypeError('seoLocale.htmlLang must be a boolean or a string');
|
|
492
|
+
}
|
|
493
|
+
if (seo.dir !== undefined && seo.dir !== null && !['ltr', 'rtl', 'auto'].includes(seo.dir)) {
|
|
494
|
+
throw new TypeError("seoLocale.dir must be 'ltr', 'rtl' or 'auto'");
|
|
495
|
+
}
|
|
496
|
+
if (seo.routes !== undefined) validPatternList(seo.routes, 'seoLocale.routes');
|
|
497
|
+
if ((seo.inject ?? true) && config.removeScriptTags) {
|
|
498
|
+
throw new TypeError(
|
|
499
|
+
'seoLocale.inject cannot be combined with removeScriptTags: the bootstrap script would be stripped',
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// The metadata keys the head component reads, so a typo is reported instead of ignored.
|
|
505
|
+
const METADATA_KEYS = new Set([
|
|
506
|
+
'siteUrl',
|
|
507
|
+
'siteName',
|
|
508
|
+
'titleTemplate',
|
|
509
|
+
'defaultTitle',
|
|
510
|
+
'defaultImage',
|
|
511
|
+
'trailingSlash',
|
|
512
|
+
'base',
|
|
513
|
+
]);
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Validates the defaults handed to the head component, so a typo surfaces at config time
|
|
517
|
+
* rather than as metadata that silently points at the build server.
|
|
518
|
+
*/
|
|
519
|
+
export function validateMetadata(config) {
|
|
520
|
+
const metadata = config.metadata;
|
|
521
|
+
if (metadata === null) return;
|
|
522
|
+
if (typeof metadata !== 'object' || Array.isArray(metadata)) {
|
|
523
|
+
throw new TypeError('metadata must be null or an options object');
|
|
524
|
+
}
|
|
525
|
+
for (const key of Object.keys(metadata)) {
|
|
526
|
+
if (!METADATA_KEYS.has(key)) throw new TypeError(`metadata.${key} is not a recognised option`);
|
|
527
|
+
}
|
|
528
|
+
for (const key of METADATA_KEYS) {
|
|
529
|
+
const value = metadata[key];
|
|
530
|
+
if (value !== undefined && value !== null && typeof value !== 'string') {
|
|
531
|
+
throw new TypeError(`metadata.${key} must be a string`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (metadata.siteUrl && !/^https?:\/\//.test(metadata.siteUrl)) {
|
|
535
|
+
throw new TypeError('metadata.siteUrl must be an absolute http(s) URL');
|
|
536
|
+
}
|
|
537
|
+
if (metadata.siteUrl) {
|
|
538
|
+
const parsed = new URL(metadata.siteUrl);
|
|
539
|
+
if (parsed.pathname !== '/' && parsed.pathname !== '') {
|
|
540
|
+
throw new TypeError(
|
|
541
|
+
'metadata.siteUrl must be an origin without a path; set base for a sub-path deployment',
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (metadata.base && !metadata.base.startsWith('/')) {
|
|
546
|
+
throw new TypeError('metadata.base must start with a slash, for example /app/');
|
|
547
|
+
}
|
|
548
|
+
if (metadata.trailingSlash && !['preserve', 'always', 'never'].includes(metadata.trailingSlash)) {
|
|
549
|
+
throw new TypeError("metadata.trailingSlash must be 'preserve', 'always' or 'never'");
|
|
550
|
+
}
|
|
551
|
+
if (metadata.titleTemplate && !metadata.titleTemplate.includes('%s')) {
|
|
552
|
+
config.warnings ??= [];
|
|
553
|
+
config.warnings.push(
|
|
554
|
+
'metadata.titleTemplate has no %s placeholder, so the title is left as the page set it',
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
}
|