wheelhouse-router
Version:
A wheelhouse library to marry flatiron's Director with Backbone's router
311 lines (252 loc) • 9.74 kB
JavaScript
'use strict';
var Backbone = require('backbone')
, _ = require('lodash')
, router = {}
// backbone oddly doesn't have a way of detecting if it's matched a route or not. Monkeypatch it.
// TODO: push this upstream to Backbone
Backbone.history.urlMatch = function(){
return _.any(Backbone.history.handlers, function(handler){
return handler.route.test(Backbone.history.fragment)
})
}
router.initialize = function(options){
this.options = _.defaults((options || {}), {
collections: 'collections/'
, controllers: 'controllers/'
, views: 'views/'
, app: {}
, fetch: true
, start: true
, user: {
model: null
, key: '_user'
}
})
this.options.app = _.defaults(this.options.app, {
Views: {}
, Templates: {}
, Renders: {}
, Collections: {}
, Datas: {}
, Models: {}
, Routers: {}
})
this.cid = _.uniqueId('router')
this.options.app.Routers[this.cid] = this
this._started = false
// TODO: story this on Backbone.history since they're a global, not a router, config
this.historyOptions = {
pushState: _.isUndefined(options.pushState) ? true : !!options.pushState
, hashChange: _.isUndefined(options.hashChange) ? false : !!options.hashChange
, root: _.isUndefined(options.root) ? '' : options.root
, silent: _.isUndefined(options.slient) ? false : !!options.slient
}
this.on('route', function(){
if (!this._started) this._started = true
if (!this.options.app._initialRouteHasFired) this.options.app._initialRouteHasFired = true
// store the fragment
this.options.app._currentFragment = Backbone.history.fragment || '/'
})
if (_.isFunction(options.parseRoutes)) options.parseRoutes(this.options)
else if (options.routesJSON) this._parseRoutes(this.options)
if (options.actions && options.routes) this._addRoutes(options)
if (this.options.start) this.start()
}
// defer executing the start until the call stack has cleared.
// This ensures that the router will be available to the views that it's rendering.
router.start = function start(immediate){
// pass the router in as an arg instead of using `bind` b/c some browsers (notably) phantomJs choke on a bind.
var backboneStart = function(router){
// Backbone.history.start will return true if it starts successfully
if (Backbone.history.start(router.historyOptions))
router._started = true
// regardless of a successful start, we're no longer trying to start the router
Backbone.history._starting = false
}
if (Backbone.History.started){
/* if we've already started, and no routes matched
check to see if any of the newly added routes match the current URL
*/
if (!this._started && Backbone.history.urlMatch() && !this.options.app._currentFragment){
if (Backbone.history.loadUrl()) this._started = true
}
// if we already have a matched route, just bail
return this
}
/* if we've already decided to start, don't try to start again.
This will prevent two routers from conflicting start statements since start is async by default
*/
else if (Backbone.history._starting){
/* we attempted to started before Backbone.history kicked off
assume that the previous start will look at the routes we've added in this router
*/
this._started = true
return this
}
Backbone.history._starting = true
if (immediate)
backboneStart(this)
else
_.defer(backboneStart, this)
return this
}
router._ctx = {
res: {
// path and code to match server-side syntax
redirect: function redirect(path, code, options){
// if no options are specified, assume we want to trigger this route
// navigate will return false if it failed
return this.navigate(path, options || {trigger: true})
}
}
, req: {
user: {}
}
}
router._getCtx = function _getCtx(options){
var ctx = this._ctx
options = _.defaults(options || {}, this.options)
ctx.req.user = options.user.model
? new options.user.model(window[options.user.key])
: window[options.user.key]
ctx.res.redirect = ctx.res.redirect.bind(this)
return ctx
}
router._addRoutes = function(options){
_.each(options.routes, function(actionName, route){
this.route(route, actionName, function(){
var params = options.actions[actionName].bind(this._getCtx())
this.render(params.view, params.collection, params)
}.bind(this))
}, this)
}
router._parseRoutes = function(options){
var routes = options.routesJSON
_.each(routes, function(actions, path){
// marrying director style routes to backbone style routes
var pathParsed = (path.charAt(0) === '/' ? path.substring(1) : path).replace('*', '*splat') + '(/)'
_.each(actions, function(controllerAction, method){
var self = this
, actionParts = controllerAction.split('#')
, controller = method.charAt(0) === '3' ? 'redirect' : require(options.controllers + actionParts[0])
, fn = actionParts[1] || method
// if this is a redirect
if (controller === 'redirect') this.route(pathParsed, ['redirect', pathParsed].join(''), function(){
self.navigate(controllerAction, {replace: true, trigger: true})
})
if (method === 'get'){
this.route(pathParsed, controllerAction, function(){
var params = controller[fn].apply(self._getCtx(), arguments)
self.render(params.view, params.collection, params)
})
}
}, this)
}, this)
}
router.render = function(view, collection, options){
var self = this
, done
, collectionFilled
, collectionData
if (!_.isString(view)) throw new Error('Must pass a view name')
options || (options = {})
// if this is the first route, we'll use any boostrapped data we find
if (!this.options.app._initialRouteHasFired) collectionData = window._bootstrapData
// after we process the collection, we'll create the view
done = options.callback || function(coll){
var v = self._setView(view, coll, options)
if (v._rendered) return
else v.render()
}
// deal with the collection
if (!collection) done()
else {
collectionFilled = this._setCollection(collection, collectionData, options.options)
options.fetch && !collectionData
? this._fetchCollection(collectionFilled, done, options)
: done(collectionFilled)
}
}
router._setCollection = function(collectionName, collectionData, options){
var data
if (_.isArray(collectionData)){
data = collectionData.length ? collectionData : void 0
}
else if (_.isPlainObject(collectionData)){
data = [collectionData]
}
this.options.app.Collections[collectionName] = require(this.options.collections + collectionName)
if (this.options.app.Datas[collectionName])
this.options.app.Datas[collectionName].set(collectionData)
else
this.options.app.Datas[collectionName] = new this.options.app.Collections[collectionName](data, options)
return this.options.app.Datas[collectionName]
}
router._fetchCollection = function(collection, callback, options){
var done = _.isFunction(callback) ? callback : function(){}
, fetchOptions
// if we already have data, don't fetch again
if (collection.length) {
return done(collection)
}
fetchOptions = {
error: function(){
// IE < 10 compatibility
if (console.error.apply) console.error.apply(console, arguments)
else console.error(arguments)
}
, success: done
}
// optional url override just for this fetch
if (options && options.options && options.options.url) fetchOptions.url = options.options.url
collection.fetch(fetchOptions)
}
router._updateRender = function(renderName){
// if we've already rendered something, remove it's events
if (this.options.app._currentRender
&& this.options.app._currentRender.cid !== this.options.app.Renders[renderName].cid){
this.options.app._currentRender.undelegateEvents()
this.options.app._currentRender.stopListening()
// trigger a remove event on the view, and let the view deal with killing any dom it doesn't want cached
this.options.app._currentRender.trigger('remove')
this.options.app._currentRender._rendered = false
}
// set this as the current render
this.options.app._currentRender = this.options.app.Renders[renderName]
}
router._setView = function(path, collection, opts){
var options = _.extend((opts || {}), {
collection: collection
})
, renderName = Backbone.history.fragment || '/'
, render
options.model = _.isFunction(options.model)
? options.model(collection)
: options.model
// if the url has been modified in a fetch, reset it
// TODO: this is hacky
if (options.model) options.model.url = Backbone.Model.prototype.url
if (options.title){
document.title = _.isFunction(options.title)
? options.title(options.model || options.collection)
: options.title
}
this.options.app.Views[path] = require(this.options.views + path)
// use the history fragment here, so that we're sure we're caching views by URL
// and not view name which could be the same for many URLs
if (this.options.app.Renders[renderName]){
render = this.options.app.Renders[renderName]
// there's a chance our view's element has been destroyed or unattached from the DOM since
// we'll recreated it and delegate it's events again
render.setElement(render.el, true)
}
else {
render = new this.options.app.Views[path](options)
this.options.app.Renders[renderName] = render
}
// unbind all previous events
// set this render to be the _currentRender
this._updateRender(renderName)
return render
}
module.exports = Backbone.Router.extend(router)