riot-starter/app/vendor/riot-route/route.js

487 lines
13 KiB
JavaScript
Raw Normal View History

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;
}());