eleva 1.0.0-rc.3 → 1.0.0-rc.4

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 (41) hide show
  1. package/README.md +135 -2
  2. package/dist/eleva-plugins.cjs.js +1330 -0
  3. package/dist/eleva-plugins.cjs.js.map +1 -0
  4. package/dist/eleva-plugins.esm.js +1327 -0
  5. package/dist/eleva-plugins.esm.js.map +1 -0
  6. package/dist/eleva-plugins.umd.js +1336 -0
  7. package/dist/eleva-plugins.umd.js.map +1 -0
  8. package/dist/eleva-plugins.umd.min.js +3 -0
  9. package/dist/eleva-plugins.umd.min.js.map +1 -0
  10. package/dist/eleva.cjs.js +15 -34
  11. package/dist/eleva.cjs.js.map +1 -1
  12. package/dist/eleva.d.ts +0 -1
  13. package/dist/eleva.esm.js +15 -34
  14. package/dist/eleva.esm.js.map +1 -1
  15. package/dist/eleva.umd.js +15 -34
  16. package/dist/eleva.umd.js.map +1 -1
  17. package/dist/eleva.umd.min.js +2 -2
  18. package/dist/eleva.umd.min.js.map +1 -1
  19. package/dist/plugins/attr.umd.js +231 -0
  20. package/dist/plugins/attr.umd.js.map +1 -0
  21. package/dist/plugins/attr.umd.min.js +3 -0
  22. package/dist/plugins/attr.umd.min.js.map +1 -0
  23. package/dist/plugins/router.umd.js +1115 -0
  24. package/dist/plugins/router.umd.js.map +1 -0
  25. package/dist/plugins/router.umd.min.js +3 -0
  26. package/dist/plugins/router.umd.min.js.map +1 -0
  27. package/package.json +40 -1
  28. package/src/core/Eleva.js +6 -7
  29. package/src/modules/Renderer.js +8 -36
  30. package/src/plugins/Attr.js +252 -0
  31. package/src/plugins/Router.js +1217 -0
  32. package/src/plugins/index.js +34 -0
  33. package/types/core/Eleva.d.ts +0 -1
  34. package/types/core/Eleva.d.ts.map +1 -1
  35. package/types/modules/Renderer.d.ts.map +1 -1
  36. package/types/plugins/Attr.d.ts +28 -0
  37. package/types/plugins/Attr.d.ts.map +1 -0
  38. package/types/plugins/Router.d.ts +500 -0
  39. package/types/plugins/Router.d.ts.map +1 -0
  40. package/types/plugins/index.d.ts +3 -0
  41. package/types/plugins/index.d.ts.map +1 -0
@@ -0,0 +1,1336 @@
1
+ /*! Eleva Plugins v1.0.0-rc.4 | MIT License | https://elevajs.com */
2
+ (function (global, factory) {
3
+ typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
4
+ typeof define === 'function' && define.amd ? define(['exports'], factory) :
5
+ (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.ElevaPlugins = {}));
6
+ })(this, (function (exports) { 'use strict';
7
+
8
+ /**
9
+ * A regular expression to match hyphenated lowercase letters.
10
+ * @private
11
+ * @type {RegExp}
12
+ */
13
+ const CAMEL_RE = /-([a-z])/g;
14
+
15
+ /**
16
+ * @class 🎯 AttrPlugin
17
+ * @classdesc A plugin that provides advanced attribute handling for Eleva components.
18
+ * This plugin extends the renderer with sophisticated attribute processing including:
19
+ * - ARIA attribute handling with proper property mapping
20
+ * - Data attribute management
21
+ * - Boolean attribute processing
22
+ * - Dynamic property detection and mapping
23
+ * - Attribute cleanup and removal
24
+ *
25
+ * @example
26
+ * // Install the plugin
27
+ * const app = new Eleva("myApp");
28
+ * app.use(AttrPlugin);
29
+ *
30
+ * // Use advanced attributes in components
31
+ * app.component("myComponent", {
32
+ * template: (ctx) => `
33
+ * <button
34
+ * aria-expanded="${ctx.isExpanded.value}"
35
+ * data-user-id="${ctx.userId.value}"
36
+ * disabled="${ctx.isLoading.value}"
37
+ * class="btn ${ctx.variant.value}"
38
+ * >
39
+ * ${ctx.text.value}
40
+ * </button>
41
+ * `
42
+ * });
43
+ */
44
+ const AttrPlugin = {
45
+ /**
46
+ * Unique identifier for the plugin
47
+ * @type {string}
48
+ */
49
+ name: "attr",
50
+ /**
51
+ * Plugin version
52
+ * @type {string}
53
+ */
54
+ version: "1.0.0-rc.1",
55
+ /**
56
+ * Plugin description
57
+ * @type {string}
58
+ */
59
+ description: "Advanced attribute handling for Eleva components",
60
+ /**
61
+ * Installs the plugin into the Eleva instance
62
+ *
63
+ * @param {Object} eleva - The Eleva instance
64
+ * @param {Object} options - Plugin configuration options
65
+ * @param {boolean} [options.enableAria=true] - Enable ARIA attribute handling
66
+ * @param {boolean} [options.enableData=true] - Enable data attribute handling
67
+ * @param {boolean} [options.enableBoolean=true] - Enable boolean attribute handling
68
+ * @param {boolean} [options.enableDynamic=true] - Enable dynamic property detection
69
+ */
70
+ install(eleva, options = {}) {
71
+ const {
72
+ enableAria = true,
73
+ enableData = true,
74
+ enableBoolean = true,
75
+ enableDynamic = true
76
+ } = options;
77
+
78
+ /**
79
+ * Updates the attributes of an element to match a new element's attributes.
80
+ * This method provides sophisticated attribute processing including:
81
+ * - ARIA attribute handling with proper property mapping
82
+ * - Data attribute management
83
+ * - Boolean attribute processing
84
+ * - Dynamic property detection and mapping
85
+ * - Attribute cleanup and removal
86
+ *
87
+ * @param {HTMLElement} oldEl - The original element to update
88
+ * @param {HTMLElement} newEl - The new element to update
89
+ * @returns {void}
90
+ */
91
+ const updateAttributes = (oldEl, newEl) => {
92
+ const oldAttrs = oldEl.attributes;
93
+ const newAttrs = newEl.attributes;
94
+
95
+ // Process new attributes
96
+ for (let i = 0; i < newAttrs.length; i++) {
97
+ const {
98
+ name,
99
+ value
100
+ } = newAttrs[i];
101
+
102
+ // Skip event attributes (handled by event system)
103
+ if (name.startsWith("@")) continue;
104
+
105
+ // Skip if attribute hasn't changed
106
+ if (oldEl.getAttribute(name) === value) continue;
107
+
108
+ // Handle ARIA attributes
109
+ if (enableAria && name.startsWith("aria-")) {
110
+ const prop = "aria" + name.slice(5).replace(CAMEL_RE, (_, l) => l.toUpperCase());
111
+ oldEl[prop] = value;
112
+ oldEl.setAttribute(name, value);
113
+ }
114
+ // Handle data attributes
115
+ else if (enableData && name.startsWith("data-")) {
116
+ oldEl.dataset[name.slice(5)] = value;
117
+ oldEl.setAttribute(name, value);
118
+ }
119
+ // Handle other attributes
120
+ else {
121
+ let prop = name.replace(CAMEL_RE, (_, l) => l.toUpperCase());
122
+
123
+ // Dynamic property detection
124
+ if (enableDynamic && !(prop in oldEl) && !Object.getOwnPropertyDescriptor(Object.getPrototypeOf(oldEl), prop)) {
125
+ const elementProps = Object.getOwnPropertyNames(Object.getPrototypeOf(oldEl));
126
+ const matchingProp = elementProps.find(p => p.toLowerCase() === name.toLowerCase() || p.toLowerCase().includes(name.toLowerCase()) || name.toLowerCase().includes(p.toLowerCase()));
127
+ if (matchingProp) {
128
+ prop = matchingProp;
129
+ }
130
+ }
131
+ const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(oldEl), prop);
132
+ const hasProperty = prop in oldEl || descriptor;
133
+ if (hasProperty) {
134
+ // Boolean attribute handling
135
+ if (enableBoolean) {
136
+ const isBoolean = typeof oldEl[prop] === "boolean" || descriptor?.get && typeof descriptor.get.call(oldEl) === "boolean";
137
+ if (isBoolean) {
138
+ const boolValue = value !== "false" && (value === "" || value === prop || value === "true");
139
+ oldEl[prop] = boolValue;
140
+ if (boolValue) {
141
+ oldEl.setAttribute(name, "");
142
+ } else {
143
+ oldEl.removeAttribute(name);
144
+ }
145
+ } else {
146
+ oldEl[prop] = value;
147
+ oldEl.setAttribute(name, value);
148
+ }
149
+ } else {
150
+ oldEl[prop] = value;
151
+ oldEl.setAttribute(name, value);
152
+ }
153
+ } else {
154
+ oldEl.setAttribute(name, value);
155
+ }
156
+ }
157
+ }
158
+
159
+ // Remove old attributes that are no longer present
160
+ for (let i = oldAttrs.length - 1; i >= 0; i--) {
161
+ const name = oldAttrs[i].name;
162
+ if (!newEl.hasAttribute(name)) {
163
+ oldEl.removeAttribute(name);
164
+ }
165
+ }
166
+ };
167
+
168
+ // Extend the renderer with the advanced attribute handler
169
+ if (eleva.renderer) {
170
+ eleva.renderer.updateAttributes = updateAttributes;
171
+
172
+ // Store the original _patchNode method
173
+ const originalPatchNode = eleva.renderer._patchNode;
174
+ eleva.renderer._originalPatchNode = originalPatchNode;
175
+
176
+ // Override the _patchNode method to use our attribute handler
177
+ eleva.renderer._patchNode = function (oldNode, newNode) {
178
+ if (oldNode?._eleva_instance) return;
179
+ if (!this._isSameNode(oldNode, newNode)) {
180
+ oldNode.replaceWith(newNode.cloneNode(true));
181
+ return;
182
+ }
183
+ if (oldNode.nodeType === Node.ELEMENT_NODE) {
184
+ updateAttributes(oldNode, newNode);
185
+ this._diff(oldNode, newNode);
186
+ } else if (oldNode.nodeType === Node.TEXT_NODE && oldNode.nodeValue !== newNode.nodeValue) {
187
+ oldNode.nodeValue = newNode.nodeValue;
188
+ }
189
+ };
190
+ }
191
+
192
+ // Add plugin metadata to the Eleva instance
193
+ if (!eleva.plugins) {
194
+ eleva.plugins = new Map();
195
+ }
196
+ eleva.plugins.set(this.name, {
197
+ name: this.name,
198
+ version: this.version,
199
+ description: this.description,
200
+ options
201
+ });
202
+
203
+ // Add utility methods for manual attribute updates
204
+ eleva.updateElementAttributes = updateAttributes;
205
+ },
206
+ /**
207
+ * Uninstalls the plugin from the Eleva instance
208
+ *
209
+ * @param {Object} eleva - The Eleva instance
210
+ */
211
+ uninstall(eleva) {
212
+ // Restore original _patchNode method if it exists
213
+ if (eleva.renderer && eleva.renderer._originalPatchNode) {
214
+ eleva.renderer._patchNode = eleva.renderer._originalPatchNode;
215
+ delete eleva.renderer._originalPatchNode;
216
+ }
217
+
218
+ // Remove plugin metadata
219
+ if (eleva.plugins) {
220
+ eleva.plugins.delete(this.name);
221
+ }
222
+
223
+ // Remove utility methods
224
+ delete eleva.updateElementAttributes;
225
+ }
226
+ };
227
+
228
+ /**
229
+ * @typedef {import('eleva').Eleva} Eleva
230
+ * @typedef {import('eleva').Signal} Signal
231
+ * @typedef {import('eleva').ComponentDefinition} ComponentDefinition
232
+ */
233
+
234
+ /**
235
+ * Simple error handler for the core router.
236
+ * Can be overridden by error handling plugins.
237
+ * Provides consistent error formatting and logging for router operations.
238
+ * @private
239
+ */
240
+ const CoreErrorHandler = {
241
+ /**
242
+ * Handles router errors with basic formatting.
243
+ * @param {Error} error - The error to handle.
244
+ * @param {string} context - The context where the error occurred.
245
+ * @param {Object} details - Additional error details.
246
+ * @throws {Error} The formatted error.
247
+ */
248
+ handle(error, context, details = {}) {
249
+ const message = `[ElevaRouter] ${context}: ${error.message}`;
250
+ const formattedError = new Error(message);
251
+
252
+ // Preserve original error details
253
+ formattedError.originalError = error;
254
+ formattedError.context = context;
255
+ formattedError.details = details;
256
+ console.error(message, {
257
+ error,
258
+ context,
259
+ details
260
+ });
261
+ throw formattedError;
262
+ },
263
+ /**
264
+ * Logs a warning without throwing an error.
265
+ * @param {string} message - The warning message.
266
+ * @param {Object} details - Additional warning details.
267
+ */
268
+ warn(message, details = {}) {
269
+ console.warn(`[ElevaRouter] ${message}`, details);
270
+ },
271
+ /**
272
+ * Logs an error without throwing.
273
+ * @param {string} message - The error message.
274
+ * @param {Error} error - The original error.
275
+ * @param {Object} details - Additional error details.
276
+ */
277
+ log(message, error, details = {}) {
278
+ console.error(`[ElevaRouter] ${message}`, {
279
+ error,
280
+ details
281
+ });
282
+ }
283
+ };
284
+
285
+ /**
286
+ * @typedef {Object} RouteLocation
287
+ * @property {string} path - The path of the route (e.g., '/users/123').
288
+ * @property {Object<string, string>} query - An object representing the query parameters.
289
+ * @property {string} fullUrl - The complete URL including hash, path, and query string.
290
+ * @property {Object<string, string>} params - An object containing dynamic route parameters.
291
+ * @property {Object<string, any>} meta - The meta object associated with the matched route.
292
+ * @property {string} [name] - The optional name of the matched route.
293
+ * @property {RouteDefinition} matched - The raw route definition object that was matched.
294
+ */
295
+
296
+ /**
297
+ * @typedef {(to: RouteLocation, from: RouteLocation | null) => boolean | string | {path: string} | void | Promise<boolean | string | {path: string} | void>} NavigationGuard
298
+ * A function that acts as a guard for navigation. It runs *before* the navigation is confirmed.
299
+ * It can return:
300
+ * - `true` or `undefined`: to allow navigation.
301
+ * - `false`: to abort the navigation.
302
+ * - a `string` (path) or a `location object`: to redirect to a new route.
303
+ */
304
+
305
+ /**
306
+ * @typedef {(...args: any[]) => void | Promise<void>} NavigationHook
307
+ * A function that acts as a lifecycle hook, typically for side effects. It does not affect navigation flow.
308
+ */
309
+
310
+ /**
311
+ * @typedef {Object} RouterPlugin
312
+ * @property {string} name - The plugin name.
313
+ * @property {string} [version] - The plugin version.
314
+ * @property {Function} install - The install function that receives the router instance.
315
+ * @property {Function} [destroy] - Optional cleanup function called when the router is destroyed.
316
+ */
317
+
318
+ /**
319
+ * @typedef {Object} RouteDefinition
320
+ * @property {string} path - The URL path pattern (e.g., '/', '/about', '/users/:id', '*').
321
+ * @property {string | ComponentDefinition | (() => Promise<{default: ComponentDefinition}>)} component - The component to render. Can be a registered name, a definition object, or an async import function.
322
+ * @property {string | ComponentDefinition | (() => Promise<{default: ComponentDefinition}>)} [layout] - An optional layout component to wrap the route's component.
323
+ * @property {string} [name] - An optional name for the route.
324
+ * @property {Object<string, any>} [meta] - Optional metadata for the route (e.g., for titles, auth flags).
325
+ * @property {NavigationGuard} [beforeEnter] - A route-specific guard executed before entering the route.
326
+ * @property {NavigationHook} [afterEnter] - A hook executed *after* the route has been entered and the new component is mounted.
327
+ * @property {NavigationGuard} [beforeLeave] - A guard executed *before* leaving the current route.
328
+ * @property {NavigationHook} [afterLeave] - A hook executed *after* leaving the current route and its component has been unmounted.
329
+ */
330
+
331
+ /**
332
+ * @class Router
333
+ * @classdesc A powerful, reactive, and flexible Router Plugin for Eleva.js.
334
+ * This class manages all routing logic, including state, navigation, and rendering.
335
+ * @private
336
+ */
337
+ class Router {
338
+ /**
339
+ * Creates an instance of the Router.
340
+ * @param {Eleva} eleva - The Eleva framework instance.
341
+ * @param {RouterOptions} options - The configuration options for the router.
342
+ */
343
+ constructor(eleva, options = {}) {
344
+ /** @type {Eleva} The Eleva framework instance. */
345
+ this.eleva = eleva;
346
+
347
+ /** @type {RouterOptions} The merged router options. */
348
+ this.options = {
349
+ mode: "hash",
350
+ queryParam: "view",
351
+ viewSelector: "root",
352
+ ...options
353
+ };
354
+
355
+ /** @private @type {RouteDefinition[]} The processed list of route definitions. */
356
+ this.routes = this._processRoutes(options.routes || []);
357
+
358
+ /** @private @type {import('eleva').Emitter} The shared Eleva event emitter for global hooks. */
359
+ this.emitter = this.eleva.emitter;
360
+
361
+ /** @private @type {boolean} A flag indicating if the router has been started. */
362
+ this.isStarted = false;
363
+
364
+ /** @private @type {boolean} A flag to prevent navigation loops from history events. */
365
+ this._isNavigating = false;
366
+
367
+ /** @private @type {Array<() => void>} A collection of cleanup functions for event listeners. */
368
+ this.eventListeners = [];
369
+
370
+ /** @type {Signal<RouteLocation | null>} A reactive signal holding the current route's information. */
371
+ this.currentRoute = new this.eleva.signal(null);
372
+
373
+ /** @type {Signal<RouteLocation | null>} A reactive signal holding the previous route's information. */
374
+ this.previousRoute = new this.eleva.signal(null);
375
+
376
+ /** @type {Signal<Object<string, string>>} A reactive signal holding the current route's parameters. */
377
+ this.currentParams = new this.eleva.signal({});
378
+
379
+ /** @type {Signal<Object<string, string>>} A reactive signal holding the current route's query parameters. */
380
+ this.currentQuery = new this.eleva.signal({});
381
+
382
+ /** @type {Signal<import('eleva').MountResult | null>} A reactive signal for the currently mounted layout instance. */
383
+ this.currentLayout = new this.eleva.signal(null);
384
+
385
+ /** @type {Signal<import('eleva').MountResult | null>} A reactive signal for the currently mounted view (page) instance. */
386
+ this.currentView = new this.eleva.signal(null);
387
+
388
+ /** @private @type {Map<string, RouterPlugin>} Map of registered plugins by name. */
389
+ this.plugins = new Map();
390
+
391
+ /** @type {Object} The error handler instance. Can be overridden by plugins. */
392
+ this.errorHandler = CoreErrorHandler;
393
+ this._validateOptions();
394
+ }
395
+
396
+ /**
397
+ * Validates the provided router options.
398
+ * @private
399
+ * @throws {Error} If the routing mode is invalid.
400
+ */
401
+ _validateOptions() {
402
+ if (!["hash", "query", "history"].includes(this.options.mode)) {
403
+ this.errorHandler.handle(new Error(`Invalid routing mode: ${this.options.mode}. Must be "hash", "query", or "history".`), "Configuration validation failed");
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Pre-processes route definitions to parse their path segments for efficient matching.
409
+ * @private
410
+ * @param {RouteDefinition[]} routes - The raw route definitions.
411
+ * @returns {RouteDefinition[]} The processed routes.
412
+ */
413
+ _processRoutes(routes) {
414
+ const processedRoutes = [];
415
+ for (const route of routes) {
416
+ try {
417
+ processedRoutes.push({
418
+ ...route,
419
+ segments: this._parsePathIntoSegments(route.path)
420
+ });
421
+ } catch (error) {
422
+ this.errorHandler.warn(`Invalid path in route definition "${route.path || "undefined"}": ${error.message}`, {
423
+ route,
424
+ error
425
+ });
426
+ }
427
+ }
428
+ return processedRoutes;
429
+ }
430
+
431
+ /**
432
+ * Parses a route path string into an array of static and parameter segments.
433
+ * @private
434
+ * @param {string} path - The path pattern to parse.
435
+ * @returns {Array<{type: 'static' | 'param', value?: string, name?: string}>} An array of segment objects.
436
+ * @throws {Error} If the route path is not a valid string.
437
+ */
438
+ _parsePathIntoSegments(path) {
439
+ if (!path || typeof path !== "string") {
440
+ this.errorHandler.handle(new Error("Route path must be a non-empty string"), "Path parsing failed", {
441
+ path
442
+ });
443
+ }
444
+ const normalizedPath = path.replace(/\/+/g, "/").replace(/\/$/, "") || "/";
445
+ if (normalizedPath === "/") {
446
+ return [];
447
+ }
448
+ return normalizedPath.split("/").filter(Boolean).map(segment => {
449
+ if (segment.startsWith(":")) {
450
+ const paramName = segment.substring(1);
451
+ if (!paramName) {
452
+ this.errorHandler.handle(new Error(`Invalid parameter segment: ${segment}`), "Path parsing failed", {
453
+ segment,
454
+ path
455
+ });
456
+ }
457
+ return {
458
+ type: "param",
459
+ name: paramName
460
+ };
461
+ }
462
+ return {
463
+ type: "static",
464
+ value: segment
465
+ };
466
+ });
467
+ }
468
+
469
+ /**
470
+ * Finds the view element within a container using multiple selector strategies.
471
+ * @private
472
+ * @param {HTMLElement} container - The parent element to search within.
473
+ * @returns {HTMLElement} The found view element or the container itself as a fallback.
474
+ */
475
+ _findViewElement(container) {
476
+ const selector = this.options.viewSelector;
477
+ return container.querySelector(`#${selector}`) || container.querySelector(`.${selector}`) || container.querySelector(`[data-${selector}]`) || container.querySelector(selector) || container;
478
+ }
479
+
480
+ /**
481
+ * Starts the router, initializes event listeners, and performs the initial navigation.
482
+ * @returns {Promise<void>}
483
+ */
484
+ async start() {
485
+ if (this.isStarted) {
486
+ this.errorHandler.warn("Router is already started");
487
+ return;
488
+ }
489
+ if (typeof window === "undefined") {
490
+ this.errorHandler.warn("Router start skipped: `window` object not available (SSR environment)");
491
+ return;
492
+ }
493
+ if (typeof document !== "undefined" && !document.querySelector(this.options.mount)) {
494
+ this.errorHandler.warn(`Mount element "${this.options.mount}" was not found in the DOM. The router will not start.`, {
495
+ mountSelector: this.options.mount
496
+ });
497
+ return;
498
+ }
499
+ const handler = () => this._handleRouteChange();
500
+ if (this.options.mode === "hash") {
501
+ window.addEventListener("hashchange", handler);
502
+ this.eventListeners.push(() => window.removeEventListener("hashchange", handler));
503
+ } else {
504
+ window.addEventListener("popstate", handler);
505
+ this.eventListeners.push(() => window.removeEventListener("popstate", handler));
506
+ }
507
+ this.isStarted = true;
508
+ await this._handleRouteChange();
509
+ }
510
+
511
+ /**
512
+ * Stops the router and cleans up all event listeners and mounted components.
513
+ * @returns {Promise<void>}
514
+ */
515
+ async destroy() {
516
+ if (!this.isStarted) return;
517
+
518
+ // Clean up plugins
519
+ for (const plugin of this.plugins.values()) {
520
+ if (typeof plugin.destroy === "function") {
521
+ try {
522
+ await plugin.destroy(this);
523
+ } catch (error) {
524
+ this.errorHandler.log(`Plugin ${plugin.name} destroy failed`, error);
525
+ }
526
+ }
527
+ }
528
+ this.eventListeners.forEach(cleanup => cleanup());
529
+ this.eventListeners = [];
530
+ if (this.currentLayout.value) {
531
+ await this.currentLayout.value.unmount();
532
+ }
533
+ this.isStarted = false;
534
+ }
535
+
536
+ /**
537
+ * Programmatically navigates to a new route.
538
+ * @param {string | {path: string, query?: object, params?: object, replace?: boolean, state?: object}} location - The target location as a string or object.
539
+ * @param {object} [params] - Optional route parameters (for string-based location).
540
+ * @returns {Promise<void>}
541
+ */
542
+ async navigate(location, params = {}) {
543
+ try {
544
+ const target = typeof location === "string" ? {
545
+ path: location,
546
+ params
547
+ } : location;
548
+ let path = this._buildPath(target.path, target.params || {});
549
+ const query = target.query || {};
550
+ if (Object.keys(query).length > 0) {
551
+ const queryString = new URLSearchParams(query).toString();
552
+ if (queryString) path += `?${queryString}`;
553
+ }
554
+ if (this._isSameRoute(path, target.params, query)) {
555
+ return;
556
+ }
557
+ const navigationSuccessful = await this._proceedWithNavigation(path);
558
+ if (navigationSuccessful) {
559
+ this._isNavigating = true;
560
+ const state = target.state || {};
561
+ const replace = target.replace || false;
562
+ const historyMethod = replace ? "replaceState" : "pushState";
563
+ if (this.options.mode === "hash") {
564
+ if (replace) {
565
+ const newUrl = `${window.location.pathname}${window.location.search}#${path}`;
566
+ window.history.replaceState(state, "", newUrl);
567
+ } else {
568
+ window.location.hash = path;
569
+ }
570
+ } else {
571
+ const url = this.options.mode === "query" ? this._buildQueryUrl(path) : path;
572
+ history[historyMethod](state, "", url);
573
+ }
574
+ queueMicrotask(() => {
575
+ this._isNavigating = false;
576
+ });
577
+ }
578
+ } catch (error) {
579
+ this.errorHandler.log("Navigation failed", error);
580
+ await this.emitter.emit("router:onError", error);
581
+ }
582
+ }
583
+
584
+ /**
585
+ * Builds a URL for query mode.
586
+ * @private
587
+ * @param {string} path - The path to set as the query parameter.
588
+ * @returns {string} The full URL with the updated query string.
589
+ */
590
+ _buildQueryUrl(path) {
591
+ const urlParams = new URLSearchParams(window.location.search);
592
+ urlParams.set(this.options.queryParam, path.split("?")[0]);
593
+ return `${window.location.pathname}?${urlParams.toString()}`;
594
+ }
595
+
596
+ /**
597
+ * Checks if the target route is identical to the current route.
598
+ * @private
599
+ * @param {string} path - The target path with query string.
600
+ * @param {object} params - The target params.
601
+ * @param {object} query - The target query.
602
+ * @returns {boolean} - True if the routes are the same.
603
+ */
604
+ _isSameRoute(path, params, query) {
605
+ const current = this.currentRoute.value;
606
+ if (!current) return false;
607
+ const [targetPath, queryString] = path.split("?");
608
+ const targetQuery = query || this._parseQuery(queryString || "");
609
+ return current.path === targetPath && JSON.stringify(current.params) === JSON.stringify(params || {}) && JSON.stringify(current.query) === JSON.stringify(targetQuery);
610
+ }
611
+
612
+ /**
613
+ * Injects dynamic parameters into a path string.
614
+ * @private
615
+ */
616
+ _buildPath(path, params) {
617
+ let result = path;
618
+ for (const [key, value] of Object.entries(params)) {
619
+ // Fix: Handle special characters and ensure proper encoding
620
+ const encodedValue = encodeURIComponent(String(value));
621
+ result = result.replace(new RegExp(`:${key}\\b`, "g"), encodedValue);
622
+ }
623
+ return result;
624
+ }
625
+
626
+ /**
627
+ * The handler for browser-initiated route changes (e.g., back/forward buttons).
628
+ * @private
629
+ */
630
+ async _handleRouteChange() {
631
+ if (this._isNavigating) return;
632
+ const from = this.currentRoute.value;
633
+ const toLocation = this._getCurrentLocation();
634
+ const navigationSuccessful = await this._proceedWithNavigation(toLocation.fullUrl);
635
+
636
+ // If navigation was blocked by a guard, revert the URL change
637
+ if (!navigationSuccessful && from) {
638
+ this.navigate({
639
+ path: from.path,
640
+ query: from.query,
641
+ replace: true
642
+ });
643
+ }
644
+ }
645
+
646
+ /**
647
+ * Manages the core navigation lifecycle. Runs guards before committing changes.
648
+ * @private
649
+ * @param {string} fullPath - The full path (e.g., '/users/123?foo=bar') to navigate to.
650
+ * @returns {Promise<boolean>} - `true` if navigation succeeded, `false` if aborted.
651
+ */
652
+ async _proceedWithNavigation(fullPath) {
653
+ const from = this.currentRoute.value;
654
+ const [path, queryString] = (fullPath || "/").split("?");
655
+ const toLocation = {
656
+ path: path.startsWith("/") ? path : `/${path}`,
657
+ query: this._parseQuery(queryString),
658
+ fullUrl: fullPath
659
+ };
660
+ let toMatch = this._matchRoute(toLocation.path);
661
+ if (!toMatch) {
662
+ const notFoundRoute = this.routes.find(route => route.path === "*");
663
+ if (notFoundRoute) {
664
+ toMatch = {
665
+ route: notFoundRoute,
666
+ params: {
667
+ pathMatch: toLocation.path.substring(1)
668
+ }
669
+ };
670
+ } else {
671
+ await this.emitter.emit("router:onError", new Error(`Route not found: ${toLocation.path}`), toLocation, from);
672
+ return false;
673
+ }
674
+ }
675
+ const to = {
676
+ ...toLocation,
677
+ params: toMatch.params,
678
+ meta: toMatch.route.meta || {},
679
+ name: toMatch.route.name,
680
+ matched: toMatch.route
681
+ };
682
+ try {
683
+ // 1. Run all *pre-navigation* guards.
684
+ const canNavigate = await this._runGuards(to, from, toMatch.route);
685
+ if (!canNavigate) return false;
686
+
687
+ // 2. Resolve async components *before* touching the DOM.
688
+ const {
689
+ layoutComponent,
690
+ pageComponent
691
+ } = await this._resolveComponents(toMatch.route);
692
+
693
+ // 3. Unmount the previous view/layout.
694
+ if (from) {
695
+ const toLayout = toMatch.route.layout || this.options.globalLayout;
696
+ const fromLayout = from.matched.layout || this.options.globalLayout;
697
+ const tryUnmount = async instance => {
698
+ if (!instance) return;
699
+ try {
700
+ await instance.unmount();
701
+ } catch (error) {
702
+ this.errorHandler.warn("Error during component unmount", {
703
+ error,
704
+ instance
705
+ });
706
+ }
707
+ };
708
+ if (toLayout !== fromLayout) {
709
+ await tryUnmount(this.currentLayout.value);
710
+ this.currentLayout.value = null;
711
+ } else {
712
+ await tryUnmount(this.currentView.value);
713
+ this.currentView.value = null;
714
+ }
715
+
716
+ // 4. Call `afterLeave` hook *after* the old component has been unmounted.
717
+ if (from.matched.afterLeave) {
718
+ await from.matched.afterLeave(to, from);
719
+ await this.emitter.emit("router:afterLeave", to, from);
720
+ }
721
+ }
722
+
723
+ // 5. Update reactive state.
724
+ this.previousRoute.value = from;
725
+ this.currentRoute.value = to;
726
+ this.currentParams.value = to.params || {};
727
+ this.currentQuery.value = to.query || {};
728
+
729
+ // 6. Render the new components.
730
+ await this._render(layoutComponent, pageComponent, to);
731
+
732
+ // 7. Run post-navigation hooks.
733
+ if (toMatch.route.afterEnter) {
734
+ await toMatch.route.afterEnter(to, from);
735
+ await this.emitter.emit("router:afterEnter", to, from);
736
+ }
737
+ await this.emitter.emit("router:afterEach", to, from);
738
+ return true;
739
+ } catch (error) {
740
+ this.errorHandler.log("Error during navigation", error, {
741
+ to,
742
+ from
743
+ });
744
+ await this.emitter.emit("router:onError", error, to, from);
745
+ return false;
746
+ }
747
+ }
748
+
749
+ /**
750
+ * Executes all applicable navigation guards for a transition in order.
751
+ * @private
752
+ * @returns {Promise<boolean>} - `false` if navigation should be aborted.
753
+ */
754
+ async _runGuards(to, from, route) {
755
+ const guards = [...(this.options.onBeforeEach ? [this.options.onBeforeEach] : []), ...(from && from.matched.beforeLeave ? [from.matched.beforeLeave] : []), ...(route.beforeEnter ? [route.beforeEnter] : [])];
756
+ for (const guard of guards) {
757
+ const result = await guard(to, from);
758
+ if (result === false) return false;
759
+ if (typeof result === "string" || typeof result === "object") {
760
+ this.navigate(result);
761
+ return false;
762
+ }
763
+ }
764
+ return true;
765
+ }
766
+
767
+ /**
768
+ * Resolves a string component definition to a component object.
769
+ * @private
770
+ * @param {string} def - The component name to resolve.
771
+ * @returns {ComponentDefinition} The resolved component.
772
+ * @throws {Error} If the component is not registered.
773
+ */
774
+ _resolveStringComponent(def) {
775
+ const componentDef = this.eleva._components.get(def);
776
+ if (!componentDef) {
777
+ this.errorHandler.handle(new Error(`Component "${def}" not registered.`), "Component resolution failed", {
778
+ componentName: def,
779
+ availableComponents: Array.from(this.eleva._components.keys())
780
+ });
781
+ }
782
+ return componentDef;
783
+ }
784
+
785
+ /**
786
+ * Resolves a function component definition to a component object.
787
+ * @private
788
+ * @param {Function} def - The function to resolve.
789
+ * @returns {Promise<ComponentDefinition>} The resolved component.
790
+ * @throws {Error} If the function fails to load the component.
791
+ */
792
+ async _resolveFunctionComponent(def) {
793
+ try {
794
+ const funcStr = def.toString();
795
+ const isAsyncImport = funcStr.includes("import(") || funcStr.startsWith("() =>");
796
+ const result = await def();
797
+ return isAsyncImport ? result.default || result : result;
798
+ } catch (error) {
799
+ this.errorHandler.handle(new Error(`Failed to load async component: ${error.message}`), "Component resolution failed", {
800
+ function: def.toString(),
801
+ error
802
+ });
803
+ }
804
+ }
805
+
806
+ /**
807
+ * Validates a component definition object.
808
+ * @private
809
+ * @param {any} def - The component definition to validate.
810
+ * @returns {ComponentDefinition} The validated component.
811
+ * @throws {Error} If the component definition is invalid.
812
+ */
813
+ _validateComponentDefinition(def) {
814
+ if (!def || typeof def !== "object") {
815
+ this.errorHandler.handle(new Error(`Invalid component definition: ${typeof def}`), "Component validation failed", {
816
+ definition: def
817
+ });
818
+ }
819
+ if (typeof def.template !== "function" && typeof def.template !== "string") {
820
+ this.errorHandler.handle(new Error("Component missing template property"), "Component validation failed", {
821
+ definition: def
822
+ });
823
+ }
824
+ return def;
825
+ }
826
+
827
+ /**
828
+ * Resolves a component definition to a component object.
829
+ * @private
830
+ * @param {any} def - The component definition to resolve.
831
+ * @returns {Promise<ComponentDefinition | null>} The resolved component or null.
832
+ */
833
+ async _resolveComponent(def) {
834
+ if (def === null || def === undefined) {
835
+ return null;
836
+ }
837
+ if (typeof def === "string") {
838
+ return this._resolveStringComponent(def);
839
+ }
840
+ if (typeof def === "function") {
841
+ return await this._resolveFunctionComponent(def);
842
+ }
843
+ if (def && typeof def === "object") {
844
+ return this._validateComponentDefinition(def);
845
+ }
846
+ this.errorHandler.handle(new Error(`Invalid component definition: ${typeof def}`), "Component resolution failed", {
847
+ definition: def
848
+ });
849
+ }
850
+
851
+ /**
852
+ * Asynchronously resolves the layout and page components for a route.
853
+ * @private
854
+ * @param {RouteDefinition} route - The route to resolve components for.
855
+ * @returns {Promise<{layoutComponent: ComponentDefinition | null, pageComponent: ComponentDefinition}>}
856
+ */
857
+ async _resolveComponents(route) {
858
+ const effectiveLayout = route.layout || this.options.globalLayout;
859
+ try {
860
+ const [layoutComponent, pageComponent] = await Promise.all([this._resolveComponent(effectiveLayout), this._resolveComponent(route.component)]);
861
+ if (!pageComponent) {
862
+ this.errorHandler.handle(new Error(`Page component is null or undefined for route: ${route.path}`), "Component resolution failed", {
863
+ route: route.path
864
+ });
865
+ }
866
+ return {
867
+ layoutComponent,
868
+ pageComponent
869
+ };
870
+ } catch (error) {
871
+ this.errorHandler.log(`Error resolving components for route ${route.path}`, error, {
872
+ route: route.path
873
+ });
874
+ throw error;
875
+ }
876
+ }
877
+
878
+ /**
879
+ * Renders the components for the current route into the DOM.
880
+ * @private
881
+ * @param {ComponentDefinition | null} layoutComponent - The pre-loaded layout component.
882
+ * @param {ComponentDefinition} pageComponent - The pre-loaded page component.
883
+ */
884
+ async _render(layoutComponent, pageComponent) {
885
+ const mountEl = document.querySelector(this.options.mount);
886
+ if (!mountEl) {
887
+ this.errorHandler.handle(new Error(`Mount element "${this.options.mount}" not found.`), {
888
+ mountSelector: this.options.mount
889
+ });
890
+ }
891
+ if (layoutComponent) {
892
+ const layoutInstance = await this.eleva.mount(mountEl, this._wrapComponentWithChildren(layoutComponent));
893
+ this.currentLayout.value = layoutInstance;
894
+ const viewEl = this._findViewElement(layoutInstance.container);
895
+ const viewInstance = await this.eleva.mount(viewEl, this._wrapComponentWithChildren(pageComponent));
896
+ this.currentView.value = viewInstance;
897
+ } else {
898
+ const viewInstance = await this.eleva.mount(mountEl, this._wrapComponentWithChildren(pageComponent));
899
+ this.currentView.value = viewInstance;
900
+ this.currentLayout.value = null;
901
+ }
902
+ }
903
+
904
+ /**
905
+ * Creates a getter function for router context properties.
906
+ * @private
907
+ * @param {string} property - The property name to access.
908
+ * @param {any} defaultValue - The default value if property is undefined.
909
+ * @returns {Function} A getter function.
910
+ */
911
+ _createRouteGetter(property, defaultValue) {
912
+ return () => this.currentRoute.value?.[property] ?? defaultValue;
913
+ }
914
+
915
+ /**
916
+ * Wraps a component definition to inject router-specific context into its setup function.
917
+ * @private
918
+ * @param {ComponentDefinition} component - The component to wrap.
919
+ * @returns {ComponentDefinition} The wrapped component definition.
920
+ */
921
+ _wrapComponent(component) {
922
+ const originalSetup = component.setup;
923
+ const self = this;
924
+ return {
925
+ ...component,
926
+ async setup(ctx) {
927
+ ctx.router = {
928
+ navigate: self.navigate.bind(self),
929
+ current: self.currentRoute,
930
+ previous: self.previousRoute,
931
+ // Route property getters
932
+ get params() {
933
+ return self._createRouteGetter("params", {})();
934
+ },
935
+ get query() {
936
+ return self._createRouteGetter("query", {})();
937
+ },
938
+ get path() {
939
+ return self._createRouteGetter("path", "/")();
940
+ },
941
+ get fullUrl() {
942
+ return self._createRouteGetter("fullUrl", window.location.href)();
943
+ },
944
+ get meta() {
945
+ return self._createRouteGetter("meta", {})();
946
+ }
947
+ };
948
+ return originalSetup ? await originalSetup(ctx) : {};
949
+ }
950
+ };
951
+ }
952
+
953
+ /**
954
+ * Recursively wraps all child components to ensure they have access to router context.
955
+ * @private
956
+ * @param {ComponentDefinition} component - The component to wrap.
957
+ * @returns {ComponentDefinition} The wrapped component definition.
958
+ */
959
+ _wrapComponentWithChildren(component) {
960
+ const wrappedComponent = this._wrapComponent(component);
961
+
962
+ // If the component has children, wrap them too
963
+ if (wrappedComponent.children && typeof wrappedComponent.children === "object") {
964
+ const wrappedChildren = {};
965
+ for (const [selector, childComponent] of Object.entries(wrappedComponent.children)) {
966
+ wrappedChildren[selector] = this._wrapComponentWithChildren(childComponent);
967
+ }
968
+ wrappedComponent.children = wrappedChildren;
969
+ }
970
+ return wrappedComponent;
971
+ }
972
+
973
+ /**
974
+ * Gets the current location information from the browser's window object.
975
+ * @private
976
+ * @returns {Omit<RouteLocation, 'params' | 'meta' | 'name' | 'matched'>}
977
+ */
978
+ _getCurrentLocation() {
979
+ if (typeof window === "undefined") return {
980
+ path: "/",
981
+ query: {},
982
+ fullUrl: ""
983
+ };
984
+ let path, queryString, fullUrl;
985
+ switch (this.options.mode) {
986
+ case "hash":
987
+ fullUrl = window.location.hash.slice(1) || "/";
988
+ [path, queryString] = fullUrl.split("?");
989
+ break;
990
+ case "query":
991
+ const urlParams = new URLSearchParams(window.location.search);
992
+ path = urlParams.get(this.options.queryParam) || "/";
993
+ queryString = window.location.search.slice(1);
994
+ fullUrl = path;
995
+ break;
996
+ default:
997
+ // 'history' mode
998
+ path = window.location.pathname || "/";
999
+ queryString = window.location.search.slice(1);
1000
+ fullUrl = `${path}${queryString ? "?" + queryString : ""}`;
1001
+ }
1002
+ return {
1003
+ path: path.startsWith("/") ? path : `/${path}`,
1004
+ query: this._parseQuery(queryString),
1005
+ fullUrl
1006
+ };
1007
+ }
1008
+
1009
+ /**
1010
+ * Parses a query string into a key-value object.
1011
+ * @private
1012
+ */
1013
+ _parseQuery(queryString) {
1014
+ const query = {};
1015
+ if (queryString) {
1016
+ new URLSearchParams(queryString).forEach((value, key) => {
1017
+ query[key] = value;
1018
+ });
1019
+ }
1020
+ return query;
1021
+ }
1022
+
1023
+ /**
1024
+ * Matches a given path against the registered routes.
1025
+ * @private
1026
+ * @param {string} path - The path to match.
1027
+ * @returns {{route: RouteDefinition, params: Object<string, string>} | null} The matched route and its params, or null.
1028
+ */
1029
+ _matchRoute(path) {
1030
+ const pathSegments = path.split("/").filter(Boolean);
1031
+ for (const route of this.routes) {
1032
+ // Handle the root path as a special case.
1033
+ if (route.path === "/") {
1034
+ if (pathSegments.length === 0) return {
1035
+ route,
1036
+ params: {}
1037
+ };
1038
+ continue;
1039
+ }
1040
+ if (route.segments.length !== pathSegments.length) continue;
1041
+ const params = {};
1042
+ let isMatch = true;
1043
+ for (let i = 0; i < route.segments.length; i++) {
1044
+ const routeSegment = route.segments[i];
1045
+ const pathSegment = pathSegments[i];
1046
+ if (routeSegment.type === "param") {
1047
+ params[routeSegment.name] = decodeURIComponent(pathSegment);
1048
+ } else if (routeSegment.value !== pathSegment) {
1049
+ isMatch = false;
1050
+ break;
1051
+ }
1052
+ }
1053
+ if (isMatch) return {
1054
+ route,
1055
+ params
1056
+ };
1057
+ }
1058
+ return null;
1059
+ }
1060
+
1061
+ /** Registers a global pre-navigation guard. */
1062
+ onBeforeEach(guard) {
1063
+ this.options.onBeforeEach = guard;
1064
+ }
1065
+ /** Registers a global hook that runs after a new route component has been mounted *if* the route has an `afterEnter` hook. */
1066
+ onAfterEnter(hook) {
1067
+ this.emitter.on("router:afterEnter", hook);
1068
+ }
1069
+ /** Registers a global hook that runs after a route component has been unmounted *if* the route has an `afterLeave` hook. */
1070
+ onAfterLeave(hook) {
1071
+ this.emitter.on("router:afterLeave", hook);
1072
+ }
1073
+ /** Registers a global hook that runs after a navigation has been confirmed and all hooks have completed. */
1074
+ onAfterEach(hook) {
1075
+ this.emitter.on("router:afterEach", hook);
1076
+ }
1077
+ /** Registers a global error handler for navigation. */
1078
+ onError(handler) {
1079
+ this.emitter.on("router:onError", handler);
1080
+ }
1081
+
1082
+ /**
1083
+ * Registers a plugin with the router.
1084
+ * @param {RouterPlugin} plugin - The plugin to register.
1085
+ */
1086
+ use(plugin, options = {}) {
1087
+ if (typeof plugin.install !== "function") {
1088
+ this.errorHandler.handle(new Error("Plugin must have an install method"), "Plugin registration failed", {
1089
+ plugin
1090
+ });
1091
+ }
1092
+
1093
+ // Check if plugin is already registered
1094
+ if (this.plugins.has(plugin.name)) {
1095
+ this.errorHandler.warn(`Plugin "${plugin.name}" is already registered`, {
1096
+ existingPlugin: this.plugins.get(plugin.name)
1097
+ });
1098
+ return;
1099
+ }
1100
+ this.plugins.set(plugin.name, plugin);
1101
+ plugin.install(this, options);
1102
+ }
1103
+
1104
+ /**
1105
+ * Gets all registered plugins.
1106
+ * @returns {RouterPlugin[]} Array of registered plugins.
1107
+ */
1108
+ getPlugins() {
1109
+ return Array.from(this.plugins.values());
1110
+ }
1111
+
1112
+ /**
1113
+ * Gets a plugin by name.
1114
+ * @param {string} name - The plugin name.
1115
+ * @returns {RouterPlugin | undefined} The plugin or undefined.
1116
+ */
1117
+ getPlugin(name) {
1118
+ return this.plugins.get(name);
1119
+ }
1120
+
1121
+ /**
1122
+ * Removes a plugin from the router.
1123
+ * @param {string} name - The plugin name.
1124
+ * @returns {boolean} True if the plugin was removed.
1125
+ */
1126
+ removePlugin(name) {
1127
+ const plugin = this.plugins.get(name);
1128
+ if (!plugin) return false;
1129
+
1130
+ // Call destroy if available
1131
+ if (typeof plugin.destroy === "function") {
1132
+ try {
1133
+ plugin.destroy(this);
1134
+ } catch (error) {
1135
+ this.errorHandler.log(`Plugin ${name} destroy failed`, error);
1136
+ }
1137
+ }
1138
+ return this.plugins.delete(name);
1139
+ }
1140
+
1141
+ /**
1142
+ * Sets a custom error handler. Used by error handling plugins.
1143
+ * @param {Object} errorHandler - The error handler object with handle, warn, and log methods.
1144
+ */
1145
+ setErrorHandler(errorHandler) {
1146
+ if (errorHandler && typeof errorHandler.handle === "function" && typeof errorHandler.warn === "function" && typeof errorHandler.log === "function") {
1147
+ this.errorHandler = errorHandler;
1148
+ } else {
1149
+ console.warn("[ElevaRouter] Invalid error handler provided. Must have handle, warn, and log methods.");
1150
+ }
1151
+ }
1152
+ }
1153
+
1154
+ /**
1155
+ * @typedef {Object} RouterOptions
1156
+ * @property {string} mount - A CSS selector for the main element where the app is mounted.
1157
+ * @property {RouteDefinition[]} routes - An array of route definitions.
1158
+ * @property {'hash' | 'query' | 'history'} [mode='hash'] - The routing mode.
1159
+ * @property {string} [queryParam='page'] - The query parameter to use in 'query' mode.
1160
+ * @property {string} [viewSelector='view'] - The selector for the view element within a layout.
1161
+ * @property {boolean} [autoStart=true] - Whether to start the router automatically.
1162
+ * @property {NavigationGuard} [onBeforeEach] - A global guard executed before every navigation.
1163
+ * @property {string | ComponentDefinition | (() => Promise<{default: ComponentDefinition}>)} [globalLayout] - A global layout for all routes. Can be overridden by a route's specific layout.
1164
+ */
1165
+
1166
+ /**
1167
+ * @class 🚀 RouterPlugin
1168
+ * @classdesc A powerful, reactive, and flexible Router Plugin for Eleva.js applications.
1169
+ * This plugin provides comprehensive client-side routing functionality including:
1170
+ * - Multiple routing modes (hash, history, query)
1171
+ * - Navigation guards and lifecycle hooks
1172
+ * - Reactive state management
1173
+ * - Component resolution and lazy loading
1174
+ * - Layout and page component separation
1175
+ * - Plugin system for extensibility
1176
+ * - Advanced error handling
1177
+ *
1178
+ * @example
1179
+ * // Install the plugin
1180
+ * const app = new Eleva("myApp");
1181
+ *
1182
+ * const HomePage = { template: () => `<h1>Home</h1>` };
1183
+ * const AboutPage = { template: () => `<h1>About Us</h1>` };
1184
+ * const UserPage = {
1185
+ * template: (ctx) => `<h1>User: ${ctx.router.params.id}</h1>`
1186
+ * };
1187
+ *
1188
+ * app.use(RouterPlugin, {
1189
+ * mount: '#app',
1190
+ * mode: 'hash',
1191
+ * routes: [
1192
+ * { path: '/', component: HomePage },
1193
+ * { path: '/about', component: AboutPage },
1194
+ * { path: '/users/:id', component: UserPage }
1195
+ * ]
1196
+ * });
1197
+ */
1198
+ const RouterPlugin = {
1199
+ /**
1200
+ * Unique identifier for the plugin
1201
+ * @type {string}
1202
+ */
1203
+ name: "router",
1204
+ /**
1205
+ * Plugin version
1206
+ * @type {string}
1207
+ */
1208
+ version: "1.0.0-rc.1",
1209
+ /**
1210
+ * Plugin description
1211
+ * @type {string}
1212
+ */
1213
+ description: "Client-side routing for Eleva applications",
1214
+ /**
1215
+ * Installs the RouterPlugin into an Eleva instance.
1216
+ *
1217
+ * @param {Eleva} eleva - The Eleva instance
1218
+ * @param {RouterOptions} options - Router configuration options
1219
+ * @param {string} options.mount - A CSS selector for the main element where the app is mounted
1220
+ * @param {RouteDefinition[]} options.routes - An array of route definitions
1221
+ * @param {'hash' | 'query' | 'history'} [options.mode='hash'] - The routing mode
1222
+ * @param {string} [options.queryParam='page'] - The query parameter to use in 'query' mode
1223
+ * @param {string} [options.viewSelector='view'] - The selector for the view element within a layout
1224
+ * @param {boolean} [options.autoStart=true] - Whether to start the router automatically
1225
+ * @param {NavigationGuard} [options.onBeforeEach] - A global guard executed before every navigation
1226
+ * @param {string | ComponentDefinition | (() => Promise<{default: ComponentDefinition}>)} [options.globalLayout] - A global layout for all routes
1227
+ *
1228
+ * @example
1229
+ * // main.js
1230
+ * import Eleva from './eleva.js';
1231
+ * import { RouterPlugin } from './plugins/RouterPlugin.js';
1232
+ *
1233
+ * const app = new Eleva('myApp');
1234
+ *
1235
+ * const HomePage = { template: () => `<h1>Home</h1>` };
1236
+ * const AboutPage = { template: () => `<h1>About Us</h1>` };
1237
+ *
1238
+ * app.use(RouterPlugin, {
1239
+ * mount: '#app',
1240
+ * routes: [
1241
+ * { path: '/', component: HomePage },
1242
+ * { path: '/about', component: AboutPage }
1243
+ * ]
1244
+ * });
1245
+ */
1246
+ install(eleva, options = {}) {
1247
+ if (!options.mount) {
1248
+ throw new Error("[RouterPlugin] 'mount' option is required");
1249
+ }
1250
+ if (!options.routes || !Array.isArray(options.routes)) {
1251
+ throw new Error("[RouterPlugin] 'routes' option must be an array");
1252
+ }
1253
+
1254
+ /**
1255
+ * Registers a component definition with the Eleva instance.
1256
+ * This method handles both inline component objects and pre-registered component names.
1257
+ *
1258
+ * @param {any} def - The component definition to register
1259
+ * @param {string} type - The type of component for naming (e.g., "Route", "Layout")
1260
+ * @returns {string | null} The registered component name or null if no definition provided
1261
+ */
1262
+ const register = (def, type) => {
1263
+ if (!def) return null;
1264
+ if (typeof def === "object" && def !== null && !def.name) {
1265
+ const name = `Eleva${type}Component_${Math.random().toString(36).slice(2, 11)}`;
1266
+ try {
1267
+ eleva.component(name, def);
1268
+ return name;
1269
+ } catch (error) {
1270
+ throw new Error(`[RouterPlugin] Failed to register ${type} component: ${error.message}`);
1271
+ }
1272
+ }
1273
+ return def;
1274
+ };
1275
+ if (options.globalLayout) {
1276
+ options.globalLayout = register(options.globalLayout, "GlobalLayout");
1277
+ }
1278
+ (options.routes || []).forEach(route => {
1279
+ route.component = register(route.component, "Route");
1280
+ if (route.layout) {
1281
+ route.layout = register(route.layout, "RouteLayout");
1282
+ }
1283
+ });
1284
+ const router = new Router(eleva, options);
1285
+ eleva.router = router;
1286
+ if (options.autoStart !== false) {
1287
+ queueMicrotask(() => router.start());
1288
+ }
1289
+
1290
+ // Add plugin metadata to the Eleva instance
1291
+ if (!eleva.plugins) {
1292
+ eleva.plugins = new Map();
1293
+ }
1294
+ eleva.plugins.set(this.name, {
1295
+ name: this.name,
1296
+ version: this.version,
1297
+ description: this.description,
1298
+ options
1299
+ });
1300
+
1301
+ // Add utility methods for manual router access
1302
+ eleva.navigate = router.navigate.bind(router);
1303
+ eleva.getCurrentRoute = () => router.currentRoute.value;
1304
+ eleva.getRouteParams = () => router.currentParams.value;
1305
+ eleva.getRouteQuery = () => router.currentQuery.value;
1306
+ return router;
1307
+ },
1308
+ /**
1309
+ * Uninstalls the plugin from the Eleva instance
1310
+ *
1311
+ * @param {Eleva} eleva - The Eleva instance
1312
+ */
1313
+ async uninstall(eleva) {
1314
+ if (eleva.router) {
1315
+ await eleva.router.destroy();
1316
+ delete eleva.router;
1317
+ }
1318
+
1319
+ // Remove plugin metadata
1320
+ if (eleva.plugins) {
1321
+ eleva.plugins.delete(this.name);
1322
+ }
1323
+
1324
+ // Remove utility methods
1325
+ delete eleva.navigate;
1326
+ delete eleva.getCurrentRoute;
1327
+ delete eleva.getRouteParams;
1328
+ delete eleva.getRouteQuery;
1329
+ }
1330
+ };
1331
+
1332
+ exports.Attr = AttrPlugin;
1333
+ exports.Router = RouterPlugin;
1334
+
1335
+ }));
1336
+ //# sourceMappingURL=eleva-plugins.umd.js.map