@ecopages/browser-router 0.2.0-alpha.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 +25 -0
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/package.json +39 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Initialization-should-create-router-instance-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Initialization-should-start-and-stop-without-errors-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Lifecycle-Events-should-dispatch-eco-before-swap--eco-after-swap--and-eco-page-load-events-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Lifecycle-Events-should-dispatch-eco-page-load-event-after-animation-frame-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Lifecycle-Events-should-provide-event-details-with-url-and-direction-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Custom-Link-Selector-should-work-with-data-attribute-selector-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Custom-Reload-Attribute-should-intercept-links-with-default-reload-attribute-when-custom-is-set-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Custom-link-selector-should-only-intercept-links-matching-custom-selector-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-External-Links--should-NOT-intercept--should-NOT-intercept-external-links-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Internal-Links-should-intercept-clicks-on-relative-path-links-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Internal-Links-should-intercept-clicks-on-same-origin-absolute-URLs-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Link-Attributes--should-NOT-intercept--should-NOT-intercept-links-with-download-attribute-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Link-Attributes--should-NOT-intercept--should-intercept-links-with-target---self--1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-alt-click-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-ctrl-click-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-meta-click--cmd-on-Mac--1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-middle-mouse-button-click-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-right-mouse-button-click-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-shift-click-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-external-links--different-origin--1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-hash-only-links-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-javascript--links-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-custom-reload-attribute-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-data-eco-reload-attribute-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-download-attribute-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-empty-href-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-target---blank--1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-target---parent--1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-without-href-attribute-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-intercept-internal-links-with-absolute-same-origin-paths-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-intercept-internal-links-with-relative-paths-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-intercept-nested-elements-inside-links-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-should-NOT-intercept-external-links-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Navigation-Abort-should-abort-previous-navigation-when-new-one-starts-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Programmatic-Navigation-should-navigate-and-update-history-with-pushState-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Programmatic-Navigation-should-use-replaceState-when-replace-option-is-true-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.ts/EcoRouter-Error-Handling-should-fall-back-to-full-page-navigation-on-fetch-error-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.ts/EcoRouter-Error-Handling-should-log-error-and-attempt-fallback-navigation-on-fetch-error-1.png +0 -0
- package/src/client/eco-router.d.ts +98 -0
- package/src/client/eco-router.js +228 -0
- package/src/client/eco-router.ts +290 -0
- package/src/client/services/dom-swapper.d.ts +65 -0
- package/src/client/services/dom-swapper.js +237 -0
- package/src/client/services/dom-swapper.ts +325 -0
- package/src/client/services/index.d.ts +8 -0
- package/src/client/services/index.js +10 -0
- package/src/client/services/index.ts +9 -0
- package/src/client/services/prefetch-manager.d.ts +169 -0
- package/src/client/services/prefetch-manager.js +374 -0
- package/src/client/services/prefetch-manager.ts +451 -0
- package/src/client/services/scroll-manager.d.ts +19 -0
- package/src/client/services/scroll-manager.js +36 -0
- package/src/client/services/scroll-manager.ts +48 -0
- package/src/client/services/view-transition-manager.d.ts +23 -0
- package/src/client/services/view-transition-manager.js +38 -0
- package/src/client/services/view-transition-manager.ts +75 -0
- package/src/client/types.d.ts +84 -0
- package/src/client/types.js +19 -0
- package/src/client/types.ts +109 -0
- package/src/client/view-transition-utils.d.ts +14 -0
- package/src/client/view-transition-utils.js +60 -0
- package/src/client/view-transition-utils.ts +98 -0
- package/src/index.d.ts +9 -0
- package/src/index.js +11 -0
- package/src/index.ts +19 -0
- package/src/styles.css +218 -0
- package/src/types.d.ts +15 -0
- package/src/types.js +4 -0
- package/src/types.ts +19 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side router for Ecopages with morphdom-based DOM diffing.
|
|
3
|
+
* @module eco-router
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { EcoRouterOptions, EcoNavigationEvent, EcoBeforeSwapEvent, EcoAfterSwapEvent } from './types.ts';
|
|
7
|
+
import { DEFAULT_OPTIONS } from './types.ts';
|
|
8
|
+
import { DomSwapper, ScrollManager, ViewTransitionManager, PrefetchManager } from './services/index.ts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Intercepts same-origin link clicks and performs client-side navigation
|
|
12
|
+
* using morphdom for efficient DOM diffing. Supports View Transitions API.
|
|
13
|
+
*/
|
|
14
|
+
export class EcoRouter {
|
|
15
|
+
private options: Required<EcoRouterOptions>;
|
|
16
|
+
private abortController: AbortController | null = null;
|
|
17
|
+
|
|
18
|
+
private domSwapper: DomSwapper;
|
|
19
|
+
private scrollManager: ScrollManager;
|
|
20
|
+
private viewTransitionManager: ViewTransitionManager;
|
|
21
|
+
private prefetchManager: PrefetchManager | null = null;
|
|
22
|
+
|
|
23
|
+
constructor(options: EcoRouterOptions = {}) {
|
|
24
|
+
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
25
|
+
|
|
26
|
+
this.domSwapper = new DomSwapper(this.options.persistAttribute);
|
|
27
|
+
this.scrollManager = new ScrollManager(this.options.scrollBehavior, this.options.smoothScroll);
|
|
28
|
+
this.viewTransitionManager = new ViewTransitionManager(this.options.viewTransitions);
|
|
29
|
+
|
|
30
|
+
if (this.options.prefetch !== false) {
|
|
31
|
+
this.prefetchManager = new PrefetchManager({
|
|
32
|
+
...this.options.prefetch,
|
|
33
|
+
linkSelector: this.options.linkSelector,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
this.handleClick = this.handleClick.bind(this);
|
|
38
|
+
this.handlePopState = this.handlePopState.bind(this);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Starts the router and begins intercepting navigation.
|
|
43
|
+
*
|
|
44
|
+
* Attaches click handlers for links and popstate handlers for browser
|
|
45
|
+
* back/forward buttons. Also starts the prefetch manager if configured.
|
|
46
|
+
*/
|
|
47
|
+
public start(): void {
|
|
48
|
+
document.addEventListener('click', this.handleClick);
|
|
49
|
+
window.addEventListener('popstate', this.handlePopState);
|
|
50
|
+
this.prefetchManager?.start();
|
|
51
|
+
|
|
52
|
+
// Cache the initial page for instant back-navigation
|
|
53
|
+
const initialHtml = document.documentElement.outerHTML;
|
|
54
|
+
this.prefetchManager?.cacheVisitedPage(window.location.href, initialHtml);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Stops the router and cleans up all event listeners.
|
|
59
|
+
* After calling this, navigation will fall back to full page reloads.
|
|
60
|
+
*/
|
|
61
|
+
public stop(): void {
|
|
62
|
+
document.removeEventListener('click', this.handleClick);
|
|
63
|
+
window.removeEventListener('popstate', this.handlePopState);
|
|
64
|
+
this.prefetchManager?.stop();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Programmatic navigation.
|
|
69
|
+
* Falls back to full page reload for cross-origin URLs.
|
|
70
|
+
* @param href - The URL to navigate to
|
|
71
|
+
* @param options - Navigation options
|
|
72
|
+
* @param options.replace - If true, replaces the current history entry instead of pushing
|
|
73
|
+
*/
|
|
74
|
+
public async navigate(href: string, options: { replace?: boolean } = {}): Promise<void> {
|
|
75
|
+
const url = new URL(href, window.location.origin);
|
|
76
|
+
|
|
77
|
+
if (!this.isSameOrigin(url)) {
|
|
78
|
+
window.location.href = href;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await this.performNavigation(url, options.replace ? 'replace' : 'forward');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Manually prefetch a URL.
|
|
87
|
+
* @param href - The URL to prefetch
|
|
88
|
+
*/
|
|
89
|
+
public async prefetch(href: string): Promise<void> {
|
|
90
|
+
if (!this.prefetchManager) {
|
|
91
|
+
console.warn('[ecopages] Prefetching is disabled. Enable it in router options.');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
return this.prefetchManager.prefetch(href);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Intercepts link clicks for client-side navigation.
|
|
99
|
+
*
|
|
100
|
+
* Filters out clicks with modifier keys (opens new tab), non-left clicks,
|
|
101
|
+
* external links, download links, and links with the reload attribute.
|
|
102
|
+
*
|
|
103
|
+
* Uses `event.composedPath()` to correctly detect clicks on anchors inside
|
|
104
|
+
* Shadow DOM boundaries (Web Components).
|
|
105
|
+
*/
|
|
106
|
+
private handleClick(event: MouseEvent): void {
|
|
107
|
+
const link = event
|
|
108
|
+
.composedPath()
|
|
109
|
+
.find(
|
|
110
|
+
(el) => el instanceof HTMLAnchorElement && el.matches(this.options.linkSelector),
|
|
111
|
+
) as HTMLAnchorElement | null;
|
|
112
|
+
|
|
113
|
+
if (!link) return;
|
|
114
|
+
|
|
115
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
116
|
+
if (event.button !== 0) return;
|
|
117
|
+
|
|
118
|
+
const target = link.getAttribute('target');
|
|
119
|
+
if (target && target !== '_self') return;
|
|
120
|
+
|
|
121
|
+
if (link.hasAttribute(this.options.reloadAttribute)) return;
|
|
122
|
+
if (link.hasAttribute('download')) return;
|
|
123
|
+
|
|
124
|
+
const href = link.getAttribute('href');
|
|
125
|
+
if (!href) return;
|
|
126
|
+
|
|
127
|
+
if (href.startsWith('#')) return;
|
|
128
|
+
if (href.startsWith('javascript:')) return;
|
|
129
|
+
|
|
130
|
+
const url = new URL(href, window.location.origin);
|
|
131
|
+
|
|
132
|
+
if (!this.isSameOrigin(url)) return;
|
|
133
|
+
|
|
134
|
+
event.preventDefault();
|
|
135
|
+
this.performNavigation(url, 'forward');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Handles browser back/forward navigation.
|
|
140
|
+
* Triggered by the History API's popstate event.
|
|
141
|
+
*/
|
|
142
|
+
private handlePopState(_event: PopStateEvent): void {
|
|
143
|
+
const url = new URL(window.location.href);
|
|
144
|
+
this.performNavigation(url, 'back');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Checks if a URL shares the same origin as the current page.
|
|
149
|
+
* Cross-origin navigation always falls back to full page reload.
|
|
150
|
+
*/
|
|
151
|
+
private isSameOrigin(url: URL): boolean {
|
|
152
|
+
return url.origin === window.location.origin;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Executes the core navigation flow.
|
|
157
|
+
*
|
|
158
|
+
* Orchestrates fetching, DOM swapping, and lifecycle events:
|
|
159
|
+
*
|
|
160
|
+
* 1. **Fetch** - Retrieves HTML (from cache or network)
|
|
161
|
+
* 2. **eco:before-swap** - Allows listeners to force a full reload
|
|
162
|
+
* 3. **History update** - Updates URL before DOM swap so Web Components
|
|
163
|
+
* see the correct URL in their `connectedCallback`
|
|
164
|
+
* 4. **Stylesheet preload** - Prevents FOUC by loading styles first
|
|
165
|
+
* 5. **DOM swap** - Morphs head/body, optionally with View Transition
|
|
166
|
+
* 6. **Lifecycle events** - Dispatches `eco:after-swap` and `eco:page-load`
|
|
167
|
+
*
|
|
168
|
+
* Falls back to full page reload on network errors.
|
|
169
|
+
*
|
|
170
|
+
* @param url - The target URL to navigate to
|
|
171
|
+
* @param direction - Navigation direction ('forward', 'back', or 'replace')
|
|
172
|
+
*/
|
|
173
|
+
private async performNavigation(url: URL, direction: EcoNavigationEvent['direction']): Promise<void> {
|
|
174
|
+
const previousUrl = new URL(window.location.href);
|
|
175
|
+
|
|
176
|
+
this.abortController?.abort();
|
|
177
|
+
this.abortController = new AbortController();
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const html = await this.fetchPage(url, this.abortController.signal);
|
|
181
|
+
const newDocument = this.domSwapper.parseHTML(html, url);
|
|
182
|
+
|
|
183
|
+
let shouldReload = false;
|
|
184
|
+
const beforeSwapEvent: EcoBeforeSwapEvent = {
|
|
185
|
+
url,
|
|
186
|
+
direction,
|
|
187
|
+
newDocument,
|
|
188
|
+
reload: () => {
|
|
189
|
+
shouldReload = true;
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
document.dispatchEvent(new CustomEvent('eco:before-swap', { detail: beforeSwapEvent }));
|
|
194
|
+
|
|
195
|
+
if (shouldReload) {
|
|
196
|
+
window.location.href = url.href;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (this.options.updateHistory && direction === 'forward') {
|
|
201
|
+
window.history.pushState({}, '', url.href);
|
|
202
|
+
} else if (direction === 'replace') {
|
|
203
|
+
window.history.replaceState({}, '', url.href);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const useViewTransitions = this.options.viewTransitions;
|
|
207
|
+
await this.domSwapper.preloadStylesheets(newDocument);
|
|
208
|
+
|
|
209
|
+
if (useViewTransitions) {
|
|
210
|
+
await this.viewTransitionManager.transition(() => {
|
|
211
|
+
this.domSwapper.morphHead(newDocument);
|
|
212
|
+
this.domSwapper.morphBody(newDocument);
|
|
213
|
+
this.scrollManager.handleScroll(url, previousUrl);
|
|
214
|
+
});
|
|
215
|
+
} else {
|
|
216
|
+
this.domSwapper.morphHead(newDocument);
|
|
217
|
+
this.domSwapper.replaceBody(newDocument);
|
|
218
|
+
this.scrollManager.handleScroll(url, previousUrl);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const afterSwapEvent: EcoAfterSwapEvent = {
|
|
222
|
+
url,
|
|
223
|
+
direction,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
document.dispatchEvent(new CustomEvent('eco:after-swap', { detail: afterSwapEvent }));
|
|
227
|
+
|
|
228
|
+
this.prefetchManager?.observeNewLinks();
|
|
229
|
+
|
|
230
|
+
// Cache the visited page for instant revisits (stale-while-revalidate)
|
|
231
|
+
this.prefetchManager?.cacheVisitedPage(url.href, html);
|
|
232
|
+
|
|
233
|
+
requestAnimationFrame(() => {
|
|
234
|
+
document.dispatchEvent(
|
|
235
|
+
new CustomEvent('eco:page-load', {
|
|
236
|
+
detail: { url, direction } as EcoNavigationEvent,
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
console.error('[ecopages] Navigation failed:', error);
|
|
246
|
+
window.location.href = url.href;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Fetches the HTML content of a page.
|
|
252
|
+
* @param url - The URL to fetch
|
|
253
|
+
* @param signal - AbortSignal for cancelling the request
|
|
254
|
+
* @throws Error if the response is not ok
|
|
255
|
+
*/
|
|
256
|
+
private async fetchPage(url: URL, signal: AbortSignal): Promise<string> {
|
|
257
|
+
if (this.prefetchManager) {
|
|
258
|
+
const cachedHtml = this.prefetchManager.getCachedHtml(url.href);
|
|
259
|
+
if (cachedHtml) {
|
|
260
|
+
return cachedHtml;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const response = await fetch(url.href, {
|
|
265
|
+
signal,
|
|
266
|
+
headers: {
|
|
267
|
+
Accept: 'text/html',
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
if (!response.ok) {
|
|
272
|
+
throw new Error(`Failed to fetch page: ${response.status}`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return response.text();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Creates and starts a router instance.
|
|
281
|
+
* @param options - Configuration options for the router
|
|
282
|
+
* @returns A started EcoRouter instance
|
|
283
|
+
*/
|
|
284
|
+
export function createRouter(options?: EcoRouterOptions): EcoRouter {
|
|
285
|
+
const router = new EcoRouter(options);
|
|
286
|
+
router.start();
|
|
287
|
+
return router;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export type { EcoRouterOptions, EcoNavigationEvent, EcoBeforeSwapEvent, EcoAfterSwapEvent } from './types';
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
* Handles DOM manipulation during client-side page transitions.
|
|
8
|
+
*
|
|
9
|
+
* Uses a hybrid approach inspired by Turbo:
|
|
10
|
+
* - Surgical head updates (no morphing) to prevent FOUC
|
|
11
|
+
* - Idiomorph for efficient body diffing
|
|
12
|
+
*/
|
|
13
|
+
export declare class DomSwapper {
|
|
14
|
+
private persistAttribute;
|
|
15
|
+
constructor(persistAttribute: string);
|
|
16
|
+
/**
|
|
17
|
+
* Parses HTML string into a Document, injecting a temporary base tag for URL resolution.
|
|
18
|
+
*/
|
|
19
|
+
parseHTML(html: string, url?: URL): Document;
|
|
20
|
+
/**
|
|
21
|
+
* Preloads new stylesheets from target document to prevent FOUC.
|
|
22
|
+
*
|
|
23
|
+
* Discovers stylesheet links in the target document that aren't present in the
|
|
24
|
+
* current document, creates corresponding link elements, and waits for all to
|
|
25
|
+
* load before resolving. This follows Turbo's approach of waiting for stylesheets
|
|
26
|
+
* before any DOM updates.
|
|
27
|
+
*/
|
|
28
|
+
preloadStylesheets(newDocument: Document): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Updates document head using Turbo-style surgical updates.
|
|
31
|
+
*
|
|
32
|
+
* This approach avoids morphing the head element entirely, which prevents
|
|
33
|
+
* browser repaints that cause FOUC. Instead, it:
|
|
34
|
+
* - Updates the document title
|
|
35
|
+
* - Merges meta tags (adds new, updates changed)
|
|
36
|
+
* - Leaves stylesheets untouched (they're preloaded separately)
|
|
37
|
+
* - Handles script re-execution for marked scripts
|
|
38
|
+
* - Injects new scripts from the incoming page that are absent from the current head
|
|
39
|
+
*/
|
|
40
|
+
morphHead(newDocument: Document): void;
|
|
41
|
+
/**
|
|
42
|
+
* Detects custom elements without shadow DOM (light-DOM custom elements).
|
|
43
|
+
* These need full replacement rather than morphing, because morphdom would
|
|
44
|
+
* strip JS-generated content from their light DOM children.
|
|
45
|
+
*/
|
|
46
|
+
private isLightDomCustomElement;
|
|
47
|
+
/**
|
|
48
|
+
* Morphs document body using morphdom.
|
|
49
|
+
* Preserves persisted elements and hydrated custom elements.
|
|
50
|
+
* Light-DOM custom elements are fully replaced to trigger proper
|
|
51
|
+
* disconnectedCallback → connectedCallback lifecycle.
|
|
52
|
+
*/
|
|
53
|
+
morphBody(newDocument: Document): void;
|
|
54
|
+
/**
|
|
55
|
+
* Replaces body content in a single operation.
|
|
56
|
+
* Preserves persisted elements by moving them to the new body.
|
|
57
|
+
* Use when View Transitions are disabled.
|
|
58
|
+
*/
|
|
59
|
+
replaceBody(newDocument: Document): void;
|
|
60
|
+
/**
|
|
61
|
+
* Manually attaches declarative shadow DOM templates.
|
|
62
|
+
* Browsers only process `<template shadowrootmode>` during initial parse.
|
|
63
|
+
*/
|
|
64
|
+
private processDeclarativeShadowDOM;
|
|
65
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import morphdom from "morphdom";
|
|
2
|
+
const DEFAULT_PERSIST_ATTR = "data-eco-persist";
|
|
3
|
+
function isPersisted(element, persistAttribute) {
|
|
4
|
+
return element.hasAttribute(persistAttribute) || element.hasAttribute(DEFAULT_PERSIST_ATTR);
|
|
5
|
+
}
|
|
6
|
+
function isHydratedCustomElement(element) {
|
|
7
|
+
return element.localName.includes("-") && element.shadowRoot !== null;
|
|
8
|
+
}
|
|
9
|
+
class DomSwapper {
|
|
10
|
+
persistAttribute;
|
|
11
|
+
constructor(persistAttribute) {
|
|
12
|
+
this.persistAttribute = persistAttribute;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Parses HTML string into a Document, injecting a temporary base tag for URL resolution.
|
|
16
|
+
*/
|
|
17
|
+
parseHTML(html, url) {
|
|
18
|
+
const parser = new DOMParser();
|
|
19
|
+
const htmlToParse = url ? `<base href="${url.href}" data-eco-injected>${html}` : html;
|
|
20
|
+
return parser.parseFromString(htmlToParse, "text/html");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Preloads new stylesheets from target document to prevent FOUC.
|
|
24
|
+
*
|
|
25
|
+
* Discovers stylesheet links in the target document that aren't present in the
|
|
26
|
+
* current document, creates corresponding link elements, and waits for all to
|
|
27
|
+
* load before resolving. This follows Turbo's approach of waiting for stylesheets
|
|
28
|
+
* before any DOM updates.
|
|
29
|
+
*/
|
|
30
|
+
async preloadStylesheets(newDocument) {
|
|
31
|
+
const existingHrefs = new Set(
|
|
32
|
+
Array.from(document.head.querySelectorAll('link[rel="stylesheet"]')).map((l) => l.href)
|
|
33
|
+
);
|
|
34
|
+
const newStylesheetLinks = Array.from(
|
|
35
|
+
newDocument.head.querySelectorAll('link[rel="stylesheet"]')
|
|
36
|
+
).filter((link) => !existingHrefs.has(link.href));
|
|
37
|
+
if (newStylesheetLinks.length === 0) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const TIMEOUT = 5e3;
|
|
41
|
+
const loadPromises = newStylesheetLinks.map((link) => {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
const newLink = document.createElement("link");
|
|
44
|
+
newLink.rel = "stylesheet";
|
|
45
|
+
newLink.media = link.media || "all";
|
|
46
|
+
const timeoutId = setTimeout(() => {
|
|
47
|
+
cleanup();
|
|
48
|
+
resolve();
|
|
49
|
+
}, TIMEOUT);
|
|
50
|
+
const cleanup = () => {
|
|
51
|
+
clearTimeout(timeoutId);
|
|
52
|
+
newLink.onload = null;
|
|
53
|
+
newLink.onerror = null;
|
|
54
|
+
};
|
|
55
|
+
newLink.onload = () => {
|
|
56
|
+
cleanup();
|
|
57
|
+
resolve();
|
|
58
|
+
};
|
|
59
|
+
newLink.onerror = () => {
|
|
60
|
+
cleanup();
|
|
61
|
+
resolve();
|
|
62
|
+
};
|
|
63
|
+
newLink.href = link.href;
|
|
64
|
+
document.head.appendChild(newLink);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
await Promise.all(loadPromises);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Updates document head using Turbo-style surgical updates.
|
|
71
|
+
*
|
|
72
|
+
* This approach avoids morphing the head element entirely, which prevents
|
|
73
|
+
* browser repaints that cause FOUC. Instead, it:
|
|
74
|
+
* - Updates the document title
|
|
75
|
+
* - Merges meta tags (adds new, updates changed)
|
|
76
|
+
* - Leaves stylesheets untouched (they're preloaded separately)
|
|
77
|
+
* - Handles script re-execution for marked scripts
|
|
78
|
+
* - Injects new scripts from the incoming page that are absent from the current head
|
|
79
|
+
*/
|
|
80
|
+
morphHead(newDocument) {
|
|
81
|
+
const newTitle = newDocument.head.querySelector("title");
|
|
82
|
+
if (newTitle && document.title !== newTitle.textContent) {
|
|
83
|
+
document.title = newTitle.textContent || "";
|
|
84
|
+
}
|
|
85
|
+
const newMetas = newDocument.head.querySelectorAll("meta[name], meta[property]");
|
|
86
|
+
for (const newMeta of newMetas) {
|
|
87
|
+
const name = newMeta.getAttribute("name");
|
|
88
|
+
const property = newMeta.getAttribute("property");
|
|
89
|
+
const content = newMeta.getAttribute("content");
|
|
90
|
+
const selector = name ? `meta[name="${name}"]` : `meta[property="${property}"]`;
|
|
91
|
+
const existingMeta = document.head.querySelector(selector);
|
|
92
|
+
if (existingMeta) {
|
|
93
|
+
if (existingMeta.getAttribute("content") !== content) {
|
|
94
|
+
existingMeta.setAttribute("content", content || "");
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
document.head.appendChild(newMeta.cloneNode(true));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const existingScriptIds = new Set(
|
|
101
|
+
Array.from(document.head.querySelectorAll("script[data-eco-script-id]")).map(
|
|
102
|
+
(s) => s.getAttribute("data-eco-script-id")
|
|
103
|
+
)
|
|
104
|
+
);
|
|
105
|
+
const rerunScripts = newDocument.head.querySelectorAll("script[data-eco-rerun]");
|
|
106
|
+
for (const script of rerunScripts) {
|
|
107
|
+
const scriptId = script.getAttribute("data-eco-script-id");
|
|
108
|
+
if (scriptId && !existingScriptIds.has(scriptId)) {
|
|
109
|
+
const newScript = document.createElement("script");
|
|
110
|
+
for (const attr of script.attributes) {
|
|
111
|
+
if (attr.name !== "data-eco-rerun") {
|
|
112
|
+
newScript.setAttribute(attr.name, attr.value);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
newScript.textContent = script.textContent;
|
|
116
|
+
document.head.appendChild(newScript);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const existingScriptSrcs = new Set(
|
|
120
|
+
Array.from(document.head.querySelectorAll("script[src]")).map((s) => s.getAttribute("src"))
|
|
121
|
+
);
|
|
122
|
+
const existingInlineContents = new Set(
|
|
123
|
+
Array.from(document.head.querySelectorAll("script:not([src])")).map((s) => (s.textContent ?? "").trim())
|
|
124
|
+
);
|
|
125
|
+
const allNewHeadScripts = newDocument.head.querySelectorAll("script");
|
|
126
|
+
for (const script of allNewHeadScripts) {
|
|
127
|
+
if (script.hasAttribute("data-eco-rerun")) continue;
|
|
128
|
+
const src = script.getAttribute("src");
|
|
129
|
+
if (src) {
|
|
130
|
+
if (existingScriptSrcs.has(src)) continue;
|
|
131
|
+
const newScript = document.createElement("script");
|
|
132
|
+
for (const attr of script.attributes) {
|
|
133
|
+
newScript.setAttribute(attr.name, attr.value);
|
|
134
|
+
}
|
|
135
|
+
document.head.appendChild(newScript);
|
|
136
|
+
existingScriptSrcs.add(src);
|
|
137
|
+
} else {
|
|
138
|
+
const content = (script.textContent ?? "").trim();
|
|
139
|
+
if (!content || existingInlineContents.has(content)) continue;
|
|
140
|
+
const newScript = document.createElement("script");
|
|
141
|
+
for (const attr of script.attributes) {
|
|
142
|
+
newScript.setAttribute(attr.name, attr.value);
|
|
143
|
+
}
|
|
144
|
+
newScript.textContent = script.textContent;
|
|
145
|
+
document.head.appendChild(newScript);
|
|
146
|
+
existingInlineContents.add(content);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Detects custom elements without shadow DOM (light-DOM custom elements).
|
|
152
|
+
* These need full replacement rather than morphing, because morphdom would
|
|
153
|
+
* strip JS-generated content from their light DOM children.
|
|
154
|
+
*/
|
|
155
|
+
isLightDomCustomElement(element) {
|
|
156
|
+
return element.localName.includes("-") && element.shadowRoot === null;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Morphs document body using morphdom.
|
|
160
|
+
* Preserves persisted elements and hydrated custom elements.
|
|
161
|
+
* Light-DOM custom elements are fully replaced to trigger proper
|
|
162
|
+
* disconnectedCallback → connectedCallback lifecycle.
|
|
163
|
+
*/
|
|
164
|
+
morphBody(newDocument) {
|
|
165
|
+
const persistAttr = this.persistAttribute;
|
|
166
|
+
morphdom(document.body, newDocument.body, {
|
|
167
|
+
onBeforeElUpdated: (fromEl, toEl) => {
|
|
168
|
+
if (isPersisted(fromEl, persistAttr)) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
if (isHydratedCustomElement(fromEl)) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
if (this.isLightDomCustomElement(fromEl)) {
|
|
175
|
+
const newEl = document.createElement(toEl.tagName);
|
|
176
|
+
for (const attr of toEl.attributes) {
|
|
177
|
+
newEl.setAttribute(attr.name, attr.value);
|
|
178
|
+
}
|
|
179
|
+
newEl.innerHTML = toEl.innerHTML;
|
|
180
|
+
fromEl.replaceWith(newEl);
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
if (fromEl.isEqualNode(toEl)) {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
this.processDeclarativeShadowDOM(document.body);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Replaces body content in a single operation.
|
|
193
|
+
* Preserves persisted elements by moving them to the new body.
|
|
194
|
+
* Use when View Transitions are disabled.
|
|
195
|
+
*/
|
|
196
|
+
replaceBody(newDocument) {
|
|
197
|
+
const persistAttr = this.persistAttribute;
|
|
198
|
+
const persistedElements = document.body.querySelectorAll(`[${persistAttr}], [${DEFAULT_PERSIST_ATTR}]`);
|
|
199
|
+
const persistedMap = /* @__PURE__ */ new Map();
|
|
200
|
+
for (const el of persistedElements) {
|
|
201
|
+
const key = el.getAttribute(persistAttr) || el.getAttribute(DEFAULT_PERSIST_ATTR);
|
|
202
|
+
if (key) {
|
|
203
|
+
persistedMap.set(key, el);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
for (const [key, oldEl] of persistedMap) {
|
|
207
|
+
const placeholder = newDocument.body.querySelector(
|
|
208
|
+
`[${persistAttr}="${key}"], [${DEFAULT_PERSIST_ATTR}="${key}"]`
|
|
209
|
+
);
|
|
210
|
+
if (placeholder) {
|
|
211
|
+
placeholder.replaceWith(oldEl);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
document.body.replaceChildren(...newDocument.body.childNodes);
|
|
215
|
+
this.processDeclarativeShadowDOM(document.body);
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Manually attaches declarative shadow DOM templates.
|
|
219
|
+
* Browsers only process `<template shadowrootmode>` during initial parse.
|
|
220
|
+
*/
|
|
221
|
+
processDeclarativeShadowDOM(root) {
|
|
222
|
+
const templates = root.querySelectorAll("template[shadowrootmode], template[shadowroot]");
|
|
223
|
+
for (const template of templates) {
|
|
224
|
+
const mode = template.getAttribute("shadowrootmode") || template.getAttribute("shadowroot");
|
|
225
|
+
const parent = template.parentElement;
|
|
226
|
+
if (parent && !parent.shadowRoot) {
|
|
227
|
+
const shadowRoot = parent.attachShadow({ mode });
|
|
228
|
+
shadowRoot.appendChild(template.content);
|
|
229
|
+
template.remove();
|
|
230
|
+
this.processDeclarativeShadowDOM(shadowRoot);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
export {
|
|
236
|
+
DomSwapper
|
|
237
|
+
};
|