rendr
Version:
Render your Backbone.js apps on the client and the server.
299 lines (243 loc) • 7.96 kB
JavaScript
/**
* Since we make rendr files AMD friendly on app setup stage
* we need to pretend that this code is pure commonjs
* means no AMD-style require calls
*/
var _ = require('underscore'),
Backbone = require('backbone'),
BaseRouter = require('../shared/base/router'),
BaseView = require('../shared/base/view'),
isServer = (typeof window === 'undefined'),
extractParamNamesRe = /:(\w+)/g,
plusRe = /\+/g,
firstRender = true,
defaultRootPath = '';
if (!isServer) {
Backbone.$ = window.$ || require('jquery');
}
module.exports = ClientRouter;
function ClientRouter(options) {
this._router = new Backbone.Router();
BaseRouter.apply(this, arguments);
this.app = options.app;
var AppView = this.options.appViewClass;
// We do this here so that it's available in AppView initialization.
this.app.router = this;
this.on('route:add', this.addBackboneRoute, this);
this.on('action:start', this.trackAction, this);
this.app.on('reload', this.renderView, this);
this.appView = new AppView({
app: this.app
});
this.appView.render();
this.buildRoutes();
this.initialize(options);
}
/**
* Set up inheritance.
*/
ClientRouter.prototype = Object.create(BaseRouter.prototype);
ClientRouter.prototype.constructor = ClientRouter;
ClientRouter.prototype.currentFragment = null;
ClientRouter.prototype.previousFragment = null;
/**
* In a controller action, can access the current route
* definition with `this.currentRoute`.
*/
ClientRouter.prototype.currentRoute = null;
/**
* Instance of Backbone.Router used to manage browser history.
*/
ClientRouter.prototype._router = null;
/**
* We need to reverse the routes in the client because
* Backbone.History matches in reverse.
*/
ClientRouter.prototype.reverseRoutes = true;
ClientRouter.prototype.initialize = _.noop;
/**
* Piggyback on adding new route definition events
* to also add to Backbone.Router.
*/
ClientRouter.prototype.addBackboneRoute = function(routeObj) {
var handler, name, pattern, route;
// Backbone.History wants no leading slash on strings.
pattern = (routeObj[0] instanceof RegExp) ? routeObj[0] : routeObj[0].slice(1);
route = routeObj[1];
handler = routeObj[2];
name = route.controller + ":" + route.action;
this._router.route(pattern, name, handler);
};
ClientRouter.prototype.getHandler = function(action, pattern, route) {
var router = this;
// abstract action call
function actionCall(action, params) {
action.call(router, params, router.getRenderCallback(route));
}
// This returns a function which is called by Backbone.history.
return function() {
var params, paramsArray, redirect;
router.trigger('action:start', route, firstRender);
router.currentRoute = route;
if (firstRender) {
firstRender = false;
BaseView.getChildViews(router.app, null, function(views) {
router.currentView = router.getMainView(views);
router.trigger('action:end', route, true);
});
} else {
paramsArray = _.toArray(arguments);
params = router.getParamsHash(pattern, paramsArray, window.location.search);
redirect = router.getRedirect(route, params);
/**
* If `redirect` is present, then do a redirect and return.
*/
if (redirect != null) {
router.redirectTo(redirect, {replace: true});
} else {
if (!action) {
throw new Error("Missing action \"" + route.action + "\" for controller \"" + route.controller + "\"");
} else {
actionCall(action, params);
}
}
}
};
};
/**
* Can be overridden by applications
* if the initial render is more complicated.
*/
ClientRouter.prototype.getMainView = function(views) {
var $content = this.appView.$content;
return _.find(views, function(view) {
return view.$el.parent().is($content);
});
};
/**
* Proxy to Backbone.Router.
*/
ClientRouter.prototype.navigate = function(path, options) {
var fragment = Backbone.history.getFragment(path);
// check if local router can handle route
if (this.matchesAnyRoute(fragment)) {
this._router.navigate.apply(this._router, arguments);
} else {
this.redirectTo(fragment, {pushState: false});
}
};
ClientRouter.prototype.getParamsHash = function(pattern, paramsArray, search) {
var paramNames, params, query;
if (pattern instanceof RegExp) {
paramNames = paramsArray.map(function(val, i) { return String(i); });
} else {
paramNames = (pattern.match(extractParamNamesRe) || []).map(function(name) {
return name.slice(1);
});
}
params = (paramNames || []).reduce(function(memo, name, i) {
memo[name] = decodeURIComponent(paramsArray[i]);
return memo;
}, {});
query = search.slice(1).split('&').reduce(function(memo, queryPart) {
var parts = queryPart.split('=');
if (parts.length > 1) {
memo[parts[0]] = decodeURIComponent(parts[1].replace(plusRe, ' '));
}
return memo;
}, {});
return _.extend(query, params);
};
ClientRouter.prototype.matchingRoute = function(path) {
return _.find(Backbone.history.handlers, function(handler) {
return handler.route.test(path);
});
};
ClientRouter.prototype.matchesAnyRoute = function(path) {
return this.matchingRoute(path) != null;
};
ClientRouter.prototype.redirectTo = function(path, options) {
var hashParts;
if (options == null) {
options = {};
}
_.defaults(options, {
trigger: true,
pushState: true,
replace: false
});
if (options.pushState === false) {
// Do a full-page redirect.
this.exitApp(path);
} else {
// Do a pushState navigation.
hashParts = path.split('#');
path = hashParts[0];
// But then trigger the hash afterwards.
if (hashParts.length > 1) {
this.once('action:end', function() {
window.location.hash = hashParts[1];
});
}
// Ignore hash for routing.
this.navigate(path, options);
}
};
ClientRouter.prototype.exitApp = function (path) {
var exitPath = this.noRelativePath(path);
window.location.href = exitPath;
}
ClientRouter.prototype.noRelativePath = function (path) {
//if path doesn't have a protocol and lacks a leading slash
if (/^[a-z]+:/i.test(path) === false && path.charAt(0) !== '/') {
path = '/' + path;
}
return path;
}
ClientRouter.prototype.handleErr = function(err, route) {
this.trigger('action:error', err, route);
}
ClientRouter.prototype.getRenderCallback = function(route) {
return function(err, viewPath, locals) {
if (err) return this.handleErr(err, route);
var View, _router = this;
if (this.currentView) {
this.currentView.remove();
}
var defaults = this.defaultHandlerParams(viewPath, locals, route);
viewPath = defaults[0];
locals = defaults[1];
locals = locals || {};
_.extend(locals, { fetch_summary: BaseView.extractFetchSummary(this.app.modelUtils, locals) });
// Inject the app.
locals.app = this.app;
this.getView(viewPath, this.options.entryPath, function(View) {
_router.currentView = new View(locals);
_router.renderView();
_router.trigger('action:end', route, firstRender);
});
}.bind(this);
};
ClientRouter.prototype.renderView = function() {
this.appView.setCurrentView(this.currentView);
};
ClientRouter.prototype.start = function() {
Backbone.history.start({
pushState: true,
hashChange: false,
root: this.options.rootPath || defaultRootPath
});
};
ClientRouter.prototype.trackAction = function() {
this.previousFragment = this.currentFragment;
this.currentFragment = Backbone.history.getFragment();
};
ClientRouter.prototype.getView = function(key, entryPath, callback) {
var View = BaseView.getView(key, entryPath, function(View) {
// TODO: Make it function (err, View)
if (!_.isFunction(View)) {
throw new Error("View '" + key + "' not found.");
}
callback(View);
});
};