// Ported from nanorouter 4.0.0 and wayfarer 7.0.1 with its trie (MIT) // https://github.com/choojs/nanorouter — https://github.com/yoshuawuyts/wayfarer // Consolidated into a single module: trie-based router with :params and // * wildcards, plus URL normalization on top. // // Normalization differs deliberately from v7: locations are parsed with the // WHATWG URL parser and each path segment is percent-decoded exactly once, // falling back to the raw segment instead of throwing on malformed input // (v7's decodeURI crashed on a literal '%'). Both route definitions and // incoming segments are NFC-normalized so 'café' matches 'café' regardless // of how the é was composed. import { ok, equal, notEqual } from './assert.js' function has (object, property) { return Object.prototype.hasOwnProperty.call(object, property) } // Decode a path segment exactly once; keep the raw segment when it isn't // valid percent-encoding. Always NFC-normalize. export function decodeSegment (segment) { try { return decodeURIComponent(segment).normalize('NFC') } catch (e) { return segment.normalize('NFC') } } class Trie { constructor () { this.trie = { nodes: {} } } // create a node on the trie at route and return it create (route) { equal(typeof route, 'string', 'route should be a string') // strip leading '/' and split routes const routes = route.replace(/^\//, '').split('/') const self = this function createNode (index, trie) { const thisRoute = has(routes, index) && routes[index] if (thisRoute === false) return trie let node = null if (/^:|^\*/.test(thisRoute)) { // if node is a name match, set name and append to ':' node if (!has(trie.nodes, '$$')) { node = { nodes: {} } trie.nodes.$$ = node } else { node = trie.nodes.$$ } if (thisRoute[0] === '*') { trie.wildcard = true } trie.name = thisRoute.replace(/^:|^\*/, '') } else { // Literal segment: store it decoded + NFC-normalized so route // definitions and incoming URLs compare in one canonical form. const key = decodeSegment(thisRoute) if (!has(trie.nodes, key)) { node = { nodes: {} } trie.nodes[key] = node } else { node = trie.nodes[key] } } return createNode(index + 1, node) } return createNode(0, self.trie) } // match a route on the trie and return the node match (route) { equal(typeof route, 'string', 'route should be a string') const routes = route.replace(/^\//, '').split('/') const params = {} function search (index, trie) { // either there's no match, or we're done searching if (trie === undefined) return undefined if (routes[index] === undefined) return trie // Segments arrive percent-encoded from the URL parser; decode each // exactly once (create() stored literal keys in the same form). const thisRoute = decodeSegment(routes[index]) if (has(trie.nodes, thisRoute)) { // match regular routes first return search(index + 1, trie.nodes[thisRoute]) } else if (trie.name) { // match named routes params[trie.name] = thisRoute return search(index + 1, trie.nodes.$$) } else if (trie.wildcard) { // match wildcards; decode per segment so an encoded '/' can't // change the segment structure mid-decode params.wildcard = routes.slice(index).map(decodeSegment).join('/') // return early, or else search may keep recursing through the wildcard return trie.nodes.$$ } else { // no matches found return search(index + 1) } } let node = search(0, this.trie) if (!node) return undefined node = Object.assign({}, node) node.params = params return node } // mount a trie onto a node at route mount (route, trie) { equal(typeof route, 'string', 'route should be a string') equal(typeof trie, 'object', 'trie should be a object') const split = route.replace(/^\//, '').split('/') let node = null if (split.length === 1) { node = this.create(split[0]) } else { node = this.create(split.join('/')) } Object.assign(node.nodes, trie.nodes) if (trie.name) node.name = trie.name // delegate properties from '/' to the new node // '/' cannot be reached once mounted if (node.nodes['']) { Object.keys(node.nodes['']).forEach(function (key) { if (key === 'nodes') return node[key] = node.nodes[''][key] }) Object.assign(node.nodes, node.nodes[''].nodes) delete node.nodes[''].nodes } } } function wayfarer (dft) { const _default = (dft || '').replace(/^\//, '') const _trie = new Trie() emit._trie = _trie emit.on = on emit.emit = emit emit.match = match emit._wayfarer = true return emit function on (route, cb) { equal(typeof route, 'string') equal(typeof cb, 'function') route = route || '/' if (cb._wayfarer && cb._trie) { _trie.mount(route, cb._trie.trie) } else { const node = _trie.create(route) node.cb = cb node.route = route } return emit } function emit (route, ...args) { const matched = match(route) return matched.cb(matched.params, ...args) } function match (route) { notEqual(route, undefined, "'route' must be defined") const matched = _trie.match(route) if (matched && matched.cb) return new Route(matched) const dft = _trie.match(_default) if (dft && dft.cb) return new Route(dft) throw new Error("route '" + route + "' did not match") } function Route (matched) { this.cb = matched.cb this.route = matched.route this.params = matched.params } } // Reduce a location (path, full URL, or electron file:// URL) to a // matchable path: pathname plus any hash segments rewritten to slashes, // query dropped. Percent-decoding is NOT done here — the trie decodes per // segment — so a malformed sequence can never throw during routing. function pathname (routename) { let url try { url = new URL(routename, 'http://localhost') } catch (e) { return routename } let path = url.pathname // electron support: file:///path/to/index.html routes as '/' if (url.protocol === 'file:') { path = path.replace(/\/[^/]*\.html?$/, '') || '/' } const hash = url.hash.replace(/^#/, '/').replaceAll('#', '/') return path + hash } export default class Nanorouter { constructor (opts) { opts = opts || {} this.router = wayfarer(opts.default || '/404') } on (routename, listener) { equal(typeof routename, 'string') routename = routename.replace(/^[#/]/, '') this.router.on(routename, listener) } emit (routename) { equal(typeof routename, 'string') routename = pathname(routename) return this.router.emit(routename) } match (routename) { equal(typeof routename, 'string') routename = pathname(routename) return this.router.match(routename) } } export { Trie, wayfarer, ok }