280 lines
9.0 KiB
JavaScript
280 lines
9.0 KiB
JavaScript
var scrollToAnchor = require('scroll-to-anchor')
|
|
var documentReady = require('document-ready')
|
|
var nanotiming = require('nanotiming')
|
|
var nanorouter = require('nanorouter')
|
|
var nanomorph = require('nanomorph')
|
|
var nanoquery = require('nanoquery')
|
|
var nanohref = require('nanohref')
|
|
var nanoraf = require('nanoraf')
|
|
var nanobus = require('nanobus')
|
|
var assert = require('assert')
|
|
|
|
var Cache = require('./component/cache')
|
|
|
|
module.exports = Choo
|
|
|
|
var HISTORY_OBJECT = {}
|
|
|
|
function Choo (opts) {
|
|
var timing = nanotiming('choo.constructor')
|
|
if (!(this instanceof Choo)) return new Choo(opts)
|
|
opts = opts || {}
|
|
|
|
assert.equal(typeof opts, 'object', 'choo: opts should be type object')
|
|
|
|
var 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 = []
|
|
this._tree = null
|
|
|
|
// state
|
|
var _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 = nanorouter({ curry: true })
|
|
this.emitter = 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
|
|
this.emitter.prependListener(this._events.DOMTITLECHANGE, function (title) {
|
|
assert.equal(typeof title, 'string', 'events.DOMTitleChange: title should be type string')
|
|
self.state.title = title
|
|
if (self._hasWindow) document.title = title
|
|
})
|
|
timing()
|
|
}
|
|
|
|
Choo.prototype.route = function (route, handler) {
|
|
var routeTiming = nanotiming("choo.route('" + route + "')")
|
|
assert.equal(typeof route, 'string', 'choo.route: route should be type string')
|
|
assert.equal(typeof handler, 'function', 'choo.handler: route should be type function')
|
|
this.router.on(route, handler)
|
|
routeTiming()
|
|
}
|
|
|
|
Choo.prototype.use = function (cb) {
|
|
assert.equal(typeof cb, 'function', 'choo.use: cb should be type function')
|
|
var self = this
|
|
this._stores.push(function (state) {
|
|
var msg = 'choo.use'
|
|
msg = cb.storeName ? msg + '(' + cb.storeName + ')' : msg
|
|
var endTiming = nanotiming(msg)
|
|
cb(state, self.emitter, self)
|
|
endTiming()
|
|
})
|
|
}
|
|
|
|
Choo.prototype.start = function () {
|
|
assert.equal(typeof window, 'object', 'choo.start: window was not found. .start() must be called in a browser, use .toString() if running in Node')
|
|
var startTiming = nanotiming('choo.start')
|
|
|
|
var self = this
|
|
if (this._historyEnabled) {
|
|
this.emitter.prependListener(this._events.NAVIGATE, function () {
|
|
self._matchRoute()
|
|
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) {
|
|
assert.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) {
|
|
assert.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) {
|
|
var href = location.href
|
|
var 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._stores.forEach(function (initStore) {
|
|
initStore(self.state)
|
|
})
|
|
|
|
this._matchRoute()
|
|
this._tree = this._prerender(this.state)
|
|
assert.ok(this._tree, 'choo.start: no valid DOM node returned for location ' + this.state.href)
|
|
|
|
this.emitter.prependListener(self._events.RENDER, nanoraf(function () {
|
|
var renderTiming = nanotiming('choo.render')
|
|
var newTree = self._prerender(self.state)
|
|
assert.ok(newTree, 'choo.render: no valid DOM node returned for location ' + self.state.href)
|
|
|
|
assert.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() + '>.')
|
|
|
|
var morphTiming = nanotiming('choo.morph')
|
|
nanomorph(self._tree, newTree)
|
|
morphTiming()
|
|
|
|
renderTiming()
|
|
}))
|
|
|
|
documentReady(function () {
|
|
self.emitter.emit(self._events.DOMCONTENTLOADED)
|
|
self._loaded = true
|
|
})
|
|
|
|
startTiming()
|
|
return this._tree
|
|
}
|
|
|
|
Choo.prototype.mount = function mount (selector) {
|
|
var mountTiming = nanotiming("choo.mount('" + selector + "')")
|
|
if (typeof window !== 'object') {
|
|
assert.ok(typeof selector === 'string', 'choo.mount: selector should be type String')
|
|
this.selector = selector
|
|
mountTiming()
|
|
return this
|
|
}
|
|
|
|
assert.ok(typeof selector === 'string' || typeof selector === 'object', 'choo.mount: selector should be type String or HTMLElement')
|
|
|
|
var self = this
|
|
|
|
documentReady(function () {
|
|
var renderTiming = nanotiming('choo.render')
|
|
var newTree = self.start()
|
|
if (typeof selector === 'string') {
|
|
self._tree = document.querySelector(selector)
|
|
} else {
|
|
self._tree = selector
|
|
}
|
|
|
|
assert.ok(self._tree, 'choo.mount: could not query selector: ' + selector)
|
|
assert.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() + '>.')
|
|
|
|
var morphTiming = nanotiming('choo.morph')
|
|
nanomorph(self._tree, newTree)
|
|
morphTiming()
|
|
|
|
renderTiming()
|
|
})
|
|
mountTiming()
|
|
}
|
|
|
|
Choo.prototype.toString = function (location, state) {
|
|
Object.assign(this.state, state || {})
|
|
|
|
assert.notEqual(typeof window, 'object', 'choo.mount: window was found. .toString() must be called in Node, use .start() or .mount() if running in the browser')
|
|
assert.equal(typeof location, 'string', 'choo.toString: location should be type string')
|
|
assert.equal(typeof this.state, 'object', 'choo.toString: state should be type object')
|
|
|
|
var self = this
|
|
this._setCache(this.state)
|
|
this._stores.forEach(function (initStore) {
|
|
initStore(self.state)
|
|
})
|
|
|
|
this._matchRoute(location)
|
|
var html = this._prerender(this.state)
|
|
assert.ok(html, 'choo.toString: no valid value returned for the route ' + location)
|
|
assert(!Array.isArray(html), 'choo.toString: return value was an array for the route ' + location)
|
|
return typeof html.outerHTML === 'string' ? html.outerHTML : html.toString()
|
|
}
|
|
|
|
Choo.prototype._matchRoute = function (locationOverride) {
|
|
var 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
|
|
}
|
|
var matched = this.router.match(location)
|
|
this._handler = matched.cb
|
|
this.state.href = location
|
|
this.state.query = nanoquery(queryString)
|
|
this.state.route = matched.route
|
|
this.state.params = matched.params
|
|
return this.state
|
|
}
|
|
|
|
Choo.prototype._prerender = function (state) {
|
|
var routeTiming = nanotiming("choo.prerender('" + state.route + "')")
|
|
var res = this._handler(state, this.emit)
|
|
routeTiming()
|
|
return res
|
|
}
|
|
|
|
Choo.prototype._setCache = function (state) {
|
|
var cache = new Cache(state, this.emitter.emit.bind(this.emitter), this._cache)
|
|
state.cache = renderComponent
|
|
|
|
function renderComponent (Component, id) {
|
|
assert.equal(typeof Component, 'function', 'choo.state.cache: Component should be type function')
|
|
var args = []
|
|
for (var i = 0, len = arguments.length; i < len; i++) {
|
|
args.push(arguments[i])
|
|
}
|
|
return cache.render.apply(cache, args)
|
|
}
|
|
|
|
// When the state gets stringified, make sure `state.cache` isn't
|
|
// stringified too.
|
|
renderComponent.toJSON = function () {
|
|
return null
|
|
}
|
|
}
|