snappy-prerender 2.7.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 +49 -0
- package/README.md +166 -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
package/src/core/write.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { parse, serialize } from 'parse5';
|
|
2
3
|
import { findBuildOriginUrls } from './build-origin.js';
|
|
3
|
-
import {
|
|
4
|
+
import { applyCspHashesToDocument } from './csp.js';
|
|
5
|
+
import { resolveHydrationPolicy } from './hydration-policy.js';
|
|
4
6
|
import { inlineCriticalCss } from './inline-css.js';
|
|
7
|
+
import { applyLocaleMetadata, buildLocaleLinks, localePageUrl } from './locales.js';
|
|
5
8
|
import { minifyHtml } from './minify.js';
|
|
6
9
|
import { normaliseHtml } from './normalize.js';
|
|
7
10
|
import {
|
|
@@ -12,11 +15,20 @@ import {
|
|
|
12
15
|
writeFileIfChanged,
|
|
13
16
|
writeRouteHtml,
|
|
14
17
|
} from './output.js';
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
+
import { collectPageMetaFromDocument } from './page-meta.js';
|
|
19
|
+
import { collectScriptSourcesFromDocument, injectPreloadLinksInto } from './preload-links.js';
|
|
20
|
+
import { collectPageResourcesFromDocument } from './preload-manifest.js';
|
|
18
21
|
import { resolveRouteConfig } from './route-config.js';
|
|
19
22
|
import { normaliseRoute, toPublicPath } from './routes.js';
|
|
23
|
+
import { mapLimit } from './scheduler.js';
|
|
24
|
+
import {
|
|
25
|
+
buildSeoLocaleScript,
|
|
26
|
+
injectSeoLocaleScript,
|
|
27
|
+
resolveSeoLocale,
|
|
28
|
+
seoLocaleMetadataOptions,
|
|
29
|
+
} from './seo-locale.js';
|
|
30
|
+
|
|
31
|
+
const WRITE_CONCURRENCY = 4;
|
|
20
32
|
|
|
21
33
|
/**
|
|
22
34
|
* Writes every rendered route and returns the routes that can be verified. A write
|
|
@@ -24,12 +36,15 @@ import { normaliseRoute, toPublicPath } from './routes.js';
|
|
|
24
36
|
*/
|
|
25
37
|
export async function writeAllRoutes({
|
|
26
38
|
rendered,
|
|
39
|
+
routeLocales = new Map(),
|
|
40
|
+
localePlan = null,
|
|
27
41
|
outputDir,
|
|
28
42
|
config,
|
|
29
43
|
log,
|
|
30
44
|
report,
|
|
31
45
|
state,
|
|
32
46
|
manifestEntries,
|
|
47
|
+
caches = null,
|
|
33
48
|
}) {
|
|
34
49
|
const sorted = [...report.routes].sort();
|
|
35
50
|
const conflicted = reportOutputCollisions({ routes: sorted, config, log, report });
|
|
@@ -38,57 +53,92 @@ export async function writeAllRoutes({
|
|
|
38
53
|
|
|
39
54
|
const verifiable = [];
|
|
40
55
|
const hashes = new Map();
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
failedRequests: result.failedRequests ?? [],
|
|
78
|
-
firstMismatch: null,
|
|
79
|
-
buildOriginUrls: file.buildOriginUrls ?? [],
|
|
80
|
-
});
|
|
81
|
-
if (resources) manifestEntries.push({ route, ...resources });
|
|
82
|
-
if (file.hash) {
|
|
83
|
-
const group = hashes.get(file.hash) ?? [];
|
|
84
|
-
group.push(route);
|
|
85
|
-
hashes.set(file.hash, group);
|
|
56
|
+
const localeScripts = new Map();
|
|
57
|
+
const { results } = await mapLimit(
|
|
58
|
+
sorted,
|
|
59
|
+
Math.min(WRITE_CONCURRENCY, config.concurrency),
|
|
60
|
+
async (route) => {
|
|
61
|
+
if (conflicted.has(route)) return null;
|
|
62
|
+
const routeReport = { errors: [], duplicateTitles: [], cspSkipped: [], files: [] };
|
|
63
|
+
try {
|
|
64
|
+
const routeConfig = resolveRouteConfig(config, route);
|
|
65
|
+
const result = rendered.get(route);
|
|
66
|
+
const { file, resources } = await writeRouteOutput({
|
|
67
|
+
route,
|
|
68
|
+
result,
|
|
69
|
+
outputDir,
|
|
70
|
+
config: routeConfig,
|
|
71
|
+
log,
|
|
72
|
+
state,
|
|
73
|
+
report: routeReport,
|
|
74
|
+
caches,
|
|
75
|
+
locale: routeLocales.get(route)?.locale ?? null,
|
|
76
|
+
baseRoute: routeLocales.get(route)?.baseRoute ?? route,
|
|
77
|
+
localePlan,
|
|
78
|
+
localeScripts,
|
|
79
|
+
});
|
|
80
|
+
return {
|
|
81
|
+
route,
|
|
82
|
+
result,
|
|
83
|
+
file,
|
|
84
|
+
resources,
|
|
85
|
+
hash: file.hash,
|
|
86
|
+
report: routeReport,
|
|
87
|
+
};
|
|
88
|
+
} catch (error) {
|
|
89
|
+
routeReport.errors.push({ route, message: `write failed: ${error.message}` });
|
|
90
|
+
log.error(` write failed for ${route}: ${error.message}`);
|
|
91
|
+
return { route, report: routeReport };
|
|
86
92
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
for (const entry of results) {
|
|
97
|
+
if (!entry) continue;
|
|
98
|
+
for (const error of entry.report.errors) report.errors.push(error);
|
|
99
|
+
for (const route of entry.report.duplicateTitles) report.duplicateTitles.push(route);
|
|
100
|
+
for (const route of entry.report.cspSkipped) report.cspSkipped.push(route);
|
|
101
|
+
for (const file of entry.report.files ?? []) report.files.push(file);
|
|
102
|
+
if (!entry.file) continue;
|
|
103
|
+
const { route, result, file, resources } = entry;
|
|
104
|
+
report.files.push(file);
|
|
105
|
+
report.routeDetails.push({
|
|
106
|
+
route,
|
|
107
|
+
locale: routeLocales.get(route)?.locale?.code ?? null,
|
|
108
|
+
order: result.order ?? null,
|
|
109
|
+
file: file.file,
|
|
110
|
+
status: file.status,
|
|
111
|
+
bytes: file.bytes,
|
|
112
|
+
depth: result.depth ?? null,
|
|
113
|
+
attempts: result.attempts ?? 1,
|
|
114
|
+
httpStatus: result.httpStatus ?? null,
|
|
115
|
+
finalUrl: result.finalUrl ?? null,
|
|
116
|
+
empty: result.empty === true,
|
|
117
|
+
notFound: file.notFound === true,
|
|
118
|
+
noindex: file.noindex === true,
|
|
119
|
+
meta: file.meta ?? null,
|
|
120
|
+
images: file.images ?? [],
|
|
121
|
+
alternates: file.alternates ?? [],
|
|
122
|
+
localeUrl: file.localeUrl ?? null,
|
|
123
|
+
mode: null,
|
|
124
|
+
framework: null,
|
|
125
|
+
hydrationErrors: [],
|
|
126
|
+
ignoredHydrationErrors: [],
|
|
127
|
+
hydrationPolicy: resolveHydrationPolicy(config, route),
|
|
128
|
+
hydrationAllowed: false,
|
|
129
|
+
pageErrors: result.pageErrors ?? [],
|
|
130
|
+
consoleErrors: result.consoleErrors ?? [],
|
|
131
|
+
failedRequests: result.failedRequests ?? [],
|
|
132
|
+
firstMismatch: null,
|
|
133
|
+
buildOriginUrls: file.buildOriginUrls ?? [],
|
|
134
|
+
});
|
|
135
|
+
if (resources) manifestEntries.push({ route, ...resources });
|
|
136
|
+
if (entry.hash) {
|
|
137
|
+
const group = hashes.get(entry.hash) ?? [];
|
|
138
|
+
group.push(route);
|
|
139
|
+
hashes.set(entry.hash, group);
|
|
91
140
|
}
|
|
141
|
+
verifiable.push(route);
|
|
92
142
|
}
|
|
93
143
|
reportDuplicatePages({ groups: [...hashes.values()], config, log, report });
|
|
94
144
|
return verifiable;
|
|
@@ -176,7 +226,20 @@ function reportOutputCollisions({ routes, config, log, report }) {
|
|
|
176
226
|
* Applies the configured post-processing to one rendered route and writes it: DOM
|
|
177
227
|
* cleanups and link hints, critical CSS extraction, then optional minification.
|
|
178
228
|
*/
|
|
179
|
-
async function writeRouteOutput({
|
|
229
|
+
async function writeRouteOutput({
|
|
230
|
+
route,
|
|
231
|
+
result,
|
|
232
|
+
outputDir,
|
|
233
|
+
config,
|
|
234
|
+
log,
|
|
235
|
+
state,
|
|
236
|
+
report,
|
|
237
|
+
caches,
|
|
238
|
+
locale = null,
|
|
239
|
+
baseRoute = route,
|
|
240
|
+
localePlan = null,
|
|
241
|
+
localeScripts = new Map(),
|
|
242
|
+
}) {
|
|
180
243
|
if (result.screenshot) {
|
|
181
244
|
const file = routeToScreenshotFile(route, config);
|
|
182
245
|
if (config.dryRun) {
|
|
@@ -198,23 +261,89 @@ async function writeRouteOutput({ route, result, outputDir, config, log, state,
|
|
|
198
261
|
return { file: { route, ...written }, resources: null };
|
|
199
262
|
}
|
|
200
263
|
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
config.
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
264
|
+
const needsString =
|
|
265
|
+
config.inlineCss === 'critical' || Boolean(config.minifyHtml) || Boolean(config.transformHtml);
|
|
266
|
+
const normalised = normaliseHtml(
|
|
267
|
+
result.html,
|
|
268
|
+
{
|
|
269
|
+
removeStyleTags: config.removeStyleTags,
|
|
270
|
+
removeScriptTags: config.removeScriptTags,
|
|
271
|
+
asyncScriptTags: config.asyncScriptTags,
|
|
272
|
+
removeBlobs: config.removeBlobs,
|
|
273
|
+
removeAttributes: config.removeAttributes,
|
|
274
|
+
stripBuildOriginMetadata: config.stripBuildOriginMetadata,
|
|
275
|
+
noindex:
|
|
276
|
+
config.notFoundRoute !== null &&
|
|
277
|
+
normaliseRoute(route) === normaliseRoute(config.notFoundRoute),
|
|
278
|
+
preconnectOrigins: config.preconnectThirdParty
|
|
279
|
+
? [...result.collector.thirdPartyOrigins].sort()
|
|
280
|
+
: [],
|
|
281
|
+
preloadImages: config.preloadImages
|
|
282
|
+
? [...result.collector.images].sort().map((image) => toPublicPath(image, config.base))
|
|
283
|
+
: [],
|
|
284
|
+
},
|
|
285
|
+
{ serialize: needsString },
|
|
286
|
+
);
|
|
287
|
+
const { stats } = normalised;
|
|
288
|
+
let document = normalised.document;
|
|
289
|
+
let preTransformScripts = new Set();
|
|
290
|
+
let transformedHtml = null;
|
|
291
|
+
let localeChanged = false;
|
|
292
|
+
if (needsString) {
|
|
293
|
+
let output = normalised.html;
|
|
294
|
+
if (config.inlineCss === 'critical') {
|
|
295
|
+
output = await applyCriticalCss({ output, outputDir, config, log, state, caches });
|
|
296
|
+
}
|
|
297
|
+
if (config.minifyHtml) {
|
|
298
|
+
output = await minifyHtml(output, config.minifyHtml, config.minifyCss);
|
|
299
|
+
}
|
|
300
|
+
if (config.transformHtml) {
|
|
301
|
+
preTransformScripts = collectScriptSourcesFromDocument(document);
|
|
302
|
+
const transformed = await config.transformHtml(output, {
|
|
303
|
+
route,
|
|
304
|
+
file: routeToFile(route, config),
|
|
305
|
+
stats,
|
|
306
|
+
config,
|
|
307
|
+
});
|
|
308
|
+
if (transformed !== null && transformed !== undefined && typeof transformed !== 'string') {
|
|
309
|
+
throw new TypeError('transformHtml must return a string, null or undefined');
|
|
310
|
+
}
|
|
311
|
+
output = transformed ?? output;
|
|
312
|
+
}
|
|
313
|
+
transformedHtml = output;
|
|
314
|
+
document = parse(output);
|
|
315
|
+
}
|
|
316
|
+
const noindex =
|
|
317
|
+
documentHasNoindex(document) ||
|
|
318
|
+
(config.notFoundRoute !== null &&
|
|
319
|
+
normaliseRoute(baseRoute) === normaliseRoute(config.notFoundRoute));
|
|
320
|
+
const localeLinks =
|
|
321
|
+
locale && localePlan ? buildLocaleLinks({ locale, baseRoute, plan: localePlan, config }) : null;
|
|
322
|
+
if (localeLinks) {
|
|
323
|
+
localeChanged = true;
|
|
324
|
+
const seo = resolveSeoLocale(config, locale, route);
|
|
325
|
+
applyLocaleMetadata(document, localeLinks, {
|
|
326
|
+
skipAlternates: noindex,
|
|
327
|
+
...seoLocaleMetadataOptions(seo),
|
|
328
|
+
});
|
|
329
|
+
if (seo?.inject) {
|
|
330
|
+
const script = buildSeoLocaleScript(seo);
|
|
331
|
+
const external = config.csp === 'strict' || (config.externalScripts && config.csp !== 'hash');
|
|
332
|
+
if (external) {
|
|
333
|
+
const externalHref = await ensureLocaleScript({
|
|
334
|
+
script,
|
|
335
|
+
outputDir,
|
|
336
|
+
config,
|
|
337
|
+
report,
|
|
338
|
+
log,
|
|
339
|
+
cache: localeScripts,
|
|
340
|
+
});
|
|
341
|
+
if (externalHref) injectSeoLocaleScript(document, script, externalHref);
|
|
342
|
+
} else {
|
|
343
|
+
injectSeoLocaleScript(document, script, null);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
218
347
|
if (
|
|
219
348
|
stats.removedElements > 0 ||
|
|
220
349
|
stats.removedAttributes > 0 ||
|
|
@@ -230,26 +359,7 @@ async function writeRouteOutput({ route, result, outputDir, config, log, state,
|
|
|
230
359
|
log.debug(` inline css: left ${result.inlineCssSkipped.length} stylesheet(s) as links`);
|
|
231
360
|
}
|
|
232
361
|
|
|
233
|
-
let
|
|
234
|
-
if (config.inlineCss === 'critical') {
|
|
235
|
-
output = await applyCriticalCss({ output, outputDir, config, log, state });
|
|
236
|
-
}
|
|
237
|
-
if (config.minifyHtml) {
|
|
238
|
-
output = await minifyHtml(output, config.minifyHtml, config.minifyCss);
|
|
239
|
-
}
|
|
240
|
-
const preTransformScripts = config.transformHtml ? collectScriptSources(output) : new Set();
|
|
241
|
-
if (config.transformHtml) {
|
|
242
|
-
const transformed = await config.transformHtml(output, {
|
|
243
|
-
route,
|
|
244
|
-
file: routeToFile(route, config),
|
|
245
|
-
stats,
|
|
246
|
-
config,
|
|
247
|
-
});
|
|
248
|
-
if (transformed !== null && transformed !== undefined && typeof transformed !== 'string') {
|
|
249
|
-
throw new TypeError('transformHtml must return a string, null or undefined');
|
|
250
|
-
}
|
|
251
|
-
output = transformed ?? output;
|
|
252
|
-
}
|
|
362
|
+
let preloadAdded = 0;
|
|
253
363
|
if (config.preloadLinks) {
|
|
254
364
|
const basePrefix = config.base === '/' ? '' : config.base.slice(0, -1);
|
|
255
365
|
const skipScripts = new Set(
|
|
@@ -262,28 +372,38 @@ async function writeRouteOutput({ route, result, outputDir, config, log, state,
|
|
|
262
372
|
return toPublicPath(appRelative, config.base);
|
|
263
373
|
}),
|
|
264
374
|
);
|
|
265
|
-
|
|
266
|
-
scripts: config.removeScriptTags ? [] : [...result.collector.scripts],
|
|
267
|
-
fonts:
|
|
375
|
+
preloadAdded = injectPreloadLinksInto(document, {
|
|
376
|
+
scripts: config.removeScriptTags ? [] : [...result.collector.scripts].sort(),
|
|
377
|
+
fonts: new Map(
|
|
378
|
+
[...result.collector.fonts].sort(([left], [right]) => left.localeCompare(right)),
|
|
379
|
+
),
|
|
268
380
|
lcpImage: result.lcpImage,
|
|
269
381
|
base: config.base,
|
|
270
382
|
ignoreForPreload: config.ignoreForPreload,
|
|
271
383
|
skipScripts,
|
|
272
384
|
});
|
|
273
385
|
}
|
|
386
|
+
let cspChanged = false;
|
|
274
387
|
if (config.csp === 'hash') {
|
|
275
|
-
const policy =
|
|
388
|
+
const policy = applyCspHashesToDocument(document);
|
|
389
|
+
cspChanged = !policy.existingPolicy;
|
|
276
390
|
if (policy.existingPolicy) {
|
|
277
391
|
log.warn(` ${route}: kept the existing CSP meta and injected no hashes`);
|
|
278
392
|
report.cspSkipped.push(route);
|
|
279
393
|
} else {
|
|
280
|
-
output = policy.html;
|
|
281
394
|
log.debug(
|
|
282
395
|
` csp ${route}: hashed ${policy.hashedScripts} script(s) and ${policy.hashedStyles} style(s)`,
|
|
283
396
|
);
|
|
284
397
|
}
|
|
285
398
|
}
|
|
286
|
-
const meta =
|
|
399
|
+
const meta = collectPageMetaFromDocument(document);
|
|
400
|
+
let output =
|
|
401
|
+
transformedHtml !== null && preloadAdded === 0 && !cspChanged && !localeChanged
|
|
402
|
+
? transformedHtml
|
|
403
|
+
: serialize(document);
|
|
404
|
+
if (localeChanged && config.minifyHtml) {
|
|
405
|
+
output = await minifyHtml(output, config.minifyHtml, config.minifyCss);
|
|
406
|
+
}
|
|
287
407
|
warnOnBuildOriginMetadata({
|
|
288
408
|
origins: stats.metadataOrigins,
|
|
289
409
|
removed: stats.removedMetadata,
|
|
@@ -302,7 +422,7 @@ async function writeRouteOutput({ route, result, outputDir, config, log, state,
|
|
|
302
422
|
}
|
|
303
423
|
const file = await writeRouteHtml({ dir: outputDir, route, html: output, config });
|
|
304
424
|
const resources = config.preloadManifest
|
|
305
|
-
?
|
|
425
|
+
? collectPageResourcesFromDocument(document, { base: config.base, route })
|
|
306
426
|
: null;
|
|
307
427
|
return {
|
|
308
428
|
file: {
|
|
@@ -317,6 +437,9 @@ async function writeRouteOutput({ route, result, outputDir, config, log, state,
|
|
|
317
437
|
meta,
|
|
318
438
|
images: meta.images,
|
|
319
439
|
alternates: meta.alternates,
|
|
440
|
+
localeUrl: locale
|
|
441
|
+
? localePageUrl(locale, baseRoute, config, localePlan?.mode ?? 'path')
|
|
442
|
+
: null,
|
|
320
443
|
inlineStyleAttributes: meta.inlineStyleAttributes,
|
|
321
444
|
buildOriginUrls: buildOrigins,
|
|
322
445
|
},
|
|
@@ -328,13 +451,14 @@ async function writeRouteOutput({ route, result, outputDir, config, log, state,
|
|
|
328
451
|
* Inlines critical CSS, or explains once why it cannot: the strategy reads stylesheets from
|
|
329
452
|
* the output directory, which a command server does not have to serve.
|
|
330
453
|
*/
|
|
331
|
-
async function applyCriticalCss({ output, outputDir, config, log, state }) {
|
|
454
|
+
async function applyCriticalCss({ output, outputDir, config, log, state, caches }) {
|
|
332
455
|
if (!config.serveCmd) {
|
|
333
456
|
return inlineCriticalCss({
|
|
334
457
|
html: output,
|
|
335
458
|
outputDir,
|
|
336
459
|
base: config.base,
|
|
337
460
|
preload: config.criticalCssPreload,
|
|
461
|
+
cache: caches?.criticalCss ?? null,
|
|
338
462
|
});
|
|
339
463
|
}
|
|
340
464
|
if (!state.warnedCriticalCssSkipped) {
|
|
@@ -346,6 +470,59 @@ async function applyCriticalCss({ output, outputDir, config, log, state }) {
|
|
|
346
470
|
return output;
|
|
347
471
|
}
|
|
348
472
|
|
|
473
|
+
/**
|
|
474
|
+
* Reports whether the document already declares a noindex policy. Locale alternates are
|
|
475
|
+
* skipped on those pages, while language attributes are still applied.
|
|
476
|
+
* @param {object} document
|
|
477
|
+
* @returns {boolean}
|
|
478
|
+
*/
|
|
479
|
+
function documentHasNoindex(document) {
|
|
480
|
+
let noindex = false;
|
|
481
|
+
const walk = (node) => {
|
|
482
|
+
if (noindex) return;
|
|
483
|
+
if (
|
|
484
|
+
node.tagName === 'meta' &&
|
|
485
|
+
node.attrs?.some((entry) => entry.name === 'name' && entry.value.toLowerCase() === 'robots')
|
|
486
|
+
) {
|
|
487
|
+
const content = node.attrs.find((entry) => entry.name === 'content')?.value ?? '';
|
|
488
|
+
if (/\b(noindex|none)\b/i.test(content)) noindex = true;
|
|
489
|
+
}
|
|
490
|
+
for (const child of node.childNodes ?? []) walk(child);
|
|
491
|
+
};
|
|
492
|
+
walk(document);
|
|
493
|
+
return noindex;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Writes the external SEO locale bootstrap once per unique script. The file is
|
|
498
|
+
* content-addressed, so reruns and locales that produce the same script share it.
|
|
499
|
+
* @returns {Promise<string | null>} public href, or null when the write failed
|
|
500
|
+
*/
|
|
501
|
+
async function ensureLocaleScript({ script, outputDir, config, report, log, cache }) {
|
|
502
|
+
const hash = createHash('sha256').update(script).digest('hex').slice(0, 16);
|
|
503
|
+
const file = `snappy-seo-locale-${hash}.js`;
|
|
504
|
+
if (cache.has(file)) return cache.get(file);
|
|
505
|
+
const pending = (async () => {
|
|
506
|
+
const bytes = Buffer.byteLength(script);
|
|
507
|
+
if (config.dryRun) {
|
|
508
|
+
report.files.push({ route: null, file, status: 'dry-run', bytes });
|
|
509
|
+
return toPublicPath(`/${file}`, config.base);
|
|
510
|
+
}
|
|
511
|
+
try {
|
|
512
|
+
const written = await writeFileIfChanged({ dir: outputDir, file, contents: script });
|
|
513
|
+
report.files.push({ route: null, ...written });
|
|
514
|
+
log.debug(` wrote ${file}: ${written.status}`);
|
|
515
|
+
return toPublicPath(`/${file}`, config.base);
|
|
516
|
+
} catch (error) {
|
|
517
|
+
report.errors.push({ route: null, message: `locale script write failed: ${error.message}` });
|
|
518
|
+
log.error(` write failed for ${file}: ${error.message}`);
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
})();
|
|
522
|
+
cache.set(file, pending);
|
|
523
|
+
return pending;
|
|
524
|
+
}
|
|
525
|
+
|
|
349
526
|
/**
|
|
350
527
|
* Warns once when metadata contains the build server's origin, which means the page was
|
|
351
528
|
* composed without a public siteUrl and og:url, og:image or canonical would ship wrong.
|
package/src/head/index.js
CHANGED
|
@@ -6,13 +6,103 @@ const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : us
|
|
|
6
6
|
|
|
7
7
|
let sequence = 0;
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Attribute names and values accepted by html, body, title and arbitrary head tags.
|
|
11
|
+
* @typedef {Object<string, string | number | boolean | null | undefined>} HeadAttributes
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One arbitrary head element.
|
|
16
|
+
* @typedef {Object} HeadTag
|
|
17
|
+
* @property {'script' | 'style' | 'noscript' | 'base'} tag
|
|
18
|
+
* @property {HeadAttributes} [attrs]
|
|
19
|
+
* @property {string | number} [text]
|
|
20
|
+
* @property {string} [html] Trusted inner HTML for noscript only.
|
|
21
|
+
* @property {string} [key]
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One hreflang alternate.
|
|
26
|
+
* @typedef {Object} HeadAlternate
|
|
27
|
+
* @property {string} href
|
|
28
|
+
* @property {string} [hreflang]
|
|
29
|
+
* @property {string} [lang]
|
|
30
|
+
* @property {string} [type]
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Extra meta or link attributes. keys and duplicates are consumed by the head layer.
|
|
35
|
+
* @typedef {Object<string, string | number | boolean | null | undefined>} HeadExtra
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One merged head entry reported to onChangeClientState.
|
|
40
|
+
* @typedef {Object} HeadEntry
|
|
41
|
+
* @property {string} kind
|
|
42
|
+
* @property {string} key
|
|
43
|
+
* @property {Record<string, string>} [attrs]
|
|
44
|
+
* @property {string} [text]
|
|
45
|
+
* @property {string} [html]
|
|
46
|
+
* @property {string} [value]
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Entries grouped by kind for onChangeClientState.
|
|
51
|
+
* @typedef {Object} HeadChangeState
|
|
52
|
+
* @property {HeadEntry | null} title
|
|
53
|
+
* @property {HeadEntry[]} meta
|
|
54
|
+
* @property {HeadEntry[]} link
|
|
55
|
+
* @property {HeadEntry[]} script
|
|
56
|
+
* @property {HeadEntry[]} style
|
|
57
|
+
* @property {HeadEntry[]} noscript
|
|
58
|
+
* @property {HeadEntry[]} base
|
|
59
|
+
* @property {HeadEntry[]} htmlAttributes
|
|
60
|
+
* @property {HeadEntry[]} bodyAttributes
|
|
61
|
+
* @property {HeadEntry[]} titleAttributes
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @typedef {(state: HeadChangeState, added: HeadEntry[], removed: HeadEntry[]) => void} HeadChange
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Props accepted by Head and setHead.
|
|
70
|
+
* @typedef {Object} HeadProps
|
|
71
|
+
* @property {string} [title]
|
|
72
|
+
* @property {string} [defaultTitle]
|
|
73
|
+
* @property {string} [titleTemplate]
|
|
74
|
+
* @property {string} [siteName]
|
|
75
|
+
* @property {string} [description]
|
|
76
|
+
* @property {string} [robots]
|
|
77
|
+
* @property {string} [type]
|
|
78
|
+
* @property {string} [locale]
|
|
79
|
+
* @property {string | false} [canonical]
|
|
80
|
+
* @property {string} [image]
|
|
81
|
+
* @property {string[]} [images]
|
|
82
|
+
* @property {string[]} [alternateLocales]
|
|
83
|
+
* @property {string} [lang]
|
|
84
|
+
* @property {'ltr' | 'rtl' | 'auto'} [dir]
|
|
85
|
+
* @property {boolean} [openGraph]
|
|
86
|
+
* @property {boolean} [twitter]
|
|
87
|
+
* @property {HeadAlternate[]} [alternates]
|
|
88
|
+
* @property {unknown} [structuredData]
|
|
89
|
+
* @property {HeadExtra[]} [extra]
|
|
90
|
+
* @property {HeadTag[]} [tags]
|
|
91
|
+
* @property {HeadAttributes} [htmlAttributes]
|
|
92
|
+
* @property {HeadAttributes} [bodyAttributes]
|
|
93
|
+
* @property {HeadAttributes} [titleAttributes]
|
|
94
|
+
* @property {HeadChange} [onChangeClientState]
|
|
95
|
+
*/
|
|
96
|
+
|
|
9
97
|
/**
|
|
10
98
|
* Declares document metadata for the route being rendered: title, description, robots,
|
|
11
|
-
* canonical, Open Graph and Twitter card tags
|
|
12
|
-
*
|
|
13
|
-
* like a site that renders a
|
|
99
|
+
* canonical, Open Graph and Twitter card tags, plus arbitrary head tags and html, body and
|
|
100
|
+
* title attributes. The head is reconciled on every render while the component is mounted,
|
|
101
|
+
* so client-side navigation gives each route its own head exactly like a site that renders a
|
|
102
|
+
* page per request.
|
|
14
103
|
*
|
|
15
104
|
* Renders nothing, so it never affects hydration of the page content.
|
|
105
|
+
* @param {HeadProps} props
|
|
16
106
|
*/
|
|
17
107
|
export function Head(props) {
|
|
18
108
|
const idRef = useRef(null);
|
|
@@ -31,7 +121,7 @@ export function Head(props) {
|
|
|
31
121
|
// A layout effect so a route change updates the head before the browser paints, and a
|
|
32
122
|
// plain effect on the server, where layout effects never run.
|
|
33
123
|
useIsomorphicLayoutEffect(() => {
|
|
34
|
-
registerHead(idRef.current, sequenceRef.current, entries);
|
|
124
|
+
registerHead(idRef.current, sequenceRef.current, entries, props.onChangeClientState ?? null);
|
|
35
125
|
});
|
|
36
126
|
|
|
37
127
|
useEffect(() => () => unregisterHead(idRef.current), []);
|
|
@@ -42,14 +132,22 @@ export function Head(props) {
|
|
|
42
132
|
/**
|
|
43
133
|
* Imperative equivalent for code that is not a React component, for example a vanilla
|
|
44
134
|
* router or an analytics callback.
|
|
135
|
+
* @param {string} id
|
|
136
|
+
* @param {HeadProps} props
|
|
45
137
|
*/
|
|
46
138
|
export function setHead(id, props) {
|
|
47
139
|
sequence += 1;
|
|
48
|
-
registerHead(
|
|
140
|
+
registerHead(
|
|
141
|
+
id,
|
|
142
|
+
sequence,
|
|
143
|
+
buildHeadEntries(props, readMetadataDefaults(), id),
|
|
144
|
+
props.onChangeClientState ?? null,
|
|
145
|
+
);
|
|
49
146
|
}
|
|
50
147
|
|
|
51
148
|
/**
|
|
52
149
|
* Removes metadata previously set with setHead.
|
|
150
|
+
* @param {string} id
|
|
53
151
|
*/
|
|
54
152
|
export function clearHead(id) {
|
|
55
153
|
unregisterHead(id);
|