snappy-prerender 2.7.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 +49 -0
- package/README.md +166 -7
- package/assets/snappy-logo.png +0 -0
- package/docs/limitations.md +40 -4
- package/docs/report.md +36 -3
- package/package.json +3 -1
- package/src/cli-options.js +258 -0
- package/src/cli.js +75 -161
- package/src/core/audit/links.js +12 -1
- package/src/core/audit/metadata.js +113 -4
- package/src/core/audit/sitemap.js +1 -1
- 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 +132 -203
- package/src/core/crawl.js +76 -14
- package/src/core/csp.js +14 -3
- package/src/core/destination.js +23 -7
- package/src/core/hydration-policy.js +38 -0
- 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 +30 -6
- package/src/core/page-content.js +57 -7
- package/src/core/page-meta.js +18 -4
- package/src/core/preload-links.js +24 -11
- package/src/core/preload-manifest.js +6 -2
- package/src/core/render.js +46 -37
- package/src/core/report.js +9 -1
- package/src/core/route-sources.js +14 -3
- package/src/core/run.js +90 -20
- package/src/core/seo-locale.js +234 -0
- package/src/core/settle.js +2 -4
- package/src/core/sitemap.js +1 -1
- package/src/core/text-separators.js +11 -7
- package/src/core/verify.js +108 -59
- package/src/core/write.js +276 -99
- package/src/head/index.js +103 -5
- package/src/head/manager.js +290 -75
- package/src/head/tags.js +305 -32
package/src/head/manager.js
CHANGED
|
@@ -2,11 +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
|
|
9
|
-
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;
|
|
10
23
|
publishOriginals();
|
|
11
24
|
|
|
12
25
|
/**
|
|
@@ -29,11 +42,11 @@ export function readMetadataDefaults() {
|
|
|
29
42
|
/**
|
|
30
43
|
* Registers one mounted Head's tag list and reconciles the document head with the merged
|
|
31
44
|
* result. Called on every render, so a client-side route change updates the head without a
|
|
32
|
-
* page load.
|
|
45
|
+
* page load. The optional callback observes real DOM changes only.
|
|
33
46
|
*/
|
|
34
|
-
export function registerHead(id, sequence, entries) {
|
|
47
|
+
export function registerHead(id, sequence, entries, onChangeClientState = null) {
|
|
35
48
|
const index = stack.findIndex((item) => item.id === id);
|
|
36
|
-
const item = { id, sequence, entries };
|
|
49
|
+
const item = { id, sequence, entries, onChangeClientState };
|
|
37
50
|
if (index === -1) stack.push(item);
|
|
38
51
|
else stack[index] = item;
|
|
39
52
|
applyMerged();
|
|
@@ -51,23 +64,26 @@ export function unregisterHead(id) {
|
|
|
51
64
|
}
|
|
52
65
|
|
|
53
66
|
// Brings the head in line with the merged entries: create, adopt or update each tag, drop
|
|
54
|
-
// duplicates of the same identity, release tags that are no longer wanted,
|
|
55
|
-
// deterministic order.
|
|
67
|
+
// duplicates of the same identity, release tags that are no longer wanted, apply element
|
|
68
|
+
// attributes, then restore a deterministic order.
|
|
56
69
|
function applyMerged() {
|
|
57
70
|
if (typeof document === 'undefined' || !document.head) return;
|
|
58
71
|
const head = document.head;
|
|
59
72
|
const merged = mergeHeadEntries(stack);
|
|
60
|
-
const headEntries = merged.filter((entry) => entry.kind
|
|
73
|
+
const headEntries = merged.filter((entry) => !ATTRIBUTE_KINDS.has(entry.kind));
|
|
61
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');
|
|
62
77
|
const desired = new Map(headEntries.map((entry) => [headKey(entry), entry]));
|
|
78
|
+
const skipTitle = adoptExternalTitle(head, desired.get('title'));
|
|
63
79
|
|
|
64
80
|
for (const [key, entry] of desired) {
|
|
65
|
-
|
|
81
|
+
if (key === 'title' && skipTitle) continue;
|
|
82
|
+
const matches = findTags(head, key, entry);
|
|
66
83
|
const element = matches[0] ?? createTag(head, entry);
|
|
67
84
|
writeTag(element, entry, key);
|
|
68
|
-
// A second tag with the same identity is one the page should not ship twice.
|
|
69
|
-
|
|
70
|
-
if (key !== 'title') {
|
|
85
|
+
// A second tag with the same identity is one the page should not ship twice.
|
|
86
|
+
if (matches.length > 1) {
|
|
71
87
|
for (const duplicate of matches.slice(1)) removeDuplicate(duplicate, key);
|
|
72
88
|
}
|
|
73
89
|
}
|
|
@@ -77,78 +93,141 @@ function applyMerged() {
|
|
|
77
93
|
if (!desired.has(key)) releaseTag(element, key);
|
|
78
94
|
}
|
|
79
95
|
|
|
80
|
-
|
|
96
|
+
applyAttributes(document.documentElement, htmlEntries, htmlOriginals);
|
|
97
|
+
applyAttributes(document.body, bodyEntries, bodyOriginals);
|
|
98
|
+
applyTitleAttributes(head, titleEntries);
|
|
81
99
|
orderTags(head, headEntries);
|
|
100
|
+
notifyState(merged);
|
|
82
101
|
}
|
|
83
102
|
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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)]));
|
|
90
143
|
for (const [key, value] of desired) {
|
|
91
|
-
|
|
92
|
-
|
|
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);
|
|
93
152
|
}
|
|
94
|
-
for (const key of [...
|
|
153
|
+
for (const key of [...originals.keys()]) {
|
|
95
154
|
if (desired.has(key)) continue;
|
|
96
|
-
const previous =
|
|
97
|
-
if (previous === null)
|
|
98
|
-
else
|
|
99
|
-
|
|
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();
|
|
100
160
|
}
|
|
101
161
|
}
|
|
102
162
|
|
|
103
163
|
/**
|
|
104
164
|
* Finds every tag a key refers to, in document order. Matching on the attribute a crawler
|
|
105
|
-
* reads means a tag already in the document — including one in prerendered HTML
|
|
106
|
-
* 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.
|
|
107
167
|
*/
|
|
108
|
-
function findTags(head, key) {
|
|
168
|
+
function findTags(head, key, entry) {
|
|
109
169
|
if (key === 'title') {
|
|
110
|
-
|
|
111
|
-
|
|
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();
|
|
112
173
|
}
|
|
113
174
|
const owned = head.querySelector(`[${OWNER_ATTRIBUTE}="${cssEscape(key)}"]`);
|
|
114
175
|
if (owned) return [owned];
|
|
115
|
-
if (
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
(
|
|
121
|
-
|
|
122
|
-
|
|
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 [];
|
|
123
191
|
}
|
|
124
|
-
return
|
|
192
|
+
return findUntrackedTag(head, entry);
|
|
125
193
|
}
|
|
126
194
|
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
function
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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,
|
|
138
217
|
);
|
|
218
|
+
return match ? [match] : [];
|
|
139
219
|
}
|
|
140
|
-
if (
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return
|
|
148
|
-
Object.entries(attrs).every(([name, value]) => link.getAttribute(name) === value),
|
|
149
|
-
);
|
|
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 [];
|
|
150
228
|
}
|
|
151
229
|
|
|
230
|
+
// Creates the element for an entry and records that the head layer owns it.
|
|
152
231
|
function createTag(head, entry) {
|
|
153
232
|
const element = document.createElement(entry.kind);
|
|
154
233
|
createdElements.add(element);
|
|
@@ -169,18 +248,34 @@ function removeDuplicate(element, key) {
|
|
|
169
248
|
// page load can still tell it apart from one the app wrote and remove rather than restore it.
|
|
170
249
|
function writeTag(element, entry, key) {
|
|
171
250
|
snapshotTag(element, key);
|
|
251
|
+
if (!createdElements.has(element) && !element.hasAttribute(CREATED_ATTRIBUTE)) {
|
|
252
|
+
adoptedElements.add(element);
|
|
253
|
+
}
|
|
172
254
|
element.setAttribute(OWNER_ATTRIBUTE, key);
|
|
173
255
|
if (createdElements.has(element)) element.setAttribute(CREATED_ATTRIBUTE, '');
|
|
174
256
|
if (entry.kind === 'title') {
|
|
175
|
-
element.textContent = entry.text;
|
|
257
|
+
if (element.textContent !== entry.text) element.textContent = entry.text;
|
|
176
258
|
return;
|
|
177
259
|
}
|
|
178
260
|
for (const attribute of [...element.attributes]) {
|
|
179
261
|
if (attribute.name === OWNER_ATTRIBUTE || attribute.name === CREATED_ATTRIBUTE) continue;
|
|
180
|
-
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
|
+
}
|
|
181
278
|
}
|
|
182
|
-
for (const [name, value] of Object.entries(entry.attrs)) element.setAttribute(name, value);
|
|
183
|
-
if (entry.kind === 'script') element.textContent = entry.text;
|
|
184
279
|
}
|
|
185
280
|
|
|
186
281
|
// Remembers what a tag looked like before the head layer first touched it, so an app-owned
|
|
@@ -196,8 +291,9 @@ function snapshotTag(element, key) {
|
|
|
196
291
|
}
|
|
197
292
|
const snapshot = {
|
|
198
293
|
attrs: Object.fromEntries(attrs),
|
|
199
|
-
text:
|
|
294
|
+
text: ['TITLE', 'SCRIPT', 'STYLE'].includes(element.tagName) ? element.textContent : null,
|
|
200
295
|
};
|
|
296
|
+
if (element.tagName === 'NOSCRIPT') snapshot.html = element.innerHTML;
|
|
201
297
|
snapshots.set(element, snapshot);
|
|
202
298
|
if (createdElements.has(element) || element.hasAttribute(CREATED_ATTRIBUTE)) return;
|
|
203
299
|
// A record from an earlier prerender stays as it is, so a rerun over already generated
|
|
@@ -207,11 +303,17 @@ function snapshotTag(element, key) {
|
|
|
207
303
|
publishOriginals();
|
|
208
304
|
}
|
|
209
305
|
|
|
210
|
-
// Mirrors the recorded originals into a global the prerenderer picks up, so the generated
|
|
211
|
-
// can carry them and a later page load can hand the app's own tags back.
|
|
212
|
-
//
|
|
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.
|
|
213
309
|
function publishOriginals() {
|
|
214
|
-
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
|
+
};
|
|
215
317
|
}
|
|
216
318
|
|
|
217
319
|
// Reads the originals an earlier prerender baked into the page.
|
|
@@ -244,7 +346,7 @@ function readRecordedOriginal(key) {
|
|
|
244
346
|
return key in originals ? originals[key] : null;
|
|
245
347
|
}
|
|
246
348
|
|
|
247
|
-
// Replaces every attribute and the
|
|
349
|
+
// Replaces every attribute, the text and the inner markup of a tag with a recorded snapshot.
|
|
248
350
|
function applySnapshot(element, snapshot) {
|
|
249
351
|
for (const name of [...element.attributes].map((attribute) => attribute.name)) {
|
|
250
352
|
element.removeAttribute(name);
|
|
@@ -252,20 +354,133 @@ function applySnapshot(element, snapshot) {
|
|
|
252
354
|
for (const [name, value] of Object.entries(snapshot.attrs ?? {})) {
|
|
253
355
|
element.setAttribute(name, value);
|
|
254
356
|
}
|
|
255
|
-
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
|
+
}
|
|
256
362
|
}
|
|
257
363
|
|
|
258
364
|
// Re-appends owned tags in merged order, so the emitted head is identical between runs
|
|
259
365
|
// regardless of the order in which components first rendered their tags.
|
|
260
366
|
function orderTags(head, merged) {
|
|
261
|
-
const order = merged
|
|
262
|
-
|
|
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
|
+
);
|
|
263
373
|
const sameOrder =
|
|
264
374
|
owned.length === order.length && owned.every((element, index) => element === order[index]);
|
|
265
375
|
if (sameOrder) return;
|
|
266
376
|
for (const element of order) head.appendChild(element);
|
|
267
377
|
}
|
|
268
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.
|
|
269
479
|
function cssEscape(value) {
|
|
270
|
-
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
|
+
});
|
|
271
486
|
}
|