@esmx/router 3.0.0-rc.103
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/LICENSE +21 -0
- package/README.md +77 -0
- package/README.zh-CN.md +158 -0
- package/dist/error.d.ts +23 -0
- package/dist/error.mjs +64 -0
- package/dist/increment-id.d.ts +7 -0
- package/dist/increment-id.mjs +16 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.mjs +13 -0
- package/dist/location.d.ts +22 -0
- package/dist/location.mjs +64 -0
- package/dist/matcher.d.ts +4 -0
- package/dist/matcher.mjs +46 -0
- package/dist/micro-app.d.ts +18 -0
- package/dist/micro-app.mjs +85 -0
- package/dist/navigation.d.ts +45 -0
- package/dist/navigation.mjs +153 -0
- package/dist/options.d.ts +4 -0
- package/dist/options.mjs +94 -0
- package/dist/route-task.d.ts +40 -0
- package/dist/route-task.mjs +77 -0
- package/dist/route-transition.d.ts +53 -0
- package/dist/route-transition.mjs +356 -0
- package/dist/route.d.ts +77 -0
- package/dist/route.mjs +223 -0
- package/dist/router-link.d.ts +10 -0
- package/dist/router-link.mjs +139 -0
- package/dist/router.d.ts +122 -0
- package/dist/router.mjs +355 -0
- package/dist/scroll.d.ts +33 -0
- package/dist/scroll.mjs +49 -0
- package/dist/types.d.ts +282 -0
- package/dist/types.mjs +18 -0
- package/dist/util.d.ts +27 -0
- package/dist/util.mjs +67 -0
- package/package.json +62 -0
- package/src/error.ts +84 -0
- package/src/increment-id.ts +12 -0
- package/src/index.ts +67 -0
- package/src/location.ts +124 -0
- package/src/matcher.ts +68 -0
- package/src/micro-app.ts +101 -0
- package/src/navigation.ts +202 -0
- package/src/options.ts +135 -0
- package/src/route-task.ts +102 -0
- package/src/route-transition.ts +472 -0
- package/src/route.ts +335 -0
- package/src/router-link.ts +238 -0
- package/src/router.ts +395 -0
- package/src/scroll.ts +106 -0
- package/src/types.ts +381 -0
- package/src/util.ts +133 -0
package/src/location.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { RouteLocation, RouteLocationInput } from './types';
|
|
2
|
+
import { isNotNullish } from './util';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Normalizes a URL input into a URL object.
|
|
6
|
+
* @param url - The URL or string to normalize.
|
|
7
|
+
* @param base - The base URL to resolve against if the input is relative.
|
|
8
|
+
* @returns A URL object.
|
|
9
|
+
*/
|
|
10
|
+
export function normalizeURL(url: string | URL, base: URL): URL {
|
|
11
|
+
if (url instanceof URL) {
|
|
12
|
+
return url;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Handle protocol-relative URLs (e.g., //example.com)
|
|
16
|
+
if (url.startsWith('//')) {
|
|
17
|
+
// Use the current base URL's protocol for security and consistency
|
|
18
|
+
const protocol = base.protocol;
|
|
19
|
+
return new URL(`${protocol}${url}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Handle root-relative paths
|
|
23
|
+
if (url.startsWith('/')) {
|
|
24
|
+
const newBase = new URL('.', base);
|
|
25
|
+
const parsed = new URL(url, newBase);
|
|
26
|
+
// This ensures that the path is resolved relative to the base's path directory.
|
|
27
|
+
parsed.pathname = newBase.pathname.slice(0, -1) + parsed.pathname;
|
|
28
|
+
return parsed;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
// Try to parse as an absolute URL.
|
|
33
|
+
// This is the WHATWG standard approach (new URL()) and works consistently across all modern browsers and Node.js.
|
|
34
|
+
// We use a try-catch block because the standard URL constructor throws an error for invalid URLs.
|
|
35
|
+
//
|
|
36
|
+
// NOTE: While `URL.parse()` might be observed in Chromium-based browsers (e.g., Chrome, Edge),
|
|
37
|
+
// it is a non-standard, legacy feature implemented by the V8 engine for Node.js compatibility.
|
|
38
|
+
// It is not part of the WHATWG URL Standard and is not supported by other browsers like Firefox or Safari.
|
|
39
|
+
// Therefore, relying on it would compromise cross-browser compatibility.
|
|
40
|
+
return new URL(url);
|
|
41
|
+
} catch (e) {
|
|
42
|
+
// Otherwise, parse as a relative URL
|
|
43
|
+
return new URL(url, base);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parses a RouteLocationInput object into a full URL.
|
|
49
|
+
* @param toInput - The route location input.
|
|
50
|
+
* @param baseURL - The base URL to resolve against.
|
|
51
|
+
* @returns The parsed URL object.
|
|
52
|
+
*/
|
|
53
|
+
export function parseLocation(toInput: RouteLocationInput, baseURL: URL): URL {
|
|
54
|
+
if (typeof toInput === 'string') {
|
|
55
|
+
return normalizeURL(toInput, baseURL);
|
|
56
|
+
}
|
|
57
|
+
const url = normalizeURL(toInput.path ?? toInput.url ?? '', baseURL);
|
|
58
|
+
const searchParams = url.searchParams;
|
|
59
|
+
|
|
60
|
+
// Priority: queryArray > query > query in path
|
|
61
|
+
const mergedQuery: Record<string, string | string[]> = {};
|
|
62
|
+
|
|
63
|
+
// First, add query values
|
|
64
|
+
if (toInput.query) {
|
|
65
|
+
Object.entries(toInput.query).forEach(([key, value]) => {
|
|
66
|
+
if (typeof value !== 'undefined') {
|
|
67
|
+
mergedQuery[key] = value;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Then, add queryArray values (higher priority)
|
|
73
|
+
if (toInput.queryArray) {
|
|
74
|
+
Object.entries(toInput.queryArray).forEach(([key, value]) => {
|
|
75
|
+
if (typeof value !== 'undefined') {
|
|
76
|
+
mergedQuery[key] = value;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
Object.entries(mergedQuery).forEach(([key, value]) => {
|
|
82
|
+
searchParams.delete(key); // Clear previous params with the same name
|
|
83
|
+
value = Array.isArray(value) ? value : [value];
|
|
84
|
+
value
|
|
85
|
+
.filter((v) => isNotNullish(v) && !Number.isNaN(v))
|
|
86
|
+
.forEach((v) => {
|
|
87
|
+
searchParams.append(key, String(v));
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Set the hash (URL fragment identifier)
|
|
92
|
+
if (toInput.hash) {
|
|
93
|
+
url.hash = toInput.hash;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return url;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolves RouteLocationInput with fallback from previous route
|
|
101
|
+
* @param toInput - The route location input
|
|
102
|
+
* @param from - The previous route URL (optional)
|
|
103
|
+
* @returns Resolved RouteLocation object
|
|
104
|
+
*/
|
|
105
|
+
export function resolveRouteLocationInput(
|
|
106
|
+
toInput: RouteLocationInput = '/',
|
|
107
|
+
from: URL | null = null
|
|
108
|
+
): RouteLocation {
|
|
109
|
+
if (typeof toInput === 'string') {
|
|
110
|
+
return { path: toInput };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (
|
|
114
|
+
toInput &&
|
|
115
|
+
typeof toInput === 'object' &&
|
|
116
|
+
typeof toInput.path !== 'string' &&
|
|
117
|
+
typeof toInput.url !== 'string' &&
|
|
118
|
+
from !== null
|
|
119
|
+
) {
|
|
120
|
+
return { ...toInput, url: from.href };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return toInput;
|
|
124
|
+
}
|
package/src/matcher.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { compile, match } from 'path-to-regexp';
|
|
2
|
+
import type { RouteConfig, RouteMatcher, RouteParsedConfig } from './types';
|
|
3
|
+
|
|
4
|
+
export function createMatcher(
|
|
5
|
+
routes: RouteConfig[],
|
|
6
|
+
compiledRoutes = createRouteMatches(routes)
|
|
7
|
+
): RouteMatcher {
|
|
8
|
+
return (
|
|
9
|
+
toURL: URL,
|
|
10
|
+
baseURL: URL,
|
|
11
|
+
cb?: (item: RouteParsedConfig) => boolean
|
|
12
|
+
) => {
|
|
13
|
+
const matchPath = toURL.pathname.substring(baseURL.pathname.length - 1);
|
|
14
|
+
const matches: RouteParsedConfig[] = [];
|
|
15
|
+
const params: Record<string, string | string[]> = {};
|
|
16
|
+
const collectMatchingRoutes = (
|
|
17
|
+
routes: RouteParsedConfig[]
|
|
18
|
+
): boolean => {
|
|
19
|
+
for (const item of routes) {
|
|
20
|
+
if (cb && !cb(item)) {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
// Depth-first traversal
|
|
24
|
+
if (
|
|
25
|
+
item.children.length &&
|
|
26
|
+
collectMatchingRoutes(item.children)
|
|
27
|
+
) {
|
|
28
|
+
matches.unshift(item);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
const result = item.match(matchPath);
|
|
32
|
+
if (result) {
|
|
33
|
+
matches.unshift(item);
|
|
34
|
+
if (typeof result === 'object') {
|
|
35
|
+
Object.assign(params, result.params);
|
|
36
|
+
}
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
};
|
|
42
|
+
collectMatchingRoutes(compiledRoutes);
|
|
43
|
+
return { matches: Object.freeze(matches), params };
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createRouteMatches(
|
|
48
|
+
routes: RouteConfig[],
|
|
49
|
+
base = ''
|
|
50
|
+
): RouteParsedConfig[] {
|
|
51
|
+
return routes.map((route: RouteConfig): RouteParsedConfig => {
|
|
52
|
+
const compilePath = joinPathname(route.path, base);
|
|
53
|
+
return {
|
|
54
|
+
...route,
|
|
55
|
+
compilePath,
|
|
56
|
+
match: match(compilePath),
|
|
57
|
+
compile: compile(compilePath),
|
|
58
|
+
meta: route.meta || {},
|
|
59
|
+
children: Array.isArray(route.children)
|
|
60
|
+
? createRouteMatches(route.children, compilePath)
|
|
61
|
+
: []
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function joinPathname(pathname: string, base = '') {
|
|
67
|
+
return '/' + `${base}/${pathname}`.split('/').filter(Boolean).join('/');
|
|
68
|
+
}
|
package/src/micro-app.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Router } from './router';
|
|
2
|
+
import type { RouterMicroAppCallback, RouterMicroAppOptions } from './types';
|
|
3
|
+
import { isBrowser, isPlainObject } from './util';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolves the root container element.
|
|
7
|
+
* Supports a DOM selector string or a direct HTMLElement.
|
|
8
|
+
*
|
|
9
|
+
* @param rootConfig - The root container configuration, can be a selector string or an HTMLElement.
|
|
10
|
+
* @returns The resolved HTMLElement.
|
|
11
|
+
*/
|
|
12
|
+
export function resolveRootElement(
|
|
13
|
+
rootConfig?: string | HTMLElement
|
|
14
|
+
): HTMLElement {
|
|
15
|
+
let el: HTMLElement | null = null;
|
|
16
|
+
// Direct HTMLElement provided
|
|
17
|
+
if (rootConfig instanceof HTMLElement) {
|
|
18
|
+
el = rootConfig;
|
|
19
|
+
}
|
|
20
|
+
if (typeof rootConfig === 'string' && rootConfig) {
|
|
21
|
+
try {
|
|
22
|
+
el = document.querySelector(rootConfig);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
console.warn(`Failed to resolve root element: ${rootConfig}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (el === null) {
|
|
28
|
+
el = document.createElement('div');
|
|
29
|
+
}
|
|
30
|
+
return el;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class MicroApp {
|
|
34
|
+
public app: RouterMicroAppOptions | null = null;
|
|
35
|
+
public root: HTMLElement | null = null;
|
|
36
|
+
private _factory: RouterMicroAppCallback | null = null;
|
|
37
|
+
|
|
38
|
+
public _update(router: Router, force = false) {
|
|
39
|
+
const factory = this._getNextFactory(router);
|
|
40
|
+
if (!force && factory === this._factory) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const oldApp = this.app;
|
|
44
|
+
// Create the new application
|
|
45
|
+
const app = factory ? factory(router) : null;
|
|
46
|
+
if (isBrowser && app) {
|
|
47
|
+
let root: HTMLElement | null = this.root;
|
|
48
|
+
if (root === null) {
|
|
49
|
+
root = resolveRootElement(router.root);
|
|
50
|
+
const { rootStyle } = router.parsedOptions;
|
|
51
|
+
if (root && isPlainObject(rootStyle)) {
|
|
52
|
+
Object.assign(root.style, router.parsedOptions.rootStyle);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (root) {
|
|
56
|
+
app.mount(root);
|
|
57
|
+
if (root.parentNode === null) {
|
|
58
|
+
document.body.appendChild(root);
|
|
59
|
+
}
|
|
60
|
+
this.root = root;
|
|
61
|
+
}
|
|
62
|
+
if (oldApp) {
|
|
63
|
+
oldApp.unmount();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
this.app = app;
|
|
67
|
+
this._factory = factory;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private _getNextFactory({
|
|
71
|
+
route,
|
|
72
|
+
options
|
|
73
|
+
}: Router): RouterMicroAppCallback | null {
|
|
74
|
+
if (!route.matched || route.matched.length === 0) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const name = route.matched[0].app;
|
|
78
|
+
if (
|
|
79
|
+
typeof name === 'string' &&
|
|
80
|
+
options.apps &&
|
|
81
|
+
typeof options.apps === 'object'
|
|
82
|
+
) {
|
|
83
|
+
return options.apps[name] || null;
|
|
84
|
+
}
|
|
85
|
+
if (typeof name === 'function') {
|
|
86
|
+
return name;
|
|
87
|
+
}
|
|
88
|
+
if (typeof options.apps === 'function') {
|
|
89
|
+
return options.apps;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
public destroy() {
|
|
95
|
+
this.app?.unmount();
|
|
96
|
+
this.app = null;
|
|
97
|
+
this.root?.remove();
|
|
98
|
+
this.root = null;
|
|
99
|
+
this._factory = null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { PAGE_ID } from './increment-id';
|
|
2
|
+
import type { RouterParsedOptions, RouteState } from './types';
|
|
3
|
+
import { RouterMode } from './types';
|
|
4
|
+
|
|
5
|
+
type NavigationSubscribe = (url: string, state: RouteState) => void;
|
|
6
|
+
type NavigationGoResult = null | {
|
|
7
|
+
type: 'success';
|
|
8
|
+
url: string;
|
|
9
|
+
state: RouteState;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const PAGE_ID_KEY = '__pageId__';
|
|
13
|
+
|
|
14
|
+
export class Navigation {
|
|
15
|
+
public readonly options: RouterParsedOptions;
|
|
16
|
+
private readonly _history: History | MemoryHistory;
|
|
17
|
+
private readonly _unSubscribePopState: () => void;
|
|
18
|
+
private _promiseResolve:
|
|
19
|
+
| ((url?: string | null, state?: RouteState) => void)
|
|
20
|
+
| null = null;
|
|
21
|
+
|
|
22
|
+
public constructor(
|
|
23
|
+
options: RouterParsedOptions,
|
|
24
|
+
onUpdated?: NavigationSubscribe
|
|
25
|
+
) {
|
|
26
|
+
const history: History =
|
|
27
|
+
options.mode === RouterMode.history
|
|
28
|
+
? window.history
|
|
29
|
+
: new MemoryHistory();
|
|
30
|
+
const onPopStateChange: NavigationSubscribe = (url, state) => {
|
|
31
|
+
const dispatchEvent = this._promiseResolve || onUpdated;
|
|
32
|
+
dispatchEvent?.(url, state);
|
|
33
|
+
};
|
|
34
|
+
const subscribePopState =
|
|
35
|
+
history instanceof MemoryHistory
|
|
36
|
+
? history.onPopState(onPopStateChange)
|
|
37
|
+
: subscribeHtmlHistory(onPopStateChange);
|
|
38
|
+
this.options = options;
|
|
39
|
+
this._history = history;
|
|
40
|
+
this._unSubscribePopState = subscribePopState;
|
|
41
|
+
}
|
|
42
|
+
public get length(): number {
|
|
43
|
+
return this._history.length;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private _push(
|
|
47
|
+
history: History,
|
|
48
|
+
data: any,
|
|
49
|
+
url?: string | URL | null
|
|
50
|
+
): RouteState {
|
|
51
|
+
const state = {
|
|
52
|
+
...(data || {}),
|
|
53
|
+
[PAGE_ID_KEY]: PAGE_ID.next()
|
|
54
|
+
};
|
|
55
|
+
history.pushState(state, '', url);
|
|
56
|
+
return state;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private _replace(
|
|
60
|
+
history: History,
|
|
61
|
+
data: any,
|
|
62
|
+
url?: string | URL | null
|
|
63
|
+
): RouteState {
|
|
64
|
+
const oldId = history.state?.[PAGE_ID_KEY];
|
|
65
|
+
const state = {
|
|
66
|
+
...(data || {}),
|
|
67
|
+
[PAGE_ID_KEY]: typeof oldId === 'number' ? oldId : PAGE_ID.next()
|
|
68
|
+
};
|
|
69
|
+
history.replaceState(state, '', url);
|
|
70
|
+
return state;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public push(data: any, url?: string | URL | null): RouteState {
|
|
74
|
+
return this._push(this._history, data, url);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
public replace(data: any, url?: string | URL | null): RouteState {
|
|
78
|
+
return this._replace(this._history, data, url);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public pushHistoryState(data: any, url?: string | URL | null) {
|
|
82
|
+
this._push(history, data, url);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
public replaceHistoryState(data: any, url?: string | URL | null) {
|
|
86
|
+
this._replace(history, data, url);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
public backHistoryState() {
|
|
90
|
+
return this._go(history, -1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private _go(history: History, index: number): Promise<NavigationGoResult> {
|
|
94
|
+
if (this._promiseResolve) {
|
|
95
|
+
return Promise.resolve(null);
|
|
96
|
+
}
|
|
97
|
+
return new Promise<NavigationGoResult>((resolve) => {
|
|
98
|
+
this._promiseResolve = (url, state) => {
|
|
99
|
+
this._promiseResolve = null;
|
|
100
|
+
if (typeof url !== 'string') return resolve(null);
|
|
101
|
+
resolve({ type: 'success', url, state: state || {} });
|
|
102
|
+
};
|
|
103
|
+
setTimeout(this._promiseResolve, 80);
|
|
104
|
+
history.go(index);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
public go(delta?: number): Promise<NavigationGoResult> {
|
|
108
|
+
return this._go(this._history, delta || 0);
|
|
109
|
+
}
|
|
110
|
+
public forward(): Promise<NavigationGoResult> {
|
|
111
|
+
return this.go(1);
|
|
112
|
+
}
|
|
113
|
+
public back(): Promise<NavigationGoResult> {
|
|
114
|
+
return this.go(-1);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
public destroy() {
|
|
118
|
+
this._promiseResolve?.();
|
|
119
|
+
this._unSubscribePopState();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export class MemoryHistory implements History {
|
|
124
|
+
private _entries: Array<{ state: any; url: string }> = [];
|
|
125
|
+
private _index = -1;
|
|
126
|
+
private get _curEntry() {
|
|
127
|
+
const idx = this._index;
|
|
128
|
+
if (idx < 0 || idx >= this.length) return null;
|
|
129
|
+
return this._entries[idx];
|
|
130
|
+
}
|
|
131
|
+
private readonly _popStateCbs = new Set<NavigationSubscribe>();
|
|
132
|
+
public scrollRestoration: ScrollRestoration = 'auto';
|
|
133
|
+
// Return null when no current entry to align with browser history.state behavior
|
|
134
|
+
// Browser history.state can be null when no state was provided
|
|
135
|
+
public get state() {
|
|
136
|
+
return this._curEntry?.state ?? null;
|
|
137
|
+
}
|
|
138
|
+
public get url() {
|
|
139
|
+
return this._curEntry?.url ?? '';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
constructor() {
|
|
143
|
+
this.pushState(null, '', '/');
|
|
144
|
+
}
|
|
145
|
+
public get length() {
|
|
146
|
+
return this._entries.length;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
public pushState(
|
|
150
|
+
data: any,
|
|
151
|
+
unused: string,
|
|
152
|
+
url?: string | URL | null
|
|
153
|
+
): void {
|
|
154
|
+
// Remove all entries after the current position
|
|
155
|
+
this._entries.splice(this._index + 1);
|
|
156
|
+
this._entries.push({ state: data, url: url?.toString() ?? this.url });
|
|
157
|
+
this._index = this._entries.length - 1;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
public replaceState(
|
|
161
|
+
data: any,
|
|
162
|
+
unused: string,
|
|
163
|
+
url?: string | URL | null
|
|
164
|
+
): void {
|
|
165
|
+
const curEntry = this._curEntry;
|
|
166
|
+
if (!curEntry) return;
|
|
167
|
+
curEntry.state = { ...data };
|
|
168
|
+
if (url) curEntry.url = url.toString();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
public back(): void {
|
|
172
|
+
this.go(-1);
|
|
173
|
+
}
|
|
174
|
+
public forward(): void {
|
|
175
|
+
this.go(1);
|
|
176
|
+
}
|
|
177
|
+
public go(delta?: number): void {
|
|
178
|
+
if (!delta) return;
|
|
179
|
+
const newIdx = this._index + delta;
|
|
180
|
+
if (newIdx < 0 || newIdx >= this.length) return;
|
|
181
|
+
this._index = newIdx;
|
|
182
|
+
const entry = this._curEntry!;
|
|
183
|
+
// Simulate the async popstate event of html history as closely as possible
|
|
184
|
+
setTimeout(() => {
|
|
185
|
+
this._popStateCbs.forEach((cb) => cb(entry.url, entry.state));
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
public onPopState(cb: NavigationSubscribe) {
|
|
190
|
+
if (typeof cb !== 'function') return () => {};
|
|
191
|
+
this._popStateCbs.add(cb);
|
|
192
|
+
return () => this._popStateCbs.delete(cb);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function subscribeHtmlHistory(cb: NavigationSubscribe) {
|
|
197
|
+
// Use history.state || {} to handle null state from browser history
|
|
198
|
+
// Browser history.state can be null, but we normalize it to empty object
|
|
199
|
+
const wrapper = () => cb(location.href, history.state || {});
|
|
200
|
+
window.addEventListener('popstate', wrapper);
|
|
201
|
+
return () => window.removeEventListener('popstate', wrapper);
|
|
202
|
+
}
|
package/src/options.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { createMatcher, createRouteMatches } from './matcher';
|
|
2
|
+
import type { Router } from './router';
|
|
3
|
+
import type { Route, RouterOptions, RouterParsedOptions } from './types';
|
|
4
|
+
import { RouterMode } from './types';
|
|
5
|
+
import { isBrowser } from './util';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Gets the base URL object for the router.
|
|
9
|
+
* @param options - Router options.
|
|
10
|
+
* @returns The processed URL object.
|
|
11
|
+
*/
|
|
12
|
+
function getBaseUrl(options: RouterOptions): URL {
|
|
13
|
+
// Determine the URL source
|
|
14
|
+
let sourceUrl: string | URL;
|
|
15
|
+
|
|
16
|
+
if (options.base) {
|
|
17
|
+
sourceUrl = options.base;
|
|
18
|
+
} else if (isBrowser) {
|
|
19
|
+
sourceUrl = location.origin;
|
|
20
|
+
} else if (options.req) {
|
|
21
|
+
// Server-side: try to get it from the req object
|
|
22
|
+
const { req } = options;
|
|
23
|
+
const protocol =
|
|
24
|
+
req.headers['x-forwarded-proto'] ||
|
|
25
|
+
req.headers['x-forwarded-protocol'] ||
|
|
26
|
+
(req.socket && 'encrypted' in req.socket && req.socket.encrypted
|
|
27
|
+
? 'https'
|
|
28
|
+
: 'http');
|
|
29
|
+
const host =
|
|
30
|
+
req.headers['x-forwarded-host'] ||
|
|
31
|
+
req.headers.host ||
|
|
32
|
+
req.headers['x-real-ip'] ||
|
|
33
|
+
'localhost';
|
|
34
|
+
const port = req.headers['x-forwarded-port'];
|
|
35
|
+
const path = req.url || '';
|
|
36
|
+
|
|
37
|
+
sourceUrl = `${protocol}://${host}${port ? `:${port}` : ''}${path}`;
|
|
38
|
+
} else {
|
|
39
|
+
sourceUrl = 'https://esmx.dev/';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Parse the URL, falling back to a default on failure.
|
|
43
|
+
// Use a try-catch block with the standard URL constructor for robustness.
|
|
44
|
+
let base: URL;
|
|
45
|
+
try {
|
|
46
|
+
base = new URL('.', sourceUrl);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
console.warn(
|
|
49
|
+
`Failed to parse base URL '${sourceUrl}', using default: https://esmx.dev/`
|
|
50
|
+
);
|
|
51
|
+
base = new URL('https://esmx.dev/');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Clean up and return
|
|
55
|
+
base.search = base.hash = '';
|
|
56
|
+
return base;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function parsedOptions(
|
|
60
|
+
options: RouterOptions = {}
|
|
61
|
+
): RouterParsedOptions {
|
|
62
|
+
const base = getBaseUrl(options);
|
|
63
|
+
const routes = options.routes ?? [];
|
|
64
|
+
const compiledRoutes = createRouteMatches(routes);
|
|
65
|
+
return Object.freeze<RouterParsedOptions>({
|
|
66
|
+
rootStyle: options.rootStyle || false,
|
|
67
|
+
root: options.root || '',
|
|
68
|
+
context: options.context || {},
|
|
69
|
+
data: options.data || {},
|
|
70
|
+
req: options.req || null,
|
|
71
|
+
res: options.res || null,
|
|
72
|
+
layer: options.layer || false,
|
|
73
|
+
zIndex: options.zIndex || 10000,
|
|
74
|
+
base,
|
|
75
|
+
mode: isBrowser
|
|
76
|
+
? (options.mode ?? RouterMode.history)
|
|
77
|
+
: RouterMode.memory,
|
|
78
|
+
routes,
|
|
79
|
+
apps:
|
|
80
|
+
typeof options.apps === 'function'
|
|
81
|
+
? options.apps
|
|
82
|
+
: Object.assign({}, options.apps),
|
|
83
|
+
compiledRoutes,
|
|
84
|
+
matcher: createMatcher(routes, compiledRoutes),
|
|
85
|
+
normalizeURL: options.normalizeURL ?? ((url) => url),
|
|
86
|
+
fallback: options.fallback ?? fallback,
|
|
87
|
+
nextTick: options.nextTick ?? (() => {}),
|
|
88
|
+
handleBackBoundary: options.handleBackBoundary ?? (() => {}),
|
|
89
|
+
handleLayerClose: options.handleLayerClose ?? (() => {})
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function fallback(to: Route, from: Route | null, router: Router) {
|
|
94
|
+
const href = to.url.href;
|
|
95
|
+
|
|
96
|
+
// Server-side environment: handle application-level redirects and status codes
|
|
97
|
+
if (!isBrowser && router?.res) {
|
|
98
|
+
// Determine status code: prioritize route-specified code, default to 302 temporary redirect
|
|
99
|
+
let statusCode = 302;
|
|
100
|
+
|
|
101
|
+
// Validate redirect status code (3xx series)
|
|
102
|
+
const validRedirectCodes = [300, 301, 302, 303, 304, 307, 308];
|
|
103
|
+
if (to.statusCode && validRedirectCodes.includes(to.statusCode)) {
|
|
104
|
+
statusCode = to.statusCode;
|
|
105
|
+
} else if (to.statusCode) {
|
|
106
|
+
console.warn(
|
|
107
|
+
`Invalid redirect status code ${to.statusCode}, using default 302`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Set redirect response
|
|
112
|
+
router.res.statusCode = statusCode;
|
|
113
|
+
router.res.setHeader('Location', href);
|
|
114
|
+
router.res.end();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Client-side environment: handle browser navigation
|
|
119
|
+
if (isBrowser) {
|
|
120
|
+
if (to.isPush) {
|
|
121
|
+
try {
|
|
122
|
+
const newWindow = window.open(href);
|
|
123
|
+
if (!newWindow) {
|
|
124
|
+
location.href = href;
|
|
125
|
+
} else {
|
|
126
|
+
newWindow.opener = null; // Sever the relationship between the new window and the current one
|
|
127
|
+
}
|
|
128
|
+
return newWindow;
|
|
129
|
+
} catch {}
|
|
130
|
+
}
|
|
131
|
+
location.href = href;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Do nothing in a server environment without a res context
|
|
135
|
+
}
|