@ecopages/browser-router 0.2.0-alpha.3 → 0.2.0-alpha.30

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 (67) hide show
  1. package/CHANGELOG.md +8 -7
  2. package/README.md +60 -37
  3. package/package.json +4 -2
  4. package/src/client/document-element-sync.d.ts +24 -0
  5. package/src/client/document-element-sync.js +20 -0
  6. package/src/client/eco-router.d.ts +35 -1
  7. package/src/client/eco-router.js +336 -77
  8. package/src/client/services/dom-swapper.d.ts +102 -0
  9. package/src/client/services/dom-swapper.js +316 -39
  10. package/src/client/services/prefetch-manager.d.ts +6 -3
  11. package/src/client/services/prefetch-manager.js +21 -11
  12. package/src/client/services/view-transition-manager.d.ts +7 -1
  13. package/src/client/services/view-transition-manager.js +21 -5
  14. package/src/client/types.d.ts +12 -0
  15. package/src/client/types.js +4 -0
  16. package/src/index.d.ts +2 -1
  17. package/src/index.js +9 -2
  18. package/src/types.d.ts +1 -1
  19. package/src/types.js +2 -1
  20. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Initialization-should-create-router-instance-1.png +0 -0
  21. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Initialization-should-start-and-stop-without-errors-1.png +0 -0
  22. 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
  23. 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
  24. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Lifecycle-Events-should-provide-event-details-with-url-and-direction-1.png +0 -0
  25. 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
  26. 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
  27. 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
  28. 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
  29. 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
  30. 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
  31. 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
  32. 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
  33. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-alt-click-1.png +0 -0
  34. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-ctrl-click-1.png +0 -0
  35. 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
  36. 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
  37. 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
  38. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-shift-click-1.png +0 -0
  39. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-external-links--different-origin--1.png +0 -0
  40. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-hash-only-links-1.png +0 -0
  41. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-javascript--links-1.png +0 -0
  42. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-custom-reload-attribute-1.png +0 -0
  43. 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
  44. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-download-attribute-1.png +0 -0
  45. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-empty-href-1.png +0 -0
  46. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-target---blank--1.png +0 -0
  47. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-target---parent--1.png +0 -0
  48. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-without-href-attribute-1.png +0 -0
  49. 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
  50. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-intercept-internal-links-with-relative-paths-1.png +0 -0
  51. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-intercept-nested-elements-inside-links-1.png +0 -0
  52. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-should-NOT-intercept-external-links-1.png +0 -0
  53. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Navigation-Abort-should-abort-previous-navigation-when-new-one-starts-1.png +0 -0
  54. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Programmatic-Navigation-should-navigate-and-update-history-with-pushState-1.png +0 -0
  55. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Programmatic-Navigation-should-use-replaceState-when-replace-option-is-true-1.png +0 -0
  56. 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
  57. 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
  58. package/src/client/eco-router.ts +0 -310
  59. package/src/client/services/dom-swapper.ts +0 -325
  60. package/src/client/services/index.ts +0 -9
  61. package/src/client/services/prefetch-manager.ts +0 -457
  62. package/src/client/services/scroll-manager.ts +0 -48
  63. package/src/client/services/view-transition-manager.ts +0 -75
  64. package/src/client/types.ts +0 -109
  65. package/src/client/view-transition-utils.ts +0 -98
  66. package/src/index.ts +0 -19
  67. package/src/types.ts +0 -19
package/src/types.js CHANGED
@@ -1,4 +1,5 @@
1
- import { DEFAULT_OPTIONS } from "./client/types";
1
+ import { DEFAULT_DOCUMENT_ELEMENT_ATTRIBUTES_TO_SYNC, DEFAULT_OPTIONS } from "./client/types";
2
2
  export {
3
+ DEFAULT_DOCUMENT_ELEMENT_ATTRIBUTES_TO_SYNC,
3
4
  DEFAULT_OPTIONS
4
5
  };
@@ -1,310 +0,0 @@
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
- const windowWithHmr = window as typeof window & {
53
- __ecopages_reload_current_page__?: (options: { clearCache: boolean }) => Promise<void>;
54
- };
55
-
56
- windowWithHmr.__ecopages_reload_current_page__ = async (options: { clearCache: boolean }) => {
57
- const currentUrl = window.location.pathname + window.location.search;
58
-
59
- if (options.clearCache) {
60
- this.prefetchManager?.invalidate(currentUrl);
61
- }
62
-
63
- await this.performNavigation(new URL(currentUrl, window.location.origin), 'replace');
64
- };
65
-
66
- // Cache the initial page for instant back-navigation
67
- const initialHtml = document.documentElement.outerHTML;
68
- this.prefetchManager?.cacheVisitedPage(window.location.href, initialHtml);
69
- }
70
-
71
- /**
72
- * Stops the router and cleans up all event listeners.
73
- * After calling this, navigation will fall back to full page reloads.
74
- */
75
- public stop(): void {
76
- document.removeEventListener('click', this.handleClick);
77
- window.removeEventListener('popstate', this.handlePopState);
78
- this.prefetchManager?.stop();
79
-
80
- const windowWithHmr = window as typeof window & {
81
- __ecopages_reload_current_page__?: (options: { clearCache: boolean }) => Promise<void>;
82
- };
83
-
84
- windowWithHmr.__ecopages_reload_current_page__ = undefined;
85
- }
86
-
87
- /**
88
- * Programmatic navigation.
89
- * Falls back to full page reload for cross-origin URLs.
90
- * @param href - The URL to navigate to
91
- * @param options - Navigation options
92
- * @param options.replace - If true, replaces the current history entry instead of pushing
93
- */
94
- public async navigate(href: string, options: { replace?: boolean } = {}): Promise<void> {
95
- const url = new URL(href, window.location.origin);
96
-
97
- if (!this.isSameOrigin(url)) {
98
- window.location.href = href;
99
- return;
100
- }
101
-
102
- await this.performNavigation(url, options.replace ? 'replace' : 'forward');
103
- }
104
-
105
- /**
106
- * Manually prefetch a URL.
107
- * @param href - The URL to prefetch
108
- */
109
- public async prefetch(href: string): Promise<void> {
110
- if (!this.prefetchManager) {
111
- console.warn('[ecopages] Prefetching is disabled. Enable it in router options.');
112
- return;
113
- }
114
- return this.prefetchManager.prefetch(href);
115
- }
116
-
117
- /**
118
- * Intercepts link clicks for client-side navigation.
119
- *
120
- * Filters out clicks with modifier keys (opens new tab), non-left clicks,
121
- * external links, download links, and links with the reload attribute.
122
- *
123
- * Uses `event.composedPath()` to correctly detect clicks on anchors inside
124
- * Shadow DOM boundaries (Web Components).
125
- */
126
- private handleClick(event: MouseEvent): void {
127
- const link = event
128
- .composedPath()
129
- .find(
130
- (el) => el instanceof HTMLAnchorElement && el.matches(this.options.linkSelector),
131
- ) as HTMLAnchorElement | null;
132
-
133
- if (!link) return;
134
-
135
- if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
136
- if (event.button !== 0) return;
137
-
138
- const target = link.getAttribute('target');
139
- if (target && target !== '_self') return;
140
-
141
- if (link.hasAttribute(this.options.reloadAttribute)) return;
142
- if (link.hasAttribute('download')) return;
143
-
144
- const href = link.getAttribute('href');
145
- if (!href) return;
146
-
147
- if (href.startsWith('#')) return;
148
- if (href.startsWith('javascript:')) return;
149
-
150
- const url = new URL(href, window.location.origin);
151
-
152
- if (!this.isSameOrigin(url)) return;
153
-
154
- event.preventDefault();
155
- this.performNavigation(url, 'forward');
156
- }
157
-
158
- /**
159
- * Handles browser back/forward navigation.
160
- * Triggered by the History API's popstate event.
161
- */
162
- private handlePopState(_event: PopStateEvent): void {
163
- const url = new URL(window.location.href);
164
- this.performNavigation(url, 'back');
165
- }
166
-
167
- /**
168
- * Checks if a URL shares the same origin as the current page.
169
- * Cross-origin navigation always falls back to full page reload.
170
- */
171
- private isSameOrigin(url: URL): boolean {
172
- return url.origin === window.location.origin;
173
- }
174
-
175
- /**
176
- * Executes the core navigation flow.
177
- *
178
- * Orchestrates fetching, DOM swapping, and lifecycle events:
179
- *
180
- * 1. **Fetch** - Retrieves HTML (from cache or network)
181
- * 2. **eco:before-swap** - Allows listeners to force a full reload
182
- * 3. **History update** - Updates URL before DOM swap so Web Components
183
- * see the correct URL in their `connectedCallback`
184
- * 4. **Stylesheet preload** - Prevents FOUC by loading styles first
185
- * 5. **DOM swap** - Morphs head/body, optionally with View Transition
186
- * 6. **Lifecycle events** - Dispatches `eco:after-swap` and `eco:page-load`
187
- *
188
- * Falls back to full page reload on network errors.
189
- *
190
- * @param url - The target URL to navigate to
191
- * @param direction - Navigation direction ('forward', 'back', or 'replace')
192
- */
193
- private async performNavigation(url: URL, direction: EcoNavigationEvent['direction']): Promise<void> {
194
- const previousUrl = new URL(window.location.href);
195
-
196
- this.abortController?.abort();
197
- this.abortController = new AbortController();
198
-
199
- try {
200
- const html = await this.fetchPage(url, this.abortController.signal);
201
- const newDocument = this.domSwapper.parseHTML(html, url);
202
-
203
- let shouldReload = false;
204
- const beforeSwapEvent: EcoBeforeSwapEvent = {
205
- url,
206
- direction,
207
- newDocument,
208
- reload: () => {
209
- shouldReload = true;
210
- },
211
- };
212
-
213
- document.dispatchEvent(new CustomEvent('eco:before-swap', { detail: beforeSwapEvent }));
214
-
215
- if (shouldReload) {
216
- window.location.href = url.href;
217
- return;
218
- }
219
-
220
- if (this.options.updateHistory && direction === 'forward') {
221
- window.history.pushState({}, '', url.href);
222
- } else if (direction === 'replace') {
223
- window.history.replaceState({}, '', url.href);
224
- }
225
-
226
- const useViewTransitions = this.options.viewTransitions;
227
- await this.domSwapper.preloadStylesheets(newDocument);
228
-
229
- if (useViewTransitions) {
230
- await this.viewTransitionManager.transition(() => {
231
- this.domSwapper.morphHead(newDocument);
232
- this.domSwapper.morphBody(newDocument);
233
- this.scrollManager.handleScroll(url, previousUrl);
234
- });
235
- } else {
236
- this.domSwapper.morphHead(newDocument);
237
- this.domSwapper.replaceBody(newDocument);
238
- this.scrollManager.handleScroll(url, previousUrl);
239
- }
240
-
241
- const afterSwapEvent: EcoAfterSwapEvent = {
242
- url,
243
- direction,
244
- };
245
-
246
- document.dispatchEvent(new CustomEvent('eco:after-swap', { detail: afterSwapEvent }));
247
-
248
- this.prefetchManager?.observeNewLinks();
249
-
250
- // Cache the visited page for instant revisits (stale-while-revalidate)
251
- this.prefetchManager?.cacheVisitedPage(url.href, html);
252
-
253
- requestAnimationFrame(() => {
254
- document.dispatchEvent(
255
- new CustomEvent('eco:page-load', {
256
- detail: { url, direction } as EcoNavigationEvent,
257
- }),
258
- );
259
- });
260
- } catch (error) {
261
- if (error instanceof Error && error.name === 'AbortError') {
262
- return;
263
- }
264
-
265
- console.error('[ecopages] Navigation failed:', error);
266
- window.location.href = url.href;
267
- }
268
- }
269
-
270
- /**
271
- * Fetches the HTML content of a page.
272
- * @param url - The URL to fetch
273
- * @param signal - AbortSignal for cancelling the request
274
- * @throws Error if the response is not ok
275
- */
276
- private async fetchPage(url: URL, signal: AbortSignal): Promise<string> {
277
- if (this.prefetchManager) {
278
- const cachedHtml = this.prefetchManager.getCachedHtml(url.href);
279
- if (cachedHtml) {
280
- return cachedHtml;
281
- }
282
- }
283
-
284
- const response = await fetch(url.href, {
285
- signal,
286
- headers: {
287
- Accept: 'text/html',
288
- },
289
- });
290
-
291
- if (!response.ok) {
292
- throw new Error(`Failed to fetch page: ${response.status}`);
293
- }
294
-
295
- return response.text();
296
- }
297
- }
298
-
299
- /**
300
- * Creates and starts a router instance.
301
- * @param options - Configuration options for the router
302
- * @returns A started EcoRouter instance
303
- */
304
- export function createRouter(options?: EcoRouterOptions): EcoRouter {
305
- const router = new EcoRouter(options);
306
- router.start();
307
- return router;
308
- }
309
-
310
- export type { EcoRouterOptions, EcoNavigationEvent, EcoBeforeSwapEvent, EcoAfterSwapEvent } from './types';