diff --git a/querystring/build.js b/querystring/build.js index 6b8c1c0e..74253624 100644 --- a/querystring/build.js +++ b/querystring/build.js @@ -20,6 +20,6 @@ module.exports = function buildQueryString(object) { destructure(key + "[" + i + "]", value[i]) } } - else args.push(encodeURIComponent(key) + "=" + (value != null ? encodeURIComponent(value) : "")) + else args.push(encodeURIComponent(key) + (value != null && value !== "" ? "=" + encodeURIComponent(value) : "")) } } \ No newline at end of file diff --git a/querystring/parse.js b/querystring/parse.js index 8bfc42ca..b9148d0b 100644 --- a/querystring/parse.js +++ b/querystring/parse.js @@ -8,7 +8,7 @@ module.exports = function parseQueryString(string) { for (var i = 0; i < entries.length; i++) { var entry = entries[i].split("=") var key = decodeURIComponent(entry[0]) - var value = decodeURIComponent(entry[1]) + var value = entry.length === 2 ? decodeURIComponent(entry[1]) : "" //TODO refactor out var number = Number(value) diff --git a/querystring/tests/test-buildQueryString.js b/querystring/tests/test-buildQueryString.js index 17f935fd..2dea470b 100644 --- a/querystring/tests/test-buildQueryString.js +++ b/querystring/tests/test-buildQueryString.js @@ -4,62 +4,77 @@ var o = require("../../ospec/ospec") var buildQueryString = require("../../querystring/build") o.spec("buildQueryString", function() { - o("builds from flat object", function() { + o("handles flat object", function() { var string = buildQueryString({a: "b", c: 1}) o(string).equals("a=b&c=1") }) - o("builds from nested object", function() { + o("handles escaped values", function() { + var data = buildQueryString({";:@&=+$,/?%#": ";:@&=+$,/?%#"}) + + o(data).equals("%3B%3A%40%26%3D%2B%24%2C%2F%3F%25%23=%3B%3A%40%26%3D%2B%24%2C%2F%3F%25%23") + }) + o("handles unicode", function() { + var data = buildQueryString({"ö": "ö"}) + + o(data).equals("%C3%B6=%C3%B6") + }) + o("handles nested object", function() { var string = buildQueryString({a: {b: 1, c: 2}}) o(string).equals("a%5Bb%5D=1&a%5Bc%5D=2") }) - o("builds from deep nested object", function() { + o("handles deep nested object", function() { var string = buildQueryString({a: {b: {c: 1, d: 2}}}) o(string).equals("a%5Bb%5D%5Bc%5D=1&a%5Bb%5D%5Bd%5D=2") }) - o("builds from nested array", function() { + o("handles nested array", function() { var string = buildQueryString({a: ["x", "y"]}) o(string).equals("a%5B0%5D=x&a%5B1%5D=y") }) - o("builds from deep nested array", function() { + o("handles deep nested array", function() { var string = buildQueryString({a: [["x", "y"]]}) o(string).equals("a%5B0%5D%5B0%5D=x&a%5B0%5D%5B1%5D=y") }) - o("builds from deep nested array in object", function() { + o("handles deep nested array in object", function() { var string = buildQueryString({a: {b: ["x", "y"]}}) o(string).equals("a%5Bb%5D%5B0%5D=x&a%5Bb%5D%5B1%5D=y") }) - o("builds from deep nested object in array", function() { + o("handles deep nested object in array", function() { var string = buildQueryString({a: [{b: 1, c: 2}]}) o(string).equals("a%5B0%5D%5Bb%5D=1&a%5B0%5D%5Bc%5D=2") }) - o("builds date", function() { + o("handles date", function() { var string = buildQueryString({a: new Date(0)}) o(string).equals("a=" + encodeURIComponent(new Date(0).toString())) }) - o("builds null into empty string (like jQuery)", function() { + o("turns null into value-less string (like jQuery)", function() { var string = buildQueryString({a: null}) - o(string).equals("a=") + o(string).equals("a") }) - o("builds undefined into empty string (like jQuery)", function() { + o("turns undefined into value-less string (like jQuery)", function() { var string = buildQueryString({a: undefined}) - o(string).equals("a=") + o(string).equals("a") }) - o("builds zero", function() { + o("turns empty string into value-less string (like jQuery)", function() { + var string = buildQueryString({a: ""}) + + o(string).equals("a") + }) + o("handles zero", function() { var string = buildQueryString({a: 0}) o(string).equals("a=0") }) - o("builds false", function() { + o("handles false", function() { var string = buildQueryString({a: false}) o(string).equals("a=false") diff --git a/querystring/tests/test-parseQueryString.js b/querystring/tests/test-parseQueryString.js index 2fe95add..8d3c100a 100644 --- a/querystring/tests/test-parseQueryString.js +++ b/querystring/tests/test-parseQueryString.js @@ -12,6 +12,22 @@ o.spec("parseQueryString", function() { var data = parseQueryString("?a=b&c=d") o(data).deepEquals({a: "b", c: "d"}) }) + o("handles escaped values", function() { + var data = parseQueryString("?%3B%3A%40%26%3D%2B%24%2C%2F%3F%25%23=%3B%3A%40%26%3D%2B%24%2C%2F%3F%25%23") + o(data).deepEquals({";:@&=+$,/?%#": ";:@&=+$,/?%#"}) + }) + o("handles escaped square brackets", function() { + var data = parseQueryString("?a%5B%5D=b") + o(data).deepEquals({"a": ["b"]}) + }) + o("handles escaped unicode", function() { + var data = parseQueryString("?%C3%B6=%C3%B6") + o(data).deepEquals({"ö": "ö"}) + }) + o("handles unicode", function() { + var data = parseQueryString("?ö=ö") + o(data).deepEquals({"ö": "ö"}) + }) o("parses without question mark", function() { var data = parseQueryString("a=b&c=d") o(data).deepEquals({a: "b", c: "d"}) @@ -71,4 +87,8 @@ o.spec("parseQueryString", function() { var data = parseQueryString("a=") o(data).deepEquals({a: ""}) }) + o("does not cast void to number", function() { + var data = parseQueryString("a") + o(data).deepEquals({a: ""}) + }) }) diff --git a/router/router.js b/router/router.js index 90f539ab..9dabbcf1 100644 --- a/router/router.js +++ b/router/router.js @@ -5,47 +5,56 @@ var parseQueryString = require("../querystring/parse") module.exports = function($window, prefix) { var supportsPushState = typeof $window.history.pushState === "function" && $window.location.protocol !== "file:" - - function parsePath(path) { - var params = {} + + function normalize(fragment) { + var data = $window.location[fragment].replace(/(?:%[a-f89][a-f0-9])+/gim, decodeURIComponent) + if (fragment === "pathname" && data[0] !== "/") data = "/" + data + return data + } + + function parsePath(path, queryData, hashData) { var queryIndex = path.indexOf("?") var hashIndex = path.indexOf("#") var pathEnd = queryIndex > -1 ? queryIndex : hashIndex > -1 ? hashIndex : path.length if (queryIndex > -1) { var queryEnd = hashIndex > -1 ? hashIndex : path.length var queryParams = parseQueryString(path.slice(queryIndex + 1, queryEnd)) - for (var key in queryParams) params[key] = queryParams[key] + for (var key in queryParams) queryData[key] = queryParams[key] } if (hashIndex > -1) { var hashParams = parseQueryString(path.slice(hashIndex + 1)) - for (var key in hashParams) params[key] = hashParams[key] + for (var key in hashParams) hashData[key] = hashParams[key] } - return {name: path.slice(0, pathEnd), params: params} + return path.slice(0, pathEnd) } function getPath() { var type = prefix.charAt(0) switch (type) { - case "#": return $window.location.hash.slice(prefix.length) - case "?": return $window.location.search.slice(prefix.length) + $window.location.hash - default: return $window.location.pathname + $window.location.search + $window.location.hash + case "#": return normalize("hash").slice(prefix.length) + case "?": return normalize("search").slice(prefix.length) + normalize("hash") + default: return normalize("pathname") + normalize("search") + normalize("hash") } } function setPath(path, data, options) { + var queryData = {}, hashData = {} + path = parsePath(path, queryData, hashData) + if (data != null) { + for (var key in data) queryData[key] = data[key] + path = path.replace(/:([^\/]+)/g, function(match, token) { + delete queryData[token] + return data[token] + }) + } + + var query = buildQueryString(queryData) + if (query) path += "?" + query + + var hash = buildQueryString(hashData) + if (hash) path += "#" + hash + if (supportsPushState) { - var queryData = {} - if (data != null) { - for (var key in data) queryData[key] = data[key] - path = path.replace(/:([^\/]+)/g, function(match, token) { - delete queryData[token] - return data[token] - }) - } - - var query = buildQueryString(queryData) - if (query) path = path + "?" + query - if (options && options.replace) $window.history.replaceState(null, null, prefix + path) else $window.history.pushState(null, null, prefix + path) $window.onpopstate() @@ -58,27 +67,28 @@ module.exports = function($window, prefix) { else if (prefix.charAt(0) === "#") $window.onhashchange = resolveRoute resolveRoute() - function resolveRoute(e) { + function resolveRoute() { var path = getPath() - var data = parsePath(path) + var params = {} + var pathname = parsePath(path, params, params) for (var route in routes) { var matcher = new RegExp("^" + route.replace(/:[^\/]+?\.{3}/g, "(.*?)").replace(/:[^\/]+/g, "([^\\/]+)") + "\/?$") - if (matcher.test(data.name)) { - data.name.replace(matcher, function() { + if (matcher.test(pathname)) { + pathname.replace(matcher, function() { var keys = route.match(/:[^\/]+/g) || [] var values = [].slice.call(arguments, 1, -2) for (var i = 0; i < keys.length; i++) { - data.params[keys[i].replace(/:|\./g, "")] = decodeURIComponent(values[i]) + params[keys[i].replace(/:|\./g, "")] = decodeURIComponent(values[i]) } - resolve(routes[route], data.params, path, route) + resolve(routes[route], params, path, route) }) return } } - reject(path, data.params) + reject(path, params) } return resolveRoute } diff --git a/router/tests/index.html b/router/tests/index.html index 7d87aa12..f766e92e 100644 --- a/router/tests/index.html +++ b/router/tests/index.html @@ -13,6 +13,8 @@ + + diff --git a/router/tests/test-defineRoutes.js b/router/tests/test-defineRoutes.js index 878999be..0de90388 100644 --- a/router/tests/test-defineRoutes.js +++ b/router/tests/test-defineRoutes.js @@ -4,7 +4,7 @@ var o = require("../../ospec/ospec") var pushStateMock = require("../../test-utils/pushStateMock") var Router = require("../../router/router") -o.spec("router", function() { +o.spec("Router.defineRoutes", function() { void ["#", "?", "", "#!", "?!"].forEach(function(prefix) { o.spec("using prefix `" + prefix + "`", function() { var $window, router, onRouteChange, onFail @@ -26,6 +26,24 @@ o.spec("router", function() { o(onFail.callCount).equals(0) }) + o("resolves to route w/ escaped unicode", function() { + $window.location.href = prefix + "/%C3%B6?%C3%B6=%C3%B6#%C3%B6=%C3%B6" + router.defineRoutes({"/ö": {data: 2}}, onRouteChange, onFail) + + o(onRouteChange.callCount).equals(1) + o(onRouteChange.args).deepEquals([{data: 2}, {"ö": "ö"}, "/ö?ö=ö#ö=ö", "/ö"]) + o(onFail.callCount).equals(0) + }) + + o("resolves to route w/ unicode", function() { + $window.location.href = prefix + "/ö?ö=ö#ö=ö" + router.defineRoutes({"/ö": {data: 2}}, onRouteChange, onFail) + + o(onRouteChange.callCount).equals(1) + o(onRouteChange.args).deepEquals([{data: 2}, {"ö": "ö"}, "/ö?ö=ö#ö=ö", "/ö"]) + o(onFail.callCount).equals(0) + }) + o("handles parameterized route", function() { $window.location.href = prefix + "/test/x" router.defineRoutes({"/test/:a": {data: 1}}, onRouteChange, onFail) @@ -158,6 +176,13 @@ o.spec("router", function() { o(onRouteChange.args).deepEquals([{data: 2}, {a: "z/y/x"}, "/z/y/x", "/:a..."]) }) + o("handles non-ascii routes", function() { + $window.location.href = prefix + "/ö" + router.defineRoutes({"/ö": "aaa"}, onRouteChange, onFail) + + o(onRouteChange.callCount).equals(1) + }) + o("replays", function() { $window.location.href = prefix + "/test" var replay = router.defineRoutes({"/test": {data: 1}}, onRouteChange, onFail) @@ -168,62 +193,6 @@ o.spec("router", function() { o(onFail.callCount).equals(0) }) }) - - o.spec("getPath", function() { - o("gets route", function() { - $window.location.href = prefix + "/test" - router.defineRoutes({"/test": {data: 1}}, onRouteChange, onFail) - - o(router.getPath()).equals("/test") - }) - o("gets route w/ params", function() { - $window.location.href = prefix + "/other/x/y/z?c=d#e=f" - router.defineRoutes({"/test": {data: 1}, "/other/:a/:b...": {data: 2}}, onRouteChange, onFail) - - o(router.getPath()).equals("/other/x/y/z?c=d#e=f") - }) - }) - - o.spec("setPath", function() { - o("sets route via API", function() { - $window.location.href = prefix + "/test" - router.defineRoutes({"/test": {data: 1}, "/other/:a/:b...": {data: 2}}, onRouteChange, onFail) - router.setPath("/other/x/y/z?c=d#e=f") - - o(router.getPath()).equals("/other/x/y/z?c=d#e=f") - }) - o("sets route via pushState/onpopstate", function() { - $window.location.href = prefix + "/test" - router.defineRoutes({"/test": {data: 1}, "/other/:a/:b...": {data: 2}}, onRouteChange, onFail) - $window.history.pushState(null, null, prefix + "/other/x/y/z?c=d#e=f") - $window.onpopstate() - - o(router.getPath()).equals("/other/x/y/z?c=d#e=f") - }) - o("sets parameterized route", function() { - $window.location.href = prefix + "/test" - router.defineRoutes({"/test": {data: 1}, "/other/:a/:b...": {data: 2}}, onRouteChange, onFail) - router.setPath("/other/:a/:b", {a: "x", b: "y/z", c: "d", e: "f"}) - - o(router.getPath()).equals("/other/x/y/z?c=d&e=f") - }) - o("replace:true works", function() { - $window.location.href = prefix + "/test" - router.defineRoutes({"/test": {data: 1}, "/other": {data: 2}}, onRouteChange, onFail) - router.setPath("/other", null, {replace: true}) - $window.history.back() - - o($window.location.href).equals("http://localhost/") - }) - o("replace:false works", function() { - $window.location.href = prefix + "/test" - router.defineRoutes({"/test": {data: 1}, "/other": {data: 2}}, onRouteChange, onFail) - router.setPath("/other", null, {replace: false}) - $window.history.back() - - o($window.location.href).equals("http://localhost/" + (prefix ? prefix + "/" : "") + "test") - }) - }) }) }) }) \ No newline at end of file