@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.
Files changed (73) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/LICENSE +21 -0
  3. package/README.md +132 -0
  4. package/package.json +39 -0
  5. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Initialization-should-create-router-instance-1.png +0 -0
  6. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Initialization-should-start-and-stop-without-errors-1.png +0 -0
  7. 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
  8. 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
  9. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Lifecycle-Events-should-provide-event-details-with-url-and-direction-1.png +0 -0
  10. 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
  11. 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
  12. 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
  13. 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
  14. 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
  15. 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
  16. 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
  17. 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
  18. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-alt-click-1.png +0 -0
  19. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-ctrl-click-1.png +0 -0
  20. 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
  21. 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
  22. 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
  23. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-shift-click-1.png +0 -0
  24. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-external-links--different-origin--1.png +0 -0
  25. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-hash-only-links-1.png +0 -0
  26. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-javascript--links-1.png +0 -0
  27. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-custom-reload-attribute-1.png +0 -0
  28. 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
  29. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-download-attribute-1.png +0 -0
  30. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-empty-href-1.png +0 -0
  31. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-target---blank--1.png +0 -0
  32. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-target---parent--1.png +0 -0
  33. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-without-href-attribute-1.png +0 -0
  34. 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
  35. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-intercept-internal-links-with-relative-paths-1.png +0 -0
  36. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-intercept-nested-elements-inside-links-1.png +0 -0
  37. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-should-NOT-intercept-external-links-1.png +0 -0
  38. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Navigation-Abort-should-abort-previous-navigation-when-new-one-starts-1.png +0 -0
  39. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Programmatic-Navigation-should-navigate-and-update-history-with-pushState-1.png +0 -0
  40. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Programmatic-Navigation-should-use-replaceState-when-replace-option-is-true-1.png +0 -0
  41. 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
  42. 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
  43. package/src/client/eco-router.d.ts +98 -0
  44. package/src/client/eco-router.js +228 -0
  45. package/src/client/eco-router.ts +290 -0
  46. package/src/client/services/dom-swapper.d.ts +65 -0
  47. package/src/client/services/dom-swapper.js +237 -0
  48. package/src/client/services/dom-swapper.ts +325 -0
  49. package/src/client/services/index.d.ts +8 -0
  50. package/src/client/services/index.js +10 -0
  51. package/src/client/services/index.ts +9 -0
  52. package/src/client/services/prefetch-manager.d.ts +169 -0
  53. package/src/client/services/prefetch-manager.js +374 -0
  54. package/src/client/services/prefetch-manager.ts +451 -0
  55. package/src/client/services/scroll-manager.d.ts +19 -0
  56. package/src/client/services/scroll-manager.js +36 -0
  57. package/src/client/services/scroll-manager.ts +48 -0
  58. package/src/client/services/view-transition-manager.d.ts +23 -0
  59. package/src/client/services/view-transition-manager.js +38 -0
  60. package/src/client/services/view-transition-manager.ts +75 -0
  61. package/src/client/types.d.ts +84 -0
  62. package/src/client/types.js +19 -0
  63. package/src/client/types.ts +109 -0
  64. package/src/client/view-transition-utils.d.ts +14 -0
  65. package/src/client/view-transition-utils.js +60 -0
  66. package/src/client/view-transition-utils.ts +98 -0
  67. package/src/index.d.ts +9 -0
  68. package/src/index.js +11 -0
  69. package/src/index.ts +19 -0
  70. package/src/styles.css +218 -0
  71. package/src/types.d.ts +15 -0
  72. package/src/types.js +4 -0
  73. package/src/types.ts +19 -0
@@ -0,0 +1,325 @@
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
+ /**
12
+ * Checks if element has a persist attribute (custom or default).
13
+ */
14
+ function isPersisted(element: Element, persistAttribute: string): boolean {
15
+ return element.hasAttribute(persistAttribute) || element.hasAttribute(DEFAULT_PERSIST_ATTR);
16
+ }
17
+
18
+ /**
19
+ * Detects hydrated custom elements (with shadow DOM) that should skip morphing.
20
+ */
21
+ function isHydratedCustomElement(element: Element): boolean {
22
+ return element.localName.includes('-') && element.shadowRoot !== null;
23
+ }
24
+
25
+ /**
26
+ * Handles DOM manipulation during client-side page transitions.
27
+ *
28
+ * Uses a hybrid approach inspired by Turbo:
29
+ * - Surgical head updates (no morphing) to prevent FOUC
30
+ * - Idiomorph for efficient body diffing
31
+ */
32
+ export class DomSwapper {
33
+ private persistAttribute: string;
34
+
35
+ constructor(persistAttribute: string) {
36
+ this.persistAttribute = persistAttribute;
37
+ }
38
+
39
+ /**
40
+ * Parses HTML string into a Document, injecting a temporary base tag for URL resolution.
41
+ */
42
+ parseHTML(html: string, url?: URL): Document {
43
+ const parser = new DOMParser();
44
+ const htmlToParse = url ? `<base href="${url.href}" data-eco-injected>${html}` : html;
45
+ return parser.parseFromString(htmlToParse, 'text/html');
46
+ }
47
+
48
+ /**
49
+ * Preloads new stylesheets from target document to prevent FOUC.
50
+ *
51
+ * Discovers stylesheet links in the target document that aren't present in the
52
+ * current document, creates corresponding link elements, and waits for all to
53
+ * load before resolving. This follows Turbo's approach of waiting for stylesheets
54
+ * before any DOM updates.
55
+ */
56
+ async preloadStylesheets(newDocument: Document): Promise<void> {
57
+ const existingHrefs = new Set(
58
+ Array.from(document.head.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')).map((l) => l.href),
59
+ );
60
+
61
+ const newStylesheetLinks = Array.from(
62
+ newDocument.head.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]'),
63
+ ).filter((link) => !existingHrefs.has(link.href));
64
+
65
+ if (newStylesheetLinks.length === 0) {
66
+ return;
67
+ }
68
+
69
+ const TIMEOUT = 5000;
70
+ const loadPromises = newStylesheetLinks.map((link) => {
71
+ return new Promise<void>((resolve) => {
72
+ const newLink = document.createElement('link');
73
+ newLink.rel = 'stylesheet';
74
+ newLink.media = link.media || 'all';
75
+
76
+ const timeoutId = setTimeout(() => {
77
+ cleanup();
78
+ resolve();
79
+ }, TIMEOUT);
80
+
81
+ const cleanup = () => {
82
+ clearTimeout(timeoutId);
83
+ newLink.onload = null;
84
+ newLink.onerror = null;
85
+ };
86
+
87
+ newLink.onload = () => {
88
+ cleanup();
89
+ resolve();
90
+ };
91
+
92
+ newLink.onerror = () => {
93
+ cleanup();
94
+ resolve();
95
+ };
96
+
97
+ newLink.href = link.href;
98
+ document.head.appendChild(newLink);
99
+ });
100
+ });
101
+
102
+ await Promise.all(loadPromises);
103
+ }
104
+
105
+ /**
106
+ * Updates document head using Turbo-style surgical updates.
107
+ *
108
+ * This approach avoids morphing the head element entirely, which prevents
109
+ * browser repaints that cause FOUC. Instead, it:
110
+ * - Updates the document title
111
+ * - Merges meta tags (adds new, updates changed)
112
+ * - Leaves stylesheets untouched (they're preloaded separately)
113
+ * - Handles script re-execution for marked scripts
114
+ * - Injects new scripts from the incoming page that are absent from the current head
115
+ */
116
+ morphHead(newDocument: Document): void {
117
+ /** Update the document title if it has changed. */
118
+ const newTitle = newDocument.head.querySelector('title');
119
+ if (newTitle && document.title !== newTitle.textContent) {
120
+ document.title = newTitle.textContent || '';
121
+ }
122
+
123
+ /** Merge meta tags: update existing ones whose content changed, append new ones. */
124
+ const newMetas = newDocument.head.querySelectorAll('meta[name], meta[property]');
125
+ for (const newMeta of newMetas) {
126
+ const name = newMeta.getAttribute('name');
127
+ const property = newMeta.getAttribute('property');
128
+ const content = newMeta.getAttribute('content');
129
+
130
+ const selector = name ? `meta[name="${name}"]` : `meta[property="${property}"]`;
131
+ const existingMeta = document.head.querySelector(selector);
132
+
133
+ if (existingMeta) {
134
+ if (existingMeta.getAttribute('content') !== content) {
135
+ existingMeta.setAttribute('content', content || '');
136
+ }
137
+ } else {
138
+ document.head.appendChild(newMeta.cloneNode(true));
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Re-execute scripts that are explicitly marked with `data-eco-rerun`.
144
+ * Deduplication is performed via `data-eco-script-id` to prevent double execution.
145
+ */
146
+ const existingScriptIds = new Set(
147
+ Array.from(document.head.querySelectorAll('script[data-eco-script-id]')).map((s) =>
148
+ s.getAttribute('data-eco-script-id'),
149
+ ),
150
+ );
151
+
152
+ const rerunScripts = newDocument.head.querySelectorAll('script[data-eco-rerun]');
153
+ for (const script of rerunScripts) {
154
+ const scriptId = script.getAttribute('data-eco-script-id');
155
+ if (scriptId && !existingScriptIds.has(scriptId)) {
156
+ const newScript = document.createElement('script');
157
+ for (const attr of script.attributes) {
158
+ if (attr.name !== 'data-eco-rerun') {
159
+ newScript.setAttribute(attr.name, attr.value);
160
+ }
161
+ }
162
+ newScript.textContent = script.textContent;
163
+ document.head.appendChild(newScript);
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
+
193
+ if (src) {
194
+ if (existingScriptSrcs.has(src)) continue;
195
+ /** New external script — append a freshly created element so the browser fetches and executes it. */
196
+ const newScript = document.createElement('script');
197
+ for (const attr of script.attributes) {
198
+ newScript.setAttribute(attr.name, attr.value);
199
+ }
200
+ document.head.appendChild(newScript);
201
+ existingScriptSrcs.add(src);
202
+ } else {
203
+ /** Inline script — skip if identical content is already present to avoid re-running on every navigation. */
204
+ const content = (script.textContent ?? '').trim();
205
+ if (!content || existingInlineContents.has(content)) continue;
206
+ const newScript = document.createElement('script');
207
+ for (const attr of script.attributes) {
208
+ newScript.setAttribute(attr.name, attr.value);
209
+ }
210
+ newScript.textContent = script.textContent;
211
+ document.head.appendChild(newScript);
212
+ existingInlineContents.add(content);
213
+ }
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Detects custom elements without shadow DOM (light-DOM custom elements).
219
+ * These need full replacement rather than morphing, because morphdom would
220
+ * strip JS-generated content from their light DOM children.
221
+ */
222
+ private isLightDomCustomElement(element: Element): boolean {
223
+ return element.localName.includes('-') && element.shadowRoot === null;
224
+ }
225
+
226
+ /**
227
+ * Morphs document body using morphdom.
228
+ * Preserves persisted elements and hydrated custom elements.
229
+ * Light-DOM custom elements are fully replaced to trigger proper
230
+ * disconnectedCallback → connectedCallback lifecycle.
231
+ */
232
+ morphBody(newDocument: Document): void {
233
+ const persistAttr = this.persistAttribute;
234
+
235
+ morphdom(document.body, newDocument.body, {
236
+ onBeforeElUpdated: (fromEl, toEl) => {
237
+ if (isPersisted(fromEl, persistAttr)) {
238
+ return false;
239
+ }
240
+ if (isHydratedCustomElement(fromEl)) {
241
+ return false;
242
+ }
243
+
244
+ /**
245
+ * Light-DOM custom elements (e.g. <radiant-code-tabs>) often
246
+ * generate DOM in connectedCallback that doesn't exist in the
247
+ * server-rendered HTML. Morphdom would diff those children away.
248
+ * Instead, replace the element entirely so the browser fires
249
+ * disconnectedCallback on the old instance and connectedCallback
250
+ * on the fresh one.
251
+ */
252
+ if (this.isLightDomCustomElement(fromEl)) {
253
+ const newEl = document.createElement(toEl.tagName);
254
+ for (const attr of toEl.attributes) {
255
+ newEl.setAttribute(attr.name, attr.value);
256
+ }
257
+ newEl.innerHTML = toEl.innerHTML;
258
+ fromEl.replaceWith(newEl);
259
+ return false;
260
+ }
261
+
262
+ if (fromEl.isEqualNode(toEl)) {
263
+ return false;
264
+ }
265
+
266
+ return true;
267
+ },
268
+ });
269
+
270
+ this.processDeclarativeShadowDOM(document.body);
271
+ }
272
+
273
+ /**
274
+ * Replaces body content in a single operation.
275
+ * Preserves persisted elements by moving them to the new body.
276
+ * Use when View Transitions are disabled.
277
+ */
278
+ replaceBody(newDocument: Document): void {
279
+ const persistAttr = this.persistAttribute;
280
+
281
+ const persistedElements = document.body.querySelectorAll(`[${persistAttr}], [${DEFAULT_PERSIST_ATTR}]`);
282
+ const persistedMap = new Map<string, Element>();
283
+
284
+ for (const el of persistedElements) {
285
+ const key = el.getAttribute(persistAttr) || el.getAttribute(DEFAULT_PERSIST_ATTR);
286
+ if (key) {
287
+ persistedMap.set(key, el);
288
+ }
289
+ }
290
+
291
+ for (const [key, oldEl] of persistedMap) {
292
+ const placeholder = newDocument.body.querySelector(
293
+ `[${persistAttr}="${key}"], [${DEFAULT_PERSIST_ATTR}="${key}"]`,
294
+ );
295
+ if (placeholder) {
296
+ placeholder.replaceWith(oldEl);
297
+ }
298
+ }
299
+
300
+ document.body.replaceChildren(...newDocument.body.childNodes);
301
+ this.processDeclarativeShadowDOM(document.body);
302
+ }
303
+
304
+ /**
305
+ * Manually attaches declarative shadow DOM templates.
306
+ * Browsers only process `<template shadowrootmode>` during initial parse.
307
+ */
308
+ private processDeclarativeShadowDOM(root: Element | Document | ShadowRoot): void {
309
+ const templates = root.querySelectorAll<HTMLTemplateElement>('template[shadowrootmode], template[shadowroot]');
310
+
311
+ for (const template of templates) {
312
+ const mode = (template.getAttribute('shadowrootmode') ||
313
+ template.getAttribute('shadowroot')) as ShadowRootMode;
314
+ const parent = template.parentElement;
315
+
316
+ if (parent && !parent.shadowRoot) {
317
+ const shadowRoot = parent.attachShadow({ mode });
318
+ shadowRoot.appendChild(template.content);
319
+ template.remove();
320
+
321
+ this.processDeclarativeShadowDOM(shadowRoot);
322
+ }
323
+ }
324
+ }
325
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Services for the EcoPages transitions package
3
+ * @module
4
+ */
5
+ export { DomSwapper } from './dom-swapper.js';
6
+ export { ScrollManager } from './scroll-manager.js';
7
+ export { ViewTransitionManager } from './view-transition-manager.js';
8
+ export { PrefetchManager } from './prefetch-manager.js';
@@ -0,0 +1,10 @@
1
+ import { DomSwapper } from "./dom-swapper.js";
2
+ import { ScrollManager } from "./scroll-manager.js";
3
+ import { ViewTransitionManager } from "./view-transition-manager.js";
4
+ import { PrefetchManager } from "./prefetch-manager.js";
5
+ export {
6
+ DomSwapper,
7
+ PrefetchManager,
8
+ ScrollManager,
9
+ ViewTransitionManager
10
+ };
@@ -0,0 +1,9 @@
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';
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Prefetch manager for client-side navigation.
3
+ * Uses a single IntersectionObserver and hover detection for optimal performance.
4
+ * @module prefetch-manager
5
+ */
6
+ export type PrefetchStrategy = 'viewport' | 'hover' | 'intent';
7
+ export interface PrefetchOptions {
8
+ strategy: PrefetchStrategy;
9
+ delay: number;
10
+ noPrefetchAttribute: string;
11
+ respectDataSaver: boolean;
12
+ linkSelector: string;
13
+ }
14
+ export declare class PrefetchManager {
15
+ private options;
16
+ private prefetched;
17
+ private htmlCache;
18
+ private observer;
19
+ private hoverTimeouts;
20
+ constructor(options: Partial<PrefetchOptions>);
21
+ /**
22
+ * Initializes prefetching based on the configured strategy.
23
+ *
24
+ * Sets up IntersectionObserver for viewport-based prefetching and/or
25
+ * hover/focus listeners for intent-based prefetching. Immediately begins
26
+ * observing existing links on the page.
27
+ */
28
+ start(): void;
29
+ /**
30
+ * Cleans up all prefetch-related observers and event listeners.
31
+ * Cancels any pending hover timeouts.
32
+ */
33
+ stop(): void;
34
+ /**
35
+ * Fetches and caches HTML content for a given URL.
36
+ *
37
+ * Skips cross-origin URLs or already-prefetched URLs. On success, both the
38
+ * HTML content is cached and any new stylesheets are preloaded to prevent
39
+ * FOUC during navigation.
40
+ *
41
+ * @param href - The URL to prefetch
42
+ */
43
+ prefetch(href: string): Promise<void>;
44
+ /**
45
+ * Retrieves cached HTML for a URL.
46
+ *
47
+ * Returns cached content without consuming it, enabling stale-while-revalidate:
48
+ * - Returns cached HTML immediately for instant navigation
49
+ * - Use cacheVisitedPage() after navigation to update cache in background
50
+ *
51
+ * @param href - The URL to look up
52
+ * @returns The cached HTML string, or null if not cached
53
+ */
54
+ getCachedHtml(href: string): string | null;
55
+ /**
56
+ * Caches HTML content for a visited page and triggers background revalidation.
57
+ *
58
+ * Implements stale-while-revalidate pattern:
59
+ * - Immediately caches the provided HTML for instant revisits
60
+ * - Fetches fresh content in background for next visit (unless it's the current page)
61
+ *
62
+ * @param href - The URL of the visited page
63
+ * @param html - The HTML content to cache initially
64
+ */
65
+ cacheVisitedPage(href: string, html: string): void;
66
+ /**
67
+ * Checks if a URL has already been prefetched.
68
+ * @param href - The URL to check
69
+ */
70
+ isPrefetched(href: string): boolean;
71
+ /**
72
+ * Determines if prefetching should be enabled based on network conditions.
73
+ *
74
+ * Respects the user's data saver settings and avoids prefetching on slow
75
+ * connections (2g or slower) when `respectDataSaver` is enabled.
76
+ */
77
+ private shouldPrefetch;
78
+ /**
79
+ * Creates an IntersectionObserver to prefetch links as they enter the viewport.
80
+ *
81
+ * Uses a 50px root margin to trigger prefetching slightly before elements
82
+ * become visible, improving perceived performance.
83
+ */
84
+ private setupIntersectionObserver;
85
+ /**
86
+ * Attaches delegated event listeners for hover and focus-based prefetching.
87
+ *
88
+ * Uses event delegation on the document for efficient handling without
89
+ * attaching listeners to individual links.
90
+ */
91
+ private setupHoverListeners;
92
+ private handleMouseOver;
93
+ private handleMouseOut;
94
+ private handleFocusIn;
95
+ private handleFocusOut;
96
+ /**
97
+ * Extracts a valid anchor element from a DOM event.
98
+ *
99
+ * Returns null for links that should not be prefetched (opt-outs, downloads,
100
+ * hash links, javascript: URLs).
101
+ */
102
+ private getLinkFromEvent;
103
+ /**
104
+ * Resolves the prefetch strategy for a link element.
105
+ *
106
+ * Checks for per-link overrides via `data-eco-prefetch` attribute,
107
+ * falling back to the global strategy.
108
+ */
109
+ private getLinkStrategy;
110
+ /**
111
+ * Resolves the prefetch delay for a link element.
112
+ *
113
+ * Checks for per-link overrides via `data-eco-prefetch-delay` attribute,
114
+ * falling back to the global delay.
115
+ */
116
+ private getLinkDelay;
117
+ /**
118
+ * Schedules a prefetch request during browser idle time.
119
+ *
120
+ * Uses `requestIdleCallback` when available for non-blocking execution.
121
+ * Eager prefetches execute immediately.
122
+ *
123
+ * @param href - The URL to prefetch
124
+ * @param eager - If true, prefetch immediately without waiting for idle
125
+ */
126
+ private scheduleIdlePrefetch;
127
+ /**
128
+ * Schedules a prefetch after a hover delay.
129
+ *
130
+ * The delay prevents prefetching when users briefly pass over links
131
+ * without intent to navigate.
132
+ *
133
+ * @param href - The URL to prefetch
134
+ * @param delay - Milliseconds to wait before prefetching
135
+ */
136
+ private scheduleHoverPrefetch;
137
+ /**
138
+ * Cancels a pending hover-initiated prefetch.
139
+ * @param href - The URL whose prefetch should be cancelled
140
+ */
141
+ private cancelHoverPrefetch;
142
+ /**
143
+ * Begins observing all existing links on the page.
144
+ *
145
+ * Called once during initialization. Eager links are prefetched immediately;
146
+ * viewport-strategy links are registered with the IntersectionObserver.
147
+ */
148
+ private observeExistingLinks;
149
+ /**
150
+ * Observes newly added links after DOM mutations.
151
+ *
152
+ * Should be called after client-side navigation or dynamic content updates
153
+ * to ensure new links are tracked for prefetching.
154
+ *
155
+ * @param root - The root element to search for links (defaults to document)
156
+ */
157
+ observeNewLinks(root?: Element | Document): void;
158
+ /**
159
+ * Prefetches stylesheets discovered in HTML content.
160
+ *
161
+ * Parses the HTML to find stylesheet links, then creates preload hints
162
+ * for stylesheets not already present in the current document. This ensures
163
+ * styles are cached before navigation to prevent FOUC.
164
+ *
165
+ * @param html - The raw HTML string to parse
166
+ * @param url - The base URL for resolving relative stylesheet paths
167
+ */
168
+ private prefetchStylesheets;
169
+ }