@easyroute/core
Version:
Framework-agnostic router for JavaScript single-page applications
354 lines (334 loc) • 11.2 kB
JavaScript
async function checkAsyncAndDownload(component) {
const isAsync = /(\.then|import\(.+\)|vitePreload|_+import_+)/i.test(component.toString());
if (!isAsync)
return component;
else {
try {
const newComponent = await component();
return newComponent.default;
}
catch (e) {
console.warn(`[Easyroute] caught an error while trying to download async component: "${e.message}"`);
return component;
}
}
}
async function downloadDynamicComponents(matchedRoutes) {
const nonDynamic = matchedRoutes.map(async (route) => {
if (route.component) {
route.component = await checkAsyncAndDownload(route.component);
}
if (route.components) {
for await (const component of Object.entries(route.components)) {
const [key, value] = component;
route.components[key] = await checkAsyncAndDownload(value);
}
}
return route;
});
return await Promise.all(nonDynamic);
}
function getRoutesTreeChain(allRoutes, currentRoute) {
var _a;
if (!currentRoute)
return [];
const tree = [currentRoute];
let currentSeekingId = currentRoute.parentId;
do {
const seed = allRoutes.find((seedRoute) => seedRoute.id === currentSeekingId);
seed && tree.push(seed);
currentSeekingId = (_a = seed === null || seed === void 0 ? void 0 : seed.parentId) !== null && _a !== void 0 ? _a : null;
} while (currentSeekingId);
return tree;
}
function parseRoutes(routes, url) {
const allMatched = [];
const usedIds = [];
const usedDepths = [];
routes.forEach((route) => {
if (route.regexpPath.test(url)) {
allMatched.push(...getRoutesTreeChain(routes, route));
}
});
return allMatched
.filter((route) => {
if (!usedIds.includes(route.id) &&
!usedDepths.includes(route.nestingDepth)) {
usedIds.push(route.id);
usedDepths.push(route.nestingDepth);
return true;
}
return false;
})
.sort((a, b) => b.nestingDepth - a.nestingDepth);
}
const deleteFirstSlash = (url) => url.replace(/^\//, '');
const deleteLastSlash = (url) => url.replace(/\/$/, '');
const deleteEdgeSlashes = (url) =>
deleteFirstSlash(deleteLastSlash(url));
const generateId = () => Math.random().toString(36).substr(2, 9);
function regexparam (str, loose) {
if (str instanceof RegExp) return { keys:false, pattern:str };
var c, o, tmp, ext, keys=[], pattern='', arr = str.split('/');
arr[0] || arr.shift();
while (tmp = arr.shift()) {
c = tmp[0];
if (c === '*') {
keys.push('wild');
pattern += '/(.*)';
} else if (c === ':') {
o = tmp.indexOf('?', 1);
ext = tmp.indexOf('.', 1);
keys.push( tmp.substring(1, !!~o ? o : !!~ext ? ext : tmp.length) );
pattern += !!~o && !~ext ? '(?:/([^/]+?))?' : '/([^/]+?)';
if (!!~ext) pattern += (!!~o ? '?' : '') + '\\' + tmp.substring(ext);
} else {
pattern += '/' + tmp;
}
}
return {
keys: keys,
pattern: new RegExp('^' + pattern + (loose ? '(?=$|\/)' : '\/?$'), 'i')
};
}
function getMatchData(arr, parentId = null, nestingDepth = 0, parentPath = '/') {
return arr.reduce((acc, val) => {
var _a;
const componentPart = (val.component && { component: val.component }) ||
(val.components && { components: val.components });
const path = deleteEdgeSlashes(parentPath) + '/' + deleteEdgeSlashes(val.path);
const { pattern, keys } = regexparam(path);
const newRoute = {
...val,
...componentPart,
parentId,
nestingDepth,
path,
id: generateId(),
regexpPath: pattern,
pathKeys: keys
};
if (Array.isArray(val.children)) {
acc = acc.concat(getMatchData(((_a = newRoute.children) !== null && _a !== void 0 ? _a : []), newRoute.id, nestingDepth + 1, newRoute.path));
}
return acc.concat(newRoute);
}, []);
}
const deleteQueryTrailingSlash = (url) => url.replace(/(\/\?)/, '?');
function constructUrl(url, base, omitTrailing = false) {
omitTrailing &&
url.split('?')[0].length > 1 &&
(url = deleteQueryTrailingSlash(deleteLastSlash(url)));
if (!base || url.includes(base))
return url;
return `/${deleteLastSlash(base)}/${deleteFirstSlash(url)}`;
}
function createObservable(initial) {
const _listeners = {};
let _value = initial;
return {
get getValue() {
return _value;
},
subscribe(listener) {
const id = generateId();
_listeners[id] = listener;
listener(_value);
return () => delete _listeners[id];
},
set(v) {
_value = v;
Object.values(_listeners).forEach((l) => l(v));
}
};
}
function decode(str) {
try {
return decodeURIComponent(str);
}
catch (err) {
console.warn(`[Easyroute] Could not decode query string: ${str}`);
}
return str;
}
function parseQuery(query) {
const res = {};
if (typeof query !== 'string')
return res;
query = query.trim().replace(/^(\?|#|&)/, '');
if (!query) {
return res;
}
query.split('&').forEach((param) => {
const parts = param.replace(/\+/g, ' ').split('=');
const key = decode(parts.shift());
const val = parts.length > 0 ? decode(parts.join('=')) : null;
if (res[key] === undefined) {
res[key] = val;
}
else if (Array.isArray(res[key])) {
val !== null && res[key].push(val);
}
else {
if (val !== null) {
res[key] = [res[key], val];
}
}
});
return res;
}
function getPathParams(matchedRoute, url) {
let pathValues = matchedRoute.regexpPath.exec(url);
if (!pathValues)
return {};
pathValues = pathValues.slice(1, pathValues.length);
const urlParams = {};
for (let pathPart = 0; pathPart < pathValues.length; pathPart++) {
const value = pathValues[pathPart];
const key = matchedRoute.pathKeys[pathPart];
urlParams[String(key)] = value;
}
return urlParams;
}
function createRouteObject(matchedRoutes, url) {
var _a;
const currentMatched = matchedRoutes.filter(Boolean)[0];
if (!currentMatched)
return {
params: {},
query: {},
fullPath: ''
};
const [pathString, queryString] = url.split('?');
return {
params: getPathParams(currentMatched, pathString),
query: parseQuery(queryString),
name: currentMatched.name,
fullPath: url,
meta: (_a = currentMatched.meta) !== null && _a !== void 0 ? _a : {}
};
}
/**
* @description Is current environment - browser
* @author flexdinesh/browser-or-node
* @returns {boolean}
*/
function isBrowser() {
return (
typeof window !== 'undefined' && typeof window.document !== 'undefined'
);
}
const SSR = !isBrowser();
class Router {
constructor(settings) {
this.settings = settings;
this.routes = [];
this.ignoreEvents = false;
this.currentUrl = '';
this.beforeEachHooks = [];
this.afterEachHooks = [];
this.modeName = 'hash';
this.transitionOutHooks = [];
this.currentMatched = createObservable([]);
this.currentRouteData = createObservable({
params: {},
query: {},
name: '',
fullPath: ''
});
this.currentRouteFromData = createObservable(null);
this.silentControl = undefined;
if (!settings.mode) {
throw new ReferenceError('[Easyroute] Router mode is not defined: pass a function into "settings.mode"');
}
this.routes = getMatchData(settings.routes);
settings.mode.call(this);
if (SSR && this.modeName !== 'history')
throw new Error('[Easyroute] SSR only works with "history" router mode');
}
async parseRoute(url, doPushState = true, replace = false) {
url = url.replace(/^#/, '');
const matched = parseRoutes(this.routes, url.split('?')[0]);
if (!matched)
return;
const toRouteInfo = createRouteObject([matched[0]], url);
const fromRouteInfo = createRouteObject([this.currentMatched.getValue[0]], this.currentUrl);
const allowNext = await this.runHooksArray([
...this.beforeEachHooks,
...matched
.map((t) => t.beforeEnter)
.filter(Boolean)
], toRouteInfo, fromRouteInfo, 'before');
if (!allowNext)
return;
// @ts-ignore
this.changeUrl(constructUrl(url, this.base, this.settings.omitTrailingSlash), doPushState, toRouteInfo, replace);
this.currentRouteData.set(toRouteInfo);
this.currentRouteFromData.set(fromRouteInfo);
this.currentMatched.set(await downloadDynamicComponents(matched));
this.runHooksArray(this.afterEachHooks, toRouteInfo, fromRouteInfo, 'after');
}
async executeHook(to, from, hook, type) {
if (type === 'after')
return hook(to, from);
return new Promise(async (resolve) => {
const next = (command) => {
if (command !== null && command !== undefined) {
if (command === false)
resolve(false);
if (typeof command === 'string') {
this.parseRoute(command);
resolve(false);
}
}
else {
resolve(true);
}
};
if (!hook)
resolve(true);
else
await hook(to, from, next);
});
}
async push(url) {
this.ignoreEvents = true;
await this.parseRoute(url, true, false);
}
async replace(url) {
if (this.modeName !== 'history')
throw new ReferenceError(`[easyroute] cannot replace "${url}": "replace" method only available in history mode`);
}
go(howFar) {
console.warn(`[easyroute] cannot go ${howFar}: "go" method is not implemented in`, this.modeName);
}
back() {
this.go(-1);
}
beforeEach(hook) {
this.beforeEachHooks.push(hook);
}
afterEach(hook) {
this.afterEachHooks.push(hook);
}
transitionOut(hook) {
this.transitionOutHooks.push(hook);
}
async runHooksArray(hooks, to, from, type) {
for await (const hook of hooks) {
const allow = await this.executeHook(to, from, hook, type);
if (!allow)
return false;
}
return true;
}
get base() {
if (!this.settings.base)
return '';
return deleteEdgeSlashes(this.settings.base) + '/';
}
get currentRoute() {
return this.currentRouteData.getValue;
}
}
export default Router;