can
Version:
MIT-licensed, client-side, JavaScript framework that makes building rich web applications easy.
692 lines (655 loc) • 24.1 kB
JavaScript
steal('can/util', 'can/map', 'can/list','can/util/string/deparam', function (can) {
// ## route.js
// `can.route`
// _Helps manage browser history (and client state) by synchronizing the
// `window.location.hash` with a `can.Map`._
//
// Helper methods used for matching routes.
var
// `RegExp` used to match route variables of the type ':name'.
// Any word character or a period is matched.
matcher = /\:([\w\.]+)/g,
// Regular expression for identifying &key=value lists.
paramsMatcher = /^(?:&[^=]+=[^&]*)+/,
// Converts a JS Object into a list of parameters that can be
// inserted into an html element tag.
makeProps = function (props) {
var tags = [];
can.each(props, function (val, name) {
tags.push((name === 'className' ? 'class' : name) + '="' +
(name === "href" ? val : can.esc(val)) + '"');
});
return tags.join(" ");
},
// Checks if a route matches the data provided. If any route variable
// is not present in the data, the route does not match. If all route
// variables are present in the data, the number of matches is returned
// to allow discerning between general and more specific routes.
matchesData = function (route, data) {
var count = 0,
i = 0,
defaults = {};
// look at default values, if they match ...
for (var name in route.defaults) {
if (route.defaults[name] === data[name]) {
// mark as matched
defaults[name] = 1;
count++;
}
}
for (; i < route.names.length; i++) {
if (!data.hasOwnProperty(route.names[i])) {
return -1;
}
if (!defaults[route.names[i]]) {
count++;
}
}
return count;
},
location = window.location,
wrapQuote = function (str) {
return (str + '')
.replace(/([.?*+\^$\[\]\\(){}|\-])/g, "\\$1");
},
each = can.each,
extend = can.extend,
definedToString = function (obj) {
return obj.toString.toString() !== Object.prototype.toString.toString();
},
// Helper for convert any object (or value) to stringified object (or value)
stringify = function (obj) {
// Object is array, plain object, Map or List
if (obj && typeof obj === "object" && !definedToString(obj)) {
if (obj instanceof can.Map) {
obj = obj;
} else {
// Get array from array-like or shallow-copy object
obj = can.isFunction(obj.slice) ? obj.slice() : can.extend({}, obj);
}
// Convert each object property or array item into stringified new
can.each(obj, function (val, prop) {
obj[prop] = stringify(val);
});
// Object supports toString function
} else if (obj !== undefined && obj !== null && can.isFunction(obj.toString)) {
obj = obj.toString();
}
return obj;
},
removeBackslash = function (str) {
return str.replace(/\\/g, "");
},
// A ~~throttled~~ debounced function called multiple times will only fire once the
// timer runs down. Each call resets the timer.
timer,
// Intermediate storage for `can.route.data`.
curParams,
// The last hash caused by a data change
lastHash,
// Are data changes pending that haven't yet updated the hash
changingData,
// List of attributes that have changed since last update
changedAttrs = [],
// If the `can.route.data` changes, update the hash.
// Using `.serialize()` retrieves the raw data contained in the `observable`.
// This function is ~~throttled~~ debounced so it only updates once even if multiple values changed.
// This might be able to use batchNum and avoid this.
onRouteDataChange = function (ev, attr, how, newval) {
// indicate that data is changing
changingData = 1;
// collect attributes that are changing
changedAttrs.push(attr);
clearTimeout(timer);
timer = setTimeout(function () {
// indicate that the hash is set to look like the data
changingData = 0;
var serialized = can.route.data.serialize(),
path = can.route.param(serialized, true);
can.route._call("setURL", path, changedAttrs);
// trigger a url change so its possible to live-bind on url-based changes
can.batch.trigger(eventsObject,"__url",[path, lastHash]);
lastHash = path;
changedAttrs = [];
}, 10);
},
// A dummy events object used to dispatch url change events on.
eventsObject = can.extend({}, can.event),
// everything in the backing Map is a string
// add type coercion during Map setter to coerce all values to strings
stringCoercingMapDecorator = function(map) {
var attrSuper = map.attr;
map.attr = function(prop, val) {
var serializable = this.define === undefined || this.define[prop] === undefined || !!this.define[prop].serialize,
args;
if (serializable) { // if setting non-str non-num attr
args = stringify(Array.apply(null, arguments));
} else {
args = arguments;
}
return attrSuper.apply(this, args);
};
return map;
};
can.route = function (url, defaults) {
// if route ends with a / and url starts with a /, remove the leading / of the url
var root = can.route._call("root");
if (root.lastIndexOf("/") === root.length - 1 &&
url.indexOf("/") === 0) {
url = url.substr(1);
}
defaults = defaults || {};
// Extract the variable names and replace with `RegExp` that will match
// an atual URL with values.
var names = [],
res,
test = "",
lastIndex = matcher.lastIndex = 0,
next,
querySeparator = can.route._call("querySeparator"),
matchSlashes = can.route._call("matchSlashes");
// res will be something like [":foo","foo"]
while (res = matcher.exec(url)) {
names.push(res[1]);
test += removeBackslash(url.substring(lastIndex, matcher.lastIndex - res[0].length));
// if matchSlashes is false (the default) don't greedily match any slash in the string, assume its part of the URL
next = "\\" + (removeBackslash(url.substr(matcher.lastIndex, 1)) || querySeparator+(matchSlashes? "": "|/"));
// a name without a default value HAS to have a value
// a name that has a default value can be empty
// The `\\` is for string-escaping giving single `\` for `RegExp` escaping.
test += "([^" + next + "]" + (defaults[res[1]] ? "*" : "+") + ")";
lastIndex = matcher.lastIndex;
}
test += url.substr(lastIndex)
.replace("\\", "");
// Add route in a form that can be easily figured out.
can.route.routes[url] = {
// A regular expression that will match the route when variable values
// are present; i.e. for `:page/:type` the `RegExp` is `/([\w\.]*)/([\w\.]*)/` which
// will match for any value of `:page` and `:type` (word chars or period).
test: new RegExp("^" + test + "($|" + wrapQuote(querySeparator) + ")"),
// The original URL, same as the index for this entry in routes.
route: url,
// An `array` of all the variable names in this route.
names: names,
// Default values provided for the variables.
defaults: defaults,
// The number of parts in the URL separated by `/`.
length: url.split('/')
.length
};
return can.route;
};
/**
* @static
*/
extend(can.route, {
/**
* @function can.route.param param
* @parent can.route.static
* @description Get a route path from given data.
* @signature `can.route.param( data )`
* @param {data} object The data to populate the route with.
* @return {String} The route, with the data populated in it.
*
* @body
* Parameterizes the raw JS object representation provided in data.
*
* can.route.param( { type: "video", id: 5 } )
* // -> "type=video&id=5"
*
* If a route matching the provided data is found, that URL is built
* from the data. Any remaining data is added at the end of the
* URL as & separated key/value parameters.
*
* can.route(":type/:id")
*
* can.route.param( { type: "video", id: 5 } ) // -> "video/5"
* can.route.param( { type: "video", id: 5, isNew: false } )
* // -> "video/5&isNew=false"
*/
param: function (data, _setRoute) {
// Check if the provided data keys match the names in any routes;
// Get the one with the most matches.
var route,
// Need to have at least 1 match.
matches = 0,
matchCount,
routeName = data.route,
propCount = 0;
delete data.route;
each(data, function () {
propCount++;
});
// Otherwise find route.
each(can.route.routes, function (temp, name) {
// best route is the first with all defaults matching
matchCount = matchesData(temp, data);
if (matchCount > matches) {
route = temp;
matches = matchCount;
}
if (matchCount >= propCount) {
return false;
}
});
// If we have a route name in our `can.route` data, and it's
// just as good as what currently matches, use that
if (can.route.routes[routeName] && matchesData(can.route.routes[routeName], data) === matches) {
route = can.route.routes[routeName];
}
// If this is match...
if (route) {
var cpy = extend({}, data),
// Create the url by replacing the var names with the provided data.
// If the default value is found an empty string is inserted.
res = route.route.replace(matcher, function (whole, name) {
delete cpy[name];
return data[name] === route.defaults[name] ? "" : encodeURIComponent(data[name]);
})
.replace("\\", ""),
after;
// Remove matching default values
each(route.defaults, function (val, name) {
if (cpy[name] === val) {
delete cpy[name];
}
});
// The remaining elements of data are added as
// `&` separated parameters to the url.
after = can.param(cpy);
// if we are paraming for setting the hash
// we also want to make sure the route value is updated
if (_setRoute) {
can.route.attr('route', route.route);
}
return res + (after ? can.route._call("querySeparator") + after : "");
}
// If no route was found, there is no hash URL, only paramters.
return can.isEmptyObject(data) ? "" : can.route._call("querySeparator") + can.param(data);
},
/**
* @function can.route.deparam deparam
* @parent can.route.static
* @description Extract data from a route path.
* @signature `can.route.deparam( url )`
* @param {String} url A route fragment to extract data from.
* @return {Object} An object containing the extracted data.
*
* @body
* Creates a data object based on the query string passed into it. This is
* useful to create an object based on the `location.hash`.
*
* can.route.deparam("id=5&type=videos")
* // -> { id: 5, type: "videos" }
*
*
* It's important to make sure the hash or exclamantion point is not passed
* to `can.route.deparam` otherwise it will be included in the first property's
* name.
*
* can.route.attr("id", 5) // location.hash -> #!id=5
* can.route.attr("type", "videos")
* // location.hash -> #!id=5&type=videos
* can.route.deparam(location.hash)
* // -> { #!id: 5, type: "videos" }
*
* `can.route.deparam` will try and find a matching route and, if it does,
* will deconstruct the URL and parse our the key/value parameters into the data object.
*
* can.route(":type/:id")
*
* can.route.deparam("videos/5");
* // -> { id: 5, route: ":type/:id", type: "videos" }
*/
deparam: function (url) {
// remove the url
var root = can.route._call("root");
if (root.lastIndexOf("/") === root.length - 1 &&
url.indexOf("/") === 0) {
url = url.substr(1);
}
// See if the url matches any routes by testing it against the `route.test` `RegExp`.
// By comparing the URL length the most specialized route that matches is used.
var route = {
length: -1
},
querySeparator = can.route._call("querySeparator"),
paramsMatcher = can.route._call("paramsMatcher");
each(can.route.routes, function (temp, name) {
if (temp.test.test(url) && temp.length > route.length) {
route = temp;
}
});
// If a route was matched.
if (route.length > -1) {
var // Since `RegExp` backreferences are used in `route.test` (parens)
// the parts will contain the full matched string and each variable (back-referenced) value.
parts = url.match(route.test),
// Start will contain the full matched string; parts contain the variable values.
start = parts.shift(),
// The remainder will be the `&key=value` list at the end of the URL.
remainder = url.substr(start.length - (parts[parts.length - 1] === querySeparator ? 1 : 0)),
// If there is a remainder and it contains a `&key=value` list deparam it.
obj = (remainder && paramsMatcher.test(remainder)) ? can.deparam(remainder.slice(1)) : {};
// Add the default values for this route.
obj = extend(true, {}, route.defaults, obj);
// Overwrite each of the default values in `obj` with those in
// parts if that part is not empty.
each(parts, function (part, i) {
if (part && part !== querySeparator) {
obj[route.names[i]] = decodeURIComponent(part);
}
});
obj.route = route.route;
return obj;
}
// If no route was matched, it is parsed as a `&key=value` list.
if (url.charAt(0) !== querySeparator) {
url = querySeparator + url;
}
return paramsMatcher.test(url) ? can.deparam(url.slice(1)) : {};
},
/**
* @hide
* A can.Map that represents the state of the history.
*/
data: stringCoercingMapDecorator(new can.Map({})),
map: function(data){
var appState;
// appState is a can.Map constructor function
if(data.prototype instanceof can.Map){
appState = new data();
}
// appState is an instance of can.Map
else {
appState = data;
}
can.route.data = stringCoercingMapDecorator(appState);
},
/**
* @property {Object} routes
* @hide
*
* A list of routes recognized by the router indixed by the url used to add it.
* Each route is an object with these members:
*
* - test - A regular expression that will match the route when variable values
* are present; i.e. for :page/:type the `RegExp` is /([\w\.]*)/([\w\.]*)/ which
* will match for any value of :page and :type (word chars or period).
*
* - route - The original URL, same as the index for this entry in routes.
*
* - names - An array of all the variable names in this route
*
* - defaults - Default values provided for the variables or an empty object.
*
* - length - The number of parts in the URL separated by '/'.
*/
routes: {},
/**
* @function can.route.ready ready
* @parent can.route.static
*
* Initialize can.route.
*
* @signature `can.route.ready()`
*
* Sets up the two-way binding between the hash and the can.route observable map and
* sets the can.route map to its initial values.
*
* @return {can.route} The `can.route` object.
*
* @body
*
* ## Use
*
* After setting all your routes, call can.route.ready().
*
* can.route("overview/:dateStart-:dateEnd");
* can.route(":type/:id")
* can.route.ready()
*/
ready: function (val) {
if (val !== true) {
can.route._setup();
if(can.isBrowserWindow || can.isWebWorker) {
can.route.setState();
}
}
return can.route;
},
/**
* @function can.route.url url
* @parent can.route.static
* @signature `can.route.url( data [, merge] )`
*
* Make a URL fragment that when set to window.location.hash will update can.route's properties
* to match those in `data`.
*
* @param {Object} data The data to populate the route with.
* @param {Boolean} [merge] Whether the given options should be merged into the current state of the route.
* @return {String} The route URL and query string.
*
* @body
* Similar to [can.route.link], but instead of creating an anchor tag, `can.route.url` creates
* only the URL based on the route options passed into it.
*
* can.route.url( { type: "videos", id: 5 } )
* // -> "#!type=videos&id=5"
*
* If a route matching the provided data is found the URL is built from the data. Any remaining
* data is added at the end of the URL as & separated key/value parameters.
*
* can.route(":type/:id")
*
* can.route.url( { type: "videos", id: 5 } ) // -> "#!videos/5"
* can.route.url( { type: "video", id: 5, isNew: false } )
* // -> "#!video/5&isNew=false"
*/
url: function (options, merge) {
if (merge) {
can.__observe(eventsObject,"__url");
options = can.extend({}, can.route.deparam(can.route._call("matchingPartOfURL")), options);
}
return can.route._call("root") + can.route.param(options);
},
/**
* @function can.route.link link
* @parent can.route.static
* @signature `can.route.link( innerText, data, props [, merge] )`
*
* Make an anchor tag (`<A>`) that when clicked on will update can.route's properties
* to match those in `data`.
*
* @param {Object} innerText The text inside the link.
* @param {Object} data The data to populate the route with.
* @param {Object} props Properties for the anchor other than `href`.
* @param {Boolean} [merge] Whether the given options should be merged into the current state of the route.
* @return {String} A string with an anchor tag that points to the populated route.
*
* @body
* Creates and returns an anchor tag with an href of the route
* attributes passed into it, as well as any properties desired
* for the tag.
*
* can.route.link( "My videos", { type: "videos" }, {}, false )
* // -> <a href="#!type=videos">My videos</a>
*
* Other attributes besides href can be added to the anchor tag
* by passing in a data object with the attributes desired.
*
* can.route.link( "My videos", { type: "videos" },
* { className: "new" }, false )
* // -> <a href="#!type=videos" class="new">My Videos</a>
*
* It is possible to utilize the current route options when making anchor
* tags in order to make your code more reusable. If merge is set to true,
* the route options passed into `can.route.link` will be passed into the
* current ones.
*
* location.hash = "#!type=videos"
* can.route.link( "The zoo", { id: 5 }, true )
* // -> <a href="#!type=videos&id=5">The zoo</true>
*
* location.hash = "#!type=pictures"
* can.route.link( "The zoo", { id: 5 }, true )
* // -> <a href="#!type=pictures&id=5">The zoo</true>
*
*
*/
link: function (name, options, props, merge) {
return "<a " + makeProps(
extend({
href: can.route.url(options, merge)
}, props)) + ">" + name + "</a>";
},
/**
* @function can.route.current current
* @parent can.route.static
* @signature `can.route.current( data )`
*
* Check if data represents the current route.
*
* @param {Object} data Data to check agains the current route.
* @return {Boolean} Whether the data matches the current URL.
*
* @body
* Checks the page's current URL to see if the route represents the options passed
* into the function.
*
* Returns true if the options respresent the current URL.
*
* can.route.attr('id', 5) // location.hash -> "#!id=5"
* can.route.current({ id: 5 }) // -> true
* can.route.current({ id: 5, type: 'videos' }) // -> false
*
* can.route.attr('type', 'videos')
* // location.hash -> #!id=5&type=videos
* can.route.current({ id: 5, type: 'videos' }) // -> true
*/
current: function (options) {
// "reads" the url so the url is live-bindable.
can.__observe(eventsObject,"__url");
return this._call("matchingPartOfURL") === can.route.param(options);
},
bindings: {
hashchange: {
paramsMatcher: paramsMatcher,
querySeparator: "&",
// don't greedily match slashes in routing rules
matchSlashes: false,
bind: function () {
can.bind.call(window, 'hashchange', setState);
},
unbind: function () {
can.unbind.call(window, 'hashchange', setState);
},
// Gets the part of the url we are determinging the route from.
// For hashbased routing, it's everything after the #, for
// pushState it's configurable
matchingPartOfURL: function () {
var loc = can.route.location || location;
return loc.href.split(/#!?/)[1] || "";
},
// gets called with the serialized can.route data after a route has changed
// returns what the url has been updated to (for matching purposes)
setURL: function (path) {
if(location.hash !== "#" + path) {
location.hash = "!" + path;
}
return path;
},
root: "#!"
}
},
defaultBinding: "hashchange",
currentBinding: null,
// ready calls setup
// setup binds and listens to data changes
// bind listens to whatever you should be listening to
// data changes tries to set the path
// we need to be able to
// easily kick off calling setState
// teardown whatever is there
// turn on a particular binding
// called when the route is ready
_setup: function () {
if (!can.route.currentBinding) {
can.route._call("bind");
can.route.bind("change", onRouteDataChange);
can.route.currentBinding = can.route.defaultBinding;
}
},
_teardown: function () {
if (can.route.currentBinding) {
can.route._call("unbind");
can.route.unbind("change", onRouteDataChange);
can.route.currentBinding = null;
}
clearTimeout(timer);
changingData = 0;
},
// a helper to get stuff from the current or default bindings
_call: function () {
var args = can.makeArray(arguments),
prop = args.shift(),
binding = can.route.bindings[can.route.currentBinding || can.route.defaultBinding],
method = binding[prop];
if (method.apply) {
return method.apply(binding, args);
} else {
return method;
}
}
});
// The functions in the following list applied to `can.route` (e.g. `can.route.attr('...')`) will
// instead act on the `can.route.data` observe.
each(['bind', 'unbind', 'on', 'off', 'delegate', 'undelegate', 'removeAttr', 'compute', '_get', '___get','each'], function (name) {
can.route[name] = function () {
// `delegate` and `undelegate` require
// the `can/map/delegate` plugin
if (!can.route.data[name]) {
return;
}
return can.route.data[name].apply(can.route.data, arguments);
};
});
can.route.attr = function () {
return can.route.data.attr.apply(can.route.data, arguments);
};
//Allow for overriding of route batching by can.transaction
can.route.batch = can.batch;
var // Deparameterizes the portion of the hash of interest and assign the
// values to the `can.route.data` removing existing values no longer in the hash.
// setState is called typically by hashchange which fires asynchronously
// So it's possible that someone started changing the data before the
// hashchange event fired. For this reason, it will not set the route data
// if the data is changing or the hash already matches the hash that was set.
setState = can.route.setState = function () {
var hash = can.route._call("matchingPartOfURL");
var oldParams = curParams;
curParams = can.route.deparam(hash);
// if the hash data is currently changing, or
// the hash is what we set it to anyway, do NOT change the hash
if (!changingData || hash !== lastHash) {
can.route.batch.start();
recursiveClean(oldParams, curParams, can.route.data);
can.route.attr(curParams);
// trigger a url change so its possible to live-bind on url-based changes
can.route.batch.trigger(eventsObject,"__url",[hash, lastHash]);
can.route.batch.stop();
}
};
var recursiveClean = function(old, cur, data){
for(var attr in old){
if(cur[attr] === undefined){
data.removeAttr(attr);
}
else if(Object.prototype.toString.call(old[attr]) === "[object Object]") {
recursiveClean( old[attr], cur[attr], data.attr(attr) );
}
}
};
return can.route;
});