@ecopages/browser-router 0.2.0-alpha.8 → 0.2.1
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 +5 -11
- package/package.json +4 -2
- package/src/client/eco-router.js +3 -0
- package/src/client/services/dom-swapper.d.ts +78 -0
- package/src/client/services/dom-swapper.js +98 -10
- package/src/client/services/view-transition-manager.js +12 -1
- package/src/client/document-element-sync.ts +0 -48
- package/src/client/eco-router.ts +0 -634
- package/src/client/services/dom-swapper.ts +0 -576
- package/src/client/services/index.ts +0 -9
- package/src/client/services/prefetch-manager.ts +0 -457
- package/src/client/services/scroll-manager.ts +0 -48
- package/src/client/services/view-transition-manager.ts +0 -81
- package/src/client/types.ts +0 -126
- package/src/client/view-transition-utils.ts +0 -98
- package/src/index.ts +0 -23
- package/src/types.ts +0 -19
|
@@ -1,576 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DOM morphing service for client-side navigation.
|
|
3
|
-
* Uses Idiomorph for body morphing and Turbo-style surgical updates for head.
|
|
4
|
-
* @module dom-swapper
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import morphdom from 'morphdom';
|
|
8
|
-
|
|
9
|
-
const DEFAULT_PERSIST_ATTR = 'data-eco-persist';
|
|
10
|
-
|
|
11
|
-
type PendingRerunScript = {
|
|
12
|
-
parent: 'head' | 'body';
|
|
13
|
-
attributes: Array<[string, string]>;
|
|
14
|
-
textContent: string;
|
|
15
|
-
src: string | null;
|
|
16
|
-
scriptId: string | null;
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
type PendingHeadScript = {
|
|
20
|
-
attributes: Array<[string, string]>;
|
|
21
|
-
textContent: string;
|
|
22
|
-
src: string | null;
|
|
23
|
-
scriptId: string | null;
|
|
24
|
-
replaceExisting: boolean;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
const RERUN_SRC_ATTR = 'data-eco-rerun-src';
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Checks if element has a persist attribute (custom or default).
|
|
31
|
-
*/
|
|
32
|
-
function isPersisted(element: Element, persistAttribute: string): boolean {
|
|
33
|
-
return element.hasAttribute(persistAttribute) || element.hasAttribute(DEFAULT_PERSIST_ATTR);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Detects hydrated custom elements (with shadow DOM) that should skip morphing.
|
|
38
|
-
*/
|
|
39
|
-
function isHydratedCustomElement(element: Element): boolean {
|
|
40
|
-
return element.localName.includes('-') && element.shadowRoot !== null;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Handles DOM manipulation during client-side page transitions.
|
|
45
|
-
*
|
|
46
|
-
* Uses a hybrid approach inspired by Turbo:
|
|
47
|
-
* - Surgical head updates (no morphing) to prevent FOUC
|
|
48
|
-
* - Idiomorph for efficient body diffing
|
|
49
|
-
*/
|
|
50
|
-
export class DomSwapper {
|
|
51
|
-
private persistAttribute: string;
|
|
52
|
-
private pendingHeadScripts: PendingHeadScript[] = [];
|
|
53
|
-
private pendingRerunScripts: PendingRerunScript[] = [];
|
|
54
|
-
private rerunNonce = 0;
|
|
55
|
-
|
|
56
|
-
constructor(persistAttribute: string) {
|
|
57
|
-
this.persistAttribute = persistAttribute;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Parses HTML string into a Document, injecting a temporary base tag for URL resolution.
|
|
62
|
-
*/
|
|
63
|
-
parseHTML(html: string, url?: URL): Document {
|
|
64
|
-
const parser = new DOMParser();
|
|
65
|
-
const htmlToParse = url ? `<base href="${url.href}" data-eco-injected>${html}` : html;
|
|
66
|
-
return parser.parseFromString(htmlToParse, 'text/html');
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Preloads new stylesheets from target document to prevent FOUC.
|
|
71
|
-
*
|
|
72
|
-
* Discovers stylesheet links in the target document that aren't present in the
|
|
73
|
-
* current document, creates corresponding link elements, and waits for all to
|
|
74
|
-
* load before resolving. This follows Turbo's approach of waiting for stylesheets
|
|
75
|
-
* before any DOM updates.
|
|
76
|
-
*/
|
|
77
|
-
async preloadStylesheets(newDocument: Document): Promise<void> {
|
|
78
|
-
const existingHrefs = new Set(
|
|
79
|
-
Array.from(document.head.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')).map((l) => l.href),
|
|
80
|
-
);
|
|
81
|
-
|
|
82
|
-
const newStylesheetLinks = Array.from(
|
|
83
|
-
newDocument.head.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]'),
|
|
84
|
-
).filter((link) => !existingHrefs.has(link.href));
|
|
85
|
-
|
|
86
|
-
if (newStylesheetLinks.length === 0) {
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const TIMEOUT = 5000;
|
|
91
|
-
const loadPromises = newStylesheetLinks.map((link) => {
|
|
92
|
-
return new Promise<void>((resolve) => {
|
|
93
|
-
const newLink = document.createElement('link');
|
|
94
|
-
newLink.rel = 'stylesheet';
|
|
95
|
-
newLink.media = link.media || 'all';
|
|
96
|
-
|
|
97
|
-
const timeoutId = setTimeout(() => {
|
|
98
|
-
cleanup();
|
|
99
|
-
resolve();
|
|
100
|
-
}, TIMEOUT);
|
|
101
|
-
|
|
102
|
-
const cleanup = () => {
|
|
103
|
-
clearTimeout(timeoutId);
|
|
104
|
-
newLink.onload = null;
|
|
105
|
-
newLink.onerror = null;
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
newLink.onload = () => {
|
|
109
|
-
cleanup();
|
|
110
|
-
resolve();
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
newLink.onerror = () => {
|
|
114
|
-
cleanup();
|
|
115
|
-
resolve();
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
newLink.href = link.href;
|
|
119
|
-
document.head.appendChild(newLink);
|
|
120
|
-
});
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
await Promise.all(loadPromises);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Updates document head using Turbo-style surgical updates.
|
|
128
|
-
*
|
|
129
|
-
* This approach avoids morphing the head element entirely, which prevents
|
|
130
|
-
* browser repaints that cause FOUC. Instead, it:
|
|
131
|
-
* - Updates the document title
|
|
132
|
-
* - Merges meta tags (adds new, updates changed)
|
|
133
|
-
* - Leaves stylesheets untouched (they're preloaded separately)
|
|
134
|
-
* - Handles script re-execution for marked scripts
|
|
135
|
-
* - Injects new scripts from the incoming page that are absent from the current head
|
|
136
|
-
*/
|
|
137
|
-
morphHead(newDocument: Document): void {
|
|
138
|
-
this.pendingHeadScripts = [];
|
|
139
|
-
this.pendingRerunScripts = this.collectRerunScripts(newDocument);
|
|
140
|
-
this.removeStaleHeadScripts(newDocument);
|
|
141
|
-
|
|
142
|
-
/** Update the document title if it has changed. */
|
|
143
|
-
const newTitle = newDocument.head.querySelector('title');
|
|
144
|
-
if (newTitle && document.title !== newTitle.textContent) {
|
|
145
|
-
document.title = newTitle.textContent || '';
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Merge meta tags: update existing ones whose content changed, append new ones. */
|
|
149
|
-
const newMetas = newDocument.head.querySelectorAll('meta[name], meta[property]');
|
|
150
|
-
for (const newMeta of newMetas) {
|
|
151
|
-
const name = newMeta.getAttribute('name');
|
|
152
|
-
const property = newMeta.getAttribute('property');
|
|
153
|
-
const content = newMeta.getAttribute('content');
|
|
154
|
-
|
|
155
|
-
const selector = name ? `meta[name="${name}"]` : `meta[property="${property}"]`;
|
|
156
|
-
const existingMeta = document.head.querySelector(selector);
|
|
157
|
-
|
|
158
|
-
if (existingMeta) {
|
|
159
|
-
if (existingMeta.getAttribute('content') !== content) {
|
|
160
|
-
existingMeta.setAttribute('content', content || '');
|
|
161
|
-
}
|
|
162
|
-
} else {
|
|
163
|
-
document.head.appendChild(newMeta.cloneNode(true));
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Inject new scripts from the incoming page that are not already loaded.
|
|
169
|
-
*
|
|
170
|
-
* When the client-side router swaps pages, the new page may require scripts
|
|
171
|
-
* (e.g. custom-element definitions) that were not present on the previous page.
|
|
172
|
-
* Because the browser only executes a <script> element when it is first parsed
|
|
173
|
-
* or dynamically appended to the DOM, a fresh element must be created for each
|
|
174
|
-
* new script — cloneNode() alone is not sufficient to trigger execution.
|
|
175
|
-
*
|
|
176
|
-
* - External scripts are matched by their `src` attribute.
|
|
177
|
-
* - Inline scripts are matched by trimmed text content to avoid re-running duplicates.
|
|
178
|
-
*/
|
|
179
|
-
const existingScriptSrcs = new Set(
|
|
180
|
-
Array.from(document.head.querySelectorAll('script[src]')).map((s) => s.getAttribute('src')),
|
|
181
|
-
);
|
|
182
|
-
const existingInlineContents = new Set(
|
|
183
|
-
Array.from(document.head.querySelectorAll('script:not([src])')).map((s) => (s.textContent ?? '').trim()),
|
|
184
|
-
);
|
|
185
|
-
|
|
186
|
-
const allNewHeadScripts = newDocument.head.querySelectorAll('script');
|
|
187
|
-
for (const script of allNewHeadScripts) {
|
|
188
|
-
/** Skip scripts already handled by the `data-eco-rerun` mechanism above. */
|
|
189
|
-
if (script.hasAttribute('data-eco-rerun')) continue;
|
|
190
|
-
|
|
191
|
-
const src = script.getAttribute('src');
|
|
192
|
-
const scriptId = script.getAttribute('data-eco-script-id') || script.getAttribute('id');
|
|
193
|
-
const existingScript = this.findExistingHeadScript(script);
|
|
194
|
-
|
|
195
|
-
if (scriptId && existingScript) {
|
|
196
|
-
if (!this.isNonExecutableHeadScript(script)) {
|
|
197
|
-
continue;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (this.areHeadScriptsEquivalent(script, existingScript)) {
|
|
201
|
-
continue;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
this.pendingHeadScripts.push({
|
|
205
|
-
attributes: Array.from(script.attributes).map((attr) => [attr.name, attr.value]),
|
|
206
|
-
textContent: script.textContent ?? '',
|
|
207
|
-
src,
|
|
208
|
-
scriptId,
|
|
209
|
-
replaceExisting: true,
|
|
210
|
-
});
|
|
211
|
-
continue;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
if (src) {
|
|
215
|
-
if (existingScriptSrcs.has(src)) continue;
|
|
216
|
-
this.pendingHeadScripts.push({
|
|
217
|
-
attributes: Array.from(script.attributes).map((attr) => [attr.name, attr.value]),
|
|
218
|
-
textContent: script.textContent ?? '',
|
|
219
|
-
src,
|
|
220
|
-
scriptId,
|
|
221
|
-
replaceExisting: false,
|
|
222
|
-
});
|
|
223
|
-
existingScriptSrcs.add(src);
|
|
224
|
-
} else {
|
|
225
|
-
/** Inline script — skip if identical content is already present to avoid re-running on every navigation. */
|
|
226
|
-
const content = (script.textContent ?? '').trim();
|
|
227
|
-
if (!content || existingInlineContents.has(content)) continue;
|
|
228
|
-
this.pendingHeadScripts.push({
|
|
229
|
-
attributes: Array.from(script.attributes).map((attr) => [attr.name, attr.value]),
|
|
230
|
-
textContent: script.textContent ?? '',
|
|
231
|
-
src: null,
|
|
232
|
-
scriptId,
|
|
233
|
-
replaceExisting: false,
|
|
234
|
-
});
|
|
235
|
-
existingInlineContents.add(content);
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Replays queued `data-eco-rerun` scripts after the body swap completes.
|
|
242
|
-
*
|
|
243
|
-
* Scripts are intentionally flushed after the new body is in place so DOM-
|
|
244
|
-
* dependent bootstraps bind against the incoming page rather than the page
|
|
245
|
-
* being replaced.
|
|
246
|
-
*/
|
|
247
|
-
flushRerunScripts(): void {
|
|
248
|
-
for (const script of this.pendingHeadScripts) {
|
|
249
|
-
const replacement = document.createElement('script');
|
|
250
|
-
|
|
251
|
-
for (const [name, value] of script.attributes) {
|
|
252
|
-
replacement.setAttribute(name, value);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
replacement.textContent = script.textContent;
|
|
256
|
-
|
|
257
|
-
const existingScript = this.findExistingHeadScript(script);
|
|
258
|
-
if (script.replaceExisting && existingScript) {
|
|
259
|
-
existingScript.replaceWith(replacement);
|
|
260
|
-
continue;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
document.head.appendChild(replacement);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
this.pendingHeadScripts = [];
|
|
267
|
-
|
|
268
|
-
for (const script of this.pendingRerunScripts) {
|
|
269
|
-
const targetParent = script.parent === 'body' ? document.body : document.head;
|
|
270
|
-
const replacement = document.createElement('script');
|
|
271
|
-
const shouldBustModuleSrc = this.isExternalModuleRerunScript(script);
|
|
272
|
-
|
|
273
|
-
for (const [name, value] of script.attributes) {
|
|
274
|
-
if (name === 'data-eco-rerun') {
|
|
275
|
-
continue;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
if (name === 'src' && shouldBustModuleSrc) {
|
|
279
|
-
replacement.setAttribute(RERUN_SRC_ATTR, value);
|
|
280
|
-
replacement.setAttribute('src', this.createRerunScriptUrl(value));
|
|
281
|
-
continue;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
replacement.setAttribute(name, value);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
replacement.textContent = script.textContent;
|
|
288
|
-
|
|
289
|
-
const existingScript = this.findExistingRerunScript(targetParent, script);
|
|
290
|
-
|
|
291
|
-
if (existingScript) {
|
|
292
|
-
existingScript.replaceWith(replacement);
|
|
293
|
-
continue;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
targetParent.appendChild(replacement);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
this.pendingRerunScripts = [];
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
shouldReplaceBodyForRerunScripts(): boolean {
|
|
303
|
-
return this.pendingRerunScripts.length > 0;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/**
|
|
307
|
-
* Detects custom elements without shadow DOM (light-DOM custom elements).
|
|
308
|
-
* These need full replacement rather than morphing, because morphdom would
|
|
309
|
-
* strip JS-generated content from their light DOM children.
|
|
310
|
-
*/
|
|
311
|
-
private isLightDomCustomElement(element: Element): boolean {
|
|
312
|
-
return element.localName.includes('-') && element.shadowRoot === null;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
private replaceCustomElement(fromEl: Element, toEl: Element): false {
|
|
316
|
-
const newEl = document.createElement(toEl.tagName);
|
|
317
|
-
for (const attr of toEl.attributes) {
|
|
318
|
-
newEl.setAttribute(attr.name, attr.value);
|
|
319
|
-
}
|
|
320
|
-
newEl.innerHTML = toEl.innerHTML;
|
|
321
|
-
fromEl.replaceWith(newEl);
|
|
322
|
-
return false;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
/**
|
|
326
|
-
* Morphs document body using morphdom.
|
|
327
|
-
* Preserves persisted elements and hydrated custom elements.
|
|
328
|
-
* Light-DOM custom elements are fully replaced to trigger proper
|
|
329
|
-
* disconnectedCallback → connectedCallback lifecycle.
|
|
330
|
-
*/
|
|
331
|
-
morphBody(newDocument: Document): void {
|
|
332
|
-
const persistAttr = this.persistAttribute;
|
|
333
|
-
|
|
334
|
-
morphdom(document.body, newDocument.body, {
|
|
335
|
-
onBeforeElUpdated: (fromEl, toEl) => {
|
|
336
|
-
if (isPersisted(fromEl, persistAttr)) {
|
|
337
|
-
return false;
|
|
338
|
-
}
|
|
339
|
-
if (isHydratedCustomElement(fromEl)) {
|
|
340
|
-
return this.replaceCustomElement(fromEl, toEl);
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
/**
|
|
344
|
-
* Light-DOM custom elements (e.g. <radiant-code-tabs>) often
|
|
345
|
-
* generate DOM in connectedCallback that doesn't exist in the
|
|
346
|
-
* server-rendered HTML. Morphdom would diff those children away.
|
|
347
|
-
* Instead, replace the element entirely so the browser fires
|
|
348
|
-
* disconnectedCallback on the old instance and connectedCallback
|
|
349
|
-
* on the fresh one.
|
|
350
|
-
*/
|
|
351
|
-
if (this.isLightDomCustomElement(fromEl)) {
|
|
352
|
-
return this.replaceCustomElement(fromEl, toEl);
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
if (fromEl.isEqualNode(toEl)) {
|
|
356
|
-
return false;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
return true;
|
|
360
|
-
},
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
this.processDeclarativeShadowDOM(document.body);
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* Replaces body content in a single operation.
|
|
368
|
-
* Preserves persisted elements by moving them to the new body.
|
|
369
|
-
* Use when View Transitions are disabled.
|
|
370
|
-
*/
|
|
371
|
-
replaceBody(newDocument: Document): void {
|
|
372
|
-
const persistAttr = this.persistAttribute;
|
|
373
|
-
|
|
374
|
-
const persistedElements = document.body.querySelectorAll(`[${persistAttr}], [${DEFAULT_PERSIST_ATTR}]`);
|
|
375
|
-
const persistedMap = new Map<string, Element>();
|
|
376
|
-
|
|
377
|
-
for (const el of persistedElements) {
|
|
378
|
-
const key = el.getAttribute(persistAttr) || el.getAttribute(DEFAULT_PERSIST_ATTR);
|
|
379
|
-
if (key) {
|
|
380
|
-
persistedMap.set(key, el);
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
for (const [key, oldEl] of persistedMap) {
|
|
385
|
-
const placeholder = newDocument.body.querySelector(
|
|
386
|
-
`[${persistAttr}="${key}"], [${DEFAULT_PERSIST_ATTR}="${key}"]`,
|
|
387
|
-
);
|
|
388
|
-
if (placeholder) {
|
|
389
|
-
placeholder.replaceWith(oldEl);
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
document.body.replaceChildren(...newDocument.body.childNodes);
|
|
394
|
-
this.processDeclarativeShadowDOM(document.body);
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
private collectRerunScripts(newDocument: Document): PendingRerunScript[] {
|
|
398
|
-
return Array.from(newDocument.querySelectorAll<HTMLScriptElement>('script[data-eco-rerun]')).map((script) => ({
|
|
399
|
-
parent: script.closest('body') ? 'body' : 'head',
|
|
400
|
-
attributes: Array.from(script.attributes).map((attr) => [attr.name, attr.value]),
|
|
401
|
-
textContent: script.textContent ?? '',
|
|
402
|
-
src: script.getAttribute('src'),
|
|
403
|
-
scriptId: script.getAttribute('data-eco-script-id'),
|
|
404
|
-
}));
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
private removeStaleHeadScripts(newDocument: Document): void {
|
|
408
|
-
const nextScriptKeys = new Set(
|
|
409
|
-
Array.from(newDocument.head.querySelectorAll<HTMLScriptElement>('script'))
|
|
410
|
-
.map((script) => this.getHeadScriptKey(script))
|
|
411
|
-
.filter((key): key is string => key !== null),
|
|
412
|
-
);
|
|
413
|
-
|
|
414
|
-
for (const script of Array.from(document.head.querySelectorAll<HTMLScriptElement>('script'))) {
|
|
415
|
-
const key = this.getHeadScriptKey(script);
|
|
416
|
-
if (!key || nextScriptKeys.has(key)) {
|
|
417
|
-
continue;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
if (this.shouldPersistExecutableInlineHeadScript(script)) {
|
|
421
|
-
continue;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
script.remove();
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
private shouldPersistExecutableInlineHeadScript(script: HTMLScriptElement): boolean {
|
|
429
|
-
const scriptId = script.getAttribute('data-eco-script-id') || script.getAttribute('id');
|
|
430
|
-
if (!scriptId) {
|
|
431
|
-
return false;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
if (script.hasAttribute('data-eco-rerun')) {
|
|
435
|
-
return false;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
if (script.getAttribute(RERUN_SRC_ATTR) || script.getAttribute('src')) {
|
|
439
|
-
return false;
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
return !this.isNonExecutableHeadScript(script);
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
private isNonExecutableHeadScript(script: HTMLScriptElement): boolean {
|
|
446
|
-
const type = (script.getAttribute('type') ?? '').trim().toLowerCase();
|
|
447
|
-
|
|
448
|
-
if (!type) {
|
|
449
|
-
return false;
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
return ![
|
|
453
|
-
'application/javascript',
|
|
454
|
-
'application/ecmascript',
|
|
455
|
-
'module',
|
|
456
|
-
'text/ecmascript',
|
|
457
|
-
'text/javascript',
|
|
458
|
-
].includes(type);
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
private areHeadScriptsEquivalent(nextScript: HTMLScriptElement, currentScript: HTMLScriptElement): boolean {
|
|
462
|
-
if (this.getHeadScriptKey(nextScript) !== this.getHeadScriptKey(currentScript)) {
|
|
463
|
-
return false;
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
if ((nextScript.textContent ?? '') !== (currentScript.textContent ?? '')) {
|
|
467
|
-
return false;
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
const nextAttributes = Array.from(nextScript.attributes).map((attribute) => [attribute.name, attribute.value]);
|
|
471
|
-
const currentAttributes = Array.from(currentScript.attributes).map((attribute) => [
|
|
472
|
-
attribute.name,
|
|
473
|
-
attribute.value,
|
|
474
|
-
]);
|
|
475
|
-
|
|
476
|
-
if (nextAttributes.length !== currentAttributes.length) {
|
|
477
|
-
return false;
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
return nextAttributes.every(
|
|
481
|
-
([name, value], index) => currentAttributes[index]?.[0] === name && currentAttributes[index]?.[1] === value,
|
|
482
|
-
);
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
private getHeadScriptKey(
|
|
486
|
-
script: HTMLScriptElement | Pick<PendingHeadScript | PendingRerunScript, 'scriptId' | 'src' | 'textContent'>,
|
|
487
|
-
): string | null {
|
|
488
|
-
const scriptId =
|
|
489
|
-
script instanceof HTMLScriptElement
|
|
490
|
-
? script.getAttribute('data-eco-script-id') || script.getAttribute('id')
|
|
491
|
-
: script.scriptId;
|
|
492
|
-
if (scriptId) {
|
|
493
|
-
return `id:${scriptId}`;
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
const src =
|
|
497
|
-
script instanceof HTMLScriptElement
|
|
498
|
-
? script.getAttribute(RERUN_SRC_ATTR) || script.getAttribute('src')
|
|
499
|
-
: script.src;
|
|
500
|
-
if (src) {
|
|
501
|
-
return `src:${src}`;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
const textContent = (script.textContent ?? '').trim();
|
|
505
|
-
return textContent ? `inline:${textContent}` : null;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
private findExistingHeadScript(
|
|
509
|
-
script: HTMLScriptElement | Pick<PendingHeadScript | PendingRerunScript, 'scriptId' | 'src' | 'textContent'>,
|
|
510
|
-
): HTMLScriptElement | null {
|
|
511
|
-
const scriptKey = this.getHeadScriptKey(script);
|
|
512
|
-
if (!scriptKey) {
|
|
513
|
-
return null;
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
return (
|
|
517
|
-
Array.from(document.head.querySelectorAll<HTMLScriptElement>('script')).find(
|
|
518
|
-
(candidate) => this.getHeadScriptKey(candidate) === scriptKey,
|
|
519
|
-
) ?? null
|
|
520
|
-
);
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
private findExistingRerunScript(root: HTMLElement, script: PendingRerunScript): HTMLScriptElement | null {
|
|
524
|
-
const scripts = Array.from(root.querySelectorAll<HTMLScriptElement>('script'));
|
|
525
|
-
|
|
526
|
-
if (script.scriptId) {
|
|
527
|
-
return (
|
|
528
|
-
scripts.find((candidate) => candidate.getAttribute('data-eco-script-id') === script.scriptId) ?? null
|
|
529
|
-
);
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
return (
|
|
533
|
-
scripts.find(
|
|
534
|
-
(candidate) =>
|
|
535
|
-
(candidate.getAttribute(RERUN_SRC_ATTR) ?? candidate.getAttribute('src')) === script.src &&
|
|
536
|
-
(candidate.textContent ?? '') === script.textContent,
|
|
537
|
-
) ?? null
|
|
538
|
-
);
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
private isExternalModuleRerunScript(script: PendingRerunScript): boolean {
|
|
542
|
-
if (!script.src) {
|
|
543
|
-
return false;
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
return script.attributes.some(([name, value]) => name === 'type' && value === 'module');
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
private createRerunScriptUrl(src: string): string {
|
|
550
|
-
const url = new URL(src, document.baseURI);
|
|
551
|
-
url.searchParams.set('__eco_rerun', String(++this.rerunNonce));
|
|
552
|
-
return url.toString();
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
/**
|
|
556
|
-
* Manually attaches declarative shadow DOM templates.
|
|
557
|
-
* Browsers only process `<template shadowrootmode>` during initial parse.
|
|
558
|
-
*/
|
|
559
|
-
private processDeclarativeShadowDOM(root: Element | Document | ShadowRoot): void {
|
|
560
|
-
const templates = root.querySelectorAll<HTMLTemplateElement>('template[shadowrootmode], template[shadowroot]');
|
|
561
|
-
|
|
562
|
-
for (const template of templates) {
|
|
563
|
-
const mode = (template.getAttribute('shadowrootmode') ||
|
|
564
|
-
template.getAttribute('shadowroot')) as ShadowRootMode;
|
|
565
|
-
const parent = template.parentElement;
|
|
566
|
-
|
|
567
|
-
if (parent && !parent.shadowRoot) {
|
|
568
|
-
const shadowRoot = parent.attachShadow({ mode });
|
|
569
|
-
shadowRoot.appendChild(template.content);
|
|
570
|
-
template.remove();
|
|
571
|
-
|
|
572
|
-
this.processDeclarativeShadowDOM(shadowRoot);
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Services for the EcoPages transitions package
|
|
3
|
-
* @module
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
export { DomSwapper } from './dom-swapper.ts';
|
|
7
|
-
export { ScrollManager } from './scroll-manager.ts';
|
|
8
|
-
export { ViewTransitionManager } from './view-transition-manager.ts';
|
|
9
|
-
export { PrefetchManager } from './prefetch-manager.ts';
|