snappy-prerender 2.6.0-rc.3 → 2.7.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 (48) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/README.md +149 -107
  3. package/docs/limitations.md +58 -0
  4. package/docs/react-snap-migration.md +45 -0
  5. package/docs/report.md +102 -0
  6. package/package.json +9 -5
  7. package/src/cli-init.js +205 -0
  8. package/src/cli.js +159 -168
  9. package/src/core/artifacts.js +112 -0
  10. package/src/core/audit/index.js +114 -0
  11. package/src/core/audit/links.js +63 -0
  12. package/src/core/audit/metadata.js +88 -0
  13. package/src/core/audit/sitemap.js +39 -0
  14. package/src/core/blocking.js +53 -1
  15. package/src/core/browser.js +109 -39
  16. package/src/core/build-origin.js +19 -0
  17. package/src/core/config.js +256 -6
  18. package/src/core/crawl.js +300 -0
  19. package/src/core/csp.js +127 -0
  20. package/src/core/css-url.js +34 -0
  21. package/src/core/destination.js +28 -3
  22. package/src/core/frameworks.js +94 -0
  23. package/src/core/hydration-diff.js +93 -0
  24. package/src/core/init.js +187 -31
  25. package/src/core/inline-css.js +34 -13
  26. package/src/core/metadata-origin.js +11 -2
  27. package/src/core/normalize.js +59 -2
  28. package/src/core/output.js +32 -9
  29. package/src/core/page-content.js +13 -0
  30. package/src/core/page-meta.js +108 -0
  31. package/src/core/preload-links.js +120 -0
  32. package/src/core/preload-manifest.js +61 -7
  33. package/src/core/prune.js +65 -0
  34. package/src/core/render.js +147 -11
  35. package/src/core/report.js +108 -0
  36. package/src/core/resources.js +6 -1
  37. package/src/core/route-config.js +17 -0
  38. package/src/core/route-sources.js +209 -0
  39. package/src/core/routes.js +9 -0
  40. package/src/core/run.js +186 -358
  41. package/src/core/server.js +30 -4
  42. package/src/core/sitemap.js +128 -20
  43. package/src/core/url-policy.js +32 -0
  44. package/src/core/verify.js +103 -49
  45. package/src/core/write.js +363 -0
  46. package/src/head/index.js +2 -2
  47. package/src/head/manager.js +54 -13
  48. package/src/head/tags.js +71 -32
@@ -0,0 +1,363 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { findBuildOriginUrls } from './build-origin.js';
3
+ import { applyCspHashes } from './csp.js';
4
+ import { inlineCriticalCss } from './inline-css.js';
5
+ import { minifyHtml } from './minify.js';
6
+ import { normaliseHtml } from './normalize.js';
7
+ import {
8
+ findOutputCollisions,
9
+ routeToFile,
10
+ routeToScreenshotFile,
11
+ writeBufferIfChanged,
12
+ writeFileIfChanged,
13
+ writeRouteHtml,
14
+ } from './output.js';
15
+ import { collectPageMeta } from './page-meta.js';
16
+ import { collectScriptSources, injectPreloadLinks } from './preload-links.js';
17
+ import { collectPageResources } from './preload-manifest.js';
18
+ import { resolveRouteConfig } from './route-config.js';
19
+ import { normaliseRoute, toPublicPath } from './routes.js';
20
+
21
+ /**
22
+ * Writes every rendered route and returns the routes that can be verified. A write
23
+ * failure is recorded against its route instead of aborting the remaining writes.
24
+ */
25
+ export async function writeAllRoutes({
26
+ rendered,
27
+ outputDir,
28
+ config,
29
+ log,
30
+ report,
31
+ state,
32
+ manifestEntries,
33
+ }) {
34
+ const sorted = [...report.routes].sort();
35
+ const conflicted = reportOutputCollisions({ routes: sorted, config, log, report });
36
+
37
+ await writeStateScripts({ rendered, outputDir, config, log, report });
38
+
39
+ const verifiable = [];
40
+ const hashes = new Map();
41
+ for (const route of sorted) {
42
+ if (conflicted.has(route)) continue;
43
+ try {
44
+ const routeConfig = resolveRouteConfig(config, route);
45
+ const result = rendered.get(route);
46
+ const { file, resources } = await writeRouteOutput({
47
+ route,
48
+ result,
49
+ outputDir,
50
+ config: routeConfig,
51
+ log,
52
+ state,
53
+ report,
54
+ });
55
+ report.files.push(file);
56
+ report.routeDetails.push({
57
+ route,
58
+ order: result.order ?? null,
59
+ file: file.file,
60
+ status: file.status,
61
+ bytes: file.bytes,
62
+ depth: result.depth ?? null,
63
+ attempts: result.attempts ?? 1,
64
+ httpStatus: result.httpStatus ?? null,
65
+ finalUrl: result.finalUrl ?? null,
66
+ empty: result.empty === true,
67
+ notFound: file.notFound === true,
68
+ noindex: file.noindex === true,
69
+ meta: file.meta ?? null,
70
+ images: file.images ?? [],
71
+ alternates: file.alternates ?? [],
72
+ mode: null,
73
+ framework: null,
74
+ hydrationErrors: [],
75
+ pageErrors: result.pageErrors ?? [],
76
+ consoleErrors: result.consoleErrors ?? [],
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);
86
+ }
87
+ verifiable.push(route);
88
+ } catch (error) {
89
+ report.errors.push({ route, message: `write failed: ${error.message}` });
90
+ log.error(` write failed for ${route}: ${error.message}`);
91
+ }
92
+ }
93
+ reportDuplicatePages({ groups: [...hashes.values()], config, log, report });
94
+ return verifiable;
95
+ }
96
+
97
+ /**
98
+ * Reports routes that produced byte-identical HTML. The usual cause is an SPA fallback
99
+ * serving the shell for a path the app does not route, which would otherwise publish a
100
+ * copy of another page under a dead URL.
101
+ */
102
+ function reportDuplicatePages({ groups, config, log, report }) {
103
+ for (const routes of groups) {
104
+ if (routes.length < 2) continue;
105
+ report.duplicatePages.push({ routes });
106
+ log.warn(
107
+ `identical output for ${routes.join(', ')}: check for a dead link served by the SPA fallback`,
108
+ );
109
+ if (config.failOnDuplicateContent) {
110
+ for (const route of routes) {
111
+ report.errors.push({
112
+ route,
113
+ message: `identical output to ${routes.filter((other) => other !== route).join(', ')}`,
114
+ });
115
+ }
116
+ }
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Writes the captured-state scripts routes reference when externalScripts is on. The payload
122
+ * is content-addressed, so routes that inject the same state share one file and a rerun
123
+ * leaves it untouched.
124
+ */
125
+ async function writeStateScripts({ rendered, outputDir, config, log, report }) {
126
+ const seen = new Set();
127
+ for (const [route, result] of rendered) {
128
+ const stateScript = result?.stateScript;
129
+ if (!stateScript || seen.has(stateScript.file)) continue;
130
+ seen.add(stateScript.file);
131
+ if (config.dryRun) {
132
+ report.files.push({
133
+ route,
134
+ file: stateScript.file,
135
+ status: 'dry-run',
136
+ bytes: Buffer.byteLength(stateScript.contents),
137
+ });
138
+ continue;
139
+ }
140
+ try {
141
+ const written = await writeFileIfChanged({
142
+ dir: outputDir,
143
+ file: stateScript.file,
144
+ contents: stateScript.contents,
145
+ });
146
+ report.files.push({ route, ...written });
147
+ log.debug(` wrote ${stateScript.file}: ${written.status}`);
148
+ } catch (error) {
149
+ report.errors.push({ route, message: `write failed: ${error.message}` });
150
+ log.error(` write failed for ${stateScript.file}: ${error.message}`);
151
+ }
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Records an error for every route involved in an output file collision and returns
157
+ * the routes to skip, so an ambiguous write can never overwrite another route.
158
+ */
159
+ function reportOutputCollisions({ routes, config, log, report }) {
160
+ const conflicted = new Set();
161
+ for (const [file, colliding] of findOutputCollisions(routes, config)) {
162
+ for (const route of colliding) {
163
+ conflicted.add(route);
164
+ const others = colliding.filter((other) => other !== route).join(', ');
165
+ report.errors.push({
166
+ route,
167
+ message: `output file collision: ${file} is also written by ${others}`,
168
+ });
169
+ }
170
+ log.error(` output file collision on ${file}: ${colliding.join(', ')}`);
171
+ }
172
+ return conflicted;
173
+ }
174
+
175
+ /**
176
+ * Applies the configured post-processing to one rendered route and writes it: DOM
177
+ * cleanups and link hints, critical CSS extraction, then optional minification.
178
+ */
179
+ async function writeRouteOutput({ route, result, outputDir, config, log, state, report }) {
180
+ if (result.screenshot) {
181
+ const file = routeToScreenshotFile(route, config);
182
+ if (config.dryRun) {
183
+ return {
184
+ file: {
185
+ route,
186
+ file,
187
+ status: 'dry-run',
188
+ bytes: result.screenshot.length,
189
+ },
190
+ resources: null,
191
+ };
192
+ }
193
+ const written = await writeBufferIfChanged({
194
+ dir: outputDir,
195
+ file,
196
+ buffer: result.screenshot,
197
+ });
198
+ return { file: { route, ...written }, resources: null };
199
+ }
200
+
201
+ const { html, stats } = normaliseHtml(result.html, {
202
+ removeStyleTags: config.removeStyleTags,
203
+ removeScriptTags: config.removeScriptTags,
204
+ asyncScriptTags: config.asyncScriptTags,
205
+ removeBlobs: config.removeBlobs,
206
+ removeAttributes: config.removeAttributes,
207
+ stripBuildOriginMetadata: config.stripBuildOriginMetadata,
208
+ noindex:
209
+ config.notFoundRoute !== null &&
210
+ normaliseRoute(route) === normaliseRoute(config.notFoundRoute),
211
+ preconnectOrigins: config.preconnectThirdParty
212
+ ? [...result.collector.thirdPartyOrigins].sort()
213
+ : [],
214
+ preloadImages: config.preloadImages
215
+ ? [...result.collector.images].sort().map((image) => toPublicPath(image, config.base))
216
+ : [],
217
+ });
218
+ if (
219
+ stats.removedElements > 0 ||
220
+ stats.removedAttributes > 0 ||
221
+ stats.dedupedStyles > 0 ||
222
+ stats.hints > 0 ||
223
+ stats.textSeparators > 0
224
+ ) {
225
+ log.debug(
226
+ ` cleaned ${route}: ${stats.removedElements} element(s) removed, ${stats.removedAttributes} attribute(s) removed, ${stats.dedupedStyles} duplicate style(s), ${stats.hints} hint(s), ${stats.textSeparators} text separator(s)`,
227
+ );
228
+ }
229
+ if ((result.inlineCssSkipped ?? []).length > 0) {
230
+ log.debug(` inline css: left ${result.inlineCssSkipped.length} stylesheet(s) as links`);
231
+ }
232
+
233
+ let output = html;
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
+ }
253
+ if (config.preloadLinks) {
254
+ const basePrefix = config.base === '/' ? '' : config.base.slice(0, -1);
255
+ const skipScripts = new Set(
256
+ [...preTransformScripts].map((src) => {
257
+ if (!src.startsWith('/')) return src;
258
+ const appRelative =
259
+ basePrefix && (src === basePrefix || src.startsWith(`${basePrefix}/`))
260
+ ? src.slice(basePrefix.length)
261
+ : src;
262
+ return toPublicPath(appRelative, config.base);
263
+ }),
264
+ );
265
+ output = injectPreloadLinks(output, {
266
+ scripts: config.removeScriptTags ? [] : [...result.collector.scripts],
267
+ fonts: result.collector.fonts,
268
+ lcpImage: result.lcpImage,
269
+ base: config.base,
270
+ ignoreForPreload: config.ignoreForPreload,
271
+ skipScripts,
272
+ });
273
+ }
274
+ if (config.csp === 'hash') {
275
+ const policy = applyCspHashes(output);
276
+ if (policy.existingPolicy) {
277
+ log.warn(` ${route}: kept the existing CSP meta and injected no hashes`);
278
+ report.cspSkipped.push(route);
279
+ } else {
280
+ output = policy.html;
281
+ log.debug(
282
+ ` csp ${route}: hashed ${policy.hashedScripts} script(s) and ${policy.hashedStyles} style(s)`,
283
+ );
284
+ }
285
+ }
286
+ const meta = collectPageMeta(output);
287
+ warnOnBuildOriginMetadata({
288
+ origins: stats.metadataOrigins,
289
+ removed: stats.removedMetadata,
290
+ route,
291
+ log,
292
+ state,
293
+ });
294
+ if (meta.titleCount > 1) report.duplicateTitles.push(route);
295
+ const buildOrigins = findBuildOriginUrls(output);
296
+ if (config.failOnBuildOriginMetadata) {
297
+ if (buildOrigins.length > 0) {
298
+ const message = `metadata still points at the build server: ${buildOrigins.join(', ')}`;
299
+ report.errors.push({ route, message });
300
+ log.error(` ${route}: ${message}`);
301
+ }
302
+ }
303
+ const file = await writeRouteHtml({ dir: outputDir, route, html: output, config });
304
+ const resources = config.preloadManifest
305
+ ? collectPageResources(output, { base: config.base, route })
306
+ : null;
307
+ return {
308
+ file: {
309
+ ...file,
310
+ // Hash the captured DOM before post-processing adds route-specific extras such as
311
+ // the not-found noindex, so a dead link serving the 404 page is still detected.
312
+ hash: createHash('sha256')
313
+ .update(result.html ?? output)
314
+ .digest('hex'),
315
+ noindex: meta.noindex,
316
+ notFound: result.notFound === true,
317
+ meta,
318
+ images: meta.images,
319
+ alternates: meta.alternates,
320
+ inlineStyleAttributes: meta.inlineStyleAttributes,
321
+ buildOriginUrls: buildOrigins,
322
+ },
323
+ resources,
324
+ };
325
+ }
326
+
327
+ /**
328
+ * Inlines critical CSS, or explains once why it cannot: the strategy reads stylesheets from
329
+ * the output directory, which a command server does not have to serve.
330
+ */
331
+ async function applyCriticalCss({ output, outputDir, config, log, state }) {
332
+ if (!config.serveCmd) {
333
+ return inlineCriticalCss({
334
+ html: output,
335
+ outputDir,
336
+ base: config.base,
337
+ preload: config.criticalCssPreload,
338
+ });
339
+ }
340
+ if (!state.warnedCriticalCssSkipped) {
341
+ state.warnedCriticalCssSkipped = true;
342
+ log.warn(
343
+ "inlineCss: 'critical' is skipped with serveCmd: stylesheets are read from the output directory, which the command server does not have to serve",
344
+ );
345
+ }
346
+ return output;
347
+ }
348
+
349
+ /**
350
+ * Warns once when metadata contains the build server's origin, which means the page was
351
+ * composed without a public siteUrl and og:url, og:image or canonical would ship wrong.
352
+ */
353
+ function warnOnBuildOriginMetadata({ origins, removed = 0, route, log, state }) {
354
+ if (state.warnedBuildOriginMetadata || origins.length === 0) return;
355
+ state.warnedBuildOriginMetadata = true;
356
+ log.warn(
357
+ `metadata on ${route} points at the build server (${origins.join(', ')}); ${
358
+ removed > 0
359
+ ? `removed ${removed} affected tag(s). Set metadata.siteUrl so canonical, og:url and og:image match the deployed site`
360
+ : 'set metadata.siteUrl so og:url, og:image and canonical match the deployed site'
361
+ }`,
362
+ );
363
+ }
package/src/head/index.js CHANGED
@@ -26,7 +26,7 @@ export function Head(props) {
26
26
  sequenceRef.current = sequence;
27
27
  }
28
28
 
29
- const entries = buildHeadEntries(props, readMetadataDefaults());
29
+ const entries = buildHeadEntries(props, readMetadataDefaults(), idRef.current);
30
30
 
31
31
  // A layout effect so a route change updates the head before the browser paints, and a
32
32
  // plain effect on the server, where layout effects never run.
@@ -45,7 +45,7 @@ export function Head(props) {
45
45
  */
46
46
  export function setHead(id, props) {
47
47
  sequence += 1;
48
- registerHead(id, sequence, buildHeadEntries(props, readDefaults()));
48
+ registerHead(id, sequence, buildHeadEntries(props, readMetadataDefaults(), id));
49
49
  }
50
50
 
51
51
  /**
@@ -5,6 +5,7 @@ const CREATED_ATTRIBUTE = 'data-snappy-head-created';
5
5
  const stack = [];
6
6
  const snapshots = new WeakMap();
7
7
  const createdElements = new WeakSet();
8
+ const htmlOriginals = new Map();
8
9
  const originals = readRecordedOriginals();
9
10
  publishOriginals();
10
11
 
@@ -56,7 +57,9 @@ function applyMerged() {
56
57
  if (typeof document === 'undefined' || !document.head) return;
57
58
  const head = document.head;
58
59
  const merged = mergeHeadEntries(stack);
59
- const desired = new Map(merged.map((entry) => [headKey(entry), entry]));
60
+ const headEntries = merged.filter((entry) => entry.kind !== 'html');
61
+ const htmlEntries = merged.filter((entry) => entry.kind === 'html');
62
+ const desired = new Map(headEntries.map((entry) => [headKey(entry), entry]));
60
63
 
61
64
  for (const [key, entry] of desired) {
62
65
  const matches = findTags(head, key);
@@ -74,7 +77,27 @@ function applyMerged() {
74
77
  if (!desired.has(key)) releaseTag(element, key);
75
78
  }
76
79
 
77
- orderTags(head, merged);
80
+ applyHtmlAttributes(htmlEntries);
81
+ orderTags(head, headEntries);
82
+ }
83
+
84
+ // Applies lang/dir to the document element and restores the original value when no Head
85
+ // wants the attribute any more.
86
+ function applyHtmlAttributes(entries) {
87
+ if (typeof document === 'undefined' || !document.documentElement) return;
88
+ const root = document.documentElement;
89
+ const desired = new Map(entries.map((entry) => [entry.key, entry.value]));
90
+ for (const [key, value] of desired) {
91
+ if (!htmlOriginals.has(key)) htmlOriginals.set(key, root.getAttribute(key));
92
+ root.setAttribute(key, value);
93
+ }
94
+ for (const key of [...htmlOriginals.keys()]) {
95
+ if (desired.has(key)) continue;
96
+ const previous = htmlOriginals.get(key);
97
+ if (previous === null) root.removeAttribute(key);
98
+ else root.setAttribute(key, previous);
99
+ htmlOriginals.delete(key);
100
+ }
78
101
  }
79
102
 
80
103
  /**
@@ -87,6 +110,9 @@ function findTags(head, key) {
87
110
  const title = head.querySelector('title');
88
111
  return title ? [title] : [];
89
112
  }
113
+ const owned = head.querySelector(`[${OWNER_ATTRIBUTE}="${cssEscape(key)}"]`);
114
+ if (owned) return [owned];
115
+ if (key.startsWith('link:')) return findLinkTags(head, key.slice(5));
90
116
  const [kind, attribute, ...rest] = key.split(':');
91
117
  const value = rest.join(':');
92
118
  if (
@@ -95,18 +121,32 @@ function findTags(head, key) {
95
121
  ) {
96
122
  return [...head.querySelectorAll(`meta[${attribute}="${cssEscape(value)}"]`)];
97
123
  }
98
- if (kind === 'link' && attribute === 'rel') return findRelLinks(head, value);
99
- const owned = head.querySelector(`[${OWNER_ATTRIBUTE}="${cssEscape(key)}"]`);
100
- return owned ? [owned] : [];
124
+ return [];
101
125
  }
102
126
 
103
- // Matches links by rel, and by href when the key carries one, so two links that share a rel
104
- // but point elsewhere stay distinct.
105
- function findRelLinks(head, value) {
106
- const [rel, href] = value.split('|');
107
- const links = [...head.querySelectorAll(`link[rel="${cssEscape(rel)}"]`)];
108
- if (href === undefined) return links;
109
- return links.filter((link) => link.getAttribute('href') === href);
127
+ // Matches links that a previous run or the app itself wrote. Head-owned links carry the
128
+ // owner attribute and are found above; this handles adoption on the first run.
129
+ function findLinkTags(head, spec) {
130
+ const links = [...head.querySelectorAll('link')];
131
+ if (spec.startsWith('rel:')) {
132
+ const [rel, hreflang, href] = spec.slice(4).split('|');
133
+ return links.filter(
134
+ (link) =>
135
+ link.getAttribute('rel') === rel &&
136
+ (hreflang === undefined || link.getAttribute('hreflang') === hreflang) &&
137
+ (href === undefined || link.getAttribute('href') === href),
138
+ );
139
+ }
140
+ if (!spec.includes('=')) return [];
141
+ const attrs = Object.fromEntries(
142
+ spec.split('|').map((pair) => {
143
+ const index = pair.indexOf('=');
144
+ return [pair.slice(0, index), pair.slice(index + 1)];
145
+ }),
146
+ );
147
+ return links.filter((link) =>
148
+ Object.entries(attrs).every(([name, value]) => link.getAttribute(name) === value),
149
+ );
110
150
  }
111
151
 
112
152
  function createTag(head, entry) {
@@ -140,6 +180,7 @@ function writeTag(element, entry, key) {
140
180
  if (!Object.hasOwn(entry.attrs, attribute.name)) element.removeAttribute(attribute.name);
141
181
  }
142
182
  for (const [name, value] of Object.entries(entry.attrs)) element.setAttribute(name, value);
183
+ if (entry.kind === 'script') element.textContent = entry.text;
143
184
  }
144
185
 
145
186
  // Remembers what a tag looked like before the head layer first touched it, so an app-owned
@@ -155,7 +196,7 @@ function snapshotTag(element, key) {
155
196
  }
156
197
  const snapshot = {
157
198
  attrs: Object.fromEntries(attrs),
158
- text: element.tagName === 'TITLE' ? element.textContent : null,
199
+ text: element.tagName === 'TITLE' || element.tagName === 'SCRIPT' ? element.textContent : null,
159
200
  };
160
201
  snapshots.set(element, snapshot);
161
202
  if (createdElements.has(element) || element.hasAttribute(CREATED_ATTRIBUTE)) return;
package/src/head/tags.js CHANGED
@@ -1,4 +1,4 @@
1
- import { collectBuildOrigins } from '../core/metadata-origin.js';
1
+ import { pagePath, withTrailingSlash } from '../core/url-policy.js';
2
2
 
3
3
  const ABSOLUTE = /^https?:\/\//i;
4
4
  const PROTOCOL_RELATIVE = /^\/\//;
@@ -11,12 +11,19 @@ const SINGLETON_LINK_RELS = new Set(['canonical']);
11
11
  * caller did not supply are simply absent, so the component never invents metadata the app
12
12
  * did not ask for.
13
13
  */
14
- export function buildHeadEntries(props = {}, defaults = {}) {
14
+ export function buildHeadEntries(props = {}, defaults = {}, id = 'default') {
15
15
  const entries = [];
16
16
  const template = props.titleTemplate ?? defaults.titleTemplate ?? null;
17
17
  const siteName = props.siteName ?? defaults.siteName ?? null;
18
18
  const image = props.image ?? defaults.defaultImage ?? null;
19
19
 
20
+ if (typeof props.lang === 'string' && props.lang.length > 0) {
21
+ entries.push({ kind: 'html', key: 'lang', value: props.lang });
22
+ }
23
+ if (props.dir === 'ltr' || props.dir === 'rtl' || props.dir === 'auto') {
24
+ entries.push({ kind: 'html', key: 'dir', value: props.dir });
25
+ }
26
+
20
27
  const title = props.title ? applyTitleTemplate(props.title, template, siteName) : null;
21
28
  if (title) entries.push({ kind: 'title', text: title });
22
29
  if (title && props.openGraph !== false) entries.push(metaProp('og:title', title));
@@ -34,7 +41,7 @@ export function buildHeadEntries(props = {}, defaults = {}) {
34
41
  entries.push(metaProp('og:type', props.type ?? 'website'));
35
42
  if (siteName) entries.push(metaProp('og:site_name', siteName));
36
43
  if (props.locale) entries.push(metaProp('og:locale', props.locale));
37
- const url = pageUrl(props.canonical ?? currentPath(), defaults);
44
+ const url = pageUrl(props.canonical ?? defaultPagePath(defaults), defaults);
38
45
  if (url) entries.push(metaProp('og:url', url));
39
46
  if (image) entries.push(metaProp('og:image', absoluteUrl(image, defaults)));
40
47
  }
@@ -45,11 +52,29 @@ export function buildHeadEntries(props = {}, defaults = {}) {
45
52
  }
46
53
 
47
54
  if (props.canonical !== false) {
48
- const url = pageUrl(props.canonical ?? currentPath(), defaults);
55
+ const url = pageUrl(props.canonical ?? defaultPagePath(defaults), defaults);
49
56
  if (url)
50
57
  entries.push({ kind: 'link', key: 'rel:canonical', attrs: { rel: 'canonical', href: url } });
51
58
  }
52
59
 
60
+ for (const alternate of props.alternates ?? []) {
61
+ const href = pageUrl(alternate.href, defaults);
62
+ const hreflang = alternate.hreflang ?? alternate.lang;
63
+ if (!href || !hreflang) continue;
64
+ const attrs = { rel: 'alternate', hreflang, href };
65
+ if (alternate.type) attrs.type = alternate.type;
66
+ entries.push({ kind: 'link', key: `rel:alternate|${hreflang}|${href}`, attrs });
67
+ }
68
+
69
+ if (props.structuredData !== undefined && props.structuredData !== null) {
70
+ entries.push({
71
+ kind: 'script',
72
+ key: `script:ld+json:${id}`,
73
+ attrs: { type: 'application/ld+json' },
74
+ text: serializeStructuredData(props.structuredData),
75
+ });
76
+ }
77
+
53
78
  for (const extra of props.extra ?? []) entries.push(extraEntry(extra));
54
79
 
55
80
  return entries;
@@ -77,6 +102,8 @@ export function mergeHeadEntries(stack = []) {
77
102
  */
78
103
  export function headKey(entry) {
79
104
  if (entry.kind === 'title') return 'title';
105
+ if (entry.kind === 'html') return `html:${entry.key}`;
106
+ if (entry.kind === 'script') return entry.key;
80
107
  if (entry.kind === 'link') return `link:${entry.key ?? attrsKey(entry.attrs)}`;
81
108
  if (entry.key) return `meta:${entry.key}`;
82
109
  const name = attrsValue(entry.attrs, 'name');
@@ -88,14 +115,6 @@ export function headKey(entry) {
88
115
  return `meta:${attrsKey(entry.attrs)}`;
89
116
  }
90
117
 
91
- /**
92
- * Collects the absolute metadata URLs in a document that point at the machine the
93
- * prerenderer ran on, which happens when no public siteUrl was configured.
94
- */
95
- export function findBuildOriginUrls(html) {
96
- return collectBuildOrigins(html);
97
- }
98
-
99
118
  // Applies the site's title template unless the title already carries its suffix or is the
100
119
  // site name itself, so an app that composed its own title never gets it suffixed twice.
101
120
  function applyTitleTemplate(title, template, siteName) {
@@ -139,23 +158,24 @@ function encodeSpaces(url) {
139
158
  return url.replace(/ /g, '%20');
140
159
  }
141
160
 
142
- // Applies the site's trailing slash convention to a URL, leaving the root alone.
143
- function withTrailingSlash(url, convention) {
144
- if (convention !== 'always' && convention !== 'never') return url;
145
- const [path, query] = url.split('?');
146
- const isRoot = /^https?:\/\/[^/]+\/?$/i.test(path);
147
- if (convention === 'always') {
148
- const next = path.endsWith('/') ? path : `${path}/`;
149
- return query ? `${next}?${query}` : next;
150
- }
151
- if (isRoot) return query ? `${path.replace(/\/$/, '/')}?${query}` : path.replace(/\/?$/, '/');
152
- const next = path.replace(/\/+$/, '');
153
- return query ? `${next}?${query}` : next;
161
+ // Reads the route being rendered, which under prerendering is the page being captured,
162
+ // and removes the public base path so absoluteUrl does not add it a second time.
163
+ function currentPath(defaults = {}) {
164
+ const pathname = typeof location === 'undefined' ? '/' : location.pathname;
165
+ const base = defaults.base ?? '/';
166
+ if (base === '/') return pathname;
167
+ const prefix = base.replace(/\/$/, '');
168
+ if (pathname === prefix) return '/';
169
+ if (pathname.startsWith(`${prefix}/`)) return pathname.slice(prefix.length);
170
+ return pathname;
154
171
  }
155
172
 
156
- // Reads the route being rendered, which under prerendering is the page being captured.
157
- function currentPath() {
158
- return typeof location === 'undefined' ? '/' : location.pathname;
173
+ // Builds the default canonical path from the current route and the output's URL policy.
174
+ function defaultPagePath(defaults) {
175
+ return pagePath(currentPath(defaults), {
176
+ flatOutput: defaults.flatOutput === true,
177
+ trailingSlash: defaults.trailingSlash,
178
+ });
159
179
  }
160
180
 
161
181
  function metaName(name, content) {
@@ -202,10 +222,29 @@ function attrsValue(attrs = {}, name) {
202
222
  return attrs[name];
203
223
  }
204
224
 
205
- // Keeps output deterministic: title, then name/property metas, then links.
225
+ // Serialises JSON-LD so a value can never close the script element. The escapes remain
226
+ // valid JSON, so crawlers parse the same object the caller supplied.
227
+ function serializeStructuredData(value) {
228
+ return JSON.stringify(value).replace(
229
+ /[<>&\u2028\u2029]/g,
230
+ (char) =>
231
+ ({
232
+ '<': '\\u003C',
233
+ '>': '\\u003E',
234
+ '&': '\\u0026',
235
+ '\u2028': '\\u2028',
236
+ '\u2029': '\\u2029',
237
+ })[char],
238
+ );
239
+ }
240
+
241
+ // Keeps output deterministic: title, metas, links, JSON-LD scripts, then html attributes.
206
242
  function sortMetasFirst(entries) {
207
- return [...entries].sort((a, b) => {
208
- const rank = (entry) => (entry.kind === 'meta' ? 0 : 1);
209
- return rank(a) - rank(b);
210
- });
243
+ const rank = (entry) => {
244
+ if (entry.kind === 'meta') return 0;
245
+ if (entry.kind === 'link') return 1;
246
+ if (entry.kind === 'script') return 2;
247
+ return 3;
248
+ };
249
+ return [...entries].sort((a, b) => rank(a) - rank(b));
211
250
  }