var route = (function () { 'use strict'; var observable = function(el) { /** * Extend the original object or create a new empty one * @type { Object } */ el = el || {}; /** * Private variables */ var callbacks = {}, slice = Array.prototype.slice; /** * Public Api */ // extend the el object adding the observable methods Object.defineProperties(el, { /** * Listen to the given `event` ands * execute the `callback` each time an event is triggered. * @param { String } event - event id * @param { Function } fn - callback function * @returns { Object } el */ on: { value: function(event, fn) { if (typeof fn == 'function') { (callbacks[event] = callbacks[event] || []).push(fn); } return el }, enumerable: false, writable: false, configurable: false }, /** * Removes the given `event` listeners * @param { String } event - event id * @param { Function } fn - callback function * @returns { Object } el */ off: { value: function(event, fn) { if (event == '*' && !fn) { callbacks = {}; } else { if (fn) { var arr = callbacks[event]; for (var i = 0, cb; cb = arr && arr[i]; ++i) { if (cb == fn) { arr.splice(i--, 1); } } } else { delete callbacks[event]; } } return el }, enumerable: false, writable: false, configurable: false }, /** * Listen to the given `event` and * execute the `callback` at most once * @param { String } event - event id * @param { Function } fn - callback function * @returns { Object } el */ one: { value: function(event, fn) { function on() { el.off(event, on); fn.apply(el, arguments); } return el.on(event, on) }, enumerable: false, writable: false, configurable: false }, /** * Execute all callback functions that listen to * the given `event` * @param { String } event - event id * @returns { Object } el */ trigger: { value: function(event) { var arguments$1 = arguments; // getting the arguments var arglen = arguments.length - 1, args = new Array(arglen), fns, fn, i; for (i = 0; i < arglen; i++) { args[i] = arguments$1[i + 1]; // skip first argument } fns = slice.call(callbacks[event] || [], 0); for (i = 0; fn = fns[i]; ++i) { fn.apply(el, args); } if (callbacks['*'] && event != '*') { el.trigger.apply(el, ['*', event].concat(args)); } return el }, enumerable: false, writable: false, configurable: false } }); return el }; /** * Simple client-side router * @module riot-route */ var RE_ORIGIN = /^.+?\/\/+[^/]+/, EVENT_LISTENER = 'EventListener', REMOVE_EVENT_LISTENER = 'remove' + EVENT_LISTENER, ADD_EVENT_LISTENER = 'add' + EVENT_LISTENER, HAS_ATTRIBUTE = 'hasAttribute', POPSTATE = 'popstate', HASHCHANGE = 'hashchange', TRIGGER = 'trigger', MAX_EMIT_STACK_LEVEL = 3, win = typeof window != 'undefined' && window, doc = typeof document != 'undefined' && document, hist = win && history, loc = win && (hist.location || win.location), // see html5-history-api prot = Router.prototype, // to minify more clickEvent = doc && doc.ontouchstart ? 'touchstart' : 'click', central = observable(); var started = false, routeFound = false, debouncedEmit, current, parser, secondParser, emitStack = [], emitStackLevel = 0; /** * Default parser. You can replace it via router.parser method. * @param {string} path - current path (normalized) * @returns {array} array */ function DEFAULT_PARSER(path) { return path.split(/[/?#]/) } /** * Default parser (second). You can replace it via router.parser method. * @param {string} path - current path (normalized) * @param {string} filter - filter string (normalized) * @returns {array} array */ function DEFAULT_SECOND_PARSER(path, filter) { var f = filter .replace(/\?/g, '\\?') .replace(/\*/g, '([^/?#]+?)') .replace(/\.\./, '.*'); var re = new RegExp(("^" + f + "$")); var args = path.match(re); if (args) { return args.slice(1) } } /** * Simple/cheap debounce implementation * @param {function} fn - callback * @param {number} delay - delay in seconds * @returns {function} debounced function */ function debounce(fn, delay) { var t; return function () { clearTimeout(t); t = setTimeout(fn, delay); } } /** * Set the window listeners to trigger the routes * @param {boolean} autoExec - see route.start */ function start(autoExec) { debouncedEmit = debounce(emit, 1); win[ADD_EVENT_LISTENER](POPSTATE, debouncedEmit); win[ADD_EVENT_LISTENER](HASHCHANGE, debouncedEmit); doc[ADD_EVENT_LISTENER](clickEvent, click); if (autoExec) { emit(true); } } /** * Router class */ function Router() { this.$ = []; observable(this); // make it observable central.on('stop', this.s.bind(this)); central.on('emit', this.e.bind(this)); } function normalize(path) { return path.replace(/^\/|\/$/, '') } function isString(str) { return typeof str == 'string' } /** * Get the part after domain name * @param {string} href - fullpath * @returns {string} path from root */ function getPathFromRoot(href) { return (href || loc.href).replace(RE_ORIGIN, '') } /** * Get the part after base * @param {string} href - fullpath * @returns {string} path from base */ function getPathFromBase(href) { var base = route._.base; return base[0] === '#' ? (href || loc.href || '').split(base)[1] || '' : (loc ? getPathFromRoot(href) : href || '').replace(base, '') } function emit(force) { // the stack is needed for redirections var isRoot = emitStackLevel === 0; if (MAX_EMIT_STACK_LEVEL <= emitStackLevel) { return } emitStackLevel++; emitStack.push(function() { var path = getPathFromBase(); if (force || path !== current) { central[TRIGGER]('emit', path); current = path; } }); if (isRoot) { var first; while (first = emitStack.shift()) { first(); } // stack increses within this call emitStackLevel = 0; } } function click(e) { if ( e.which !== 1 // not left click || e.metaKey || e.ctrlKey || e.shiftKey // or meta keys || e.defaultPrevented // or default prevented ) { return } var el = e.target; while (el && el.nodeName !== 'A') { el = el.parentNode; } if ( !el || el.nodeName !== 'A' // not A tag || el[HAS_ATTRIBUTE]('download') // has download attr || !el[HAS_ATTRIBUTE]('href') // has no href attr || el.target && el.target !== '_self' // another window or frame || el.href.indexOf(loc.href.match(RE_ORIGIN)[0]) === -1 // cross origin ) { return } var base = route._.base; if (el.href !== loc.href && ( el.href.split('#')[0] === loc.href.split('#')[0] // internal jump || base[0] !== '#' && getPathFromRoot(el.href).indexOf(base) !== 0 // outside of base || base[0] === '#' && el.href.split(base)[0] !== loc.href.split(base)[0] // outside of #base || !go(getPathFromBase(el.href), el.title || doc.title) // route not found )) { return } e.preventDefault(); } /** * Go to the path * @param {string} path - destination path * @param {string} title - page title * @param {boolean} shouldReplace - use replaceState or pushState * @returns {boolean} - route not found flag */ function go(path, title, shouldReplace) { // Server-side usage: directly execute handlers for the path if (!hist) { return central[TRIGGER]('emit', getPathFromBase(path)) } path = route._.base + normalize(path); title = title || doc.title; // browsers ignores the second parameter `title` shouldReplace ? hist.replaceState(null, title, path) : hist.pushState(null, title, path); // so we need to set it manually doc.title = title; routeFound = false; emit(); return routeFound } /** * Go to path or set action * a single string: go there * two strings: go there with setting a title * two strings and boolean: replace history with setting a title * a single function: set an action on the default route * a string/RegExp and a function: set an action on the route * @param {(string|function)} first - path / action / filter * @param {(string|RegExp|function)} second - title / action * @param {boolean} third - replace flag */ prot.m = function(first, second, third) { if (isString(first) && (!second || isString(second))) { go(first, second, third || false); } else if (second) { this.r(first, second); } else { this.r('@', first); } }; /** * Stop routing */ prot.s = function() { this.off('*'); this.$ = []; }; /** * Emit * @param {string} path - path */ prot.e = function(path) { this.$.concat('@').some(function(filter) { var args = (filter === '@' ? parser : secondParser)(normalize(path), normalize(filter)); if (typeof args != 'undefined') { this[TRIGGER].apply(null, [filter].concat(args)); return routeFound = true // exit from loop } }, this); }; /** * Register route * @param {string} filter - filter for matching to url * @param {function} action - action to register */ prot.r = function(filter, action) { if (filter !== '@') { filter = '/' + normalize(filter); this.$.push(filter); } this.on(filter, action); }; var mainRouter = new Router(); var route = mainRouter.m.bind(mainRouter); // adding base and getPathFromBase to route so we can access them in route.tag's script route._ = { base: null, getPathFromBase: getPathFromBase }; /** * Create a sub router * @returns {function} the method of a new Router object */ route.create = function() { var newSubRouter = new Router(); // assign sub-router's main method var router = newSubRouter.m.bind(newSubRouter); // stop only this sub-router router.stop = newSubRouter.s.bind(newSubRouter); return router }; /** * Set the base of url * @param {(str|RegExp)} arg - a new base or '#' or '#!' */ route.base = function(arg) { route._.base = arg || '#'; current = getPathFromBase(); // recalculate current path }; /** Exec routing right now **/ route.exec = function() { emit(true); }; /** * Replace the default router to yours * @param {function} fn - your parser function * @param {function} fn2 - your secondParser function */ route.parser = function(fn, fn2) { if (!fn && !fn2) { // reset parser for testing... parser = DEFAULT_PARSER; secondParser = DEFAULT_SECOND_PARSER; } if (fn) { parser = fn; } if (fn2) { secondParser = fn2; } }; /** * Helper function to get url query as an object * @returns {object} parsed query */ route.query = function() { var q = {}; var href = loc.href || current; href.replace(/[?&](.+?)=([^&]*)/g, function(_, k, v) { q[k] = v; }); return q }; /** Stop routing **/ route.stop = function () { if (started) { if (win) { win[REMOVE_EVENT_LISTENER](POPSTATE, debouncedEmit); win[REMOVE_EVENT_LISTENER](HASHCHANGE, debouncedEmit); doc[REMOVE_EVENT_LISTENER](clickEvent, click); } central[TRIGGER]('stop'); started = false; } }; /** * Start routing * @param {boolean} autoExec - automatically exec after starting if true */ route.start = function (autoExec) { if (!started) { if (win) { if (document.readyState === 'interactive' || document.readyState === 'complete') { start(autoExec); } else { document.onreadystatechange = function () { if (document.readyState === 'interactive') { // the timeout is needed to solve // a weird safari bug https://github.com/riot/route/issues/33 setTimeout(function() { start(autoExec); }, 1); } }; } } started = true; } }; /** Prepare the router **/ route.base(); route.parser(); return route; }());