Skip to content

Instantly share code, notes, and snippets.

@intrnl
Last active May 5, 2022 14:06
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save intrnl/f322fc00950410bacc9a29364975a429 to your computer and use it in GitHub Desktop.
Save intrnl/f322fc00950410bacc9a29364975a429 to your computer and use it in GitHub Desktop.
Custom elements router
const _location = location;
const _addListener = addEventListener;
const _removeListener = removeEventListener;
const isFunction = (x) => typeof x === 'function';
const createElement = (el) => isFunction(el) ? new el() : document.createElement(el);
export const createRouter = ({ base, routes }) => {
base = '/' + (base || '').replace(/^\/+|\/+$/g, '');
const branches = flattenRoutes(routes);
rankRouteBranches(branches);
let running = false;
let outlet = null;
let currPathname;
let currKey;
const run = async () => {
const key = {};
let pathname = _location.pathname;
if (currPathname === pathname || !pathname.startsWith(base)) {
return;
}
currKey = key;
currPathname = pathname;
pathname = '/' + pathname.slice(base.length);
const matches = matchRoutes(branches, pathname);
const components = [];
for (const match of matches) {
let comp = match.route.component;
if (isFunction(comp) && !(comp.prototype instanceof HTMLElement)) {
comp = comp().then((mod) => mod.default || mod);
}
components.push(comp);
}
const views = await Promise.all(components);
if (currKey !== key) {
return;
}
let parent = outlet;
for (let index = 0; index < matches.length; index++) {
const match = matches[index];
const view = views[index];
if (!view) {
continue;
}
const curr = parent.childNodes[0];
let next = curr;
const isFn = isFunction(view);
const isNew = !next;
const isIncorrect = next && !(isFn ? next instanceof view : next.localName === view);
// we need to set properties first before child gets appended to parent,
// because connectedCallback is called immediately after append.
if (isNew || isIncorrect) {
next = createElement(view);
}
next.pathname = match.pathname;
next.params = match.params;
next.context = match.context;
if (isNew) {
parent.append(next);
}
else if (isIncorrect) {
curr.replaceWith(next);
}
parent = next;
}
};
const handleClick = (event) => {
if (
event.button ||
event.ctrlKey || event.metaKey || event.ctrlKey || event.shiftKey ||
event.defaultPrevented
) {
return;
}
let link;
for (const node of event.composedPath()) {
if (node.localName !== 'a' || !node.hasAttribute('href')) {
continue;
}
link = node;
break;
}
if (!link || (link.target && link.target !== '_self') || link.matches(':is([download], [rel~=external])')) {
return;
}
const url = new URL(link.href);
if (url.origin !== _location.origin || !url.pathname.startsWith(base)) {
return;
}
event.preventDefault();
if (url.href === _location.href) {
return;
}
history.pushState({}, '', url);
};
const listen = (nextOutlet) => {
if (running) {
return;
}
running = true;
_addListener('click', handleClick);
_addListener('popstate', run);
_addListener('pushstate', run);
_addListener('replacestate', run);
outlet = nextOutlet;
run();
return () => {
running = false;
_removeListener('click', handleClick);
_removeListener('popstate', run);
_removeListener('pushstate', run);
_removeListener('replacestate', run);
};
};
return {
listen,
};
};
// wrap history methods
const wrapMethod = (type) => {
const lowercase = type.toLowerCase();
const prev = history[type];
history[type] = function (...args) {
const event = new Event(lowercase);
const ret = prev.apply(history, args);
dispatchEvent(event);
return ret;
};
};
wrapMethod('pushState');
wrapMethod('replaceState');
// route matching
const matchRoutes = (branches, pathname) => {
let matches = [];
for (const branch of branches) {
matches = matchRouteBranch(branch, pathname);
if (matches.length) break;
}
return matches;
};
const matchRouteBranch = (branch, pathname) => {
const [, routes] = branch;
const matches = [];
let matchedPathname = '/';
let matchedParams = {};
let matchedContext = {};
for (let i = 0; i < routes.length; i++) {
const route = routes[i];
const end = routes.length - 1 === i;
if (route.path) {
const remaining = matchedPathname === '/'
? pathname
: pathname.slice(matchedPathname.length) || '/';
const matcher = route._match ||= compilePath(route.path, end, route.caseSensitive);
const match = remaining.match(matcher);
if (!match) {
return [];
}
matchedPathname = joinPath(matchedPathname, match[1]);
matchedParams = { ...matchedParams, ...match.groups };
}
matchedContext = { ...matchedContext, ...route.context };
matches.push({
route: route,
pathname: matchedPathname,
params: matchedParams,
context: matchedContext,
});
}
return matches;
};
const flattenRoutes = (routes, branches = [], parentPath = '', parentRoutes = []) => {
for (const route of routes) {
const path = joinPath(parentPath, route.path);
const routes = parentRoutes.concat(route);
if (route.children) {
flattenRoutes(route.children, branches, path, routes);
if (!route.path) {
continue;
}
}
branches.push([path, routes]);
}
return branches;
};
const PARAM_RE = /^:\w+$/;
const rankRouteBranches = (branches) => {
const scores = {};
for (const [path] of branches) {
if (scores[path]) {
continue;
}
const segments = path.split('/');
let score = segments.length;
for (const segment of segments) {
if (PARAM_RE.test(segment)) {
score += 2;
}
else if (segment === '*') {
score += -2;
}
else if (segment === '') {
score += 1;
}
else {
score += 10;
}
}
scores[path] = score;
}
branches.sort((a, b) => {
const [aPath] = a;
const [bPath] = b;
const aScore = scores[aPath];
const bScore = scores[bPath];
return bScore - aScore;
});
}
const PATH_CACHE = {};
const compilePath = (path, end, caseSensitive) => {
const key = path + '|' + end + '|' + caseSensitive;
if (PATH_CACHE[key]) {
return PATH_CACHE[key];
}
const source = path
.replace(/^\/*/, '/')
.replace(/\/?\*?$/, '')
.replace(/[\\.*+^$?{}|()[\]]/g, '\\$&')
.replace(/:(\w+)/g, '(?<$1>[^\/]+)');
let re = '^(' + source + ')';
let flags = caseSensitive ? undefined : 'i';
if (at(path, -1) === '*') {
if (at(path, -2) === '/') {
re += '(?:\\/(?<$>.+)|\\/?)';
} else {
re += '(?<$>.*)';
}
}
else if (end) {
re += '\\/?$';
}
return PATH_CACHE[key] = new RegExp(re, flags);
};
const normalizePath = (path) => {
return path.replace(/\/\/+/g, '/');
};
const joinPath = (...paths) => {
return normalizePath(paths.join('/'));
};
const at = (target, n) => {
n = Math.trunc(n) || 0;
if (n < 0) {
n += target.length;
}
return target[n];
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment