All files / lib/core/runtime AvenxRouter.js

94.57% Statements 157/166
85.71% Branches 36/42
87.5% Functions 7/8
94.57% Lines 157/166

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 1675x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 5x 5x 5x 5x 5x 2x 2x 5x 5x 5x 5x 5x 5x 1x 1x 5x 5x 5x 5x 5x     5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 13x 13x 16x 11x 11x 11x 5x 16x 16x 16x 16x 5x 2x 5x 3x 3x 16x 16x     16x 13x 13x 13x 13x 5x 5x 5x 5x 5x 5x 5x 14x 14x 14x 14x 14x 14x 2x 2x 14x 14x 1x 1x 1x 13x 13x 13x 13x 14x 23x 23x 23x 23x 23x 23x 8x 8x 23x 23x 23x 23x 23x 23x 23x 13x 13x 6x 13x 13x 3x 3x 3x 13x 13x 23x 13x 13x 14x     13x 14x       13x 13x 14x 14x 14x 14x 14x 14x 14x 13x 1x 1x 1x 1x 1x 13x 1x 12x 11x 11x 11x 14x 14x 5x  
import { AvenxErrorCodes, formatMessage } from './AvenxError.js';
 
/**
 * AvenxRouter handles hash-based routing for the application.
 * It maps URL hashes to specific Page components.
 */
export class AvenxRouter {
    /**
     * @param {AvenxApp} app - The main application instance.
     * @param {Object<string, string|Object>} routes - A map of hash routes to page names or route definitions.
     */
    constructor(app, routes = {}) {
        /** @type {AvenxApp} */
        this.app = app;
        /** @type {Object<string, string|Object>} */
        this.routes = routes;
        /** @type {Object|null} */
        this.currentRoute = null;
        /** @type {string|null} @private */
        this.hashToIgnore = null;
 
        this.hashChangeHandler = () => this.#handleRoute();
        window.addEventListener('hashchange', this.hashChangeHandler);
    }
 
    /**
     * Starts the router and handles the initial route.
     */
    start() {
        this.#handleRoute();
    }
 
    /**
     * Navigates to a specific hash route.
     * @param {string} hash - The target hash (e.g., '#/about').
     */
    navigate(hash) {
        window.location.hash = hash;
    }
 
    /**
     * Destroys the router and cleans up event listeners.
     */
    destroy() {
        window.removeEventListener('hashchange', this.hashChangeHandler);
    }
 
    /**
     * Sequentially executes an array of guards for a route transition.
     * @param {Array<typeof AvenxGuard|AvenxGuard>} guards - Route guards.
     * @param {Object} to - Target route details.
     * @param {Object|null} from - Current route details.
     * @returns {Promise<boolean|string>} Result of the guard checks (true, false, or redirect path).
     * @private
     */
    #runGuards(guards, to, from) {
        return new Promise((resolve) => {
            const nextGuard = (index) => {
                if (index >= guards.length) {
                    resolve(true);
                    return;
                }
                const Guard = guards[index];
                const instance = typeof Guard === 'function' ? new Guard() : Guard;
 
                Promise.resolve(instance.canActivate(to, from))
                    .then(result => {
                        if (result === false || typeof result === 'string') {
                            resolve(result);
                        } else {
                            nextGuard(index + 1);
                        }
                    })
                    .catch(err => {
                        console.error(formatMessage(AvenxErrorCodes.ROUTER_GUARD_ERROR, to.hash, err));
                        resolve(false);
                    });
            };
            nextGuard(0);
        });
    }
 
    /**
     * Handles the current route by matching it against patterns, executing guards,
     * and mounting the corresponding page.
     * @private
     */
    #handleRoute() {
        let hash = window.location.hash || '#/';
 
        // Strip any secondary anchor (e.g. #/profile#details)
        const secondHashIndex = hash.indexOf('#', 1);
 
        if (secondHashIndex !== -1) {
            hash = hash.substring(0, secondHashIndex);
        }
 
        if (this.hashToIgnore === hash) {
            this.hashToIgnore = null;
            return;
        }
 
        let matchedRoute = null;
        let params = {};
 
        for (const [routePattern, routeDef] of Object.entries(this.routes)) {
            if (routePattern === '*') continue;
 
            const paramNames = [];
            const regexStr = routePattern
                .replace(/[.+^${}()|[\]\\]/g, '\\$&')
                .replace(/:([a-zA-Z0-9_$]+)/g, (_, name) => {
                    paramNames.push(name);
                    return '([^/]+)';
                });
            const regex = new RegExp(`^${regexStr}$`);
 
            const [pathPart, queryPart] = hash.split('?');
            const match = pathPart.match(regex);
 
            if (match) {
                matchedRoute = { pattern: routePattern, definition: routeDef };
                paramNames.forEach((name, idx) => {
                    params[name] = decodeURIComponent(match[idx + 1]);
                });
                if (queryPart) {
                    const queryParams = new URLSearchParams(queryPart);
                    params.query = Object.fromEntries(queryParams.entries());
                }
                break;
            }
        }
 
        // Fallback to '*' if no route matched
        if (!matchedRoute && this.routes['*']) {
            matchedRoute = { pattern: '*', definition: this.routes['*'] };
        }
 
        if (!matchedRoute) {
            console.warn(`[AvenxRouter] No route defined for hash: ${hash}`);
            return;
        }
 
        const def = matchedRoute.definition;
        const pageName = typeof def === 'string' ? def : def.page;
        const guards = typeof def === 'object' ? (def.guards || []) : [];
 
        const to = { hash, page: pageName, params };
        const from = this.currentRoute;
 
        this.#runGuards(guards, to, from).then(result => {
            if (result === false) {
                console.warn(formatMessage(AvenxErrorCodes.ROUTER_GUARD_DENIED, to.hash));
                if (from && from.hash !== window.location.hash) {
                    this.hashToIgnore = from.hash;
                    window.location.hash = from.hash;
                }
            } else if (typeof result === 'string') {
                this.navigate(result);
            } else {
                this.currentRoute = to;
                this.app.mountPage(pageName, params);
            }
        });
    }
}