diff --git a/assets/javascripts/lib/util.js b/assets/javascripts/lib/util.js index ca977da113..555c129e33 100644 --- a/assets/javascripts/lib/util.js +++ b/assets/javascripts/lib/util.js @@ -450,6 +450,9 @@ $.escapeRegexp = (string) => string.replace(ESCAPE_REGEXP, "\\$1"); $.urlDecode = (string) => decodeURIComponent(string.replace(/\+/g, "%20")); +// Hash fragments are not form-encoded, so literal plus signs must stay plus signs. +$.urlDecodeFragment = (string) => decodeURIComponent(string); + $.classify = function (string) { string = string.split("_"); for (let i = 0; i < string.length; i++) { diff --git a/assets/javascripts/views/search/search.js b/assets/javascripts/views/search/search.js index 163d96e1f7..94105618f0 100644 --- a/assets/javascripts/views/search/search.js +++ b/assets/javascripts/views/search/search.js @@ -216,7 +216,7 @@ app.views.Search = class Search extends app.View { getHashValue() { try { - return Search.HASH_RGX.exec($.urlDecode(location.hash))?.[1]; + return Search.HASH_RGX.exec($.urlDecodeFragment(location.hash))?.[1]; } catch (error) {} } }; diff --git a/assets/javascripts/views/search/search_scope.js b/assets/javascripts/views/search/search_scope.js index c06d54e0ae..45984bc176 100644 --- a/assets/javascripts/views/search/search_scope.js +++ b/assets/javascripts/views/search/search_scope.js @@ -157,7 +157,7 @@ app.views.SearchScope = class SearchScope extends app.View { extractHashValue() { const value = this.getHashValue(); if (value) { - const newHash = $.urlDecode(location.hash).replace( + const newHash = $.urlDecodeFragment(location.hash).replace( `#${SearchScope.SEARCH_PARAM}=${value} `, `#${SearchScope.SEARCH_PARAM}=` ); @@ -168,7 +168,7 @@ app.views.SearchScope = class SearchScope extends app.View { getHashValue() { try { - return SearchScope.HASH_RGX.exec($.urlDecode(location.hash))?.[1]; + return SearchScope.HASH_RGX.exec($.urlDecodeFragment(location.hash))?.[1]; } catch (error) {} } diff --git a/test/assets/search_hash_test.js b/test/assets/search_hash_test.js new file mode 100644 index 0000000000..276f3df387 --- /dev/null +++ b/test/assets/search_hash_test.js @@ -0,0 +1,50 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const test = require("node:test"); +const vm = require("node:vm"); + +const context = { + app: { + config: { search_param: "q" }, + router: { replaceHash: () => {} }, + views: {}, + View: class {}, + }, + location: { hash: "" }, + $: { + urlDecodeFragment: decodeURIComponent, + }, +}; + +vm.createContext(context); + +for (const file of [ + "assets/javascripts/views/search/search_scope.js", + "assets/javascripts/views/search/search.js", +]) { + vm.runInContext(fs.readFileSync(file, "utf8"), context, { + filename: file, + }); +} + +test("URL search hash preserves plus signs in a scoped C++ query", () => { + context.location.hash = "#q=c++%20std::min"; + + const scope = new context.app.views.SearchScope(); + let replacedHash; + context.app.router.replaceHash = (hash) => { + replacedHash = hash; + }; + + assert.equal(scope.getHashValue(), "c++"); + assert.equal(scope.extractHashValue(), "c++"); + assert.equal(replacedHash, "#q=std::min"); +}); + +test("URL search hash preserves encoded literal plus signs in the query", () => { + context.location.hash = "#q=operator%2B"; + + const search = new context.app.views.Search(); + + assert.equal(search.getHashValue(), "operator+"); +});