2026-09-08 15:59:26 +02:00
|
|
|
// 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
|
2026-09-08 16:50:34 +02:00
|
|
|
// * 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.
|
2026-09-08 15:59:26 +02:00
|
|
|
|
|
|
|
|
import { ok, equal, notEqual } from './assert.js'
|
|
|
|
|
|
|
|
|
|
function has (object, property) {
|
|
|
|
|
return Object.prototype.hasOwnProperty.call(object, property)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-08 16:50:34 +02:00
|
|
|
// 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')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-08 15:59:26 +02:00
|
|
|
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 {
|
2026-09-08 16:50:34 +02:00
|
|
|
// 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]
|
|
|
|
|
}
|
2026-09-08 15:59:26 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
2026-09-08 16:50:34 +02:00
|
|
|
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])
|
2026-09-08 15:59:26 +02:00
|
|
|
|
|
|
|
|
if (has(trie.nodes, thisRoute)) {
|
|
|
|
|
// match regular routes first
|
|
|
|
|
return search(index + 1, trie.nodes[thisRoute])
|
|
|
|
|
} else if (trie.name) {
|
|
|
|
|
// match named routes
|
2026-09-08 16:50:34 +02:00
|
|
|
params[trie.name] = thisRoute
|
2026-09-08 15:59:26 +02:00
|
|
|
return search(index + 1, trie.nodes.$$)
|
|
|
|
|
} else if (trie.wildcard) {
|
2026-09-08 16:50:34 +02:00
|
|
|
// match wildcards; decode per segment so an encoded '/' can't
|
|
|
|
|
// change the segment structure mid-decode
|
|
|
|
|
params.wildcard = routes.slice(index).map(decodeSegment).join('/')
|
2026-09-08 15:59:26 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-08 16:50:34 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
2026-09-08 15:59:26 +02:00
|
|
|
|
2026-09-08 16:50:34 +02:00
|
|
|
let path = url.pathname
|
|
|
|
|
// electron support: file:///path/to/index.html routes as '/'
|
|
|
|
|
if (url.protocol === 'file:') {
|
|
|
|
|
path = path.replace(/\/[^/]*\.html?$/, '') || '/'
|
|
|
|
|
}
|
2026-09-08 15:59:26 +02:00
|
|
|
|
2026-09-08 16:50:34 +02:00
|
|
|
const hash = url.hash.replace(/^#/, '/').replaceAll('#', '/')
|
|
|
|
|
return path + hash
|
2026-09-08 15:59:26 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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')
|
2026-09-08 16:50:34 +02:00
|
|
|
routename = pathname(routename)
|
2026-09-08 15:59:26 +02:00
|
|
|
return this.router.emit(routename)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
match (routename) {
|
|
|
|
|
equal(typeof routename, 'string')
|
2026-09-08 16:50:34 +02:00
|
|
|
routename = pathname(routename)
|
2026-09-08 15:59:26 +02:00
|
|
|
return this.router.match(routename)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export { Trie, wayfarer, ok }
|