UNPKG

@symbiotejs/symbiote

Version:

Symbiote.js - zero-dependency close-to-platform frontend library to build super-powered web components

399 lines (360 loc) 10.7 kB
import PubSub from './PubSub.js'; import { warnMsg } from './warn.js'; export class AppRouter { /** * @typedef {{ * title?: String | (() => String), * default?: Boolean, * error?: Boolean, * pattern?: String, * load?: () => Promise<*>, * __loaded?: Boolean, * }} RouteDescriptor */ /** * @typedef {{ * route: String, * options: Object<string, any>, * }} RouteState */ /** * @callback RouteGuard * @param {RouteState} to * @param {RouteState | null} from * @returns {string | boolean | void | Promise<string | boolean | void>} */ /** @type {() => void} */ static #onPopstate; /** @type {String} */ static #separator; /** @type {String} */ static #routingEventName; /** @type {Object<string, RouteDescriptor>} */ static appMap = Object.create(null); /** @type {RouteGuard[]} */ static #guards = []; /** @type {RouteState | null} */ static #currentState = null; /** @type {boolean} */ static #usePathMode = false; /** @type {Array<{regex: RegExp, keys: string[], route: string}>} */ static #compiledPatterns = []; static get #isBrowser() { return typeof window !== 'undefined' && !globalThis.__SYMBIOTE_SSR; } static #print(msg) { warnMsg(13, msg); } /** @param {String | (() => String)} title */ static setDefaultTitle(title) { this.defaultTitle = title; } /** @param {String | (() => String) | undefined} title */ static #resolveTitle(title) { return typeof title === 'function' ? title() : title; } /** @param {Object<string, {}>} map */ static setRoutingMap(map) { Object.assign(this.appMap, map); for (let route in this.appMap) { let desc = this.appMap[route]; if (!this.defaultRoute && desc.default === true) { this.defaultRoute = route; } else if (!this.errorRoute && desc.error === true) { this.errorRoute = route; } if (desc.pattern) { this.#usePathMode = true; } } if (this.#usePathMode) { this.#compilePatterns(); } } /** * Compiles route patterns into regex matchers. * Pattern syntax: `/users/:id/posts/:postId` → regex with named groups */ static #compilePatterns() { this.#compiledPatterns = []; for (let route in this.appMap) { let desc = this.appMap[route]; if (!desc.pattern) continue; let keys = []; let regexStr = desc.pattern.replace(/:([^/]+)/g, (_, key) => { keys.push(key); return '([^/]+)'; }); this.#compiledPatterns.push({ regex: new RegExp(`^${regexStr}$`), keys, route, }); } // Sort by specificity: longer patterns first, patterns without params first this.#compiledPatterns.sort((a, b) => { if (a.keys.length !== b.keys.length) { return a.keys.length - b.keys.length; } return b.regex.source.length - a.regex.source.length; }); } /** @param {String} name */ static set routingEventName(name) { /** @private */ this.#routingEventName = name; } /** @returns {String} */ static get routingEventName() { return this.#routingEventName || 'sym-on-route'; } static readAddressBar() { if (this.#usePathMode) { return this.#readPath(); } return this.#readQuery(); } static #readQuery() { let result = { route: null, options: {}, }; let paramsArr = window.location.search.split(this.separator); paramsArr.forEach((part) => { if (part.includes('?')) { result.route = part.replace('?', ''); } else if (part.includes('=')) { let pair = part.split('='); result.options[pair[0]] = decodeURI(pair[1]); } else if (part) { result.options[part] = true; } }); return result; } static #readPath() { let pathname = window.location.pathname; let result = { route: null, options: {}, }; for (let compiled of this.#compiledPatterns) { let match = pathname.match(compiled.regex); if (match) { result.route = compiled.route; for (let i = 0; i < compiled.keys.length; i++) { result.options[compiled.keys[i]] = decodeURIComponent(match[i + 1]); } // Also parse query string for additional options let searchParams = new URLSearchParams(window.location.search); searchParams.forEach((value, key) => { result.options[key] = value; }); return result; } } return result; } /** * @param {RouteState} to * @returns {Promise<string | boolean | void>} */ static async #runGuards(to) { let from = this.#currentState; for (let guard of this.#guards) { let result = await guard(to, from); if (result === false) return false; if (typeof result === 'string') return result; } } static async notify() { if (!this.#isBrowser) return; let routeBase = this.readAddressBar(); let routeScheme = this.appMap[routeBase.route]; if (routeBase.route === null) { // Path mode: null = no pattern matched = 404 // Query mode: null = empty URL = go to default if (this.#usePathMode && this.errorRoute) { this.navigate(this.errorRoute); } else if (this.defaultRoute) { this.navigate(this.defaultRoute); } else { this.#print('No route matched and no default/error route configured.'); } return; } else if (!routeScheme && this.errorRoute) { this.navigate(this.errorRoute); return; } else if (!routeScheme && this.defaultRoute) { this.navigate(this.defaultRoute); return; } else if (!routeScheme) { this.#print(`Route "${routeBase.route}" not found...`); return; } // Run guards if (this.#guards.length) { let guardResult = await this.#runGuards(routeBase); if (guardResult === false) return; if (typeof guardResult === 'string') { this.navigate(guardResult); return; } } // Lazy load if needed if (routeScheme.load && !routeScheme.__loaded) { try { await routeScheme.load(); routeScheme.__loaded = true; } catch (err) { this.#print(`Failed to load route "${routeBase.route}": ${err}`); if (this.errorRoute) { this.navigate(this.errorRoute); } return; } } let resolvedTitle = this.#resolveTitle(routeScheme.title); if (resolvedTitle) { document.title = resolvedTitle; } this.#currentState = routeBase; let schemeOptions = {}; for (let key in routeScheme) { if (key === 'pattern' || key === 'load' || key === '__loaded' || key === 'default' || key === 'error') continue; schemeOptions[key] = routeScheme[key]; } let event = new CustomEvent(AppRouter.routingEventName, { detail: { route: routeBase.route, options: {...schemeOptions, ...routeBase.options}, }, }); window.dispatchEvent(event); } /** * @param {String} route * @param {Object<string, any>} [options] */ static reflect(route, options = {}) { if (!this.#isBrowser) return; let routeScheme = this.appMap[route]; if (!routeScheme) { this.#print('Wrong route: ' + route); return; } let url; if (this.#usePathMode && routeScheme.pattern) { url = routeScheme.pattern.replace(/:([^/]+)/g, (_, key) => { let val = options[key]; delete options[key]; return encodeURIComponent(val ?? ''); }); // Append remaining options as query string let remaining = Object.entries(options).filter(([, v]) => v !== undefined); if (remaining.length) { url += '?' + remaining.map(([k, v]) => v === true ? k : `${k}=${encodeURIComponent(v)}` ).join('&'); } } else { url = '?' + route; for (let prop in options) { if (options[prop] === true) { url += this.separator + prop; } else { url += this.separator + prop + '=' + `${options[prop]}`; } } } let title = this.#resolveTitle(routeScheme.title) || this.#resolveTitle(this.defaultTitle) || ''; try { window.history.pushState(null, title, url); } catch (err) { warnMsg(14); } document.title = title; } /** * @param {String} route * @param {Object<string, any>} [options] */ static navigate(route, options = {}) { this.reflect(route, options); this.notify(); } /** * Register a route guard. Guards run before navigation. * Return `false` to cancel, a route string to redirect, or nothing to proceed. * @param {RouteGuard} fn * @returns {() => void} unsubscribe function */ static beforeRoute(fn) { this.#guards.push(fn); return () => { let idx = this.#guards.indexOf(fn); if (idx !== -1) { this.#guards.splice(idx, 1); } }; } /** @param {String} char */ static setSeparator(char) { /** @private */ this.#separator = char; } /** @returns {String} */ static get separator() { return this.#separator || '&'; } /** * @param {String} ctxName * @param {Object<string, RouteDescriptor>} routingMap * @returns {PubSub} */ static initRoutingCtx(ctxName, routingMap) { this.setRoutingMap(routingMap); let routingCtx = PubSub.registerCtx( { route: null, options: null, title: null, }, ctxName ); if (this.#isBrowser) { window.addEventListener(this.routingEventName, (/** @type {CustomEvent} */ e) => { routingCtx.multiPub({ options: e.detail.options, title: this.#resolveTitle(e.detail.options?.title) || this.#resolveTitle(this.defaultTitle) || '', route: e.detail.route, }); }); AppRouter.notify(); this.#initPopstateListener(); } else if (this.defaultRoute) { let defaultDesc = this.appMap[this.defaultRoute]; routingCtx.multiPub({ route: this.defaultRoute, options: {}, title: this.#resolveTitle(defaultDesc?.title) || this.#resolveTitle(this.defaultTitle) || '', }); } return routingCtx; } static #initPopstateListener() { if (this.#onPopstate) { return; } this.#onPopstate = () => { this.notify(); }; window.addEventListener('popstate', this.#onPopstate); } static removePopstateListener() { if (!this.#isBrowser) return; window.removeEventListener('popstate', this.#onPopstate); this.#onPopstate = null; } } export default AppRouter;