Skip to content

Instantly share code, notes, and snippets.

@rgrove
Last active August 29, 2015 14:08
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rgrove/9d6698a8b03876cf6d26 to your computer and use it in GitHub Desktop.
Save rgrove/9d6698a8b03876cf6d26 to your computer and use it in GitHub Desktop.
"use strict";
var _ = SM.import('lodash');
var DOM = SM.import('sm-dom');
var Uri = SM.import('sm-uri');
// WebKit (as of version 538.35.8) fires a useless popstate event after every
// page load, even when the page wasn't popped off the HTML5 history stack. We
// only want to handle popstate events that result from a page actually being
// popped off the HTML5 history stack, so we need a way to differentiate between
// a popstate we care about and one that's bullshit.
//
// By using `replaceState` here to silently set a state flag, we can then check
// for this flag in any subsequent popstate event and if it's not set, we know
// the event is bullshit and can be ignored.
if (DOM.win.history && DOM.win.history.replaceState) {
DOM.win.history.replaceState({smRouter: true}, '', '');
}
/**
Client-side router based on HTML5 history.
@class Router
**/
function Router() {
this._routes = [];
DOM.win.addEventListener('popstate', _.bind(this._onPopState, this));
}
var proto = Router.prototype;
// -- Public Methods -----------------------------------------------------------
/**
Begins executing routes that match the current URL.
The only time it's necessary to call this manually is when you want to execute
routes that match the URL of the original pageview, before any client-side
navigation has happened.
Matching routes are executed in the order they were defined. Each route callback
may choose either to pass control to the next callback by calling the `next()`
function that's passed in as an argument, or it may end the chain by not calling
`next()`, in which case no more callbacks or routes will be executed.
If a route callback experiences an error, it may end execution by passing an
Error object to the `next()` function.
@chainable
**/
proto.dispatch = function () {
var path = DOM.win.location.pathname;
var matches = this.matches(path);
if (!matches.length) {
return this;
}
var req;
var res;
req = {
path : path,
query : Uri.parseQuery(DOM.win.location.search.slice(1)),
router: this,
url : DOM.win.location.toString()
};
res = {
req : req,
title: this._setTitle
};
var callbacks = [];
var match;
var next = function (err) {
if (err) {
throw err;
}
var callback = callbacks.shift();
if (callback) {
callback.call(null, req, res, next);
} else if ((match = matches.shift())) { // assignment
// Make this route the current route.
req.params = match.params;
req.route = match.route;
// Execute the current route's callbacks.
callbacks = match.route.callbacks.concat();
next();
}
};
next();
return this;
};
/**
Returns an array containing a match object for each route that matches the given
absolute path, in the order the routes were originally defined.
Each match object has the following properties:
- params (Array|Object)
An array or object of matched subpatterns from the route's path spec. If
the path spec is a string, then this will be a hash of named parameters.
If the path spec is a regex, this will be an array of regex matches. If
the path spec is a function, this will be the return value of the
function.
- route (Object)
The route object that matches the given _path_.
@param {String} path
Absolute path to match. Must not include a query string or hash.
@return {Object[]}
**/
proto.matches = function (path) {
var results = [];
_.forEach(this._routes, function (route) {
var params = route.pathFn(path);
if (params) {
results.push({
route : route,
params: params
});
}
});
return results;
};
/**
Navigates to the given _url_ using `pushState()` or `replaceState()` when
possible.
The URL may be relative or absolute, and it may include a query string or hash.
If the URL includes a protocol or hostname, it must be the same origin as the
current page or the browser will throw a security error.
If the browser doesn't support `pushState()` or `replaceState()`, Router will
fall back to standard full-page navigation to navigate to the given URL,
resulting in an HTTP request to the server.
@param {String} url
URL to navigate to.
@param {Object} options
@param {Boolean} [options.replace=false]
Whether to replace the current history entry instead of adding a new
history entry.
@chainable
**/
proto.navigate = function (url, options) {
var method = options && options.replace ? 'replaceState' : 'pushState';
if (DOM.win.history && DOM.win.history[method]) {
DOM.win.history[method]({smRouter: true}, '', url);
this.dispatch();
} else {
DOM.win.location = url;
}
return this;
};
/**
Adds a route whose callback(s) will be called when the browser navigates to a
path that matches the given _pathSpec_.
Matching routes are executed in the order they were added, and earlier routes
have the option of either ending processing or passing control to subsequent
routes.
## Path Specifications
The _pathSpec_ describes what URL paths (not including query strings) should be
handled by a route. A path spec may be one of the following types:
### String
Path spec strings use a more readable subset of regular expression syntax, and
allow you to define named placeholders that will be captured as parameter values
and made available to your route callbacks.
A path spec string looks like this:
/foo/:placeholder/bar
This path spec will match the following URL paths:
- /foo/pie/bar
- /foo/cookies/bar
- /foo/abc123/bar
...but it won't match these:
- /foo/bar
- /foo/pie/bar/baz
- /foo/pie/baz
The `:placeholder` in the path spec will capture the value of the path segment
in that place and make it available on the request object's `params` property,
so in the case of the path `/foo/pie/bar`, the value of `req.params.placeholder`
will be `"pie"`.
A placeholder preceded by a `:` will only match a single path segment. To match
zero or more path segments, use a placeholder with a `*` prefix.
/foo/*stuff
...matches these paths:
- /foo/
- /foo/pie
- /foo/pie/cookies/donuts
A `*` without a param name after it will be treated as a non-capturing wildcard:
/foo/*
To create a wildcard route that matches any path, use the path spec `*`.
### RegExp
If you need a little more power in your path spec, you can use a regular
expression. You miss out on named parameters this way, but captured subpatterns
will still be available as numeric properties on the request's `params` object.
This regex is equivalent to the `/foo/:placeholder/bar` example above:
/^\/foo\/([^\/]+)\/bar$/
### Function
For ultimate power (and zero hand-holding), you can use a function as a path
spec. The function will receive a path string as its only argument, and should
return an array or object of captured subpatterns if the route matches the path,
or a falsy value if the route doesn't match the path.
This function is equivalent to the `/foo/:placeholder/bar` example above:
function (path) {
var segments = path.split('/');
if (segments.length === 3 && segments[0] === 'foo' && segments[2] === 'bar') {
return {placeholder: segments[1]};
}
}
## Callbacks
You may specify one or more callbacks when you create a route. When the route is
matched, its first callback will be executed, and will receive three arguments:
- req (Object)
A request object containing the following information about the current
request.
- req.params (Array|Object)
A hash or array of URL-decoded parameters that were captured by this
route's path spec. See above for details on path parameters.
- req.path (String)
The URL path that was matched.
- req.query (Object)
A parsed hash representing the query string portion of the matched URL,
if there was one. Query parameter names and values in this hash are
URL-decoded. Query parameters that weren't associated with a value in
the URL will have the value `true`.
- req.route (Object)
The route object for the currently executing route.
- req.router (Router)
The Router instance that handled this request.
- req.url (String)
The full URL (including protocol, hostname, query string, and hash) that
was matched.
- res (Object)
A response object containing the following methods and properties for
manipulating the page in response to the current request.
- res.req (Object)
A reference to the request object.
- res.title()
Sets the page title.
@param {String} title
New title to set.
- next (Function)
A function which, when called with no arguments, will pass control to the
next route callback (if any). If called with an argument, that argument
will be thrown as an error and request processing will halt.
@param {String|RegExp|Function} pathSpec
Path specification describing what path(s) this route should match. See
above for details.
@param {Function} ...callbacks
One or more callback functions that should be executed when this route is
matched. See above for details.
@return {Object}
Route object describing the new route.
**/
proto.route = function (/* pathSpec, ...callbacks */) {
var route = this._createRoute.apply(this, arguments);
this._routes.push(route);
return route;
};
/**
Removes all routes from this router's route list.
Call this method if you want to discard a router and ensure that its routes
don't remain in memory and continue handling history events.
@chainable
**/
proto.removeAllRoutes = function () {
this._routes = [];
return this;
};
/**
Removes the given _route_ from this router's route list.
@param {Object} route
Route to remove. This object is returned by `route()` when a route is
created.
@chainable
**/
proto.removeRoute = function (route) {
var index = _.indexOf(this._routes, route);
if (index > -1) {
this._routes.splice(index, 1);
}
return this;
};
// -- Protected Methods --------------------------------------------------------
/**
Compiles a string path spec into a path-matching function.
@param {String} pathSpec
Path spec to compile.
@return {Function}
Compiled path matching function.
**/
proto._compilePathSpec = function (pathSpec) {
var paramNames = [];
var regex;
if (pathSpec === '*') {
regex = /.*/;
} else {
pathSpec = pathSpec.replace(/\./g, '\\.');
pathSpec = pathSpec.replace(/([*:])([\w-]*)/g, function (match, operator, paramName) {
if (!paramName) {
return operator === '*' ? '.*?' : match;
}
paramNames.push(paramName);
return operator === '*' ? '(.*?)' : '([^/]+?)';
});
regex = new RegExp('^' + pathSpec + '$');
}
return function (path) {
var matches = path.match(regex);
var params;
if (matches) {
params = {};
if (paramNames.length) {
// Assign matches to params.
for (var i = 0, len = paramNames.length; i < len; ++i) {
params[paramNames[i]] = Uri.decodeComponent(matches[i + 1]);
}
}
}
return params;
};
};
/**
Creates a new route object with the given _pathSpec_ and _callbacks_.
@param {String|RegExp|Function} pathSpec
Path specification describing what path(s) this route should match. See
`route()` details.
@param {Function} ...callbacks
One or more callback functions that should be executed when this route is
matched. See `route()` for details.
@return {Object}
Route object.
**/
proto._createRoute = function (pathSpec/*, callbacks */) {
var route = {};
route.callbacks = Array.prototype.slice.call(arguments, 1);
route.pathSpec = pathSpec;
switch (typeof pathSpec) {
case 'function':
route.pathFn = pathSpec;
break;
case 'string':
route.pathFn = this._compilePathSpec(pathSpec);
break;
default:
if (_.isRegExp(pathSpec)) {
route.pathFn = function (path) {
var matches = path.match(pathSpec);
if (!matches) {
return;
}
return _.map(matches, function (value, index) {
return index === 0 ? value : Uri.decodeComponent(value);
});
};
} else {
throw new Error('Invalid pathSpec argument. Expected a String, RegExp, or Function, but got a ' + typeof pathSpec);
}
}
return route;
};
/**
Sets the title of the document.
@param {String} title
New title.
**/
proto._setTitle = function (title) {
DOM.doc.title = title;
};
// -- Protected Event Handlers -------------------------------------------------
/**
Handles browser `popstate` events.
@param {Event} e
**/
proto._onPopState = function (e) {
if (!e.state || !e.state.smRouter) {
// This is a bullshit initial-pageload popstate event in WebKit, so we
// should ignore it.
return;
}
this.dispatch();
};
module.exports = Router;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment