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') choo.view = 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) _models.forEach(function (model) { if (model.subscriptions) { assert.ok(Array.isArray(model.subscriptions), 'subs must be an array') 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 // [obj|fn] -> null function router (cb) { _router = sheetRouter(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 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] }) }