UNPKG

can

Version:

MIT-licensed, client-side, JavaScript framework that makes building rich web applications easy.

692 lines (655 loc) 24.1 kB
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 &amp;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 &amp; 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 // `&amp;` 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 `&amp;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 `&amp;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 `&amp;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; });