import Persist from './stores/Persist'; let standalone = undefined; /** * Determine if the app is running in mobile/tablet 'standalone' mode * @return {Boolean} True if the app is in standalone mode */ function isStandAlone() { if (standalone === undefined) { try { standalone = window.navigator.standalone || (window.external && window.external.msIsSiteMode && window.external.msIsSiteMode()); } catch (ex) { standalone = false; } } return standalone; } /** * Register path with callback fn(), or route path`, or Router.start(). * * Router('*', fn); * Router('/user/:id', load, user); * Router('/user/' + user.id, { some: 'thing' }); * Router('/user/' + user.id); * Router(); * * @param {String} path path * @param {Function} fn Callbacks (fn, fn, ...) * @api public */ function Router(path, fn) { let route = new Route(path); for (let i = 1; i < arguments.length; ++i) { Router.callbacks.push(route.middleware(arguments[i])); } } /** * Callback functions. */ Router.callbacks = []; Router.start = function() { window.addEventListener('popstate', onpopstate, false); if (isStandAlone()) { let state = Persist.getState(); // If a previous state has been stored, load that state if (state && state.path) { Router.replace(state.path, null, true); } else { Router.replace('/', null, true); } } else { let url = location.pathname + location.search; Router.replace(url, null, true); } }; /** * Show `path` with optional `state` object. * * @param {String} path Path * @param {Object} state Additional state * @return {Context} New Context * @api public */ Router.go = function(path, state) { gaTrack(path); let ctx = new Context(path, state); Router.dispatch(ctx); if (!ctx.unhandled) { if (isStandAlone()) { Persist.setState(ctx); } try { history.pushState(ctx.state, ctx.title, ctx.canonicalPath); } catch (ex) { sessionStorage.setItem('__safari_history_fix', JSON.stringify({ state: ctx.state, title: ctx.title, path: ctx.canonicalPath })); location.reload(); } } return ctx; }; /** * Replace `path` with optional `state` object. * * @param {String} path path * @param {Object} state State * @param {Boolean} dispatch If true dispatch the route / trigger update * @return {Context} New Context * @api public */ Router.replace = function(path, state, dispatch) { gaTrack(path); let ctx = new Context(path, state); if (dispatch) { Router.dispatch(ctx); } if (isStandAlone()) { Persist.setState(ctx); } try { history.replaceState(ctx.state, ctx.title, ctx.canonicalPath); } catch (ex) { sessionStorage.setItem('__safari_history_fix', JSON.stringify({ state: ctx.state, title: ctx.title, path: ctx.canonicalPath })); location.reload(); } return ctx; }; /** * Dispatch the given `ctx`. * * @param {Context} ctx Context * @api private */ Router.dispatch = function(ctx) { let i = 0; /** * Handle the next route * @return {Function} Unhandled */ function next() { let fn = Router.callbacks[i++]; if (!fn) return unhandled(ctx); fn(ctx, next); } next(); }; /** * Unhandled `ctx`. When it's not the initial * popstate then redirect. If you wish to handle * 404s on your own use `Router('*', callback)`. * * @param {Context} ctx Context * @return {Context} context * @api private */ function unhandled(ctx) { let current = window.location.pathname + window.location.search; if (current != ctx.canonicalPath) { window.location = ctx.canonicalPath; } return ctx; } /** * Initialize a new "request" `Context` * with the given `path` and optional initial `state`. * * @param {String} path Path * @param {Object} state State * @api public */ function Context(path, state) { let i = path.indexOf('?'); this.canonicalPath = path; this.path = path || '/'; this.title = document.title; this.state = state || {}; this.state.path = path; this.querystring = ~i ? path.slice(i + 1) : ''; this.pathname = ~i ? path.slice(0, i) : path; this.params = {}; this.querystring.split('&').forEach((str) =>{ let query = str.split('='); this.params[query[0]] = decodeURIComponent(query[1]); }, this); } /** * Initialize `Route` with the given HTTP `path`, * and an array of `callbacks` and `options`. * * Options: * * - `sensitive` enable case-sensitive routes * - `strict` enable strict matching for trailing slashes * * @param {String} path Path * @param {Object} options Options * @api private */ function Route(path, options) { options = options || {}; this.path = path; this.method = 'GET'; this.regexp = pathtoRegexp(path, this.keys = [], options.sensitive, options.strict); } /** * Return route middleware with * the given callback `fn()`. * * @param {Function} fn Route function * @return {Function} Callback * @api public */ Route.prototype.middleware = function(fn) { let self = this; return function(ctx, next) { if (self.match(ctx.path, ctx.params)) return fn(ctx, next); next(); }; }; /** * Check if this route matches `path`, if so * populate `params`. * * @param {String} path Path * @param {Array} params Path params * @return {Boolean} True if path matches * @api private */ Route.prototype.match = function(path, params) { let keys = this.keys, qsIndex = path.indexOf('?'), pathname = ~qsIndex ? path.slice(0, qsIndex) : path, m = this.regexp.exec(decodeURIComponent(pathname)); if (!m) return false; for (let i = 1, len = m.length; i < len; ++i) { let key = keys[i - 1]; let val = 'string' == typeof m[i] ? decodeURIComponent(m[i]) : m[i]; if (key) { params[key.name] = undefined !== params[key.name] ? params[key.name] : val; } } return true; }; /** * Track a page view in Google Analytics * @param {string} path Path to track */ function gaTrack(path) { if (window.ga) { window.ga('send', 'pageview', path); } } /** * Normalize the given path string, * returning a regular expression. * * An empty array should be passed, * which will contain the placeholder * key names. For example "/user/:id" will * then contain ["id"]. * * @param {String|RegExp|Array} path Path template(s) * @param {Array} keys keys * @param {Boolean} sensitive Case sensitive * @param {Boolean} strict Strict matching * @return {RegExp} Regular expression * @api private */ function pathtoRegexp(path, keys, sensitive, strict) { if (path instanceof RegExp) return path; if (path instanceof Array) path = '(' + path.join('|') + ')'; path = path .concat(strict ? '' : '/?') .replace(/\/\(/g, '(?:/') .replace(/(\/)?(\.)?:(\w+)(?:(\(.*?\)))?(\?)?/g, function(_, slash, format, key, capture, optional) { keys.push({ name: key, optional: !! optional }); slash = slash || ''; return '' + (optional ? '' : slash) + '(?:' + (optional ? slash : '') + (format || '') + (capture || (format && '([^/.]+?)' || '([^/]+?)')) + ')' + (optional || ''); }) .replace(/([\/.])/g, '\\$1') .replace(/\*/g, '(.*)'); return new RegExp('^' + path + '$', sensitive ? '' : 'i'); } /** * Handle "populate" events. * @param {Event} e Event object */ function onpopstate(e) { if (e.state) { let path = e.state.path; Router.replace(path, e.state, true); } } export default Router;