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
package/src/core/write.js
CHANGED
|
@@ -1,17 +1,34 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { parse, serialize } from 'parse5';
|
|
2
3
|
import { findBuildOriginUrls } from './build-origin.js';
|
|
4
|
+
import { applyCspHashesToDocument } from './csp.js';
|
|
5
|
+
import { resolveHydrationPolicy } from './hydration-policy.js';
|
|
3
6
|
import { inlineCriticalCss } from './inline-css.js';
|
|
7
|
+
import { applyLocaleMetadata, buildLocaleLinks, localePageUrl } from './locales.js';
|
|
4
8
|
import { minifyHtml } from './minify.js';
|
|
5
9
|
import { normaliseHtml } from './normalize.js';
|
|
6
10
|
import {
|
|
7
11
|
findOutputCollisions,
|
|
12
|
+
routeToFile,
|
|
8
13
|
routeToScreenshotFile,
|
|
9
14
|
writeBufferIfChanged,
|
|
10
15
|
writeFileIfChanged,
|
|
11
16
|
writeRouteHtml,
|
|
12
17
|
} from './output.js';
|
|
13
|
-
import {
|
|
18
|
+
import { collectPageMetaFromDocument } from './page-meta.js';
|
|
19
|
+
import { collectScriptSourcesFromDocument, injectPreloadLinksInto } from './preload-links.js';
|
|
20
|
+
import { collectPageResourcesFromDocument } from './preload-manifest.js';
|
|
21
|
+
import { resolveRouteConfig } from './route-config.js';
|
|
14
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;
|
|
15
32
|
|
|
16
33
|
/**
|
|
17
34
|
* Writes every rendered route and returns the routes that can be verified. A write
|
|
@@ -19,12 +36,15 @@ import { normaliseRoute, toPublicPath } from './routes.js';
|
|
|
19
36
|
*/
|
|
20
37
|
export async function writeAllRoutes({
|
|
21
38
|
rendered,
|
|
39
|
+
routeLocales = new Map(),
|
|
40
|
+
localePlan = null,
|
|
22
41
|
outputDir,
|
|
23
42
|
config,
|
|
24
43
|
log,
|
|
25
44
|
report,
|
|
26
45
|
state,
|
|
27
46
|
manifestEntries,
|
|
47
|
+
caches = null,
|
|
28
48
|
}) {
|
|
29
49
|
const sorted = [...report.routes].sort();
|
|
30
50
|
const conflicted = reportOutputCollisions({ routes: sorted, config, log, report });
|
|
@@ -33,30 +53,92 @@ export async function writeAllRoutes({
|
|
|
33
53
|
|
|
34
54
|
const verifiable = [];
|
|
35
55
|
const hashes = new Map();
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 };
|
|
54
92
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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);
|
|
59
140
|
}
|
|
141
|
+
verifiable.push(route);
|
|
60
142
|
}
|
|
61
143
|
reportDuplicatePages({ groups: [...hashes.values()], config, log, report });
|
|
62
144
|
return verifiable;
|
|
@@ -144,7 +226,20 @@ function reportOutputCollisions({ routes, config, log, report }) {
|
|
|
144
226
|
* Applies the configured post-processing to one rendered route and writes it: DOM
|
|
145
227
|
* cleanups and link hints, critical CSS extraction, then optional minification.
|
|
146
228
|
*/
|
|
147
|
-
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
|
+
}) {
|
|
148
243
|
if (result.screenshot) {
|
|
149
244
|
const file = routeToScreenshotFile(route, config);
|
|
150
245
|
if (config.dryRun) {
|
|
@@ -166,23 +261,89 @@ async function writeRouteOutput({ route, result, outputDir, config, log, state,
|
|
|
166
261
|
return { file: { route, ...written }, resources: null };
|
|
167
262
|
}
|
|
168
263
|
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
config.
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
+
}
|
|
186
347
|
if (
|
|
187
348
|
stats.removedElements > 0 ||
|
|
188
349
|
stats.removedAttributes > 0 ||
|
|
@@ -198,11 +359,49 @@ async function writeRouteOutput({ route, result, outputDir, config, log, state,
|
|
|
198
359
|
log.debug(` inline css: left ${result.inlineCssSkipped.length} stylesheet(s) as links`);
|
|
199
360
|
}
|
|
200
361
|
|
|
201
|
-
let
|
|
202
|
-
if (config.
|
|
203
|
-
|
|
362
|
+
let preloadAdded = 0;
|
|
363
|
+
if (config.preloadLinks) {
|
|
364
|
+
const basePrefix = config.base === '/' ? '' : config.base.slice(0, -1);
|
|
365
|
+
const skipScripts = new Set(
|
|
366
|
+
[...preTransformScripts].map((src) => {
|
|
367
|
+
if (!src.startsWith('/')) return src;
|
|
368
|
+
const appRelative =
|
|
369
|
+
basePrefix && (src === basePrefix || src.startsWith(`${basePrefix}/`))
|
|
370
|
+
? src.slice(basePrefix.length)
|
|
371
|
+
: src;
|
|
372
|
+
return toPublicPath(appRelative, config.base);
|
|
373
|
+
}),
|
|
374
|
+
);
|
|
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
|
+
),
|
|
380
|
+
lcpImage: result.lcpImage,
|
|
381
|
+
base: config.base,
|
|
382
|
+
ignoreForPreload: config.ignoreForPreload,
|
|
383
|
+
skipScripts,
|
|
384
|
+
});
|
|
204
385
|
}
|
|
205
|
-
|
|
386
|
+
let cspChanged = false;
|
|
387
|
+
if (config.csp === 'hash') {
|
|
388
|
+
const policy = applyCspHashesToDocument(document);
|
|
389
|
+
cspChanged = !policy.existingPolicy;
|
|
390
|
+
if (policy.existingPolicy) {
|
|
391
|
+
log.warn(` ${route}: kept the existing CSP meta and injected no hashes`);
|
|
392
|
+
report.cspSkipped.push(route);
|
|
393
|
+
} else {
|
|
394
|
+
log.debug(
|
|
395
|
+
` csp ${route}: hashed ${policy.hashedScripts} script(s) and ${policy.hashedStyles} style(s)`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
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) {
|
|
206
405
|
output = await minifyHtml(output, config.minifyHtml, config.minifyCss);
|
|
207
406
|
}
|
|
208
407
|
warnOnBuildOriginMetadata({
|
|
@@ -212,18 +411,18 @@ async function writeRouteOutput({ route, result, outputDir, config, log, state,
|
|
|
212
411
|
log,
|
|
213
412
|
state,
|
|
214
413
|
});
|
|
215
|
-
if (
|
|
414
|
+
if (meta.titleCount > 1) report.duplicateTitles.push(route);
|
|
415
|
+
const buildOrigins = findBuildOriginUrls(output);
|
|
216
416
|
if (config.failOnBuildOriginMetadata) {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const message = `metadata still points at the build server: ${origins.join(', ')}`;
|
|
417
|
+
if (buildOrigins.length > 0) {
|
|
418
|
+
const message = `metadata still points at the build server: ${buildOrigins.join(', ')}`;
|
|
220
419
|
report.errors.push({ route, message });
|
|
221
420
|
log.error(` ${route}: ${message}`);
|
|
222
421
|
}
|
|
223
422
|
}
|
|
224
423
|
const file = await writeRouteHtml({ dir: outputDir, route, html: output, config });
|
|
225
424
|
const resources = config.preloadManifest
|
|
226
|
-
?
|
|
425
|
+
? collectPageResourcesFromDocument(document, { base: config.base, route })
|
|
227
426
|
: null;
|
|
228
427
|
return {
|
|
229
428
|
file: {
|
|
@@ -233,7 +432,16 @@ async function writeRouteOutput({ route, result, outputDir, config, log, state,
|
|
|
233
432
|
hash: createHash('sha256')
|
|
234
433
|
.update(result.html ?? output)
|
|
235
434
|
.digest('hex'),
|
|
236
|
-
noindex:
|
|
435
|
+
noindex: meta.noindex,
|
|
436
|
+
notFound: result.notFound === true,
|
|
437
|
+
meta,
|
|
438
|
+
images: meta.images,
|
|
439
|
+
alternates: meta.alternates,
|
|
440
|
+
localeUrl: locale
|
|
441
|
+
? localePageUrl(locale, baseRoute, config, localePlan?.mode ?? 'path')
|
|
442
|
+
: null,
|
|
443
|
+
inlineStyleAttributes: meta.inlineStyleAttributes,
|
|
444
|
+
buildOriginUrls: buildOrigins,
|
|
237
445
|
},
|
|
238
446
|
resources,
|
|
239
447
|
};
|
|
@@ -243,13 +451,14 @@ async function writeRouteOutput({ route, result, outputDir, config, log, state,
|
|
|
243
451
|
* Inlines critical CSS, or explains once why it cannot: the strategy reads stylesheets from
|
|
244
452
|
* the output directory, which a command server does not have to serve.
|
|
245
453
|
*/
|
|
246
|
-
async function applyCriticalCss({ output, outputDir, config, log, state }) {
|
|
454
|
+
async function applyCriticalCss({ output, outputDir, config, log, state, caches }) {
|
|
247
455
|
if (!config.serveCmd) {
|
|
248
456
|
return inlineCriticalCss({
|
|
249
457
|
html: output,
|
|
250
458
|
outputDir,
|
|
251
459
|
base: config.base,
|
|
252
460
|
preload: config.criticalCssPreload,
|
|
461
|
+
cache: caches?.criticalCss ?? null,
|
|
253
462
|
});
|
|
254
463
|
}
|
|
255
464
|
if (!state.warnedCriticalCssSkipped) {
|
|
@@ -261,6 +470,59 @@ async function applyCriticalCss({ output, outputDir, config, log, state }) {
|
|
|
261
470
|
return output;
|
|
262
471
|
}
|
|
263
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
|
+
|
|
264
526
|
/**
|
|
265
527
|
* Warns once when metadata contains the build server's origin, which means the page was
|
|
266
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);
|
|
@@ -26,12 +116,12 @@ export function Head(props) {
|
|
|
26
116
|
sequenceRef.current = sequence;
|
|
27
117
|
}
|
|
28
118
|
|
|
29
|
-
const entries = buildHeadEntries(props, readMetadataDefaults());
|
|
119
|
+
const entries = buildHeadEntries(props, readMetadataDefaults(), idRef.current);
|
|
30
120
|
|
|
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);
|