var _ = require('lodash'); var querystring = require('querystring'); var RouteParser = require('route-parser'); var EventEmitter = require('events').EventEmitter; var cancellableNextTick = require('cancellable-next-tick'); if (process.browser){ var History = require('html5-history'); var routers = []; History.Adapter.bind(window, 'statechange', function(){ _.forEach(routers, function(router){ router._update(window.document.location.pathname + window.document.location.search, true); }); }); } var route_parser_cache = {}; var getRouteParser = function(pattern){ if (pattern in route_parser_cache){ return route_parser_cache[pattern]; } try { return route_parser_cache[pattern] = new RouteParser(pattern); } catch (error){ throw new Error('Could not parse url pattern "' + pattern + '": ' + error.message); } }; /** * Mutable observable abstraction of url as route with parameters * @example var router = new ObsRouter({ home: '/', blog: '/blog(/tag/:tag)(/:slug)', contact: '/contact' }, { initialEmit: true, bindToWindow: false, url: '/?foo=bar' }); console.log(router.url, router.name, router.params, router.routes); // -> '/?foo=bar' 'home' {foo: 'bar'} {home: {foo: 'bar'}, blog: null, contact: null} * @class ObsRouter * @param {Object} patterns Pathname patterns keyed by route names. * @param {Object} [options] Options * @param {Boolean} [options.initialEmit=false] If true, events will be emitted after nextTick, unless emitted earlier due to changes, ensuring that events are emitted at least once. * @param {Boolean} [options.bindToWindow=true] Bind to document location if running in browser * @param {string} [options.url=""] Initial url. If binding to document loctation then this is ignored. */ var ObsRouter = function(patterns, options) { var self = this; if (!(typeof patterns=='object')){throw new Error('ObsRouter constructor expects patterns object as first argument')} options = options || {}; EventEmitter.call(self); if (process.browser){ //bind to window by default self._bindToWindow = 'bindToWindow' in options ? options.bindToWindow : true; if (self._bindToWindow){ // add to module-scoped list of routers routers.push(self); // override any url input with window location options.url = window.document.location.pathname + window.document.location.search; } } // initialise state self.patterns = patterns; self.name = null; self.params = {}; self.routes = {}; _.forEach(_.keys(self.patterns), function(route){ self.routes[route] = null; }); // normalise state self._update(options.url || '', false); // implement initialEmit option if (options.initialEmit){ var cancel = cancellableNextTick(function(){ self._emit(); }); self.once('url', cancel); } }; ObsRouter.prototype = _.create(EventEmitter.prototype); ObsRouter.prototype._update = function(url, emit){ var self = this; if (url == self.url){return;} self.old_url = self.url; self.old_name = self.name; self.old_params = self.params; self.url = url; self.name = self.urlToRoute(self.url, self.params = {}); if (self.old_name){ self.routes[self.old_name] = null; } if (self.name){ self.routes[self.name] = self.params; } if (emit){ self._emit(); } }; ObsRouter.prototype._emit = function(){ var self = this; self.emit('url', self.url, self.old_url); self.emit('route', self.name, self.params, self.old_name, self.old_params); if (self.old_name && (self.old_name !== self.name)){ self.emit(self.old_name, null); } if (self.name){ self.emit(self.name, self.params); } }; /** * Uses History.replaceState to change url to new url and updates route name + params + routes * Replacing rather than pushing means the browser's user can not get to the previous state with the back button. * @param {string} url The new url */ ObsRouter.prototype.replaceUrl = function(url){ var self = this; if (process.browser && self._bindToWindow){ History.replaceState({}, window.document.title, url); } else { self._update(url, true); } }; /** * Uses History.pushState to change url to new url and updates route name + params + routes * Pushing rather than replacing means the browser's user can get to the previous state with the back button. * @param {string} url The new url */ ObsRouter.prototype.pushUrl = function(url){ var self = this; if (process.browser && self._bindToWindow){ History.pushState({}, window.document.title, url); } else { self._update(url, true); } }; /** * Uses History.replaceState to change the url to new url and updates route name + params + routes * Replacing rather than pushing means the browser's user can not get to the previous state with the back button. * @param {string} [name=this.name] The new route name * @param {Object} [params={}] The new parameters * @param {Boolean} [extend_params=false] Extend parameters rather than replacing them. */ ObsRouter.prototype.replaceRoute = function(name, params, extend_params){ var self = this; self.replaceUrl(self.routeToUrl(name, params, extend_params)); }; /** * Uses History.pushState to change the route name + params to new route name + params and updates url + routes * Pushing rather than replacing means the browser's user can get to the previous state with the back button. * @param {string} [name=this.name] The new route name * @param {Object} [params={}] The new parameters * @param {Boolean} [extend_params=false] Extend parameters rather than replacing them. */ ObsRouter.prototype.pushRoute = function(name, params, extend_params){ var self = this; self.pushUrl(self.routeToUrl(name, params, extend_params)); }; /** * Converts a route (name & params) to a url * @example * // simple example * * var url = router.routeToUrl('blog', {slug: 'Why_I_love_Russian_girls', page: 82}); * console.log(url); // -> /blog/Why_I_love_Russian_girls?page=82 * * // cool example using extend_params=true * * console.log(router.params); // -> {tags: 'sexy'} * var url = router.routeToUrl('blog', {filter: 'graphic'}, true); * console.log(url); // -> '/blog/tags/sexy?filter=graphic' * @param {string} [name=this.name] Route name * @param {Object} [params={}] Route parameters * @param {Boolean} [extend_params=false] Extend parameters rather than replacing them. * @returns {string} Url */ ObsRouter.prototype.routeToUrl = function(name, params, extend_params){ var self = this; var _name = name || self.name; var _params = _.assign({}, extend_params ? self.params : {}, params || {}); return ObsRouter.routeToUrl(self.patterns, _name, _params); }; /** * Converts a url to a name & params. * The optional params argument is used to pass back the arguments, and only the name is `return`ed. * @example * var route_params = {}; * var route_name = router.urlToRoute('/blog', route_params); * console.log(route_name, route_params); * // -> blog {tag: undefined, slug: undefined} * @param {string} url Url to convert. * @param {Object} [params] Parameters object to populate. * @returns {string|null} Route name or null if no route matched */ ObsRouter.prototype.urlToRoute = function(url, params){ return ObsRouter.urlToRoute(this.patterns, url, params); }; /** * Cleanup method to be called when you're done with your ObsRouter instance. */ ObsRouter.prototype.destroy = function(){ var self = this; self.removeAllListeners(); if (process.browser && self._bindToWindow){ routers = _.without(routers, self); }; }; /** * Converts a route (name & params) to a url, given patterns * @param {Object} patterns Pathname patterns keyed by route names * @param {string} name Route name * @param {Object} [params={}] Route parameters * @returns {string} Url */ ObsRouter.routeToUrl = function(patterns, name, params){ params = params || {}; var route_parser = getRouteParser(patterns[name]); var path = route_parser.reverse(params); if (path===false){ throw new Error('Missing required parameter') } for (var _name in patterns){ if (_name == name){break;} if (getRouteParser(patterns[_name]).match(path)){ throw new Error('Found unreachable route. Path ' + path + ' for route ' + name + ' (params: ' + JSON.stringify(params) + ') also matches route ' + _name + '! Maybe you need to change the order of the patterns?'); } } var pathname_params = route_parser.match(path); var query_params = {}; _.forEach(params, function(value, key){ if (!(key in pathname_params)){ query_params[key] = value; } }); return path + (JSON.stringify(query_params)=='{}' ? '' : '?' + querystring.encode(query_params)); }; /** * Converts a url to a name & params, given patterns * The optional params argument is used to pass back the arguments, and only the name is `return`ed. * @param {Object} patterns Pathname patterns keyed by route names * @param {string} url Url to convert. * @param {Object} [params] Parameters object to populate. * @returns {string|null} Route name or null if no route matched */ ObsRouter.urlToRoute = function(patterns, url, params){ var self = this; var name = null; _.find(patterns, function(pattern, _name){ var pathname_params = getRouteParser(pattern).match(url); if (pathname_params){ if (typeof params=='object'){ var index = url.indexOf('?'); var query_params = index == -1 ? {} : querystring.decode(url.slice(index+1)); _.assign(params, query_params, pathname_params); } name = _name; return true; } }); return name; }; module.exports = ObsRouter;