@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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +77 -0
  3. package/README.zh-CN.md +158 -0
  4. package/dist/error.d.ts +23 -0
  5. package/dist/error.mjs +64 -0
  6. package/dist/increment-id.d.ts +7 -0
  7. package/dist/increment-id.mjs +16 -0
  8. package/dist/index.d.ts +14 -0
  9. package/dist/index.mjs +13 -0
  10. package/dist/location.d.ts +22 -0
  11. package/dist/location.mjs +64 -0
  12. package/dist/matcher.d.ts +4 -0
  13. package/dist/matcher.mjs +46 -0
  14. package/dist/micro-app.d.ts +18 -0
  15. package/dist/micro-app.mjs +85 -0
  16. package/dist/navigation.d.ts +45 -0
  17. package/dist/navigation.mjs +153 -0
  18. package/dist/options.d.ts +4 -0
  19. package/dist/options.mjs +94 -0
  20. package/dist/route-task.d.ts +40 -0
  21. package/dist/route-task.mjs +77 -0
  22. package/dist/route-transition.d.ts +53 -0
  23. package/dist/route-transition.mjs +356 -0
  24. package/dist/route.d.ts +77 -0
  25. package/dist/route.mjs +223 -0
  26. package/dist/router-link.d.ts +10 -0
  27. package/dist/router-link.mjs +139 -0
  28. package/dist/router.d.ts +122 -0
  29. package/dist/router.mjs +355 -0
  30. package/dist/scroll.d.ts +33 -0
  31. package/dist/scroll.mjs +49 -0
  32. package/dist/types.d.ts +282 -0
  33. package/dist/types.mjs +18 -0
  34. package/dist/util.d.ts +27 -0
  35. package/dist/util.mjs +67 -0
  36. package/package.json +62 -0
  37. package/src/error.ts +84 -0
  38. package/src/increment-id.ts +12 -0
  39. package/src/index.ts +67 -0
  40. package/src/location.ts +124 -0
  41. package/src/matcher.ts +68 -0
  42. package/src/micro-app.ts +101 -0
  43. package/src/navigation.ts +202 -0
  44. package/src/options.ts +135 -0
  45. package/src/route-task.ts +102 -0
  46. package/src/route-transition.ts +472 -0
  47. package/src/route.ts +335 -0
  48. package/src/router-link.ts +238 -0
  49. package/src/router.ts +395 -0
  50. package/src/scroll.ts +106 -0
  51. package/src/types.ts +381 -0
  52. package/src/util.ts +133 -0
package/src/router.ts ADDED
@@ -0,0 +1,395 @@
1
+ import { LAYER_ID } from './increment-id';
2
+ import { MicroApp } from './micro-app';
3
+ import { Navigation } from './navigation';
4
+ import { parsedOptions } from './options';
5
+ import { Route } from './route';
6
+ import { RouteTransition } from './route-transition';
7
+ import { createLinkResolver } from './router-link';
8
+ import type {
9
+ RouteConfirmHook,
10
+ RouteLayerOptions,
11
+ RouteLayerResult,
12
+ RouteLocationInput,
13
+ RouteMatchType,
14
+ RouteNotifyHook,
15
+ RouterLinkProps,
16
+ RouterLinkResolved,
17
+ RouterOptions,
18
+ RouterParsedOptions,
19
+ RouteState
20
+ } from './types';
21
+ import { RouterMode, RouteType } from './types';
22
+ import { isNotNullish, isPlainObject, isRouteMatched } from './util';
23
+
24
+ export class Router {
25
+ public readonly options: RouterOptions;
26
+ public readonly parsedOptions: RouterParsedOptions;
27
+ public readonly isLayer: boolean;
28
+ public readonly navigation: Navigation;
29
+ public readonly microApp: MicroApp = new MicroApp();
30
+
31
+ // Route transition manager
32
+ public readonly transition = new RouteTransition(this);
33
+ public get route() {
34
+ const route = this.transition.route;
35
+ if (route === null) {
36
+ throw new Error(
37
+ 'No active route found. Please navigate to a route first using router.push() or router.replace().'
38
+ );
39
+ }
40
+ return route;
41
+ }
42
+
43
+ public get context() {
44
+ return this.parsedOptions.context;
45
+ }
46
+ public get data() {
47
+ return this.parsedOptions.data;
48
+ }
49
+
50
+ public get root() {
51
+ return this.parsedOptions.root;
52
+ }
53
+ public get mode(): RouterMode {
54
+ return this.parsedOptions.mode;
55
+ }
56
+ public get base(): URL {
57
+ return this.parsedOptions.base;
58
+ }
59
+ public get req() {
60
+ return this.parsedOptions.req ?? null;
61
+ }
62
+ public get res() {
63
+ return this.parsedOptions.res ?? null;
64
+ }
65
+
66
+ public constructor(options: RouterOptions) {
67
+ this.options = options;
68
+ this.parsedOptions = parsedOptions(options);
69
+ this.isLayer = this.parsedOptions.layer;
70
+
71
+ this.navigation = new Navigation(
72
+ this.parsedOptions,
73
+ (url: string, state: RouteState) => {
74
+ this.transition.to(RouteType.unknown, {
75
+ url,
76
+ state
77
+ });
78
+ }
79
+ );
80
+ }
81
+
82
+ public push(toInput: RouteLocationInput): Promise<Route> {
83
+ return this.transition.to(RouteType.push, toInput);
84
+ }
85
+ public replace(toInput: RouteLocationInput): Promise<Route> {
86
+ return this.transition.to(RouteType.replace, toInput);
87
+ }
88
+ public pushWindow(toInput: RouteLocationInput): Promise<Route> {
89
+ return this.transition.to(RouteType.pushWindow, toInput);
90
+ }
91
+ public replaceWindow(toInput: RouteLocationInput): Promise<Route> {
92
+ return this.transition.to(RouteType.replaceWindow, toInput);
93
+ }
94
+ public restartApp(toInput?: RouteLocationInput): Promise<Route> {
95
+ return this.transition.to(
96
+ RouteType.restartApp,
97
+ toInput ?? this.route.url.href
98
+ );
99
+ }
100
+
101
+ public async back(): Promise<Route | null> {
102
+ const result = await this.navigation.go(-1);
103
+ if (result === null) {
104
+ this.parsedOptions.handleBackBoundary(this);
105
+ return null;
106
+ }
107
+ return this.transition.to(RouteType.back, {
108
+ url: result.url,
109
+ state: result.state
110
+ });
111
+ }
112
+ public async go(index: number): Promise<Route | null> {
113
+ // go(0) refreshes the page in browser, but we return null directly in router
114
+ if (index === 0) return null;
115
+
116
+ const result = await this.navigation.go(index);
117
+ if (result === null) {
118
+ // Call handleBackBoundary when backward navigation has no response
119
+ if (index < 0) {
120
+ this.parsedOptions.handleBackBoundary(this);
121
+ }
122
+ return null;
123
+ }
124
+ return this.transition.to(RouteType.go, {
125
+ url: result.url,
126
+ state: result.state
127
+ });
128
+ }
129
+ public async forward(): Promise<Route | null> {
130
+ const result = await this.navigation.go(1);
131
+ if (result === null) return null;
132
+ return this.transition.to(RouteType.forward, {
133
+ url: result.url,
134
+ state: result.state
135
+ });
136
+ }
137
+
138
+ /**
139
+ * Parse route location without performing actual navigation
140
+ *
141
+ * This method is used to parse route configuration and return the corresponding route object,
142
+ * but does not trigger actual page navigation. It is mainly used for the following scenarios:
143
+ * - Generate link URLs without jumping
144
+ * - Pre-check route matching
145
+ * - Get route parameters, meta information, etc.
146
+ * - Test the validity of route configuration
147
+ *
148
+ * @param toInput Target route location, can be a string path or route configuration object
149
+ * @returns Parsed route object containing complete route information
150
+ *
151
+ * @example
152
+ * ```typescript
153
+ * // Parse string path
154
+ * const route = router.resolve('/user/123');
155
+ * const url = route.url.href; // Get complete URL
156
+ *
157
+ * // Parse named route
158
+ * const userRoute = router.resolve({
159
+ * name: 'user',
160
+ * params: { id: '123' }
161
+ * });
162
+ * console.log(userRoute.params.id); // '123'
163
+ *
164
+ * // Check route validity
165
+ * const testRoute = router.resolve('/some/path');
166
+ * if (testRoute.matched.length > 0) {
167
+ * // Route matched successfully
168
+ * }
169
+ * ```
170
+ */
171
+ public resolve(toInput: RouteLocationInput, toType?: RouteType): Route {
172
+ return new Route({
173
+ options: this.parsedOptions,
174
+ toType,
175
+ toInput,
176
+ from: this.transition.route?.url ?? null
177
+ });
178
+ }
179
+
180
+ /**
181
+ * Check if the route matches the current route
182
+ *
183
+ * @param toRoute Target route object to compare
184
+ * @param matchType Match type
185
+ * - 'route': Route-level matching, compare if route configurations are the same
186
+ * - 'exact': Exact matching, compare if paths are completely the same
187
+ * - 'include': Include matching, check if current path contains target path
188
+ * @returns Whether it matches
189
+ */
190
+ public isRouteMatched(
191
+ toRoute: Route,
192
+ matchType: RouteMatchType = 'include'
193
+ ): boolean {
194
+ const currentRoute = this.transition.route;
195
+ if (!currentRoute) return false;
196
+
197
+ return isRouteMatched(currentRoute, toRoute, matchType);
198
+ }
199
+
200
+ /**
201
+ * Resolve router link configuration and return complete link data
202
+ *
203
+ * This method analyzes router link properties and returns a comprehensive
204
+ * link resolution result including route information, navigation functions,
205
+ * HTML attributes, and event handlers. It's primarily used for:
206
+ * - Framework-agnostic link component implementation
207
+ * - Generating link attributes and navigation handlers
208
+ * - Computing active states and CSS classes
209
+ * - Creating event handlers for different frameworks
210
+ *
211
+ * @param props Router link configuration properties
212
+ * @returns Complete link resolution result with all necessary data
213
+ *
214
+ * @example
215
+ * ```typescript
216
+ * // Basic link resolution
217
+ * const linkData = router.resolveLink({
218
+ * to: '/user/123',
219
+ * type: 'push'
220
+ * });
221
+ *
222
+ * // Access resolved data
223
+ * console.log(linkData.route.path); // '/user/123'
224
+ * console.log(linkData.attributes.href); // Full href URL
225
+ * console.log(linkData.isActive); // Active state
226
+ *
227
+ * // Use navigation function
228
+ * linkData.navigate(); // Programmatic navigation
229
+ *
230
+ * // Get event handlers for React
231
+ * const handlers = linkData.createEventHandlers(name => `on${name.charAt(0).toUpperCase() + name.slice(1)}`);
232
+ * // handlers.onClick for React
233
+ * ```
234
+ */
235
+ public resolveLink(props: RouterLinkProps): RouterLinkResolved {
236
+ return createLinkResolver(this, props);
237
+ }
238
+
239
+ public async createLayer(
240
+ toInput: RouteLocationInput
241
+ ): Promise<{ promise: Promise<RouteLayerResult>; router: Router }> {
242
+ const layerOptions: RouteLayerOptions =
243
+ (isPlainObject(toInput) && toInput.layer) || {};
244
+
245
+ const zIndex =
246
+ layerOptions.zIndex ?? this.parsedOptions.zIndex + LAYER_ID.next();
247
+
248
+ let promiseResolve: (result: RouteLayerResult) => void;
249
+ let promise = new Promise<RouteLayerResult>((resolve) => {
250
+ promiseResolve = resolve;
251
+ });
252
+
253
+ const router = new Router({
254
+ rootStyle: {
255
+ position: 'fixed',
256
+ top: '0',
257
+ left: '0',
258
+ width: '100%',
259
+ height: '100%',
260
+ zIndex: String(zIndex),
261
+ background: 'rgba(0,0,0,.6)',
262
+ display: 'flex',
263
+ alignItems: 'center',
264
+ justifyContent: 'center'
265
+ },
266
+ ...this.options,
267
+ context: this.parsedOptions.context,
268
+ mode: RouterMode.memory,
269
+ root: undefined,
270
+ ...layerOptions.routerOptions,
271
+ handleBackBoundary(router) {
272
+ router.destroy();
273
+ promiseResolve({
274
+ type: 'close',
275
+ route: router.route
276
+ });
277
+ },
278
+ handleLayerClose(router, data) {
279
+ router.destroy();
280
+ if (isNotNullish(data)) {
281
+ promiseResolve({
282
+ type: 'success',
283
+ route: router.route,
284
+ data
285
+ });
286
+ } else {
287
+ promiseResolve({
288
+ type: 'close',
289
+ route: router.route
290
+ });
291
+ }
292
+ },
293
+ layer: true
294
+ });
295
+ const initRoute = await router.replace(toInput);
296
+
297
+ router.afterEach(async (to, from) => {
298
+ if (
299
+ ![
300
+ RouteType.pushWindow,
301
+ RouteType.replaceWindow,
302
+ RouteType.replace,
303
+ RouteType.restartApp,
304
+ RouteType.pushLayer
305
+ ].includes(to.type)
306
+ )
307
+ return;
308
+ let keepAlive = false;
309
+ if (layerOptions.keepAlive === 'exact') {
310
+ keepAlive = to.path === initRoute.path;
311
+ } else if (layerOptions.keepAlive === 'include') {
312
+ keepAlive = to.path.startsWith(initRoute.path);
313
+ } else if (typeof layerOptions.keepAlive === 'function') {
314
+ keepAlive = await layerOptions.keepAlive(to, from, router);
315
+ } else {
316
+ if (layerOptions.shouldClose) {
317
+ console.warn(
318
+ '[esmx-router] RouteLayerOptions.shouldClose is deprecated. Use keepAlive instead. ' +
319
+ 'Note: shouldClose returns true to close, keepAlive returns true to keep alive.'
320
+ );
321
+ keepAlive = !(await layerOptions.shouldClose(
322
+ to,
323
+ from,
324
+ router
325
+ ));
326
+ } else {
327
+ keepAlive = to.path === initRoute.path;
328
+ }
329
+ }
330
+ if (!keepAlive) {
331
+ router.destroy();
332
+ promiseResolve({
333
+ type: 'push',
334
+ route: to
335
+ });
336
+ }
337
+ });
338
+ if (layerOptions.push) {
339
+ router.navigation.pushHistoryState(
340
+ router.route.state,
341
+ router.route.url
342
+ );
343
+ promise = promise.then(async (result) => {
344
+ await this.navigation.backHistoryState();
345
+ return result;
346
+ });
347
+ }
348
+ if (layerOptions.autoPush) {
349
+ promise = promise.then(async (result) => {
350
+ if (result.type === 'push') {
351
+ await this.push(result.route.url.href);
352
+ }
353
+ return result;
354
+ });
355
+ }
356
+ return {
357
+ promise,
358
+ router
359
+ };
360
+ }
361
+ public async pushLayer(
362
+ toInput: RouteLocationInput
363
+ ): Promise<RouteLayerResult> {
364
+ const result = await this.transition.to(RouteType.pushLayer, toInput);
365
+ return result.handleResult as RouteLayerResult;
366
+ }
367
+ public closeLayer(data?: any) {
368
+ if (!this.isLayer) return;
369
+ this.parsedOptions.handleLayerClose(this, data);
370
+ }
371
+
372
+ public async renderToString(throwError = false): Promise<string | null> {
373
+ try {
374
+ const result = await this.microApp.app?.renderToString?.();
375
+ return result ?? null;
376
+ } catch (e) {
377
+ if (throwError) throw e;
378
+ else console.error(e);
379
+ return null;
380
+ }
381
+ }
382
+
383
+ public beforeEach(guard: RouteConfirmHook): () => void {
384
+ return this.transition.beforeEach(guard);
385
+ }
386
+ public afterEach(guard: RouteNotifyHook): () => void {
387
+ return this.transition.afterEach(guard);
388
+ }
389
+
390
+ public destroy() {
391
+ this.transition.destroy();
392
+ this.navigation.destroy();
393
+ this.microApp.destroy();
394
+ }
395
+ }
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
+ }