@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
package/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@ecopages/browser-router` are documented here.
4
+
5
+ > **Note:** Changelog tracking begins at version `0.2.0`. Changes prior to this release are not recorded here but are available in the git history.
6
+
7
+ ## [UNRELEASED] — TBD
8
+
9
+ ### Features
10
+
11
+ - **Light-DOM custom element support in `dom-swapper`** — `morphdom` morphing process now correctly handles light-DOM custom elements during page transitions (`fce8080c`).
12
+ - **Improved `morphHead` script injection** — New scripts from the incoming page's `<head>` are now injected and executed correctly during client-side navigation, even when not marked with `data-eco-rerun` (`08e15e99`).
13
+ - **Global injector lifecycle management** — Enhanced global injector with structured lifecycle hooks and tests for hydration script handling (`2ba35aa4`).
14
+
15
+ ### Bug Fixes
16
+
17
+ - Published npm package metadata now includes validated declaration exports for generated dist entrypoints.
18
+
19
+ ### Refactoring
20
+
21
+ - Removed unused `@types/morphdom` dev dependency (`ceb243d0`).
22
+
23
+ ### Documentation
24
+
25
+ - README updated with new API usage examples.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-present Andrea Zanenghi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,132 @@
1
+ # @ecopages/browser-router
2
+
3
+ Client-side navigation and view transitions for Ecopages. Intercepts same-origin link clicks to provide smooth page transitions without full page reloads.
4
+
5
+ ## Features
6
+
7
+ - **Client-side navigation** - Intercepts `<a>` clicks for fast navigation
8
+ - **Efficient DOM diffing** - Uses [morphdom](https://github.com/patrick-steele-idem/morphdom) to update only what changed, preserving scroll positions and internal state
9
+ - **State persistence** - Elements with `data-eco-persist` are never recreated, preserving internal state
10
+ - **View Transitions** - Optional integration with the View Transition API
11
+ - **Lifecycle events** - Hook into navigation with `eco:before-swap`, `eco:after-swap`, `eco:page-load`
12
+
13
+ ## Compatibility
14
+
15
+ This package works with MPA-style rendering (KitaJS, Lit, vanilla JS) where the server returns full HTML pages.
16
+
17
+ **Not compatible with React/Preact** - These frameworks manage their own virtual DOM and component trees. Replacing the DOM breaks hydration, state, and event handlers. For React apps, use a framework-specific routing solution.
18
+
19
+ Component-level islands are a narrower case: small interactive roots emitted by another integration (for example a React island inside an otherwise MPA-style page) can work with `@ecopages/browser-router`, because ownership stays scoped to the island root instead of the full document.
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ bunx jsr add @ecopages/browser-router
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ Create and start the router in a **global** client-side script (e.g., `src/layouts/base-layout.script.ts`).
30
+
31
+ > **Important**: Ensure the router script is injected in a **consistent order** within the `<head>` across all pages. Inconsistent ordering (e.g. script between styles on one page but after on another) causes `morphdom` to reload styles, leading to a "Flash of Unstyled Content" (FOUC).
32
+
33
+ ```ts
34
+ import { createRouter } from '@ecopages/browser-router/client';
35
+
36
+ // Creates and starts the router with default options
37
+ const router = createRouter();
38
+ ```
39
+
40
+ With custom options:
41
+
42
+ ```ts
43
+ import { createRouter } from '@ecopages/browser-router/client';
44
+
45
+ const router = createRouter({
46
+ viewTransitions: true,
47
+ scrollBehavior: 'auto',
48
+ });
49
+ ```
50
+
51
+ ## Configuration
52
+
53
+ | Option | Type | Default | Description |
54
+ | :----------------- | :-----------------------------: | :------------------: | :--------------------------------------------- |
55
+ | `linkSelector` | `string` | `'a[href]'` | Selector for links to intercept |
56
+ | `persistAttribute` | `string` | `'data-eco-persist'` | Attribute to mark elements for DOM persistence |
57
+ | `reloadAttribute` | `string` | `'data-eco-reload'` | Attribute to force full page reload |
58
+ | `updateHistory` | `boolean` | `true` | Whether to update browser history |
59
+ | `scrollBehavior` | `'top' \| 'preserve' \| 'auto'` | `'top'` | Scroll behavior after navigation |
60
+ | `viewTransitions` | `boolean` | `false` | Use View Transition API for animations |
61
+ | `smoothScroll` | `boolean` | `false` | Use smooth scrolling during navigation |
62
+
63
+ ## Persistence
64
+
65
+ Mark elements to preserve across navigations. These elements are never recreated during navigation, morphdom skips them entirely, preserving their internal state (event listeners, web component state, form values, etc.):
66
+
67
+ ```html
68
+ <!-- This counter keeps its state across all navigations -->
69
+ <radiant-counter data-eco-persist="counter"></radiant-counter>
70
+ ```
71
+
72
+ ## Script Re-execution
73
+
74
+ To force a script to re-execute on every navigation (e.g. analytics, hydration), add `data-eco-rerun` and `data-eco-script-id`:
75
+
76
+ ```html
77
+ <script data-eco-rerun="true" data-eco-script-id="analytics">
78
+ // This runs on every navigation
79
+ trackPageview();
80
+ </script>
81
+ ```
82
+
83
+ ### React islands with `@ecopages/browser-router`
84
+
85
+ When `@ecopages/browser-router` is used in an MPA-style app that also renders component-level React islands:
86
+
87
+ - island hydration scripts may need to run again after `eco:after-swap`
88
+ - hydration bootstraps should carry stable `data-eco-script-id` metadata
89
+ - `data-eco-rerun` allows those bootstraps to be re-executed safely during head reconciliation
90
+
91
+ This note is specific to DOM-swapping navigation with `@ecopages/browser-router`. It does **not** apply to full React applications using [@ecopages/react-router](../react-router/README.md), where page routing and hydration are handled by the React router runtime itself.
92
+
93
+ ## Force Full Reload
94
+
95
+ Use `data-eco-reload` to force a full page reload:
96
+
97
+ ```html
98
+ <a href="/logout" data-eco-reload>Logout</a>
99
+ ```
100
+
101
+ ## Events
102
+
103
+ Listen to navigation lifecycle events:
104
+
105
+ ```ts
106
+ document.addEventListener('eco:before-swap', (e) => {
107
+ console.log('Navigating to:', e.detail.url);
108
+ // Call e.detail.reload() to abort and do full reload
109
+ });
110
+
111
+ document.addEventListener('eco:after-swap', (e) => {
112
+ console.log('Swapped to:', e.detail.url);
113
+ });
114
+
115
+ document.addEventListener('eco:page-load', (e) => {
116
+ console.log('Page loaded:', e.detail.url);
117
+ });
118
+ ```
119
+
120
+ ## Programmatic Navigation
121
+
122
+ ```ts
123
+ import { createRouter } from '@ecopages/browser-router/client';
124
+
125
+ const router = createRouter();
126
+
127
+ // Navigate with pushState
128
+ await router.navigate('/new-page');
129
+
130
+ // Navigate with replaceState
131
+ await router.navigate('/new-page', { replace: true });
132
+ ```
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@ecopages/browser-router",
3
+ "version": "0.2.0-alpha.1",
4
+ "description": "Client-side router for Ecopages with view transitions support",
5
+ "keywords": [
6
+ "ecopages",
7
+ "router",
8
+ "browser-router",
9
+ "navigation",
10
+ "spa",
11
+ "view-transitions"
12
+ ],
13
+ "license": "MIT",
14
+ "type": "module",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./src/index.d.ts",
18
+ "default": "./src/index.js"
19
+ },
20
+ "./client": {
21
+ "types": "./src/client/eco-router.d.ts",
22
+ "default": "./src/client/eco-router.js"
23
+ },
24
+ "./client.ts": {
25
+ "types": "./src/client/eco-router.d.ts",
26
+ "default": "./src/client/eco-router.js"
27
+ }
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/ecopages/ecopages.git",
32
+ "directory": "packages/browser-router"
33
+ },
34
+ "dependencies": {
35
+ "@ecopages/core": "0.2.0-alpha.1",
36
+ "morphdom": "^2.7.8"
37
+ },
38
+ "types": "./src/index.d.ts"
39
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Client-side router for Ecopages with morphdom-based DOM diffing.
3
+ * @module eco-router
4
+ */
5
+ import type { EcoRouterOptions } from './types.js';
6
+ /**
7
+ * Intercepts same-origin link clicks and performs client-side navigation
8
+ * using morphdom for efficient DOM diffing. Supports View Transitions API.
9
+ */
10
+ export declare class EcoRouter {
11
+ private options;
12
+ private abortController;
13
+ private domSwapper;
14
+ private scrollManager;
15
+ private viewTransitionManager;
16
+ private prefetchManager;
17
+ constructor(options?: EcoRouterOptions);
18
+ /**
19
+ * Starts the router and begins intercepting navigation.
20
+ *
21
+ * Attaches click handlers for links and popstate handlers for browser
22
+ * back/forward buttons. Also starts the prefetch manager if configured.
23
+ */
24
+ start(): void;
25
+ /**
26
+ * Stops the router and cleans up all event listeners.
27
+ * After calling this, navigation will fall back to full page reloads.
28
+ */
29
+ stop(): void;
30
+ /**
31
+ * Programmatic navigation.
32
+ * Falls back to full page reload for cross-origin URLs.
33
+ * @param href - The URL to navigate to
34
+ * @param options - Navigation options
35
+ * @param options.replace - If true, replaces the current history entry instead of pushing
36
+ */
37
+ navigate(href: string, options?: {
38
+ replace?: boolean;
39
+ }): Promise<void>;
40
+ /**
41
+ * Manually prefetch a URL.
42
+ * @param href - The URL to prefetch
43
+ */
44
+ prefetch(href: string): Promise<void>;
45
+ /**
46
+ * Intercepts link clicks for client-side navigation.
47
+ *
48
+ * Filters out clicks with modifier keys (opens new tab), non-left clicks,
49
+ * external links, download links, and links with the reload attribute.
50
+ *
51
+ * Uses `event.composedPath()` to correctly detect clicks on anchors inside
52
+ * Shadow DOM boundaries (Web Components).
53
+ */
54
+ private handleClick;
55
+ /**
56
+ * Handles browser back/forward navigation.
57
+ * Triggered by the History API's popstate event.
58
+ */
59
+ private handlePopState;
60
+ /**
61
+ * Checks if a URL shares the same origin as the current page.
62
+ * Cross-origin navigation always falls back to full page reload.
63
+ */
64
+ private isSameOrigin;
65
+ /**
66
+ * Executes the core navigation flow.
67
+ *
68
+ * Orchestrates fetching, DOM swapping, and lifecycle events:
69
+ *
70
+ * 1. **Fetch** - Retrieves HTML (from cache or network)
71
+ * 2. **eco:before-swap** - Allows listeners to force a full reload
72
+ * 3. **History update** - Updates URL before DOM swap so Web Components
73
+ * see the correct URL in their `connectedCallback`
74
+ * 4. **Stylesheet preload** - Prevents FOUC by loading styles first
75
+ * 5. **DOM swap** - Morphs head/body, optionally with View Transition
76
+ * 6. **Lifecycle events** - Dispatches `eco:after-swap` and `eco:page-load`
77
+ *
78
+ * Falls back to full page reload on network errors.
79
+ *
80
+ * @param url - The target URL to navigate to
81
+ * @param direction - Navigation direction ('forward', 'back', or 'replace')
82
+ */
83
+ private performNavigation;
84
+ /**
85
+ * Fetches the HTML content of a page.
86
+ * @param url - The URL to fetch
87
+ * @param signal - AbortSignal for cancelling the request
88
+ * @throws Error if the response is not ok
89
+ */
90
+ private fetchPage;
91
+ }
92
+ /**
93
+ * Creates and starts a router instance.
94
+ * @param options - Configuration options for the router
95
+ * @returns A started EcoRouter instance
96
+ */
97
+ export declare function createRouter(options?: EcoRouterOptions): EcoRouter;
98
+ export type { EcoRouterOptions, EcoNavigationEvent, EcoBeforeSwapEvent, EcoAfterSwapEvent } from './types';
@@ -0,0 +1,228 @@
1
+ import { DEFAULT_OPTIONS } from "./types.js";
2
+ import { DomSwapper, ScrollManager, ViewTransitionManager, PrefetchManager } from "./services/index.js";
3
+ class EcoRouter {
4
+ options;
5
+ abortController = null;
6
+ domSwapper;
7
+ scrollManager;
8
+ viewTransitionManager;
9
+ prefetchManager = null;
10
+ constructor(options = {}) {
11
+ this.options = { ...DEFAULT_OPTIONS, ...options };
12
+ this.domSwapper = new DomSwapper(this.options.persistAttribute);
13
+ this.scrollManager = new ScrollManager(this.options.scrollBehavior, this.options.smoothScroll);
14
+ this.viewTransitionManager = new ViewTransitionManager(this.options.viewTransitions);
15
+ if (this.options.prefetch !== false) {
16
+ this.prefetchManager = new PrefetchManager({
17
+ ...this.options.prefetch,
18
+ linkSelector: this.options.linkSelector
19
+ });
20
+ }
21
+ this.handleClick = this.handleClick.bind(this);
22
+ this.handlePopState = this.handlePopState.bind(this);
23
+ }
24
+ /**
25
+ * Starts the router and begins intercepting navigation.
26
+ *
27
+ * Attaches click handlers for links and popstate handlers for browser
28
+ * back/forward buttons. Also starts the prefetch manager if configured.
29
+ */
30
+ start() {
31
+ document.addEventListener("click", this.handleClick);
32
+ window.addEventListener("popstate", this.handlePopState);
33
+ this.prefetchManager?.start();
34
+ const initialHtml = document.documentElement.outerHTML;
35
+ this.prefetchManager?.cacheVisitedPage(window.location.href, initialHtml);
36
+ }
37
+ /**
38
+ * Stops the router and cleans up all event listeners.
39
+ * After calling this, navigation will fall back to full page reloads.
40
+ */
41
+ stop() {
42
+ document.removeEventListener("click", this.handleClick);
43
+ window.removeEventListener("popstate", this.handlePopState);
44
+ this.prefetchManager?.stop();
45
+ }
46
+ /**
47
+ * Programmatic navigation.
48
+ * Falls back to full page reload for cross-origin URLs.
49
+ * @param href - The URL to navigate to
50
+ * @param options - Navigation options
51
+ * @param options.replace - If true, replaces the current history entry instead of pushing
52
+ */
53
+ async navigate(href, options = {}) {
54
+ const url = new URL(href, window.location.origin);
55
+ if (!this.isSameOrigin(url)) {
56
+ window.location.href = href;
57
+ return;
58
+ }
59
+ await this.performNavigation(url, options.replace ? "replace" : "forward");
60
+ }
61
+ /**
62
+ * Manually prefetch a URL.
63
+ * @param href - The URL to prefetch
64
+ */
65
+ async prefetch(href) {
66
+ if (!this.prefetchManager) {
67
+ console.warn("[ecopages] Prefetching is disabled. Enable it in router options.");
68
+ return;
69
+ }
70
+ return this.prefetchManager.prefetch(href);
71
+ }
72
+ /**
73
+ * Intercepts link clicks for client-side navigation.
74
+ *
75
+ * Filters out clicks with modifier keys (opens new tab), non-left clicks,
76
+ * external links, download links, and links with the reload attribute.
77
+ *
78
+ * Uses `event.composedPath()` to correctly detect clicks on anchors inside
79
+ * Shadow DOM boundaries (Web Components).
80
+ */
81
+ handleClick(event) {
82
+ const link = event.composedPath().find(
83
+ (el) => el instanceof HTMLAnchorElement && el.matches(this.options.linkSelector)
84
+ );
85
+ if (!link) return;
86
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
87
+ if (event.button !== 0) return;
88
+ const target = link.getAttribute("target");
89
+ if (target && target !== "_self") return;
90
+ if (link.hasAttribute(this.options.reloadAttribute)) return;
91
+ if (link.hasAttribute("download")) return;
92
+ const href = link.getAttribute("href");
93
+ if (!href) return;
94
+ if (href.startsWith("#")) return;
95
+ if (href.startsWith("javascript:")) return;
96
+ const url = new URL(href, window.location.origin);
97
+ if (!this.isSameOrigin(url)) return;
98
+ event.preventDefault();
99
+ this.performNavigation(url, "forward");
100
+ }
101
+ /**
102
+ * Handles browser back/forward navigation.
103
+ * Triggered by the History API's popstate event.
104
+ */
105
+ handlePopState(_event) {
106
+ const url = new URL(window.location.href);
107
+ this.performNavigation(url, "back");
108
+ }
109
+ /**
110
+ * Checks if a URL shares the same origin as the current page.
111
+ * Cross-origin navigation always falls back to full page reload.
112
+ */
113
+ isSameOrigin(url) {
114
+ return url.origin === window.location.origin;
115
+ }
116
+ /**
117
+ * Executes the core navigation flow.
118
+ *
119
+ * Orchestrates fetching, DOM swapping, and lifecycle events:
120
+ *
121
+ * 1. **Fetch** - Retrieves HTML (from cache or network)
122
+ * 2. **eco:before-swap** - Allows listeners to force a full reload
123
+ * 3. **History update** - Updates URL before DOM swap so Web Components
124
+ * see the correct URL in their `connectedCallback`
125
+ * 4. **Stylesheet preload** - Prevents FOUC by loading styles first
126
+ * 5. **DOM swap** - Morphs head/body, optionally with View Transition
127
+ * 6. **Lifecycle events** - Dispatches `eco:after-swap` and `eco:page-load`
128
+ *
129
+ * Falls back to full page reload on network errors.
130
+ *
131
+ * @param url - The target URL to navigate to
132
+ * @param direction - Navigation direction ('forward', 'back', or 'replace')
133
+ */
134
+ async performNavigation(url, direction) {
135
+ const previousUrl = new URL(window.location.href);
136
+ this.abortController?.abort();
137
+ this.abortController = new AbortController();
138
+ try {
139
+ const html = await this.fetchPage(url, this.abortController.signal);
140
+ const newDocument = this.domSwapper.parseHTML(html, url);
141
+ let shouldReload = false;
142
+ const beforeSwapEvent = {
143
+ url,
144
+ direction,
145
+ newDocument,
146
+ reload: () => {
147
+ shouldReload = true;
148
+ }
149
+ };
150
+ document.dispatchEvent(new CustomEvent("eco:before-swap", { detail: beforeSwapEvent }));
151
+ if (shouldReload) {
152
+ window.location.href = url.href;
153
+ return;
154
+ }
155
+ if (this.options.updateHistory && direction === "forward") {
156
+ window.history.pushState({}, "", url.href);
157
+ } else if (direction === "replace") {
158
+ window.history.replaceState({}, "", url.href);
159
+ }
160
+ const useViewTransitions = this.options.viewTransitions;
161
+ await this.domSwapper.preloadStylesheets(newDocument);
162
+ if (useViewTransitions) {
163
+ await this.viewTransitionManager.transition(() => {
164
+ this.domSwapper.morphHead(newDocument);
165
+ this.domSwapper.morphBody(newDocument);
166
+ this.scrollManager.handleScroll(url, previousUrl);
167
+ });
168
+ } else {
169
+ this.domSwapper.morphHead(newDocument);
170
+ this.domSwapper.replaceBody(newDocument);
171
+ this.scrollManager.handleScroll(url, previousUrl);
172
+ }
173
+ const afterSwapEvent = {
174
+ url,
175
+ direction
176
+ };
177
+ document.dispatchEvent(new CustomEvent("eco:after-swap", { detail: afterSwapEvent }));
178
+ this.prefetchManager?.observeNewLinks();
179
+ this.prefetchManager?.cacheVisitedPage(url.href, html);
180
+ requestAnimationFrame(() => {
181
+ document.dispatchEvent(
182
+ new CustomEvent("eco:page-load", {
183
+ detail: { url, direction }
184
+ })
185
+ );
186
+ });
187
+ } catch (error) {
188
+ if (error instanceof Error && error.name === "AbortError") {
189
+ return;
190
+ }
191
+ console.error("[ecopages] Navigation failed:", error);
192
+ window.location.href = url.href;
193
+ }
194
+ }
195
+ /**
196
+ * Fetches the HTML content of a page.
197
+ * @param url - The URL to fetch
198
+ * @param signal - AbortSignal for cancelling the request
199
+ * @throws Error if the response is not ok
200
+ */
201
+ async fetchPage(url, signal) {
202
+ if (this.prefetchManager) {
203
+ const cachedHtml = this.prefetchManager.getCachedHtml(url.href);
204
+ if (cachedHtml) {
205
+ return cachedHtml;
206
+ }
207
+ }
208
+ const response = await fetch(url.href, {
209
+ signal,
210
+ headers: {
211
+ Accept: "text/html"
212
+ }
213
+ });
214
+ if (!response.ok) {
215
+ throw new Error(`Failed to fetch page: ${response.status}`);
216
+ }
217
+ return response.text();
218
+ }
219
+ }
220
+ function createRouter(options) {
221
+ const router = new EcoRouter(options);
222
+ router.start();
223
+ return router;
224
+ }
225
+ export {
226
+ EcoRouter,
227
+ createRouter
228
+ };