255 lines
7.4 KiB
JavaScript
255 lines
7.4 KiB
JavaScript
const history = require('sheet-router/history')
|
|
const sheetRouter = require('sheet-router')
|
|
const document = require('global/document')
|
|
const href = require('sheet-router/href')
|
|
const hash = require('sheet-router/hash')
|
|
const hashMatch = require('hash-match')
|
|
const sendAction = require('send-action')
|
|
const mutate = require('xtend/mutable')
|
|
const assert = require('assert')
|
|
const xtend = require('xtend')
|
|
const yo = require('yo-yo')
|
|
|
|
module.exports = choo
|
|
|
|
// framework for creating sturdy web applications
|
|
// null -> fn
|
|
function choo () {
|
|
const _models = []
|
|
var _router = null
|
|
|
|
start.toString = toString
|
|
start.router = router
|
|
start.model = model
|
|
start.start = start
|
|
|
|
return start
|
|
|
|
// render the application to a string
|
|
// (str, obj) -> str
|
|
function toString (route, serverState) {
|
|
const initialState = {}
|
|
const nsState = {}
|
|
|
|
_models.forEach(function (model) {
|
|
const ns = model.namespace
|
|
if (ns) {
|
|
if (!nsState[ns]) nsState[ns] = {}
|
|
apply(ns, model.state, nsState)
|
|
nsState[ns] = xtend(nsState[ns], serverState[ns])
|
|
} else {
|
|
apply(model.namespace, model.state, initialState)
|
|
}
|
|
})
|
|
|
|
const state = xtend(initialState, xtend(serverState, nsState))
|
|
const tree = _router(route, state, function () {
|
|
throw new Error('send() cannot be called on the server')
|
|
})
|
|
|
|
return tree.toString()
|
|
}
|
|
|
|
// start the application
|
|
// (str?, obj?) -> DOMNode
|
|
function start (rootId, opts) {
|
|
if (!opts && typeof rootId !== 'string') {
|
|
opts = rootId
|
|
rootId = null
|
|
}
|
|
opts = opts || {}
|
|
const name = opts.name || 'choo'
|
|
const initialState = {}
|
|
const reducers = {}
|
|
const effects = {}
|
|
|
|
_models.push(appInit(opts))
|
|
_models.forEach(function (model) {
|
|
if (model.state) apply(model.namespace, model.state, initialState)
|
|
if (model.reducers) apply(model.namespace, model.reducers, reducers)
|
|
if (model.effects) apply(model.namespace, model.effects, effects)
|
|
})
|
|
|
|
// send() is used to trigger actions inside
|
|
// views, effects and subscriptions
|
|
const send = sendAction({
|
|
onaction: handleAction,
|
|
onchange: onchange,
|
|
state: initialState
|
|
})
|
|
|
|
// subscriptions are loaded after sendAction() is called
|
|
// because they both need access to send() and can't
|
|
// react to actions (read-only) - also wait on DOM to
|
|
// be loaded
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
_models.forEach(function (model) {
|
|
if (model.subscriptions) {
|
|
assert.ok(Array.isArray(model.subscriptions), 'subs must be an arr')
|
|
model.subscriptions.forEach(function (sub) {
|
|
sub(send)
|
|
})
|
|
}
|
|
})
|
|
})
|
|
|
|
// If an id is provided, the application will rehydrate
|
|
// on the node. If no id is provided it will return
|
|
// a tree that's ready to be appended to the DOM.
|
|
//
|
|
// The rootId is determined to find the application root
|
|
// on update. Since the DOM nodes change between updates,
|
|
// we must call document.querySelector() to find the root.
|
|
// Use different names when loading multiple choo applications
|
|
// on the same page
|
|
if (rootId) {
|
|
document.addEventListener('DOMContentLoaded', function (event) {
|
|
rootId = rootId.replace(/^#/, '')
|
|
|
|
const oldTree = document.querySelector('#' + rootId)
|
|
assert.ok(oldTree, 'could not find node #' + rootId)
|
|
|
|
const newTree = _router(send.state().app.location, send.state(), send)
|
|
|
|
yo.update(oldTree, newTree)
|
|
})
|
|
} else {
|
|
rootId = name + '-root'
|
|
const tree = _router(send.state().app.location, send.state(), send)
|
|
tree.setAttribute('id', rootId)
|
|
return tree
|
|
}
|
|
|
|
// handle an action by either reducers, effects
|
|
// or both - return the new state when done
|
|
// (obj, obj, fn) -> obj
|
|
function handleAction (action, state, send) {
|
|
var reducersCalled = false
|
|
var effectsCalled = false
|
|
const newState = xtend(state)
|
|
|
|
// validate if a namespace exists. Namespaces
|
|
// are delimited by the first ':'. Perhaps
|
|
// we'll allow recursive namespaces in the
|
|
// future - who knows
|
|
if (/:/.test(action.type)) {
|
|
const arr = action.type.split(':')
|
|
var ns = arr.shift()
|
|
action.type = arr.join(':')
|
|
}
|
|
|
|
const _reducers = ns ? reducers[ns] : reducers
|
|
if (_reducers && _reducers[action.type]) {
|
|
if (ns) {
|
|
const reducedState = _reducers[action.type](action, state[ns])
|
|
if (!newState[ns]) newState[ns] = {}
|
|
mutate(newState[ns], xtend(state[ns], reducedState))
|
|
} else {
|
|
mutate(newState, reducers[action.type](action, state))
|
|
}
|
|
reducersCalled = true
|
|
}
|
|
|
|
const _effects = ns ? effects[ns] : effects
|
|
if (_effects && _effects[action.type]) {
|
|
if (ns) _effects[action.type](action, state[ns], send)
|
|
else _effects[action.type](action, state, send)
|
|
effectsCalled = true
|
|
}
|
|
|
|
if (!reducersCalled && !effectsCalled) {
|
|
throw new Error('Could not find action ' + action.type)
|
|
}
|
|
|
|
// allows (newState === oldState) checks
|
|
return (reducersCalled) ? newState : state
|
|
}
|
|
|
|
// update the DOM after every state mutation
|
|
// (obj, obj) -> null
|
|
function onchange (action, newState, oldState) {
|
|
if (newState === oldState) return
|
|
const oldTree = document.querySelector('#' + rootId)
|
|
assert.ok(oldTree, "Could not find DOM node '#" + rootId + "' to update")
|
|
const newTree = _router(newState.app.location, newState, send, oldState)
|
|
newTree.setAttribute('id', rootId)
|
|
yo.update(oldTree, newTree)
|
|
}
|
|
}
|
|
|
|
// register all routes on the router
|
|
// (str?, [fn|[fn]]) -> obj
|
|
function router (defaultRoute, cb) {
|
|
_router = sheetRouter(defaultRoute, cb)
|
|
return _router
|
|
}
|
|
|
|
// create a new model
|
|
// (str?, obj) -> null
|
|
function model (model) {
|
|
_models.push(model)
|
|
}
|
|
}
|
|
|
|
// initial application state model
|
|
// obj -> obj
|
|
function appInit (opts) {
|
|
const initialLocation = (opts.hash === true)
|
|
? hashMatch(document.location.hash)
|
|
: document.location.href
|
|
|
|
const model = {
|
|
namespace: 'app',
|
|
state: { location: initialLocation },
|
|
subscriptions: [],
|
|
reducers: {
|
|
// handle href links
|
|
location: function setLocation (action, state) {
|
|
return {
|
|
location: action.location.replace(/#.*/, '')
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// if hash routing explicitly enabled, subscribe to it
|
|
if (opts.hash === true) {
|
|
pushLocationSub(function (navigate) {
|
|
hash(function (fragment) {
|
|
navigate(hashMatch(fragment))
|
|
})
|
|
})
|
|
// otherwise, subscribe to HTML5 history API
|
|
} else {
|
|
if (opts.history !== false) pushLocationSub(history)
|
|
// enable catching <a href=""></a> links
|
|
if (opts.href !== false) pushLocationSub(href)
|
|
}
|
|
|
|
return model
|
|
|
|
// create a new subscription that modifies
|
|
// 'app:location' and push it to be loaded
|
|
// fn -> null
|
|
function pushLocationSub (cb) {
|
|
model.subscriptions.push(function (send) {
|
|
cb(function (href) {
|
|
send('app:location', { location: href })
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
// compose an object conditionally
|
|
// optionally contains a namespace
|
|
// which is used to nest properties.
|
|
// (str, obj, obj) -> null
|
|
function apply (ns, source, target) {
|
|
Object.keys(source).forEach(function (key) {
|
|
if (ns) {
|
|
if (!target[ns]) target[ns] = {}
|
|
target[ns][key] = source[key]
|
|
} else target[key] = source[key]
|
|
})
|
|
}
|