// 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 hydrate from '@choojs/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) }