@tinijs/router
Version:
The router module for the TiniJS framework.
336 lines • 12 kB
JavaScript
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