diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..b85fc2a --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,24 @@ +name: CI +on: + - push + - pull_request +jobs: + test: + name: Node.js ${{ matrix.node-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: + - 14 + - 12 + - 10 + - 8 + - 6 + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm test diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 81f3378..0000000 --- a/.travis.yml +++ /dev/null @@ -1,7 +0,0 @@ -language: node_js -node_js: - - '14' - - '12' - - '10' - - '8' - - '6' diff --git a/dist/index.js b/dist/index.js index 6e28c40..c684b0a 100644 --- a/dist/index.js +++ b/dist/index.js @@ -116,6 +116,8 @@ var decodeComponent = __webpack_require__(2); var splitOnFirst = __webpack_require__(3); +var filterObject = __webpack_require__(4); + var isNullOrUndefined = function isNullOrUndefined(value) { return value === null || value === undefined; }; @@ -362,6 +364,10 @@ function parse(query, options) { for (var _iterator = query.split('&')[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) { var param = _step.value; + if (param === '') { + continue; + } + var _splitOnFirst = splitOnFirst(options.decode ? param.replace(/\+/g, ' ') : param, '='), _splitOnFirst2 = _slicedToArray(_splitOnFirst, 2), key = _splitOnFirst2[0], @@ -521,6 +527,32 @@ exports.stringifyUrl = function (object, options) { return "".concat(url).concat(queryString).concat(hash); }; +exports.pick = function (input, filter, options) { + options = Object.assign({ + parseFragmentIdentifier: true + }, options); + + var _exports$parseUrl = exports.parseUrl(input, options), + url = _exports$parseUrl.url, + query = _exports$parseUrl.query, + fragmentIdentifier = _exports$parseUrl.fragmentIdentifier; + + return exports.stringifyUrl({ + url: url, + query: filterObject(query, filter), + fragmentIdentifier: fragmentIdentifier + }, options); +}; + +exports.exclude = function (input, filter, options) { + var exclusionFilter = Array.isArray(filter) ? function (key) { + return !filter.includes(key); + } : function (key, value) { + return !filter(key, value); + }; + return exports.pick(input, exclusionFilter, options); +}; + /***/ }), /* 1 */ /***/ (function(module, exports, __webpack_require__) { @@ -656,5 +688,29 @@ module.exports = function (string, separator) { return [string.slice(0, separatorIndex), string.slice(separatorIndex + separator.length)]; }; +/***/ }), +/* 4 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +module.exports = function (obj, predicate) { + var ret = {}; + var keys = Object.keys(obj); + var isArr = Array.isArray(predicate); + + for (var i = 0; i < keys.length; i++) { + var key = keys[i]; + var val = obj[key]; + + if (isArr ? predicate.indexOf(key) !== -1 : predicate(key, val, obj)) { + ret[key] = val; + } + } + + return ret; +}; + /***/ }) /******/ ]); \ No newline at end of file diff --git a/index.d.ts b/index.d.ts index d828894..4a115fb 100644 --- a/index.d.ts +++ b/index.d.ts @@ -350,7 +350,12 @@ export type StringifiableRecord = Record< Stringify an object into a query string and sort the keys. */ export function stringify( - object: StringifiableRecord, + // TODO: Use the below instead when the following TS issues are fixed: + // - https://github.com/microsoft/TypeScript/issues/15300 + // - https://github.com/microsoft/TypeScript/issues/42021 + // Context: https://github.com/sindresorhus/query-string/issues/298 + // object: StringifiableRecord, + object: Record, options?: StringifyOptions ): string; @@ -367,7 +372,7 @@ export interface UrlObject { /** Overrides queries in the `url` property. */ - readonly query: StringifiableRecord; + readonly query?: StringifiableRecord; /** Overrides the fragment identifier in the `url` property. @@ -404,3 +409,81 @@ export function stringifyUrl( object: UrlObject, options?: StringifyOptions ): string; + +/** +Pick query parameters from a URL. + +@param url - The URL containing the query parameters to pick. +@param keys - The names of the query parameters to keep. All other query parameters will be removed from the URL. +@param filter - A filter predicate that will be provided the name of each query parameter and its value. The `parseNumbers` and `parseBooleans` options also affect `value`. + +@returns The URL with the picked query parameters. + +@example +``` +queryString.pick('https://foo.bar?foo=1&bar=2#hello', ['foo']); +//=> 'https://foo.bar?foo=1#hello' + +queryString.pick('https://foo.bar?foo=1&bar=2#hello', (name, value) => value === 2, {parseNumbers: true}); +//=> 'https://foo.bar?bar=2#hello' +``` +*/ +export function pick( + url: string, + keys: readonly string[], + options?: ParseOptions & StringifyOptions +): string +export function pick( + url: string, + filter: (key: string, value: string | boolean | number) => boolean, + options?: {parseBooleans: true, parseNumbers: true} & ParseOptions & StringifyOptions +): string +export function pick( + url: string, + filter: (key: string, value: string | boolean) => boolean, + options?: {parseBooleans: true} & ParseOptions & StringifyOptions +): string +export function pick( + url: string, + filter: (key: string, value: string | number) => boolean, + options?: {parseNumbers: true} & ParseOptions & StringifyOptions +): string + +/** +Exclude query parameters from a URL. Like `.pick()` but reversed. + +@param url - The URL containing the query parameters to exclude. +@param keys - The names of the query parameters to remove. All other query parameters will remain in the URL. +@param filter - A filter predicate that will be provided the name of each query parameter and its value. The `parseNumbers` and `parseBooleans` options also affect `value`. + +@returns The URL without the excluded the query parameters. + +@example +``` +queryString.exclude('https://foo.bar?foo=1&bar=2#hello', ['foo']); +//=> 'https://foo.bar?bar=2#hello' + +queryString.exclude('https://foo.bar?foo=1&bar=2#hello', (name, value) => value === 2, {parseNumbers: true}); +//=> 'https://foo.bar?foo=1#hello' +``` +*/ +export function exclude( + url: string, + keys: readonly string[], + options?: ParseOptions & StringifyOptions +): string +export function exclude( + url: string, + filter: (key: string, value: string | boolean | number) => boolean, + options?: {parseBooleans: true, parseNumbers: true} & ParseOptions & StringifyOptions +): string +export function exclude( + url: string, + filter: (key: string, value: string | boolean) => boolean, + options?: {parseBooleans: true} & ParseOptions & StringifyOptions +): string +export function exclude( + url: string, + filter: (key: string, value: string | number) => boolean, + options?: {parseNumbers: true} & ParseOptions & StringifyOptions +): string diff --git a/index.js b/index.js index 559ecd4..423b9d6 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,7 @@ const strictUriEncode = require('strict-uri-encode'); const decodeComponent = require('decode-uri-component'); const splitOnFirst = require('split-on-first'); +const filterObject = require('filter-obj'); const isNullOrUndefined = value => value === null || value === undefined; @@ -244,6 +245,10 @@ function parse(query, options) { } for (const param of query.split('&')) { + if (param === '') { + continue; + } + let [key, value] = splitOnFirst(options.decode ? param.replace(/\+/g, ' ') : param, '='); // Missing `=` should be `null`: @@ -378,3 +383,22 @@ exports.stringifyUrl = (object, options) => { return `${url}${queryString}${hash}`; }; + +exports.pick = (input, filter, options) => { + options = Object.assign({ + parseFragmentIdentifier: true + }, options); + + const {url, query, fragmentIdentifier} = exports.parseUrl(input, options); + return exports.stringifyUrl({ + url, + query: filterObject(query, filter), + fragmentIdentifier + }, options); +}; + +exports.exclude = (input, filter, options) => { + const exclusionFilter = Array.isArray(filter) ? key => !filter.includes(key) : (key, value) => !filter(key, value); + + return exports.pick(input, exclusionFilter, options); +}; diff --git a/index.test-d.ts b/index.test-d.ts index 9c46344..2032584 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -35,6 +35,17 @@ expectType( ) ); +// Ensure it accepts an `interface`. +interface Query { + foo: string; +} + +const query: Query = { + foo: 'bar' +}; + +queryString.stringify(query); + // Parse expectType(queryString.parse('?foo=bar')); @@ -113,3 +124,9 @@ expectType( }, }) ); + +// Pick +expectType(queryString.pick('http://foo.bar/?abc=def&hij=klm', ['abc'])) + +// Exclude +expectType(queryString.exclude('http://foo.bar/?abc=def&hij=klm', ['abc'])) diff --git a/package.json b/package.json index 80b4fd3..58be3b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "query-string-for-all", - "version": "6.13.7", + "version": "6.14.1", "description": "Parse and stringify URL query strings", "license": "MIT", "repository": "cdeutsch/query-string-for-all", @@ -36,7 +36,8 @@ "stringify", "encode", "decode", - "searchparams" + "searchparams", + "filter" ], "dependencies": {}, "devDependencies": { @@ -49,6 +50,7 @@ "benchmark": "^2.1.4", "deep-equal": "^1.0.1", "fast-check": "^1.5.0", + "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0", "tsd": "^0.7.3", diff --git a/readme.md b/readme.md index d2b9a9f..ff187ea 100644 --- a/readme.md +++ b/readme.md @@ -391,6 +391,64 @@ Type: `object` Query items to add to the URL. +### .pick(url, keys, options?) +### .pick(url, filter, options?) + +Pick query parameters from a URL. + +Returns a string with the new URL. + +```js +const queryString = require('query-string'); + +queryString.pick('https://foo.bar?foo=1&bar=2#hello', ['foo']); +//=> 'https://foo.bar?foo=1#hello' + +queryString.pick('https://foo.bar?foo=1&bar=2#hello', (name, value) => value === 2, {parseNumbers: true}); +//=> 'https://foo.bar?bar=2#hello' +``` + +### .exclude(url, keys, options?) +### .exclude(url, filter, options?) + +Exclude query parameters from a URL. + +Returns a string with the new URL. + +```js +const queryString = require('query-string'); + +queryString.exclude('https://foo.bar?foo=1&bar=2#hello', ['foo']); +//=> 'https://foo.bar?bar=2#hello' + +queryString.exclude('https://foo.bar?foo=1&bar=2#hello', (name, value) => value === 2, {parseNumbers: true}); +//=> 'https://foo.bar?foo=1#hello' +``` + +#### url + +Type: `string` + +The URL containing the query parameters to filter. + +#### keys + +Type: `string[]` + +The names of the query parameters to filter based on the function used. + +#### filter + +Type: `(key, value) => boolean` + +A filter predicate that will be provided the name of each query parameter and its value. The `parseNumbers` and `parseBooleans` options also affect `value`. + +#### options + +Type: `object` + +[Parse options](#options) and [stringify options](#options-1). + ## Nesting This module intentionally doesn't support nesting as it's not spec'd and varies between implementations, which causes a lot of [edge cases](https://github.com/visionmedia/node-querystring/issues). diff --git a/test/exclude.js b/test/exclude.js new file mode 100644 index 0000000..91e0d4f --- /dev/null +++ b/test/exclude.js @@ -0,0 +1,17 @@ +import test from 'ava'; +import queryString from '..'; + +test('excludes elements in a URL with a filter array', t => { + t.is(queryString.exclude('http://example.com/?a=1&b=2&c=3#a', ['c']), 'http://example.com/?a=1&b=2#a'); +}); + +test('excludes elements in a URL with a filter predicate', t => { + t.is(queryString.exclude('http://example.com/?a=1&b=2&c=3#a', (name, value) => { + t.is(typeof name, 'string'); + t.is(typeof value, 'number'); + + return name === 'a'; + }, { + parseNumbers: true + }), 'http://example.com/?b=2&c=3#a'); +}); diff --git a/test/parse.js b/test/parse.js index 84a32e5..ebe0cfd 100644 --- a/test/parse.js +++ b/test/parse.js @@ -13,6 +13,11 @@ test('query strings starting with a `&`', t => { t.deepEqual(queryString.parse('&foo=bar&foo=baz'), {foo: ['bar', 'baz']}); }); +test('query strings ending with a `&`', t => { + t.deepEqual(queryString.parse('foo=bar&'), {foo: 'bar'}); + t.deepEqual(queryString.parse('foo=bar&&&'), {foo: 'bar'}); +}); + test('parse a query string', t => { t.deepEqual(queryString.parse('foo=bar'), {foo: 'bar'}); }); diff --git a/test/pick.js b/test/pick.js new file mode 100644 index 0000000..e5e4381 --- /dev/null +++ b/test/pick.js @@ -0,0 +1,17 @@ +import test from 'ava'; +import queryString from '..'; + +test('picks elements in a URL with a filter array', t => { + t.is(queryString.pick('http://example.com/?a=1&b=2&c=3#a', ['a', 'b']), 'http://example.com/?a=1&b=2#a'); +}); + +test('picks elements in a URL with a filter predicate', t => { + t.is(queryString.pick('http://example.com/?a=1&b=2&c=3#a', (name, value) => { + t.is(typeof name, 'string'); + t.is(typeof value, 'number'); + + return name === 'a'; + }, { + parseNumbers: true + }), 'http://example.com/?a=1#a'); +});