Files
Bendik Aagaard LynghaugandClaude Fable 5 ce1ec9e4a9 rebrand: buuh — a friendly public fork under the uhhm org
Packages renamed to the @uhhm scope (@uhhm/buuh, @uhhm/buuh-html,
@uhhm/buuh-component, @uhhm/buuh-devtools, @uhhm/buuh-migrate,
@uhhm/bankai). Scoping is load-bearing twice over: npm routes registries
per scope so @uhhm/* resolves against project.uhhm.no while everything
else stays on npmjs, and it means this fork never squats upstream's
names anywhere. The codemod now migrates choo v7 apps to the @uhhm
names. README rewritten with the fork framing and full upstream credit;
the choojs RFC moves to docs/upstream-rfc-draft.md, in the drawer for if
this work ever goes home. API unchanged — choo() is still choo().

Also: Gitea Actions CI + release workflows (npm publish to the uhhm
registry on tag push, CDN bundle uploaded as a generic package),
npm run bundle producing dist-cdn/buuh.js (the whole framework as one
minified ES module for import-map use), docs/publishing.md explaining
what Gitea Packages is (a real npm registry) and is not (a CDN — serve
the bundle from a static host with module-safe MIME instead), and
onload.js constructing window.MutationObserver to match its own guard
(surfaced by smoke-testing the bundle outside a full browser).

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

392 lines
14 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 '@uhhm/buuh-html/morph'
import hydrate from '@uhhm/buuh-html/hydrate'
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 lazy, { PENDING } from './lib/lazy.js'
import { documentReady, scrollToAnchor } from './lib/dom.js'
import { ok, equal, notEqual } from './lib/assert.js'
export { lazy }
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
this._pendingTree = false
// 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)
})
let tree = this._prerender(this.state)
ok(tree, 'choo.start: no valid DOM node returned for location ' + this.state.href)
if (tree === PENDING) {
// lazy route still loading and no loading view: hold the spot with
// a placeholder; the first real render replaces it wholesale
tree = document.createElement('div')
tree.setAttribute('data-choo-pending', '')
this._pendingTree = true
}
this._tree = tree
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)
if (newTree === PENDING) {
// lazy route still loading: keep whatever is on screen
renderTiming()
return
}
if (self._pendingTree) {
// placeholder → first real view: replace, don't morph
const old = self._tree
self._tree = newTree
if (old.parentNode) old.parentNode.replaceChild(newTree, old)
self._pendingTree = false
renderTiming()
return
}
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() + '>.')
// First render adopts the existing (usually server-rendered) DOM:
// matching nodes are left in place, and any server/client markup
// disagreement is reported before the client render wins.
const morphTiming = nanotiming('choo.morph')
hydrate(self._tree, newTree, {
onMismatch: function (diff) {
console.warn(
'choo.mount: server and client markup differ at ' + diff.path +
' (' + diff.reason + '): server rendered ' + diff.server +
', client rendered ' + diff.client +
'. The client version wins; fix the view so both sides agree.'
)
}
})
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()
state.prefetch = []
this._stores.forEach(function (initStore) {
initStore(state)
})
ok(state.prefetch.length === 0, 'choo.toString: stores requested prefetch data — render this route with toStream() instead')
const html = this._prerender(state)
ok(html, 'choo.toString: no valid value returned for the route ' + location)
ok(html !== PENDING, 'choo.toString: the route ' + location + ' loads its view lazily — render it with toStream() instead')
ok(!Array.isArray(html), 'choo.toString: return value was an array for the route ' + location)
return typeof html.outerHTML === 'string' ? html.outerHTML : html.toString()
}
// Streaming server render on web-standard streams: returns a
// ReadableStream of UTF-8 bytes. Compared to toString() it can wait —
// for store prefetch promises (pushed into state.prefetch during store
// init) and for lazy route views — and it flushes template output
// progressively: everything before an async hole is sent immediately,
// the rest follows as each promise resolves, in document order.
// Runtime-portable by construction: pass it to `new Response(stream)`
// on web-standard servers, or `Readable.fromWeb(stream).pipe(res)` on
// Node's http.
toStream (location, state) {
state = state || {}
state.components = state.components || {}
state.events = Object.assign({}, state.events, this._events)
notEqual(typeof window, 'object', 'choo.toStream: window was found. .toStream() must be called in Node, use .start() or .mount() if running in the browser')
equal(typeof location, 'string', 'choo.toStream: location should be type string')
const self = this
this._setCache(state)
this._matchRoute(state, location)
this.emitter.removeAllListeners()
state.prefetch = []
this._stores.forEach(function (initStore) {
initStore(state)
})
const prefetch = state.prefetch
const encoder = new TextEncoder()
return new ReadableStream({
async start (controller) {
try {
if (prefetch.length) await Promise.all(prefetch)
if (self._handler.__lazy) await self._handler.load()
const html = self._prerender(state)
ok(html, 'choo.toStream: no valid value returned for the route ' + location)
ok(!Array.isArray(html), 'choo.toStream: return value was an array for the route ' + location)
if (typeof html[Symbol.asyncIterator] === 'function') {
for await (const chunk of html) {
controller.enqueue(encoder.encode(chunk))
}
} else {
const str = typeof html.outerHTML === 'string' ? html.outerHTML : html.toString()
controller.enqueue(encoder.encode(str))
}
controller.close()
} catch (err) {
controller.error(err)
}
}
})
}
_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)
}