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:
co-authored by
Claude Fable 5
parent
90cca611c9
commit
4ad3d02207
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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'))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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>`
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user