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