metaowl 0.1.3 → 0.2.0

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/modules/router.js CHANGED
@@ -1,26 +1,610 @@
1
1
  /**
2
- * Resolves the current URL against a route table.
3
- * Used internally by processRoutes().
2
+ * @module Router
3
+ *
4
+ * Enhanced router with navigation guards support.
5
+ *
6
+ * Navigation guards allow intercepting navigation and controlling access
7
+ * to routes based on conditions like authentication, permissions, etc.
8
+ *
9
+ * Features:
10
+ * - Global beforeEach guards
11
+ * - Global afterEach hooks
12
+ * - Per-route beforeEnter guards
13
+ * - Route metadata support
14
+ * - Navigation cancellation and redirection
15
+ *
16
+ * @example
17
+ * import { router } from 'metaowl'
18
+ *
19
+ * // Global guard - runs before every navigation
20
+ * router.beforeEach((to, from, next) => {
21
+ * const auth = useAuthStore()
22
+ * if (to.meta.requiresAuth && !auth.isLoggedIn) {
23
+ * next('/login')
24
+ * } else {
25
+ * next()
26
+ * }
27
+ * })
28
+ *
29
+ * // Global after hook - runs after navigation
30
+ * router.afterEach((to, from) => {
31
+ * console.log(`Navigated from ${from.path} to ${to.path}`)
32
+ * })
33
+ *
34
+ * // Per-route guard
35
+ * export class AdminPage extends Component {
36
+ * static route = {
37
+ * path: '/admin',
38
+ * meta: { requiresAuth: true, role: 'admin' },
39
+ * beforeEnter: (to, from, next) => { ... }
40
+ * }
41
+ * }
42
+ */
43
+
44
+ import { Component } from '@odoo/owl'
45
+
46
+ /**
47
+ * Navigation guard function signature:
48
+ * @typedef {Function} NavigationGuard
49
+ * @param {Route} to - Target route
50
+ * @param {Route} from - Current route (or null on initial)
51
+ * @param {Function} next - Callback to resolve navigation
52
+ * - next() - proceed to next guard
53
+ * - next(false) - abort navigation
54
+ * - next('/path') - redirect to path
55
+ * - next({ path: '/path', replace: true }) - redirect with options
56
+ * - next(error) - abort with error
57
+ */
58
+
59
+ /**
60
+ * Current route object.
61
+ * @type {Route|null}
62
+ */
63
+ let _currentRoute = null
64
+
65
+ /**
66
+ * Previous route object.
67
+ * @type {Route|null}
68
+ */
69
+ let _previousRoute = null
70
+
71
+ /**
72
+ * Global beforeEach guards.
73
+ * @type {NavigationGuard[]}
74
+ */
75
+ const _beforeEachGuards = []
76
+
77
+ /**
78
+ * Global afterEach hooks.
79
+ * @type {Function[]}
80
+ */
81
+ const _afterEachHooks = []
82
+
83
+ /**
84
+ * Navigation in progress flag.
85
+ * @type {boolean}
86
+ */
87
+ let _isNavigating = false
88
+
89
+ /**
90
+ * Navigation cancellation token.
91
+ * @type {Function|null}
92
+ */
93
+ let _cancelNavigation = null
94
+
95
+ /**
96
+ * Router instance with guard support.
4
97
  */
5
98
  class Router {
6
99
  constructor(routes) {
7
100
  this.routes = routes
101
+ this.routeMap = new Map()
102
+
103
+ // Build route map for quick lookup
104
+ for (const route of routes) {
105
+ for (const path of route.path) {
106
+ this.routeMap.set(path, route)
107
+ }
108
+ }
8
109
  }
9
110
 
111
+ /**
112
+ * Resolve current URL against route table.
113
+ *
114
+ * @returns {Route|null}
115
+ */
10
116
  resolve() {
11
- const match = this.routes.filter(route =>
12
- (typeof route.path === 'string' && document.location.pathname === route.path) ||
13
- (Array.isArray(route.path) && route.path.includes(document.location.pathname))
14
- )
117
+ const currentPath = document.location.pathname
118
+
119
+ // Try exact match first
120
+ if (this.routeMap.has(currentPath)) {
121
+ return this.routeMap.get(currentPath)
122
+ }
123
+
124
+ // Try matching dynamic routes
125
+ for (const route of this.routes) {
126
+ if (this.matchRoute(route, currentPath)) {
127
+ return route
128
+ }
129
+ }
130
+
131
+ return null
132
+ }
133
+
134
+ /**
135
+ * Match a route against a path.
136
+ *
137
+ * @param {object} route - Route definition
138
+ * @param {string} path - Current path
139
+ * @returns {boolean}
140
+ */
141
+ matchRoute(route, path) {
142
+ for (const routePath of route.path) {
143
+ if (this.pathMatches(routePath, path)) {
144
+ return true
145
+ }
146
+ }
147
+ return false
148
+ }
15
149
 
16
- if (match.length !== 1) {
17
- throw new Error(`No route found for "${document.location.pathname}".`)
150
+ /**
151
+ * Check if a route path matches the current path.
152
+ *
153
+ * Supports:
154
+ * - Exact matches: /about
155
+ * - Dynamic segments: /user/:id
156
+ * - Optional segments: /user/:id?
157
+ * - Wildcards: /user/*
158
+ *
159
+ * @param {string} routePath - Route path pattern
160
+ * @param {string} currentPath - Current URL path
161
+ * @returns {boolean}
162
+ */
163
+ pathMatches(routePath, currentPath) {
164
+ // Convert route pattern to regex
165
+ if (!routePath.includes(':') && !routePath.includes('*')) {
166
+ // Simple exact match
167
+ const normalizedRoute = routePath.replace(/\/$/, '') || '/'
168
+ const normalizedCurrent = currentPath.replace(/\/$/, '') || '/'
169
+ return normalizedRoute === normalizedCurrent
18
170
  }
19
171
 
20
- return match
172
+ // Dynamic route matching
173
+ let pattern = routePath
174
+ // Escape special regex characters except pattern markers
175
+ .replace(/[.+?^${}()|[\]\\]/g, '\\$&')
176
+ // Replace optional params :param?
177
+ .replace(/\\:([^/]+)\\?/g, '(?:\\/([^/]+))?')
178
+ // Replace required params :param
179
+ .replace(/\\:([^/]+)/g, '([^/]+)')
180
+ // Replace wildcards
181
+ .replace(/\\\*/g, '(.*)')
182
+
183
+ pattern = '^' + pattern + '$'
184
+ const regex = new RegExp(pattern)
185
+
186
+ return regex.test(currentPath)
187
+ }
188
+
189
+ /**
190
+ * Extract parameters from a matched route.
191
+ *
192
+ * @param {object} route - Route definition
193
+ * @param {string} path - Current path
194
+ * @returns {object} Parameter key-value pairs
195
+ */
196
+ extractParams(route, path) {
197
+ const params = {}
198
+
199
+ for (const routePath of route.path) {
200
+ const match = this.matchAndExtract(routePath, path)
201
+ if (match) {
202
+ Object.assign(params, match)
203
+ }
204
+ }
205
+
206
+ return params
207
+ }
208
+
209
+ /**
210
+ * Match a path and extract parameters.
211
+ *
212
+ * @param {string} routePath - Route pattern
213
+ * @param {string} currentPath - Current URL
214
+ * @returns {object|null}
215
+ */
216
+ matchAndExtract(routePath, currentPath) {
217
+ if (!routePath.includes(':')) {
218
+ return null
219
+ }
220
+
221
+ // Extract parameter names
222
+ const paramNames = []
223
+ const pattern = routePath.replace(/:([^/?]+)\??/g, (match, name) => {
224
+ paramNames.push(name)
225
+ return '([^/]+)'
226
+ })
227
+
228
+ const regex = new RegExp('^' + pattern + '$')
229
+ const matches = currentPath.match(regex)
230
+
231
+ if (!matches) return null
232
+
233
+ const params = {}
234
+ for (let i = 0; i < paramNames.length; i++) {
235
+ params[paramNames[i]] = matches[i + 1]
236
+ }
237
+
238
+ return params
21
239
  }
22
240
  }
23
241
 
242
+ /**
243
+ * Route object passed to guards.
244
+ * @typedef {object} Route
245
+ * @property {string} name - Route name
246
+ * @property {string[]} path - URL paths
247
+ * @property {Function} component - Component class
248
+ * @property {object} [meta] - Route metadata
249
+ * @property {NavigationGuard} [beforeEnter] - Per-route guard
250
+ * @property {object} [params] - Dynamic parameters
251
+ */
252
+
253
+ /**
254
+ * Process routes with guards.
255
+ *
256
+ * @param {object[]} routes - Route table
257
+ * @returns {Promise<object[]>} Resolved route or throws error
258
+ * @throws {NavigationError} If navigation is aborted
259
+ */
260
+ export async function processRoutes(routes) {
261
+ // Inject SSG-compatible path variants
262
+ for (const route of routes) {
263
+ const originalPaths = [...route.path]
264
+ for (const path of originalPaths) {
265
+ if (typeof path === 'string') {
266
+ injectSystemRoutes(route, path)
267
+ }
268
+ }
269
+ }
270
+
271
+ const router = new Router(routes)
272
+ const toRoute = router.resolve()
273
+
274
+ if (!toRoute) {
275
+ throw new Error(`No route found for "${document.location.pathname}".`)
276
+ }
277
+
278
+ // Build route object
279
+ const to = buildRouteObject(toRoute, router)
280
+ const from = _currentRoute
281
+
282
+ // Run navigation guards
283
+ try {
284
+ await runGuards(to, from, router)
285
+
286
+ // Update current route
287
+ _previousRoute = _currentRoute
288
+ _currentRoute = to
289
+
290
+ // Run afterEach hooks
291
+ for (const hook of _afterEachHooks) {
292
+ hook(to, from)
293
+ }
294
+
295
+ return [toRoute]
296
+ } catch (error) {
297
+ if (error.name === 'NavigationRedirect') {
298
+ // Redirect to new location
299
+ if (error.path) {
300
+ window.location.href = error.path
301
+ return null
302
+ }
303
+ }
304
+ throw error
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Build a route object for guards.
310
+ *
311
+ * @param {object} routeDef - Raw route definition
312
+ * @param {Router} router - Router instance
313
+ * @returns {Route}
314
+ */
315
+ function buildRouteObject(routeDef, router) {
316
+ const currentPath = document.location.pathname
317
+ const params = router.extractParams(routeDef, currentPath)
318
+
319
+ return {
320
+ name: routeDef.name,
321
+ path: routeDef.path,
322
+ fullPath: currentPath,
323
+ component: routeDef.component,
324
+ meta: routeDef.meta || {},
325
+ beforeEnter: routeDef.beforeEnter,
326
+ params,
327
+ query: parseQuery(document.location.search)
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Parse query string into object.
333
+ *
334
+ * @param {string} search - Query string (e.g., '?foo=bar&baz=qux')
335
+ * @returns {object}
336
+ */
337
+ function parseQuery(search) {
338
+ const query = {}
339
+ if (!search || search === '?') return query
340
+
341
+ const params = new URLSearchParams(search.substring(1))
342
+ for (const [key, value] of params) {
343
+ if (query[key]) {
344
+ if (Array.isArray(query[key])) {
345
+ query[key].push(value)
346
+ } else {
347
+ query[key] = [query[key], value]
348
+ }
349
+ } else {
350
+ query[key] = value
351
+ }
352
+ }
353
+
354
+ return query
355
+ }
356
+
357
+ /**
358
+ * Run all navigation guards.
359
+ *
360
+ * @param {Route} to - Target route
361
+ * @param {Route} from - Current route
362
+ * @param {Router} router - Router instance
363
+ */
364
+ async function runGuards(to, from, router) {
365
+ _isNavigating = true
366
+
367
+ // Create navigation controller
368
+ let cancelled = false
369
+ _cancelNavigation = () => { cancelled = true }
370
+
371
+ try {
372
+ // Run global beforeEach guards
373
+ for (const guard of _beforeEachGuards) {
374
+ if (cancelled) break
375
+
376
+ const result = await runGuard(guard, to, from)
377
+
378
+ if (result === false) {
379
+ throw new NavigationCancelled()
380
+ }
381
+
382
+ if (typeof result === 'string') {
383
+ throw new NavigationRedirect(result)
384
+ }
385
+
386
+ if (result && typeof result === 'object' && result.path) {
387
+ throw new NavigationRedirect(result.path)
388
+ }
389
+ }
390
+
391
+ // Run per-route beforeEnter
392
+ if (to.beforeEnter && !cancelled) {
393
+ const result = await runGuard(to.beforeEnter, to, from)
394
+
395
+ if (result === false) {
396
+ throw new NavigationCancelled()
397
+ }
398
+
399
+ if (typeof result === 'string') {
400
+ throw new NavigationRedirect(result)
401
+ }
402
+ }
403
+ } finally {
404
+ _isNavigating = false
405
+ _cancelNavigation = null
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Run a single guard with next callback support.
411
+ *
412
+ * @param {Function} guard - Guard function
413
+ * @param {Route} to - Target route
414
+ * @param {Route} from - Current route
415
+ * @returns {Promise<*>}
416
+ */
417
+ async function runGuard(guard, to, from) {
418
+ return new Promise((resolve, reject) => {
419
+ const next = (result) => {
420
+ if (result instanceof Error) {
421
+ reject(result)
422
+ } else {
423
+ resolve(result)
424
+ }
425
+ }
426
+
427
+ try {
428
+ const guardResult = guard(to, from, next)
429
+
430
+ // Handle both sync and async guards
431
+ if (guardResult && typeof guardResult.then === 'function') {
432
+ guardResult.then(resolve).catch(reject)
433
+ } else if (guardResult !== undefined) {
434
+ // Sync guard returned a value
435
+ resolve(guardResult)
436
+ }
437
+ // If undefined, guard called next() callback
438
+ } catch (error) {
439
+ reject(error)
440
+ }
441
+ })
442
+ }
443
+
444
+ /**
445
+ * Navigation cancelled error.
446
+ */
447
+ class NavigationCancelled extends Error {
448
+ constructor() {
449
+ super('Navigation cancelled')
450
+ this.name = 'NavigationCancelled'
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Navigation redirect error.
456
+ */
457
+ class NavigationRedirect extends Error {
458
+ constructor(path) {
459
+ super('Navigation redirect')
460
+ this.name = 'NavigationRedirect'
461
+ this.path = path
462
+ }
463
+ }
464
+
465
+ // --- Public Router API ---
466
+
467
+ /**
468
+ * Register a global guard to be called before every navigation.
469
+ *
470
+ * @param {NavigationGuard} guard - Guard function
471
+ * @returns {Function} Remove guard function
472
+ */
473
+ export function beforeEach(guard) {
474
+ _beforeEachGuards.push(guard)
475
+ return () => {
476
+ const index = _beforeEachGuards.indexOf(guard)
477
+ if (index > -1) _beforeEachGuards.splice(index, 1)
478
+ }
479
+ }
480
+
481
+ /**
482
+ * Register a hook to be called after every navigation.
483
+ *
484
+ * @param {Function} hook - Hook function (to, from) => void
485
+ * @returns {Function} Remove hook function
486
+ */
487
+ export function afterEach(hook) {
488
+ _afterEachHooks.push(hook)
489
+ return () => {
490
+ const index = _afterEachHooks.indexOf(hook)
491
+ if (index > -1) _afterEachHooks.splice(index, 1)
492
+ }
493
+ }
494
+
495
+ /**
496
+ * Get the current route.
497
+ *
498
+ * @returns {Route|null}
499
+ */
500
+ export function getCurrentRoute() {
501
+ return _currentRoute
502
+ }
503
+
504
+ /**
505
+ * Get the previous route.
506
+ *
507
+ * @returns {Route|null}
508
+ */
509
+ export function getPreviousRoute() {
510
+ return _previousRoute
511
+ }
512
+
513
+ /**
514
+ * Check if navigation is in progress.
515
+ *
516
+ * @returns {boolean}
517
+ */
518
+ export function isNavigating() {
519
+ return _isNavigating
520
+ }
521
+
522
+ /**
523
+ * Cancel current navigation (if in progress).
524
+ */
525
+ export function cancelNavigation() {
526
+ if (_cancelNavigation) {
527
+ _cancelNavigation()
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Programmatically navigate to a path.
533
+ *
534
+ * @param {string} path - Target path
535
+ * @param {object} [options] - Navigation options
536
+ * @param {boolean} [options.replace=false] - Replace current history entry
537
+ * @param {boolean} [options.reload=true] - Reload the page
538
+ */
539
+ export function navigate(path, options = {}) {
540
+ const { replace = false, reload = true } = options
541
+
542
+ if (replace) {
543
+ window.location.replace(path)
544
+ } else {
545
+ window.location.href = path
546
+ }
547
+ }
548
+
549
+ /**
550
+ * Push a new history entry.
551
+ *
552
+ * @param {string} path - Target path
553
+ */
554
+ export function push(path) {
555
+ navigate(path, { replace: false })
556
+ }
557
+
558
+ /**
559
+ * Replace current history entry.
560
+ *
561
+ * @param {string} path - Target path
562
+ */
563
+ export function replace(path) {
564
+ navigate(path, { replace: true })
565
+ }
566
+
567
+ /**
568
+ * Go back in history.
569
+ */
570
+ export function back() {
571
+ window.history.back()
572
+ }
573
+
574
+ /**
575
+ * Go forward in history.
576
+ */
577
+ export function forward() {
578
+ window.history.forward()
579
+ }
580
+
581
+ /**
582
+ * Go n steps in history.
583
+ *
584
+ * @param {number} n - Steps to go (negative for back)
585
+ */
586
+ export function go(n) {
587
+ window.history.go(n)
588
+ }
589
+
590
+ /**
591
+ * Router singleton with guard methods.
592
+ */
593
+ export const router = {
594
+ beforeEach,
595
+ afterEach,
596
+ get currentRoute() { return getCurrentRoute() },
597
+ get previousRoute() { return getPreviousRoute() },
598
+ get isNavigating() { return isNavigating() },
599
+ cancel: cancelNavigation,
600
+ push,
601
+ replace,
602
+ back,
603
+ forward,
604
+ go,
605
+ navigate
606
+ }
607
+
24
608
  /**
25
609
  * Injects SSG-compatible path variants for a route:
26
610
  * trailing slash, .html suffix, /index.html suffix.
@@ -40,23 +624,3 @@ function injectSystemRoutes(route, path) {
40
624
 
41
625
  return route
42
626
  }
43
-
44
- /**
45
- * Expands all routes with SSG path variants, then resolves the current URL.
46
- *
47
- * @param {object[]} routes
48
- * @returns {Promise<object[]>}
49
- */
50
- export async function processRoutes(routes) {
51
- for (const route of routes) {
52
- const originalPaths = [...route.path]
53
- for (const path of originalPaths) {
54
- if (typeof path === 'string') {
55
- injectSystemRoutes(route, path)
56
- }
57
- }
58
- }
59
-
60
- return new Router(routes).resolve()
61
- }
62
-