Last active
August 29, 2015 14:08
-
-
Save rgrove/9d6698a8b03876cf6d26 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"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