@bromscandium/router 1.0.0 → 1.0.2

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/src/router.ts CHANGED
@@ -1,448 +1,448 @@
1
- /**
2
- * Router implementation with file-based routing support.
3
- * @module
4
- */
5
-
6
- import { ref, Ref } from '@bromscandium/core';
7
-
8
- /**
9
- * A route configuration object.
10
- */
11
- export interface Route {
12
- /** The path pattern to match (supports `:param` and `[param]` syntax) */
13
- path: string;
14
- /** The component to render, can be async for lazy loading */
15
- component: () => Promise<{ default: any }> | { default: any };
16
- /** Optional layout component to wrap the page */
17
- layout?: () => Promise<{ default: any }> | { default: any };
18
- /** Child routes for nested routing */
19
- children?: Route[];
20
- /** Arbitrary metadata attached to the route */
21
- meta?: Record<string, any>;
22
- /** Named route identifier for programmatic navigation */
23
- name?: string;
24
- }
25
-
26
- /**
27
- * The current location state.
28
- */
29
- export interface RouteLocation {
30
- /** The current path without query or hash */
31
- path: string;
32
- /** Dynamic route parameters extracted from the path */
33
- params: Record<string, string>;
34
- /** Query string parameters */
35
- query: Record<string, string>;
36
- /** Hash fragment (without #) */
37
- hash: string;
38
- /** All matched route records for the current location */
39
- matched: MatchedRoute[];
40
- /** Full path including query and hash */
41
- fullPath: string;
42
- /** Name of the matched route if defined */
43
- name?: string;
44
- /** Merged metadata from all matched routes */
45
- meta: Record<string, any>;
46
- }
47
-
48
- /**
49
- * A matched route record with extracted parameters.
50
- */
51
- export interface MatchedRoute {
52
- /** The route configuration */
53
- route: Route;
54
- /** Parameters extracted from the path */
55
- params: Record<string, string>;
56
- /** The resolved path for this route */
57
- path: string;
58
- }
59
-
60
- /**
61
- * The router instance providing navigation and route state.
62
- */
63
- export interface Router {
64
- /** Reactive reference to the current route location */
65
- currentRoute: Ref<RouteLocation>;
66
- /** Navigate to a new location, adding to history */
67
- push(to: string | NavigationTarget): Promise<void>;
68
- /** Navigate to a new location, replacing current history entry */
69
- replace(to: string | NavigationTarget): Promise<void>;
70
- /** Navigate back in history */
71
- back(): void;
72
- /** Navigate forward in history */
73
- forward(): void;
74
- /** Navigate by a relative history position */
75
- go(delta: number): void;
76
- /** Register a navigation guard called before navigation */
77
- beforeEach(guard: NavigationGuard): () => void;
78
- /** Register a hook called after navigation completes */
79
- afterEach(hook: NavigationHook): () => void;
80
- /** Returns a promise that resolves when router is ready */
81
- isReady(): Promise<void>;
82
- /** The configured routes */
83
- routes: Route[];
84
- /** The base path for all routes */
85
- base: string;
86
- }
87
-
88
- /**
89
- * A navigation target for programmatic navigation.
90
- */
91
- export interface NavigationTarget {
92
- /** Target path */
93
- path?: string;
94
- /** Named route to navigate to */
95
- name?: string;
96
- /** Route parameters for dynamic segments */
97
- params?: Record<string, string>;
98
- /** Query parameters */
99
- query?: Record<string, string>;
100
- /** Hash fragment */
101
- hash?: string;
102
- }
103
-
104
- /**
105
- * A navigation guard function that can block or redirect navigation.
106
- * Return false to cancel, string/NavigationTarget to redirect, or true to proceed.
107
- */
108
- export type NavigationGuard = (
109
- to: RouteLocation,
110
- from: RouteLocation
111
- ) => boolean | string | NavigationTarget | Promise<boolean | string | NavigationTarget>;
112
-
113
- /**
114
- * A hook called after navigation completes.
115
- */
116
- export type NavigationHook = (to: RouteLocation, from: RouteLocation) => void;
117
-
118
- interface RouterOptions {
119
- /** Route configurations */
120
- routes: Route[];
121
- /** Base path prepended to all routes */
122
- base?: string;
123
- }
124
-
125
- let currentRouter: Router | null = null;
126
-
127
- /**
128
- * Sets the current router instance for global access.
129
- *
130
- * @param router - The router instance or null to clear
131
- */
132
- export function setRouter(router: Router | null): void {
133
- currentRouter = router;
134
- }
135
-
136
- /**
137
- * Gets the current router instance.
138
- *
139
- * @returns The current router or null if not set
140
- */
141
- export function getRouter(): Router | null {
142
- return currentRouter;
143
- }
144
-
145
- /**
146
- * Creates a new router instance with the given configuration.
147
- *
148
- * @param options - Router configuration including routes and optional base path
149
- * @returns A configured router instance
150
- *
151
- * @example
152
- * ```ts
153
- * const router = createRouter({
154
- * base: '/app',
155
- * routes: [
156
- * { path: '/', component: () => import('./pages/Home') },
157
- * { path: '/users/:id', component: () => import('./pages/User') },
158
- * ]
159
- * });
160
- *
161
- * // Use navigation guards
162
- * router.beforeEach((to, from) => {
163
- * if (!isAuthenticated && to.path !== '/login') {
164
- * return '/login';
165
- * }
166
- * return true;
167
- * });
168
- * ```
169
- */
170
- export function createRouter(options: RouterOptions): Router {
171
- const { routes, base = '' } = options;
172
-
173
- const currentRoute = ref<RouteLocation>(createEmptyLocation());
174
- const beforeGuards: NavigationGuard[] = [];
175
- const afterHooks: NavigationHook[] = [];
176
- let isReady = false;
177
- let readyResolve: ((value?: void | PromiseLike<void>) => void) | null = null;
178
- const readyPromise = new Promise<void>((resolve) => {
179
- readyResolve = resolve;
180
- });
181
-
182
- function createEmptyLocation(): RouteLocation {
183
- return {
184
- path: '/',
185
- params: {},
186
- query: {},
187
- hash: '',
188
- matched: [],
189
- fullPath: '/',
190
- meta: {},
191
- };
192
- }
193
-
194
- function parseQuery(search: string): Record<string, string> {
195
- const query: Record<string, string> = {};
196
- if (!search) return query;
197
-
198
- const searchParams = new URLSearchParams(search);
199
- searchParams.forEach((value, key) => {
200
- query[key] = value;
201
- });
202
-
203
- return query;
204
- }
205
-
206
- function stringifyQuery(query: Record<string, string>): string {
207
- const params = new URLSearchParams();
208
- for (const [key, value] of Object.entries(query)) {
209
- if (value != null && value !== '') {
210
- params.set(key, value);
211
- }
212
- }
213
- const str = params.toString();
214
- return str ? `?${str}` : '';
215
- }
216
-
217
- function matchRoute(
218
- path: string,
219
- routes: Route[],
220
- basePath = ''
221
- ): MatchedRoute[] {
222
- const matched: MatchedRoute[] = [];
223
- const segments = path.split('/').filter(Boolean);
224
-
225
- function match(routes: Route[], segmentIndex: number, parentPath: string): boolean {
226
- for (const route of routes) {
227
- const routePath = route.path.startsWith('/')
228
- ? route.path
229
- : `${parentPath}/${route.path}`.replace(/\/+/g, '/');
230
-
231
- const routeSegments = routePath.split('/').filter(Boolean);
232
- const params: Record<string, string> = {};
233
- let matches = true;
234
- let i = 0;
235
-
236
- for (const routeSeg of routeSegments) {
237
- const pathSeg = segments[segmentIndex + i];
238
-
239
- if (routeSeg.startsWith(':')) {
240
- const paramName = routeSeg.slice(1);
241
- if (pathSeg) {
242
- params[paramName] = decodeURIComponent(pathSeg);
243
- } else {
244
- matches = false;
245
- break;
246
- }
247
- } else if (routeSeg.startsWith('[') && routeSeg.endsWith(']')) {
248
- const paramName = routeSeg.slice(1, -1).replace('...', '');
249
- if (routeSeg.startsWith('[...')) {
250
- const remaining = segments.slice(segmentIndex + i);
251
- params[paramName] = remaining.join('/');
252
- i += remaining.length;
253
- break;
254
- } else if (pathSeg) {
255
- params[paramName] = decodeURIComponent(pathSeg);
256
- } else {
257
- matches = false;
258
- break;
259
- }
260
- } else if (routeSeg !== pathSeg) {
261
- matches = false;
262
- break;
263
- }
264
- i++;
265
- }
266
-
267
- const totalSegments = segmentIndex + i;
268
- const isExactMatch = totalSegments === segments.length;
269
- const hasChildren = route.children && route.children.length > 0;
270
-
271
- if (matches && (isExactMatch || hasChildren)) {
272
- matched.push({
273
- route,
274
- params,
275
- path: routePath,
276
- });
277
-
278
- if (hasChildren && !isExactMatch) {
279
- match(route.children!, totalSegments, routePath);
280
- }
281
-
282
- return true;
283
- }
284
- }
285
-
286
- return false;
287
- }
288
-
289
- match(routes, 0, basePath);
290
- return matched;
291
- }
292
-
293
- function parseLocation(location: Location): RouteLocation {
294
- const urlPath = location.pathname;
295
- const path = urlPath.replace(base, '') || '/';
296
- const query = parseQuery(location.search);
297
- const hash = location.hash.slice(1);
298
- const matched = matchRoute(path, routes, '');
299
-
300
- const params: Record<string, string> = {};
301
- const meta: Record<string, any> = {};
302
-
303
- matched.forEach(m => {
304
- Object.assign(params, m.params);
305
- Object.assign(meta, m.route.meta || {});
306
- });
307
-
308
- const fullPath = path + stringifyQuery(query) + (hash ? `#${hash}` : '');
309
- const name = matched[matched.length - 1]?.route.name;
310
-
311
- return { path, params, query, hash, matched, fullPath, name, meta };
312
- }
313
-
314
- function resolveTarget(target: string | NavigationTarget): string {
315
- if (typeof target === 'string') {
316
- return target;
317
- }
318
-
319
- let path = target.path || '/';
320
-
321
- if (target.name) {
322
- const findRoute = (routes: Route[], name: string): Route | null => {
323
- for (const route of routes) {
324
- if (route.name === name) return route;
325
- if (route.children) {
326
- const found = findRoute(route.children, name);
327
- if (found) return found;
328
- }
329
- }
330
- return null;
331
- };
332
-
333
- const route = findRoute(routes, target.name);
334
- if (route) {
335
- path = route.path;
336
- if (target.params) {
337
- for (const [key, value] of Object.entries(target.params)) {
338
- path = path.replace(`:${key}`, encodeURIComponent(value));
339
- path = path.replace(`[${key}]`, encodeURIComponent(value));
340
- }
341
- }
342
- }
343
- }
344
-
345
- if (target.query) {
346
- path += stringifyQuery(target.query);
347
- }
348
-
349
- if (target.hash) {
350
- path += target.hash.startsWith('#') ? target.hash : `#${target.hash}`;
351
- }
352
-
353
- return path;
354
- }
355
-
356
- async function navigate(
357
- to: string | NavigationTarget,
358
- replace = false
359
- ): Promise<void> {
360
- const resolvedPath = resolveTarget(to);
361
- const url = new URL(resolvedPath, window.location.origin);
362
-
363
- if (base && !url.pathname.startsWith(base)) {
364
- url.pathname = base + url.pathname;
365
- }
366
-
367
- const newLocation = parseLocation({
368
- pathname: url.pathname,
369
- search: url.search,
370
- hash: url.hash,
371
- } as Location);
372
-
373
- const from = currentRoute.value;
374
-
375
- for (const guard of beforeGuards) {
376
- const result = await guard(newLocation, from);
377
-
378
- if (result === false) {
379
- return;
380
- }
381
-
382
- if (typeof result === 'string' || (typeof result === 'object' && result !== null)) {
383
- await navigate(result as string | NavigationTarget, replace);
384
- return;
385
- }
386
- }
387
-
388
- if (replace) {
389
- window.history.replaceState(null, '', url.href);
390
- } else {
391
- window.history.pushState(null, '', url.href);
392
- }
393
-
394
- currentRoute.value = newLocation;
395
-
396
- afterHooks.forEach(hook => hook(newLocation, from));
397
- }
398
-
399
- window.addEventListener('popstate', () => {
400
- const newLocation = parseLocation(window.location);
401
- const from = currentRoute.value;
402
-
403
- currentRoute.value = newLocation;
404
-
405
- afterHooks.forEach(hook => hook(newLocation, from));
406
- });
407
-
408
- const router: Router = {
409
- currentRoute,
410
- routes,
411
- base,
412
-
413
- push: (to) => navigate(to, false),
414
- replace: (to) => navigate(to, true),
415
- back: () => window.history.back(),
416
- forward: () => window.history.forward(),
417
- go: (delta) => window.history.go(delta),
418
-
419
- beforeEach(guard) {
420
- beforeGuards.push(guard);
421
- return () => {
422
- const index = beforeGuards.indexOf(guard);
423
- if (index > -1) beforeGuards.splice(index, 1);
424
- };
425
- },
426
-
427
- afterEach(hook) {
428
- afterHooks.push(hook);
429
- return () => {
430
- const index = afterHooks.indexOf(hook);
431
- if (index > -1) afterHooks.splice(index, 1);
432
- };
433
- },
434
-
435
- isReady() {
436
- if (isReady) return Promise.resolve();
437
- return readyPromise;
438
- },
439
- };
440
-
441
- currentRoute.value = parseLocation(window.location);
442
- isReady = true;
443
- (readyResolve as (() => void) | null)?.()
444
-
445
- setRouter(router);
446
-
447
- return router;
448
- }
1
+ /**
2
+ * Router implementation with file-based routing support.
3
+ * @module
4
+ */
5
+
6
+ import { ref, Ref } from '@bromscandium/core';
7
+
8
+ /**
9
+ * A route configuration object.
10
+ */
11
+ export interface Route {
12
+ /** The path pattern to match (supports `:param` and `[param]` syntax) */
13
+ path: string;
14
+ /** The component to render, can be async for lazy loading */
15
+ component: () => Promise<{ default: any }> | { default: any };
16
+ /** Optional layout component to wrap the page */
17
+ layout?: () => Promise<{ default: any }> | { default: any };
18
+ /** Child routes for nested routing */
19
+ children?: Route[];
20
+ /** Arbitrary metadata attached to the route */
21
+ meta?: Record<string, any>;
22
+ /** Named route identifier for programmatic navigation */
23
+ name?: string;
24
+ }
25
+
26
+ /**
27
+ * The current location state.
28
+ */
29
+ export interface RouteLocation {
30
+ /** The current path without query or hash */
31
+ path: string;
32
+ /** Dynamic route parameters extracted from the path */
33
+ params: Record<string, string>;
34
+ /** Query string parameters */
35
+ query: Record<string, string>;
36
+ /** Hash fragment (without #) */
37
+ hash: string;
38
+ /** All matched route records for the current location */
39
+ matched: MatchedRoute[];
40
+ /** Full path including query and hash */
41
+ fullPath: string;
42
+ /** Name of the matched route if defined */
43
+ name?: string;
44
+ /** Merged metadata from all matched routes */
45
+ meta: Record<string, any>;
46
+ }
47
+
48
+ /**
49
+ * A matched route record with extracted parameters.
50
+ */
51
+ export interface MatchedRoute {
52
+ /** The route configuration */
53
+ route: Route;
54
+ /** Parameters extracted from the path */
55
+ params: Record<string, string>;
56
+ /** The resolved path for this route */
57
+ path: string;
58
+ }
59
+
60
+ /**
61
+ * The router instance providing navigation and route state.
62
+ */
63
+ export interface Router {
64
+ /** Reactive reference to the current route location */
65
+ currentRoute: Ref<RouteLocation>;
66
+ /** Navigate to a new location, adding to history */
67
+ push(to: string | NavigationTarget): Promise<void>;
68
+ /** Navigate to a new location, replacing current history entry */
69
+ replace(to: string | NavigationTarget): Promise<void>;
70
+ /** Navigate back in history */
71
+ back(): void;
72
+ /** Navigate forward in history */
73
+ forward(): void;
74
+ /** Navigate by a relative history position */
75
+ go(delta: number): void;
76
+ /** Register a navigation guard called before navigation */
77
+ beforeEach(guard: NavigationGuard): () => void;
78
+ /** Register a hook called after navigation completes */
79
+ afterEach(hook: NavigationHook): () => void;
80
+ /** Returns a promise that resolves when router is ready */
81
+ isReady(): Promise<void>;
82
+ /** The configured routes */
83
+ routes: Route[];
84
+ /** The base path for all routes */
85
+ base: string;
86
+ }
87
+
88
+ /**
89
+ * A navigation target for programmatic navigation.
90
+ */
91
+ export interface NavigationTarget {
92
+ /** Target path */
93
+ path?: string;
94
+ /** Named route to navigate to */
95
+ name?: string;
96
+ /** Route parameters for dynamic segments */
97
+ params?: Record<string, string>;
98
+ /** Query parameters */
99
+ query?: Record<string, string>;
100
+ /** Hash fragment */
101
+ hash?: string;
102
+ }
103
+
104
+ /**
105
+ * A navigation guard function that can block or redirect navigation.
106
+ * Return false to cancel, string/NavigationTarget to redirect, or true to proceed.
107
+ */
108
+ export type NavigationGuard = (
109
+ to: RouteLocation,
110
+ from: RouteLocation
111
+ ) => boolean | string | NavigationTarget | Promise<boolean | string | NavigationTarget>;
112
+
113
+ /**
114
+ * A hook called after navigation completes.
115
+ */
116
+ export type NavigationHook = (to: RouteLocation, from: RouteLocation) => void;
117
+
118
+ interface RouterOptions {
119
+ /** Route configurations */
120
+ routes: Route[];
121
+ /** Base path prepended to all routes */
122
+ base?: string;
123
+ }
124
+
125
+ let currentRouter: Router | null = null;
126
+
127
+ /**
128
+ * Sets the current router instance for global access.
129
+ *
130
+ * @param router - The router instance or null to clear
131
+ */
132
+ export function setRouter(router: Router | null): void {
133
+ currentRouter = router;
134
+ }
135
+
136
+ /**
137
+ * Gets the current router instance.
138
+ *
139
+ * @returns The current router or null if not set
140
+ */
141
+ export function getRouter(): Router | null {
142
+ return currentRouter;
143
+ }
144
+
145
+ /**
146
+ * Creates a new router instance with the given configuration.
147
+ *
148
+ * @param options - Router configuration including routes and optional base path
149
+ * @returns A configured router instance
150
+ *
151
+ * @example
152
+ * ```ts
153
+ * const router = createRouter({
154
+ * base: '/app',
155
+ * routes: [
156
+ * { path: '/', component: () => import('./pages/Home') },
157
+ * { path: '/users/:id', component: () => import('./pages/User') },
158
+ * ]
159
+ * });
160
+ *
161
+ * // Use navigation guards
162
+ * router.beforeEach((to, from) => {
163
+ * if (!isAuthenticated && to.path !== '/login') {
164
+ * return '/login';
165
+ * }
166
+ * return true;
167
+ * });
168
+ * ```
169
+ */
170
+ export function createRouter(options: RouterOptions): Router {
171
+ const { routes, base = '' } = options;
172
+
173
+ const currentRoute = ref<RouteLocation>(createEmptyLocation());
174
+ const beforeGuards: NavigationGuard[] = [];
175
+ const afterHooks: NavigationHook[] = [];
176
+ let isReady = false;
177
+ let readyResolve: ((value?: void | PromiseLike<void>) => void) | null = null;
178
+ const readyPromise = new Promise<void>((resolve) => {
179
+ readyResolve = resolve;
180
+ });
181
+
182
+ function createEmptyLocation(): RouteLocation {
183
+ return {
184
+ path: '/',
185
+ params: {},
186
+ query: {},
187
+ hash: '',
188
+ matched: [],
189
+ fullPath: '/',
190
+ meta: {},
191
+ };
192
+ }
193
+
194
+ function parseQuery(search: string): Record<string, string> {
195
+ const query: Record<string, string> = {};
196
+ if (!search) return query;
197
+
198
+ const searchParams = new URLSearchParams(search);
199
+ searchParams.forEach((value, key) => {
200
+ query[key] = value;
201
+ });
202
+
203
+ return query;
204
+ }
205
+
206
+ function stringifyQuery(query: Record<string, string>): string {
207
+ const params = new URLSearchParams();
208
+ for (const [key, value] of Object.entries(query)) {
209
+ if (value != null && value !== '') {
210
+ params.set(key, value);
211
+ }
212
+ }
213
+ const str = params.toString();
214
+ return str ? `?${str}` : '';
215
+ }
216
+
217
+ function matchRoute(
218
+ path: string,
219
+ routes: Route[],
220
+ basePath = ''
221
+ ): MatchedRoute[] {
222
+ const matched: MatchedRoute[] = [];
223
+ const segments = path.split('/').filter(Boolean);
224
+
225
+ function match(routes: Route[], segmentIndex: number, parentPath: string): boolean {
226
+ for (const route of routes) {
227
+ const routePath = route.path.startsWith('/')
228
+ ? route.path
229
+ : `${parentPath}/${route.path}`.replace(/\/+/g, '/');
230
+
231
+ const routeSegments = routePath.split('/').filter(Boolean);
232
+ const params: Record<string, string> = {};
233
+ let matches = true;
234
+ let i = 0;
235
+
236
+ for (const routeSeg of routeSegments) {
237
+ const pathSeg = segments[segmentIndex + i];
238
+
239
+ if (routeSeg.startsWith(':')) {
240
+ const paramName = routeSeg.slice(1);
241
+ if (pathSeg) {
242
+ params[paramName] = decodeURIComponent(pathSeg);
243
+ } else {
244
+ matches = false;
245
+ break;
246
+ }
247
+ } else if (routeSeg.startsWith('[') && routeSeg.endsWith(']')) {
248
+ const paramName = routeSeg.slice(1, -1).replace('...', '');
249
+ if (routeSeg.startsWith('[...')) {
250
+ const remaining = segments.slice(segmentIndex + i);
251
+ params[paramName] = remaining.join('/');
252
+ i += remaining.length;
253
+ break;
254
+ } else if (pathSeg) {
255
+ params[paramName] = decodeURIComponent(pathSeg);
256
+ } else {
257
+ matches = false;
258
+ break;
259
+ }
260
+ } else if (routeSeg !== pathSeg) {
261
+ matches = false;
262
+ break;
263
+ }
264
+ i++;
265
+ }
266
+
267
+ const totalSegments = segmentIndex + i;
268
+ const isExactMatch = totalSegments === segments.length;
269
+ const hasChildren = route.children && route.children.length > 0;
270
+
271
+ if (matches && (isExactMatch || hasChildren)) {
272
+ matched.push({
273
+ route,
274
+ params,
275
+ path: routePath,
276
+ });
277
+
278
+ if (hasChildren && !isExactMatch) {
279
+ match(route.children!, totalSegments, routePath);
280
+ }
281
+
282
+ return true;
283
+ }
284
+ }
285
+
286
+ return false;
287
+ }
288
+
289
+ match(routes, 0, basePath);
290
+ return matched;
291
+ }
292
+
293
+ function parseLocation(location: Location): RouteLocation {
294
+ const urlPath = location.pathname;
295
+ const path = urlPath.replace(base, '') || '/';
296
+ const query = parseQuery(location.search);
297
+ const hash = location.hash.slice(1);
298
+ const matched = matchRoute(path, routes, '');
299
+
300
+ const params: Record<string, string> = {};
301
+ const meta: Record<string, any> = {};
302
+
303
+ matched.forEach(m => {
304
+ Object.assign(params, m.params);
305
+ Object.assign(meta, m.route.meta || {});
306
+ });
307
+
308
+ const fullPath = path + stringifyQuery(query) + (hash ? `#${hash}` : '');
309
+ const name = matched[matched.length - 1]?.route.name;
310
+
311
+ return { path, params, query, hash, matched, fullPath, name, meta };
312
+ }
313
+
314
+ function resolveTarget(target: string | NavigationTarget): string {
315
+ if (typeof target === 'string') {
316
+ return target;
317
+ }
318
+
319
+ let path = target.path || '/';
320
+
321
+ if (target.name) {
322
+ const findRoute = (routes: Route[], name: string): Route | null => {
323
+ for (const route of routes) {
324
+ if (route.name === name) return route;
325
+ if (route.children) {
326
+ const found = findRoute(route.children, name);
327
+ if (found) return found;
328
+ }
329
+ }
330
+ return null;
331
+ };
332
+
333
+ const route = findRoute(routes, target.name);
334
+ if (route) {
335
+ path = route.path;
336
+ if (target.params) {
337
+ for (const [key, value] of Object.entries(target.params)) {
338
+ path = path.replace(`:${key}`, encodeURIComponent(value));
339
+ path = path.replace(`[${key}]`, encodeURIComponent(value));
340
+ }
341
+ }
342
+ }
343
+ }
344
+
345
+ if (target.query) {
346
+ path += stringifyQuery(target.query);
347
+ }
348
+
349
+ if (target.hash) {
350
+ path += target.hash.startsWith('#') ? target.hash : `#${target.hash}`;
351
+ }
352
+
353
+ return path;
354
+ }
355
+
356
+ async function navigate(
357
+ to: string | NavigationTarget,
358
+ replace = false
359
+ ): Promise<void> {
360
+ const resolvedPath = resolveTarget(to);
361
+ const url = new URL(resolvedPath, window.location.origin);
362
+
363
+ if (base && !url.pathname.startsWith(base)) {
364
+ url.pathname = base + url.pathname;
365
+ }
366
+
367
+ const newLocation = parseLocation({
368
+ pathname: url.pathname,
369
+ search: url.search,
370
+ hash: url.hash,
371
+ } as Location);
372
+
373
+ const from = currentRoute.value;
374
+
375
+ for (const guard of beforeGuards) {
376
+ const result = await guard(newLocation, from);
377
+
378
+ if (result === false) {
379
+ return;
380
+ }
381
+
382
+ if (typeof result === 'string' || (typeof result === 'object' && result !== null)) {
383
+ await navigate(result as string | NavigationTarget, replace);
384
+ return;
385
+ }
386
+ }
387
+
388
+ if (replace) {
389
+ window.history.replaceState(null, '', url.href);
390
+ } else {
391
+ window.history.pushState(null, '', url.href);
392
+ }
393
+
394
+ currentRoute.value = newLocation;
395
+
396
+ afterHooks.forEach(hook => hook(newLocation, from));
397
+ }
398
+
399
+ window.addEventListener('popstate', () => {
400
+ const newLocation = parseLocation(window.location);
401
+ const from = currentRoute.value;
402
+
403
+ currentRoute.value = newLocation;
404
+
405
+ afterHooks.forEach(hook => hook(newLocation, from));
406
+ });
407
+
408
+ const router: Router = {
409
+ currentRoute,
410
+ routes,
411
+ base,
412
+
413
+ push: (to) => navigate(to, false),
414
+ replace: (to) => navigate(to, true),
415
+ back: () => window.history.back(),
416
+ forward: () => window.history.forward(),
417
+ go: (delta) => window.history.go(delta),
418
+
419
+ beforeEach(guard) {
420
+ beforeGuards.push(guard);
421
+ return () => {
422
+ const index = beforeGuards.indexOf(guard);
423
+ if (index > -1) beforeGuards.splice(index, 1);
424
+ };
425
+ },
426
+
427
+ afterEach(hook) {
428
+ afterHooks.push(hook);
429
+ return () => {
430
+ const index = afterHooks.indexOf(hook);
431
+ if (index > -1) afterHooks.splice(index, 1);
432
+ };
433
+ },
434
+
435
+ isReady() {
436
+ if (isReady) return Promise.resolve();
437
+ return readyPromise;
438
+ },
439
+ };
440
+
441
+ currentRoute.value = parseLocation(window.location);
442
+ isReady = true;
443
+ (readyResolve as (() => void) | null)?.()
444
+
445
+ setRouter(router);
446
+
447
+ return router;
448
+ }