@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.
Files changed (105) hide show
  1. package/README.md +158 -0
  2. package/README.zh-CN.md +158 -0
  3. package/dist/error.d.ts +23 -0
  4. package/dist/error.mjs +64 -0
  5. package/dist/increment-id.d.ts +7 -0
  6. package/dist/increment-id.mjs +16 -0
  7. package/dist/index.d.ts +14 -3
  8. package/dist/index.mjs +13 -3
  9. package/dist/location.d.ts +22 -0
  10. package/dist/location.mjs +64 -0
  11. package/dist/matcher.d.ts +4 -0
  12. package/dist/matcher.mjs +49 -0
  13. package/dist/micro-app.d.ts +20 -0
  14. package/dist/micro-app.mjs +132 -0
  15. package/dist/navigation.d.ts +45 -0
  16. package/dist/navigation.mjs +153 -0
  17. package/dist/options.d.ts +4 -0
  18. package/dist/options.mjs +95 -0
  19. package/dist/route-task.d.ts +40 -0
  20. package/dist/route-task.mjs +77 -0
  21. package/dist/route-transition.d.ts +54 -0
  22. package/dist/route-transition.mjs +362 -0
  23. package/dist/route.d.ts +77 -0
  24. package/dist/route.mjs +223 -0
  25. package/dist/router-link.d.ts +10 -0
  26. package/dist/router-link.mjs +142 -0
  27. package/dist/router.d.ts +113 -102
  28. package/dist/router.mjs +321 -354
  29. package/dist/scroll.d.ts +33 -0
  30. package/dist/scroll.mjs +50 -0
  31. package/dist/types.d.ts +312 -0
  32. package/dist/types.mjs +18 -0
  33. package/dist/util.d.ts +32 -0
  34. package/dist/util.mjs +100 -0
  35. package/package.json +42 -15
  36. package/src/error.ts +84 -0
  37. package/src/increment-id.ts +12 -0
  38. package/src/index.ts +67 -3
  39. package/src/location.ts +124 -0
  40. package/src/matcher.ts +71 -0
  41. package/src/micro-app.ts +153 -0
  42. package/src/navigation.ts +202 -0
  43. package/src/options.ts +136 -0
  44. package/src/route-task.ts +102 -0
  45. package/src/route-transition.ts +480 -0
  46. package/src/route.ts +335 -0
  47. package/src/router-link.ts +241 -0
  48. package/src/router.ts +351 -467
  49. package/src/scroll.ts +106 -0
  50. package/src/types.ts +415 -0
  51. package/src/util.ts +184 -0
  52. package/dist/history/abstract.d.ts +0 -29
  53. package/dist/history/abstract.mjs +0 -107
  54. package/dist/history/base.d.ts +0 -79
  55. package/dist/history/base.mjs +0 -275
  56. package/dist/history/html.d.ts +0 -22
  57. package/dist/history/html.mjs +0 -181
  58. package/dist/history/index.d.ts +0 -7
  59. package/dist/history/index.mjs +0 -16
  60. package/dist/matcher/create-matcher.d.ts +0 -5
  61. package/dist/matcher/create-matcher.mjs +0 -218
  62. package/dist/matcher/create-matcher.spec.d.ts +0 -1
  63. package/dist/matcher/create-matcher.spec.mjs +0 -0
  64. package/dist/matcher/index.d.ts +0 -1
  65. package/dist/matcher/index.mjs +0 -1
  66. package/dist/task-pipe/index.d.ts +0 -1
  67. package/dist/task-pipe/index.mjs +0 -1
  68. package/dist/task-pipe/task.d.ts +0 -30
  69. package/dist/task-pipe/task.mjs +0 -66
  70. package/dist/utils/bom.d.ts +0 -5
  71. package/dist/utils/bom.mjs +0 -10
  72. package/dist/utils/encoding.d.ts +0 -48
  73. package/dist/utils/encoding.mjs +0 -44
  74. package/dist/utils/guards.d.ts +0 -9
  75. package/dist/utils/guards.mjs +0 -12
  76. package/dist/utils/index.d.ts +0 -7
  77. package/dist/utils/index.mjs +0 -27
  78. package/dist/utils/path.d.ts +0 -60
  79. package/dist/utils/path.mjs +0 -264
  80. package/dist/utils/path.spec.d.ts +0 -1
  81. package/dist/utils/path.spec.mjs +0 -30
  82. package/dist/utils/scroll.d.ts +0 -25
  83. package/dist/utils/scroll.mjs +0 -59
  84. package/dist/utils/utils.d.ts +0 -16
  85. package/dist/utils/utils.mjs +0 -11
  86. package/dist/utils/warn.d.ts +0 -2
  87. package/dist/utils/warn.mjs +0 -12
  88. package/src/history/abstract.ts +0 -149
  89. package/src/history/base.ts +0 -408
  90. package/src/history/html.ts +0 -231
  91. package/src/history/index.ts +0 -20
  92. package/src/matcher/create-matcher.spec.ts +0 -3
  93. package/src/matcher/create-matcher.ts +0 -293
  94. package/src/matcher/index.ts +0 -1
  95. package/src/task-pipe/index.ts +0 -1
  96. package/src/task-pipe/task.ts +0 -97
  97. package/src/utils/bom.ts +0 -14
  98. package/src/utils/encoding.ts +0 -153
  99. package/src/utils/guards.ts +0 -25
  100. package/src/utils/index.ts +0 -27
  101. package/src/utils/path.spec.ts +0 -44
  102. package/src/utils/path.ts +0 -397
  103. package/src/utils/scroll.ts +0 -120
  104. package/src/utils/utils.ts +0 -30
  105. 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
+ }