feat(core): add @choojs/core — ESM port of choo with consolidated nano* internals

Same public API as choo 7.1.0 (use/route/start/mount/toString/emit; choo()
callable with or without new, Choo exported). nanobus, nanorouter+wayfarer,
nanolru+component cache, nanoraf, nanohref, nanotiming, document-ready and
scroll-to-anchor are ported into lib/ with per-file attribution; nanoquery
is replaced by URLSearchParams. The v7 node test suite is ported from tape
to node:test and passes unchanged in behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-08 15:59:26 +02:00
co-authored by Claude Fable 5
parent 90cca611c9
commit 4ad3d02207
12 changed files with 1322 additions and 0 deletions
+283
View File
@@ -0,0 +1,283 @@
// 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 = {}
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 = 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)
}
+19
View File
@@ -0,0 +1,19 @@
// Minimal assertions in the spirit of nanoassert (MIT).
// These guard the public API in development; a production bundler can strip
// them by replacing this module with no-ops.
export function ok (value, message) {
if (!value) throw new Error(message || 'assertion failed')
}
/* eslint-disable eqeqeq */
export function equal (a, b, message) {
if (a != b) throw new Error(message || `${a} != ${b}`)
}
export function notEqual (a, b, message) {
if (a == b) throw new Error(message || `${a} == ${b}`)
}
/* eslint-enable eqeqeq */
export default ok
+161
View File
@@ -0,0 +1,161 @@
// Ported from nanobus 4.5.0 (MIT) — https://github.com/choojs/nanobus
// Event emitter with a '*' wildcard channel and nanotiming instrumentation.
import nanotiming from './timing.js'
import { ok, equal } from './assert.js'
function assertEventName (eventName, method) {
ok(
typeof eventName === 'string' || typeof eventName === 'symbol',
`nanobus.${method}: eventName should be type string or symbol`
)
}
export default class Nanobus {
constructor (name) {
this._name = name || 'nanobus'
this._starListeners = []
this._listeners = {}
}
emit (eventName, ...data) {
assertEventName(eventName, 'emit')
const emitTiming = nanotiming(this._name + "('" + eventName.toString() + "')")
const listeners = this._listeners[eventName]
if (listeners && listeners.length > 0) {
this._emit(this._listeners[eventName], data)
}
if (this._starListeners.length > 0) {
this._emit(this._starListeners, eventName, data, emitTiming.uuid)
}
emitTiming()
return this
}
on (eventName, listener) {
assertEventName(eventName, 'on')
equal(typeof listener, 'function', 'nanobus.on: listener should be type function')
if (eventName === '*') {
this._starListeners.push(listener)
} else {
if (!this._listeners[eventName]) this._listeners[eventName] = []
this._listeners[eventName].push(listener)
}
return this
}
addListener (eventName, listener) {
return this.on(eventName, listener)
}
prependListener (eventName, listener) {
assertEventName(eventName, 'prependListener')
equal(typeof listener, 'function', 'nanobus.prependListener: listener should be type function')
if (eventName === '*') {
this._starListeners.unshift(listener)
} else {
if (!this._listeners[eventName]) this._listeners[eventName] = []
this._listeners[eventName].unshift(listener)
}
return this
}
once (eventName, listener) {
assertEventName(eventName, 'once')
equal(typeof listener, 'function', 'nanobus.once: listener should be type function')
const self = this
this.on(eventName, function once (...args) {
listener.apply(self, args)
self.removeListener(eventName, once)
})
return this
}
prependOnceListener (eventName, listener) {
assertEventName(eventName, 'prependOnceListener')
equal(typeof listener, 'function', 'nanobus.prependOnceListener: listener should be type function')
const self = this
this.prependListener(eventName, function once (...args) {
listener.apply(self, args)
self.removeListener(eventName, once)
})
return this
}
removeListener (eventName, listener) {
assertEventName(eventName, 'removeListener')
equal(typeof listener, 'function', 'nanobus.removeListener: listener should be type function')
if (eventName === '*') {
this._starListeners = this._starListeners.slice()
return remove(this._starListeners, listener)
} else {
if (typeof this._listeners[eventName] !== 'undefined') {
this._listeners[eventName] = this._listeners[eventName].slice()
}
return remove(this._listeners[eventName], listener)
}
function remove (arr, listener) {
if (!arr) return
const index = arr.indexOf(listener)
if (index !== -1) {
arr.splice(index, 1)
return true
}
}
}
removeAllListeners (eventName) {
if (eventName) {
if (eventName === '*') {
this._starListeners = []
} else {
this._listeners[eventName] = []
}
} else {
this._starListeners = []
this._listeners = {}
}
return this
}
listeners (eventName) {
const listeners = eventName !== '*'
? this._listeners[eventName]
: this._starListeners
return listeners ? listeners.slice() : []
}
_emit (arr, eventName, data, uuid) {
if (typeof arr === 'undefined') return
if (arr.length === 0) return
if (data === undefined) {
data = eventName
eventName = null
}
if (eventName) {
if (uuid !== undefined) {
data = [eventName].concat(data, uuid)
} else {
data = [eventName].concat(data)
}
}
// Take a copy in case a listener mutates the array while we iterate.
const listeners = arr.slice()
for (let i = 0; i < listeners.length; i++) {
const listener = listeners[i]
listener.apply(listener, data)
}
}
}
+150
View File
@@ -0,0 +1,150 @@
// Component instance cache for state.cache().
// LRU ported from nanolru 1.0.0 (MIT) — https://github.com/s3ththompson/nanolru
// Cache wrapper ported from choo 7.1.0 component/cache.js (MIT)
import { ok, equal } from './assert.js'
export class LRU {
constructor (opts) {
if (typeof opts === 'number') opts = { max: opts }
if (!opts) opts = {}
this.cache = {}
this.head = this.tail = null
this.length = 0
this.max = opts.max || 1000
}
get keys () {
return Object.keys(this.cache)
}
clear () {
this.cache = {}
this.head = this.tail = null
this.length = 0
}
remove (key) {
if (typeof key !== 'string') key = '' + key
if (!Object.hasOwn(this.cache, key)) return
const element = this.cache[key]
delete this.cache[key]
this._unlink(key, element.prev, element.next)
return element.value
}
_unlink (key, prev, next) {
this.length--
if (this.length === 0) {
this.head = this.tail = null
} else {
if (this.head === key) {
this.head = prev
this.cache[this.head].next = null
} else if (this.tail === key) {
this.tail = next
this.cache[this.tail].prev = null
} else {
this.cache[prev].next = next
this.cache[next].prev = prev
}
}
}
peek (key) {
if (!Object.hasOwn(this.cache, key)) return
return this.cache[key].value
}
set (key, value) {
if (typeof key !== 'string') key = '' + key
let element
if (Object.hasOwn(this.cache, key)) {
element = this.cache[key]
element.value = value
// If it's already the head, there's nothing more to do:
if (key === this.head) return value
this._unlink(key, element.prev, element.next)
} else {
element = { value, next: null, prev: null }
this.cache[key] = element
// Eviction is only possible if the key didn't already exist:
if (this.length === this.max) this.evict()
}
this.length++
element.next = null
element.prev = this.head
if (this.head) this.cache[this.head].next = key
this.head = key
if (!this.tail) this.tail = key
return value
}
get (key) {
if (typeof key !== 'string') key = '' + key
if (!Object.hasOwn(this.cache, key)) return
const element = this.cache[key]
if (this.head !== key) {
if (key === this.tail) {
this.tail = element.next
this.cache[this.tail].prev = null
} else {
// Set prev.next -> element.next:
this.cache[element.prev].next = element.next
}
// Set element.next.prev -> element.prev:
this.cache[element.next].prev = element.prev
// Element is the new head
this.cache[this.head].next = key
element.prev = this.head
element.next = null
this.head = key
}
return element.value
}
evict () {
if (!this.tail) return
this.remove(this.tail)
}
}
export default class ComponentCache {
constructor (state, emit, lru) {
equal(typeof state, 'object', 'ComponentCache: state should be type object')
equal(typeof emit, 'function', 'ComponentCache: emit should be type function')
if (typeof lru === 'number') this.cache = new LRU(lru)
else this.cache = lru || new LRU(100)
this.state = state
this.emit = emit
}
// Get & create component instances.
render (Component, id, ...args) {
equal(typeof Component, 'function', 'ComponentCache.render: Component should be type function')
ok(typeof id === 'string' || typeof id === 'number', 'ComponentCache.render: id should be type string or type number')
let el = this.cache.get(id)
if (!el) {
el = new Component(id, this.state, this.emit, ...args)
this.cache.set(id, el)
}
return el
}
}
+25
View File
@@ -0,0 +1,25 @@
// Ported from document-ready 2.0.1 and scroll-to-anchor 1.0.0 (MIT)
// https://github.com/bendrucker/document-ready — https://github.com/yoshuawuyts/scroll-to-anchor
import { notEqual } from './assert.js'
export function documentReady (callback) {
notEqual(typeof document, 'undefined', 'documentReady only runs in the browser')
const state = document.readyState
if (state === 'complete' || state === 'interactive') {
return setTimeout(callback, 0)
}
document.addEventListener('DOMContentLoaded', function onLoad () {
callback()
})
}
export function scrollToAnchor (anchor) {
if (anchor) {
try {
const el = document.querySelector(anchor)
if (el) el.scrollIntoView(true)
} catch (e) {}
}
}
+44
View File
@@ -0,0 +1,44 @@
// Ported from nanohref 3.1.0 (MIT) — https://github.com/choojs/nanohref
// Intercept same-origin anchor clicks for client-side navigation.
import { equal, notEqual } from './assert.js'
const safeExternalLink = /(noopener|noreferrer) (noopener|noreferrer)/
const protocolLink = /^[\w-_]+:/
export default function href (cb, root) {
notEqual(typeof window, 'undefined', 'nanohref: expected window to exist')
root = root || window.document
equal(typeof cb, 'function', 'nanohref: cb should be type function')
equal(typeof root, 'object', 'nanohref: root should be type object')
window.addEventListener('click', function (e) {
if ((e.button && e.button !== 0) ||
e.ctrlKey || e.metaKey || e.altKey || e.shiftKey ||
e.defaultPrevented) return
const anchor = (function traverse (node) {
if (!node || node === root) return
if (node.localName !== 'a' || node.href === undefined) {
return traverse(node.parentNode)
}
return node
})(e.target)
if (!anchor) return
if (window.location.protocol !== anchor.protocol ||
window.location.hostname !== anchor.hostname ||
window.location.port !== anchor.port ||
anchor.hasAttribute('data-nanohref-ignore') ||
anchor.hasAttribute('download') ||
(anchor.getAttribute('target') === '_blank' &&
safeExternalLink.test(anchor.getAttribute('rel'))) ||
protocolLink.test(anchor.getAttribute('href'))) return
e.preventDefault()
cb(anchor)
})
}
+23
View File
@@ -0,0 +1,23 @@
// Replaces nanoquery 1.3.0 (MIT) with the platform's URLSearchParams.
// Semantics preserved: returns a plain object; repeated keys become arrays.
import { equal } from './assert.js'
export default function nanoquery (url) {
equal(typeof url, 'string', 'nanoquery: url should be type string')
const query = {}
const index = url.indexOf('?')
if (index === -1) return query
const params = new URLSearchParams(url.slice(index + 1))
for (const [key, value] of params) {
if (Object.hasOwn(query, key)) {
if (Array.isArray(query[key])) query[key].push(value)
else query[key] = [query[key], value]
} else {
query[key] = value
}
}
return query
}
+28
View File
@@ -0,0 +1,28 @@
// Ported from nanoraf 3.1.0 (MIT) — https://github.com/choojs/nanoraf
// Only call requestAnimationFrame when needed.
import { ok, equal } from './assert.js'
export default function nanoraf (render, raf) {
equal(typeof render, 'function', 'nanoraf: render should be a function')
ok(typeof raf === 'function' || typeof raf === 'undefined', 'nanoraf: raf should be a function or undefined')
if (!raf) raf = globalThis.requestAnimationFrame
let redrawScheduled = false
let args = null
return function frame (...frameArgs) {
if (args === null && !redrawScheduled) {
redrawScheduled = true
raf(function redraw () {
redrawScheduled = false
const _args = args
args = null
render(..._args)
})
}
args = frameArgs
}
}
+237
View File
@@ -0,0 +1,237 @@
// 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 nanorouter's URL normalization on top.
import { ok, equal, notEqual } from './assert.js'
function has (object, property) {
return Object.prototype.hasOwnProperty.call(object, property)
}
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 if (!has(trie.nodes, thisRoute)) {
node = { nodes: {} }
trie.nodes[thisRoute] = node
} else {
node = trie.nodes[thisRoute]
}
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
const thisRoute = routes[index]
if (thisRoute === undefined) return trie
if (has(trie.nodes, thisRoute)) {
// match regular routes first
return search(index + 1, trie.nodes[thisRoute])
} else if (trie.name) {
// match named routes
try {
params[trie.name] = decodeURIComponent(thisRoute)
} catch (e) {
return search(index, undefined)
}
return search(index + 1, trie.nodes.$$)
} else if (trie.wildcard) {
// match wildcards
try {
params.wildcard = decodeURIComponent(routes.slice(index).join('/'))
} catch (e) {
return search(index, undefined)
}
// 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
}
}
// electron support
const isLocalFile = /file:\/\//.test(
typeof window === 'object' &&
window.location &&
window.location.origin
)
/* eslint-disable no-useless-escape */
const electron = '^(file:\/\/|\/)(.*\.html?\/?)?'
const protocol = '^(http(s)?(:\/\/))?(www\.)?'
const domain = '[a-zA-Z0-9-_\.]+(:[0-9]{1,5})?(\/{1})?'
const qs = '[\?].*$'
/* eslint-enable no-useless-escape */
const stripElectron = new RegExp(electron)
const prefix = new RegExp(protocol + domain)
const normalize = new RegExp('#')
const suffix = new RegExp(qs)
// replace everything in a route but the pathname and hash
function pathname (routename, isElectron) {
if (isElectron) routename = routename.replace(stripElectron, '')
else routename = routename.replace(prefix, '')
return decodeURI(routename.replace(suffix, '').replace(normalize, '/'))
}
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, isLocalFile)
return this.router.emit(routename)
}
match (routename) {
equal(typeof routename, 'string')
routename = pathname(routename, isLocalFile)
return this.router.match(routename)
}
}
export { Trie, wayfarer, ok }
+56
View File
@@ -0,0 +1,56 @@
// Ported from nanotiming 7.3.1 (MIT) — https://github.com/choojs/nanotiming
// Unified browser/Node implementation on the global `performance` object.
import { equal } from './assert.js'
const perf = typeof performance !== 'undefined' && typeof performance.mark === 'function'
? performance
: null
function checkDisabled () {
if (!perf) return true
if (typeof process !== 'undefined' && process.env && process.env.DISABLE_NANOTIMING) return true
if (typeof window !== 'undefined') {
try {
return window.localStorage.DISABLE_NANOTIMING === 'true'
} catch (e) {
return false
}
}
return false
}
nanotiming.disabled = checkDisabled()
export default function nanotiming (name) {
equal(typeof name, 'string', 'nanotiming: name should be type string')
if (nanotiming.disabled) return noop
const uuid = (perf.now() * 10000).toFixed() % Number.MAX_SAFE_INTEGER
const startName = 'start-' + uuid + '-' + name
perf.mark(startName)
function end (cb) {
const endName = 'end-' + uuid + '-' + name
perf.mark(endName)
let err = null
try {
const measureName = name + ' [' + uuid + ']'
perf.measure(measureName, startName, endName)
perf.clearMarks(startName)
perf.clearMarks(endName)
} catch (e) { err = e }
if (cb) cb(err, name)
}
end.uuid = uuid
return end
}
function noop (cb) {
if (cb) {
cb(new Error('nanotiming: performance API unavailable or disabled'))
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@choojs/core",
"version": "8.0.0-dev",
"description": "A 4kb framework for creating sturdy frontend applications",
"type": "module",
"exports": {
".": "./index.js"
},
"files": [
"index.js",
"lib"
],
"engines": {
"node": ">=24"
},
"dependencies": {
"@choojs/html": "*"
},
"repository": "choojs/choo",
"keywords": [
"client",
"frontend",
"framework",
"minimal",
"composable",
"tiny"
],
"license": "MIT"
}
+267
View File
@@ -0,0 +1,267 @@
// Ported from choo 7.1.0 test/node.js (MIT), tape → node:test.
// The hyperscript case now uses a plain object with outerHTML, which is the
// contract toString actually relies on.
import { test } from 'node:test'
import assert from 'node:assert'
import html from '@choojs/html'
import raw from '@choojs/html/raw'
import choo, { Choo } from '@choojs/core'
test('should render on the server with @choojs/html', () => {
const app = choo()
app.route('/', function (state, emit) {
const strong = '<strong>Hello filthy planet</strong>'
return html`
<p>${raw(strong)}</p>
`
})
const res = app.toString('/')
const exp = '<p><strong>Hello filthy planet</strong></p>'
assert.strictEqual(res.toString().trim(), exp, 'result was OK')
})
test('should render on the server with any view returning outerHTML', () => {
const app = choo()
app.route('/', function (state, emit) {
return { outerHTML: '<p><strong>Hello filthy planet</strong></p>', nodeName: 'P' }
})
const res = app.toString('/')
const exp = '<p><strong>Hello filthy planet</strong></p>'
assert.strictEqual(res.toString().trim(), exp, 'result was OK')
})
test('should expose a public API', () => {
const app = choo()
assert.strictEqual(typeof app.route, 'function', 'app.route prototype method exists')
assert.strictEqual(typeof app.toString, 'function', 'app.toString prototype method exists')
assert.strictEqual(typeof app.start, 'function', 'app.start prototype method exists')
assert.strictEqual(typeof app.mount, 'function', 'app.mount prototype method exists')
assert.strictEqual(typeof app.emitter, 'object', 'app.emitter prototype method exists')
assert.strictEqual(typeof app.emit, 'function', 'app.emit instance method exists')
assert.strictEqual(typeof app.router, 'object', 'app.router instance object exists')
assert.strictEqual(typeof app.state, 'object', 'app.state instance object exists')
})
test('the default export works with and without new, and instances are Choo', () => {
assert.ok(choo() instanceof Choo, 'choo() returns a Choo instance')
assert.ok(new Choo() instanceof Choo, 'new Choo() works')
})
test('should enable history and href by default', () => {
const app = choo()
assert.ok(app._historyEnabled, 'history enabled')
assert.ok(app._hrefEnabled, 'href enabled')
})
test('router should pass state and emit to view', () => {
let calls = 0
const app = choo()
app.route('/', function (state, emit) {
assert.strictEqual(typeof state, 'object', 'state is an object')
assert.strictEqual(typeof emit, 'function', 'emit is a function')
calls++
return html`<div></div>`
})
app.toString('/')
assert.strictEqual(calls, 1, 'view was rendered')
})
test('router should support a default route', () => {
let calls = 0
const app = choo()
app.route('*', function (state, emit) {
calls++
return html`<div></div>`
})
app.toString('/random')
assert.strictEqual(calls, 1, 'default route was rendered')
})
test('enabling hash routing should treat hashes as slashes', () => {
let calls = 0
const app = choo({ hash: true })
app.route('/account/security', function (state, emit) {
calls++
return html`<div></div>`
})
app.toString('/account#security')
assert.strictEqual(calls, 1, 'hash route was rendered')
})
test('router should ignore hashes by default', () => {
let calls = 0
const app = choo()
app.route('/account', function (state, emit) {
calls++
return html`<div></div>`
})
app.toString('/account#security')
assert.strictEqual(calls, 1, 'route was rendered')
})
test('cache should default to 100 instances', () => {
let pruned = 0
const app = choo()
app.route('/', function (state, emit) {
let i
for (i = 0; i <= 100; i++) state.cache(Component, i)
state.cache(Component, 0)
return html`<div></div>`
function Component (id) {
if (id < i) pruned++
}
})
app.toString('/')
assert.strictEqual(pruned, 1, 'oldest instance was pruned when exceeding 100')
})
test('cache option should override number of max instances', () => {
let pruned = 0
const app = choo({ cache: 1 })
app.route('/', function (state, emit) {
let instances = 0
state.cache(Component, instances)
state.cache(Component, instances)
state.cache(Component, 0)
return html`<div></div>`
function Component (id) {
if (id < instances) pruned++
instances++
}
})
app.toString('/')
assert.strictEqual(pruned, 1, 'oldest instance was pruned when exceeding 1')
})
test('cache option should override default LRU cache', () => {
let gets = 0
let sets = 0
const cache = {
get (id) { gets++ },
set (id, el) { sets++ }
}
const app = choo({ cache })
app.route('/', function (state, emit) {
state.cache(Component, 'foo')
return html`<div></div>`
})
app.toString('/')
assert.strictEqual(gets, 1, 'called get')
assert.strictEqual(sets, 1, 'called set')
function Component () {}
})
// built-in state
test('state should include events', () => {
let checked = false
const app = choo()
app.route('/', function (state, emit) {
assert.ok(Object.hasOwn(state, 'events'), 'state has events property')
assert.ok(Object.keys(state.events).length > 0, 'events object has keys')
checked = true
return html`<div></div>`
})
app.toString('/')
assert.ok(checked)
})
test('state should include location on render', () => {
let checked = false
const app = choo()
app.route('/:first/:second/*', function (state, emit) {
const params = { first: 'foo', second: 'bar', wildcard: 'file.txt' }
assert.strictEqual(state.href, '/foo/bar/file.txt', 'state has href')
assert.strictEqual(state.route, ':first/:second/*', 'state has route')
assert.ok(Object.hasOwn(state, 'params'), 'state has params')
assert.deepStrictEqual(state.params, params, 'params match')
assert.ok(Object.hasOwn(state, 'query'), 'state has query')
assert.deepStrictEqual(state.query, { bin: 'baz' }, 'query match')
checked = true
return html`<div></div>`
})
app.toString('/foo/bar/file.txt?bin=baz')
assert.ok(checked)
})
test('state should include location on store init', () => {
let checked = false
const app = choo()
app.use(store)
app.route('/:first/:second/*', function (state, emit) {
return html`<div></div>`
})
app.toString('/foo/bar/file.txt?bin=baz')
assert.ok(checked)
function store (state, emit) {
const params = { first: 'foo', second: 'bar', wildcard: 'file.txt' }
assert.strictEqual(state.href, '/foo/bar/file.txt', 'state has href')
assert.strictEqual(state.route, ':first/:second/*', 'state has route')
assert.ok(Object.hasOwn(state, 'params'), 'state has params')
assert.deepStrictEqual(state.params, params, 'params match')
assert.ok(Object.hasOwn(state, 'query'), 'state has query')
assert.deepStrictEqual(state.query, { bin: 'baz' }, 'query match')
checked = true
}
})
test('state should include cache', () => {
let constructed = 0
const app = choo()
app.route('/', function (state, emit) {
assert.strictEqual(typeof state.cache, 'function', 'state has cache method')
const cached = state.cache(Component, 'foo', 'arg')
assert.strictEqual(cached, state.cache(Component, 'foo'), 'consecutive calls return same instance')
return html`<div></div>`
})
app.toString('/')
assert.strictEqual(constructed, 1, 'component constructed once')
function Component (id, state, emit, arg) {
assert.strictEqual(id, 'foo', 'id was prefixed to constructor args')
assert.strictEqual(typeof state, 'object', 'state was prefixed to constructor args')
assert.strictEqual(typeof emit, 'function', 'emit was prefixed to constructor args')
assert.strictEqual(arg, 'arg', 'constructor args were forwarded')
constructed++
}
})
test('state should not mutate on toString', () => {
const app = choo()
app.use(store)
const routes = ['foo', 'bar']
const states = routes.map(function (route) {
const state = {}
app.route(`/${route}`, view)
app.toString(`/${route}`, state)
return state
})
for (let i = 0; i < routes.length; i++) {
assert.strictEqual(states[i].test, routes[i], 'store was used')
assert.strictEqual(states[i].title, routes[i], 'title was added to state')
}
function store (state, emitter) {
state.test = null
emitter.on('test', function (str) {
assert.strictEqual(state.test, null, 'state has been reset')
state.test = str
})
}
function view (state, emit) {
emit('test', state.route)
emit(state.events.DOMTITLECHANGE, state.route)
return html`<body>Hello ${state.route}</body>`
}
})