snappy-prerender 2.6.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +281 -101
  3. package/assets/snappy-logo.png +0 -0
  4. package/docs/limitations.md +94 -0
  5. package/docs/react-snap-migration.md +45 -0
  6. package/docs/report.md +135 -0
  7. package/package.json +4 -1
  8. package/src/cli-init.js +205 -0
  9. package/src/cli-options.js +258 -0
  10. package/src/cli.js +128 -309
  11. package/src/core/artifacts.js +2 -0
  12. package/src/core/audit/index.js +114 -0
  13. package/src/core/audit/links.js +74 -0
  14. package/src/core/audit/metadata.js +197 -0
  15. package/src/core/audit/sitemap.js +39 -0
  16. package/src/core/clock.js +60 -0
  17. package/src/core/config-validation.js +557 -0
  18. package/src/core/config-warnings.js +150 -0
  19. package/src/core/config.js +189 -136
  20. package/src/core/crawl.js +362 -0
  21. package/src/core/csp.js +138 -0
  22. package/src/core/destination.js +23 -7
  23. package/src/core/frameworks.js +94 -0
  24. package/src/core/hydration-diff.js +93 -0
  25. package/src/core/hydration-policy.js +38 -0
  26. package/src/core/init.js +143 -30
  27. package/src/core/inline-css.js +52 -9
  28. package/src/core/locale-context.js +63 -0
  29. package/src/core/locales.js +385 -0
  30. package/src/core/normalize.js +37 -6
  31. package/src/core/page-content.js +57 -7
  32. package/src/core/page-meta.js +122 -0
  33. package/src/core/preload-links.js +133 -0
  34. package/src/core/preload-manifest.js +6 -2
  35. package/src/core/render.js +126 -35
  36. package/src/core/report.js +116 -0
  37. package/src/core/resources.js +5 -1
  38. package/src/core/route-config.js +17 -0
  39. package/src/core/route-sources.js +220 -0
  40. package/src/core/run.js +156 -131
  41. package/src/core/seo-locale.js +234 -0
  42. package/src/core/settle.js +2 -4
  43. package/src/core/sitemap.js +90 -14
  44. package/src/core/text-separators.js +11 -7
  45. package/src/core/verify.js +164 -71
  46. package/src/core/write.js +314 -52
  47. package/src/head/index.js +104 -6
  48. package/src/head/manager.js +304 -48
  49. package/src/head/tags.js +349 -30
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 { collectPageResources } from './preload-manifest.js';
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
- for (const route of sorted) {
37
- if (conflicted.has(route)) continue;
38
- try {
39
- const { file, resources } = await writeRouteOutput({
40
- route,
41
- result: rendered.get(route),
42
- outputDir,
43
- config,
44
- log,
45
- state,
46
- report,
47
- });
48
- report.files.push(file);
49
- if (resources) manifestEntries.push({ route, ...resources });
50
- if (file.hash) {
51
- const group = hashes.get(file.hash) ?? [];
52
- group.push(route);
53
- 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 };
54
92
  }
55
- verifiable.push(route);
56
- } catch (error) {
57
- report.errors.push({ route, message: `write failed: ${error.message}` });
58
- log.error(` write failed for ${route}: ${error.message}`);
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({ route, result, outputDir, config, log, state, report }) {
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 { html, stats } = normaliseHtml(result.html, {
170
- removeStyleTags: config.removeStyleTags,
171
- removeScriptTags: config.removeScriptTags,
172
- asyncScriptTags: config.asyncScriptTags,
173
- removeBlobs: config.removeBlobs,
174
- removeAttributes: config.removeAttributes,
175
- stripBuildOriginMetadata: config.stripBuildOriginMetadata,
176
- noindex:
177
- config.notFoundRoute !== null &&
178
- normaliseRoute(route) === normaliseRoute(config.notFoundRoute),
179
- preconnectOrigins: config.preconnectThirdParty
180
- ? [...result.collector.thirdPartyOrigins].sort()
181
- : [],
182
- preloadImages: config.preloadImages
183
- ? [...result.collector.images].sort().map((image) => toPublicPath(image, config.base))
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 output = html;
202
- if (config.inlineCss === 'critical') {
203
- output = await applyCriticalCss({ output, outputDir, config, log, state });
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
- if (config.minifyHtml) {
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 (stats.titleElements > 1) report.duplicateTitles.push(route);
414
+ if (meta.titleCount > 1) report.duplicateTitles.push(route);
415
+ const buildOrigins = findBuildOriginUrls(output);
216
416
  if (config.failOnBuildOriginMetadata) {
217
- const origins = findBuildOriginUrls(output);
218
- if (origins.length > 0) {
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
- ? collectPageResources(output, { base: config.base, route })
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: stats.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. The head is reconciled on every render while
12
- * the component is mounted, so client-side navigation gives each route its own head exactly
13
- * like a site that renders a page per request.
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(id, sequence, buildHeadEntries(props, readMetadataDefaults()));
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);