Files
buuh/packages/core/index.js
T
Bendik Aagaard LynghaugandClaude Fable 5 501e8f5927 fix(core): URL normalization that survives non-ASCII and malformed input
Replace nanorouter's regex+decodeURI normalization with the WHATWG URL
parser. Path segments are percent-decoded exactly once in the trie, with
a keep-raw fallback instead of the URIError that crashed v7 on a literal
'%'. Route definitions and locations are NFC-normalized so 'café' matches
regardless of composition. Hash-to-slash rewriting now handles every hash,
not just the first, and state.href is decoded for humans (raw on failure).

Behavior changes from v7: params are no longer double-decoded (%2540 →
'%40', not '@'), and unmatchable encodings fall back to raw segments
instead of silently 404ing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 16:50:34 +02:00

294 lines
9.7 KiB
JavaScript

// Ported from choo 7.1.0 index.js (MIT) — https://github.com/choojs/choo
// Same API, same event flow; ESM, consolidated nano* internals in ./lib.
import morph from '@choojs/html/morph'
import nanotiming from './lib/timing.js'
import Nanorouter from './lib/router.js'
import Nanobus from './lib/bus.js'
import nanoquery from './lib/query.js'
import nanoraf from './lib/raf.js'
import nanohref from './lib/href.js'
import ComponentCache from './lib/cache.js'
import { documentReady, scrollToAnchor } from './lib/dom.js'
import { ok, equal, notEqual } from './lib/assert.js'
const HISTORY_OBJECT = {}
// state.href is for reading: decode it for humans ('/caf%C3%A9' → '/café'),
// but never throw on malformed input — keep the raw string instead.
function safeDecode (location) {
try {
return decodeURI(location).normalize('NFC')
} catch (e) {
return location
}
}
export class Choo {
constructor (opts) {
const timing = nanotiming('choo.constructor')
opts = opts || {}
equal(typeof opts, 'object', 'choo: opts should be type object')
const self = this
// define events used by choo
this._events = {
DOMCONTENTLOADED: 'DOMContentLoaded',
DOMTITLECHANGE: 'DOMTitleChange',
REPLACESTATE: 'replaceState',
PUSHSTATE: 'pushState',
NAVIGATE: 'navigate',
POPSTATE: 'popState',
RENDER: 'render'
}
// properties for internal use only
this._historyEnabled = opts.history === undefined ? true : opts.history
this._hrefEnabled = opts.href === undefined ? true : opts.href
this._hashEnabled = opts.hash === undefined ? false : opts.hash
this._hasWindow = typeof window !== 'undefined'
this._cache = opts.cache
this._loaded = false
this._stores = [ondomtitlechange]
this._tree = null
// state
const _state = {
events: this._events,
components: {}
}
if (this._hasWindow) {
this.state = window.initialState
? Object.assign({}, window.initialState, _state)
: _state
delete window.initialState
} else {
this.state = _state
}
// properties that are part of the API
this.router = new Nanorouter()
this.emitter = new Nanobus('choo.emit')
this.emit = this.emitter.emit.bind(this.emitter)
// listen for title changes; available even when calling .toString()
if (this._hasWindow) this.state.title = document.title
function ondomtitlechange (state) {
self.emitter.prependListener(self._events.DOMTITLECHANGE, function (title) {
equal(typeof title, 'string', 'events.DOMTitleChange: title should be type string')
state.title = title
if (self._hasWindow) document.title = title
})
}
timing()
}
route (route, handler) {
const routeTiming = nanotiming("choo.route('" + route + "')")
equal(typeof route, 'string', 'choo.route: route should be type string')
equal(typeof handler, 'function', 'choo.handler: route should be type function')
this.router.on(route, handler)
routeTiming()
}
use (cb) {
equal(typeof cb, 'function', 'choo.use: cb should be type function')
const self = this
this._stores.push(function (state) {
let msg = 'choo.use'
msg = cb.storeName ? msg + '(' + cb.storeName + ')' : msg
const endTiming = nanotiming(msg)
cb(state, self.emitter, self)
endTiming()
})
}
start () {
equal(typeof window, 'object', 'choo.start: window was not found. .start() must be called in a browser, use .toString() if running in Node')
const startTiming = nanotiming('choo.start')
const self = this
if (this._historyEnabled) {
this.emitter.prependListener(this._events.NAVIGATE, function () {
self._matchRoute(self.state)
if (self._loaded) {
self.emitter.emit(self._events.RENDER)
setTimeout(scrollToAnchor.bind(null, window.location.hash), 0)
}
})
this.emitter.prependListener(this._events.POPSTATE, function () {
self.emitter.emit(self._events.NAVIGATE)
})
this.emitter.prependListener(this._events.PUSHSTATE, function (href) {
equal(typeof href, 'string', 'events.pushState: href should be type string')
window.history.pushState(HISTORY_OBJECT, null, href)
self.emitter.emit(self._events.NAVIGATE)
})
this.emitter.prependListener(this._events.REPLACESTATE, function (href) {
equal(typeof href, 'string', 'events.replaceState: href should be type string')
window.history.replaceState(HISTORY_OBJECT, null, href)
self.emitter.emit(self._events.NAVIGATE)
})
window.onpopstate = function () {
self.emitter.emit(self._events.POPSTATE)
}
if (self._hrefEnabled) {
nanohref(function (location) {
const href = location.href
const hash = location.hash
if (href === window.location.href) {
if (!self._hashEnabled && hash) scrollToAnchor(hash)
return
}
self.emitter.emit(self._events.PUSHSTATE, href)
})
}
}
this._setCache(this.state)
this._matchRoute(this.state)
this._stores.forEach(function (initStore) {
initStore(self.state)
})
this._tree = this._prerender(this.state)
ok(this._tree, 'choo.start: no valid DOM node returned for location ' + this.state.href)
this.emitter.prependListener(self._events.RENDER, nanoraf(function () {
const renderTiming = nanotiming('choo.render')
const newTree = self._prerender(self.state)
ok(newTree, 'choo.render: no valid DOM node returned for location ' + self.state.href)
equal(self._tree.nodeName, newTree.nodeName, 'choo.render: The target node <' +
self._tree.nodeName.toLowerCase() + '> is not the same type as the new node <' +
newTree.nodeName.toLowerCase() + '>.')
const morphTiming = nanotiming('choo.morph')
morph(self._tree, newTree)
morphTiming()
renderTiming()
}))
documentReady(function () {
self.emitter.emit(self._events.DOMCONTENTLOADED)
self._loaded = true
})
startTiming()
return this._tree
}
mount (selector) {
const mountTiming = nanotiming("choo.mount('" + selector + "')")
if (typeof window !== 'object') {
ok(typeof selector === 'string', 'choo.mount: selector should be type String')
this.selector = selector
mountTiming()
return this
}
ok(typeof selector === 'string' || typeof selector === 'object', 'choo.mount: selector should be type String or HTMLElement')
const self = this
documentReady(function () {
const renderTiming = nanotiming('choo.render')
const newTree = self.start()
if (typeof selector === 'string') {
self._tree = document.querySelector(selector)
} else {
self._tree = selector
}
ok(self._tree, 'choo.mount: could not query selector: ' + selector)
equal(self._tree.nodeName, newTree.nodeName, 'choo.mount: The target node <' +
self._tree.nodeName.toLowerCase() + '> is not the same type as the new node <' +
newTree.nodeName.toLowerCase() + '>.')
const morphTiming = nanotiming('choo.morph')
morph(self._tree, newTree)
morphTiming()
renderTiming()
})
mountTiming()
}
toString (location, state) {
state = state || {}
state.components = state.components || {}
state.events = Object.assign({}, state.events, this._events)
notEqual(typeof window, 'object', 'choo.mount: window was found. .toString() must be called in Node, use .start() or .mount() if running in the browser')
equal(typeof location, 'string', 'choo.toString: location should be type string')
equal(typeof state, 'object', 'choo.toString: state should be type object')
this._setCache(state)
this._matchRoute(state, location)
this.emitter.removeAllListeners()
this._stores.forEach(function (initStore) {
initStore(state)
})
const html = this._prerender(state)
ok(html, 'choo.toString: no valid value returned for the route ' + location)
ok(!Array.isArray(html), 'choo.toString: return value was an array for the route ' + location)
return typeof html.outerHTML === 'string' ? html.outerHTML : html.toString()
}
_matchRoute (state, locationOverride) {
let location, queryString
if (locationOverride) {
location = locationOverride.replace(/\?.+$/, '').replace(/\/$/, '')
if (!this._hashEnabled) location = location.replace(/#.+$/, '')
queryString = locationOverride
} else {
location = window.location.pathname.replace(/\/$/, '')
if (this._hashEnabled) location += window.location.hash.replace(/^#/, '/')
queryString = window.location.search
}
const matched = this.router.match(location)
this._handler = matched.cb
state.href = safeDecode(location)
state.query = nanoquery(queryString)
state.route = matched.route
state.params = matched.params
}
_prerender (state) {
const routeTiming = nanotiming("choo.prerender('" + state.route + "')")
const res = this._handler(state, this.emit)
routeTiming()
return res
}
_setCache (state) {
const cache = new ComponentCache(state, this.emitter.emit.bind(this.emitter), this._cache)
state.cache = renderComponent
function renderComponent (Component, id, ...args) {
equal(typeof Component, 'function', 'choo.state.cache: Component should be type function')
return cache.render(Component, id, ...args)
}
// When the state gets stringified, make sure `state.cache` isn't
// stringified too.
renderComponent.toJSON = function () {
return null
}
}
}
// Callable with or without `new`, matching choo v7's `choo()` usage.
export default function choo (opts) {
return new Choo(opts)
}