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
+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'))
}
}