@ecopages/react-router 0.2.0-alpha.9 → 0.2.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 +3 -5
- package/package.json +3 -3
- package/src/head-morpher.d.ts +6 -2
- package/src/head-morpher.js +67 -7
- package/src/router.js +15 -1
- package/browser.ts +0 -17
- package/src/adapter.ts +0 -48
- package/src/context.ts +0 -25
- package/src/head-morpher.ts +0 -214
- package/src/index.ts +0 -21
- package/src/manage-scroll.ts +0 -47
- package/src/navigation.ts +0 -297
- package/src/props-script.ts +0 -19
- package/src/router.ts +0 -670
- package/src/scroll-persist.ts +0 -96
- package/src/types.ts +0 -64
- package/src/view-transition-manager.ts +0 -30
- package/src/view-transition-utils.ts +0 -95
package/src/navigation.ts
DELETED
|
@@ -1,297 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Navigation utilities for fetching and parsing page content.
|
|
3
|
-
* @module
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
/// <reference types="@ecopages/core/declarations" />
|
|
7
|
-
|
|
8
|
-
import { getEcoDocumentOwner } from '@ecopages/core/router/navigation-coordinator';
|
|
9
|
-
import { type ComponentType } from 'react';
|
|
10
|
-
import type { EcoRouterOptions } from './types.ts';
|
|
11
|
-
|
|
12
|
-
const ROUTER_PROPS_SCRIPT_ID = '__ECO_PAGE_DATA__';
|
|
13
|
-
|
|
14
|
-
function isReactPageHydrationAsset(src: string): boolean {
|
|
15
|
-
return src.includes('ecopages-react-') && src.includes('hydration.js') && !src.includes('ecopages-react-island-');
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export type PageState = {
|
|
19
|
-
Component: ComponentType<any>;
|
|
20
|
-
props: Record<string, any>;
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
export type LoadedPageModule = {
|
|
24
|
-
Component: ComponentType<any>;
|
|
25
|
-
props: Record<string, any>;
|
|
26
|
-
doc: Document;
|
|
27
|
-
finalPath: string;
|
|
28
|
-
moduleUrl: string;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
export type FetchedPageDocument = {
|
|
32
|
-
doc: Document;
|
|
33
|
-
finalPath: string;
|
|
34
|
-
html: string;
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
type LoadPageModuleOptions = {
|
|
38
|
-
signal?: AbortSignal;
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
export type InterceptDecision =
|
|
42
|
-
| { shouldIntercept: true }
|
|
43
|
-
| {
|
|
44
|
-
shouldIntercept: false;
|
|
45
|
-
reason:
|
|
46
|
-
| 'modified-click'
|
|
47
|
-
| 'non-left-click'
|
|
48
|
-
| 'external-target'
|
|
49
|
-
| 'explicit-reload'
|
|
50
|
-
| 'download'
|
|
51
|
-
| 'invalid-href'
|
|
52
|
-
| 'cross-origin';
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Determines whether a link click should be intercepted for client-side navigation.
|
|
57
|
-
*
|
|
58
|
-
* Standard SPA navigation rules:
|
|
59
|
-
* - Modified clicks (Cmd/Ctrl/Shift/Alt) open in new tab
|
|
60
|
-
* - Non-left clicks use default browser behavior
|
|
61
|
-
* - External targets, downloads, and cross-origin links navigate normally
|
|
62
|
-
*
|
|
63
|
-
* @returns Object indicating whether to intercept and the reason if not
|
|
64
|
-
*/
|
|
65
|
-
export function getInterceptDecision(
|
|
66
|
-
event: MouseEvent,
|
|
67
|
-
link: HTMLAnchorElement,
|
|
68
|
-
options: Required<EcoRouterOptions>,
|
|
69
|
-
): InterceptDecision {
|
|
70
|
-
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
|
71
|
-
return { shouldIntercept: false, reason: 'modified-click' };
|
|
72
|
-
}
|
|
73
|
-
if (event.button !== 0) return { shouldIntercept: false, reason: 'non-left-click' };
|
|
74
|
-
|
|
75
|
-
const target = link.getAttribute('target');
|
|
76
|
-
if (target && target !== '_self') return { shouldIntercept: false, reason: 'external-target' };
|
|
77
|
-
|
|
78
|
-
if (link.hasAttribute(options.reloadAttribute)) return { shouldIntercept: false, reason: 'explicit-reload' };
|
|
79
|
-
if (link.hasAttribute('download')) return { shouldIntercept: false, reason: 'download' };
|
|
80
|
-
|
|
81
|
-
const href = link.getAttribute('href');
|
|
82
|
-
if (!href || href.startsWith('#') || href.startsWith('javascript:')) {
|
|
83
|
-
return { shouldIntercept: false, reason: 'invalid-href' };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const url = new URL(href, window.location.origin);
|
|
87
|
-
if (url.origin !== window.location.origin) return { shouldIntercept: false, reason: 'cross-origin' };
|
|
88
|
-
|
|
89
|
-
return { shouldIntercept: true };
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Extracts component module URL from window.__ECO_PAGES__.page.
|
|
94
|
-
* For current document, returns the module path set by hydration script.
|
|
95
|
-
* For fetched documents, parses the hydration script to extract the module path.
|
|
96
|
-
*/
|
|
97
|
-
function extractComponentUrlFromMarker(doc: Document): string | null {
|
|
98
|
-
if (doc === document && window.__ECO_PAGES__?.page?.module) {
|
|
99
|
-
return window.__ECO_PAGES__.page.module;
|
|
100
|
-
}
|
|
101
|
-
return null;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Matches default import: `import Content from './Content'`
|
|
106
|
-
* Used to extract module path from hydration script for fetched documents.
|
|
107
|
-
*/
|
|
108
|
-
const DEFAULT_IMPORT_REGEX = /import\s+(\w+)\s+from\s*['"]([^'"]+)['"]/;
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Matches namespace import: `import * as Content from './Content'`
|
|
112
|
-
* Used for MDX components. Also handles minified: `import*as Content from'./Content'`
|
|
113
|
-
*/
|
|
114
|
-
const NAMESPACE_IMPORT_REGEX = /import\s*\*\s*as\s*(\w+)\s*from\s*['"]([^'"]+)['"]/;
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Extracts import path from hydration script code using regex.
|
|
118
|
-
* Used for fetched documents. Less reliable due to minification.
|
|
119
|
-
*/
|
|
120
|
-
function extractModulePathFromCode(code: string): string | null {
|
|
121
|
-
const defaultMatch = code.match(DEFAULT_IMPORT_REGEX);
|
|
122
|
-
const namespaceMatch = code.match(NAMESPACE_IMPORT_REGEX);
|
|
123
|
-
return (defaultMatch || namespaceMatch)?.[2] ?? null;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Extracts serialized page props from window.__ECO_PAGES__.page or fetched document.
|
|
128
|
-
* For current document, returns props set by hydration script.
|
|
129
|
-
* For fetched documents, parses the JSON script tag directly.
|
|
130
|
-
*/
|
|
131
|
-
export function extractProps(doc: Document): Record<string, any> {
|
|
132
|
-
if (doc === document && window.__ECO_PAGES__?.page?.props) {
|
|
133
|
-
return window.__ECO_PAGES__.page.props;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const propsScript = doc.getElementById(ROUTER_PROPS_SCRIPT_ID);
|
|
137
|
-
if (propsScript?.textContent) {
|
|
138
|
-
try {
|
|
139
|
-
return JSON.parse(propsScript.textContent);
|
|
140
|
-
} catch (e) {
|
|
141
|
-
console.error('[EcoRouter] Failed to parse props:', e);
|
|
142
|
-
return {};
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
return {};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function isReactRouteDocument(doc: Document): boolean {
|
|
150
|
-
return getEcoDocumentOwner(doc) === 'react-router';
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Adds cache-busting timestamp for HMR in development.
|
|
155
|
-
*
|
|
156
|
-
* Prevents loading stale cached modules when navigating to previously visited pages.
|
|
157
|
-
* Disabled in production where filenames have content hashes.
|
|
158
|
-
*/
|
|
159
|
-
function addCacheBuster(url: string): string {
|
|
160
|
-
if (import.meta.env?.MODE === 'production' || import.meta.env?.PROD) {
|
|
161
|
-
return url;
|
|
162
|
-
}
|
|
163
|
-
const separator = url.includes('?') ? '&' : '?';
|
|
164
|
-
return `${url}${separator}t=${Date.now()}`;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Extracts component module URL using multi-tier strategy.
|
|
169
|
-
*
|
|
170
|
-
* 1. Read from window.__ECO_PAGES__.page.module (for current document)
|
|
171
|
-
* 2. Parse inline hydration script with regex (for fetched documents)
|
|
172
|
-
* 3. Fetch and parse external hydration script (final fallback)
|
|
173
|
-
*
|
|
174
|
-
* Regex parsing is less reliable due to minification.
|
|
175
|
-
*/
|
|
176
|
-
export async function extractComponentUrl(doc: Document): Promise<string | null> {
|
|
177
|
-
const markerUrl = extractComponentUrlFromMarker(doc);
|
|
178
|
-
if (markerUrl) return markerUrl;
|
|
179
|
-
|
|
180
|
-
const scripts = Array.from(doc.querySelectorAll('script'));
|
|
181
|
-
|
|
182
|
-
const inlineHydrationScript = scripts.find(
|
|
183
|
-
(s) =>
|
|
184
|
-
!s.src &&
|
|
185
|
-
!!s.textContent &&
|
|
186
|
-
s.textContent.includes('__ECO_PAGES__') &&
|
|
187
|
-
s.textContent.includes('hydrateRoot') &&
|
|
188
|
-
s.textContent.includes('import'),
|
|
189
|
-
);
|
|
190
|
-
|
|
191
|
-
if (inlineHydrationScript?.textContent) {
|
|
192
|
-
return extractModulePathFromCode(inlineHydrationScript.textContent);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const hydrationScript = scripts.find((s) => isReactPageHydrationAsset(s.src ?? ''));
|
|
196
|
-
if (!hydrationScript?.src) return null;
|
|
197
|
-
|
|
198
|
-
try {
|
|
199
|
-
const scriptUrl = addCacheBuster(hydrationScript.src);
|
|
200
|
-
const res = await fetch(scriptUrl);
|
|
201
|
-
const code = await res.text();
|
|
202
|
-
return extractModulePathFromCode(code);
|
|
203
|
-
} catch {
|
|
204
|
-
return null;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Fetches and parses a page, returning its component, props, and document.
|
|
210
|
-
*
|
|
211
|
-
* Flow: Fetch HTML → Parse → Extract props → Extract component URL → Import module
|
|
212
|
-
*
|
|
213
|
-
* Handles multiple export patterns (Content, default.Content, default) for different
|
|
214
|
-
* integration setups. Does NOT update DOM - caller applies changes.
|
|
215
|
-
*
|
|
216
|
-
* @param url - The URL to load
|
|
217
|
-
* @returns Object with Component, props, doc, and finalPath, or null on error
|
|
218
|
-
*/
|
|
219
|
-
export async function loadPageModule(
|
|
220
|
-
url: string,
|
|
221
|
-
options: LoadPageModuleOptions = {},
|
|
222
|
-
): Promise<LoadedPageModule | null> {
|
|
223
|
-
const fetchedPage = await fetchPageDocument(url, options);
|
|
224
|
-
if (!fetchedPage) {
|
|
225
|
-
return null;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
return loadPageModuleFromDocument(fetchedPage.doc, fetchedPage.finalPath);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
export async function fetchPageDocument(
|
|
232
|
-
url: string,
|
|
233
|
-
options: LoadPageModuleOptions = {},
|
|
234
|
-
): Promise<FetchedPageDocument | null> {
|
|
235
|
-
try {
|
|
236
|
-
const res = await fetch(url, {
|
|
237
|
-
signal: options.signal,
|
|
238
|
-
headers: {
|
|
239
|
-
Accept: 'text/html',
|
|
240
|
-
},
|
|
241
|
-
});
|
|
242
|
-
const html = await res.text();
|
|
243
|
-
|
|
244
|
-
const finalUrl = new URL(res.url || url, window.location.origin);
|
|
245
|
-
const finalPath = finalUrl.pathname + finalUrl.search;
|
|
246
|
-
|
|
247
|
-
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
248
|
-
|
|
249
|
-
return { doc, finalPath, html };
|
|
250
|
-
} catch (e) {
|
|
251
|
-
if (e instanceof DOMException && e.name === 'AbortError') {
|
|
252
|
-
return null;
|
|
253
|
-
}
|
|
254
|
-
console.error('[EcoRouter] Navigation failed:', e);
|
|
255
|
-
return null;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
export async function loadPageModuleFromDocument(doc: Document, finalPath: string): Promise<LoadedPageModule | null> {
|
|
260
|
-
const props = extractProps(doc);
|
|
261
|
-
const componentUrl = await extractComponentUrl(doc);
|
|
262
|
-
|
|
263
|
-
if (!componentUrl) {
|
|
264
|
-
if (isReactRouteDocument(doc)) {
|
|
265
|
-
console.error('[EcoRouter] Could not find component URL');
|
|
266
|
-
}
|
|
267
|
-
return null;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
const moduleUrl = addCacheBuster(componentUrl);
|
|
271
|
-
const module = await import(/* @vite-ignore */ moduleUrl);
|
|
272
|
-
const rawComponent = module.Content || module.default?.Content || module.default;
|
|
273
|
-
const config = module.config || rawComponent?.config;
|
|
274
|
-
|
|
275
|
-
if (!rawComponent) {
|
|
276
|
-
console.error('[EcoRouter] No component found in module');
|
|
277
|
-
return null;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
if (config && !rawComponent.config) {
|
|
281
|
-
rawComponent.config = config;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
return { Component: rawComponent, props, doc, finalPath, moduleUrl: componentUrl };
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/**
|
|
288
|
-
* Convenience wrapper around getInterceptDecision that returns a boolean.
|
|
289
|
-
* Use getInterceptDecision directly when you need the reason for debugging.
|
|
290
|
-
*/
|
|
291
|
-
export function shouldInterceptClick(
|
|
292
|
-
event: MouseEvent,
|
|
293
|
-
link: HTMLAnchorElement,
|
|
294
|
-
options: Required<EcoRouterOptions>,
|
|
295
|
-
): boolean {
|
|
296
|
-
return getInterceptDecision(event, link, options).shouldIntercept;
|
|
297
|
-
}
|
package/src/props-script.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { createElement, type FC } from 'react';
|
|
2
|
-
|
|
3
|
-
export interface EcoPropsScriptProps {
|
|
4
|
-
/** The page props to serialize for client-side hydration */
|
|
5
|
-
data: Record<string, any>;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Serializes page props as JSON for SPA navigation.
|
|
10
|
-
* The hydration script reads this and sets window.__ECO_PAGES__.page.
|
|
11
|
-
* Using application/json allows direct parsing without regex.
|
|
12
|
-
*/
|
|
13
|
-
export const EcoPropsScript: FC<EcoPropsScriptProps> = ({ data }) => {
|
|
14
|
-
return createElement('script', {
|
|
15
|
-
id: '__ECO_PAGE_DATA__',
|
|
16
|
-
type: 'application/json',
|
|
17
|
-
dangerouslySetInnerHTML: { __html: JSON.stringify(data || {}) },
|
|
18
|
-
});
|
|
19
|
-
};
|