Coverage

70%
90
63
27

index.js

70%
90
63
27
LineHitsSource
1
2 /* jshint browser:true */
3 /* jshint -W079 */ // history.location
4 /* globals require, module */
5
6/**
7 * Module dependencies.
8 */
9
101 var pathtoRegexp = require('path-to-regexp');
11
12 /**
13 * Module exports.
14 */
15
161 module.exports = page;
17
18 /**
19 * To work properly with the URL
20 * history.location generated polyfill in https://github.com/devote/HTML5-History-API
21 */
22
231 var location = window.history.location || window.location;
24
25 /**
26 * Perform initial dispatch.
27 */
28
291 var dispatch = true;
30
31 /**
32 * Base path.
33 */
34
351 var base = '';
36
37 /**
38 * Running flag.
39 */
40
411 var running;
42
43 /**
44 * HashBang option
45 */
46
471 var hashbang = false;
48
49 /**
50 * Register `path` with callback `fn()`,
51 * or route `path`, or `page.start()`.
52 *
53 * page(fn);
54 * page('*', fn);
55 * page('/user/:id', load, user);
56 * page('/user/' + user.id, { some: 'thing' });
57 * page('/user/' + user.id);
58 * page();
59 *
60 * @param {String|Function} path
61 * @param {Function} fn...
62 * @api public
63 */
64
65 function page(path, fn) {
66 // <callback>
67 if ('function' == typeof path) {
680 return page('*', path);
69 }
70
71 // route <path> to <callback ...>
72 if ('function' == typeof fn) {
7312 var route = new Route(path);
74 for (var i = 1; i < arguments.length; ++i) {
7512 page.callbacks.push(route.middleware(arguments[i]));
76 }
77 // show <path> with [state]
78 } else if ('string' == typeof path) {
7910 page.show(path, fn);
80 // start [options]
81 } else {
821 page.start(path);
83 }
84 }
85
86 /**
87 * Callback functions.
88 */
89
901 page.callbacks = [];
91
92 /**
93 * Get or set basepath to `path`.
94 *
95 * @param {String} path
96 * @api public
97 */
98
991 page.base = function(path){
100 if (0 === arguments.length) return base;
1010 base = path;
102 };
103
104 /**
105 * Bind with the given `options`.
106 *
107 * Options:
108 *
109 * - `click` bind to click events [true]
110 * - `popstate` bind to popstate [true]
111 * - `dispatch` perform initial dispatch [true]
112 *
113 * @param {Object} options
114 * @api public
115 */
116
1171 page.start = function(options){
1181 options = options || {};
119 if (running) return;
1201 running = true;
121 if (false === options.dispatch) dispatch = false;
122 if (false !== options.popstate) window.addEventListener('popstate', onpopstate, false);
123 if (false !== options.click) window.addEventListener('click', onclick, false);
124 if (true === options.hashbang) hashbang = true;
125 if (!dispatch) return;
1261 var url = (hashbang && location.hash.indexOf('#!') === 0)
127 ? location.hash.substr(2) + location.search
128 : location.pathname + location.search + location.hash;
1291 page.replace(url, null, true, dispatch);
130 };
131
132 /**
133 * Unbind click and popstate event handlers.
134 *
135 * @api public
136 */
137
1381 page.stop = function(){
1390 running = false;
1400 removeEventListener('click', onclick, false);
1410 removeEventListener('popstate', onpopstate, false);
142 };
143
144 /**
145 * Show `path` with optional `state` object.
146 *
147 * @param {String} path
148 * @param {Object} state
149 * @param {Boolean} dispatch
150 * @return {Context}
151 * @api public
152 */
153
1541 page.show = function(path, state, dispatch){
15510 var ctx = new Context(path, state);
156 if (!ctx.unhandled) ctx.pushState();
157 if (false !== dispatch) page.dispatch(ctx);
15810 return ctx;
159 };
160
161 /**
162 * Replace `path` with optional `state` object.
163 *
164 * @param {String} path
165 * @param {Object} state
166 * @return {Context}
167 * @api public
168 */
169
1701 page.replace = function(path, state, init, dispatch){
1711 var ctx = new Context(path, state);
1721 ctx.init = init;
173 if (false !== dispatch) page.dispatch(ctx);
1741 ctx.save();
1751 return ctx;
176 };
177
178 /**
179 * Dispatch the given `ctx`.
180 *
181 * @param {Object} ctx
182 * @api private
183 */
184
1851 page.dispatch = function(ctx){
18611 var i = 0;
187
188 function next() {
18958 var fn = page.callbacks[i++];
190 if (!fn) return unhandled(ctx);
19158 fn(ctx, next);
192 }
193
19411 next();
195 };
196
197 /**
198 * Unhandled `ctx`. When it's not the initial
199 * popstate then redirect. If you wish to handle
200 * 404s on your own use `page('*', callback)`.
201 *
202 * @param {Context} ctx
203 * @api private
204 */
205
206 function unhandled(ctx) {
2070 var current = location.pathname + location.search;
208 if (current == ctx.canonicalPath) return;
2090 page.stop();
2100 ctx.unhandled = true;
2110 location.href = ctx.canonicalPath;
212 }
213
214 /**
215 * Initialize a new "request" `Context`
216 * with the given `path` and optional initial `state`.
217 *
218 * @param {String} path
219 * @param {Object} state
220 * @api public
221 */
222
223 function Context(path, state) {
224 if ('/' === path[0] && 0 !== path.indexOf(base)) path = base + path;
22511 var i = path.indexOf('?');
226
22711 this.canonicalPath = path;
22811 this.path = path.replace(base, '') || '/';
229
23011 this.title = document.title;
23111 this.state = state || {};
23211 this.state.path = path;
23311 this.querystring = ~i
234 ? path.slice(i + 1)
235 : '';
23611 this.pathname = ~i
237 ? path.slice(0, i)
238 : path;
23911 this.params = [];
240
241 // fragment
24211 this.hash = '';
243 if (!~this.path.indexOf('#')) return;
2440 var parts = this.path.split('#');
2450 this.path = parts[0];
2460 this.hash = parts[1] || '';
2470 this.querystring = this.querystring.split('#')[0];
248 }
249
250 /**
251 * Expose `Context`.
252 */
253
2541 page.Context = Context;
255
256 /**
257 * Push state.
258 *
259 * @api private
260 */
261
2621 Context.prototype.pushState = function(){
26310 history.pushState(this.state
264 , this.title
265 , hashbang && this.canonicalPath !== '/'
266 ? '#!' + this.canonicalPath
267 : this.canonicalPath);
268 };
269
270 /**
271 * Save the context state.
272 *
273 * @api public
274 */
275
2761 Context.prototype.save = function(){
2771 history.replaceState(this.state
278 , this.title
279 , hashbang && this.canonicalPath !== '/'
280 ? '#!' + this.canonicalPath
281 : this.canonicalPath);
282 };
283
284 /**
285 * Initialize `Route` with the given HTTP `path`,
286 * and an array of `callbacks` and `options`.
287 *
288 * Options:
289 *
290 * - `sensitive` enable case-sensitive routes
291 * - `strict` enable strict matching for trailing slashes
292 *
293 * @param {String} path
294 * @param {Object} options.
295 * @api private
296 */
297
298 function Route(path, options) {
29912 options = options || {};
30012 this.path = (path === '*') ? '(.*)' : path;
30112 this.method = 'GET';
30212 this.regexp = pathtoRegexp(this.path,
303 this.keys = [],
304 options.sensitive,
305 options.strict);
306 }
307
308 /**
309 * Expose `Route`.
310 */
311
3121 page.Route = Route;
313
314 /**
315 * Return route middleware with
316 * the given callback `fn()`.
317 *
318 * @param {Function} fn
319 * @return {Function}
320 * @api public
321 */
322
3231 Route.prototype.middleware = function(fn){
32412 var self = this;
32512 return function(ctx, next){
326 if (self.match(ctx.path, ctx.params)) return fn(ctx, next);
32747 next();
328 };
329 };
330
331 /**
332 * Check if this route matches `path`, if so
333 * populate `params`.
334 *
335 * @param {String} path
336 * @param {Array} params
337 * @return {Boolean}
338 * @api private
339 */
340
3411 Route.prototype.match = function(path, params){
34258 var keys = this.keys,
343 qsIndex = path.indexOf('?'),
344 pathname = ~qsIndex
345 ? path.slice(0, qsIndex)
346 : path,
347 m = this.regexp.exec(decodeURIComponent(pathname));
348
349 if (!m) return false;
350
351 for (var i = 1, len = m.length; i < len; ++i) {
3525 var key = keys[i - 1];
353
3545 var val = 'string' == typeof m[i]
355 ? decodeURIComponent(m[i])
356 : m[i];
357
358 if (key) {
3595 params[key.name] = undefined !== params[key.name]
360 ? params[key.name]
361 : val;
362 } else {
3630 params.push(val);
364 }
365 }
366
36711 return true;
368 };
369
370 /**
371 * Handle "populate" events.
372 */
373
374 function onpopstate(e) {
375 if (e.state) {
3760 var path = e.state.path;
3770 page.replace(path, e.state);
378 }
379 }
380
381 /**
382 * Handle "click" events.
383 */
384
385 function onclick(e) {
386 if (1 != which(e)) return;
387 if (e.metaKey || e.ctrlKey || e.shiftKey) return;
388 if (e.defaultPrevented) return;
389
390 // ensure link
3910 var el = e.target;
392 while (el && 'A' != el.nodeName) el = el.parentNode;
393 if (!el || 'A' != el.nodeName) return;
394
395 // ensure non-hash for the same path
3960 var link = el.getAttribute('href');
397 if (el.pathname == location.pathname && (el.hash || '#' == link)) return;
398
399 // Check for mailto: in the href
400 if (link && link.indexOf("mailto:") > -1) return;
401
402 // check target
403 if (el.target) return;
404
405 // x-origin
406 if (!sameOrigin(el.href)) return;
407
408 // rebuild path
4090 var path = el.pathname + el.search + (el.hash || '');
410
411 // same page
4120 var orig = path + el.hash;
413
4140 path = path.replace(base, '');
415 if (base && orig == path) return;
416
4170 e.preventDefault();
4180 page.show(orig);
419 }
420
421 /**
422 * Event button.
423 */
424
425 function which(e) {
4260 e = e || window.event;
4270 return null === e.which
428 ? e.button
429 : e.which;
430 }
431
432 /**
433 * Check if `href` is the same origin.
434 */
435
436 function sameOrigin(href) {
4370 var origin = location.protocol + '//' + location.hostname;
438 if (location.port) origin += ':' + location.port;
4390 return href && (0 === href.indexOf(origin));
440 }
441
4421 page.sameOrigin = sameOrigin;
443