RouterBase.js 10.2 KB
define([
	"dojo/_base/declare",
	"dojo/hash",
	"dojo/topic"
], function(declare, hash, topic){

	// module:
	//		dojo/router/RouterBase

	// Creating a basic trim to avoid needing the full dojo/string module
	// similarly to dojo/_base/lang's trim
	var trim;
	if(String.prototype.trim){
		trim = function(str){ return str.trim(); };
	}else{
		trim = function(str){ return str.replace(/^\s\s*/, '').replace(/\s\s*$/, ''); };
	}

	// Firing of routes on the route object is always the same,
	// no clean way to expose this on the prototype since it's for the
	// internal router objects.
	function fireRoute(params, currentPath, newPath){
		var queue, isStopped, isPrevented, eventObj, callbackArgs, i, l;

		queue = this.callbackQueue;
		isStopped = false;
		isPrevented = false;
		eventObj = {
			stopImmediatePropagation: function(){ isStopped = true; },
			preventDefault: function(){ isPrevented = true; },
			oldPath: currentPath,
			newPath: newPath,
			params: params
		};

		callbackArgs = [eventObj];

		if(params instanceof Array){
			callbackArgs = callbackArgs.concat(params);
		}else{
			for(var key in params){
				callbackArgs.push(params[key]);
			}
		}

		for(i=0, l=queue.length; i<l; ++i){
			if(!isStopped){
				queue[i].apply(null, callbackArgs);
			}
		}

		return !isPrevented;
	}

	// Our actual class-like object
	var RouterBase = declare(null, {
		// summary:
		//		A module that allows one to easily map hash-based structures into
		//		callbacks. The router module is a singleton, offering one central
		//		point for all registrations of this type.
		// example:
		//	|	var router = new RouterBase({});
		//	|	router.register("/widgets/:id", function(evt){
		//	|		// If "/widgets/3" was matched,
		//	|		// evt.params.id === "3"
		//	|		xhr.get({
		//	|			url: "/some/path/" + evt.params.id,
		//	|			load: function(data){
		//	|				// ...
		//	|			}
		//	|		});
		//	|	});

		_routes: null,
		_routeIndex: null,
		_started: false,
		_currentPath: "",

		idMatch: /:(\w[\w\d]*)/g,
		idReplacement: "([^\\/]+)",
		globMatch: /\*(\w[\w\d]*)/,
		globReplacement: "(.+)",

		constructor: function(kwArgs){
			// A couple of safety initializations
			this._routes = [];
			this._routeIndex = {};

			// Simple constructor-style "Decorate myself all over" for now
			for(var i in kwArgs){
				if(kwArgs.hasOwnProperty(i)){
					this[i] = kwArgs[i];
				}
			}
		},

		register: function(/*String|RegExp*/ route, /*Function*/ callback){
			// summary:
			//		Registers a route to a handling callback
			// description:
			//		Given either a string or a regular expression, the router
			//		will monitor the page's hash and respond to changes that
			//		match the string or regex as provided.
			//
			//		When provided a regex for the route:
			//
			//		- Matching is performed, and the resulting capture groups
			//		are passed through to the callback as an array.
			//
			//		When provided a string for the route:
			//
			//		- The string is parsed as a URL-like structure, like
			//		"/foo/bar"
			//		- If any portions of that URL are prefixed with a colon
			//		(:), they will be parsed out and provided to the callback
			//		as properties of an object.
			//		- If the last piece of the URL-like structure is prefixed
			//		with a star (*) instead of a colon, it will be replaced in
			//		the resulting regex with a greedy (.+) match and
			//		anything remaining on the hash will be provided as a
			//		property on the object passed into the callback. Think of
			//		it like a basic means of globbing the end of a route.
			// example:
			//	|	router.register("/foo/:bar/*baz", function(object){
			//	|		// If the hash was "/foo/abc/def/ghi",
			//	|		// object.bar === "abc"
			//	|		// object.baz === "def/ghi"
			//	|	});
			// returns: Object
			//		A plain JavaScript object to be used as a handle for
			//		either removing this specific callback's registration, as
			//		well as to add new callbacks with the same route initially
			//		used.
			// route: String|RegExp
			//		A string or regular expression which will be used when
			//		monitoring hash changes.
			// callback: Function
			//		When the hash matches a pattern as described in the route,
			//		this callback will be executed. It will receive an event
			//		object that will have several properties:
			//
			//		- params: Either an array or object of properties pulled
			//		from the new hash
			//		- oldPath: The hash in its state before the change
			//		- newPath: The new hash being shifted to
			//		- preventDefault: A method that will stop hash changes
			//		from being actually applied to the active hash. This only
			//		works if the hash change was initiated using `router.go`,
			//		as changes initiated more directly to the location.hash
			//		property will already be in place
			//		- stopImmediatePropagation: When called, will stop any
			//		further bound callbacks on this particular route from
			//		being executed. If two distinct routes are bound that are
			//		different, but both happen to match the current hash in
			//		some way, this will *not* keep other routes from receiving
			//		notice of the change.

			return this._registerRoute(route, callback);
		},

		registerBefore: function(/*String|RegExp*/ route, /*Function*/ callback){
			// summary:
			//		Registers a route to a handling callback, except before
			//		any previously registered callbacks
			// description:
			//		Much like the `register` method, `registerBefore` allows
			//		us to register route callbacks to happen before any
			//		previously registered callbacks. See the documentation for
			//		`register` for more details and examples.

			return this._registerRoute(route, callback, true);
		},

		go: function(path, replace){
			// summary:
			//		A simple pass-through to make changing the hash easy,
			//		without having to require dojo/hash directly. It also
			//		synchronously fires off any routes that match.
			// example:
			//	|	router.go("/foo/bar");

			var applyChange;

			if(typeof path !== "string"){return false;}

			path = trim(path);
			applyChange = this._handlePathChange(path);

			if(applyChange){
				hash(path, replace);
			}

			return applyChange;
		},

		startup: function(defaultPath){
			// summary:
			//		This method must be called to activate the router. Until
			//		startup is called, no hash changes will trigger route
			//		callbacks.

			if(this._started){ return; }

			var self = this,
				startingPath = hash();

			this._started = true;
			this._hashchangeHandle = topic.subscribe("/dojo/hashchange", function(){
				self._handlePathChange.apply(self, arguments);
			});

			if(!startingPath){
				// If there is no initial starting point, push our defaultPath into our
				// history as the starting point
				this.go(defaultPath, true);
			}else{
				// Handle the starting path
				this._handlePathChange(startingPath);
			}
		},

		destroy: function(){
			this._hashchangeHandle.remove();
			this._routes = null;
			this._routeIndex = null;
		},

		_handlePathChange: function(newPath){
			var i, j, li, lj, routeObj, result,
				allowChange, parameterNames, params,
				routes = this._routes,
				currentPath = this._currentPath;

			if(!this._started || newPath === currentPath){ return allowChange; }

			allowChange = true;

			for(i=0, li=routes.length; i<li; ++i){
				routeObj = routes[i];
				result = routeObj.route.exec(newPath);

				if(result){
					if(routeObj.parameterNames){
						parameterNames = routeObj.parameterNames;
						params = {};

						for(j=0, lj=parameterNames.length; j<lj; ++j){
							params[parameterNames[j]] = result[j+1];
						}
					}else{
						params = result.slice(1);
					}
					allowChange = routeObj.fire(params, currentPath, newPath);
				}
			}

			if(allowChange){
				this._currentPath = newPath;
			}

			return allowChange;
		},

		_convertRouteToRegExp: function(route){
			// Sub in based on IDs and globs
			route = route.replace(this.idMatch, this.idReplacement);
			route = route.replace(this.globMatch, this.globReplacement);
			// Make sure it's an exact match
			route = "^" + route + "$";

			return new RegExp(route);
		},

		_getParameterNames: function(route){
			var idMatch = this.idMatch,
				globMatch = this.globMatch,
				parameterNames = [], match;

			idMatch.lastIndex = 0;

			while((match = idMatch.exec(route)) !== null){
				parameterNames.push(match[1]);
			}
			if((match = globMatch.exec(route)) !== null){
				parameterNames.push(match[1]);
			}

			return parameterNames.length > 0 ? parameterNames : null;
		},

		_indexRoutes: function(){
			var i, l, route, routeIndex, routes = this._routes;

			// Start a new route index
			routeIndex = this._routeIndex = {};

			// Set it up again
			for(i=0, l=routes.length; i<l; ++i){
				route = routes[i];
				routeIndex[route.route] = i;
			}
		},

		_registerRoute: function(/*String|RegExp*/route, /*Function*/callback, /*Boolean?*/isBefore){
			var index, exists, routeObj, callbackQueue, removed,
				self = this, routes = this._routes,
				routeIndex = this._routeIndex;

			// Try to fetch the route if it already exists.
			// This works thanks to stringifying of regex
			index = this._routeIndex[route];
			exists = typeof index !== "undefined";
			if(exists){
				routeObj = routes[index];
			}

			// If we didn't get one, make a default start point
			if(!routeObj){
				routeObj = {
					route: route,
					callbackQueue: [],
					fire: fireRoute
				};
			}

			callbackQueue = routeObj.callbackQueue;

			if(typeof route == "string"){
				routeObj.parameterNames = this._getParameterNames(route);
				routeObj.route = this._convertRouteToRegExp(route);
			}

			if(isBefore){
				callbackQueue.unshift(callback);
			}else{
				callbackQueue.push(callback);
			}

			if(!exists){
				index = routes.length;
				routeIndex[route] = index;
				routes.push(routeObj);
			}

			// Useful in a moment to keep from re-removing routes
			removed = false;

			return { // Object
				remove: function(){
					var i, l;

					if(removed){ return; }

					for(i=0, l=callbackQueue.length; i<l; ++i){
						if(callbackQueue[i] === callback){
							callbackQueue.splice(i, 1);
						}
					}


					if(callbackQueue.length === 0){
						routes.splice(index, 1);
						self._indexRoutes();
					}

					removed = true;
				},
				register: function(callback, isBefore){
					return self.register(route, callback, isBefore);
				}
			};
		}
	});

	return RouterBase;
});