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
392 lines
14 KiB
JavaScript
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)
|
|
}
|