Last active
May 5, 2022 14:06
-
-
Save intrnl/f322fc00950410bacc9a29364975a429 to your computer and use it in GitHub Desktop.
Custom elements router
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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