1 | | |
2 | | /* jshint browser:true */ |
3 | | /* jshint -W079 */ // history.location |
4 | | /* globals require, module */ |
5 | | |
6 | | /** |
7 | | * Module dependencies. |
8 | | */ |
9 | | |
10 | 1 | var pathtoRegexp = require('path-to-regexp'); |
11 | | |
12 | | /** |
13 | | * Module exports. |
14 | | */ |
15 | | |
16 | 1 | 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 | | |
23 | 1 | var location = window.history.location || window.location; |
24 | | |
25 | | /** |
26 | | * Perform initial dispatch. |
27 | | */ |
28 | | |
29 | 1 | var dispatch = true; |
30 | | |
31 | | /** |
32 | | * Base path. |
33 | | */ |
34 | | |
35 | 1 | var base = ''; |
36 | | |
37 | | /** |
38 | | * Running flag. |
39 | | */ |
40 | | |
41 | 1 | var running; |
42 | | |
43 | | /** |
44 | | * HashBang option |
45 | | */ |
46 | | |
47 | 1 | 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) { |
68 | 0 | return page('*', path); |
69 | | } |
70 | | |
71 | | // route <path> to <callback ...> |
72 | | if ('function' == typeof fn) { |
73 | 12 | var route = new Route(path); |
74 | | for (var i = 1; i < arguments.length; ++i) { |
75 | 12 | page.callbacks.push(route.middleware(arguments[i])); |
76 | | } |
77 | | // show <path> with [state] |
78 | | } else if ('string' == typeof path) { |
79 | 10 | page.show(path, fn); |
80 | | // start [options] |
81 | | } else { |
82 | 1 | page.start(path); |
83 | | } |
84 | | } |
85 | | |
86 | | /** |
87 | | * Callback functions. |
88 | | */ |
89 | | |
90 | 1 | page.callbacks = []; |
91 | | |
92 | | /** |
93 | | * Get or set basepath to `path`. |
94 | | * |
95 | | * @param {String} path |
96 | | * @api public |
97 | | */ |
98 | | |
99 | 1 | page.base = function(path){ |
100 | | if (0 === arguments.length) return base; |
101 | 0 | 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 | | |
117 | 1 | page.start = function(options){ |
118 | 1 | options = options || {}; |
119 | | if (running) return; |
120 | 1 | 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; |
126 | 1 | var url = (hashbang && location.hash.indexOf('#!') === 0) |
127 | | ? location.hash.substr(2) + location.search |
128 | | : location.pathname + location.search + location.hash; |
129 | 1 | page.replace(url, null, true, dispatch); |
130 | | }; |
131 | | |
132 | | /** |
133 | | * Unbind click and popstate event handlers. |
134 | | * |
135 | | * @api public |
136 | | */ |
137 | | |
138 | 1 | page.stop = function(){ |
139 | 0 | running = false; |
140 | 0 | removeEventListener('click', onclick, false); |
141 | 0 | 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 | | |
154 | 1 | page.show = function(path, state, dispatch){ |
155 | 10 | var ctx = new Context(path, state); |
156 | | if (!ctx.unhandled) ctx.pushState(); |
157 | | if (false !== dispatch) page.dispatch(ctx); |
158 | 10 | 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 | | |
170 | 1 | page.replace = function(path, state, init, dispatch){ |
171 | 1 | var ctx = new Context(path, state); |
172 | 1 | ctx.init = init; |
173 | | if (false !== dispatch) page.dispatch(ctx); |
174 | 1 | ctx.save(); |
175 | 1 | return ctx; |
176 | | }; |
177 | | |
178 | | /** |
179 | | * Dispatch the given `ctx`. |
180 | | * |
181 | | * @param {Object} ctx |
182 | | * @api private |
183 | | */ |
184 | | |
185 | 1 | page.dispatch = function(ctx){ |
186 | 11 | var i = 0; |
187 | | |
188 | | function next() { |
189 | 58 | var fn = page.callbacks[i++]; |
190 | | if (!fn) return unhandled(ctx); |
191 | 58 | fn(ctx, next); |
192 | | } |
193 | | |
194 | 11 | 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) { |
207 | 0 | var current = location.pathname + location.search; |
208 | | if (current == ctx.canonicalPath) return; |
209 | 0 | page.stop(); |
210 | 0 | ctx.unhandled = true; |
211 | 0 | 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; |
225 | 11 | var i = path.indexOf('?'); |
226 | | |
227 | 11 | this.canonicalPath = path; |
228 | 11 | this.path = path.replace(base, '') || '/'; |
229 | | |
230 | 11 | this.title = document.title; |
231 | 11 | this.state = state || {}; |
232 | 11 | this.state.path = path; |
233 | 11 | this.querystring = ~i |
234 | | ? path.slice(i + 1) |
235 | | : ''; |
236 | 11 | this.pathname = ~i |
237 | | ? path.slice(0, i) |
238 | | : path; |
239 | 11 | this.params = []; |
240 | | |
241 | | // fragment |
242 | 11 | this.hash = ''; |
243 | | if (!~this.path.indexOf('#')) return; |
244 | 0 | var parts = this.path.split('#'); |
245 | 0 | this.path = parts[0]; |
246 | 0 | this.hash = parts[1] || ''; |
247 | 0 | this.querystring = this.querystring.split('#')[0]; |
248 | | } |
249 | | |
250 | | /** |
251 | | * Expose `Context`. |
252 | | */ |
253 | | |
254 | 1 | page.Context = Context; |
255 | | |
256 | | /** |
257 | | * Push state. |
258 | | * |
259 | | * @api private |
260 | | */ |
261 | | |
262 | 1 | Context.prototype.pushState = function(){ |
263 | 10 | 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 | | |
276 | 1 | Context.prototype.save = function(){ |
277 | 1 | 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) { |
299 | 12 | options = options || {}; |
300 | 12 | this.path = (path === '*') ? '(.*)' : path; |
301 | 12 | this.method = 'GET'; |
302 | 12 | this.regexp = pathtoRegexp(this.path, |
303 | | this.keys = [], |
304 | | options.sensitive, |
305 | | options.strict); |
306 | | } |
307 | | |
308 | | /** |
309 | | * Expose `Route`. |
310 | | */ |
311 | | |
312 | 1 | 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 | | |
323 | 1 | Route.prototype.middleware = function(fn){ |
324 | 12 | var self = this; |
325 | 12 | return function(ctx, next){ |
326 | | if (self.match(ctx.path, ctx.params)) return fn(ctx, next); |
327 | 47 | 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 | | |
341 | 1 | Route.prototype.match = function(path, params){ |
342 | 58 | 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) { |
352 | 5 | var key = keys[i - 1]; |
353 | | |
354 | 5 | var val = 'string' == typeof m[i] |
355 | | ? decodeURIComponent(m[i]) |
356 | | : m[i]; |
357 | | |
358 | | if (key) { |
359 | 5 | params[key.name] = undefined !== params[key.name] |
360 | | ? params[key.name] |
361 | | : val; |
362 | | } else { |
363 | 0 | params.push(val); |
364 | | } |
365 | | } |
366 | | |
367 | 11 | return true; |
368 | | }; |
369 | | |
370 | | /** |
371 | | * Handle "populate" events. |
372 | | */ |
373 | | |
374 | | function onpopstate(e) { |
375 | | if (e.state) { |
376 | 0 | var path = e.state.path; |
377 | 0 | 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 |
391 | 0 | 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 |
396 | 0 | 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 |
409 | 0 | var path = el.pathname + el.search + (el.hash || ''); |
410 | | |
411 | | // same page |
412 | 0 | var orig = path + el.hash; |
413 | | |
414 | 0 | path = path.replace(base, ''); |
415 | | if (base && orig == path) return; |
416 | | |
417 | 0 | e.preventDefault(); |
418 | 0 | page.show(orig); |
419 | | } |
420 | | |
421 | | /** |
422 | | * Event button. |
423 | | */ |
424 | | |
425 | | function which(e) { |
426 | 0 | e = e || window.event; |
427 | 0 | 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) { |
437 | 0 | var origin = location.protocol + '//' + location.hostname; |
438 | | if (location.port) origin += ':' + location.port; |
439 | 0 | return href && (0 === href.indexOf(origin)); |
440 | | } |
441 | | |
442 | 1 | page.sameOrigin = sameOrigin; |
443 | | |