diff --git a/package-lock.json b/package-lock.json
index d68148a2a075fc..715088d2457a69 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18333,7 +18333,6 @@
"requires": {
"@babel/runtime": "^7.11.2",
"lodash": "^4.17.19",
- "qs": "^6.5.2",
"react-native-url-polyfill": "^1.1.2"
}
},
@@ -52241,7 +52240,8 @@
"qs": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz",
- "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA=="
+ "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==",
+ "dev": true
},
"query-string": {
"version": "4.3.4",
diff --git a/packages/url/README.md b/packages/url/README.md
index be964361ac1def..c0df7acf89e227 100644
--- a/packages/url/README.md
+++ b/packages/url/README.md
@@ -37,6 +37,36 @@ _Returns_
- `string`: URL with arguments applied.
+# **buildQueryString**
+
+Generates URL-encoded query string using input query data.
+
+It is intended to behave equivalent as PHP's `http_build_query`, configured
+with encoding type PHP_QUERY_RFC3986 (spaces as `%20`).
+
+_Usage_
+
+```js
+const queryString = buildQueryString( {
+ simple: 'is ok',
+ arrays: [ 'are', 'fine', 'too' ],
+ objects: {
+ evenNested: {
+ ok: 'yes',
+ },
+ },
+} );
+// "simple=is%20ok&arrays%5B0%5D=are&arrays%5B1%5D=fine&arrays%5B2%5D=too&objects%5BevenNested%5D%5Bok%5D=yes"
+```
+
+_Parameters_
+
+- _data_ `Record`: Data to encode.
+
+_Returns_
+
+- `string`: Query string.
+
# **cleanForSlug**
Performs some basic cleanup of a string for use as a post slug.
@@ -188,7 +218,27 @@ _Parameters_
_Returns_
-- `(QueryArgParsed|undefined)`: Query arg value.
+- `(QueryArgParsed|void)`: Query arg value.
+
+# **getQueryArgs**
+
+Returns an object of query arguments of the given URL. If the given URL is
+invalid or has no querystring, an empty object is returned.
+
+_Usage_
+
+```js
+const foo = getQueryArgs( 'https://wordpress.org?foo=bar&bar=baz' );
+// { "foo": "bar", "bar": "baz" }
+```
+
+_Parameters_
+
+- _url_ `string`: URL.
+
+_Returns_
+
+- `QueryArgs`: Query args object.
# **getQueryString**
diff --git a/packages/url/package.json b/packages/url/package.json
index 01d14070995d0c..b57a37eba4af01 100644
--- a/packages/url/package.json
+++ b/packages/url/package.json
@@ -26,7 +26,6 @@
"dependencies": {
"@babel/runtime": "^7.11.2",
"lodash": "^4.17.19",
- "qs": "^6.5.2",
"react-native-url-polyfill": "^1.1.2"
},
"publishConfig": {
diff --git a/packages/url/src/add-query-args.js b/packages/url/src/add-query-args.js
index 2d48c0be5a06b8..859785d44544a7 100644
--- a/packages/url/src/add-query-args.js
+++ b/packages/url/src/add-query-args.js
@@ -1,7 +1,8 @@
/**
- * External dependencies
+ * Internal dependencies
*/
-import { parse, stringify } from 'qs';
+import { getQueryArgs } from './get-query-args';
+import { buildQueryString } from './build-query-string';
/**
* Appends arguments as querystring to the provided URL. If the URL already
@@ -31,14 +32,11 @@ export function addQueryArgs( url = '', args ) {
const queryStringIndex = url.indexOf( '?' );
if ( queryStringIndex !== -1 ) {
// Merge into existing query arguments.
- args = Object.assign(
- parse( url.substr( queryStringIndex + 1 ) ),
- args
- );
+ args = Object.assign( getQueryArgs( url ), args );
// Change working base URL to omit previous query arguments.
baseUrl = baseUrl.substr( 0, queryStringIndex );
}
- return baseUrl + '?' + stringify( args );
+ return baseUrl + '?' + buildQueryString( args );
}
diff --git a/packages/url/src/build-query-string.js b/packages/url/src/build-query-string.js
new file mode 100644
index 00000000000000..51216a9f791d99
--- /dev/null
+++ b/packages/url/src/build-query-string.js
@@ -0,0 +1,61 @@
+/**
+ * Generates URL-encoded query string using input query data.
+ *
+ * It is intended to behave equivalent as PHP's `http_build_query`, configured
+ * with encoding type PHP_QUERY_RFC3986 (spaces as `%20`).
+ *
+ * @example
+ * ```js
+ * const queryString = buildQueryString( {
+ * simple: 'is ok',
+ * arrays: [ 'are', 'fine', 'too' ],
+ * objects: {
+ * evenNested: {
+ * ok: 'yes',
+ * },
+ * },
+ * } );
+ * // "simple=is%20ok&arrays%5B0%5D=are&arrays%5B1%5D=fine&arrays%5B2%5D=too&objects%5BevenNested%5D%5Bok%5D=yes"
+ * ```
+ *
+ * @param {Record} data Data to encode.
+ *
+ * @return {string} Query string.
+ */
+export function buildQueryString( data ) {
+ let string = '';
+
+ const stack = Array.from( Object.entries( data ) );
+
+ let pair;
+ while ( ( pair = stack.shift() ) ) {
+ let [ key, value ] = pair;
+
+ // Support building deeply nested data, from array or object values.
+ const hasNestedData =
+ Array.isArray( value ) || ( value && value.constructor === Object );
+
+ if ( hasNestedData ) {
+ // Push array or object values onto the stack as composed of their
+ // original key and nested index or key, retaining order by a
+ // combination of Array#reverse and Array#unshift onto the stack.
+ const valuePairs = Object.entries( value ).reverse();
+ for ( const [ member, memberValue ] of valuePairs ) {
+ stack.unshift( [ `${ key }[${ member }]`, memberValue ] );
+ }
+ } else if ( value !== undefined ) {
+ // Null is treated as special case, equivalent to empty string.
+ if ( value === null ) {
+ value = '';
+ }
+
+ string +=
+ '&' + [ key, value ].map( encodeURIComponent ).join( '=' );
+ }
+ }
+
+ // Loop will concatenate with leading `&`, but it's only expected for all
+ // but the first query parameter. This strips the leading `&`, while still
+ // accounting for the case that the string may in-fact be empty.
+ return string.substr( 1 );
+}
diff --git a/packages/url/src/get-query-arg.js b/packages/url/src/get-query-arg.js
index f6e184f0798ce3..d81a6249a1c0ae 100644
--- a/packages/url/src/get-query-arg.js
+++ b/packages/url/src/get-query-arg.js
@@ -1,7 +1,7 @@
/**
- * External dependencies
+ * Internal dependencies
*/
-import { parse } from 'qs';
+import { getQueryArgs } from './get-query-args';
/* eslint-disable jsdoc/valid-types */
/**
@@ -24,14 +24,8 @@ import { parse } from 'qs';
* const foo = getQueryArg( 'https://wordpress.org?foo=bar&bar=baz', 'foo' ); // bar
* ```
*
- * @return {QueryArgParsed|undefined} Query arg value.
+ * @return {QueryArgParsed|void} Query arg value.
*/
export function getQueryArg( url, arg ) {
- const queryStringIndex = url.indexOf( '?' );
- const query =
- queryStringIndex !== -1
- ? parse( url.substr( queryStringIndex + 1 ) )
- : {};
-
- return query[ arg ];
+ return getQueryArgs( url )[ arg ];
}
diff --git a/packages/url/src/get-query-args.js b/packages/url/src/get-query-args.js
new file mode 100644
index 00000000000000..273f7f380c8ef1
--- /dev/null
+++ b/packages/url/src/get-query-args.js
@@ -0,0 +1,94 @@
+/**
+ * Internal dependencies
+ */
+import { getQueryString } from './get-query-string';
+
+/** @typedef {import('./get-query-arg').QueryArgParsed} QueryArgParsed */
+
+/**
+ * @typedef {Record} QueryArgs
+ */
+
+/**
+ * Sets a value in object deeply by a given array of path segments. Mutates the
+ * object reference.
+ *
+ * @param {Record} object Object in which to assign.
+ * @param {string[]} path Path segment at which to set value.
+ * @param {*} value Value to set.
+ */
+function setPath( object, path, value ) {
+ const length = path.length;
+ const lastIndex = length - 1;
+ for ( let i = 0; i < length; i++ ) {
+ let key = path[ i ];
+
+ if ( ! key && Array.isArray( object ) ) {
+ // If key is empty string and next value is array, derive key from
+ // the current length of the array.
+ key = object.length.toString();
+ }
+
+ // If the next key in the path is numeric (or empty string), it will be
+ // created as an array. Otherwise, it will be created as an object.
+ const isNextKeyArrayIndex = ! isNaN( Number( path[ i + 1 ] ) );
+
+ object[ key ] =
+ i === lastIndex
+ ? // If at end of path, assign the intended value.
+ value
+ : // Otherwise, advance to the next object in the path, creating
+ // it if it does not yet exist.
+ object[ key ] || ( isNextKeyArrayIndex ? [] : {} );
+
+ if ( Array.isArray( object[ key ] ) && ! isNextKeyArrayIndex ) {
+ // If we current key is non-numeric, but the next value is an
+ // array, coerce the value to an object.
+ object[ key ] = { ...object[ key ] };
+ }
+
+ // Update working reference object to the next in the path.
+ object = object[ key ];
+ }
+}
+
+/**
+ * Returns an object of query arguments of the given URL. If the given URL is
+ * invalid or has no querystring, an empty object is returned.
+ *
+ * @param {string} url URL.
+ *
+ * @example
+ * ```js
+ * const foo = getQueryArgs( 'https://wordpress.org?foo=bar&bar=baz' );
+ * // { "foo": "bar", "bar": "baz" }
+ * ```
+ *
+ * @return {QueryArgs} Query args object.
+ */
+export function getQueryArgs( url ) {
+ return (
+ ( getQueryString( url ) || '' )
+ // Normalize space encoding, accounting for PHP URL encoding
+ // corresponding to `application/x-www-form-urlencoded`.
+ //
+ // See: https://tools.ietf.org/html/rfc1866#section-8.2.1
+ .replace( /\+/g, '%20' )
+ .split( '&' )
+ .reduce( ( accumulator, keyValue ) => {
+ const [ key, value = '' ] = keyValue
+ .split( '=' )
+ // Filtering avoids decoding as `undefined` for value, where
+ // default is restored in destructuring assignment.
+ .filter( Boolean )
+ .map( decodeURIComponent );
+
+ if ( key ) {
+ const segments = key.replace( /\]/g, '' ).split( '[' );
+ setPath( accumulator, segments, value );
+ }
+
+ return accumulator;
+ }, {} )
+ );
+}
diff --git a/packages/url/src/get-query-string.js b/packages/url/src/get-query-string.js
index e1437f4e78a131..8624ce5c6c8b9d 100644
--- a/packages/url/src/get-query-string.js
+++ b/packages/url/src/get-query-string.js
@@ -13,7 +13,7 @@
export function getQueryString( url ) {
let query;
try {
- query = new URL( url ).search.substring( 1 );
+ query = new URL( url, 'http://example.com' ).search.substring( 1 );
} catch ( error ) {}
if ( query ) {
diff --git a/packages/url/src/index.js b/packages/url/src/index.js
index 5175803dfb1e9e..f060ae8152897d 100644
--- a/packages/url/src/index.js
+++ b/packages/url/src/index.js
@@ -7,12 +7,14 @@ export { isValidAuthority } from './is-valid-authority';
export { getPath } from './get-path';
export { isValidPath } from './is-valid-path';
export { getQueryString } from './get-query-string';
+export { buildQueryString } from './build-query-string';
export { isValidQueryString } from './is-valid-query-string';
export { getPathAndQueryString } from './get-path-and-query-string';
export { getFragment } from './get-fragment';
export { isValidFragment } from './is-valid-fragment';
export { addQueryArgs } from './add-query-args';
export { getQueryArg } from './get-query-arg';
+export { getQueryArgs } from './get-query-args';
export { hasQueryArg } from './has-query-arg';
export { removeQueryArgs } from './remove-query-args';
export { prependHTTP } from './prepend-http';
diff --git a/packages/url/src/remove-query-args.js b/packages/url/src/remove-query-args.js
index 796e46e0ab980c..8d2ac9757ae305 100644
--- a/packages/url/src/remove-query-args.js
+++ b/packages/url/src/remove-query-args.js
@@ -1,7 +1,8 @@
/**
- * External dependencies
+ * Internal dependencies
*/
-import { parse, stringify } from 'qs';
+import { getQueryArgs } from './get-query-args';
+import { buildQueryString } from './build-query-string';
/**
* Removes arguments from the query string of the url
@@ -18,14 +19,12 @@ import { parse, stringify } from 'qs';
*/
export function removeQueryArgs( url, ...args ) {
const queryStringIndex = url.indexOf( '?' );
- const query =
- queryStringIndex !== -1
- ? parse( url.substr( queryStringIndex + 1 ) )
- : {};
- const baseUrl =
- queryStringIndex !== -1 ? url.substr( 0, queryStringIndex ) : url;
+ if ( queryStringIndex === -1 ) {
+ return url;
+ }
+ const query = getQueryArgs( url );
+ const baseURL = url.substr( 0, queryStringIndex );
args.forEach( ( arg ) => delete query[ arg ] );
-
- return baseUrl + '?' + stringify( query );
+ return baseURL + '?' + buildQueryString( query );
}
diff --git a/packages/url/src/test/index.test.js b/packages/url/src/test/index.test.js
index 9699e24ec0a067..ff256fd5784ea2 100644
--- a/packages/url/src/test/index.test.js
+++ b/packages/url/src/test/index.test.js
@@ -16,6 +16,7 @@ import {
getPath,
isValidPath,
getQueryString,
+ buildQueryString,
isValidQueryString,
getFragment,
isValidFragment,
@@ -27,6 +28,7 @@ import {
safeDecodeURI,
filterURLForDisplay,
cleanForSlug,
+ getQueryArgs,
} from '../';
import wptData from './fixtures/wpt-data';
@@ -288,6 +290,14 @@ describe( 'getQueryString', () => {
).toBe( 'foo=bar&foo=baz?test' );
} );
+ it( 'returns the query string of a path', () => {
+ expect( getQueryString( '/wp-json/wp/v2/posts?type=page' ) ).toBe(
+ 'type=page'
+ );
+
+ expect( getQueryString( '/wp-json/wp/v2/posts' ) ).toBeUndefined();
+ } );
+
it( 'returns undefined when the provided does not contain a url query string', () => {
expect( getQueryString( '' ) ).toBeUndefined();
expect(
@@ -313,6 +323,56 @@ describe( 'getQueryString', () => {
} );
} );
+describe( 'buildQueryString', () => {
+ it( 'builds simple strings', () => {
+ const data = {
+ foo: 'bar',
+ baz: 'boom',
+ cow: 'milk',
+ php: 'hypertext processor',
+ };
+
+ expect( buildQueryString( data ) ).toBe(
+ 'foo=bar&baz=boom&cow=milk&php=hypertext%20processor'
+ );
+ } );
+
+ it( 'builds complex data', () => {
+ const data = {
+ user: {
+ name: 'Bob Smith',
+ age: 47,
+ sex: 'M',
+ dob: '5/12/1956',
+ },
+ pastimes: [ 'golf', 'opera', 'poker', 'rap' ],
+ children: {
+ bobby: { age: 12, sex: 'M' },
+ sally: { age: 8, sex: 'F' },
+ },
+ };
+
+ expect( buildQueryString( data ) ).toBe(
+ 'user%5Bname%5D=Bob%20Smith&user%5Bage%5D=47&user%5Bsex%5D=M&user%5Bdob%5D=5%2F12%2F1956&pastimes%5B0%5D=golf&pastimes%5B1%5D=opera&pastimes%5B2%5D=poker&pastimes%5B3%5D=rap&children%5Bbobby%5D%5Bage%5D=12&children%5Bbobby%5D%5Bsex%5D=M&children%5Bsally%5D%5Bage%5D=8&children%5Bsally%5D%5Bsex%5D=F'
+ );
+ } );
+
+ it( 'builds falsey values', () => {
+ const data = {
+ empty: '',
+ null: null,
+ undefined,
+ zero: 0,
+ };
+
+ expect( buildQueryString( data ) ).toBe( 'empty=&null=&zero=0' );
+ } );
+
+ it( 'builds an empty object as an empty string', () => {
+ expect( buildQueryString( {} ) ).toBe( '' );
+ } );
+} );
+
describe( 'isValidQueryString', () => {
it( 'returns true if the query string is valid', () => {
expect( isValidQueryString( 'test' ) ).toBe( true );
@@ -520,6 +580,116 @@ describe( 'addQueryArgs', () => {
} );
} );
+describe( 'getQueryArgs', () => {
+ it( 'should parse simple query arguments', () => {
+ const url = 'https://andalouses.example/beach?foo=bar&baz=quux';
+
+ expect( getQueryArgs( url ) ).toEqual( {
+ foo: 'bar',
+ baz: 'quux',
+ } );
+ } );
+
+ it( 'should accumulate array of values', () => {
+ const url =
+ 'https://andalouses.example/beach?foo[]=zero&foo[]=one&foo[]=two';
+
+ expect( getQueryArgs( url ) ).toEqual( {
+ foo: [ 'zero', 'one', 'two' ],
+ } );
+ } );
+
+ it( 'should accumulate keyed array of values', () => {
+ const url =
+ 'https://andalouses.example/beach?foo[1]=one&foo[0]=zero&foo[]=two';
+
+ expect( getQueryArgs( url ) ).toEqual( {
+ foo: [ 'zero', 'one', 'two' ],
+ } );
+ } );
+
+ it( 'should accumulate object of values', () => {
+ const url =
+ 'https://andalouses.example/beach?foo[zero]=0&foo[one]=1&foo[]=empty';
+
+ expect( getQueryArgs( url ) ).toEqual( {
+ foo: {
+ '': 'empty',
+ zero: '0',
+ one: '1',
+ },
+ } );
+ } );
+
+ it( 'normalizes mixed numeric and named keys', () => {
+ const url = 'https://andalouses.example/beach?foo[0]=0&foo[one]=1';
+
+ expect( getQueryArgs( url ) ).toEqual( {
+ foo: {
+ '0': '0',
+ one: '1',
+ },
+ } );
+ } );
+
+ it( 'should return empty object for URL without querystring', () => {
+ const urlWithoutQuerystring = 'https://andalouses.example/beach';
+ const urlWithEmptyQuerystring = 'https://andalouses.example/beach?';
+ const invalidURL = 'example';
+
+ expect( getQueryArgs( invalidURL ) ).toEqual( {} );
+ expect( getQueryArgs( urlWithoutQuerystring ) ).toEqual( {} );
+ expect( getQueryArgs( urlWithEmptyQuerystring ) ).toEqual( {} );
+ } );
+
+ it( 'should gracefully handle empty keys and values', () => {
+ const url = 'https://andalouses.example/beach?&foo';
+
+ expect( getQueryArgs( url ) ).toEqual( {
+ foo: '',
+ } );
+ } );
+
+ describe( 'reverses buildQueryString', () => {
+ it( 'unbuilds simple strings', () => {
+ const data = {
+ foo: 'bar',
+ baz: 'boom',
+ cow: 'milk',
+ php: 'hypertext processor',
+ };
+
+ expect(
+ getQueryArgs(
+ 'https://example.com/?foo=bar&baz=boom&cow=milk&php=hypertext%20processor'
+ )
+ ).toEqual( data );
+ } );
+
+ it( 'unbuilds complex data, with stringified values', () => {
+ const data = {
+ user: {
+ name: 'Bob Smith',
+ age: '47',
+ sex: 'M',
+ dob: '5/12/1956',
+ },
+ pastimes: [ 'golf', 'opera', 'poker', 'rap' ],
+ children: {
+ bobby: { age: '12', sex: 'M' },
+ sally: { age: '8', sex: 'F' },
+ },
+ };
+
+ expect(
+ getQueryArgs(
+ 'https://example.com/?user%5Bname%5D=Bob%20Smith&user%5Bage%5D=47&user%5Bsex%5D=M&user%5Bdob%5D=5%2F12%2F1956&pastimes%5B0%5D=golf&pastimes%5B1%5D=opera&pastimes%5B2%5D=poker&pastimes%5B3%5D=rap&children%5Bbobby%5D%5Bage%5D=12&children%5Bbobby%5D%5Bsex%5D=M&children%5Bsally%5D%5Bage%5D=8&children%5Bsally%5D%5Bsex%5D=F'
+ )
+ ).toEqual( data );
+ } );
+ } );
+} );
+
describe( 'getQueryArg', () => {
it( 'should get the value of an existing query arg', () => {
const url = 'https://andalouses.example/beach?foo=bar&bar=baz';
@@ -543,6 +713,7 @@ describe( 'getQueryArg', () => {
const url = 'https://andalouses.example/beach?foo=bar&bar=baz#foo';
expect( getQueryArg( url, 'foo' ) ).toEqual( 'bar' );
+ expect( getQueryArg( url, 'bar' ) ).toEqual( 'baz' );
} );
} );
@@ -567,6 +738,12 @@ describe( 'hasQueryArg', () => {
} );
describe( 'removeQueryArgs', () => {
+ it( 'should not change URL without a querystring', () => {
+ const url = 'https://andalouses.example/beach';
+
+ expect( removeQueryArgs( url, 'baz', 'test' ) ).toEqual( url );
+ } );
+
it( 'should not change URL not containing query args', () => {
const url = 'https://andalouses.example/beach?foo=bar&bar=baz';
diff --git a/tsconfig.base.json b/tsconfig.base.json
index fbaf60c3fdd529..495796402bdf85 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -37,8 +37,8 @@
"**/*.ios.js",
"**/*.native.js",
"**/benchmark",
- "**/build-*/**",
- "**/build/**",
+ "packages/*/build-*/**",
+ "packages/*/build/**",
"**/test/**",
"packages/**/react-native-*/**"
]