steal('can/util', 'can/map', 'can/list','can/util/string/deparam', function (can) {
steal('can/util', 'can/map', 'can/list','can/util/string/deparam', function (can) {
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;
});