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
@@ -2,10 +2,24 @@ import { headKey, mergeHeadEntries } from './tags.js';
2
2
 
3
3
  const OWNER_ATTRIBUTE = 'data-snappy-head';
4
4
  const CREATED_ATTRIBUTE = 'data-snappy-head-created';
5
+ const ATTRIBUTE_KINDS = new Set(['html', 'body', 'titleAttribute']);
6
+ const ORIGINAL_HTML = '__html';
7
+ const ORIGINAL_BODY = '__body';
8
+ const ORIGINAL_TITLE = '__title';
5
9
  const stack = [];
6
10
  const snapshots = new WeakMap();
7
11
  const createdElements = new WeakSet();
8
- const originals = readRecordedOriginals();
12
+ const adoptedElements = new WeakSet();
13
+ const recorded = readRecordedOriginals();
14
+ const originals = { ...recorded };
15
+ delete originals[ORIGINAL_HTML];
16
+ delete originals[ORIGINAL_BODY];
17
+ delete originals[ORIGINAL_TITLE];
18
+ const htmlOriginals = new Map(Object.entries(recorded[ORIGINAL_HTML] ?? {}));
19
+ const bodyOriginals = new Map(Object.entries(recorded[ORIGINAL_BODY] ?? {}));
20
+ const titleOriginals = new Map(Object.entries(recorded[ORIGINAL_TITLE] ?? {}));
21
+ let previousState = null;
22
+ let externalTitleElement = null;
9
23
  publishOriginals();
10
24
 
11
25
  /**
@@ -28,11 +42,11 @@ export function readMetadataDefaults() {
28
42
  /**
29
43
  * Registers one mounted Head's tag list and reconciles the document head with the merged
30
44
  * result. Called on every render, so a client-side route change updates the head without a
31
- * page load.
45
+ * page load. The optional callback observes real DOM changes only.
32
46
  */
33
- export function registerHead(id, sequence, entries) {
47
+ export function registerHead(id, sequence, entries, onChangeClientState = null) {
34
48
  const index = stack.findIndex((item) => item.id === id);
35
- const item = { id, sequence, entries };
49
+ const item = { id, sequence, entries, onChangeClientState };
36
50
  if (index === -1) stack.push(item);
37
51
  else stack[index] = item;
38
52
  applyMerged();
@@ -50,21 +64,26 @@ export function unregisterHead(id) {
50
64
  }
51
65
 
52
66
  // Brings the head in line with the merged entries: create, adopt or update each tag, drop
53
- // duplicates of the same identity, release tags that are no longer wanted, then restore a
54
- // deterministic order.
67
+ // duplicates of the same identity, release tags that are no longer wanted, apply element
68
+ // attributes, then restore a deterministic order.
55
69
  function applyMerged() {
56
70
  if (typeof document === 'undefined' || !document.head) return;
57
71
  const head = document.head;
58
72
  const merged = mergeHeadEntries(stack);
59
- const desired = new Map(merged.map((entry) => [headKey(entry), entry]));
73
+ const headEntries = merged.filter((entry) => !ATTRIBUTE_KINDS.has(entry.kind));
74
+ const htmlEntries = merged.filter((entry) => entry.kind === 'html');
75
+ const bodyEntries = merged.filter((entry) => entry.kind === 'body');
76
+ const titleEntries = merged.filter((entry) => entry.kind === 'titleAttribute');
77
+ const desired = new Map(headEntries.map((entry) => [headKey(entry), entry]));
78
+ const skipTitle = adoptExternalTitle(head, desired.get('title'));
60
79
 
61
80
  for (const [key, entry] of desired) {
62
- const matches = findTags(head, key);
81
+ if (key === 'title' && skipTitle) continue;
82
+ const matches = findTags(head, key, entry);
63
83
  const element = matches[0] ?? createTag(head, entry);
64
84
  writeTag(element, entry, key);
65
- // A second tag with the same identity is one the page should not ship twice. Titles are
66
- // exempt: React 19 native metadata and the head layer can each write one.
67
- if (key !== 'title') {
85
+ // A second tag with the same identity is one the page should not ship twice.
86
+ if (matches.length > 1) {
68
87
  for (const duplicate of matches.slice(1)) removeDuplicate(duplicate, key);
69
88
  }
70
89
  }
@@ -74,41 +93,141 @@ function applyMerged() {
74
93
  if (!desired.has(key)) releaseTag(element, key);
75
94
  }
76
95
 
77
- orderTags(head, merged);
96
+ applyAttributes(document.documentElement, htmlEntries, htmlOriginals);
97
+ applyAttributes(document.body, bodyEntries, bodyOriginals);
98
+ applyTitleAttributes(head, titleEntries);
99
+ orderTags(head, headEntries);
100
+ notifyState(merged);
101
+ }
102
+
103
+ // Leaves an already-correct, app-owned title untouched. React 19 hoists native titles and
104
+ // removes them on navigation itself; adopting that node would make React's own removal fail.
105
+ // A title whose text already matches is therefore treated as externally owned.
106
+ function adoptExternalTitle(head, entry) {
107
+ if (!entry) {
108
+ externalTitleElement = null;
109
+ return false;
110
+ }
111
+ const titles = [...head.querySelectorAll('title')];
112
+ const external = titles.find(
113
+ (title) =>
114
+ !title.hasAttribute(OWNER_ATTRIBUTE) &&
115
+ !title.hasAttribute(CREATED_ATTRIBUTE) &&
116
+ !snapshots.has(title) &&
117
+ title.textContent === entry.text,
118
+ );
119
+ if (!external) {
120
+ externalTitleElement = null;
121
+ return false;
122
+ }
123
+ externalTitleElement = external;
124
+ for (const title of titles) {
125
+ if (title !== external) title.remove();
126
+ }
127
+ return true;
128
+ }
129
+
130
+ // Applies title attributes, leaving them alone when the document has no title to decorate.
131
+ function applyTitleAttributes(head, entries) {
132
+ const title = head.querySelector('title');
133
+ if (!title || title === externalTitleElement) return;
134
+ applyAttributes(title, entries, titleOriginals);
135
+ }
136
+
137
+ // Applies one attribute family to an element and restores the original value once no Head
138
+ // wants the attribute any more. Recorded values survive a prerender so client navigation
139
+ // can restore what the document shipped with.
140
+ function applyAttributes(element, entries, originals) {
141
+ if (!element) return;
142
+ const desired = new Map(entries.map((entry) => [entry.key, String(entry.value)]));
143
+ for (const [key, value] of desired) {
144
+ const current = element.getAttribute(key);
145
+ // Record only a real change. When the Head writes the value the document already had,
146
+ // leaving it in place after unmount is the correct restoration.
147
+ if (!originals.has(key) && current !== value) {
148
+ originals.set(key, current);
149
+ publishOriginals();
150
+ }
151
+ element.setAttribute(key, value);
152
+ }
153
+ for (const key of [...originals.keys()]) {
154
+ if (desired.has(key)) continue;
155
+ const previous = originals.get(key);
156
+ if (previous === null) element.removeAttribute(key);
157
+ else element.setAttribute(key, previous);
158
+ originals.delete(key);
159
+ publishOriginals();
160
+ }
78
161
  }
79
162
 
80
163
  /**
81
164
  * Finds every tag a key refers to, in document order. Matching on the attribute a crawler
82
- * reads means a tag already in the document — including one in prerendered HTML — is
83
- * updated in place instead of duplicated.
165
+ * reads means a tag already in the document — including one in prerendered HTML or hoisted
166
+ * by React 19 — is updated in place instead of duplicated.
84
167
  */
85
- function findTags(head, key) {
168
+ function findTags(head, key, entry) {
86
169
  if (key === 'title') {
87
- const title = head.querySelector('title');
88
- return title ? [title] : [];
89
- }
90
- const [kind, attribute, ...rest] = key.split(':');
91
- const value = rest.join(':');
92
- if (
93
- kind === 'meta' &&
94
- (attribute === 'name' || attribute === 'property' || attribute === 'http-equiv')
95
- ) {
96
- return [...head.querySelectorAll(`meta[${attribute}="${cssEscape(value)}"]`)];
97
- }
98
- if (kind === 'link' && attribute === 'rel') return findRelLinks(head, value);
170
+ // Prefer the last title so a React 19 hoisted title keeps its DOM node and the older
171
+ // template title is the one removed as a duplicate.
172
+ return [...head.querySelectorAll('title')].reverse();
173
+ }
99
174
  const owned = head.querySelector(`[${OWNER_ATTRIBUTE}="${cssEscape(key)}"]`);
100
- return owned ? [owned] : [];
175
+ if (owned) return [owned];
176
+ if (entry?.match) {
177
+ const matches = [...head.querySelectorAll(entry.kind)].filter((element) =>
178
+ entry.match.every(([name, value]) => element.getAttribute(name) === value),
179
+ );
180
+ if (matches.length > 0) return matches;
181
+ if (entry.adopt) {
182
+ return [...head.querySelectorAll(entry.kind)]
183
+ .filter(
184
+ (element) =>
185
+ element.getAttribute(OWNER_ATTRIBUTE) === null &&
186
+ entry.adopt.every(([name, value]) => element.getAttribute(name) === value),
187
+ )
188
+ .slice(0, 1);
189
+ }
190
+ return [];
191
+ }
192
+ return findUntrackedTag(head, entry);
101
193
  }
102
194
 
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);
195
+ // Finds an app-written script, style, noscript or base tag by the content it carries, so
196
+ // repeated renders adopt the same element instead of appending a copy.
197
+ function findUntrackedTag(head, entry) {
198
+ if (entry.kind === 'base') {
199
+ const base = head.querySelector('base');
200
+ return base ? [base] : [];
201
+ }
202
+ if (entry.kind === 'script') {
203
+ const src = entry.attrs?.src;
204
+ const scripts = [...head.querySelectorAll('script')];
205
+ if (src) {
206
+ const match = scripts.find((element) => element.getAttribute('src') === src);
207
+ return match ? [match] : [];
208
+ }
209
+ const text = unescapeClosingTag(entry.text ?? '', 'script');
210
+ const match = scripts.find((element) => element.textContent === text);
211
+ return match ? [match] : [];
212
+ }
213
+ if (entry.kind === 'style') {
214
+ const text = unescapeClosingTag(entry.text ?? '', 'style');
215
+ const match = [...head.querySelectorAll('style')].find(
216
+ (element) => element.textContent === text,
217
+ );
218
+ return match ? [match] : [];
219
+ }
220
+ if (entry.kind === 'noscript') {
221
+ const html = entry.html ?? entry.text ?? '';
222
+ const match = [...head.querySelectorAll('noscript')].find(
223
+ (element) => element.innerHTML === html,
224
+ );
225
+ return match ? [match] : [];
226
+ }
227
+ return [];
110
228
  }
111
229
 
230
+ // Creates the element for an entry and records that the head layer owns it.
112
231
  function createTag(head, entry) {
113
232
  const element = document.createElement(entry.kind);
114
233
  createdElements.add(element);
@@ -129,17 +248,34 @@ function removeDuplicate(element, key) {
129
248
  // page load can still tell it apart from one the app wrote and remove rather than restore it.
130
249
  function writeTag(element, entry, key) {
131
250
  snapshotTag(element, key);
251
+ if (!createdElements.has(element) && !element.hasAttribute(CREATED_ATTRIBUTE)) {
252
+ adoptedElements.add(element);
253
+ }
132
254
  element.setAttribute(OWNER_ATTRIBUTE, key);
133
255
  if (createdElements.has(element)) element.setAttribute(CREATED_ATTRIBUTE, '');
134
256
  if (entry.kind === 'title') {
135
- element.textContent = entry.text;
257
+ if (element.textContent !== entry.text) element.textContent = entry.text;
136
258
  return;
137
259
  }
138
260
  for (const attribute of [...element.attributes]) {
139
261
  if (attribute.name === OWNER_ATTRIBUTE || attribute.name === CREATED_ATTRIBUTE) continue;
140
- if (!Object.hasOwn(entry.attrs, attribute.name)) element.removeAttribute(attribute.name);
262
+ if (!Object.hasOwn(entry.attrs ?? {}, attribute.name)) element.removeAttribute(attribute.name);
263
+ }
264
+ for (const [name, value] of Object.entries(entry.attrs ?? {})) {
265
+ if (element.getAttribute(name) !== value) element.setAttribute(name, value);
266
+ }
267
+ if (entry.kind === 'script' || entry.kind === 'style') {
268
+ const text = entry.text ?? '';
269
+ if (element.textContent !== text) element.textContent = text;
270
+ return;
271
+ }
272
+ if (entry.kind === 'noscript') {
273
+ if (entry.html !== undefined) {
274
+ if (element.innerHTML !== entry.html) element.innerHTML = entry.html;
275
+ } else if (element.textContent !== (entry.text ?? '')) {
276
+ element.textContent = entry.text ?? '';
277
+ }
141
278
  }
142
- for (const [name, value] of Object.entries(entry.attrs)) element.setAttribute(name, value);
143
279
  }
144
280
 
145
281
  // Remembers what a tag looked like before the head layer first touched it, so an app-owned
@@ -155,8 +291,9 @@ function snapshotTag(element, key) {
155
291
  }
156
292
  const snapshot = {
157
293
  attrs: Object.fromEntries(attrs),
158
- text: element.tagName === 'TITLE' ? element.textContent : null,
294
+ text: ['TITLE', 'SCRIPT', 'STYLE'].includes(element.tagName) ? element.textContent : null,
159
295
  };
296
+ if (element.tagName === 'NOSCRIPT') snapshot.html = element.innerHTML;
160
297
  snapshots.set(element, snapshot);
161
298
  if (createdElements.has(element) || element.hasAttribute(CREATED_ATTRIBUTE)) return;
162
299
  // A record from an earlier prerender stays as it is, so a rerun over already generated
@@ -166,11 +303,17 @@ function snapshotTag(element, key) {
166
303
  publishOriginals();
167
304
  }
168
305
 
169
- // Mirrors the recorded originals into a global the prerenderer picks up, so the generated HTML
170
- // can carry them and a later page load can hand the app's own tags back. Published once at
171
- // module load as well, because a rerun must not lose records it did not add itself.
306
+ // Mirrors the recorded originals into a global the prerenderer picks up, so the generated
307
+ // HTML can carry them and a later page load can hand the app's own tags back. Attribute
308
+ // families use reserved keys so they travel beside the tag records.
172
309
  function publishOriginals() {
173
- if (typeof window !== 'undefined') window.__snappyHeadOriginals = originals;
310
+ if (typeof window === 'undefined') return;
311
+ window.__snappyHeadOriginals = {
312
+ ...originals,
313
+ ...(htmlOriginals.size > 0 ? { [ORIGINAL_HTML]: Object.fromEntries(htmlOriginals) } : {}),
314
+ ...(bodyOriginals.size > 0 ? { [ORIGINAL_BODY]: Object.fromEntries(bodyOriginals) } : {}),
315
+ ...(titleOriginals.size > 0 ? { [ORIGINAL_TITLE]: Object.fromEntries(titleOriginals) } : {}),
316
+ };
174
317
  }
175
318
 
176
319
  // Reads the originals an earlier prerender baked into the page.
@@ -203,7 +346,7 @@ function readRecordedOriginal(key) {
203
346
  return key in originals ? originals[key] : null;
204
347
  }
205
348
 
206
- // Replaces every attribute and the text of a tag with a recorded snapshot.
349
+ // Replaces every attribute, the text and the inner markup of a tag with a recorded snapshot.
207
350
  function applySnapshot(element, snapshot) {
208
351
  for (const name of [...element.attributes].map((attribute) => attribute.name)) {
209
352
  element.removeAttribute(name);
@@ -211,20 +354,133 @@ function applySnapshot(element, snapshot) {
211
354
  for (const [name, value] of Object.entries(snapshot.attrs ?? {})) {
212
355
  element.setAttribute(name, value);
213
356
  }
214
- if (snapshot.text !== null && snapshot.text !== undefined) element.textContent = snapshot.text;
357
+ if (snapshot.html !== null && snapshot.html !== undefined) {
358
+ element.innerHTML = snapshot.html;
359
+ } else if (snapshot.text !== null && snapshot.text !== undefined) {
360
+ element.textContent = snapshot.text;
361
+ }
215
362
  }
216
363
 
217
364
  // Re-appends owned tags in merged order, so the emitted head is identical between runs
218
365
  // regardless of the order in which components first rendered their tags.
219
366
  function orderTags(head, merged) {
220
- const order = merged.flatMap((entry) => findTags(head, headKey(entry)));
221
- const owned = [...head.querySelectorAll(`[${OWNER_ATTRIBUTE}]`)];
367
+ const order = merged
368
+ .flatMap((entry) => findTags(head, headKey(entry), entry))
369
+ .filter((element) => element !== externalTitleElement && !adoptedElements.has(element));
370
+ const owned = [...head.querySelectorAll(`[${OWNER_ATTRIBUTE}]`)].filter(
371
+ (element) => !adoptedElements.has(element),
372
+ );
222
373
  const sameOrder =
223
374
  owned.length === order.length && owned.every((element, index) => element === order[index]);
224
375
  if (sameOrder) return;
225
376
  for (const element of order) head.appendChild(element);
226
377
  }
227
378
 
379
+ // Compares the merged entries with the previous pass and reports real additions, removals
380
+ // and value changes. A render that produces the same head never calls a callback.
381
+ function notifyState(merged) {
382
+ const current = new Map(
383
+ merged.map((entry) => [headKey(entry), { signature: entrySignature(entry), entry }]),
384
+ );
385
+ const previous = previousState;
386
+ previousState = current;
387
+ if (!previous) {
388
+ if (current.size === 0) return;
389
+ runCallbacks(
390
+ buildState(merged),
391
+ [...current.values()].map((item) => describeEntry(item.entry)),
392
+ [],
393
+ );
394
+ return;
395
+ }
396
+ const added = [];
397
+ const removed = [];
398
+ for (const [key, item] of current) {
399
+ const before = previous.get(key);
400
+ if (!before || before.signature !== item.signature) added.push(describeEntry(item.entry));
401
+ }
402
+ for (const [key, item] of previous) {
403
+ const after = current.get(key);
404
+ if (!after || after.signature !== item.signature) removed.push(describeEntry(item.entry));
405
+ }
406
+ if (added.length === 0 && removed.length === 0) return;
407
+ runCallbacks(buildState(merged), added, removed);
408
+ }
409
+
410
+ // Groups the merged entries by the shape documented for onChangeClientState.
411
+ function buildState(merged) {
412
+ const state = {
413
+ title: null,
414
+ meta: [],
415
+ link: [],
416
+ script: [],
417
+ style: [],
418
+ noscript: [],
419
+ base: [],
420
+ htmlAttributes: [],
421
+ bodyAttributes: [],
422
+ titleAttributes: [],
423
+ };
424
+ for (const entry of merged) {
425
+ const descriptor = describeEntry(entry);
426
+ if (entry.kind === 'title') state.title = descriptor;
427
+ else if (entry.kind === 'html') state.htmlAttributes.push(descriptor);
428
+ else if (entry.kind === 'body') state.bodyAttributes.push(descriptor);
429
+ else if (entry.kind === 'titleAttribute') state.titleAttributes.push(descriptor);
430
+ else state[entry.kind]?.push(descriptor);
431
+ }
432
+ return state;
433
+ }
434
+
435
+ // Converts one entry into the public callback descriptor, without internal match data.
436
+ function describeEntry(entry) {
437
+ const descriptor = { kind: entry.kind, key: headKey(entry) };
438
+ if (entry.attrs) descriptor.attrs = { ...entry.attrs };
439
+ if (entry.text !== undefined) descriptor.text = entry.text;
440
+ if (entry.html !== undefined) descriptor.html = entry.html;
441
+ if (entry.value !== undefined) descriptor.value = entry.value;
442
+ return descriptor;
443
+ }
444
+
445
+ // Captures everything that affects the rendered DOM, ignoring internal matching helpers.
446
+ function entrySignature(entry) {
447
+ return JSON.stringify([
448
+ headKey(entry),
449
+ entry.kind,
450
+ entry.key ?? null,
451
+ entry.attrs ?? null,
452
+ entry.text ?? null,
453
+ entry.html ?? null,
454
+ entry.value ?? null,
455
+ ]);
456
+ }
457
+
458
+ // Calls each mounted Head's callback once per change. A callback that throws is rethrown
459
+ // asynchronously so one observer cannot stop the head from reconciling.
460
+ function runCallbacks(state, added, removed) {
461
+ const callbacks = [...new Set(stack.map((item) => item.onChangeClientState).filter(Boolean))];
462
+ for (const callback of callbacks) {
463
+ try {
464
+ callback(state, added, removed);
465
+ } catch (error) {
466
+ queueMicrotask(() => {
467
+ throw error;
468
+ });
469
+ }
470
+ }
471
+ }
472
+
473
+ // Reverses escapeClosingTag so an app-written element can be recognised by its real text.
474
+ function unescapeClosingTag(text, tag) {
475
+ return text.replace(new RegExp(`<\\\\/${tag}`, 'gi'), (match) => `</${match.slice(3)}`);
476
+ }
477
+
478
+ // Escapes a value for use inside an attribute selector, including control characters.
228
479
  function cssEscape(value) {
229
- return String(value).replace(/["\\]/g, '\\$&');
480
+ return String(value).replace(/["\\\n\r\f]/g, (char) => {
481
+ if (char === '\n') return '\\a ';
482
+ if (char === '\r') return '\\d ';
483
+ if (char === '\f') return '\\c ';
484
+ return `\\${char}`;
485
+ });
230
486
  }