@esmx/router 3.0.0-rc.12 → 3.0.0-rc.120
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/README.md +158 -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 -3
- package/dist/index.mjs +13 -3
- package/dist/location.d.ts +22 -0
- package/dist/location.mjs +64 -0
- package/dist/matcher.d.ts +4 -0
- package/dist/matcher.mjs +49 -0
- package/dist/micro-app.d.ts +20 -0
- package/dist/micro-app.mjs +132 -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 +95 -0
- package/dist/route-task.d.ts +40 -0
- package/dist/route-task.mjs +77 -0
- package/dist/route-transition.d.ts +54 -0
- package/dist/route-transition.mjs +362 -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 +142 -0
- package/dist/router.d.ts +113 -102
- package/dist/router.mjs +321 -354
- package/dist/scroll.d.ts +33 -0
- package/dist/scroll.mjs +50 -0
- package/dist/types.d.ts +312 -0
- package/dist/types.mjs +18 -0
- package/dist/util.d.ts +32 -0
- package/dist/util.mjs +100 -0
- package/package.json +42 -15
- package/src/error.ts +84 -0
- package/src/increment-id.ts +12 -0
- package/src/index.ts +67 -3
- package/src/location.ts +124 -0
- package/src/matcher.ts +71 -0
- package/src/micro-app.ts +153 -0
- package/src/navigation.ts +202 -0
- package/src/options.ts +136 -0
- package/src/route-task.ts +102 -0
- package/src/route-transition.ts +480 -0
- package/src/route.ts +335 -0
- package/src/router-link.ts +241 -0
- package/src/router.ts +351 -467
- package/src/scroll.ts +106 -0
- package/src/types.ts +415 -0
- package/src/util.ts +184 -0
- package/dist/history/abstract.d.ts +0 -29
- package/dist/history/abstract.mjs +0 -107
- package/dist/history/base.d.ts +0 -79
- package/dist/history/base.mjs +0 -275
- package/dist/history/html.d.ts +0 -22
- package/dist/history/html.mjs +0 -181
- package/dist/history/index.d.ts +0 -7
- package/dist/history/index.mjs +0 -16
- package/dist/matcher/create-matcher.d.ts +0 -5
- package/dist/matcher/create-matcher.mjs +0 -218
- package/dist/matcher/create-matcher.spec.d.ts +0 -1
- package/dist/matcher/create-matcher.spec.mjs +0 -0
- package/dist/matcher/index.d.ts +0 -1
- package/dist/matcher/index.mjs +0 -1
- package/dist/task-pipe/index.d.ts +0 -1
- package/dist/task-pipe/index.mjs +0 -1
- package/dist/task-pipe/task.d.ts +0 -30
- package/dist/task-pipe/task.mjs +0 -66
- package/dist/utils/bom.d.ts +0 -5
- package/dist/utils/bom.mjs +0 -10
- package/dist/utils/encoding.d.ts +0 -48
- package/dist/utils/encoding.mjs +0 -44
- package/dist/utils/guards.d.ts +0 -9
- package/dist/utils/guards.mjs +0 -12
- package/dist/utils/index.d.ts +0 -7
- package/dist/utils/index.mjs +0 -27
- package/dist/utils/path.d.ts +0 -60
- package/dist/utils/path.mjs +0 -264
- package/dist/utils/path.spec.d.ts +0 -1
- package/dist/utils/path.spec.mjs +0 -30
- package/dist/utils/scroll.d.ts +0 -25
- package/dist/utils/scroll.mjs +0 -59
- package/dist/utils/utils.d.ts +0 -16
- package/dist/utils/utils.mjs +0 -11
- package/dist/utils/warn.d.ts +0 -2
- package/dist/utils/warn.mjs +0 -12
- package/src/history/abstract.ts +0 -149
- package/src/history/base.ts +0 -408
- package/src/history/html.ts +0 -231
- package/src/history/index.ts +0 -20
- package/src/matcher/create-matcher.spec.ts +0 -3
- package/src/matcher/create-matcher.ts +0 -293
- package/src/matcher/index.ts +0 -1
- package/src/task-pipe/index.ts +0 -1
- package/src/task-pipe/task.ts +0 -97
- package/src/utils/bom.ts +0 -14
- package/src/utils/encoding.ts +0 -153
- package/src/utils/guards.ts +0 -25
- package/src/utils/index.ts +0 -27
- package/src/utils/path.spec.ts +0 -44
- package/src/utils/path.ts +0 -397
- package/src/utils/scroll.ts +0 -120
- package/src/utils/utils.ts +0 -30
- package/src/utils/warn.ts +0 -13
package/src/scroll.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/** Internal {@link ScrollToOptions | `ScrollToOptions`}: `left` and `top` properties always have values */
|
|
2
|
+
interface _ScrollPosition extends ScrollToOptions {
|
|
3
|
+
left: number;
|
|
4
|
+
top: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface ScrollPositionElement extends ScrollToOptions {
|
|
8
|
+
/**
|
|
9
|
+
* A valid CSS selector. Some special characters need to be escaped (https://mathiasbynens.be/notes/css-escapes).
|
|
10
|
+
* @example
|
|
11
|
+
* Here are some examples:
|
|
12
|
+
*
|
|
13
|
+
* - `.title`
|
|
14
|
+
* - `.content:first-child`
|
|
15
|
+
* - `#marker`
|
|
16
|
+
* - `#marker\~with\~symbols`
|
|
17
|
+
* - `#marker.with.dot`: Selects `class="with dot" id="marker"`, not `id="marker.with.dot"`
|
|
18
|
+
*
|
|
19
|
+
*/
|
|
20
|
+
el: string | Element;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Scroll parameters */
|
|
24
|
+
export type ScrollPosition = ScrollToOptions | ScrollPositionElement;
|
|
25
|
+
|
|
26
|
+
/** Get current window scroll position */
|
|
27
|
+
export const winScrollPos = (): _ScrollPosition => ({
|
|
28
|
+
left: window.scrollX,
|
|
29
|
+
top: window.scrollY
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** Get element position for scrolling in document */
|
|
33
|
+
function getElementPosition(
|
|
34
|
+
el: Element,
|
|
35
|
+
offset: ScrollToOptions
|
|
36
|
+
): _ScrollPosition {
|
|
37
|
+
const docRect = document.documentElement.getBoundingClientRect();
|
|
38
|
+
const elRect = el.getBoundingClientRect();
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
behavior: offset.behavior,
|
|
42
|
+
left: elRect.left - docRect.left - (offset.left || 0),
|
|
43
|
+
top: elRect.top - docRect.top - (offset.top || 0)
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Scroll to specified position */
|
|
48
|
+
export function scrollToPosition(position: ScrollPosition): void {
|
|
49
|
+
if ('el' in position) {
|
|
50
|
+
const positionEl = position.el;
|
|
51
|
+
|
|
52
|
+
const el =
|
|
53
|
+
typeof positionEl === 'string'
|
|
54
|
+
? document.querySelector(positionEl)
|
|
55
|
+
: positionEl;
|
|
56
|
+
|
|
57
|
+
if (!el) return;
|
|
58
|
+
|
|
59
|
+
position = getElementPosition(el, position);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if ('scrollBehavior' in document.documentElement.style) {
|
|
63
|
+
window.scrollTo(position);
|
|
64
|
+
} else {
|
|
65
|
+
window.scrollTo(
|
|
66
|
+
Number.isFinite(position.left) ? position.left! : window.scrollX,
|
|
67
|
+
Number.isFinite(position.top) ? position.top! : window.scrollY
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Stored scroll positions */
|
|
73
|
+
export const scrollPositions = new Map<string, _ScrollPosition>();
|
|
74
|
+
|
|
75
|
+
const POSITION_KEY = '__scroll_position_key';
|
|
76
|
+
|
|
77
|
+
/** Save scroll position */
|
|
78
|
+
export function saveScrollPosition(
|
|
79
|
+
key: string,
|
|
80
|
+
scrollPosition = winScrollPos()
|
|
81
|
+
) {
|
|
82
|
+
scrollPosition = { ...scrollPosition };
|
|
83
|
+
scrollPositions.set(key, scrollPosition);
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
if (location.href !== key) return;
|
|
87
|
+
// preserve the existing history state as it could be overridden by the user
|
|
88
|
+
const stateCopy = {
|
|
89
|
+
...(history.state || {}),
|
|
90
|
+
[POSITION_KEY]: scrollPosition
|
|
91
|
+
};
|
|
92
|
+
history.replaceState(stateCopy, '');
|
|
93
|
+
} catch (error) {}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Get saved scroll position */
|
|
97
|
+
export function getSavedScrollPosition(
|
|
98
|
+
key: string,
|
|
99
|
+
defaultValue: _ScrollPosition | null = null
|
|
100
|
+
): _ScrollPosition | null {
|
|
101
|
+
const scroll = scrollPositions.get(key) || history.state?.[POSITION_KEY];
|
|
102
|
+
|
|
103
|
+
// Saved scroll position should not be used multiple times, next time should use newly saved position
|
|
104
|
+
scrollPositions.delete(key);
|
|
105
|
+
return scroll || defaultValue;
|
|
106
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { MatchFunction } from 'path-to-regexp';
|
|
3
|
+
import type { Route } from './route';
|
|
4
|
+
import type { Router } from './router';
|
|
5
|
+
|
|
6
|
+
// Re-export Route type for backward compatibility
|
|
7
|
+
export type { Route };
|
|
8
|
+
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// Utility types
|
|
11
|
+
// ============================================================================
|
|
12
|
+
export type Awaitable<T> = T | Promise<T>;
|
|
13
|
+
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// Core enums
|
|
16
|
+
// ============================================================================
|
|
17
|
+
export enum RouterMode {
|
|
18
|
+
history = 'history',
|
|
19
|
+
memory = 'memory'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export enum RouteType {
|
|
23
|
+
push = 'push',
|
|
24
|
+
replace = 'replace',
|
|
25
|
+
restartApp = 'restartApp',
|
|
26
|
+
go = 'go',
|
|
27
|
+
forward = 'forward',
|
|
28
|
+
back = 'back',
|
|
29
|
+
unknown = 'unknown',
|
|
30
|
+
pushWindow = 'pushWindow',
|
|
31
|
+
replaceWindow = 'replaceWindow',
|
|
32
|
+
pushLayer = 'pushLayer'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// Hook function types
|
|
37
|
+
// ============================================================================
|
|
38
|
+
export type RouteConfirmHookResult =
|
|
39
|
+
| void
|
|
40
|
+
| false
|
|
41
|
+
| RouteLocationInput
|
|
42
|
+
| RouteHandleHook;
|
|
43
|
+
|
|
44
|
+
export type RouteConfirmHook = (
|
|
45
|
+
to: Route,
|
|
46
|
+
from: Route | null,
|
|
47
|
+
router: Router
|
|
48
|
+
) => Awaitable<RouteConfirmHookResult>;
|
|
49
|
+
|
|
50
|
+
export type RouteVerifyHook = (
|
|
51
|
+
to: Route,
|
|
52
|
+
from: Route | null,
|
|
53
|
+
router: Router
|
|
54
|
+
) => Awaitable<boolean>;
|
|
55
|
+
|
|
56
|
+
export type RouteHandleHook = (
|
|
57
|
+
to: Route,
|
|
58
|
+
from: Route | null,
|
|
59
|
+
router: Router
|
|
60
|
+
) => Awaitable<RouteHandleResult>;
|
|
61
|
+
|
|
62
|
+
export type RouteNotifyHook = (
|
|
63
|
+
to: Route,
|
|
64
|
+
from: Route | null,
|
|
65
|
+
router: Router
|
|
66
|
+
) => void;
|
|
67
|
+
|
|
68
|
+
// ============================================================================
|
|
69
|
+
// Basic data types
|
|
70
|
+
// ============================================================================
|
|
71
|
+
export type RouteMeta = Record<string | symbol, unknown>;
|
|
72
|
+
export type RouteState = Record<string, unknown>;
|
|
73
|
+
export type RouteHandleResult = unknown | null | void;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Route matching type
|
|
77
|
+
* - 'route': Route-level matching, compare if route configurations are the same
|
|
78
|
+
* - 'exact': Exact matching, compare if paths are exactly the same
|
|
79
|
+
* - 'include': Include matching, check if current path contains target path
|
|
80
|
+
*/
|
|
81
|
+
export type RouteMatchType = 'route' | 'exact' | 'include';
|
|
82
|
+
|
|
83
|
+
// ============================================================================
|
|
84
|
+
// Route location and config types
|
|
85
|
+
// ============================================================================
|
|
86
|
+
export interface RouteLocation {
|
|
87
|
+
path?: string;
|
|
88
|
+
url?: string | URL;
|
|
89
|
+
params?: Record<string, string>;
|
|
90
|
+
query?: Record<string, string | undefined>;
|
|
91
|
+
queryArray?: Record<string, string[] | undefined>;
|
|
92
|
+
hash?: string;
|
|
93
|
+
state?: RouteState;
|
|
94
|
+
/** When `true`, maintains current scroll position after navigation (default behavior scrolls to top) */
|
|
95
|
+
keepScrollPosition?: boolean;
|
|
96
|
+
statusCode?: number | null;
|
|
97
|
+
layer?: RouteLayerOptions | null;
|
|
98
|
+
confirm?: RouteConfirmHook | null;
|
|
99
|
+
}
|
|
100
|
+
export type RouteLocationInput = RouteLocation | string;
|
|
101
|
+
|
|
102
|
+
export interface RouteConfig {
|
|
103
|
+
/** Pass a URL-encoded path */
|
|
104
|
+
path: string;
|
|
105
|
+
component?: unknown;
|
|
106
|
+
children?: RouteConfig[];
|
|
107
|
+
redirect?: RouteLocationInput | RouteConfirmHook;
|
|
108
|
+
meta?: RouteMeta;
|
|
109
|
+
app?: string | RouterMicroAppCallback;
|
|
110
|
+
asyncComponent?: () => Promise<unknown>;
|
|
111
|
+
beforeEnter?: RouteConfirmHook;
|
|
112
|
+
beforeUpdate?: RouteConfirmHook;
|
|
113
|
+
beforeLeave?: RouteConfirmHook;
|
|
114
|
+
/** Mark this route as only effective in layer mode */
|
|
115
|
+
layer?: boolean;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Require an index child route to match the parent path
|
|
119
|
+
*
|
|
120
|
+
* When set to `true`, the parent route will not match its own path
|
|
121
|
+
* if no child route matches. The parent route requires a `path: ''`
|
|
122
|
+
* index child route to match the parent path itself.
|
|
123
|
+
*
|
|
124
|
+
* - `requireIndex: false` (default): If no child route matches,
|
|
125
|
+
* the matcher falls back to matching the parent route itself
|
|
126
|
+
* - `requireIndex: true`: If no child route matches, the parent
|
|
127
|
+
* route is skipped entirely (no fallback to self-match)
|
|
128
|
+
*
|
|
129
|
+
* @default false
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* ```typescript
|
|
133
|
+
* // requireIndex: true - /A won't match without index child
|
|
134
|
+
* {
|
|
135
|
+
* path: '/A',
|
|
136
|
+
* requireIndex: true,
|
|
137
|
+
* children: [
|
|
138
|
+
* { path: '', component: IndexComponent }, // /A matches this
|
|
139
|
+
* { path: 'a', component: AComponent } // /A/a matches this
|
|
140
|
+
* ]
|
|
141
|
+
* }
|
|
142
|
+
*
|
|
143
|
+
* // Default behavior - /A matches even without index child
|
|
144
|
+
* {
|
|
145
|
+
* path: '/A',
|
|
146
|
+
* children: [
|
|
147
|
+
* { path: 'a', component: AComponent } // /A/a matches, /A also matches (parent self)
|
|
148
|
+
* ]
|
|
149
|
+
* }
|
|
150
|
+
* ```
|
|
151
|
+
*/
|
|
152
|
+
requireIndex?: boolean;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Route override function for hybrid app development
|
|
156
|
+
*
|
|
157
|
+
* Note: Override is not executed during initial route loading
|
|
158
|
+
*
|
|
159
|
+
* @param to Target route
|
|
160
|
+
* @param from Source route
|
|
161
|
+
* @returns Function to execute instead of default routing, or void for default
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* ```typescript
|
|
165
|
+
* override: (to, from) => {
|
|
166
|
+
* if (isInApp()) {
|
|
167
|
+
* return () => JSBridge.openNative();
|
|
168
|
+
* }
|
|
169
|
+
* }
|
|
170
|
+
* ```
|
|
171
|
+
*/
|
|
172
|
+
override?: RouteConfirmHook;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface RouteParsedConfig extends RouteConfig {
|
|
176
|
+
/** Require an index child route to match the parent path (always has a value after parsing) */
|
|
177
|
+
requireIndex: boolean;
|
|
178
|
+
compilePath: string;
|
|
179
|
+
children: RouteParsedConfig[];
|
|
180
|
+
match: MatchFunction;
|
|
181
|
+
compile: (params?: Record<string, string>) => string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface RouteMatchResult {
|
|
185
|
+
readonly matches: readonly RouteParsedConfig[];
|
|
186
|
+
readonly params: Record<string, string | string[]>;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export type RouteMatcher = (
|
|
190
|
+
to: URL,
|
|
191
|
+
base: URL,
|
|
192
|
+
cb?: (item: RouteParsedConfig) => boolean
|
|
193
|
+
) => RouteMatchResult;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Route constructor options interface
|
|
197
|
+
*/
|
|
198
|
+
export interface RouteOptions {
|
|
199
|
+
/** Router parsed options */
|
|
200
|
+
options?: RouterParsedOptions;
|
|
201
|
+
/** Route type */
|
|
202
|
+
toType?: RouteType;
|
|
203
|
+
/** Target route location */
|
|
204
|
+
toInput?: RouteLocationInput;
|
|
205
|
+
/** Source URL */
|
|
206
|
+
from?: URL | null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ============================================================================
|
|
210
|
+
// Router Layer types
|
|
211
|
+
// ============================================================================
|
|
212
|
+
export interface RouteLayerOptions {
|
|
213
|
+
/**
|
|
214
|
+
* Layer zIndex value
|
|
215
|
+
* If not set, will automatically use incremental layer value (1000 + increment)
|
|
216
|
+
*/
|
|
217
|
+
zIndex?: number;
|
|
218
|
+
/**
|
|
219
|
+
* Hook function to determine layer keep-alive behavior before route closure
|
|
220
|
+
* Determines whether the layer should remain open during navigation
|
|
221
|
+
* - 'exact': Keep layer alive only when navigating to the exact initial path (default)
|
|
222
|
+
* - 'include': Keep layer alive when navigating to paths that start with the initial path
|
|
223
|
+
* - function: Custom logic to determine if the layer should be kept alive
|
|
224
|
+
* @default 'exact'
|
|
225
|
+
* @example
|
|
226
|
+
* ```typescript
|
|
227
|
+
* // Default behavior - keep only when navigating back to initial path
|
|
228
|
+
* keepAlive: 'exact'
|
|
229
|
+
*
|
|
230
|
+
* // Keep layer alive for all sub-paths (paths starting with the initial path)
|
|
231
|
+
* keepAlive: 'include'
|
|
232
|
+
*
|
|
233
|
+
* // Custom logic
|
|
234
|
+
* keepAlive: (to, from, router) => to.query.keepLayer === 'true'
|
|
235
|
+
* ```
|
|
236
|
+
*/
|
|
237
|
+
keepAlive?: 'exact' | 'include' | RouteVerifyHook;
|
|
238
|
+
/**
|
|
239
|
+
* @deprecated Use keepAlive instead
|
|
240
|
+
* Verification hook function before route closure
|
|
241
|
+
* @returns Return true to allow closure, false to prevent closure
|
|
242
|
+
*/
|
|
243
|
+
shouldClose?: RouteVerifyHook;
|
|
244
|
+
/**
|
|
245
|
+
* Whether to automatically record route history
|
|
246
|
+
* @default true
|
|
247
|
+
*/
|
|
248
|
+
autoPush?: boolean;
|
|
249
|
+
/**
|
|
250
|
+
* Route navigation mode control
|
|
251
|
+
* - When autoPush is true:
|
|
252
|
+
* - true: Use push mode (add new history record)
|
|
253
|
+
* - false: Use replace mode (replace current history record)
|
|
254
|
+
* @default true
|
|
255
|
+
*/
|
|
256
|
+
push?: boolean;
|
|
257
|
+
/**
|
|
258
|
+
* Router options for creating the layer instance
|
|
259
|
+
* These options will be merged with the default router configuration
|
|
260
|
+
*/
|
|
261
|
+
routerOptions?: RouterLayerOptions;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export type RouteLayerResult =
|
|
265
|
+
| { type: 'close'; route: Route }
|
|
266
|
+
| { type: 'push'; route: Route }
|
|
267
|
+
| { type: 'success'; route: Route; data?: any };
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Router options for creating layer instances
|
|
271
|
+
* Excludes handler functions and layer fields which are handled internally by createLayer
|
|
272
|
+
*/
|
|
273
|
+
export type RouterLayerOptions = Omit<
|
|
274
|
+
RouterOptions,
|
|
275
|
+
'handleBackBoundary' | 'handleLayerClose' | 'layer'
|
|
276
|
+
>;
|
|
277
|
+
|
|
278
|
+
// ============================================================================
|
|
279
|
+
// Router MicroApp types
|
|
280
|
+
// ============================================================================
|
|
281
|
+
export interface RouterMicroAppOptions {
|
|
282
|
+
mount: (el: HTMLElement) => void;
|
|
283
|
+
hydration?: (el: HTMLElement) => void;
|
|
284
|
+
unmount: () => void;
|
|
285
|
+
renderToString?: () => Awaitable<string>;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export type RouterMicroAppCallback = (router: Router) => RouterMicroAppOptions;
|
|
289
|
+
|
|
290
|
+
export type RouterMicroApp =
|
|
291
|
+
| Record<string, RouterMicroAppCallback | undefined>
|
|
292
|
+
| RouterMicroAppCallback;
|
|
293
|
+
|
|
294
|
+
// ============================================================================
|
|
295
|
+
// Router core types
|
|
296
|
+
// ============================================================================
|
|
297
|
+
export interface RouterOptions {
|
|
298
|
+
/**
|
|
299
|
+
* Application mount container ID
|
|
300
|
+
* - Pure string ID, no '#' prefix needed
|
|
301
|
+
* - Client-side: uses document.getElementById(appId)
|
|
302
|
+
* - Server-side: generates <div id="${appId}"> wrapper
|
|
303
|
+
* - Defaults to 'app'
|
|
304
|
+
*
|
|
305
|
+
* @example
|
|
306
|
+
* ```typescript
|
|
307
|
+
* new Router({ appId: 'app' })
|
|
308
|
+
* ```
|
|
309
|
+
*/
|
|
310
|
+
appId?: string;
|
|
311
|
+
context?: Record<string | symbol, unknown>;
|
|
312
|
+
data?: Record<string | symbol, unknown>;
|
|
313
|
+
routes?: RouteConfig[];
|
|
314
|
+
mode?: RouterMode;
|
|
315
|
+
/** Optional in browser, but required on server side */
|
|
316
|
+
base?: URL;
|
|
317
|
+
req?: IncomingMessage | null;
|
|
318
|
+
res?: ServerResponse | null;
|
|
319
|
+
apps?: RouterMicroApp;
|
|
320
|
+
normalizeURL?: (to: URL, from: URL | null) => URL;
|
|
321
|
+
fallback?: RouteHandleHook;
|
|
322
|
+
nextTick?: () => Awaitable<void>;
|
|
323
|
+
|
|
324
|
+
rootStyle?: Partial<CSSStyleDeclaration> | false | null;
|
|
325
|
+
layer?: boolean;
|
|
326
|
+
zIndex?: number;
|
|
327
|
+
handleBackBoundary?: (router: Router) => void;
|
|
328
|
+
handleLayerClose?: (router: Router, data?: any) => void;
|
|
329
|
+
resolveLink?: (
|
|
330
|
+
link: RouterLinkResolved,
|
|
331
|
+
props: RouterLinkProps
|
|
332
|
+
) => RouterLinkResolved;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export interface RouterParsedOptions extends Readonly<Required<RouterOptions>> {
|
|
336
|
+
readonly compiledRoutes: readonly RouteParsedConfig[];
|
|
337
|
+
readonly matcher: RouteMatcher;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ============================================================================
|
|
341
|
+
// RouterLink types
|
|
342
|
+
// ============================================================================
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Router link navigation types
|
|
346
|
+
*/
|
|
347
|
+
export type RouterLinkType =
|
|
348
|
+
| 'push'
|
|
349
|
+
| 'replace'
|
|
350
|
+
| 'pushWindow'
|
|
351
|
+
| 'replaceWindow'
|
|
352
|
+
| 'pushLayer';
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Router link attributes generated by the source code
|
|
356
|
+
*/
|
|
357
|
+
export interface RouterLinkAttributes {
|
|
358
|
+
// Always generated
|
|
359
|
+
href: string;
|
|
360
|
+
class: string;
|
|
361
|
+
|
|
362
|
+
// Conditionally generated
|
|
363
|
+
target?: '_blank';
|
|
364
|
+
rel?: string;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Framework-agnostic link configuration interface
|
|
369
|
+
*/
|
|
370
|
+
export interface RouterLinkProps {
|
|
371
|
+
to: RouteLocationInput;
|
|
372
|
+
type?: RouterLinkType;
|
|
373
|
+
/**
|
|
374
|
+
* @deprecated Use type='replace' instead
|
|
375
|
+
*/
|
|
376
|
+
replace?: boolean;
|
|
377
|
+
exact?: RouteMatchType;
|
|
378
|
+
activeClass?: string;
|
|
379
|
+
event?: string | string[];
|
|
380
|
+
tag?: string;
|
|
381
|
+
layerOptions?: RouteLayerOptions;
|
|
382
|
+
/**
|
|
383
|
+
* Hook function called before navigation occurs
|
|
384
|
+
* @param event - The DOM event that triggered the navigation
|
|
385
|
+
* @param eventName - The name of the event that triggered navigation
|
|
386
|
+
*
|
|
387
|
+
* Can prevent default event to block the default navigation handling logic.
|
|
388
|
+
* Call event.preventDefault() to stop the navigation from proceeding.
|
|
389
|
+
*/
|
|
390
|
+
beforeNavigate?: (event: Event, eventName: string) => void;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Framework-agnostic link resolution result
|
|
395
|
+
*/
|
|
396
|
+
export interface RouterLinkResolved {
|
|
397
|
+
// Basic information
|
|
398
|
+
route: Route;
|
|
399
|
+
type: RouterLinkType;
|
|
400
|
+
isActive: boolean;
|
|
401
|
+
isExactActive: boolean;
|
|
402
|
+
isExternal: boolean;
|
|
403
|
+
tag: string;
|
|
404
|
+
|
|
405
|
+
// Element attributes
|
|
406
|
+
attributes: RouterLinkAttributes;
|
|
407
|
+
|
|
408
|
+
// Navigation function
|
|
409
|
+
navigate: (e: Event) => Promise<void>;
|
|
410
|
+
|
|
411
|
+
// Event handling
|
|
412
|
+
createEventHandlers: (
|
|
413
|
+
format?: (eventType: string) => string
|
|
414
|
+
) => Record<string, (e: Event) => Promise<void>>;
|
|
415
|
+
}
|
package/src/util.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { Route, RouteConfirmHookResult, RouteMatchType } from './types';
|
|
2
|
+
export const isBrowser = typeof window === 'object';
|
|
3
|
+
|
|
4
|
+
export function isNotNullish(value: unknown): boolean {
|
|
5
|
+
return (
|
|
6
|
+
value !== undefined &&
|
|
7
|
+
value !== null &&
|
|
8
|
+
!Number.isNaN(
|
|
9
|
+
value instanceof Number
|
|
10
|
+
? value.valueOf() // For new Number() cases
|
|
11
|
+
: value
|
|
12
|
+
)
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isPlainObject(o: unknown): o is Record<string, any> {
|
|
17
|
+
return (
|
|
18
|
+
o?.constructor === Object ||
|
|
19
|
+
Object.prototype.toString.call(o) === '[object Object]'
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Check if value is a valid non-empty plain object
|
|
25
|
+
* Only check enumerable string keys to ensure it's a proper plain object
|
|
26
|
+
* @param value Value to check
|
|
27
|
+
* @returns true if it's a valid non-empty plain object
|
|
28
|
+
*/
|
|
29
|
+
export function isNonEmptyPlainObject(
|
|
30
|
+
value: unknown
|
|
31
|
+
): value is Record<string, any> {
|
|
32
|
+
if (!isPlainObject(value)) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
// Only check enumerable string keys to ensure valid properties of plain object
|
|
36
|
+
return Object.keys(value as Record<string, any>).length > 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const removeFromArray = <T>(arr: T[], ele: T) => {
|
|
40
|
+
if (!Array.isArray(arr) || arr.length === 0) return;
|
|
41
|
+
const i = Number.isNaN(ele)
|
|
42
|
+
? // If ele is NaN, use findIndex to search for NaN, because NaN !== NaN, so we can't use indexOf directly
|
|
43
|
+
arr.findIndex((item) => Number.isNaN(item))
|
|
44
|
+
: arr.indexOf(ele);
|
|
45
|
+
if (i === -1) return;
|
|
46
|
+
arr.splice(i, 1);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function isValidConfirmHookResult(
|
|
50
|
+
result: unknown
|
|
51
|
+
): result is Exclude<RouteConfirmHookResult, void> {
|
|
52
|
+
return (
|
|
53
|
+
result === false ||
|
|
54
|
+
typeof result === 'function' ||
|
|
55
|
+
typeof result === 'string' ||
|
|
56
|
+
isPlainObject(result)
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function isUrlEqual(url1: URL, url2?: URL | null): boolean {
|
|
61
|
+
// If url2 doesn't exist, return false
|
|
62
|
+
if (!url2) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// If it's the same object reference, return true directly
|
|
67
|
+
if (url1 === url2) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Copy and sort query parameters
|
|
72
|
+
(url1 = new URL(url1)).searchParams.sort();
|
|
73
|
+
(url2 = new URL(url2)).searchParams.sort();
|
|
74
|
+
// Normalize trailing empty hash:
|
|
75
|
+
// new URL('https://a.com/path#').href includes a trailing '#',
|
|
76
|
+
// but new URL('https://a.com/path').href does not.
|
|
77
|
+
// Assigning hash to itself triggers the setter to re-normalize the URL,
|
|
78
|
+
// ensuring both forms produce the same href.
|
|
79
|
+
url1.hash = url1.hash;
|
|
80
|
+
url2.hash = url2.hash;
|
|
81
|
+
return url1.href === url2.href;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Compare if two routes match
|
|
86
|
+
*
|
|
87
|
+
* @param fromRoute First route object
|
|
88
|
+
* @param toRoute Second route object, may be null
|
|
89
|
+
* @param matchType Match type
|
|
90
|
+
* - 'route': Route-level matching, compare if route configurations are the same
|
|
91
|
+
* - 'exact': Exact matching, compare if full paths are the same
|
|
92
|
+
* - 'include': Include matching, check if route1 path starts with route2 path
|
|
93
|
+
* @returns Whether they match
|
|
94
|
+
*/
|
|
95
|
+
export function isRouteMatched(
|
|
96
|
+
fromRoute: Route,
|
|
97
|
+
toRoute: Route | null,
|
|
98
|
+
matchType: RouteMatchType
|
|
99
|
+
): boolean {
|
|
100
|
+
if (!toRoute) return false;
|
|
101
|
+
|
|
102
|
+
switch (matchType) {
|
|
103
|
+
case 'route':
|
|
104
|
+
// Route-level matching - compare route configurations
|
|
105
|
+
return fromRoute.config === toRoute.config;
|
|
106
|
+
|
|
107
|
+
case 'exact':
|
|
108
|
+
// Exact matching - full paths are identical
|
|
109
|
+
return fromRoute.fullPath === toRoute.fullPath;
|
|
110
|
+
|
|
111
|
+
case 'include':
|
|
112
|
+
// Include matching - route1 path contains route2 path
|
|
113
|
+
return fromRoute.fullPath.startsWith(toRoute.fullPath);
|
|
114
|
+
|
|
115
|
+
default:
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function decodeParams<T extends Record<string, string | string[]>>(
|
|
121
|
+
params: T
|
|
122
|
+
): T {
|
|
123
|
+
const result = {} as T;
|
|
124
|
+
|
|
125
|
+
for (const key in params) {
|
|
126
|
+
const value = params[key];
|
|
127
|
+
if (Array.isArray(value)) {
|
|
128
|
+
result[key] = value.map((item) =>
|
|
129
|
+
decodeURIComponent(item)
|
|
130
|
+
) as T[typeof key];
|
|
131
|
+
} else {
|
|
132
|
+
result[key] = decodeURIComponent(value) as T[typeof key];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Validates that SSR renderToString output contains exactly one root HTML element.
|
|
141
|
+
* Non-production only - throws if validation fails.
|
|
142
|
+
*/
|
|
143
|
+
export function validateSsrRootElement(html: string): void {
|
|
144
|
+
const trimmed = html.trim();
|
|
145
|
+
const firstMatch = trimmed.match(/^<([a-zA-Z][^\s>]*)/);
|
|
146
|
+
const firstTag = firstMatch?.[1];
|
|
147
|
+
const lastTag = trimmed.match(/<\/([a-zA-Z][^\s>]*)>\s*$/)?.[1];
|
|
148
|
+
if (!firstTag || firstTag !== lastTag) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
'SSR renderToString() must return exactly one root HTML element. ' +
|
|
151
|
+
'Current output: ' +
|
|
152
|
+
trimmed.slice(0, 100)
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
// Find the ROOT element's matching close tag by depth-counting occurrences
|
|
156
|
+
// of the root tag, so a single root that nests same-tag children (e.g. a
|
|
157
|
+
// `<div>` wrapping child `<div>`s — very common) is not mistaken for
|
|
158
|
+
// multiple roots. A naive "first `</tag>`" scan matches an inner close and
|
|
159
|
+
// wrongly reports trailing content. Anything after the matched root close is
|
|
160
|
+
// a sibling root → invalid.
|
|
161
|
+
const tagRe = new RegExp(`<(/?)${firstTag}(?:\\s[^>]*)?(/?)>`, 'g');
|
|
162
|
+
let depth = 0;
|
|
163
|
+
let rootCloseEnd = -1;
|
|
164
|
+
let tag: RegExpExecArray | null = tagRe.exec(trimmed);
|
|
165
|
+
while (tag !== null) {
|
|
166
|
+
if (tag[1] === '/') {
|
|
167
|
+
depth--;
|
|
168
|
+
if (depth === 0) {
|
|
169
|
+
rootCloseEnd = tagRe.lastIndex;
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
} else if (tag[2] !== '/') {
|
|
173
|
+
depth++;
|
|
174
|
+
}
|
|
175
|
+
tag = tagRe.exec(trimmed);
|
|
176
|
+
}
|
|
177
|
+
if (rootCloseEnd === -1 || trimmed.slice(rootCloseEnd).trim().length > 0) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
'SSR renderToString() must return exactly one root HTML element. ' +
|
|
180
|
+
'Current output: ' +
|
|
181
|
+
trimmed.slice(0, 100)
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|