724 lines
24 KiB
JavaScript
724 lines
24 KiB
JavaScript
/** @license
|
|
* crossroads <http://millermedeiros.github.com/crossroads.js/>
|
|
* Author: Miller Medeiros | MIT License
|
|
* v0.12.2 (2015/07/31 18:37)
|
|
*/
|
|
|
|
(function () {
|
|
var factory = function (signals) {
|
|
|
|
var crossroads,
|
|
_hasOptionalGroupBug,
|
|
UNDEF;
|
|
|
|
// Helpers -----------
|
|
//====================
|
|
|
|
// IE 7-8 capture optional groups as empty strings while other browsers
|
|
// capture as `undefined`
|
|
_hasOptionalGroupBug = (/t(.+)?/).exec('t')[1] === '';
|
|
|
|
function arrayIndexOf(arr, val) {
|
|
if (arr.indexOf) {
|
|
return arr.indexOf(val);
|
|
} else {
|
|
//Array.indexOf doesn't work on IE 6-7
|
|
var n = arr.length;
|
|
while (n--) {
|
|
if (arr[n] === val) {
|
|
return n;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
function arrayRemove(arr, item) {
|
|
var i = arrayIndexOf(arr, item);
|
|
if (i !== -1) {
|
|
arr.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
function isKind(val, kind) {
|
|
return '[object '+ kind +']' === Object.prototype.toString.call(val);
|
|
}
|
|
|
|
function isRegExp(val) {
|
|
return isKind(val, 'RegExp');
|
|
}
|
|
|
|
function isArray(val) {
|
|
return isKind(val, 'Array');
|
|
}
|
|
|
|
function isFunction(val) {
|
|
return typeof val === 'function';
|
|
}
|
|
|
|
//borrowed from AMD-utils
|
|
function typecastValue(val) {
|
|
var r;
|
|
if (val === null || val === 'null') {
|
|
r = null;
|
|
} else if (val === 'true') {
|
|
r = true;
|
|
} else if (val === 'false') {
|
|
r = false;
|
|
} else if (val === UNDEF || val === 'undefined') {
|
|
r = UNDEF;
|
|
} else if (val === '' || isNaN(val)) {
|
|
r = val;
|
|
} else {
|
|
r = parseFloat(val);
|
|
}
|
|
return r;
|
|
}
|
|
|
|
function typecastArrayValues(values) {
|
|
var n = values.length,
|
|
result = [];
|
|
while (n--) {
|
|
result[n] = typecastValue(values[n]);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// borrowed from MOUT
|
|
function decodeQueryString(queryStr, shouldTypecast) {
|
|
var queryArr = (queryStr || '').replace('?', '').split('&'),
|
|
reg = /([^=]+)=(.+)/,
|
|
i = -1,
|
|
obj = {},
|
|
equalIndex, cur, pValue, pName;
|
|
|
|
while ((cur = queryArr[++i])) {
|
|
equalIndex = cur.indexOf('=');
|
|
pName = cur.substring(0, equalIndex);
|
|
pValue = decodeURIComponent(cur.substring(equalIndex + 1));
|
|
if (shouldTypecast !== false) {
|
|
pValue = typecastValue(pValue);
|
|
}
|
|
if (pName in obj){
|
|
if(isArray(obj[pName])){
|
|
obj[pName].push(pValue);
|
|
} else {
|
|
obj[pName] = [obj[pName], pValue];
|
|
}
|
|
} else {
|
|
obj[pName] = pValue;
|
|
}
|
|
}
|
|
return obj;
|
|
}
|
|
|
|
|
|
// Crossroads --------
|
|
//====================
|
|
|
|
/**
|
|
* @constructor
|
|
*/
|
|
function Crossroads() {
|
|
this.bypassed = new signals.Signal();
|
|
this.routed = new signals.Signal();
|
|
this._routes = [];
|
|
this._prevRoutes = [];
|
|
this._piped = [];
|
|
this.resetState();
|
|
}
|
|
|
|
Crossroads.prototype = {
|
|
|
|
greedy : false,
|
|
|
|
greedyEnabled : true,
|
|
|
|
ignoreCase : true,
|
|
|
|
ignoreState : false,
|
|
|
|
shouldTypecast : false,
|
|
|
|
normalizeFn : null,
|
|
|
|
resetState : function(){
|
|
this._prevRoutes.length = 0;
|
|
this._prevMatchedRequest = null;
|
|
this._prevBypassedRequest = null;
|
|
},
|
|
|
|
create : function () {
|
|
return new Crossroads();
|
|
},
|
|
|
|
addRoute : function (pattern, callback, priority) {
|
|
var route = new Route(pattern, callback, priority, this);
|
|
this._sortedInsert(route);
|
|
return route;
|
|
},
|
|
|
|
removeRoute : function (route) {
|
|
arrayRemove(this._routes, route);
|
|
route._destroy();
|
|
},
|
|
|
|
removeAllRoutes : function () {
|
|
var n = this.getNumRoutes();
|
|
while (n--) {
|
|
this._routes[n]._destroy();
|
|
}
|
|
this._routes.length = 0;
|
|
},
|
|
|
|
parse : function (request, defaultArgs) {
|
|
request = request || '';
|
|
defaultArgs = defaultArgs || [];
|
|
|
|
// should only care about different requests if ignoreState isn't true
|
|
if ( !this.ignoreState &&
|
|
(request === this._prevMatchedRequest ||
|
|
request === this._prevBypassedRequest) ) {
|
|
return;
|
|
}
|
|
|
|
var routes = this._getMatchedRoutes(request),
|
|
i = 0,
|
|
n = routes.length,
|
|
cur;
|
|
|
|
if (n) {
|
|
this._prevMatchedRequest = request;
|
|
|
|
this._notifyPrevRoutes(routes, request);
|
|
this._prevRoutes = routes;
|
|
//should be incremental loop, execute routes in order
|
|
while (i < n) {
|
|
cur = routes[i];
|
|
cur.route.matched.dispatch.apply(cur.route.matched, defaultArgs.concat(cur.params));
|
|
cur.isFirst = !i;
|
|
this.routed.dispatch.apply(this.routed, defaultArgs.concat([request, cur]));
|
|
i += 1;
|
|
}
|
|
} else {
|
|
this._prevBypassedRequest = request;
|
|
this.bypassed.dispatch.apply(this.bypassed, defaultArgs.concat([request]));
|
|
}
|
|
|
|
this._pipeParse(request, defaultArgs);
|
|
},
|
|
|
|
_notifyPrevRoutes : function(matchedRoutes, request) {
|
|
var i = 0, prev;
|
|
while (prev = this._prevRoutes[i++]) {
|
|
//check if switched exist since route may be disposed
|
|
if(prev.route.switched && this._didSwitch(prev.route, matchedRoutes)) {
|
|
prev.route.switched.dispatch(request);
|
|
}
|
|
}
|
|
},
|
|
|
|
_didSwitch : function (route, matchedRoutes){
|
|
var matched,
|
|
i = 0;
|
|
while (matched = matchedRoutes[i++]) {
|
|
// only dispatch switched if it is going to a different route
|
|
if (matched.route === route) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
},
|
|
|
|
_pipeParse : function(request, defaultArgs) {
|
|
var i = 0, route;
|
|
while (route = this._piped[i++]) {
|
|
route.parse(request, defaultArgs);
|
|
}
|
|
},
|
|
|
|
getNumRoutes : function () {
|
|
return this._routes.length;
|
|
},
|
|
|
|
_sortedInsert : function (route) {
|
|
//simplified insertion sort
|
|
var routes = this._routes,
|
|
n = routes.length;
|
|
do { --n; } while (routes[n] && route._priority <= routes[n]._priority);
|
|
routes.splice(n+1, 0, route);
|
|
},
|
|
|
|
_getMatchedRoutes : function (request) {
|
|
var res = [],
|
|
routes = this._routes,
|
|
n = routes.length,
|
|
route;
|
|
//should be decrement loop since higher priorities are added at the end of array
|
|
while (route = routes[--n]) {
|
|
if ((!res.length || this.greedy || route.greedy) && route.match(request)) {
|
|
res.push({
|
|
route : route,
|
|
params : route._getParamsArray(request)
|
|
});
|
|
}
|
|
if (!this.greedyEnabled && res.length) {
|
|
break;
|
|
}
|
|
}
|
|
return res;
|
|
},
|
|
|
|
pipe : function (otherRouter) {
|
|
this._piped.push(otherRouter);
|
|
},
|
|
|
|
unpipe : function (otherRouter) {
|
|
arrayRemove(this._piped, otherRouter);
|
|
},
|
|
|
|
toString : function () {
|
|
return '[crossroads numRoutes:'+ this.getNumRoutes() +']';
|
|
}
|
|
};
|
|
|
|
//"static" instance
|
|
crossroads = new Crossroads();
|
|
crossroads.VERSION = '0.12.2';
|
|
|
|
crossroads.NORM_AS_ARRAY = function (req, vals) {
|
|
return [vals.vals_];
|
|
};
|
|
|
|
crossroads.NORM_AS_OBJECT = function (req, vals) {
|
|
return [vals];
|
|
};
|
|
|
|
|
|
// Route --------------
|
|
//=====================
|
|
|
|
/**
|
|
* @constructor
|
|
*/
|
|
function Route(pattern, callback, priority, router) {
|
|
var isRegexPattern = isRegExp(pattern),
|
|
patternLexer = router.patternLexer;
|
|
this._router = router;
|
|
this._pattern = pattern;
|
|
this._paramsIds = isRegexPattern? null : patternLexer.getParamIds(pattern);
|
|
this._optionalParamsIds = isRegexPattern? null : patternLexer.getOptionalParamsIds(pattern);
|
|
this._matchRegexp = isRegexPattern? pattern : patternLexer.compilePattern(pattern, router.ignoreCase);
|
|
this.matched = new signals.Signal();
|
|
this.switched = new signals.Signal();
|
|
if (callback) {
|
|
this.matched.add(callback);
|
|
}
|
|
this._priority = priority || 0;
|
|
}
|
|
|
|
Route.prototype = {
|
|
|
|
greedy : false,
|
|
|
|
rules : void(0),
|
|
|
|
match : function (request) {
|
|
request = request || '';
|
|
return this._matchRegexp.test(request) && this._validateParams(request); //validate params even if regexp because of `request_` rule.
|
|
},
|
|
|
|
_validateParams : function (request) {
|
|
var rules = this.rules,
|
|
values = this._getParamsObject(request),
|
|
key;
|
|
for (key in rules) {
|
|
// normalize_ isn't a validation rule... (#39)
|
|
if(key !== 'normalize_' && rules.hasOwnProperty(key) && ! this._isValidParam(request, key, values)){
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
},
|
|
|
|
_isValidParam : function (request, prop, values) {
|
|
var validationRule = this.rules[prop],
|
|
val = values[prop],
|
|
isValid = false,
|
|
isQuery = (prop.indexOf('?') === 0);
|
|
|
|
if (val == null && this._optionalParamsIds && arrayIndexOf(this._optionalParamsIds, prop) !== -1) {
|
|
isValid = true;
|
|
}
|
|
else if (isRegExp(validationRule)) {
|
|
if (isQuery) {
|
|
val = values[prop +'_']; //use raw string
|
|
}
|
|
isValid = validationRule.test(val);
|
|
}
|
|
else if (isArray(validationRule)) {
|
|
if (isQuery) {
|
|
val = values[prop +'_']; //use raw string
|
|
}
|
|
isValid = this._isValidArrayRule(validationRule, val);
|
|
}
|
|
else if (isFunction(validationRule)) {
|
|
isValid = validationRule(val, request, values);
|
|
}
|
|
|
|
return isValid; //fail silently if validationRule is from an unsupported type
|
|
},
|
|
|
|
_isValidArrayRule : function (arr, val) {
|
|
if (! this._router.ignoreCase) {
|
|
return arrayIndexOf(arr, val) !== -1;
|
|
}
|
|
|
|
if (typeof val === 'string') {
|
|
val = val.toLowerCase();
|
|
}
|
|
|
|
var n = arr.length,
|
|
item,
|
|
compareVal;
|
|
|
|
while (n--) {
|
|
item = arr[n];
|
|
compareVal = (typeof item === 'string')? item.toLowerCase() : item;
|
|
if (compareVal === val) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
|
|
_getParamsObject : function (request) {
|
|
var shouldTypecast = this._router.shouldTypecast,
|
|
values = this._router.patternLexer.getParamValues(request, this._matchRegexp, shouldTypecast),
|
|
o = {},
|
|
n = values.length,
|
|
param, val;
|
|
while (n--) {
|
|
val = values[n];
|
|
if (this._paramsIds) {
|
|
param = this._paramsIds[n];
|
|
if (param.indexOf('?') === 0 && val) {
|
|
//make a copy of the original string so array and
|
|
//RegExp validation can be applied properly
|
|
o[param +'_'] = val;
|
|
//update vals_ array as well since it will be used
|
|
//during dispatch
|
|
val = decodeQueryString(val, shouldTypecast);
|
|
values[n] = val;
|
|
}
|
|
// IE will capture optional groups as empty strings while other
|
|
// browsers will capture `undefined` so normalize behavior.
|
|
// see: #gh-58, #gh-59, #gh-60
|
|
if ( _hasOptionalGroupBug && val === '' && arrayIndexOf(this._optionalParamsIds, param) !== -1 ) {
|
|
val = void(0);
|
|
values[n] = val;
|
|
}
|
|
o[param] = val;
|
|
}
|
|
//alias to paths and for RegExp pattern
|
|
o[n] = val;
|
|
}
|
|
o.request_ = shouldTypecast? typecastValue(request) : request;
|
|
o.vals_ = values;
|
|
return o;
|
|
},
|
|
|
|
_getParamsArray : function (request) {
|
|
var norm = this.rules? this.rules.normalize_ : null,
|
|
params;
|
|
norm = norm || this._router.normalizeFn; // default normalize
|
|
if (norm && isFunction(norm)) {
|
|
params = norm(request, this._getParamsObject(request));
|
|
} else {
|
|
params = this._getParamsObject(request).vals_;
|
|
}
|
|
return params;
|
|
},
|
|
|
|
interpolate : function(replacements) {
|
|
var str = this._router.patternLexer.interpolate(this._pattern, replacements);
|
|
if (! this._validateParams(str) ) {
|
|
throw new Error('Generated string doesn\'t validate against `Route.rules`.');
|
|
}
|
|
return str;
|
|
},
|
|
|
|
dispose : function () {
|
|
this._router.removeRoute(this);
|
|
},
|
|
|
|
_destroy : function () {
|
|
this.matched.dispose();
|
|
this.switched.dispose();
|
|
this.matched = this.switched = this._pattern = this._matchRegexp = null;
|
|
},
|
|
|
|
toString : function () {
|
|
return '[Route pattern:"'+ this._pattern +'", numListeners:'+ this.matched.getNumListeners() +']';
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
// Pattern Lexer ------
|
|
//=====================
|
|
|
|
Crossroads.prototype.patternLexer = (function () {
|
|
|
|
var
|
|
//match chars that should be escaped on string regexp
|
|
ESCAPE_CHARS_REGEXP = /[\\.+*?\^$\[\](){}\/'#]/g,
|
|
|
|
//trailing slashes (begin/end of string)
|
|
LOOSE_SLASHES_REGEXP = /^\/|\/$/g,
|
|
LEGACY_SLASHES_REGEXP = /\/$/g,
|
|
|
|
//params - everything between `{ }` or `: :`
|
|
PARAMS_REGEXP = /(?:\{|:)([^}:]+)(?:\}|:)/g,
|
|
|
|
//used to save params during compile (avoid escaping things that
|
|
//shouldn't be escaped).
|
|
TOKENS = {
|
|
'OS' : {
|
|
//optional slashes
|
|
//slash between `::` or `}:` or `\w:` or `:{?` or `}{?` or `\w{?`
|
|
rgx : /([:}]|\w(?=\/))\/?(:|(?:\{\?))/g,
|
|
save : '$1{{id}}$2',
|
|
res : '\\/?'
|
|
},
|
|
'RS' : {
|
|
//required slashes
|
|
//used to insert slash between `:{` and `}{`
|
|
rgx : /([:}])\/?(\{)/g,
|
|
save : '$1{{id}}$2',
|
|
res : '\\/'
|
|
},
|
|
'RQ' : {
|
|
//required query string - everything in between `{? }`
|
|
rgx : /\{\?([^}]+)\}/g,
|
|
//everything from `?` till `#` or end of string
|
|
res : '\\?([^#]+)'
|
|
},
|
|
'OQ' : {
|
|
//optional query string - everything in between `:? :`
|
|
rgx : /:\?([^:]+):/g,
|
|
//everything from `?` till `#` or end of string
|
|
res : '(?:\\?([^#]*))?'
|
|
},
|
|
'OR' : {
|
|
//optional rest - everything in between `: *:`
|
|
rgx : /:([^:]+)\*:/g,
|
|
res : '(.*)?' // optional group to avoid passing empty string as captured
|
|
},
|
|
'RR' : {
|
|
//rest param - everything in between `{ *}`
|
|
rgx : /\{([^}]+)\*\}/g,
|
|
res : '(.+)'
|
|
},
|
|
// required/optional params should come after rest segments
|
|
'RP' : {
|
|
//required params - everything between `{ }`
|
|
rgx : /\{([^}]+)\}/g,
|
|
res : '([^\\/?]+)'
|
|
},
|
|
'OP' : {
|
|
//optional params - everything between `: :`
|
|
rgx : /:([^:]+):/g,
|
|
res : '([^\\/?]+)?\/?'
|
|
}
|
|
},
|
|
|
|
LOOSE_SLASH = 1,
|
|
STRICT_SLASH = 2,
|
|
LEGACY_SLASH = 3,
|
|
|
|
_slashMode = LOOSE_SLASH;
|
|
|
|
|
|
function precompileTokens(){
|
|
var key, cur;
|
|
for (key in TOKENS) {
|
|
if (TOKENS.hasOwnProperty(key)) {
|
|
cur = TOKENS[key];
|
|
cur.id = '__CR_'+ key +'__';
|
|
cur.save = ('save' in cur)? cur.save.replace('{{id}}', cur.id) : cur.id;
|
|
cur.rRestore = new RegExp(cur.id, 'g');
|
|
}
|
|
}
|
|
}
|
|
precompileTokens();
|
|
|
|
|
|
function captureVals(regex, pattern) {
|
|
var vals = [], match;
|
|
// very important to reset lastIndex since RegExp can have "g" flag
|
|
// and multiple runs might affect the result, specially if matching
|
|
// same string multiple times on IE 7-8
|
|
regex.lastIndex = 0;
|
|
while (match = regex.exec(pattern)) {
|
|
vals.push(match[1]);
|
|
}
|
|
return vals;
|
|
}
|
|
|
|
function getParamIds(pattern) {
|
|
return captureVals(PARAMS_REGEXP, pattern);
|
|
}
|
|
|
|
function getOptionalParamsIds(pattern) {
|
|
return captureVals(TOKENS.OP.rgx, pattern);
|
|
}
|
|
|
|
function compilePattern(pattern, ignoreCase) {
|
|
pattern = pattern || '';
|
|
|
|
if(pattern){
|
|
if (_slashMode === LOOSE_SLASH) {
|
|
pattern = pattern.replace(LOOSE_SLASHES_REGEXP, '');
|
|
}
|
|
else if (_slashMode === LEGACY_SLASH) {
|
|
pattern = pattern.replace(LEGACY_SLASHES_REGEXP, '');
|
|
}
|
|
|
|
//save tokens
|
|
pattern = replaceTokens(pattern, 'rgx', 'save');
|
|
//regexp escape
|
|
pattern = pattern.replace(ESCAPE_CHARS_REGEXP, '\\$&');
|
|
//restore tokens
|
|
pattern = replaceTokens(pattern, 'rRestore', 'res');
|
|
|
|
if (_slashMode === LOOSE_SLASH) {
|
|
pattern = '\\/?'+ pattern;
|
|
}
|
|
}
|
|
|
|
if (_slashMode !== STRICT_SLASH) {
|
|
//single slash is treated as empty and end slash is optional
|
|
pattern += '\\/?';
|
|
}
|
|
return new RegExp('^'+ pattern + '$', ignoreCase? 'i' : '');
|
|
}
|
|
|
|
function replaceTokens(pattern, regexpName, replaceName) {
|
|
var cur, key;
|
|
for (key in TOKENS) {
|
|
if (TOKENS.hasOwnProperty(key)) {
|
|
cur = TOKENS[key];
|
|
pattern = pattern.replace(cur[regexpName], cur[replaceName]);
|
|
}
|
|
}
|
|
return pattern;
|
|
}
|
|
|
|
function getParamValues(request, regexp, shouldTypecast) {
|
|
var vals = regexp.exec(request);
|
|
if (vals) {
|
|
vals.shift();
|
|
if (shouldTypecast) {
|
|
vals = typecastArrayValues(vals);
|
|
}
|
|
}
|
|
return vals;
|
|
}
|
|
|
|
function interpolate(pattern, replacements) {
|
|
// default to an empty object because pattern might have just
|
|
// optional arguments
|
|
replacements = replacements || {};
|
|
if (typeof pattern !== 'string') {
|
|
throw new Error('Route pattern should be a string.');
|
|
}
|
|
|
|
var replaceFn = function(match, prop){
|
|
var val;
|
|
prop = (prop.substr(0, 1) === '?')? prop.substr(1) : prop;
|
|
if (replacements[prop] != null) {
|
|
if (typeof replacements[prop] === 'object') {
|
|
var queryParts = [], rep;
|
|
for(var key in replacements[prop]) {
|
|
rep = replacements[prop][key];
|
|
if (isArray(rep)) {
|
|
for (var k in rep) {
|
|
if ( key.slice(-2) == '[]' ) {
|
|
queryParts.push(encodeURI(key.slice(0, -2)) + '[]=' + encodeURI(rep[k]));
|
|
} else {
|
|
queryParts.push(encodeURI(key + '=' + rep[k]));
|
|
}
|
|
}
|
|
}
|
|
else {
|
|
queryParts.push(encodeURI(key + '=' + rep));
|
|
}
|
|
}
|
|
val = '?' + queryParts.join('&');
|
|
} else {
|
|
// make sure value is a string see #gh-54
|
|
val = String(replacements[prop]);
|
|
}
|
|
|
|
if (match.indexOf('*') === -1 && val.indexOf('/') !== -1) {
|
|
throw new Error('Invalid value "'+ val +'" for segment "'+ match +'".');
|
|
}
|
|
}
|
|
else if (match.indexOf('{') !== -1) {
|
|
throw new Error('The segment '+ match +' is required.');
|
|
}
|
|
else {
|
|
val = '';
|
|
}
|
|
return val;
|
|
};
|
|
|
|
if (! TOKENS.OS.trail) {
|
|
TOKENS.OS.trail = new RegExp('(?:'+ TOKENS.OS.id +')+$');
|
|
}
|
|
|
|
return pattern
|
|
.replace(TOKENS.OS.rgx, TOKENS.OS.save)
|
|
.replace(PARAMS_REGEXP, replaceFn)
|
|
.replace(TOKENS.OS.trail, '') // remove trailing
|
|
.replace(TOKENS.OS.rRestore, '/'); // add slash between segments
|
|
}
|
|
|
|
//API
|
|
return {
|
|
strict : function(){
|
|
_slashMode = STRICT_SLASH;
|
|
},
|
|
loose : function(){
|
|
_slashMode = LOOSE_SLASH;
|
|
},
|
|
legacy : function(){
|
|
_slashMode = LEGACY_SLASH;
|
|
},
|
|
getParamIds : getParamIds,
|
|
getOptionalParamsIds : getOptionalParamsIds,
|
|
getParamValues : getParamValues,
|
|
compilePattern : compilePattern,
|
|
interpolate : interpolate
|
|
};
|
|
|
|
}());
|
|
|
|
|
|
return crossroads;
|
|
};
|
|
|
|
if (typeof define === 'function' && define.amd) {
|
|
define(['signals'], factory);
|
|
} else if (typeof module !== 'undefined' && module.exports) { //Node
|
|
module.exports = factory(require('signals'));
|
|
} else {
|
|
/*jshint sub:true */
|
|
window['crossroads'] = factory(window['signals']);
|
|
}
|
|
|
|
}());
|
|
|