diff --git a/packages/core/index.js b/packages/core/index.js new file mode 100644 index 0000000..92ee634 --- /dev/null +++ b/packages/core/index.js @@ -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) +} diff --git a/packages/core/lib/assert.js b/packages/core/lib/assert.js new file mode 100644 index 0000000..7cdc31f --- /dev/null +++ b/packages/core/lib/assert.js @@ -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 diff --git a/packages/core/lib/bus.js b/packages/core/lib/bus.js new file mode 100644 index 0000000..b48b082 --- /dev/null +++ b/packages/core/lib/bus.js @@ -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) + } + } +} diff --git a/packages/core/lib/cache.js b/packages/core/lib/cache.js new file mode 100644 index 0000000..b1fac3c --- /dev/null +++ b/packages/core/lib/cache.js @@ -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 + } +} diff --git a/packages/core/lib/dom.js b/packages/core/lib/dom.js new file mode 100644 index 0000000..c2ef6af --- /dev/null +++ b/packages/core/lib/dom.js @@ -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) {} + } +} diff --git a/packages/core/lib/href.js b/packages/core/lib/href.js new file mode 100644 index 0000000..8182aa0 --- /dev/null +++ b/packages/core/lib/href.js @@ -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) + }) +} diff --git a/packages/core/lib/query.js b/packages/core/lib/query.js new file mode 100644 index 0000000..d91d6b8 --- /dev/null +++ b/packages/core/lib/query.js @@ -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 +} diff --git a/packages/core/lib/raf.js b/packages/core/lib/raf.js new file mode 100644 index 0000000..15dac42 --- /dev/null +++ b/packages/core/lib/raf.js @@ -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 + } +} diff --git a/packages/core/lib/router.js b/packages/core/lib/router.js new file mode 100644 index 0000000..5a0f6eb --- /dev/null +++ b/packages/core/lib/router.js @@ -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 } diff --git a/packages/core/lib/timing.js b/packages/core/lib/timing.js new file mode 100644 index 0000000..2f2acbd --- /dev/null +++ b/packages/core/lib/timing.js @@ -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')) + } +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..98c6d58 --- /dev/null +++ b/packages/core/package.json @@ -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" +} diff --git a/packages/core/test/core.test.js b/packages/core/test/core.test.js new file mode 100644 index 0000000..5a93ad5 --- /dev/null +++ b/packages/core/test/core.test.js @@ -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 = 'Hello filthy planet' + return html` +
${raw(strong)}
+ ` + }) + const res = app.toString('/') + const exp = 'Hello filthy planet
' + 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: 'Hello filthy planet
', nodeName: 'P' } + }) + const res = app.toString('/') + const exp = 'Hello filthy planet
' + 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`` + }) + 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`` + }) + 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`` + }) + 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`` + }) + 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`` + + 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`` + + 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`` + }) + 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`` + }) + 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`` + }) + 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`` + }) + 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`` + }) + 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`Hello ${state.route}` + } +})