UNPKG

lead-router

Version:
544 lines (484 loc) 13.6 kB
var Emitter = require('component-emitter'); var qs = require('qs'); var trim = require('trim-character'); var extend = require('extend-merge').extend; var merge = require('extend-merge').merge; var Route = require('./route'); var Transition = require('./transition'); class Router { constructor(options) { this._options = extend({ basePath: '', scopes: [], handler: (name, routes) => { throw new Error('Missing dispatching handler, you need to define a dispatching handler.'); }, interval: 100 }, options); if (!this._options.basePath) { var bases = document.getElementsByTagName('base'); this._options.basePath = bases.length > 0 ? bases[0].href.replace(/^(?:\/\/|[^\/]+)*\//, "") : ''; } /** * The router base path * * var String */ this._basePath = trim(this._options.basePath, '/'); this._basePath = this._basePath ? '/' + this._basePath : ''; /** * The router base path regexp * * var RegExp */ var basePath = this._basePath.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); this._basePathRegExp = new RegExp('^' + '(' + basePath + '$|' + basePath + '\/)'); /** * Available scopes * * var Array */ this._scopes = this._options.scopes; /** * The router's tree * * var Object */ this._route = new Route(); /** * The default params * * var Object */ this._defaultParams /** * The ongoing route instance * * var Object */ this._ongoingRoute = null; /** * The ongoing params * * var Object */ this._ongoingParams = {}; /** * The current route instance * * var Object */ this._currentRoute = null; /** * The current scope prefix * * var String */ this._currentScope = ''; /** * The current params * * var Object */ this._currentParams = {}; /** * The current location * * var String */ this._currentLocation = null; /** * The URL listener * * var Function */ this._listener = null; /** * The URL checked * * var RegExp */ this._isAbsoluteUrl = new RegExp('^(?:[a-z]+:)?//', 'i'); } /** * Gets/sets the router dispatch handler. * * @param Function handler The dispatch handler. * @return Function|self The dispatch handler on get or `this` on set. */ handler(handler) { if (!arguments.length) { return this._options.handler; } this._options.handler = handler; return this; } /** * Adds a route state. * * @param mixed names The dotted route name string or a route name array. * @param String pattern A string pattern. * @param Object content The custom route content to attach to the pattern * * @return self */ add(name, pattern, content) { this._route.add(name, pattern, content); return this; } /** * Get/set the default params. * * @param Object|undefined The ongoing route to set or none to get the setted one. * @return Object|self */ defaultParams(defaultParams) { if (!arguments.length) { return this._defaultParams; } this._defaultParams = defaultParams; return this; } /** * Get/set the ongoing route. * * @param Object|undefined The ongoing route to set or none to get the setted one. * @return Object|self */ ongoingRoute(route) { if (!arguments.length) { return this._ongoingRoute; } this._ongoingRoute = route; return this; } /** * Get/set the ongoing params. * * @param Object|undefined The ongoing params to set or none to get the setted one. * @return Object|self */ ongoingParams(params) { if (!arguments.length) { return this._ongoingParams; } this._ongoingParams = extend({}, params); return this; } /** * Get/set the current route. * * @param Object|undefined The current route to set or none to get the setted one. * @return Object|self */ currentRoute(route) { if (!arguments.length) { return this._currentRoute; } this._currentRoute = route; return this; } /** * Get/set the scope. * * @param String|undefined The scope prefix. * @return String|self */ currentScope(scope) { if (!arguments.length) { return this._currentScope; } this._currentScope = scope; return this; } /** * Get/set the current params. * * @param Object|undefined The current params to set or none to get the setted one. * @return Object|self */ currentParams(params) { if (!arguments.length) { return this._currentParams; } this._currentParams = extend({}, params); return this; } /** * Get/set the current URL. * * @param String|undefined location The current location to set or none to get the setted one. * @return String|self */ currentLocation(location) { if (!arguments.length) { return this._currentLocation; } var path = '/' + trim.left(location, '/'); this._currentLocation = '/' + trim.left(path.replace(/index\.html$/, ''), '/'); return this; } /** * Check is a state is active or not. * * @param params Object The params of the state. * @return Boolean */ isActive(name, params) { var currentRoute = this.currentRoute(); if (!this._currentRoute) { return false; } function paramsMatch(currentParams, params) { if (currentParams === params) { return true; } for (var name in params) { if (String(currentParams[name]) !== String(params[name])) { return false; } } return true; } var currentRouteName = currentRoute.name(); return (name === currentRouteName || currentRouteName.indexOf(name + '.') === 0) && paramsMatch(this.currentParams(), params); } /** * Generate an URL from a route name and route params. * * @param String name The route name * @param Object params The route params (key-value pairs) * @param Object options Some link generation options * @return String The built URL */ link(name, params, options = {}) { if (name && name.match(/^(.*:)?\/\/.*/)) { return name; } if (name === '.') { var ongoingRoute = this.ongoingRoute(); if (ongoingRoute) { name = ongoingRoute.name(); } else { throw new Error("No current route available, the `'.'` shortcut can't be used."); } } var defaults = { basePath: this._basePath, query: undefined }; options = extend({}, defaults, options); options.scope = options.scope != null ? options.scope : this._currentScope; var route, content; do { route = this._route.fetch(name); content = route.content(); if (content.redirectTo) { name = content.redirectTo; } } while(content.redirectTo); if (typeof options.query === 'string') { options.query = qs.parse(options.query); } else if (options.query == null) { params = extend({}, this._defaultParams, params); options.query = this.buildQueryParams(name, params); } return route.link(params, options); } /** * Build query string params. * * @param mixed names The dotted route name string or a route name array. * @param Array params The route parameters. * @return Object The query string parameters. */ buildQueryParams(names, params) { if (names == null) { throw new Error("A route's name can't be empty."); } names = Array.isArray(names) ? names : names.split('.'); var route = this._route; var params = params || {}; var result = {}; while(names.length) { var route = route.getChildren(names[0]); result = extend(result, route.buildQueryParams(params)); names.shift(); } return result; } /** * Return a transition matching a defined URL. * * @param String location The location to match * @return Object The matched transition object (undefined if no match found) */ match(location) { var parts = location.split('?'); var path = trim.left(parts[0], '/'); // Find scope var currentScope = ''; for (var scope of this._scopes) { if (path.match(new RegExp('^' + '(' + scope + '$|' + scope + '\/)'))) { currentScope = scope; } } if (currentScope) { path = path.replace(new RegExp('^' + '(' + currentScope + '$|' + currentScope + '\/)'), ''); } var bag = this._route.match(path, parts[1] ? qs.parse(parts[1], { arrayLimit: Infinity }) : {}); if (!bag) { return; } var content = bag.route.content(); while (content.redirectTo) { var name = content.redirectTo; bag.route = this._route.fetch(name); content = bag.route.content(); } return new Transition({ from: this.currentRoute(), to: bag.route, params: bag.params, scope: currentScope }); } /** * Return a route instance from a route name. * * @param mixed name The dotted route name string or a route name array. * @return Object Returns the corresponding route. */ fetch(name) { return this._route.fetch(name); } /** * Return the browser location. * * @return String The browser location. */ location() { var path = ''; path = location.pathname + location.search; return '/' + trim.left(path.replace(/index\.html$/, ''), '/'); } /** * Navigate to an new location to the browser. * * @param mixed name The dotted route name string or a route name array. * @param Object params The route params (key-value pairs) */ push(name, params, replace = false, options = {}) { var location = this.link(name, params, options); return this.navigate(location, replace); } /** * Navigate to an new location to the browser without pushing a new history entry. * * @param mixed name The dotted route name string or a route name array. * @param Object params The route params (key-value pairs) */ replace(name, params) { return this.push(name, params, true); } /** * Navigate to an new location to the browser. * * @param String location The location URL. * @param Boolean replace If `true` replace the url without pushing a new history entry. */ navigate(location, replace) { location = this._isAbsoluteUrl.test(location) ? location : '/' + trim.left(location.replace(/index\.html$/, ''), '/'); return this.dispatch(location, true).then(() => { if (location) { this.currentLocation(location); history[replace ? 'replaceState' : 'pushState'](null, null, location); } }); } /** * Move forwards/backwards in the history stack. * This method takes a single integer as parameter that indicates by how many steps to go forwards or go backwards in the history stack * * @param Number step How many steps to go forwards or go backwards. */ go(step) { return history.go(step); } /** * Dispatch an URL. * * @param String|undefined location The URL to dispatch. */ dispatch(location, navigation) { if (this._in) { return Promise.resolve(this.currentLocation()); } this._in = true; location = location || this.location(); location = '/' + trim.left(location.replace(/index\.html$/, ''), '/'); if (this.currentLocation() === location) { this._in = false; return Promise.resolve(location); } this.currentLocation(location); var transition = this.match(location.replace(this._basePathRegExp, '')); if (!transition) { this._in = false; this.emit('404', location); return Promise.reject(new Error('Page not found.')); } let oldRoute = this.currentRoute(); let oldScope = this.currentScope(); let oldParams = this.currentParams(); this.currentScope(transition.scope()); var to = transition.to(); var toLocation = this.link(to.name(), transition.params()); // It happens when transition has been redirected through redirectTo if (location.split('?')[0] !== toLocation.split('?')[0]) { this.currentLocation(toLocation); history.replaceState(null, null, toLocation); } return this._options.handler(transition, this).then((location) => { if (this.currentLocation() !== location && !navigation) { this.currentLocation(location); history.replaceState(null, null, location); } this.currentRoute(transition.to()); this.currentParams(transition.params()); this.emit('transitioned', transition); this._in = false; return location; }, () => { this.emit('424', transition); if (oldRoute) { this.currentScope(oldScope); this.abort().push(oldRoute.name(), oldParams); } }); }; abort() { this._in = false; return this; } /** * Listen for browser URL changes. */ listen() { if (this._listener) { return; } this._listener = setInterval(this.dispatch.bind(this), this._options.interval); } /** * Stop listening. */ stop() { if (!this._listener) { return; } clearInterval(this._listener); } } Emitter(Router.prototype); module.exports = Router;