UNPKG

@tinijs/router

Version:

The router module for the TiniJS framework.

336 lines 12 kB
import { pathToRegexp } from 'path-to-regexp'; import { PACKAGE_PREFIX } from '@tinijs/core'; import { ROUTER_OUTLET_TAG_NAME, ROUTE_CHANGE_EVENT } from './consts.js'; import { go, redirect, back, forward, requestChange } from './methods.js'; import { RouterOutletComponent } from './router-outlet.js'; export class Router { constructor(routes, options = {}) { this.routes = routes; this.options = options; this.NOT_FOUND_PATH = '/**'; this.cache = new Map(); this.indicatorSchedule = null; this.fragmentManager = null; this.registry = this.buildRegistry(routes); } init() { this.registerOutlet(); this.registerTriggers(); return this; } setCallback(cb) { this.callback = cb; return this; } renewFragments(container, activation) { const url = new URL(location.href); this.fragmentManager = { url, container, items: Array.from(container.querySelectorAll('[id]')).reduce((result, element) => { if (element instanceof HTMLElement && element.id) { result[element.id] = element; } return result; }, {}), options: activation === false ? undefined : activation, }; if (activation !== false) { setTimeout(() => this.goFragment(url.hash.replace(/^#/, '')), activation?.delay || 0); } return this; } retrieveFragments(symbolOrExtractor) { return Object.entries(this.fragmentManager?.items || {}) .map(([id, element]) => { let title = ''; if (symbolOrExtractor instanceof Function) { title = symbolOrExtractor(element); } else { title = element?.textContent || ''; const symbol = symbolOrExtractor || '#'; if (title.slice(0, symbol.length) === symbol) { title = title.slice(symbol.length).trim(); } else if (title.slice(-symbol.length) === symbol) { title = title.slice(0, -symbol.length).trim(); } } const level = Number(element.tagName.replace(/^H/i, '')); return { id, title, level: isNaN(level) ? 0 : level, element, }; }) .filter(item => !!item.title); } setState(key, state) { sessionStorage.setItem(`${PACKAGE_PREFIX}:router-state-${key}`, state); return this; } getState(key) { return sessionStorage.getItem(`${PACKAGE_PREFIX}:router-state-${key}`); } go(to, replace) { return go(to, replace); } goFragment(to, options) { const { items, options: currentOptions } = this.fragmentManager || {}; const element = items?.[to]; if (!element) return false; const { action, behavior = 'smooth', block, inline, } = options || currentOptions || {}; element.scrollIntoView({ behavior, block, inline }); action?.(element); return true; } redirect(to) { return redirect(to); } back() { return back(); } forward() { return forward(); } requestChange() { return requestChange(); } getActiveRoute() { return this.match(new URL(location.href)); } getParams() { return this.getActiveRoute()?.params || {}; } getQuery() { return this.getActiveRoute()?.query || {}; } getFragment() { return this.getActiveRoute()?.fragment || ''; } match(url) { const path = url.pathname; /* * 1. check cache */ const cachedResult = this.cache.get(path); if (cachedResult) return cachedResult; /* * 2. check routes */ const pathSegments = path.split('/'); let matched; let matchedRoutePath; let matchedExecResult = null; // root if (!pathSegments[1] && this.registry['/']) { matched = this.registry['/']; } // exact else if (this.registry[path]) { matched = this.registry[path]; } // route else { for (const routePath in this.registry) { const route = this.registry[routePath]; const execResult = !route.regexp ? null : route.regexp.exec(path); if (execResult) { matched = route; matchedRoutePath = routePath; matchedExecResult = execResult; break; } } } // 404 if (!matched) { const notFoundPath = (!pathSegments[1] ? '/' : `/${pathSegments[1]}/`) + this.NOT_FOUND_PATH.replace(/^\//, ''); matched = this.registry[notFoundPath] || this.registry[this.NOT_FOUND_PATH]; } /* * 3. result */ const { regexp, keys, params = {}, } = !matchedRoutePath || !matchedExecResult ? {} : this.extractParams(matchedRoutePath, matchedExecResult); const query = {}; url.searchParams.forEach((value, key) => { if (!/\[\]$/.test(key)) { query[key] = value; } else { key = key.slice(0, -2); if (!query[key]) { query[key] = [value]; } else { query[key] = Array.isArray(query[key]) ? query[key].concat(value) : [query[key], value]; } } }); const fragment = url.hash.replace(/^#/, ''); const result = { url, path, routePath: matchedRoutePath, regexp, keys, params, query, fragment, layoutRoute: matched?.layout, pageRoute: matched?.page, }; if (matched) this.cache.set(path, result); return result; } extractParams(routePath, execResult) { const keys = []; const regexp = pathToRegexp(routePath, keys); // extract params const params = {}; for (let i = 1; i < execResult.length; i++) { const key = keys[i - 1]; const prop = key.name; const value = execResult[i]; if (value !== undefined || !Object.hasOwnProperty.call(params, prop)) { if (key.modifier === '+' || key.modifier === '*') { params[prop] = value ? value.split(/[/?#]/).map(item => this.decodeParam(item)) : []; } else { params[prop] = value ? this.decodeParam(value) : value; } } } // result return { regexp, keys, params }; } registerOutlet() { if (customElements.get(ROUTER_OUTLET_TAG_NAME)) return; customElements.define(ROUTER_OUTLET_TAG_NAME, RouterOutletComponent); } registerTriggers() { // link trigger if (this.options.linkTrigger) { addEventListener('click', e => { const { origin: locationOrigin, pathname: locationPathname, href: locationHref, } = location; // pre-check if (e.defaultPrevented || // the default action is prevented e.button !== 0 || // not with the primary mouse button // a modifier key is pressed e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return; // process anchor let anchor = null; const paths = (!e.composedPath ? e.path || [] : e.composedPath()); for (let i = 0; i < paths.length; i++) { if (paths[i]?.tagName?.toLowerCase() === 'a') { anchor = paths[i]; break; } } // validation const testTarget = anchor?.target?.toLowerCase() || ''; const testHref = anchor?.href?.toLowerCase() || ''; if (!anchor || // no anchor (testTarget && testTarget !== '_self') || // has target /^javascript:(void\(0\);?)|;$/.test(testHref) || // js void testHref.startsWith('mailto:') || // mailto protocol testHref.startsWith('tel:') || // tel protocol anchor.hasAttribute('download') || // has download anchor.hasAttribute('router-ignore') || // has router-ignore (anchor.origin || this.getAnchorOrigin(anchor)) !== locationOrigin // cross origin ) return; // do navigation if (anchor.href !== locationHref) { const url = new URL(anchor.href); history.pushState({}, '', url.href); this.onRouteChanges(url); } if (anchor.pathname === locationPathname && anchor.hash !== '') { this.goFragment(anchor.hash.replace(/^#/, '')); } else { scrollTo(0, 0); } e.preventDefault(); }); } // popstate trigger addEventListener('popstate', e => { this.onRouteChanges(new URL(location.href)); e.preventDefault(); }); } onRouteChanges(url) { if (this.fragmentManager?.url.pathname !== url.pathname) { this.fragmentManager = null; } const detail = this.match(url); if (this.callback) this.callback(detail); return dispatchEvent(new CustomEvent(ROUTE_CHANGE_EVENT, { detail })); } getAnchorOrigin(anchor) { const port = anchor.port; const protocol = anchor.protocol; const defaultHttp = protocol === 'http:' && port === '80'; const defaultHttps = protocol === 'https:' && port === '443'; const host = defaultHttp || defaultHttps ? anchor.hostname // without port : anchor.host; // with port return `${protocol}//${host}`; } decodeParam(val) { try { return decodeURIComponent(val); } catch (err) { return val; } } buildRegistry(routes) { const processPath = (path) => path.replace(/^\/|\/$/g, ''); const is404Path = (id) => id.substring(id.length - 3, id.length) === this.NOT_FOUND_PATH; return routes.reduce((result, route) => { if (!route.children?.length) { const id = `/${processPath(route.path)}`; result[id] = { regexp: is404Path(id) ? undefined : pathToRegexp(id), page: route, }; } else { route.children.forEach(child => { let id = (!route.path ? '' : `/${processPath(route.path)}`) + (!child.path ? '' : `/${processPath(child.path)}`); id ||= '/'; result[id] = { regexp: is404Path(id) ? undefined : pathToRegexp(id), page: child, layout: route, }; }); } return result; }, {}); } } //# sourceMappingURL=router.js.map