UNPKG

eleva

Version:

A minimalist and lightweight, pure vanilla JavaScript frontend runtime framework.

1,390 lines (1,300 loc) 90.1 kB
/*! Eleva Plugins v1.0.0-rc.7 | MIT License | https://elevajs.com */ /** * A regular expression to match hyphenated lowercase letters. * @private * @type {RegExp} */ const CAMEL_RE = /-([a-z])/g; /** * @class 🎯 AttrPlugin * @classdesc A plugin that provides advanced attribute handling for Eleva components. * This plugin extends the renderer with sophisticated attribute processing including: * - ARIA attribute handling with proper property mapping * - Data attribute management * - Boolean attribute processing * - Dynamic property detection and mapping * - Attribute cleanup and removal * * @example * // Install the plugin * const app = new Eleva("myApp"); * app.use(AttrPlugin); * * // Use advanced attributes in components * app.component("myComponent", { * template: (ctx) => ` * <button * aria-expanded="${ctx.isExpanded.value}" * data-user-id="${ctx.userId.value}" * disabled="${ctx.isLoading.value}" * class="btn ${ctx.variant.value}" * > * ${ctx.text.value} * </button> * ` * }); */ const AttrPlugin = { /** * Unique identifier for the plugin * @type {string} */ name: "attr", /** * Plugin version * @type {string} */ version: "1.0.0-rc.1", /** * Plugin description * @type {string} */ description: "Advanced attribute handling for Eleva components", /** * Installs the plugin into the Eleva instance * * @param {Object} eleva - The Eleva instance * @param {Object} options - Plugin configuration options * @param {boolean} [options.enableAria=true] - Enable ARIA attribute handling * @param {boolean} [options.enableData=true] - Enable data attribute handling * @param {boolean} [options.enableBoolean=true] - Enable boolean attribute handling * @param {boolean} [options.enableDynamic=true] - Enable dynamic property detection */ install(eleva, options = {}) { const { enableAria = true, enableData = true, enableBoolean = true, enableDynamic = true } = options; /** * Updates the attributes of an element to match a new element's attributes. * This method provides sophisticated attribute processing including: * - ARIA attribute handling with proper property mapping * - Data attribute management * - Boolean attribute processing * - Dynamic property detection and mapping * - Attribute cleanup and removal * * @param {HTMLElement} oldEl - The original element to update * @param {HTMLElement} newEl - The new element to update * @returns {void} */ const updateAttributes = (oldEl, newEl) => { const oldAttrs = oldEl.attributes; const newAttrs = newEl.attributes; // Process new attributes for (let i = 0; i < newAttrs.length; i++) { const { name, value } = newAttrs[i]; // Skip event attributes (handled by event system) if (name.startsWith("@")) continue; // Skip if attribute hasn't changed if (oldEl.getAttribute(name) === value) continue; // Handle ARIA attributes if (enableAria && name.startsWith("aria-")) { const prop = "aria" + name.slice(5).replace(CAMEL_RE, (_, l) => l.toUpperCase()); oldEl[prop] = value; oldEl.setAttribute(name, value); } // Handle data attributes else if (enableData && name.startsWith("data-")) { oldEl.dataset[name.slice(5)] = value; oldEl.setAttribute(name, value); } // Handle other attributes else { let prop = name.replace(CAMEL_RE, (_, l) => l.toUpperCase()); // Dynamic property detection if (enableDynamic && !(prop in oldEl) && !Object.getOwnPropertyDescriptor(Object.getPrototypeOf(oldEl), prop)) { const elementProps = Object.getOwnPropertyNames(Object.getPrototypeOf(oldEl)); const matchingProp = elementProps.find(p => p.toLowerCase() === name.toLowerCase() || p.toLowerCase().includes(name.toLowerCase()) || name.toLowerCase().includes(p.toLowerCase())); if (matchingProp) { prop = matchingProp; } } const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(oldEl), prop); const hasProperty = prop in oldEl || descriptor; if (hasProperty) { // Boolean attribute handling if (enableBoolean) { const isBoolean = typeof oldEl[prop] === "boolean" || (descriptor == null ? void 0 : descriptor.get) && typeof descriptor.get.call(oldEl) === "boolean"; if (isBoolean) { const boolValue = value !== "false" && (value === "" || value === prop || value === "true"); oldEl[prop] = boolValue; if (boolValue) { oldEl.setAttribute(name, ""); } else { oldEl.removeAttribute(name); } } else { oldEl[prop] = value; oldEl.setAttribute(name, value); } } else { oldEl[prop] = value; oldEl.setAttribute(name, value); } } else { oldEl.setAttribute(name, value); } } } // Remove old attributes that are no longer present for (let i = oldAttrs.length - 1; i >= 0; i--) { const name = oldAttrs[i].name; if (!newEl.hasAttribute(name)) { oldEl.removeAttribute(name); } } }; // Extend the renderer with the advanced attribute handler if (eleva.renderer) { eleva.renderer.updateAttributes = updateAttributes; // Store the original _patchNode method const originalPatchNode = eleva.renderer._patchNode; eleva.renderer._originalPatchNode = originalPatchNode; // Override the _patchNode method to use our attribute handler eleva.renderer._patchNode = function (oldNode, newNode) { if (oldNode != null && oldNode._eleva_instance) return; if (!this._isSameNode(oldNode, newNode)) { oldNode.replaceWith(newNode.cloneNode(true)); return; } if (oldNode.nodeType === Node.ELEMENT_NODE) { updateAttributes(oldNode, newNode); this._diff(oldNode, newNode); } else if (oldNode.nodeType === Node.TEXT_NODE && oldNode.nodeValue !== newNode.nodeValue) { oldNode.nodeValue = newNode.nodeValue; } }; } // Add plugin metadata to the Eleva instance if (!eleva.plugins) { eleva.plugins = new Map(); } eleva.plugins.set(this.name, { name: this.name, version: this.version, description: this.description, options }); // Add utility methods for manual attribute updates eleva.updateElementAttributes = updateAttributes; }, /** * Uninstalls the plugin from the Eleva instance * * @param {Object} eleva - The Eleva instance */ uninstall(eleva) { // Restore original _patchNode method if it exists if (eleva.renderer && eleva.renderer._originalPatchNode) { eleva.renderer._patchNode = eleva.renderer._originalPatchNode; delete eleva.renderer._originalPatchNode; } // Remove plugin metadata if (eleva.plugins) { eleva.plugins.delete(this.name); } // Remove utility methods delete eleva.updateElementAttributes; } }; function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } const CoreErrorHandler = { /** * Handles router errors with basic formatting. * @param {Error} error - The error to handle. * @param {string} context - The context where the error occurred. * @param {Object} details - Additional error details. * @throws {Error} The formatted error. */ handle(error, context, details = {}) { const message = `[ElevaRouter] ${context}: ${error.message}`; const formattedError = new Error(message); // Preserve original error details formattedError.originalError = error; formattedError.context = context; formattedError.details = details; console.error(message, { error, context, details }); throw formattedError; }, /** * Logs a warning without throwing an error. * @param {string} message - The warning message. * @param {Object} details - Additional warning details. */ warn(message, details = {}) { console.warn(`[ElevaRouter] ${message}`, details); }, /** * Logs an error without throwing. * @param {string} message - The error message. * @param {Error} error - The original error. * @param {Object} details - Additional error details. */ log(message, error, details = {}) { console.error(`[ElevaRouter] ${message}`, { error, details }); } }; /** * @typedef {Object} RouteLocation * @property {string} path - The path of the route (e.g., '/users/123'). * @property {Object<string, string>} query - An object representing the query parameters. * @property {string} fullUrl - The complete URL including hash, path, and query string. * @property {Object<string, string>} params - An object containing dynamic route parameters. * @property {Object<string, any>} meta - The meta object associated with the matched route. * @property {string} [name] - The optional name of the matched route. * @property {RouteDefinition} matched - The raw route definition object that was matched. */ /** * @typedef {(to: RouteLocation, from: RouteLocation | null) => boolean | string | {path: string} | void | Promise<boolean | string | {path: string} | void>} NavigationGuard * A function that acts as a guard for navigation. It runs *before* the navigation is confirmed. * It can return: * - `true` or `undefined`: to allow navigation. * - `false`: to abort the navigation. * - a `string` (path) or a `location object`: to redirect to a new route. */ /** * @typedef {(...args: any[]) => void | Promise<void>} NavigationHook * A function that acts as a lifecycle hook, typically for side effects. It does not affect navigation flow. */ /** * @typedef {Object} RouterPlugin * @property {string} name - The plugin name. * @property {string} [version] - The plugin version. * @property {Function} install - The install function that receives the router instance. * @property {Function} [destroy] - Optional cleanup function called when the router is destroyed. */ /** * @typedef {Object} RouteDefinition * @property {string} path - The URL path pattern (e.g., '/', '/about', '/users/:id', '*'). * @property {string | ComponentDefinition | (() => Promise<{default: ComponentDefinition}>)} component - The component to render. Can be a registered name, a definition object, or an async import function. * @property {string | ComponentDefinition | (() => Promise<{default: ComponentDefinition}>)} [layout] - An optional layout component to wrap the route's component. * @property {string} [name] - An optional name for the route. * @property {Object<string, any>} [meta] - Optional metadata for the route (e.g., for titles, auth flags). * @property {NavigationGuard} [beforeEnter] - A route-specific guard executed before entering the route. * @property {NavigationHook} [afterEnter] - A hook executed *after* the route has been entered and the new component is mounted. * @property {NavigationGuard} [beforeLeave] - A guard executed *before* leaving the current route. * @property {NavigationHook} [afterLeave] - A hook executed *after* leaving the current route and its component has been unmounted. */ /** * @class Router * @classdesc A powerful, reactive, and flexible Router Plugin for Eleva.js. * This class manages all routing logic, including state, navigation, and rendering. * @private */ class Router { /** * Creates an instance of the Router. * @param {Eleva} eleva - The Eleva framework instance. * @param {RouterOptions} options - The configuration options for the router. */ constructor(eleva, options = {}) { /** @type {Eleva} The Eleva framework instance. */ this.eleva = eleva; /** @type {RouterOptions} The merged router options. */ this.options = _extends({ mode: "hash", queryParam: "view", viewSelector: "root" }, options); /** @private @type {RouteDefinition[]} The processed list of route definitions. */ this.routes = this._processRoutes(options.routes || []); /** @private @type {import('eleva').Emitter} The shared Eleva event emitter for global hooks. */ this.emitter = this.eleva.emitter; /** @private @type {boolean} A flag indicating if the router has been started. */ this.isStarted = false; /** @private @type {boolean} A flag to prevent navigation loops from history events. */ this._isNavigating = false; /** @private @type {Array<() => void>} A collection of cleanup functions for event listeners. */ this.eventListeners = []; /** @type {Signal<RouteLocation | null>} A reactive signal holding the current route's information. */ this.currentRoute = new this.eleva.signal(null); /** @type {Signal<RouteLocation | null>} A reactive signal holding the previous route's information. */ this.previousRoute = new this.eleva.signal(null); /** @type {Signal<Object<string, string>>} A reactive signal holding the current route's parameters. */ this.currentParams = new this.eleva.signal({}); /** @type {Signal<Object<string, string>>} A reactive signal holding the current route's query parameters. */ this.currentQuery = new this.eleva.signal({}); /** @type {Signal<import('eleva').MountResult | null>} A reactive signal for the currently mounted layout instance. */ this.currentLayout = new this.eleva.signal(null); /** @type {Signal<import('eleva').MountResult | null>} A reactive signal for the currently mounted view (page) instance. */ this.currentView = new this.eleva.signal(null); /** @private @type {Map<string, RouterPlugin>} Map of registered plugins by name. */ this.plugins = new Map(); /** @type {Object} The error handler instance. Can be overridden by plugins. */ this.errorHandler = CoreErrorHandler; this._validateOptions(); } /** * Validates the provided router options. * @private * @throws {Error} If the routing mode is invalid. */ _validateOptions() { if (!["hash", "query", "history"].includes(this.options.mode)) { this.errorHandler.handle(new Error(`Invalid routing mode: ${this.options.mode}. Must be "hash", "query", or "history".`), "Configuration validation failed"); } } /** * Pre-processes route definitions to parse their path segments for efficient matching. * @private * @param {RouteDefinition[]} routes - The raw route definitions. * @returns {RouteDefinition[]} The processed routes. */ _processRoutes(routes) { const processedRoutes = []; for (const route of routes) { try { processedRoutes.push(_extends({}, route, { segments: this._parsePathIntoSegments(route.path) })); } catch (error) { this.errorHandler.warn(`Invalid path in route definition "${route.path || "undefined"}": ${error.message}`, { route, error }); } } return processedRoutes; } /** * Parses a route path string into an array of static and parameter segments. * @private * @param {string} path - The path pattern to parse. * @returns {Array<{type: 'static' | 'param', value?: string, name?: string}>} An array of segment objects. * @throws {Error} If the route path is not a valid string. */ _parsePathIntoSegments(path) { if (!path || typeof path !== "string") { this.errorHandler.handle(new Error("Route path must be a non-empty string"), "Path parsing failed", { path }); } const normalizedPath = path.replace(/\/+/g, "/").replace(/\/$/, "") || "/"; if (normalizedPath === "/") { return []; } return normalizedPath.split("/").filter(Boolean).map(segment => { if (segment.startsWith(":")) { const paramName = segment.substring(1); if (!paramName) { this.errorHandler.handle(new Error(`Invalid parameter segment: ${segment}`), "Path parsing failed", { segment, path }); } return { type: "param", name: paramName }; } return { type: "static", value: segment }; }); } /** * Finds the view element within a container using multiple selector strategies. * @private * @param {HTMLElement} container - The parent element to search within. * @returns {HTMLElement} The found view element or the container itself as a fallback. */ _findViewElement(container) { const selector = this.options.viewSelector; return container.querySelector(`#${selector}`) || container.querySelector(`.${selector}`) || container.querySelector(`[data-${selector}]`) || container.querySelector(selector) || container; } /** * Starts the router, initializes event listeners, and performs the initial navigation. * @returns {Promise<void>} */ async start() { if (this.isStarted) { this.errorHandler.warn("Router is already started"); return; } if (typeof window === "undefined") { this.errorHandler.warn("Router start skipped: `window` object not available (SSR environment)"); return; } if (typeof document !== "undefined" && !document.querySelector(this.options.mount)) { this.errorHandler.warn(`Mount element "${this.options.mount}" was not found in the DOM. The router will not start.`, { mountSelector: this.options.mount }); return; } const handler = () => this._handleRouteChange(); if (this.options.mode === "hash") { window.addEventListener("hashchange", handler); this.eventListeners.push(() => window.removeEventListener("hashchange", handler)); } else { window.addEventListener("popstate", handler); this.eventListeners.push(() => window.removeEventListener("popstate", handler)); } this.isStarted = true; await this._handleRouteChange(); } /** * Stops the router and cleans up all event listeners and mounted components. * @returns {Promise<void>} */ async destroy() { if (!this.isStarted) return; // Clean up plugins for (const plugin of this.plugins.values()) { if (typeof plugin.destroy === "function") { try { await plugin.destroy(this); } catch (error) { this.errorHandler.log(`Plugin ${plugin.name} destroy failed`, error); } } } this.eventListeners.forEach(cleanup => cleanup()); this.eventListeners = []; if (this.currentLayout.value) { await this.currentLayout.value.unmount(); } this.isStarted = false; } /** * Programmatically navigates to a new route. * @param {string | {path: string, query?: object, params?: object, replace?: boolean, state?: object}} location - The target location as a string or object. * @param {object} [params] - Optional route parameters (for string-based location). * @returns {Promise<void>} */ async navigate(location, params = {}) { try { const target = typeof location === "string" ? { path: location, params } : location; let path = this._buildPath(target.path, target.params || {}); const query = target.query || {}; if (Object.keys(query).length > 0) { const queryString = new URLSearchParams(query).toString(); if (queryString) path += `?${queryString}`; } if (this._isSameRoute(path, target.params, query)) { return; } const navigationSuccessful = await this._proceedWithNavigation(path); if (navigationSuccessful) { this._isNavigating = true; const state = target.state || {}; const replace = target.replace || false; const historyMethod = replace ? "replaceState" : "pushState"; if (this.options.mode === "hash") { if (replace) { const newUrl = `${window.location.pathname}${window.location.search}#${path}`; window.history.replaceState(state, "", newUrl); } else { window.location.hash = path; } } else { const url = this.options.mode === "query" ? this._buildQueryUrl(path) : path; history[historyMethod](state, "", url); } queueMicrotask(() => { this._isNavigating = false; }); } } catch (error) { this.errorHandler.log("Navigation failed", error); await this.emitter.emit("router:onError", error); } } /** * Builds a URL for query mode. * @private * @param {string} path - The path to set as the query parameter. * @returns {string} The full URL with the updated query string. */ _buildQueryUrl(path) { const urlParams = new URLSearchParams(window.location.search); urlParams.set(this.options.queryParam, path.split("?")[0]); return `${window.location.pathname}?${urlParams.toString()}`; } /** * Checks if the target route is identical to the current route. * @private * @param {string} path - The target path with query string. * @param {object} params - The target params. * @param {object} query - The target query. * @returns {boolean} - True if the routes are the same. */ _isSameRoute(path, params, query) { const current = this.currentRoute.value; if (!current) return false; const [targetPath, queryString] = path.split("?"); const targetQuery = query || this._parseQuery(queryString || ""); return current.path === targetPath && JSON.stringify(current.params) === JSON.stringify(params || {}) && JSON.stringify(current.query) === JSON.stringify(targetQuery); } /** * Injects dynamic parameters into a path string. * @private */ _buildPath(path, params) { let result = path; for (const [key, value] of Object.entries(params)) { // Fix: Handle special characters and ensure proper encoding const encodedValue = encodeURIComponent(String(value)); result = result.replace(new RegExp(`:${key}\\b`, "g"), encodedValue); } return result; } /** * The handler for browser-initiated route changes (e.g., back/forward buttons). * @private */ async _handleRouteChange() { if (this._isNavigating) return; const from = this.currentRoute.value; const toLocation = this._getCurrentLocation(); const navigationSuccessful = await this._proceedWithNavigation(toLocation.fullUrl); // If navigation was blocked by a guard, revert the URL change if (!navigationSuccessful && from) { this.navigate({ path: from.path, query: from.query, replace: true }); } } /** * Manages the core navigation lifecycle. Runs guards before committing changes. * @private * @param {string} fullPath - The full path (e.g., '/users/123?foo=bar') to navigate to. * @returns {Promise<boolean>} - `true` if navigation succeeded, `false` if aborted. */ async _proceedWithNavigation(fullPath) { const from = this.currentRoute.value; const [path, queryString] = (fullPath || "/").split("?"); const toLocation = { path: path.startsWith("/") ? path : `/${path}`, query: this._parseQuery(queryString), fullUrl: fullPath }; let toMatch = this._matchRoute(toLocation.path); if (!toMatch) { const notFoundRoute = this.routes.find(route => route.path === "*"); if (notFoundRoute) { toMatch = { route: notFoundRoute, params: { pathMatch: toLocation.path.substring(1) } }; } else { await this.emitter.emit("router:onError", new Error(`Route not found: ${toLocation.path}`), toLocation, from); return false; } } const to = _extends({}, toLocation, { params: toMatch.params, meta: toMatch.route.meta || {}, name: toMatch.route.name, matched: toMatch.route }); try { // 1. Run all *pre-navigation* guards. const canNavigate = await this._runGuards(to, from, toMatch.route); if (!canNavigate) return false; // 2. Resolve async components *before* touching the DOM. const { layoutComponent, pageComponent } = await this._resolveComponents(toMatch.route); // 3. Unmount the previous view/layout. if (from) { const toLayout = toMatch.route.layout || this.options.globalLayout; const fromLayout = from.matched.layout || this.options.globalLayout; const tryUnmount = async instance => { if (!instance) return; try { await instance.unmount(); } catch (error) { this.errorHandler.warn("Error during component unmount", { error, instance }); } }; if (toLayout !== fromLayout) { await tryUnmount(this.currentLayout.value); this.currentLayout.value = null; } else { await tryUnmount(this.currentView.value); this.currentView.value = null; } // 4. Call `afterLeave` hook *after* the old component has been unmounted. if (from.matched.afterLeave) { await from.matched.afterLeave(to, from); await this.emitter.emit("router:afterLeave", to, from); } } // 5. Update reactive state. this.previousRoute.value = from; this.currentRoute.value = to; this.currentParams.value = to.params || {}; this.currentQuery.value = to.query || {}; // 6. Render the new components. await this._render(layoutComponent, pageComponent, to); // 7. Run post-navigation hooks. if (toMatch.route.afterEnter) { await toMatch.route.afterEnter(to, from); await this.emitter.emit("router:afterEnter", to, from); } await this.emitter.emit("router:afterEach", to, from); return true; } catch (error) { this.errorHandler.log("Error during navigation", error, { to, from }); await this.emitter.emit("router:onError", error, to, from); return false; } } /** * Executes all applicable navigation guards for a transition in order. * @private * @returns {Promise<boolean>} - `false` if navigation should be aborted. */ async _runGuards(to, from, route) { const guards = [...(this.options.onBeforeEach ? [this.options.onBeforeEach] : []), ...(from && from.matched.beforeLeave ? [from.matched.beforeLeave] : []), ...(route.beforeEnter ? [route.beforeEnter] : [])]; for (const guard of guards) { const result = await guard(to, from); if (result === false) return false; if (typeof result === "string" || typeof result === "object") { this.navigate(result); return false; } } return true; } /** * Resolves a string component definition to a component object. * @private * @param {string} def - The component name to resolve. * @returns {ComponentDefinition} The resolved component. * @throws {Error} If the component is not registered. */ _resolveStringComponent(def) { const componentDef = this.eleva._components.get(def); if (!componentDef) { this.errorHandler.handle(new Error(`Component "${def}" not registered.`), "Component resolution failed", { componentName: def, availableComponents: Array.from(this.eleva._components.keys()) }); } return componentDef; } /** * Resolves a function component definition to a component object. * @private * @param {Function} def - The function to resolve. * @returns {Promise<ComponentDefinition>} The resolved component. * @throws {Error} If the function fails to load the component. */ async _resolveFunctionComponent(def) { try { const funcStr = def.toString(); const isAsyncImport = funcStr.includes("import(") || funcStr.startsWith("() =>"); const result = await def(); return isAsyncImport ? result.default || result : result; } catch (error) { this.errorHandler.handle(new Error(`Failed to load async component: ${error.message}`), "Component resolution failed", { function: def.toString(), error }); } } /** * Validates a component definition object. * @private * @param {any} def - The component definition to validate. * @returns {ComponentDefinition} The validated component. * @throws {Error} If the component definition is invalid. */ _validateComponentDefinition(def) { if (!def || typeof def !== "object") { this.errorHandler.handle(new Error(`Invalid component definition: ${typeof def}`), "Component validation failed", { definition: def }); } if (typeof def.template !== "function" && typeof def.template !== "string") { this.errorHandler.handle(new Error("Component missing template property"), "Component validation failed", { definition: def }); } return def; } /** * Resolves a component definition to a component object. * @private * @param {any} def - The component definition to resolve. * @returns {Promise<ComponentDefinition | null>} The resolved component or null. */ async _resolveComponent(def) { if (def === null || def === undefined) { return null; } if (typeof def === "string") { return this._resolveStringComponent(def); } if (typeof def === "function") { return await this._resolveFunctionComponent(def); } if (def && typeof def === "object") { return this._validateComponentDefinition(def); } this.errorHandler.handle(new Error(`Invalid component definition: ${typeof def}`), "Component resolution failed", { definition: def }); } /** * Asynchronously resolves the layout and page components for a route. * @private * @param {RouteDefinition} route - The route to resolve components for. * @returns {Promise<{layoutComponent: ComponentDefinition | null, pageComponent: ComponentDefinition}>} */ async _resolveComponents(route) { const effectiveLayout = route.layout || this.options.globalLayout; try { const [layoutComponent, pageComponent] = await Promise.all([this._resolveComponent(effectiveLayout), this._resolveComponent(route.component)]); if (!pageComponent) { this.errorHandler.handle(new Error(`Page component is null or undefined for route: ${route.path}`), "Component resolution failed", { route: route.path }); } return { layoutComponent, pageComponent }; } catch (error) { this.errorHandler.log(`Error resolving components for route ${route.path}`, error, { route: route.path }); throw error; } } /** * Renders the components for the current route into the DOM. * @private * @param {ComponentDefinition | null} layoutComponent - The pre-loaded layout component. * @param {ComponentDefinition} pageComponent - The pre-loaded page component. */ async _render(layoutComponent, pageComponent) { const mountEl = document.querySelector(this.options.mount); if (!mountEl) { this.errorHandler.handle(new Error(`Mount element "${this.options.mount}" not found.`), { mountSelector: this.options.mount }); } if (layoutComponent) { const layoutInstance = await this.eleva.mount(mountEl, this._wrapComponentWithChildren(layoutComponent)); this.currentLayout.value = layoutInstance; const viewEl = this._findViewElement(layoutInstance.container); const viewInstance = await this.eleva.mount(viewEl, this._wrapComponentWithChildren(pageComponent)); this.currentView.value = viewInstance; } else { const viewInstance = await this.eleva.mount(mountEl, this._wrapComponentWithChildren(pageComponent)); this.currentView.value = viewInstance; this.currentLayout.value = null; } } /** * Creates a getter function for router context properties. * @private * @param {string} property - The property name to access. * @param {any} defaultValue - The default value if property is undefined. * @returns {Function} A getter function. */ _createRouteGetter(property, defaultValue) { return () => { var _this$currentRoute$va, _this$currentRoute$va2; return (_this$currentRoute$va = (_this$currentRoute$va2 = this.currentRoute.value) == null ? void 0 : _this$currentRoute$va2[property]) != null ? _this$currentRoute$va : defaultValue; }; } /** * Wraps a component definition to inject router-specific context into its setup function. * @private * @param {ComponentDefinition} component - The component to wrap. * @returns {ComponentDefinition} The wrapped component definition. */ _wrapComponent(component) { const originalSetup = component.setup; const self = this; return _extends({}, component, { async setup(ctx) { ctx.router = { navigate: self.navigate.bind(self), current: self.currentRoute, previous: self.previousRoute, // Route property getters get params() { return self._createRouteGetter("params", {})(); }, get query() { return self._createRouteGetter("query", {})(); }, get path() { return self._createRouteGetter("path", "/")(); }, get fullUrl() { return self._createRouteGetter("fullUrl", window.location.href)(); }, get meta() { return self._createRouteGetter("meta", {})(); } }; return originalSetup ? await originalSetup(ctx) : {}; } }); } /** * Recursively wraps all child components to ensure they have access to router context. * @private * @param {ComponentDefinition} component - The component to wrap. * @returns {ComponentDefinition} The wrapped component definition. */ _wrapComponentWithChildren(component) { const wrappedComponent = this._wrapComponent(component); // If the component has children, wrap them too if (wrappedComponent.children && typeof wrappedComponent.children === "object") { const wrappedChildren = {}; for (const [selector, childComponent] of Object.entries(wrappedComponent.children)) { wrappedChildren[selector] = this._wrapComponentWithChildren(childComponent); } wrappedComponent.children = wrappedChildren; } return wrappedComponent; } /** * Gets the current location information from the browser's window object. * @private * @returns {Omit<RouteLocation, 'params' | 'meta' | 'name' | 'matched'>} */ _getCurrentLocation() { if (typeof window === "undefined") return { path: "/", query: {}, fullUrl: "" }; let path, queryString, fullUrl; switch (this.options.mode) { case "hash": fullUrl = window.location.hash.slice(1) || "/"; [path, queryString] = fullUrl.split("?"); break; case "query": const urlParams = new URLSearchParams(window.location.search); path = urlParams.get(this.options.queryParam) || "/"; queryString = window.location.search.slice(1); fullUrl = path; break; default: // 'history' mode path = window.location.pathname || "/"; queryString = window.location.search.slice(1); fullUrl = `${path}${queryString ? "?" + queryString : ""}`; } return { path: path.startsWith("/") ? path : `/${path}`, query: this._parseQuery(queryString), fullUrl }; } /** * Parses a query string into a key-value object. * @private */ _parseQuery(queryString) { const query = {}; if (queryString) { new URLSearchParams(queryString).forEach((value, key) => { query[key] = value; }); } return query; } /** * Matches a given path against the registered routes. * @private * @param {string} path - The path to match. * @returns {{route: RouteDefinition, params: Object<string, string>} | null} The matched route and its params, or null. */ _matchRoute(path) { const pathSegments = path.split("/").filter(Boolean); for (const route of this.routes) { // Handle the root path as a special case. if (route.path === "/") { if (pathSegments.length === 0) return { route, params: {} }; continue; } if (route.segments.length !== pathSegments.length) continue; const params = {}; let isMatch = true; for (let i = 0; i < route.segments.length; i++) { const routeSegment = route.segments[i]; const pathSegment = pathSegments[i]; if (routeSegment.type === "param") { params[routeSegment.name] = decodeURIComponent(pathSegment); } else if (routeSegment.value !== pathSegment) { isMatch = false; break; } } if (isMatch) return { route, params }; } return null; } /** Registers a global pre-navigation guard. */ onBeforeEach(guard) { this.options.onBeforeEach = guard; } /** Registers a global hook that runs after a new route component has been mounted *if* the route has an `afterEnter` hook. */ onAfterEnter(hook) { this.emitter.on("router:afterEnter", hook); } /** Registers a global hook that runs after a route component has been unmounted *if* the route has an `afterLeave` hook. */ onAfterLeave(hook) { this.emitter.on("router:afterLeave", hook); } /** Registers a global hook that runs after a navigation has been confirmed and all hooks have completed. */ onAfterEach(hook) { this.emitter.on("router:afterEach", hook); } /** Registers a global error handler for navigation. */ onError(handler) { this.emitter.on("router:onError", handler); } /** * Registers a plugin with the router. * @param {RouterPlugin} plugin - The plugin to register. */ use(plugin, options = {}) { if (typeof plugin.install !== "function") { this.errorHandler.handle(new Error("Plugin must have an install method"), "Plugin registration failed", { plugin }); } // Check if plugin is already registered if (this.plugins.has(plugin.name)) { this.errorHandler.warn(`Plugin "${plugin.name}" is already registered`, { existingPlugin: this.plugins.get(plugin.name) }); return; } this.plugins.set(plugin.name, plugin); plugin.install(this, options); } /** * Gets all registered plugins. * @returns {RouterPlugin[]} Array of registered plugins. */ getPlugins() { return Array.from(this.plugins.values()); } /** * Gets a plugin by name. * @param {string} name - The plugin name. * @returns {RouterPlugin | undefined} The plugin or undefined. */ getPlugin(name) { return this.plugins.get(name); } /** * Removes a plugin from the router. * @param {string} name - The plugin name. * @returns {boolean} True if the plugin was removed. */ removePlugin(name) { const plugin = this.plugins.get(name); if (!plugin) return false; // Call destroy if available if (typeof plugin.destroy === "function") { try { plugin.destroy(this); } catch (error) { this.errorHandler.log(`Plugin ${name} destroy failed`, error); } } return this.plugins.delete(name); } /** * Sets a custom error handler. Used by error handling plugins. * @param {Object} errorHandler - The error handler object with handle, warn, and log methods. */ setErrorHandler(errorHandler) { if (errorHandler && typeof errorHandler.handle === "function" && typeof errorHandler.warn === "function" && typeof errorHandler.log === "function") { this.errorHandler = errorHandler; } else { console.warn("[ElevaRouter] Invalid error handler provided. Must have handle, warn, and log methods."); } } } /** * @typedef {Object} RouterOptions * @property {string} mount - A CSS selector for the main element where the app is mounted. * @property {RouteDefinition[]} routes - An array of route definitions. * @property {'hash' | 'query' | 'history'} [mode='hash'] - The routing mode. * @property {string} [queryParam='page'] - The query parameter to use in 'query' mode. * @property {string} [viewSelector='view'] - The selector for the view element within a layout. * @property {boolean} [autoStart=true] - Whether to start the router automatically. * @property {NavigationGuard} [onBeforeEach] - A global guard executed before every navigation. * @property {string | ComponentDefinition | (() => Promise<{default: ComponentDefinition}>)} [globalLayout] - A global layout for all routes. Can be overridden by a route's specific layout. */ /** * @class 🚀 RouterPlugin * @classdesc A powerful, reactive, and flexible Router Plugin for Eleva.js applications. * This plugin provides comprehensive client-side routing functionality including: * - Multiple routing modes (hash, history, query) * - Navigation guards and lifecycle hooks * - Reactive state management * - Component resolution and lazy loading * - Layout and page component separation * - Plugin system for extensibility * - Advanced error handling * * @example * // Install the plugin * const app = new Eleva("myApp"); * * const HomePage = { template: () => `<h1>Home</h1>` }; * const AboutPage = { template: () => `<h1>About Us</h1>` }; * const UserPage = { * template: (ctx) => `<h1>User: ${ctx.router.params.id}</h1>` * }; * * app.use(RouterPlugin, { * mount: '#app', * mode: 'hash', * routes: [ * { path: '/', component: HomePage }, * { path: '/about', component: AboutPage }, * { path: '/users/:id', component: UserPage } * ] * }); */ const RouterPlugin = { /** * Unique identifier for the plugin * @type {string} */ name: "router", /** * Plugin version * @type {string} */ version: "1.0.0-rc.1", /** * Plugin description * @type {string} */ description: "Client-side routing for Eleva applications", /** * Installs the RouterPlugin into an Eleva instance. * * @param {Eleva} eleva - The Eleva instance * @param {RouterOptions} options - Router configuration options * @param {string} options.mount - A CSS selector for the main element where the app is mounted * @param {RouteDefinition[]} options.routes - An array of route definitions * @param {'hash' | 'query' | 'history'} [options.mode='hash'] - The routing mode * @param {string} [options.queryParam='page'] - The query parameter to use in 'query' mode * @param {string} [options.viewSelector='view'] - The selector for the view element within a layout * @param {boolean} [options.autoStart=true] - Whether to start the router automatically * @param {NavigationGuard} [options.onBeforeEach] - A global guard executed before every navigation * @param {string | ComponentDefinition | (() => Promise<{default: ComponentDefinition}>)} [options.globalLayout] - A global layout for all routes * * @example * // main.js * import Eleva from './eleva.js'; * import { RouterPlugin } from './plugins/RouterPlugin.js'; * * const app = new Eleva('myApp'); * * const HomePage = { template: () => `<h1>Home</h1>` }; * const AboutPage = { template: () => `<h1>About Us</h1>` }; * * app.use(RouterPlugin, { * mount: '#app', * routes: [ * { path: '/', component: HomePage }, * { path: '/about', component: AboutPage } * ] * }); */ install(eleva, options = {}) { if (!options.mount) { throw new Error("[RouterPlugin] 'mount' option is required"); } if (!options.routes || !Array.isArray(options.routes)) { throw new Error("[RouterPlugin] 'routes' option must be an array"); } /** * Registers a component definition with the Eleva instance. * This method handles both inline component objects and pre-registered component names. * * @param {any} def - The component definition to register * @param {string} type - The type of component for naming (e.g., "Route", "Layout") * @returns {string | null} The registered component name or null if no definition provided */ const register = (def, type) => { if (!def) return null; if (typeof def === "object" && def !== null && !def.name) { const name = `Eleva${type}Component_${Math.random().toString(36).slice(2, 11)}`; try { eleva.component(name, def); return name; } catch (error) { throw new Error(`[RouterPlugin] Failed to register ${type} component: ${error.message}`); } } return def; }; if (options.globalLayout) { options.globalLayout = register(options.globalLayout, "GlobalLayout"); } (options.routes || []).forEach(route => { route.component = register(route.component, "Route"); if (route.layout) { route.layout = register(route.layout, "RouteLayout"); } }); const router = new Router(eleva, options); eleva.router = router; if (options.autoStart !== false) { queueMicrotask(() => router.start()); } // Add plugin metadata to the Eleva instance if (!eleva.plugins) { eleva.plugins = new Map(); } eleva.plugins.set(this.name, { name: this.name, version: this.version, description: this.description, options }); // Add utility methods for manual router access eleva.navigate = router.navigate.bind(router); eleva.getCurrentRoute = () => router.currentRoute.value; eleva.getRouteParams = () => router.currentParams.value; eleva.getRouteQuery = () => router.currentQuery.value; return router; }, /** * Uninstalls the plugin from the Eleva instance * * @param {Eleva} eleva - The Eleva instance */ async uninstall(eleva) { if (eleva.router) { await eleva.router.destroy(); delete eleva.router; } // Remove plugin metadata if (eleva.plugins) { eleva.plugins.delete(this.name); } // Remove utility methods delete eleva.navigate; delete eleva.getCurrentRoute; delete eleva.getRouteParams; delete eleva.getRouteQuery; } }; /** * @class 🔒 TemplateEngine * @classdesc A secure template engine that handles interpolation and dynamic attribute parsing. * Provides a safe way to evaluate expressions in templates while preventing XSS attacks. * All methods are static and can be called directly on the class. * * @example * const template = "Hello, {{name}}!"; * const data = { name: "World" }; * const result = TemplateEngine.parse(template, data); // Returns: "Hello, World!" */ class TemplateEngine { /** * Parses a template string, replacing expressions with their evaluated values. * Expressions are evaluated in the provided data context. * * @public * @static * @param {string} template - The template string to parse. * @param {Record<string, unknown>} data - The data context for evaluating expressions. * @returns {string} The parsed template with expressions replaced by their values. * @example * const result = TemplateEngine.parse("{{user.name}} is {{user.age}} years old", { * user: { name: "John", age: 30 } * }); // Returns: "John is 30 years old" */ static parse(template, data) { if (typeof template !== "string") return template; return template.replace(this.expressionPattern, (_, expression) => this.evaluate(expression, data)); } /** * Evaluates an expression in the context of the provided data object. * Note: This does not provide a true sandbox and evaluated expressions may access global scope. * The use of the `with` statement is necessary for expression evaluation but has security implications. * Expressions should be carefully validated before evaluation. * * @public * @static * @param {string} expression - The expression to evaluate. * @param {Record<string, unknown>} data - The data context for evaluation. * @returns {unknown} The result of the evaluation, or an empty string if evaluation fails. * @example * const result = TemplateEngine.evaluate("user.name", { user: { name: "John" } }); // Returns: "John" * const age = TemplateEngine.evaluate("user.age", { user: { age: 30 } }); // Returns: 30 */ static evaluate(expression, data) { if (typeof expression !== "string") return expression; try { return new Function("data", `with(data) { return ${expression}; }`)(data); } catch (_unused) { return ""; } } } /** * @private {RegExp} Regular expression for matching template expressions in the format {{ expression }} * @type {RegExp} */ TemplateEngine.expressionPattern = /\{\{\s*(.*?)\s*\}\}/g; /** * @class 🎯 PropsPlugin * @classdesc A plugin that extends Eleva's props data handling to support any type of data structure * with automatic type detection, parsing, and reactive prop updates. This plugin enables seamless * passing of complex data types from parent to child components without manual parsing. * * Core Feature