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/head/manager.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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.
|
|
66
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
104
|
-
//
|
|
105
|
-
function
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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:
|
|
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
|
|
170
|
-
// can carry them and a later page load can hand the app's own tags back.
|
|
171
|
-
//
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
221
|
-
|
|
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(/["
|
|
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
|
}
|