eleva 1.0.1 → 1.1.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.
Files changed (97) hide show
  1. package/README.md +21 -10
  2. package/dist/{eleva-plugins.cjs.js → eleva-plugins.cjs} +1002 -292
  3. package/dist/eleva-plugins.cjs.map +1 -0
  4. package/dist/eleva-plugins.d.cts +1352 -0
  5. package/dist/eleva-plugins.d.cts.map +1 -0
  6. package/dist/eleva-plugins.d.ts +1352 -0
  7. package/dist/eleva-plugins.d.ts.map +1 -0
  8. package/dist/{eleva-plugins.esm.js → eleva-plugins.js} +1002 -292
  9. package/dist/eleva-plugins.js.map +1 -0
  10. package/dist/eleva-plugins.umd.js +1001 -291
  11. package/dist/eleva-plugins.umd.js.map +1 -1
  12. package/dist/eleva-plugins.umd.min.js +1 -1
  13. package/dist/eleva-plugins.umd.min.js.map +1 -1
  14. package/dist/{eleva.cjs.js → eleva.cjs} +421 -191
  15. package/dist/eleva.cjs.map +1 -0
  16. package/dist/eleva.d.cts +1329 -0
  17. package/dist/eleva.d.cts.map +1 -0
  18. package/dist/eleva.d.ts +473 -226
  19. package/dist/eleva.d.ts.map +1 -0
  20. package/dist/{eleva.esm.js → eleva.js} +422 -192
  21. package/dist/eleva.js.map +1 -0
  22. package/dist/eleva.umd.js +420 -190
  23. package/dist/eleva.umd.js.map +1 -1
  24. package/dist/eleva.umd.min.js +1 -1
  25. package/dist/eleva.umd.min.js.map +1 -1
  26. package/dist/plugins/attr.cjs +279 -0
  27. package/dist/plugins/attr.cjs.map +1 -0
  28. package/dist/plugins/attr.d.cts +101 -0
  29. package/dist/plugins/attr.d.cts.map +1 -0
  30. package/dist/plugins/attr.d.ts +101 -0
  31. package/dist/plugins/attr.d.ts.map +1 -0
  32. package/dist/plugins/attr.js +276 -0
  33. package/dist/plugins/attr.js.map +1 -0
  34. package/dist/plugins/attr.umd.js +111 -22
  35. package/dist/plugins/attr.umd.js.map +1 -1
  36. package/dist/plugins/attr.umd.min.js +1 -1
  37. package/dist/plugins/attr.umd.min.js.map +1 -1
  38. package/dist/plugins/router.cjs +1873 -0
  39. package/dist/plugins/router.cjs.map +1 -0
  40. package/dist/plugins/router.d.cts +1296 -0
  41. package/dist/plugins/router.d.cts.map +1 -0
  42. package/dist/plugins/router.d.ts +1296 -0
  43. package/dist/plugins/router.d.ts.map +1 -0
  44. package/dist/plugins/router.js +1870 -0
  45. package/dist/plugins/router.js.map +1 -0
  46. package/dist/plugins/router.umd.js +482 -186
  47. package/dist/plugins/router.umd.js.map +1 -1
  48. package/dist/plugins/router.umd.min.js +1 -1
  49. package/dist/plugins/router.umd.min.js.map +1 -1
  50. package/dist/plugins/store.cjs +920 -0
  51. package/dist/plugins/store.cjs.map +1 -0
  52. package/dist/plugins/store.d.cts +266 -0
  53. package/dist/plugins/store.d.cts.map +1 -0
  54. package/dist/plugins/store.d.ts +266 -0
  55. package/dist/plugins/store.d.ts.map +1 -0
  56. package/dist/plugins/store.js +917 -0
  57. package/dist/plugins/store.js.map +1 -0
  58. package/dist/plugins/store.umd.js +410 -85
  59. package/dist/plugins/store.umd.js.map +1 -1
  60. package/dist/plugins/store.umd.min.js +1 -1
  61. package/dist/plugins/store.umd.min.js.map +1 -1
  62. package/package.json +112 -68
  63. package/src/core/Eleva.js +195 -115
  64. package/src/index.cjs +10 -0
  65. package/src/index.js +11 -0
  66. package/src/modules/Emitter.js +68 -20
  67. package/src/modules/Renderer.js +82 -20
  68. package/src/modules/Signal.js +43 -15
  69. package/src/modules/TemplateEngine.js +50 -9
  70. package/src/plugins/Attr.js +121 -19
  71. package/src/plugins/Router.js +526 -181
  72. package/src/plugins/Store.js +448 -69
  73. package/src/plugins/index.js +1 -0
  74. package/types/core/Eleva.d.ts +263 -169
  75. package/types/core/Eleva.d.ts.map +1 -1
  76. package/types/index.d.cts +3 -0
  77. package/types/index.d.cts.map +1 -0
  78. package/types/index.d.ts +5 -0
  79. package/types/index.d.ts.map +1 -1
  80. package/types/modules/Emitter.d.ts +73 -30
  81. package/types/modules/Emitter.d.ts.map +1 -1
  82. package/types/modules/Renderer.d.ts +48 -18
  83. package/types/modules/Renderer.d.ts.map +1 -1
  84. package/types/modules/Signal.d.ts +44 -16
  85. package/types/modules/Signal.d.ts.map +1 -1
  86. package/types/modules/TemplateEngine.d.ts +46 -11
  87. package/types/modules/TemplateEngine.d.ts.map +1 -1
  88. package/types/plugins/Attr.d.ts +83 -16
  89. package/types/plugins/Attr.d.ts.map +1 -1
  90. package/types/plugins/Router.d.ts +498 -207
  91. package/types/plugins/Router.d.ts.map +1 -1
  92. package/types/plugins/Store.d.ts +211 -37
  93. package/types/plugins/Store.d.ts.map +1 -1
  94. package/dist/eleva-plugins.cjs.js.map +0 -1
  95. package/dist/eleva-plugins.esm.js.map +0 -1
  96. package/dist/eleva.cjs.js.map +0 -1
  97. package/dist/eleva.esm.js.map +0 -1
@@ -0,0 +1,1873 @@
1
+ /*! Eleva Router Plugin v1.1.0 | MIT License | https://elevajs.com */
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module eleva/plugins/router
6
+ * @fileoverview Client-side router plugin with hash, history, and query modes,
7
+ * navigation guards, and lifecycle hooks.
8
+ */ // ============================================================================
9
+ // TYPE DEFINITIONS
10
+ // ============================================================================
11
+ // -----------------------------------------------------------------------------
12
+ // External Type Imports
13
+ // -----------------------------------------------------------------------------
14
+ /**
15
+ * Type imports from the Eleva core library.
16
+ * @typedef {import('eleva').Eleva} Eleva
17
+ * @typedef {import('eleva').ComponentDefinition} ComponentDefinition
18
+ * @typedef {import('eleva').Emitter} Emitter
19
+ * @typedef {import('eleva').MountResult} MountResult
20
+ * @typedef {import('eleva').UnsubscribeFunction} UnsubscribeFunction
21
+ */ /**
22
+ * Generic type import.
23
+ * @template T
24
+ * @typedef {import('eleva').Signal<T>} Signal
25
+ */ // -----------------------------------------------------------------------------
26
+ // Router Events
27
+ // -----------------------------------------------------------------------------
28
+ /**
29
+ * Fired when the router initialization completes successfully.
30
+ * @event router:ready
31
+ * @type {Router}
32
+ */ /**
33
+ * Fired when an error occurs during navigation or route handling.
34
+ * @event router:error
35
+ * @type {Error}
36
+ */ /**
37
+ * Fired when no matching route is found for the requested path.
38
+ * @event router:notFound
39
+ * @type {{to: RouteLocation, from: RouteLocation | null, path: string}}
40
+ */ /**
41
+ * Fired before guards run, allowing plugins to block or redirect navigation.
42
+ * @event router:beforeEach
43
+ * @type {NavigationContext}
44
+ */ /**
45
+ * Fired before component resolution, allowing plugins to modify the resolve context.
46
+ * @event router:beforeResolve
47
+ * @type {ResolveContext}
48
+ */ /**
49
+ * Fired after components are resolved successfully.
50
+ * @event router:afterResolve
51
+ * @type {ResolveContext}
52
+ */ /**
53
+ * Fired after leaving the previous route.
54
+ * @event router:afterLeave
55
+ * @type {{to: RouteLocation, from: RouteLocation}}
56
+ */ /**
57
+ * Fired before DOM rendering begins.
58
+ * @event router:beforeRender
59
+ * @type {RenderContext}
60
+ */ /**
61
+ * Fired after DOM rendering completes.
62
+ * @event router:afterRender
63
+ * @type {RenderContext}
64
+ */ /**
65
+ * Fired after render for scroll behavior handling.
66
+ * @event router:scroll
67
+ * @type {ScrollContext}
68
+ */ /**
69
+ * Fired after entering the new route.
70
+ * @event router:afterEnter
71
+ * @type {{to: RouteLocation, from: RouteLocation | null}}
72
+ */ /**
73
+ * Fired after navigation completes successfully.
74
+ * @event router:afterEach
75
+ * @type {{to: RouteLocation, from: RouteLocation | null}}
76
+ */ /**
77
+ * Fired when a route is dynamically added.
78
+ * @event router:routeAdded
79
+ * @type {RouteDefinition}
80
+ */ /**
81
+ * Fired when a route is dynamically removed.
82
+ * @event router:routeRemoved
83
+ * @type {RouteDefinition}
84
+ */ // -----------------------------------------------------------------------------
85
+ // Router Data Types
86
+ // -----------------------------------------------------------------------------
87
+ /**
88
+ * The routing mode determines how the router manages URL state.
89
+ * - `hash`: Uses URL hash (e.g., `/#/path`) - works without server config
90
+ * - `history`: Uses HTML5 History API (e.g., `/path`) - requires server config
91
+ * - `query`: Uses query parameters (e.g., `?view=/path`) - useful for embedded apps
92
+ * @typedef {'hash' | 'history' | 'query'} RouterMode
93
+ */ /**
94
+ * Route parameters extracted from the URL path.
95
+ * @typedef {Record<string, string>} RouteParams
96
+ * @description Key-value pairs extracted from dynamic route segments (e.g., `/users/:id` → `{ id: '123' }`).
97
+ */ /**
98
+ * Query parameters from the URL query string.
99
+ * @typedef {Record<string, string>} QueryParams
100
+ * @description Key-value pairs from the URL query string (e.g., `?page=1&sort=name`).
101
+ */ /**
102
+ * Navigation input parameters supporting multiple value types.
103
+ * @typedef {Record<string, string | number | boolean>} NavigationParams
104
+ * @description Parameters passed to navigation functions, automatically converted to strings in URLs.
105
+ */ /**
106
+ * Function signature for programmatic navigation.
107
+ * @typedef {(location: string | NavigationTarget, params?: NavigationParams) => Promise<boolean>} NavigateFunction
108
+ * @description Returns true if navigation succeeded, false if blocked by a guard.
109
+ */ /**
110
+ * Router configuration options.
111
+ * @typedef {Object} RouterOptions
112
+ * @property {RouterMode} [mode='hash']
113
+ * The routing mode to use.
114
+ * @property {string} [queryParam='view']
115
+ * Query parameter name for 'query' mode.
116
+ * @property {string} [viewSelector='view']
117
+ * Base selector for the view element.
118
+ * @property {string} mount
119
+ * CSS selector for the mount point element.
120
+ * @property {RouteDefinition[]} routes
121
+ * Array of route definitions.
122
+ * @property {RouteComponent} [globalLayout]
123
+ * Default layout for all routes.
124
+ * @property {NavigationGuard} [onBeforeEach]
125
+ * Global navigation guard.
126
+ * @property {boolean} [autoStart=true]
127
+ * Whether to start the router automatically.
128
+ * @description Configuration options for the Router plugin.
129
+ */ /**
130
+ * Object describing a navigation target for `router.navigate()`.
131
+ * @typedef {Object} NavigationTarget
132
+ * @property {string} path
133
+ * The target path (can include params like '/users/:id').
134
+ * @property {NavigationParams} [params]
135
+ * Route parameters to inject.
136
+ * @property {NavigationParams} [query]
137
+ * Query parameters to append.
138
+ * @property {boolean} [replace=false]
139
+ * Whether to replace current history entry.
140
+ * @property {unknown} [state]
141
+ * History state to pass.
142
+ * @description Object describing a navigation target for `router.navigate()`.
143
+ */ /**
144
+ * Saved scroll position.
145
+ * @typedef {Object} ScrollPosition
146
+ * @property {number} x
147
+ * Horizontal scroll position.
148
+ * @property {number} y
149
+ * Vertical scroll position.
150
+ * @description Represents a saved scroll position.
151
+ */ /**
152
+ * Internal representation of a parsed route path segment.
153
+ * @typedef {Object} RouteSegment
154
+ * @property {'static' | 'param'} type
155
+ * The segment type.
156
+ * @property {string} [value]
157
+ * The segment value (static segments).
158
+ * @property {string} [name]
159
+ * The parameter name (param segments).
160
+ * @description Internal representation of a parsed route path segment.
161
+ * @private
162
+ */ /**
163
+ * Result of matching a path against route definitions.
164
+ * @typedef {Object} RouteMatch
165
+ * @property {RouteDefinition} route
166
+ * The matched route definition.
167
+ * @property {RouteParams} params
168
+ * The extracted route parameters.
169
+ * @description Result of matching a path against route definitions.
170
+ * @private
171
+ */ /**
172
+ * Arbitrary metadata attached to routes for use in guards and components.
173
+ * @typedef {Record<string, unknown>} RouteMeta
174
+ * @description Common properties include:
175
+ * - `requiresAuth: boolean` - Whether the route requires authentication
176
+ * - `title: string` - Page title for the route
177
+ * - `roles: string[]` - Required user roles
178
+ * @example
179
+ * {
180
+ * path: '/admin',
181
+ * component: AdminPage,
182
+ * meta: { requiresAuth: true, roles: ['admin'], title: 'Admin Dashboard' }
183
+ * }
184
+ */ /**
185
+ * Interface for the router's error handling system.
186
+ * @typedef {Object} RouterErrorHandler
187
+ * @property {(error: Error, context: string, details?: Record<string, unknown>) => void} handle
188
+ * Throws a formatted error.
189
+ * @property {(message: string, details?: Record<string, unknown>) => void} warn
190
+ * Logs a warning.
191
+ * @property {(message: string, error: Error, details?: Record<string, unknown>) => void} log
192
+ * Logs an error without throwing.
193
+ * @description Interface for the router's error handling system.
194
+ */ // -----------------------------------------------------------------------------
195
+ // Event Callback Types
196
+ // -----------------------------------------------------------------------------
197
+ /**
198
+ * Callback for `router:beforeEach` event.
199
+ * @callback NavigationContextCallback
200
+ * @param {NavigationContext} context
201
+ * The navigation context (can be modified to block/redirect).
202
+ * @returns {void | Promise<void>}
203
+ * @description Modify context to control navigation flow.
204
+ */ /**
205
+ * Callback for `router:beforeResolve` and `router:afterResolve` events.
206
+ * @callback ResolveContextCallback
207
+ * @param {ResolveContext} context
208
+ * The resolve context (can be modified to block/redirect).
209
+ * @returns {void | Promise<void>}
210
+ * @description Callback for `router:beforeResolve` and `router:afterResolve` events.
211
+ */ /**
212
+ * Callback for `router:beforeRender` and `router:afterRender` events.
213
+ * @callback RenderContextCallback
214
+ * @param {RenderContext} context
215
+ * The render context.
216
+ * @returns {void | Promise<void>}
217
+ * @description Callback for `router:beforeRender` and `router:afterRender` events.
218
+ */ /**
219
+ * Callback for `router:scroll` event.
220
+ * @callback ScrollContextCallback
221
+ * @param {ScrollContext} context
222
+ * The scroll context with saved position info.
223
+ * @returns {void | Promise<void>}
224
+ * @description Use to implement custom scroll behavior.
225
+ */ /**
226
+ * Callback for `router:afterEnter`, `router:afterLeave`, `router:afterEach` events.
227
+ * @callback RouteChangeCallback
228
+ * @param {RouteLocation} to
229
+ * The target route location.
230
+ * @param {RouteLocation | null} from
231
+ * The source route location.
232
+ * @returns {void | Promise<void>}
233
+ * @description Callback for `router:afterEnter`, `router:afterLeave`, `router:afterEach` events.
234
+ */ /**
235
+ * Router context injected into component setup as `ctx.router`.
236
+ * @typedef {Object} RouterContext
237
+ * @property {NavigateFunction} navigate
238
+ * Programmatic navigation function.
239
+ * @property {Signal<RouteLocation | null>} current
240
+ * Reactive signal for current route.
241
+ * @property {Signal<RouteLocation | null>} previous
242
+ * Reactive signal for previous route.
243
+ * @property {RouteParams} params
244
+ * Current route params (getter).
245
+ * @property {QueryParams} query
246
+ * Current route query (getter).
247
+ * @property {string} path
248
+ * Current route path (getter).
249
+ * @property {string} fullUrl
250
+ * Current routed URL string (getter).
251
+ * @property {RouteMeta} meta
252
+ * Current route meta (getter).
253
+ * @description Injected into component setup as `ctx.router`.
254
+ */ /**
255
+ * Callback for `router:error` event.
256
+ * @callback RouterErrorCallback
257
+ * @param {Error} error
258
+ * The error that occurred.
259
+ * @param {RouteLocation} [to]
260
+ * The target route (if available).
261
+ * @param {RouteLocation | null} [from]
262
+ * The source route (if available).
263
+ * @returns {void | Promise<void>}
264
+ * @description Callback for `router:error` event.
265
+ */ /**
266
+ * Callback for `router:ready` event.
267
+ * @callback RouterReadyCallback
268
+ * @param {Router} router
269
+ * The router instance.
270
+ * @returns {void | Promise<void>}
271
+ * @description Callback for `router:ready` event.
272
+ */ /**
273
+ * Callback for `router:routeAdded` event.
274
+ * @callback RouteAddedCallback
275
+ * @param {RouteDefinition} route
276
+ * The added route definition.
277
+ * @returns {void | Promise<void>}
278
+ * @description Callback for `router:routeAdded` event.
279
+ */ /**
280
+ * Callback for `router:routeRemoved` event.
281
+ * @callback RouteRemovedCallback
282
+ * @param {RouteDefinition} route
283
+ * The removed route definition.
284
+ * @returns {void | Promise<void>}
285
+ * @description Callback for `router:routeRemoved` event.
286
+ */ // ============================================================================
287
+ // CORE IMPLEMENTATION
288
+ // ============================================================================
289
+ /**
290
+ * Simple error handler for the core router.
291
+ * @private
292
+ */ const CoreErrorHandler = {
293
+ /**
294
+ * Handles router errors with basic formatting.
295
+ * @param {Error} error - The error to handle.
296
+ * @param {string} context - The context where the error occurred.
297
+ * @param {Record<string, unknown>} details - Additional error details.
298
+ * @throws {Error} The formatted error.
299
+ */ handle (error, context, details = {}) {
300
+ const message = `[ElevaRouter] ${context}: ${error.message}`;
301
+ const formattedError = new Error(message);
302
+ // Preserve original error details
303
+ formattedError.originalError = error;
304
+ formattedError.context = context;
305
+ formattedError.details = details;
306
+ console.error(message, {
307
+ error,
308
+ context,
309
+ details
310
+ });
311
+ throw formattedError;
312
+ },
313
+ /**
314
+ * Logs a warning without throwing an error.
315
+ * @param {string} message - The warning message.
316
+ * @param {Record<string, unknown>} details - Additional warning details.
317
+ */ warn (message, details = {}) {
318
+ console.warn(`[ElevaRouter] ${message}`, details);
319
+ },
320
+ /**
321
+ * Logs an error without throwing.
322
+ * @param {string} message - The error message.
323
+ * @param {Error} error - The original error.
324
+ * @param {Record<string, unknown>} details - Additional error details.
325
+ */ log (message, error, details = {}) {
326
+ console.error(`[ElevaRouter] ${message}`, {
327
+ error,
328
+ details
329
+ });
330
+ }
331
+ };
332
+ /**
333
+ * Represents the current or target location in the router.
334
+ * @typedef {Object} RouteLocation
335
+ * @property {string} path
336
+ * The path of the route (e.g., '/users/123').
337
+ * @property {QueryParams} query
338
+ * Query parameters as key-value pairs.
339
+ * @property {string} fullUrl
340
+ * The routed URL string (path plus query).
341
+ * @property {RouteParams} params
342
+ * Dynamic route parameters.
343
+ * @property {RouteMeta} meta
344
+ * Metadata associated with the matched route.
345
+ * @property {string} [name]
346
+ * The optional name of the matched route.
347
+ * @property {RouteDefinition} matched
348
+ * The raw route definition that was matched.
349
+ * @description Represents the current or target location in the router.
350
+ */ /**
351
+ * Return value of a navigation guard.
352
+ * - `true` or `undefined/void`: Allow navigation
353
+ * - `false`: Abort navigation
354
+ * - `string`: Redirect to path
355
+ * - `NavigationTarget`: Redirect with options
356
+ * @typedef {boolean | string | NavigationTarget | void} NavigationGuardResult
357
+ */ /**
358
+ * Navigation guard function that controls navigation flow.
359
+ * @callback NavigationGuard
360
+ * @param {RouteLocation} to
361
+ * The target route location.
362
+ * @param {RouteLocation | null} from
363
+ * The source route location (null on initial).
364
+ * @returns {NavigationGuardResult | Promise<NavigationGuardResult>}
365
+ * @description A function that controls navigation flow. Runs before navigation is confirmed.
366
+ * @example
367
+ * // Simple auth guard
368
+ * const authGuard = (to, from) => {
369
+ * if (to.meta.requiresAuth && !isLoggedIn()) {
370
+ * return '/login'; // Redirect
371
+ * }
372
+ * // Allow navigation (implicit return undefined)
373
+ * };
374
+ */ /**
375
+ * Navigation hook for side effects. Does not affect navigation flow.
376
+ * @callback NavigationHook
377
+ * @param {RouteLocation} to
378
+ * The target route location.
379
+ * @param {RouteLocation | null} from
380
+ * The source route location.
381
+ * @returns {void | Promise<void>}
382
+ * @description A lifecycle hook for side effects. Does not affect navigation flow.
383
+ * @example
384
+ * // Analytics hook
385
+ * const analyticsHook = (to, from) => {
386
+ * analytics.trackPageView(to.path);
387
+ * };
388
+ */ /**
389
+ * Interface for router plugins.
390
+ * @typedef {Object} RouterPlugin
391
+ * @property {string} name
392
+ * Unique plugin identifier.
393
+ * @property {string} [version]
394
+ * Plugin version (recommended to match router version).
395
+ * @property {(router: Router, options?: Record<string, unknown>) => void} install
396
+ * Installation function.
397
+ * @property {(router: Router) => void | Promise<void>} [destroy]
398
+ * Cleanup function called on router.destroy().
399
+ * @description Interface for router plugins. Plugins can extend router functionality.
400
+ * @example
401
+ * const AnalyticsPlugin = {
402
+ * name: 'analytics',
403
+ * version: '1.0.0',
404
+ * install(router, options) {
405
+ * router.emitter.on('router:afterEach', (to, from) => {
406
+ * analytics.track(to.path);
407
+ * });
408
+ * }
409
+ * };
410
+ */ /**
411
+ * Context object for navigation events that plugins can modify.
412
+ * @typedef {Object} NavigationContext
413
+ * @property {RouteLocation} to
414
+ * The target route location.
415
+ * @property {RouteLocation | null} from
416
+ * The source route location.
417
+ * @property {boolean} cancelled
418
+ * Whether navigation has been cancelled.
419
+ * @property {string | NavigationTarget | null} redirectTo
420
+ * Redirect target if set.
421
+ * @description Passed to navigation events. Plugins can modify to control navigation flow.
422
+ */ /**
423
+ * Context object for component resolution events.
424
+ * @typedef {Object} ResolveContext
425
+ * @property {RouteLocation} to
426
+ * The target route location.
427
+ * @property {RouteLocation | null} from
428
+ * The source route location.
429
+ * @property {RouteDefinition} route
430
+ * The matched route definition.
431
+ * @property {ComponentDefinition | null} layoutComponent
432
+ * The resolved layout component (available in afterResolve).
433
+ * @property {ComponentDefinition | null} pageComponent
434
+ * The resolved page component (available in afterResolve).
435
+ * @property {boolean} cancelled
436
+ * Whether navigation has been cancelled.
437
+ * @property {string | NavigationTarget | null} redirectTo
438
+ * Redirect target if set.
439
+ * @description Passed to component resolution events.
440
+ */ /**
441
+ * Context object for render events.
442
+ * @typedef {Object} RenderContext
443
+ * @property {RouteLocation} to
444
+ * The target route location.
445
+ * @property {RouteLocation | null} from
446
+ * The source route location.
447
+ * @property {ComponentDefinition | null} layoutComponent
448
+ * The layout component being rendered.
449
+ * @property {ComponentDefinition} pageComponent
450
+ * The page component being rendered.
451
+ * @description Passed to render events.
452
+ */ /**
453
+ * Context object for scroll events.
454
+ * @typedef {Object} ScrollContext
455
+ * @property {RouteLocation} to
456
+ * The target route location.
457
+ * @property {RouteLocation | null} from
458
+ * The source route location.
459
+ * @property {{x: number, y: number} | null} savedPosition
460
+ * Saved position (back/forward nav).
461
+ * @description Passed to scroll events for plugins to handle scroll behavior.
462
+ */ /**
463
+ * A component that can be rendered for a route.
464
+ * - `string`: Name of a registered component
465
+ * - `ComponentDefinition`: Inline component definition
466
+ * - `() => ComponentDefinition`: Factory function returning a component
467
+ * - `() => Promise<ComponentDefinition>`: Async factory function
468
+ * - `() => Promise<{default: ComponentDefinition}>`: Lazy-loaded module (e.g., `() => import('./Page.js')`)
469
+ * @typedef {string | ComponentDefinition | (() => ComponentDefinition | Promise<ComponentDefinition | {default: ComponentDefinition}>)} RouteComponent
470
+ */ /**
471
+ * Defines a route in the application.
472
+ * @typedef {Object} RouteDefinition
473
+ * @property {string} path
474
+ * URL path pattern. Supports:
475
+ * - Static: '/about'
476
+ * - Dynamic params: '/users/:id'
477
+ * - Wildcard: '*' (catch-all, conventionally last)
478
+ * @property {RouteComponent} component
479
+ * The component to render for this route.
480
+ * @property {RouteComponent} [layout]
481
+ * Optional layout component to wrap the route component.
482
+ * @property {string} [name]
483
+ * Optional route name for programmatic navigation.
484
+ * @property {RouteMeta} [meta]
485
+ * Optional metadata (auth flags, titles, etc.).
486
+ * @property {NavigationGuard} [beforeEnter]
487
+ * Route-specific guard before entering.
488
+ * @property {NavigationHook} [afterEnter]
489
+ * Hook after entering and component is mounted.
490
+ * @property {NavigationGuard} [beforeLeave]
491
+ * Guard before leaving this route.
492
+ * @property {NavigationHook} [afterLeave]
493
+ * Hook after leaving and component is unmounted.
494
+ * @property {RouteSegment[]} [segments]
495
+ * Internal: parsed path segments (added by router).
496
+ * @description Defines a route in the application.
497
+ * @note Nested routes are not supported. Use shared layouts with flat routes instead.
498
+ * @example
499
+ * // Static route
500
+ * { path: '/about', component: AboutPage }
501
+ *
502
+ * // Dynamic route with params
503
+ * { path: '/users/:id', component: UserPage, meta: { requiresAuth: true } }
504
+ *
505
+ * // Lazy-loaded route with layout
506
+ * {
507
+ * path: '/dashboard',
508
+ * component: () => import('./Dashboard.js'),
509
+ * layout: DashboardLayout,
510
+ * beforeEnter: (to, from) => isLoggedIn() || '/login'
511
+ * }
512
+ *
513
+ * // Catch-all 404 route (conventionally last)
514
+ * { path: '*', component: NotFoundPage }
515
+ */ /**
516
+ * @class 🛤️ Router
517
+ * @classdesc A powerful, reactive, and flexible Router Plugin for Eleva.
518
+ * This class manages all routing logic, including state, navigation, and rendering.
519
+ *
520
+ * ## Features
521
+ * - Multiple routing modes (hash, history, query)
522
+ * - Reactive route state via Signals
523
+ * - Navigation guards and lifecycle hooks
524
+ * - Lazy-loaded components
525
+ * - Layout system
526
+ * - Plugin architecture
527
+ * - Scroll position management
528
+ *
529
+ * ## Events Reference
530
+ * | Event | Callback Type | Can Block | Description |
531
+ * |-------|--------------|-----------|-------------|
532
+ * | `router:ready` | {@link RouterReadyCallback} | No | Router initialized |
533
+ * | `router:beforeEach` | {@link NavigationContextCallback} | Yes | Before guards run |
534
+ * | `router:beforeResolve` | {@link ResolveContextCallback} | Yes | Before component loading |
535
+ * | `router:afterResolve` | {@link ResolveContextCallback} | No | After components loaded |
536
+ * | `router:afterLeave` | {@link RouteChangeCallback} | No | After leaving route |
537
+ * | `router:beforeRender` | {@link RenderContextCallback} | No | Before DOM update |
538
+ * | `router:afterRender` | {@link RenderContextCallback} | No | After DOM update |
539
+ * | `router:scroll` | {@link ScrollContextCallback} | No | For scroll behavior |
540
+ * | `router:afterEnter` | {@link RouteChangeCallback} | No | After entering route |
541
+ * | `router:afterEach` | {@link RouteChangeCallback} | No | Navigation complete |
542
+ * | `router:error` | {@link RouterErrorCallback} | No | Navigation error |
543
+ * | `router:routeAdded` | {@link RouteAddedCallback} | No | Dynamic route added |
544
+ * | `router:routeRemoved` | {@link RouteRemovedCallback} | No | Dynamic route removed |
545
+ *
546
+ * ## Reactive Signals
547
+ * - `currentRoute: Signal<RouteLocation | null>` - Current route info
548
+ * - `previousRoute: Signal<RouteLocation | null>` - Previous route info
549
+ * - `currentParams: Signal<RouteParams>` - Current route params
550
+ * - `currentQuery: Signal<QueryParams>` - Current query params
551
+ * - `currentLayout: Signal<MountResult | null>` - Mounted layout instance
552
+ * - `currentView: Signal<MountResult | null>` - Mounted view instance
553
+ * - `isReady: Signal<boolean>` - Router readiness state
554
+ *
555
+ * @note Internal API Access Policy:
556
+ * As a core Eleva plugin, the Router may access internal Eleva APIs (prefixed with _)
557
+ * such as `eleva._components`. This is intentional and these internal APIs are
558
+ * considered stable for official plugins. Third-party plugins should avoid
559
+ * accessing internal APIs as they may change without notice.
560
+ *
561
+ * @example
562
+ * // Basic setup
563
+ * const router = new Router(eleva, {
564
+ * mode: 'hash',
565
+ * mount: '#app',
566
+ * routes: [
567
+ * { path: '/', component: HomePage },
568
+ * { path: '/users/:id', component: UserPage },
569
+ * { path: '*', component: NotFoundPage }
570
+ * ]
571
+ * });
572
+ *
573
+ * // Start router
574
+ * await router.start();
575
+ *
576
+ * // Navigate programmatically
577
+ * const success = await router.navigate('/users/123');
578
+ *
579
+ * // Watch for route changes
580
+ * router.currentRoute.watch((route) => {
581
+ * document.title = route?.meta?.title || 'My App';
582
+ * });
583
+ *
584
+ * @private
585
+ */ class Router {
586
+ /**
587
+ * Validates the provided router options.
588
+ * @private
589
+ * @throws {Error} If the routing mode is invalid.
590
+ */ _validateOptions() {
591
+ if (![
592
+ "hash",
593
+ "query",
594
+ "history"
595
+ ].includes(this.options.mode)) {
596
+ this.errorHandler.handle(new Error(`Invalid routing mode: ${this.options.mode}. Must be "hash", "query", or "history".`), "Configuration validation failed");
597
+ }
598
+ }
599
+ /**
600
+ * Pre-processes route definitions to parse their path segments for efficient matching.
601
+ * @private
602
+ * @param {RouteDefinition[]} routes - The raw route definitions.
603
+ * @returns {RouteDefinition[]} The processed routes.
604
+ */ _processRoutes(routes) {
605
+ const processedRoutes = [];
606
+ for (const route of routes){
607
+ try {
608
+ processedRoutes.push({
609
+ ...route,
610
+ segments: this._parsePathIntoSegments(route.path)
611
+ });
612
+ } catch (error) {
613
+ this.errorHandler.warn(`Invalid path in route definition "${route.path || "undefined"}": ${error.message}`, {
614
+ route,
615
+ error
616
+ });
617
+ }
618
+ }
619
+ return processedRoutes;
620
+ }
621
+ /**
622
+ * Parses a route path string into an array of static and parameter segments.
623
+ * @private
624
+ * @param {string} path - The path pattern to parse.
625
+ * @returns {{type: 'static' | 'param', value?: string, name?: string}[]} An array of segment objects.
626
+ * @throws {Error} If the route path is not a valid string.
627
+ */ _parsePathIntoSegments(path) {
628
+ if (!path || typeof path !== "string") {
629
+ this.errorHandler.handle(new Error("Route path must be a non-empty string"), "Path parsing failed", {
630
+ path
631
+ });
632
+ }
633
+ const normalizedPath = path.replace(/\/+/g, "/").replace(/\/$/, "") || "/";
634
+ if (normalizedPath === "/") {
635
+ return [];
636
+ }
637
+ return normalizedPath.split("/").filter(Boolean).map((segment)=>{
638
+ if (segment.startsWith(":")) {
639
+ const paramName = segment.substring(1);
640
+ if (!paramName) {
641
+ this.errorHandler.handle(new Error(`Invalid parameter segment: ${segment}`), "Path parsing failed", {
642
+ segment,
643
+ path
644
+ });
645
+ }
646
+ return {
647
+ type: "param",
648
+ name: paramName
649
+ };
650
+ }
651
+ return {
652
+ type: "static",
653
+ value: segment
654
+ };
655
+ });
656
+ }
657
+ /**
658
+ * Finds the view element within a container using multiple selector strategies.
659
+ * @private
660
+ * @param {HTMLElement} container - The parent element to search within.
661
+ * @returns {HTMLElement} The found view element or the container itself as a fallback.
662
+ */ _findViewElement(container) {
663
+ const selector = this.options.viewSelector;
664
+ return container.querySelector(`#${selector}`) || container.querySelector(`.${selector}`) || container.querySelector(`[data-${selector}]`) || container.querySelector(selector) || container;
665
+ }
666
+ /**
667
+ * Starts the router, initializes event listeners, and performs the initial navigation.
668
+ * @returns {Promise<Router>} The router instance for method chaining.
669
+ * @listens window:hashchange In hash mode, triggers route changes.
670
+ * @listens window:popstate In history/query mode, triggers route changes.
671
+ * @emits router:ready When initialization completes successfully.
672
+ * @see destroy - Stop the router and clean up listeners.
673
+ * @see navigate - Programmatically navigate to a route.
674
+ *
675
+ * @example
676
+ * // Basic usage
677
+ * await router.start();
678
+ *
679
+ * // Method chaining
680
+ * await router.start().then(r => r.navigate('/home'));
681
+ *
682
+ * // Reactive readiness
683
+ * router.isReady.watch((ready) => {
684
+ * if (ready) console.log('Router is ready!');
685
+ * });
686
+ */ async start() {
687
+ if (this.isStarted) {
688
+ this.errorHandler.warn("Router is already started");
689
+ return this;
690
+ }
691
+ if (typeof window === "undefined") {
692
+ this.errorHandler.warn("Router start skipped: `window` object not available (SSR environment)");
693
+ return this;
694
+ }
695
+ if (typeof document !== "undefined" && !document.querySelector(this.options.mount)) {
696
+ this.errorHandler.warn(`Mount element "${this.options.mount}" was not found in the DOM. The router will not start.`, {
697
+ mountSelector: this.options.mount
698
+ });
699
+ return this;
700
+ }
701
+ const handler = ()=>this._handleRouteChange();
702
+ if (this.options.mode === "hash") {
703
+ window.addEventListener("hashchange", handler);
704
+ this.eventListeners.push(()=>window.removeEventListener("hashchange", handler));
705
+ } else {
706
+ window.addEventListener("popstate", handler);
707
+ this.eventListeners.push(()=>window.removeEventListener("popstate", handler));
708
+ }
709
+ this.isStarted = true;
710
+ // Initial navigation is not a popstate event
711
+ await this._handleRouteChange(false);
712
+ // Set isReady to true after initial navigation completes
713
+ this.isReady.value = true;
714
+ await this.emitter.emit("router:ready", this);
715
+ return this;
716
+ }
717
+ /**
718
+ * Stops the router and cleans up event listeners.
719
+ * Unmounts the current layout instance if present.
720
+ * @async
721
+ * @returns {Promise<void>}
722
+ * @see start - Restart the router after destroying.
723
+ */ async destroy() {
724
+ if (!this.isStarted) return;
725
+ // Clean up plugins
726
+ for (const plugin of this.plugins.values()){
727
+ if (typeof plugin.destroy === "function") {
728
+ try {
729
+ await plugin.destroy(this);
730
+ } catch (error) {
731
+ this.errorHandler.log(`Plugin ${plugin.name} destroy failed`, error);
732
+ }
733
+ }
734
+ }
735
+ this.eventListeners.forEach((cleanup)=>cleanup());
736
+ this.eventListeners = [];
737
+ if (this.currentLayout.value) {
738
+ await this.currentLayout.value.unmount();
739
+ }
740
+ this.isStarted = false;
741
+ this.isReady.value = false;
742
+ }
743
+ /**
744
+ * Alias for destroy(). Stops the router and cleans up all resources.
745
+ * Provided for semantic consistency (start/stop pattern).
746
+ * @async
747
+ * @returns {Promise<void>}
748
+ *
749
+ * @example
750
+ * await router.start();
751
+ * // ... later
752
+ * await router.stop();
753
+ */ async stop() {
754
+ return this.destroy();
755
+ }
756
+ /**
757
+ * Programmatically navigates to a new route.
758
+ * @async
759
+ * @param {string | NavigationTarget} location - The target location as a path string or navigation target object.
760
+ * @param {NavigationParams} [params] - Route parameters (only used when location is a string).
761
+ * @returns {Promise<boolean>} True if navigation succeeded, false if blocked by guards or failed.
762
+ * @emits router:error When navigation fails due to an exception.
763
+ * @see start - Initialize the router before navigating.
764
+ * @see currentRoute - Access the current route after navigation.
765
+ *
766
+ * @example
767
+ * // Basic navigation
768
+ * await router.navigate('/users/123');
769
+ *
770
+ * // Check if navigation succeeded
771
+ * const success = await router.navigate('/protected');
772
+ * if (!success) {
773
+ * console.log('Navigation was blocked by a guard');
774
+ * }
775
+ *
776
+ * // Navigate with options
777
+ * await router.navigate({
778
+ * path: '/users/:id',
779
+ * params: { id: '123' },
780
+ * query: { tab: 'profile' },
781
+ * replace: true
782
+ * });
783
+ */ async navigate(location, params = {}) {
784
+ try {
785
+ const target = typeof location === "string" ? {
786
+ path: location,
787
+ params
788
+ } : location;
789
+ let path = this._buildPath(target.path, target.params || {});
790
+ const query = target.query || {};
791
+ if (Object.keys(query).length > 0) {
792
+ const queryString = new URLSearchParams(query).toString();
793
+ if (queryString) path += `?${queryString}`;
794
+ }
795
+ if (this._isSameRoute(path, target.params, query)) {
796
+ return true; // Already at this route, consider it successful
797
+ }
798
+ const navigationSuccessful = await this._proceedWithNavigation(path);
799
+ if (navigationSuccessful) {
800
+ // Increment navigation ID and capture it for this navigation
801
+ const currentNavId = ++this._navigationId;
802
+ this._isNavigating = true;
803
+ const state = target.state || {};
804
+ const replace = target.replace || false;
805
+ const historyMethod = replace ? "replaceState" : "pushState";
806
+ if (this.options.mode === "hash") {
807
+ if (replace) {
808
+ const newUrl = `${window.location.pathname}${window.location.search}#${path}`;
809
+ window.history.replaceState(state, "", newUrl);
810
+ } else {
811
+ window.location.hash = path;
812
+ }
813
+ } else {
814
+ const url = this.options.mode === "query" ? this._buildQueryUrl(path) : path;
815
+ history[historyMethod](state, "", url);
816
+ }
817
+ // Only reset the flag if no newer navigation has started
818
+ queueMicrotask(()=>{
819
+ if (this._navigationId === currentNavId) {
820
+ this._isNavigating = false;
821
+ }
822
+ });
823
+ }
824
+ return navigationSuccessful;
825
+ } catch (error) {
826
+ this.errorHandler.log("Navigation failed", error);
827
+ await this.emitter.emit("router:error", error);
828
+ return false;
829
+ }
830
+ }
831
+ /**
832
+ * Builds a URL for query mode.
833
+ * @private
834
+ * @param {string} path - The path to set as the query parameter.
835
+ * @returns {string} The full URL with the updated query string.
836
+ */ _buildQueryUrl(path) {
837
+ const urlParams = new URLSearchParams(window.location.search);
838
+ urlParams.set(this.options.queryParam, path.split("?")[0]);
839
+ return `${window.location.pathname}?${urlParams.toString()}`;
840
+ }
841
+ /**
842
+ * Checks if the target route is identical to the current route.
843
+ * @private
844
+ * @param {string} path - The target path with query string.
845
+ * @param {object} params - The target params.
846
+ * @param {object} query - The target query.
847
+ * @returns {boolean} True if the routes are the same.
848
+ */ _isSameRoute(path, params, query) {
849
+ const current = this.currentRoute.value;
850
+ if (!current) return false;
851
+ const [targetPath, queryString] = path.split("?");
852
+ const targetQuery = query || this._parseQuery(queryString || "");
853
+ return current.path === targetPath && JSON.stringify(current.params) === JSON.stringify(params || {}) && JSON.stringify(current.query) === JSON.stringify(targetQuery);
854
+ }
855
+ /**
856
+ * Injects dynamic parameters into a path string.
857
+ * Replaces `:param` placeholders with URL-encoded values from the params object.
858
+ *
859
+ * @private
860
+ * @param {string} path - The path pattern containing `:param` placeholders.
861
+ * @param {RouteParams} params - Key-value pairs to inject into the path.
862
+ * @returns {string} The path with all parameters replaced.
863
+ *
864
+ * @example
865
+ * this._buildPath('/users/:id/posts/:postId', { id: '123', postId: '456' });
866
+ * // Returns: '/users/123/posts/456'
867
+ */ _buildPath(path, params) {
868
+ let result = path;
869
+ for (const [key, value] of Object.entries(params)){
870
+ // Fix: Handle special characters and ensure proper encoding
871
+ const encodedValue = encodeURIComponent(String(value));
872
+ result = result.replace(new RegExp(`:${key}\\b`, "g"), encodedValue);
873
+ }
874
+ return result;
875
+ }
876
+ /**
877
+ * The handler for browser-initiated route changes (e.g., back/forward buttons).
878
+ *
879
+ * @private
880
+ * @async
881
+ * @param {boolean} [isPopState=true] - Whether this is a popstate event (back/forward navigation).
882
+ * @returns {Promise<void>}
883
+ * @emits router:error When route change handling fails.
884
+ */ async _handleRouteChange(isPopState = true) {
885
+ if (this._isNavigating) return;
886
+ try {
887
+ const from = this.currentRoute.value;
888
+ const toLocation = this._getCurrentLocation();
889
+ const navigationSuccessful = await this._proceedWithNavigation(toLocation.fullUrl, isPopState);
890
+ // If navigation was blocked by a guard, revert the URL change
891
+ if (!navigationSuccessful && from) {
892
+ this.navigate({
893
+ path: from.path,
894
+ query: from.query,
895
+ replace: true
896
+ });
897
+ }
898
+ } catch (error) {
899
+ this.errorHandler.log("Route change handling failed", error, {
900
+ currentUrl: typeof window !== "undefined" ? window.location.href : ""
901
+ });
902
+ await this.emitter.emit("router:error", error);
903
+ }
904
+ }
905
+ /**
906
+ * Manages the core navigation lifecycle. Runs guards before committing changes.
907
+ *
908
+ * @private
909
+ * @async
910
+ * @param {string} fullPath - The full path (e.g., '/users/123?foo=bar') to navigate to.
911
+ * @param {boolean} [isPopState=false] - Whether this navigation was triggered by popstate (back/forward).
912
+ * @returns {Promise<boolean>} `true` if navigation succeeded, `false` if aborted.
913
+ * @emits router:notFound When no matching route is found.
914
+ * @emits router:beforeResolve Before component resolution (can block/redirect).
915
+ * @emits router:afterResolve After components are resolved.
916
+ * @emits router:afterLeave After leaving the previous route.
917
+ * @emits router:beforeRender Before DOM rendering.
918
+ * @emits router:afterRender After DOM rendering completes.
919
+ * @emits router:scroll After render, for scroll behavior handling.
920
+ * @emits router:afterEnter After entering the new route.
921
+ * @emits router:afterEach After navigation completes successfully.
922
+ * @emits router:error When an error occurs during navigation.
923
+ * @see _runGuards - Guard execution.
924
+ * @see _resolveComponents - Component resolution.
925
+ * @see _render - DOM rendering.
926
+ */ async _proceedWithNavigation(fullPath, isPopState = false) {
927
+ const from = this.currentRoute.value;
928
+ const [path, queryString] = (fullPath || "/").split("?");
929
+ const toLocation = {
930
+ path: path.startsWith("/") ? path : `/${path}`,
931
+ query: this._parseQuery(queryString),
932
+ fullUrl: fullPath
933
+ };
934
+ let toMatch = this._matchRoute(toLocation.path);
935
+ if (!toMatch) {
936
+ const notFoundRoute = this.routes.find((route)=>route.path === "*");
937
+ if (notFoundRoute) {
938
+ toMatch = {
939
+ route: notFoundRoute,
940
+ params: {
941
+ pathMatch: decodeURIComponent(toLocation.path.substring(1))
942
+ }
943
+ };
944
+ } else {
945
+ await this.emitter.emit("router:error", new Error(`Route not found: ${toLocation.path}`), toLocation, from);
946
+ return false;
947
+ }
948
+ }
949
+ const to = {
950
+ ...toLocation,
951
+ params: toMatch.params,
952
+ meta: toMatch.route.meta || {},
953
+ name: toMatch.route.name,
954
+ matched: toMatch.route
955
+ };
956
+ try {
957
+ // 1. Run all *pre-navigation* guards.
958
+ const canNavigate = await this._runGuards(to, from, toMatch.route);
959
+ if (!canNavigate) return false;
960
+ // 2. Save current scroll position before navigating away
961
+ if (from && typeof window !== "undefined") {
962
+ this._scrollPositions.set(from.path, {
963
+ x: window.scrollX || window.pageXOffset || 0,
964
+ y: window.scrollY || window.pageYOffset || 0
965
+ });
966
+ }
967
+ // 3. Emit beforeResolve event - plugins can show loading indicators
968
+ /** @type {ResolveContext} */ const resolveContext = {
969
+ to,
970
+ from,
971
+ route: toMatch.route,
972
+ layoutComponent: null,
973
+ pageComponent: null,
974
+ cancelled: false,
975
+ redirectTo: null
976
+ };
977
+ await this.emitter.emit("router:beforeResolve", resolveContext);
978
+ // Check if resolution was cancelled or redirected
979
+ if (resolveContext.cancelled) return false;
980
+ if (resolveContext.redirectTo) {
981
+ this.navigate(resolveContext.redirectTo);
982
+ return false;
983
+ }
984
+ // 4. Resolve async components *before* touching the DOM.
985
+ const { layoutComponent, pageComponent } = await this._resolveComponents(toMatch.route);
986
+ // 5. Emit afterResolve event - plugins can hide loading indicators
987
+ resolveContext.layoutComponent = layoutComponent;
988
+ resolveContext.pageComponent = pageComponent;
989
+ await this.emitter.emit("router:afterResolve", resolveContext);
990
+ // 6. Unmount the previous view/layout.
991
+ if (from) {
992
+ const toLayout = toMatch.route.layout || this.options.globalLayout;
993
+ const fromLayout = from.matched.layout || this.options.globalLayout;
994
+ const tryUnmount = async (instance)=>{
995
+ if (!instance) return;
996
+ try {
997
+ await instance.unmount();
998
+ } catch (error) {
999
+ this.errorHandler.warn("Error during component unmount", {
1000
+ error,
1001
+ instance
1002
+ });
1003
+ }
1004
+ };
1005
+ if (toLayout !== fromLayout) {
1006
+ await tryUnmount(this.currentLayout.value);
1007
+ this.currentLayout.value = null;
1008
+ } else {
1009
+ await tryUnmount(this.currentView.value);
1010
+ this.currentView.value = null;
1011
+ }
1012
+ // Call `afterLeave` hook *after* the old component has been unmounted.
1013
+ if (from.matched.afterLeave) {
1014
+ await from.matched.afterLeave(to, from);
1015
+ }
1016
+ await this.emitter.emit("router:afterLeave", to, from);
1017
+ }
1018
+ // 7. Update reactive state.
1019
+ this.previousRoute.value = from;
1020
+ this.currentRoute.value = to;
1021
+ this.currentParams.value = to.params || {};
1022
+ this.currentQuery.value = to.query || {};
1023
+ // 8. Emit beforeRender event - plugins can add transitions
1024
+ /** @type {RenderContext} */ const renderContext = {
1025
+ to,
1026
+ from,
1027
+ layoutComponent,
1028
+ pageComponent
1029
+ };
1030
+ await this.emitter.emit("router:beforeRender", renderContext);
1031
+ // 9. Render the new components.
1032
+ await this._render(layoutComponent, pageComponent);
1033
+ // 10. Emit afterRender event - plugins can trigger animations
1034
+ await this.emitter.emit("router:afterRender", renderContext);
1035
+ // 11. Emit scroll event - plugins can handle scroll restoration
1036
+ /** @type {ScrollContext} */ const scrollContext = {
1037
+ to,
1038
+ from,
1039
+ savedPosition: isPopState ? this._scrollPositions.get(to.path) || null : null
1040
+ };
1041
+ await this.emitter.emit("router:scroll", scrollContext);
1042
+ // 12. Run post-navigation hooks.
1043
+ if (toMatch.route.afterEnter) {
1044
+ await toMatch.route.afterEnter(to, from);
1045
+ }
1046
+ await this.emitter.emit("router:afterEnter", to, from);
1047
+ await this.emitter.emit("router:afterEach", to, from);
1048
+ return true;
1049
+ } catch (error) {
1050
+ this.errorHandler.log("Error during navigation", error, {
1051
+ to,
1052
+ from
1053
+ });
1054
+ await this.emitter.emit("router:error", error, to, from);
1055
+ return false;
1056
+ }
1057
+ }
1058
+ /**
1059
+ * Executes all applicable navigation guards for a transition in order.
1060
+ * Guards are executed in the following order:
1061
+ * 1. Global beforeEach event (emitter-based, can block via context)
1062
+ * 2. Global beforeEach guards (registered via onBeforeEach)
1063
+ * 3. Route-specific beforeLeave guard (from the route being left)
1064
+ * 4. Route-specific beforeEnter guard (from the route being entered)
1065
+ *
1066
+ * @private
1067
+ * @param {RouteLocation} to - The target route location.
1068
+ * @param {RouteLocation | null} from - The current route location (null on initial navigation).
1069
+ * @param {RouteDefinition} route - The matched route definition.
1070
+ * @returns {Promise<boolean>} `false` if navigation should be aborted.
1071
+ * @emits router:beforeEach Before guards run (can block/redirect via context).
1072
+ */ async _runGuards(to, from, route) {
1073
+ // Create navigation context that plugins can modify to block navigation
1074
+ /** @type {NavigationContext} */ const navContext = {
1075
+ to,
1076
+ from,
1077
+ cancelled: false,
1078
+ redirectTo: null
1079
+ };
1080
+ // Emit beforeEach event with context - plugins can block by modifying context
1081
+ await this.emitter.emit("router:beforeEach", navContext);
1082
+ // Check if navigation was cancelled or redirected by event listeners
1083
+ if (navContext.cancelled) return false;
1084
+ if (navContext.redirectTo) {
1085
+ this.navigate(navContext.redirectTo);
1086
+ return false;
1087
+ }
1088
+ // Collect all guards in execution order
1089
+ const guards = [
1090
+ ...this._beforeEachGuards,
1091
+ ...from && from.matched.beforeLeave ? [
1092
+ from.matched.beforeLeave
1093
+ ] : [],
1094
+ ...route.beforeEnter ? [
1095
+ route.beforeEnter
1096
+ ] : []
1097
+ ];
1098
+ for (const guard of guards){
1099
+ const result = await guard(to, from);
1100
+ if (result === false) return false;
1101
+ if (typeof result === "string" || typeof result === "object") {
1102
+ this.navigate(result);
1103
+ return false;
1104
+ }
1105
+ }
1106
+ return true;
1107
+ }
1108
+ /**
1109
+ * Resolves a string component definition to a component object.
1110
+ * @private
1111
+ * @param {string} def - The component name to resolve.
1112
+ * @returns {ComponentDefinition} The resolved component.
1113
+ * @throws {Error} If the component is not registered.
1114
+ *
1115
+ * @note Core plugins (Router, Attr, Store) may access eleva._components
1116
+ * directly. This is intentional and stable for official Eleva plugins shipped
1117
+ * with the framework. Third-party plugins should use eleva.component() for
1118
+ * registration and avoid direct access to internal APIs.
1119
+ */ _resolveStringComponent(def) {
1120
+ const componentDef = this.eleva._components.get(def);
1121
+ if (!componentDef) {
1122
+ this.errorHandler.handle(new Error(`Component "${def}" not registered.`), "Component resolution failed", {
1123
+ componentName: def,
1124
+ availableComponents: Array.from(this.eleva._components.keys())
1125
+ });
1126
+ }
1127
+ return componentDef;
1128
+ }
1129
+ /**
1130
+ * Resolves a function component definition to a component object.
1131
+ * @private
1132
+ * @async
1133
+ * @param {() => ComponentDefinition | Promise<ComponentDefinition | { default: ComponentDefinition }>} def - The function to resolve.
1134
+ * @returns {Promise<ComponentDefinition>} The resolved component.
1135
+ * @throws {Error} If the function fails to load the component.
1136
+ */ async _resolveFunctionComponent(def) {
1137
+ try {
1138
+ const funcStr = def.toString();
1139
+ const isAsyncImport = funcStr.includes("import(") || funcStr.startsWith("() =>");
1140
+ const result = await def();
1141
+ return isAsyncImport ? result.default || result : result;
1142
+ } catch (error) {
1143
+ this.errorHandler.handle(new Error(`Failed to load async component: ${error.message}`), "Component resolution failed", {
1144
+ function: def.toString(),
1145
+ error
1146
+ });
1147
+ }
1148
+ }
1149
+ /**
1150
+ * Validates a component definition object.
1151
+ * @private
1152
+ * @param {unknown} def - The component definition to validate.
1153
+ * @returns {ComponentDefinition} The validated component.
1154
+ * @throws {Error} If the component definition is invalid.
1155
+ */ _validateComponentDefinition(def) {
1156
+ if (!def || typeof def !== "object") {
1157
+ this.errorHandler.handle(new Error(`Invalid component definition: ${typeof def}`), "Component validation failed", {
1158
+ definition: def
1159
+ });
1160
+ }
1161
+ if (typeof def.template !== "function" && typeof def.template !== "string") {
1162
+ this.errorHandler.handle(new Error("Component missing template property"), "Component validation failed", {
1163
+ definition: def
1164
+ });
1165
+ }
1166
+ return def;
1167
+ }
1168
+ /**
1169
+ * Resolves a component definition to a component object.
1170
+ * @private
1171
+ * @param {unknown} def - The component definition to resolve.
1172
+ * @returns {Promise<ComponentDefinition | null>} The resolved component or null.
1173
+ */ async _resolveComponent(def) {
1174
+ if (def === null || def === undefined) {
1175
+ return null;
1176
+ }
1177
+ if (typeof def === "string") {
1178
+ return this._resolveStringComponent(def);
1179
+ }
1180
+ if (typeof def === "function") {
1181
+ return await this._resolveFunctionComponent(def);
1182
+ }
1183
+ if (def && typeof def === "object") {
1184
+ return this._validateComponentDefinition(def);
1185
+ }
1186
+ this.errorHandler.handle(new Error(`Invalid component definition: ${typeof def}`), "Component resolution failed", {
1187
+ definition: def
1188
+ });
1189
+ }
1190
+ /**
1191
+ * Asynchronously resolves the layout and page components for a route.
1192
+ * @private
1193
+ * @async
1194
+ * @param {RouteDefinition} route - The route to resolve components for.
1195
+ * @returns {Promise<{layoutComponent: ComponentDefinition | null, pageComponent: ComponentDefinition}>}
1196
+ * @throws {Error} If page component cannot be resolved.
1197
+ */ async _resolveComponents(route) {
1198
+ const effectiveLayout = route.layout || this.options.globalLayout;
1199
+ try {
1200
+ const [layoutComponent, pageComponent] = await Promise.all([
1201
+ this._resolveComponent(effectiveLayout),
1202
+ this._resolveComponent(route.component)
1203
+ ]);
1204
+ if (!pageComponent) {
1205
+ this.errorHandler.handle(new Error(`Page component is null or undefined for route: ${route.path}`), "Component resolution failed", {
1206
+ route: route.path
1207
+ });
1208
+ }
1209
+ return {
1210
+ layoutComponent,
1211
+ pageComponent
1212
+ };
1213
+ } catch (error) {
1214
+ this.errorHandler.log(`Error resolving components for route ${route.path}`, error, {
1215
+ route: route.path
1216
+ });
1217
+ throw error;
1218
+ }
1219
+ }
1220
+ /**
1221
+ * Renders the components for the current route into the DOM.
1222
+ *
1223
+ * Rendering algorithm:
1224
+ * 1. Find the mount element using options.mount selector
1225
+ * 2. If layoutComponent exists:
1226
+ * a. Mount layout to mount element
1227
+ * b. Find view element within layout (using viewSelector)
1228
+ * c. Mount page component to view element
1229
+ * 3. If no layoutComponent:
1230
+ * a. Mount page component directly to mount element
1231
+ * b. Set currentLayout to null
1232
+ *
1233
+ * @private
1234
+ * @async
1235
+ * @param {ComponentDefinition | null} layoutComponent - The pre-loaded layout component.
1236
+ * @param {ComponentDefinition} pageComponent - The pre-loaded page component.
1237
+ * @returns {Promise<void>}
1238
+ * @throws {Error} If mount element is not found in the DOM.
1239
+ * @throws {Error} If component mounting fails (propagated from eleva.mount).
1240
+ */ async _render(layoutComponent, pageComponent) {
1241
+ const mountEl = document.querySelector(this.options.mount);
1242
+ if (!mountEl) {
1243
+ this.errorHandler.handle(new Error(`Mount element "${this.options.mount}" not found.`), {
1244
+ mountSelector: this.options.mount
1245
+ });
1246
+ }
1247
+ if (layoutComponent) {
1248
+ const layoutInstance = await this.eleva.mount(mountEl, this._wrapComponentWithChildren(layoutComponent));
1249
+ this.currentLayout.value = layoutInstance;
1250
+ const viewEl = this._findViewElement(layoutInstance.container);
1251
+ const viewInstance = await this.eleva.mount(viewEl, this._wrapComponentWithChildren(pageComponent));
1252
+ this.currentView.value = viewInstance;
1253
+ } else {
1254
+ const viewInstance = await this.eleva.mount(mountEl, this._wrapComponentWithChildren(pageComponent));
1255
+ this.currentView.value = viewInstance;
1256
+ this.currentLayout.value = null;
1257
+ }
1258
+ }
1259
+ /**
1260
+ * Creates a getter function for router context properties.
1261
+ * @private
1262
+ * @param {string} property - The property name to access.
1263
+ * @param {unknown} defaultValue - The default value if property is undefined.
1264
+ * @returns {() => unknown} A getter function.
1265
+ */ _createRouteGetter(property, defaultValue) {
1266
+ return ()=>this.currentRoute.value?.[property] ?? defaultValue;
1267
+ }
1268
+ /**
1269
+ * Wraps a component definition to inject router-specific context into its setup function.
1270
+ * @private
1271
+ * @param {ComponentDefinition} component - The component to wrap.
1272
+ * @returns {ComponentDefinition} The wrapped component definition.
1273
+ */ _wrapComponent(component) {
1274
+ const originalSetup = component.setup;
1275
+ const self = this;
1276
+ return {
1277
+ ...component,
1278
+ async setup (ctx) {
1279
+ /** @type {RouterContext} */ ctx.router = {
1280
+ navigate: self.navigate.bind(self),
1281
+ current: self.currentRoute,
1282
+ previous: self.previousRoute,
1283
+ // Route property getters
1284
+ get params () {
1285
+ return self._createRouteGetter("params", {})();
1286
+ },
1287
+ get query () {
1288
+ return self._createRouteGetter("query", {})();
1289
+ },
1290
+ get path () {
1291
+ return self._createRouteGetter("path", "/")();
1292
+ },
1293
+ get fullUrl () {
1294
+ return self._createRouteGetter("fullUrl", window.location.href)();
1295
+ },
1296
+ get meta () {
1297
+ return self._createRouteGetter("meta", {})();
1298
+ }
1299
+ };
1300
+ return originalSetup ? await originalSetup(ctx) : {};
1301
+ }
1302
+ };
1303
+ }
1304
+ /**
1305
+ * Recursively wraps all child components to ensure they have access to router context.
1306
+ * String component references are returned as-is (context injected during mount).
1307
+ * Objects are wrapped with router context and their children are recursively wrapped.
1308
+ *
1309
+ * @private
1310
+ * @param {ComponentDefinition | string} component - The component to wrap (can be a definition object or a registered component name).
1311
+ * @returns {ComponentDefinition | string} The wrapped component definition or the original string reference.
1312
+ * @see _wrapComponent - Single component wrapping.
1313
+ */ _wrapComponentWithChildren(component) {
1314
+ // If the component is a string (registered component name), return as-is
1315
+ // The router context will be injected when the component is resolved during mounting
1316
+ if (typeof component === "string") {
1317
+ return component;
1318
+ }
1319
+ // If not a valid component object, return as-is
1320
+ if (!component || typeof component !== "object") {
1321
+ return component;
1322
+ }
1323
+ const wrappedComponent = this._wrapComponent(component);
1324
+ // If the component has children, wrap them too
1325
+ if (wrappedComponent.children && typeof wrappedComponent.children === "object") {
1326
+ const wrappedChildren = {};
1327
+ for (const [selector, childComponent] of Object.entries(wrappedComponent.children)){
1328
+ wrappedChildren[selector] = this._wrapComponentWithChildren(childComponent);
1329
+ }
1330
+ wrappedComponent.children = wrappedChildren;
1331
+ }
1332
+ return wrappedComponent;
1333
+ }
1334
+ /**
1335
+ * Gets the current location information from the browser's window object.
1336
+ * @private
1337
+ * @returns {Omit<RouteLocation, 'params' | 'meta' | 'name' | 'matched'>}
1338
+ */ _getCurrentLocation() {
1339
+ if (typeof window === "undefined") return {
1340
+ path: "/",
1341
+ query: {},
1342
+ fullUrl: ""
1343
+ };
1344
+ let path, queryString, fullUrl;
1345
+ switch(this.options.mode){
1346
+ case "hash":
1347
+ fullUrl = window.location.hash.slice(1) || "/";
1348
+ [path, queryString] = fullUrl.split("?");
1349
+ break;
1350
+ case "query":
1351
+ const urlParams = new URLSearchParams(window.location.search);
1352
+ path = urlParams.get(this.options.queryParam) || "/";
1353
+ queryString = window.location.search.slice(1);
1354
+ fullUrl = path;
1355
+ break;
1356
+ default:
1357
+ path = window.location.pathname || "/";
1358
+ queryString = window.location.search.slice(1);
1359
+ fullUrl = `${path}${queryString ? "?" + queryString : ""}`;
1360
+ }
1361
+ return {
1362
+ path: path.startsWith("/") ? path : `/${path}`,
1363
+ query: this._parseQuery(queryString),
1364
+ fullUrl
1365
+ };
1366
+ }
1367
+ /**
1368
+ * Parses a query string into a key-value object.
1369
+ * Uses URLSearchParams for robust parsing of encoded values.
1370
+ *
1371
+ * @private
1372
+ * @param {string} queryString - The query string to parse (without leading '?').
1373
+ * @returns {QueryParams} Key-value pairs from the query string.
1374
+ *
1375
+ * @example
1376
+ * this._parseQuery('foo=bar&baz=qux');
1377
+ * // Returns: { foo: 'bar', baz: 'qux' }
1378
+ */ _parseQuery(queryString) {
1379
+ const query = {};
1380
+ if (queryString) {
1381
+ new URLSearchParams(queryString).forEach((value, key)=>{
1382
+ query[key] = value;
1383
+ });
1384
+ }
1385
+ return query;
1386
+ }
1387
+ /**
1388
+ * Matches a given path against the registered routes.
1389
+ * @private
1390
+ * @param {string} path - The path to match.
1391
+ * @returns {{route: RouteDefinition, params: Object<string, string>} | null} The matched route and its params, or null.
1392
+ */ _matchRoute(path) {
1393
+ const pathSegments = path.split("/").filter(Boolean);
1394
+ for (const route of this.routes){
1395
+ // Handle the root path as a special case.
1396
+ if (route.path === "/") {
1397
+ if (pathSegments.length === 0) return {
1398
+ route,
1399
+ params: {}
1400
+ };
1401
+ continue;
1402
+ }
1403
+ if (route.segments.length !== pathSegments.length) continue;
1404
+ const params = {};
1405
+ let isMatch = true;
1406
+ for(let i = 0; i < route.segments.length; i++){
1407
+ const routeSegment = route.segments[i];
1408
+ const pathSegment = pathSegments[i];
1409
+ if (routeSegment.type === "param") {
1410
+ params[routeSegment.name] = decodeURIComponent(pathSegment);
1411
+ } else if (routeSegment.value !== pathSegment) {
1412
+ isMatch = false;
1413
+ break;
1414
+ }
1415
+ }
1416
+ if (isMatch) return {
1417
+ route,
1418
+ params
1419
+ };
1420
+ }
1421
+ return null;
1422
+ }
1423
+ // ============================================
1424
+ // Dynamic Route Management API
1425
+ // ============================================
1426
+ /**
1427
+ * Adds a new route dynamically at runtime.
1428
+ * The route will be processed and available for navigation immediately.
1429
+ * Routes are inserted before the wildcard (*) route if one exists.
1430
+ *
1431
+ * @param {RouteDefinition} route - The route definition to add.
1432
+ * @param {RouteDefinition} [parentRoute] - Optional parent route to add as a child (not yet implemented).
1433
+ * @returns {() => void} A function to remove the added route (returns no-op if route was invalid).
1434
+ * @emits router:routeAdded When a route is successfully added.
1435
+ *
1436
+ * @example
1437
+ * // Add a route dynamically
1438
+ * const removeRoute = router.addRoute({
1439
+ * path: '/dynamic',
1440
+ * component: DynamicPage,
1441
+ * meta: { title: 'Dynamic Page' }
1442
+ * });
1443
+ *
1444
+ * // Later, remove the route
1445
+ * removeRoute();
1446
+ */ addRoute(route, parentRoute = null) {
1447
+ if (!route || !route.path) {
1448
+ this.errorHandler.warn("Invalid route definition: missing path", {
1449
+ route
1450
+ });
1451
+ return ()=>{};
1452
+ }
1453
+ // Check if route already exists
1454
+ if (this.hasRoute(route.path)) {
1455
+ this.errorHandler.warn(`Route "${route.path}" already exists`, {
1456
+ route
1457
+ });
1458
+ return ()=>{};
1459
+ }
1460
+ // Process the route (parse segments)
1461
+ const processedRoute = {
1462
+ ...route,
1463
+ segments: this._parsePathIntoSegments(route.path)
1464
+ };
1465
+ // Add to routes array (before wildcard if exists)
1466
+ const wildcardIndex = this.routes.findIndex((r)=>r.path === "*");
1467
+ if (wildcardIndex !== -1) {
1468
+ this.routes.splice(wildcardIndex, 0, processedRoute);
1469
+ } else {
1470
+ this.routes.push(processedRoute);
1471
+ }
1472
+ // Emit event for plugins
1473
+ this.emitter.emit("router:routeAdded", processedRoute);
1474
+ // Return removal function
1475
+ return ()=>this.removeRoute(route.path);
1476
+ }
1477
+ /**
1478
+ * Removes a route by its path.
1479
+ *
1480
+ * @param {string} path - The path of the route to remove.
1481
+ * @returns {boolean} True if the route was removed, false if not found.
1482
+ * @emits router:routeRemoved When a route is successfully removed.
1483
+ *
1484
+ * @example
1485
+ * router.removeRoute('/dynamic');
1486
+ */ removeRoute(path) {
1487
+ const index = this.routes.findIndex((r)=>r.path === path);
1488
+ if (index === -1) {
1489
+ return false;
1490
+ }
1491
+ const [removedRoute] = this.routes.splice(index, 1);
1492
+ // Emit event for plugins
1493
+ this.emitter.emit("router:routeRemoved", removedRoute);
1494
+ return true;
1495
+ }
1496
+ /**
1497
+ * Checks if a route with the given path exists.
1498
+ *
1499
+ * @param {string} path - The path to check.
1500
+ * @returns {boolean} True if the route exists.
1501
+ *
1502
+ * @example
1503
+ * if (router.hasRoute('/users/:id')) {
1504
+ * console.log('User route exists');
1505
+ * }
1506
+ */ hasRoute(path) {
1507
+ return this.routes.some((r)=>r.path === path);
1508
+ }
1509
+ /**
1510
+ * Gets all registered routes.
1511
+ *
1512
+ * @returns {RouteDefinition[]} A copy of the routes array.
1513
+ *
1514
+ * @example
1515
+ * const routes = router.getRoutes();
1516
+ * console.log('Available routes:', routes.map(r => r.path));
1517
+ */ getRoutes() {
1518
+ return [
1519
+ ...this.routes
1520
+ ];
1521
+ }
1522
+ /**
1523
+ * Gets a route by its path.
1524
+ *
1525
+ * @param {string} path - The path of the route to get.
1526
+ * @returns {RouteDefinition | undefined} The route definition or undefined.
1527
+ *
1528
+ * @example
1529
+ * const route = router.getRoute('/users/:id');
1530
+ * if (route) {
1531
+ * console.log('Route meta:', route.meta);
1532
+ * }
1533
+ */ getRoute(path) {
1534
+ return this.routes.find((r)=>r.path === path);
1535
+ }
1536
+ // ============================================
1537
+ // Hook Registration Methods
1538
+ // ============================================
1539
+ /**
1540
+ * Registers a global pre-navigation guard.
1541
+ * Multiple guards can be registered and will be executed in order.
1542
+ * Guards can also be registered via the emitter using `router:beforeEach` event.
1543
+ *
1544
+ * @param {NavigationGuard} guard - The guard function to register.
1545
+ * @returns {() => void} A function to unregister the guard.
1546
+ *
1547
+ * @example
1548
+ * // Register a guard
1549
+ * const unregister = router.onBeforeEach((to, from) => {
1550
+ * if (to.meta.requiresAuth && !isAuthenticated()) {
1551
+ * return '/login';
1552
+ * }
1553
+ * });
1554
+ *
1555
+ * // Later, unregister the guard
1556
+ * unregister();
1557
+ */ onBeforeEach(guard) {
1558
+ this._beforeEachGuards.push(guard);
1559
+ return ()=>{
1560
+ const index = this._beforeEachGuards.indexOf(guard);
1561
+ if (index > -1) {
1562
+ this._beforeEachGuards.splice(index, 1);
1563
+ }
1564
+ };
1565
+ }
1566
+ /**
1567
+ * Registers a global hook that runs after a new route component has been mounted.
1568
+ * @param {NavigationHook} hook - The hook function to register.
1569
+ * @returns {() => void} A function to unregister the hook.
1570
+ * @listens router:afterEnter
1571
+ */ onAfterEnter(hook) {
1572
+ return this.emitter.on("router:afterEnter", hook);
1573
+ }
1574
+ /**
1575
+ * Registers a global hook that runs after a route component has been unmounted.
1576
+ * @param {NavigationHook} hook - The hook function to register.
1577
+ * @returns {() => void} A function to unregister the hook.
1578
+ * @listens router:afterLeave
1579
+ */ onAfterLeave(hook) {
1580
+ return this.emitter.on("router:afterLeave", hook);
1581
+ }
1582
+ /**
1583
+ * Registers a global hook that runs after a navigation has been confirmed and all hooks have completed.
1584
+ * @param {NavigationHook} hook - The hook function to register.
1585
+ * @returns {() => void} A function to unregister the hook.
1586
+ * @listens router:afterEach
1587
+ */ onAfterEach(hook) {
1588
+ return this.emitter.on("router:afterEach", hook);
1589
+ }
1590
+ /**
1591
+ * Registers a global error handler for navigation errors.
1592
+ * @param {(error: Error, to?: RouteLocation, from?: RouteLocation) => void} handler - The error handler function.
1593
+ * @returns {() => void} A function to unregister the handler.
1594
+ * @listens router:error
1595
+ */ onError(handler) {
1596
+ return this.emitter.on("router:error", handler);
1597
+ }
1598
+ /**
1599
+ * Registers a plugin with the router.
1600
+ * Logs a warning if the plugin is already registered.
1601
+ *
1602
+ * @param {RouterPlugin} plugin - The plugin to register (must have install method).
1603
+ * @param {Record<string, unknown>} [options={}] - Options to pass to plugin.install().
1604
+ * @returns {void}
1605
+ * @throws {Error} If plugin does not have an install method.
1606
+ */ use(plugin, options = {}) {
1607
+ if (typeof plugin.install !== "function") {
1608
+ this.errorHandler.handle(new Error("Plugin must have an install method"), "Plugin registration failed", {
1609
+ plugin
1610
+ });
1611
+ }
1612
+ // Check if plugin is already registered
1613
+ if (this.plugins.has(plugin.name)) {
1614
+ this.errorHandler.warn(`Plugin "${plugin.name}" is already registered`, {
1615
+ existingPlugin: this.plugins.get(plugin.name)
1616
+ });
1617
+ return;
1618
+ }
1619
+ this.plugins.set(plugin.name, plugin);
1620
+ plugin.install(this, options);
1621
+ }
1622
+ /**
1623
+ * Gets all registered plugins.
1624
+ * @returns {RouterPlugin[]} Array of registered plugins.
1625
+ */ getPlugins() {
1626
+ return Array.from(this.plugins.values());
1627
+ }
1628
+ /**
1629
+ * Gets a plugin by name.
1630
+ * @param {string} name - The plugin name.
1631
+ * @returns {RouterPlugin | undefined} The plugin or undefined.
1632
+ */ getPlugin(name) {
1633
+ return this.plugins.get(name);
1634
+ }
1635
+ /**
1636
+ * Removes a plugin from the router.
1637
+ * @param {string} name - The plugin name.
1638
+ * @returns {boolean} True if the plugin was removed.
1639
+ */ removePlugin(name) {
1640
+ const plugin = this.plugins.get(name);
1641
+ if (!plugin) return false;
1642
+ // Call destroy if available
1643
+ if (typeof plugin.destroy === "function") {
1644
+ try {
1645
+ plugin.destroy(this);
1646
+ } catch (error) {
1647
+ this.errorHandler.log(`Plugin ${name} destroy failed`, error);
1648
+ }
1649
+ }
1650
+ return this.plugins.delete(name);
1651
+ }
1652
+ /**
1653
+ * Sets a custom error handler. Used by error handling plugins.
1654
+ * Logs a warning if the provided handler is invalid (missing required methods).
1655
+ * @param {RouterErrorHandler} errorHandler - The error handler object with handle, warn, and log methods.
1656
+ * @returns {void}
1657
+ */ setErrorHandler(errorHandler) {
1658
+ if (errorHandler && typeof errorHandler.handle === "function" && typeof errorHandler.warn === "function" && typeof errorHandler.log === "function") {
1659
+ this.errorHandler = errorHandler;
1660
+ } else {
1661
+ console.warn("[ElevaRouter] Invalid error handler provided. Must have handle, warn, and log methods.");
1662
+ }
1663
+ }
1664
+ /**
1665
+ * Creates an instance of the Router.
1666
+ * @param {Eleva} eleva - The Eleva framework instance.
1667
+ * @param {RouterOptions} options - The configuration options for the router.
1668
+ * @throws {Error} If the routing mode is invalid.
1669
+ */ constructor(eleva, options = {}){
1670
+ /** @type {Eleva} The Eleva framework instance. */ this.eleva = eleva;
1671
+ /** @type {RouterOptions} The merged router options. */ this.options = {
1672
+ mode: "hash",
1673
+ queryParam: "view",
1674
+ viewSelector: "view",
1675
+ ...options
1676
+ };
1677
+ /** @private @type {RouteDefinition[]} The processed list of route definitions. */ this.routes = this._processRoutes(options.routes || []);
1678
+ /** @private @type {Emitter} The shared Eleva event emitter for global hooks. */ this.emitter = this.eleva.emitter;
1679
+ /** @private @type {boolean} A flag indicating if the router has been started. */ this.isStarted = false;
1680
+ /** @private @type {boolean} A flag to prevent navigation loops from history events. */ this._isNavigating = false;
1681
+ /** @private @type {number} Counter for tracking navigation operations to prevent race conditions. */ this._navigationId = 0;
1682
+ /** @private @type {UnsubscribeFunction[]} A collection of cleanup functions for event listeners. */ this.eventListeners = [];
1683
+ /** @type {Signal<RouteLocation | null>} A reactive signal holding the current route's information. */ this.currentRoute = new this.eleva.signal(null);
1684
+ /** @type {Signal<RouteLocation | null>} A reactive signal holding the previous route's information. */ this.previousRoute = new this.eleva.signal(null);
1685
+ /** @type {Signal<RouteParams>} A reactive signal holding the current route's parameters. */ this.currentParams = new this.eleva.signal({});
1686
+ /** @type {Signal<QueryParams>} A reactive signal holding the current route's query parameters. */ this.currentQuery = new this.eleva.signal({});
1687
+ /** @type {Signal<MountResult | null>} A reactive signal for the currently mounted layout instance. */ this.currentLayout = new this.eleva.signal(null);
1688
+ /** @type {Signal<MountResult | null>} A reactive signal for the currently mounted view (page) instance. */ this.currentView = new this.eleva.signal(null);
1689
+ /** @type {Signal<boolean>} A reactive signal indicating if the router is ready (started and initial navigation complete). */ this.isReady = new this.eleva.signal(false);
1690
+ /** @private @type {Map<string, RouterPlugin>} Map of registered plugins by name. */ this.plugins = new Map();
1691
+ /** @private @type {NavigationGuard[]} Array of global before-each navigation guards. */ this._beforeEachGuards = [];
1692
+ // If onBeforeEach was provided in options, add it to the guards array
1693
+ if (options.onBeforeEach) {
1694
+ this._beforeEachGuards.push(options.onBeforeEach);
1695
+ }
1696
+ /** @type {RouterErrorHandler} The error handler instance. Can be overridden by plugins. */ this.errorHandler = CoreErrorHandler;
1697
+ /** @private @type {Map<string, {x: number, y: number}>} Saved scroll positions by route path. */ this._scrollPositions = new Map();
1698
+ this._validateOptions();
1699
+ }
1700
+ }
1701
+ /**
1702
+ * @class 🚀 RouterPlugin
1703
+ * @classdesc A powerful, reactive, and flexible Router Plugin for Eleva applications.
1704
+ * This plugin provides comprehensive client-side routing functionality including:
1705
+ * - Multiple routing modes (hash, history, query)
1706
+ * - Navigation guards and lifecycle hooks
1707
+ * - Reactive state management
1708
+ * - Component resolution and lazy loading
1709
+ * - Layout and page component separation
1710
+ * - Plugin system for extensibility
1711
+ * - Advanced error handling
1712
+ *
1713
+ * @example
1714
+ * // Install the plugin
1715
+ * const app = new Eleva("myApp");
1716
+ *
1717
+ * const HomePage = { template: () => `<h1>Home</h1>` };
1718
+ * const AboutPage = { template: () => `<h1>About Us</h1>` };
1719
+ * const UserPage = {
1720
+ * template: (ctx) => `<h1>User: ${ctx.router.params.id}</h1>`
1721
+ * };
1722
+ *
1723
+ * app.use(RouterPlugin, {
1724
+ * mount: '#app',
1725
+ * mode: 'hash',
1726
+ * routes: [
1727
+ * { path: '/', component: HomePage },
1728
+ * { path: '/about', component: AboutPage },
1729
+ * { path: '/users/:id', component: UserPage }
1730
+ * ]
1731
+ * });
1732
+ */ const RouterPlugin = {
1733
+ /**
1734
+ * Unique identifier for the plugin
1735
+ * @type {string}
1736
+ */ name: "router",
1737
+ /**
1738
+ * Plugin version
1739
+ * @type {string}
1740
+ */ version: "1.1.0",
1741
+ /**
1742
+ * Plugin description
1743
+ * @type {string}
1744
+ */ description: "Client-side routing for Eleva applications",
1745
+ /**
1746
+ * Installs the RouterPlugin into an Eleva instance.
1747
+ *
1748
+ * @public
1749
+ * @param {Eleva} eleva - The Eleva instance.
1750
+ * @param {RouterOptions} options - Router configuration options.
1751
+ * @param {string} options.mount - A CSS selector for the main element where the app is mounted.
1752
+ * @param {RouteDefinition[]} options.routes - An array of route definitions.
1753
+ * @param {'hash' | 'query' | 'history'} [options.mode='hash'] - The routing mode.
1754
+ * @param {string} [options.queryParam='view'] - The query parameter to use in 'query' mode.
1755
+ * @param {string} [options.viewSelector='view'] - Base selector for the view element (matched as #id, .class, [data-*], or raw selector).
1756
+ * @param {boolean} [options.autoStart=true] - Whether to start the router automatically.
1757
+ * @param {NavigationGuard} [options.onBeforeEach] - A global guard executed before every navigation.
1758
+ * @param {RouteComponent} [options.globalLayout] - A global layout for all routes.
1759
+ * @returns {Router} The created router instance.
1760
+ * @throws {Error} If 'mount' option is not provided.
1761
+ * @throws {Error} If 'routes' option is not an array.
1762
+ * @throws {Error} If component registration fails during route processing.
1763
+ * @description
1764
+ * Registers route/layout components, sets `eleva.router`, and adds helpers
1765
+ * (`eleva.navigate`, `eleva.getCurrentRoute`, `eleva.getRouteParams`, `eleva.getRouteQuery`).
1766
+ * When `autoStart` is enabled, startup is scheduled via microtask.
1767
+ *
1768
+ * @example
1769
+ * // main.js
1770
+ * import Eleva from 'eleva';
1771
+ * import { RouterPlugin } from './plugins/RouterPlugin.js';
1772
+ *
1773
+ * const app = new Eleva('myApp');
1774
+ *
1775
+ * const HomePage = { template: () => `<h1>Home</h1>` };
1776
+ * const AboutPage = { template: () => `<h1>About Us</h1>` };
1777
+ *
1778
+ * app.use(RouterPlugin, {
1779
+ * mount: '#app',
1780
+ * routes: [
1781
+ * { path: '/', component: HomePage },
1782
+ * { path: '/about', component: AboutPage }
1783
+ * ]
1784
+ * });
1785
+ */ install (eleva, options = {}) {
1786
+ if (!options.mount) {
1787
+ throw new Error("[RouterPlugin] 'mount' option is required");
1788
+ }
1789
+ if (!options.routes || !Array.isArray(options.routes)) {
1790
+ throw new Error("[RouterPlugin] 'routes' option must be an array");
1791
+ }
1792
+ /**
1793
+ * Registers a component definition with the Eleva instance.
1794
+ * This method handles both inline component objects and pre-registered component names.
1795
+ *
1796
+ * @inner
1797
+ * @param {unknown} def - The component definition to register.
1798
+ * @param {string} type - The type of component for naming (e.g., "Route", "Layout").
1799
+ * @returns {string | null} The registered component name or null if no definition provided.
1800
+ */ const register = (def, type)=>{
1801
+ if (!def) return null;
1802
+ if (typeof def === "object" && def !== null && !def.name) {
1803
+ const name = `Eleva${type}Component_${Math.random().toString(36).slice(2, 11)}`;
1804
+ try {
1805
+ eleva.component(name, def);
1806
+ return name;
1807
+ } catch (error) {
1808
+ throw new Error(`[RouterPlugin] Failed to register ${type} component: ${error.message}`);
1809
+ }
1810
+ }
1811
+ return def;
1812
+ };
1813
+ if (options.globalLayout) {
1814
+ options.globalLayout = register(options.globalLayout, "GlobalLayout");
1815
+ }
1816
+ (options.routes || []).forEach((route)=>{
1817
+ route.component = register(route.component, "Route");
1818
+ if (route.layout) {
1819
+ route.layout = register(route.layout, "RouteLayout");
1820
+ }
1821
+ });
1822
+ const router = new Router(eleva, options);
1823
+ /** @type {Router} */ eleva.router = router;
1824
+ if (options.autoStart !== false) {
1825
+ queueMicrotask(()=>router.start());
1826
+ }
1827
+ // Add plugin metadata to the Eleva instance
1828
+ if (!eleva.plugins) {
1829
+ eleva.plugins = new Map();
1830
+ }
1831
+ eleva.plugins.set(this.name, {
1832
+ name: this.name,
1833
+ version: this.version,
1834
+ description: this.description,
1835
+ options
1836
+ });
1837
+ // Add utility methods for manual router access
1838
+ /** @type {NavigateFunction} */ eleva.navigate = router.navigate.bind(router);
1839
+ /** @type {() => RouteLocation | null} */ eleva.getCurrentRoute = ()=>router.currentRoute.value;
1840
+ /** @type {() => RouteParams} */ eleva.getRouteParams = ()=>router.currentParams.value;
1841
+ /** @type {() => QueryParams} */ eleva.getRouteQuery = ()=>router.currentQuery.value;
1842
+ return router;
1843
+ },
1844
+ /**
1845
+ * Uninstalls the plugin from the Eleva instance.
1846
+ *
1847
+ * @public
1848
+ * @async
1849
+ * @param {Eleva} eleva - The Eleva instance.
1850
+ * @returns {Promise<void>}
1851
+ * @description
1852
+ * Destroys the router instance, removes `eleva.router`, and deletes helper methods
1853
+ * (`eleva.navigate`, `eleva.getCurrentRoute`, `eleva.getRouteParams`, `eleva.getRouteQuery`).
1854
+ */ async uninstall (eleva) {
1855
+ if (eleva.router) {
1856
+ await eleva.router.destroy();
1857
+ delete eleva.router;
1858
+ }
1859
+ // Remove plugin metadata
1860
+ if (eleva.plugins) {
1861
+ eleva.plugins.delete(this.name);
1862
+ }
1863
+ // Remove utility methods
1864
+ delete eleva.navigate;
1865
+ delete eleva.getCurrentRoute;
1866
+ delete eleva.getRouteParams;
1867
+ delete eleva.getRouteQuery;
1868
+ }
1869
+ };
1870
+
1871
+ exports.Router = RouterPlugin;
1872
+ exports.RouterPlugin = RouterPlugin;
1873
+ //# sourceMappingURL=router.cjs.map