@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/route.ts ADDED
@@ -0,0 +1,335 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { parseLocation, resolveRouteLocationInput } from './location';
3
+ import { parsedOptions } from './options';
4
+ import type { Router } from './router';
5
+
6
+ import {
7
+ type RouteConfirmHook,
8
+ type RouteHandleHook,
9
+ type RouteHandleResult,
10
+ type RouteLayerOptions,
11
+ type RouteLocationInput,
12
+ type RouteMatchResult,
13
+ type RouteMeta,
14
+ type RouteOptions,
15
+ type RouteParsedConfig,
16
+ type RouterParsedOptions,
17
+ type RouteState,
18
+ RouteType
19
+ } from './types';
20
+ import { decodeParams, isNonEmptyPlainObject, isPlainObject } from './util';
21
+
22
+ /**
23
+ * Configuration for non-enumerable properties in Route class
24
+ * These properties will be hidden during object traversal and serialization
25
+ */
26
+ export const NON_ENUMERABLE_PROPERTIES = [
27
+ // Private fields - internal implementation details
28
+ '_handled',
29
+ '_handle',
30
+ '_handleResult',
31
+ '_options',
32
+
33
+ // SSR-specific properties - meaningless in client environment
34
+ 'req',
35
+ 'res',
36
+
37
+ // Internal context - used by framework internally
38
+ 'context',
39
+
40
+ // Status code - internal status information
41
+ 'statusCode',
42
+
43
+ // Route behavior overrides - framework internal logic
44
+ 'confirm',
45
+
46
+ // Layer configuration - used for layer routes
47
+ 'layer'
48
+ ] satisfies string[];
49
+
50
+ /**
51
+ * Append user-provided parameters to URL path
52
+ * @param match Route matching result
53
+ * @param toInput User-provided route location object
54
+ * @param base Base URL
55
+ * @param to Current parsed URL object
56
+ */
57
+ export function applyRouteParams(
58
+ match: RouteMatchResult,
59
+ toInput: RouteLocationInput,
60
+ base: URL,
61
+ to: URL
62
+ ): void {
63
+ if (
64
+ !isPlainObject(toInput) ||
65
+ !isNonEmptyPlainObject(toInput.params) ||
66
+ !match.matches.length
67
+ ) {
68
+ return;
69
+ }
70
+
71
+ // Get the last matched route configuration
72
+ const lastMatch = match.matches[match.matches.length - 1];
73
+
74
+ // Split current path
75
+ const current = to.pathname.split('/');
76
+
77
+ // Compile new path with user parameters and split
78
+ const next = new URL(
79
+ lastMatch.compile(toInput.params).substring(1),
80
+ base
81
+ ).pathname.split('/');
82
+
83
+ // Replace current path segments with new path segments
84
+ next.forEach((item, index) => {
85
+ current[index] = item || current[index];
86
+ });
87
+
88
+ // Update URL path
89
+ to.pathname = current.join('/');
90
+
91
+ // Merge parameters to match result, user parameters take precedence
92
+ Object.assign(match.params, toInput.params);
93
+ }
94
+
95
+ /**
96
+ * Route class provides complete route object functionality
97
+ */
98
+ export class Route {
99
+ // Private fields for handle validation
100
+ private _handled = false;
101
+ private _handle: RouteHandleHook | null = null;
102
+ private _handleResult: RouteHandleResult | null = null;
103
+ private readonly _options: RouterParsedOptions;
104
+
105
+ // Public properties
106
+ public readonly statusCode: number | null = null;
107
+ public readonly state: RouteState;
108
+ public readonly keepScrollPosition: boolean;
109
+ /** Custom confirm handler that overrides default route-transition confirm logic */
110
+ public readonly confirm: RouteConfirmHook | null;
111
+ /** Layer configuration for layer routes */
112
+ public readonly layer: RouteLayerOptions | null;
113
+
114
+ // Read-only properties
115
+ public readonly type: RouteType;
116
+ public readonly req: IncomingMessage | null;
117
+ public readonly res: ServerResponse | null;
118
+ public readonly context: Record<string | symbol, any>;
119
+ public readonly url: URL;
120
+ public readonly path: string;
121
+ public readonly fullPath: string;
122
+ public readonly hash: string;
123
+ public readonly params: Record<string, string> = {};
124
+ public readonly paramsArray: Record<string, string[]> = {};
125
+ public readonly query: Record<string, string | undefined> = {};
126
+ public readonly queryArray: Record<string, string[] | undefined> = {};
127
+ public readonly meta: RouteMeta;
128
+ public readonly matched: readonly RouteParsedConfig[];
129
+ public readonly config: RouteParsedConfig | null;
130
+
131
+ /** @deprecated Use `url.pathname` instead. */
132
+ public get pathname(): string {
133
+ return this.url.pathname;
134
+ }
135
+
136
+ /** @deprecated Use `url.href` instead. */
137
+ public get href(): string {
138
+ return this.url.href;
139
+ }
140
+
141
+ constructor(routeOptions: Partial<RouteOptions> = {}) {
142
+ const {
143
+ toType = RouteType.push,
144
+ from = null,
145
+ options = parsedOptions()
146
+ } = routeOptions;
147
+
148
+ this._options = options;
149
+ this.type = toType;
150
+ this.req = options.req;
151
+ this.res = options.res;
152
+ this.context = options.context;
153
+
154
+ const base = options.base;
155
+ const toInput = resolveRouteLocationInput(routeOptions.toInput, from);
156
+ const to = options.normalizeURL(parseLocation(toInput, base), from);
157
+ let match: RouteMatchResult | null = null;
158
+
159
+ // Check if URL origin matches base origin (protocol + hostname + port)
160
+ // If origins don't match, treat as external URL and don't attempt route matching
161
+ if (
162
+ to.origin === base.origin &&
163
+ to.pathname.startsWith(base.pathname)
164
+ ) {
165
+ const isLayer = toType === RouteType.pushLayer || options.layer;
166
+ match = options.matcher(to, base, (config) => {
167
+ if (isLayer) {
168
+ return config.layer !== false;
169
+ }
170
+ return config.layer !== true;
171
+ });
172
+ }
173
+
174
+ if (match) {
175
+ applyRouteParams(match, toInput, base, to);
176
+
177
+ const decodedParams = decodeParams(match.params);
178
+
179
+ for (const key in decodedParams) {
180
+ const value = decodedParams[key];
181
+
182
+ if (Array.isArray(value)) {
183
+ this.params[key] = value[0] || '';
184
+ this.paramsArray[key] = value;
185
+ } else {
186
+ this.params[key] = value;
187
+ this.paramsArray[key] = [value];
188
+ }
189
+ }
190
+ }
191
+
192
+ this.url = to;
193
+ this.path = match
194
+ ? to.pathname.substring(base.pathname.length - 1)
195
+ : to.pathname;
196
+ this.fullPath = (match ? this.path : to.pathname) + to.search + to.hash;
197
+ this.matched = match ? match.matches : Object.freeze([]);
198
+ this.keepScrollPosition = Boolean(toInput.keepScrollPosition);
199
+ this.confirm = toInput.confirm || null;
200
+ this.layer =
201
+ toType === RouteType.pushLayer && toInput.layer
202
+ ? toInput.layer
203
+ : null;
204
+ this.config =
205
+ this.matched.length > 0
206
+ ? this.matched[this.matched.length - 1]
207
+ : null;
208
+ this.meta = this.config?.meta || {};
209
+
210
+ const state: RouteState = {};
211
+ if (toInput.state) {
212
+ Object.assign(state, toInput.state);
213
+ }
214
+ this.state = state;
215
+
216
+ for (const key of new Set(to.searchParams.keys())) {
217
+ this.query[key] = to.searchParams.get(key)!;
218
+ this.queryArray[key] = to.searchParams.getAll(key);
219
+ }
220
+ this.hash = to.hash;
221
+
222
+ // Set status code
223
+ // Prioritize user-provided statusCode
224
+ if (typeof toInput.statusCode === 'number') {
225
+ this.statusCode = toInput.statusCode;
226
+ }
227
+ // If statusCode is not provided, keep default null value
228
+
229
+ // Configure property enumerability
230
+ // Set internal implementation details as non-enumerable, keep user-common properties enumerable
231
+ // Set specified properties as non-enumerable according to configuration
232
+ for (const property of NON_ENUMERABLE_PROPERTIES) {
233
+ Object.defineProperty(this, property, { enumerable: false });
234
+ }
235
+ }
236
+
237
+ get isPush(): boolean {
238
+ return this.type.startsWith('push');
239
+ }
240
+
241
+ // handle related getter/setter
242
+ get handle(): RouteHandleHook | null {
243
+ return this._handle;
244
+ }
245
+
246
+ set handle(val: RouteHandleHook | null) {
247
+ this.setHandle(val);
248
+ }
249
+
250
+ get handleResult(): RouteHandleResult | null {
251
+ return this._handleResult;
252
+ }
253
+
254
+ set handleResult(val: RouteHandleResult | null) {
255
+ this._handleResult = val;
256
+ }
257
+
258
+ /**
259
+ * Set handle function with validation logic wrapper
260
+ */
261
+ setHandle(val: RouteHandleHook | null): void {
262
+ if (typeof val !== 'function') {
263
+ this._handle = null;
264
+ return;
265
+ }
266
+ const self = this;
267
+ this._handle = function handle(
268
+ this: Route,
269
+ to: Route,
270
+ from: Route | null,
271
+ router: Router
272
+ ) {
273
+ if (self._handled) {
274
+ throw new Error(
275
+ 'Route handle hook can only be called once per navigation'
276
+ );
277
+ }
278
+ self._handled = true;
279
+ return val.call(this, to, from, router);
280
+ };
281
+ }
282
+
283
+ /**
284
+ * Apply navigation-generated state to current route
285
+ * Used by route handlers to add system state like pageId
286
+ * @param navigationState Navigation-generated state to apply
287
+ */
288
+ applyNavigationState(navigationState: Partial<RouteState>): void {
289
+ Object.assign(this.state, navigationState);
290
+ }
291
+
292
+ /**
293
+ * Sync all properties of current route to target route object
294
+ * Used for route object updates in reactive systems
295
+ * @param targetRoute Target route object
296
+ */
297
+ syncTo(targetRoute: Route): void {
298
+ // Copy enumerable properties
299
+ Object.assign(targetRoute, this);
300
+
301
+ // Copy non-enumerable properties - type-safe property copying
302
+ for (const property of NON_ENUMERABLE_PROPERTIES) {
303
+ if (!(property in this && property in targetRoute)) continue;
304
+ // Use Reflect.set for type-safe property setting
305
+ const value = Reflect.get(this, property);
306
+ Reflect.set(targetRoute, property, value);
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Clone current route instance
312
+ * Returns a new Route instance with same configuration and state
313
+ */
314
+ clone(): Route {
315
+ // Reconstruct route object, passing current state and confirm handler
316
+ const toInput: RouteLocationInput = {
317
+ path: this.fullPath,
318
+ state: { ...this.state },
319
+ ...(this.confirm && { confirm: this.confirm }),
320
+ ...(this.layer && { layer: this.layer }),
321
+ ...(this.statusCode !== null && { statusCode: this.statusCode })
322
+ };
323
+
324
+ // Get original options from constructor's finalOptions
325
+ const options = this._options;
326
+
327
+ const clonedRoute = new Route({
328
+ options,
329
+ toType: this.type,
330
+ toInput
331
+ });
332
+
333
+ return clonedRoute;
334
+ }
335
+ }
@@ -0,0 +1,238 @@
1
+ import type { Router } from './router';
2
+ import type {
3
+ RouterLinkAttributes,
4
+ RouterLinkProps,
5
+ RouterLinkResolved,
6
+ RouterLinkType
7
+ } from './types';
8
+
9
+ // Constants definition
10
+ const CSS_CLASSES = {
11
+ BASE: 'router-link',
12
+ ACTIVE: 'router-link-active',
13
+ EXACT_ACTIVE: 'router-link-exact-active'
14
+ } satisfies Record<string, string>;
15
+ /**
16
+ * Normalize navigation type with backward compatibility for deprecated replace property
17
+ */
18
+ function normalizeNavigationType(props: RouterLinkProps): RouterLinkType {
19
+ if (props.replace) {
20
+ console.warn(
21
+ '[RouterLink] The `replace` property is deprecated and will be removed in a future version.\n' +
22
+ 'Please use `type="replace"` instead.\n' +
23
+ 'Before: <RouterLink replace={true} />\n' +
24
+ 'After: <RouterLink type="replace" />'
25
+ );
26
+ return 'replace';
27
+ }
28
+ return props.type || 'push';
29
+ }
30
+
31
+ /**
32
+ * Get event type list - normalize and validate event types
33
+ */
34
+ function getEventTypeList(eventType: unknown | unknown[]): string[] {
35
+ const events = Array.isArray(eventType) ? eventType : [eventType];
36
+ const validEvents = events
37
+ .filter((type): type is string => typeof type === 'string')
38
+ .map((type) => type.trim())
39
+ .filter(Boolean);
40
+ return validEvents.length ? validEvents : ['click'];
41
+ }
42
+
43
+ /**
44
+ * Navigation event handler called before navigation - determines if the router should handle the navigation
45
+ *
46
+ * Returns false: Let browser handle default behavior (normal link navigation)
47
+ * Returns true: Router takes over navigation, prevents default browser behavior
48
+ *
49
+ * This function intelligently decides when to let the browser handle clicks
50
+ * (like Ctrl+click for new tabs) vs when to use SPA routing
51
+ */
52
+ function shouldHandleNavigation(e: Event): boolean {
53
+ if (e.defaultPrevented) return false;
54
+ if (e instanceof MouseEvent) {
55
+ if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return false;
56
+ if (e.button !== undefined && e.button !== 0) return false;
57
+ }
58
+
59
+ e.preventDefault?.();
60
+
61
+ return true;
62
+ }
63
+
64
+ /**
65
+ * Execute route navigation
66
+ */
67
+ async function executeNavigation(
68
+ router: Router,
69
+ props: RouterLinkProps,
70
+ linkType: RouterLinkType
71
+ ): Promise<void> {
72
+ const { to, layerOptions } = props;
73
+
74
+ switch (linkType) {
75
+ case 'push':
76
+ await router.push(to);
77
+ break;
78
+ case 'replace':
79
+ await router.replace(to);
80
+ break;
81
+ case 'pushWindow':
82
+ await router.pushWindow(to);
83
+ break;
84
+ case 'replaceWindow':
85
+ await router.replaceWindow(to);
86
+ break;
87
+ case 'pushLayer':
88
+ await router.pushLayer(
89
+ layerOptions
90
+ ? typeof to === 'string'
91
+ ? { path: to, layer: layerOptions }
92
+ : { ...to, layer: layerOptions }
93
+ : to
94
+ );
95
+ break;
96
+ default:
97
+ await router.push(to);
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Create navigation function
103
+ */
104
+ function createNavigateFunction(
105
+ router: Router,
106
+ props: RouterLinkProps,
107
+ navigationType: RouterLinkType
108
+ ): RouterLinkResolved['navigate'] {
109
+ return async (e: Event): Promise<void> => {
110
+ if (shouldHandleNavigation(e)) {
111
+ await executeNavigation(router, props, navigationType);
112
+ }
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Compute HTML attributes
118
+ */
119
+ function computeAttributes(
120
+ href: string,
121
+ navigationType: RouterLinkType,
122
+ isExternal: boolean,
123
+ isActive: boolean,
124
+ isExactActive: boolean,
125
+ activeClass?: string
126
+ ): RouterLinkAttributes {
127
+ // Only pushWindow opens in a new window, replaceWindow replaces current window
128
+ const isNewWindow = navigationType === 'pushWindow';
129
+
130
+ // Build CSS classes
131
+ const classes: string[] = [CSS_CLASSES.BASE];
132
+ if (isActive) {
133
+ classes.push(activeClass || CSS_CLASSES.ACTIVE);
134
+ }
135
+ if (isExactActive) {
136
+ classes.push(CSS_CLASSES.EXACT_ACTIVE);
137
+ }
138
+
139
+ const attributes: RouterLinkAttributes = {
140
+ href,
141
+ class: classes.join(' ')
142
+ };
143
+
144
+ // Set target for new window
145
+ if (isNewWindow) {
146
+ attributes.target = '_blank';
147
+ }
148
+
149
+ // Build rel attribute
150
+ const relParts: string[] = [];
151
+ if (isNewWindow) {
152
+ relParts.push('noopener', 'noreferrer');
153
+ }
154
+ if (isExternal) {
155
+ relParts.push('external', 'nofollow');
156
+ }
157
+ if (relParts.length > 0) {
158
+ attributes.rel = relParts.join(' ');
159
+ }
160
+
161
+ return attributes;
162
+ }
163
+
164
+ /**
165
+ * Create event handlers generator function
166
+ */
167
+ function createEventHandlersGenerator(
168
+ router: Router,
169
+ props: RouterLinkProps,
170
+ navigationType: RouterLinkType,
171
+ eventTypes: string[]
172
+ ): RouterLinkResolved['createEventHandlers'] {
173
+ return (format?: (eventType: string) => string) => {
174
+ const handlers: Record<string, (e: Event) => Promise<void>> = {};
175
+ const navigate = createNavigateFunction(router, props, navigationType);
176
+
177
+ eventTypes.forEach((eventType) => {
178
+ const eventName = format?.(eventType) ?? eventType.toLowerCase();
179
+ handlers[eventName] = (event) => {
180
+ props.beforeNavigate?.(event, eventType);
181
+ return navigate(event);
182
+ };
183
+ });
184
+
185
+ return handlers;
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Framework-agnostic link resolver
191
+ *
192
+ * @param router Router instance
193
+ * @param props Link configuration
194
+ * @returns Resolution result
195
+ */
196
+ export function createLinkResolver(
197
+ router: Router,
198
+ props: RouterLinkProps
199
+ ): RouterLinkResolved {
200
+ const route = router.resolve(props.to);
201
+ const type = normalizeNavigationType(props);
202
+ const href = route.url.href;
203
+
204
+ const isActive = router.isRouteMatched(route, props.exact);
205
+ const isExactActive = router.isRouteMatched(route, 'exact');
206
+ const isExternal = route.url.origin !== router.route.url.origin;
207
+
208
+ const attributes = computeAttributes(
209
+ href,
210
+ type,
211
+ isExternal,
212
+ isActive,
213
+ isExactActive,
214
+ props.activeClass
215
+ );
216
+
217
+ const eventTypes = getEventTypeList(props.event || 'click');
218
+ const createEventHandlers = createEventHandlersGenerator(
219
+ router,
220
+ props,
221
+ type,
222
+ eventTypes
223
+ );
224
+
225
+ const navigate = createNavigateFunction(router, props, type);
226
+
227
+ return {
228
+ route,
229
+ type,
230
+ isActive,
231
+ isExactActive,
232
+ isExternal,
233
+ tag: props.tag || 'a',
234
+ attributes,
235
+ navigate,
236
+ createEventHandlers
237
+ };
238
+ }