Files
buuh/index.js
T
Todd Kennedy 1d38ab9f22 Allow for naming the rootId with no options
Right now if you want to name your rootId something different, but don't pass
in any options, it doesn't change the name of your rootId (see confusion in
github #49.

This would allow someone to use

```js
const app = choo()
app.start('myRootNode')
```

Rather than having to use

```js
const app = choo()
app.start('myRootNode', {name: 'myRootNode'})
```
2016-06-19 08:35:41 -07:00

253 lines
7.3 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')
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 <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]
})
}