@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
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
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
+
};
|