const history = require('sheet-router/history') const sheetRouter = require('sheet-router') const document = require('global/document') const href = require('sheet-router/href') const sendAction = require('send-action') 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, state) { const initialState = {} _models.forEach(function (model) { if (model.state) apply(model.name, model.state, initialState) }) const tree = _router(route, xtend(initialState, state), function () { throw new Error('send() cannot be called on the server') }) return tree.toString() } // start the application // obj -> DOMNode function start (opts) { 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) }) } }) // 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 const 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 // 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 newState = _reducers[action.type](action, state[ns]) state[ns] = xtend(state[ns], newState) } else state = xtend(state, 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) } return state } // update the DOM after every state mutation // (obj, obj) -> null function onchange (action, state) { const oldTree = document.querySelector('#' + rootId) const newTree = _router(state.app.location, state, send) 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 model = { namespace: 'app', state: { location: document.location.href }, subscriptions: [], reducers: { // handle href links location: function setLocation (action, state) { return { location: action.location.replace(/#.*/, '') } } } } // enable catching links // enable HTML5 history API if (opts.history !== false) pushLocationSub(href) if (opts.history !== false) pushLocationSub(history) 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] }) }