Skip to content

Commit 2f2536e

Browse files
committed
Add emptyQueryValue option
Fixes #194
1 parent b7fbc1a commit 2f2536e

4 files changed

Lines changed: 326 additions & 22 deletions

File tree

index.d.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ export type Options = {
147147
/**
148148
Removes query parameters that matches any of the provided strings or regexes.
149149
150+
Global and sticky regex flags are stripped.
151+
150152
@default [/^utm_\w+/i]
151153
152154
@example
@@ -182,6 +184,8 @@ export type Options = {
182184
183185
__Note__: It overrides the `removeQueryParameters` option.
184186
187+
Global and sticky regex flags are stripped.
188+
185189
@default undefined
186190
187191
@example
@@ -233,8 +237,11 @@ export type Options = {
233237

234238
/**
235239
Removes the default directory index file from path that matches any of the provided strings or regexes.
240+
236241
When `true`, the regex `/^index\.[a-z]+$/` is used.
237242
243+
Global and sticky regex flags are stripped.
244+
238245
@default false
239246
240247
@example
@@ -279,6 +286,26 @@ export type Options = {
279286
*/
280287
readonly sortQueryParameters?: boolean;
281288

289+
/**
290+
Controls how query parameters with empty values are formatted.
291+
292+
- `'preserve'` - Keep the original format (`?key` stays `?key`, `?key=` stays `?key=`). If the same key appears with both formats (`?a&a=`), all instances will use the format without `=`.
293+
- `'always'` - Always include `=` for empty values (`?key` becomes `?key=`)
294+
- `'never'` - Never include `=` for empty values (`?key=` becomes `?key`)
295+
296+
@default 'preserve'
297+
298+
@example
299+
```
300+
normalizeUrl('www.sindresorhus.com?a&b=', {emptyQueryValue: 'always'});
301+
//=> 'http://sindresorhus.com/?a=&b='
302+
303+
normalizeUrl('www.sindresorhus.com?a&b=', {emptyQueryValue: 'never'});
304+
//=> 'http://sindresorhus.com/?a&b'
305+
```
306+
*/
307+
readonly emptyQueryValue?: 'preserve' | 'always' | 'never';
308+
282309
/**
283310
Removes the entire URL path, leaving only the domain.
284311

index.js

Lines changed: 102 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,17 @@
22
const DATA_URL_DEFAULT_MIME_TYPE = 'text/plain';
33
const DATA_URL_DEFAULT_CHARSET = 'us-ascii';
44

5-
const testParameter = (name, filters) => filters.some(filter => filter instanceof RegExp ? filter.test(name) : filter === name);
5+
const testParameter = (name, filters) => Array.isArray(filters) && filters.some(filter => {
6+
if (filter instanceof RegExp) {
7+
if (filter.flags.includes('g') || filter.flags.includes('y')) {
8+
return new RegExp(filter.source, filter.flags.replace(/[gy]/g, '')).test(name);
9+
}
10+
11+
return filter.test(name);
12+
}
13+
14+
return filter === name;
15+
});
616

717
const supportedProtocols = new Set([
818
'https:',
@@ -22,6 +32,76 @@ const hasCustomProtocol = urlString => {
2232
}
2333
};
2434

35+
const decodeQueryKey = value => {
36+
try {
37+
return decodeURIComponent(value.replace(/\+/g, '%20'));
38+
} catch {
39+
// Match URLSearchParams behavior for malformed percent-encoding.
40+
return new URLSearchParams(`${value}=`).keys().next().value;
41+
}
42+
};
43+
44+
const getKeysWithoutEquals = search => {
45+
const keys = new Set();
46+
if (!search) {
47+
return keys;
48+
}
49+
50+
for (const part of search.slice(1).split('&')) {
51+
if (part && !part.includes('=')) {
52+
keys.add(decodeQueryKey(part));
53+
}
54+
}
55+
56+
return keys;
57+
};
58+
59+
const normalizeEmptyQueryParameters = (search, emptyQueryValue, originalSearch) => {
60+
const isAlways = emptyQueryValue === 'always';
61+
const isNever = emptyQueryValue === 'never';
62+
const keysWithoutEquals = (isAlways || isNever) ? undefined : getKeysWithoutEquals(originalSearch);
63+
64+
const normalizeKey = key => key.replace(/\+/g, '%20');
65+
const formatEmptyValue = normalizedKey => {
66+
if (isAlways) {
67+
return `${normalizedKey}=`;
68+
}
69+
70+
if (isNever) {
71+
return normalizedKey;
72+
}
73+
74+
return keysWithoutEquals.has(decodeQueryKey(normalizedKey)) ? normalizedKey : `${normalizedKey}=`;
75+
};
76+
77+
const normalizeParam = param => {
78+
const equalIndex = param.indexOf('=');
79+
80+
if (equalIndex === -1) {
81+
// Normalize + to %20 (+ means space in query strings)
82+
return formatEmptyValue(normalizeKey(param));
83+
}
84+
85+
const key = param.slice(0, equalIndex);
86+
const value = param.slice(equalIndex + 1);
87+
88+
if (value === '') {
89+
if (key === '') {
90+
return '=';
91+
}
92+
93+
// Normalize + to %20 (+ means space in query strings)
94+
return formatEmptyValue(normalizeKey(key));
95+
}
96+
97+
// Normalize + to %20 in key, decode %3D to = in values (= is safe unencoded in query values)
98+
return `${normalizeKey(key)}=${value.replace(/%3D/gi, '=')}`;
99+
};
100+
101+
const params = search.slice(1).split('&').filter(Boolean);
102+
return params.length === 0 ? '' : `?${params.map(normalizeParam).join('&')}`;
103+
};
104+
25105
const normalizeDataURL = (urlString, {stripHash}) => {
26106
const match = /^data:(?<type>[^,]*?),(?<data>[^#]*?)(?:#(?<hash>.*))?$/.exec(urlString);
27107

@@ -38,7 +118,7 @@ const normalizeDataURL = (urlString, {stripHash}) => {
38118
}
39119

40120
// Lowercase MIME type
41-
const mimeType = mediaType.shift()?.toLowerCase() ?? '';
121+
const mimeType = mediaType.shift().toLowerCase();
42122
const attributes = mediaType
43123
.map(attribute => {
44124
let [key, value = ''] = attribute.split('=').map(string => string.trim());
@@ -88,6 +168,7 @@ export default function normalizeUrl(urlString, options) {
88168
sortQueryParameters: true,
89169
removePath: false,
90170
transformPath: false,
171+
emptyQueryValue: 'preserve',
91172
...options,
92173
};
93174

@@ -225,49 +306,49 @@ export default function normalizeUrl(urlString, options) {
225306
}
226307
}
227308

309+
// Capture original query params format before any searchParams modifications
310+
const originalSearch = urlObject.search;
311+
const hasKeepQueryParameters = Array.isArray(options.keepQueryParameters);
312+
const searchParams = urlObject.searchParams;
313+
228314
// Remove query unwanted parameters
229-
if (Array.isArray(options.removeQueryParameters)) {
315+
if (!hasKeepQueryParameters && Array.isArray(options.removeQueryParameters) && options.removeQueryParameters.length > 0) {
230316
// eslint-disable-next-line unicorn/no-useless-spread -- We are intentionally spreading to get a copy.
231-
for (const key of [...urlObject.searchParams.keys()]) {
317+
for (const key of [...searchParams.keys()]) {
232318
if (testParameter(key, options.removeQueryParameters)) {
233-
urlObject.searchParams.delete(key);
319+
searchParams.delete(key);
234320
}
235321
}
236322
}
237323

238-
if (!Array.isArray(options.keepQueryParameters) && options.removeQueryParameters === true) {
324+
if (!hasKeepQueryParameters && options.removeQueryParameters === true) {
239325
urlObject.search = '';
240326
}
241327

242328
// Keep wanted query parameters
243-
if (Array.isArray(options.keepQueryParameters) && options.keepQueryParameters.length > 0) {
329+
if (hasKeepQueryParameters && options.keepQueryParameters.length > 0) {
244330
// eslint-disable-next-line unicorn/no-useless-spread -- We are intentionally spreading to get a copy.
245-
for (const key of [...urlObject.searchParams.keys()]) {
331+
for (const key of [...searchParams.keys()]) {
246332
if (!testParameter(key, options.keepQueryParameters)) {
247-
urlObject.searchParams.delete(key);
333+
searchParams.delete(key);
248334
}
249335
}
336+
} else if (hasKeepQueryParameters) {
337+
urlObject.search = '';
250338
}
251339

252340
// Sort query parameters
253341
if (options.sortQueryParameters) {
254-
const originalSearch = urlObject.search;
255342
urlObject.searchParams.sort();
256343

257344
// Calling `.sort()` encodes the search parameters, so we need to decode them again.
258-
try {
259-
urlObject.search = decodeURIComponent(urlObject.search);
260-
} catch {}
261-
262-
// Fix parameters that originally had no equals sign but got one added by URLSearchParams
263-
const partsWithoutEquals = originalSearch.slice(1).split('&').filter(p => p && !p.includes('='));
264-
for (const part of partsWithoutEquals) {
265-
const decoded = decodeURIComponent(part);
266-
// Only replace at word boundaries to avoid partial matches
267-
urlObject.search = urlObject.search.replace(`?${decoded}=`, `?${decoded}`).replace(`&${decoded}=`, `&${decoded}`);
268-
}
345+
// Protect &=%#? %25 and %2B from decoding (would break URL structure or change meaning) by double-encoding them first.
346+
urlObject.search = decodeURIComponent(urlObject.search.replace(/%(?:26|3D|23|3F|25|2B)/gi, match => `%25${match.slice(1)}`));
269347
}
270348

349+
// Normalize empty query parameter values
350+
urlObject.search = normalizeEmptyQueryParameters(urlObject.search, options.emptyQueryValue, originalSearch);
351+
271352
if (options.removeTrailingSlash) {
272353
urlObject.pathname = urlObject.pathname.replace(/\/$/, '');
273354
}

readme.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,8 @@ Default: `[/^utm_\w+/i]`
187187

188188
Remove query parameters that matches any of the provided strings or regexes.
189189

190+
Global and sticky regex flags are stripped.
191+
190192
```js
191193
normalizeUrl('www.sindresorhus.com?foo=bar&ref=test_ref', {
192194
removeQueryParameters: ['ref']
@@ -221,6 +223,8 @@ Keeps only query parameters that matches any of the provided strings or regexes.
221223

222224
**Note:** It overrides the `removeQueryParameters` option.
223225

226+
Global and sticky regex flags are stripped.
227+
224228
```js
225229
normalizeUrl('https://sindresorhus.com?foo=bar&ref=unicorn', {
226230
keepQueryParameters: ['ref']
@@ -268,7 +272,11 @@ normalizeUrl('https://sindresorhus.com/', {removeSingleSlash: false});
268272
Type: `boolean | Array<RegExp | string>`\
269273
Default: `false`
270274

271-
Removes the default directory index file from path that matches any of the provided strings or regexes. When `true`, the regex `/^index\.[a-z]+$/` is used.
275+
Removes the default directory index file from path that matches any of the provided strings or regexes.
276+
277+
When `true`, the regex `/^index\.[a-z]+$/` is used.
278+
279+
Global and sticky regex flags are stripped.
272280

273281
```js
274282
normalizeUrl('www.sindresorhus.com/foo/default.php', {
@@ -307,6 +315,26 @@ normalizeUrl('www.sindresorhus.com?b=two&a=one&c=three', {
307315
//=> 'http://sindresorhus.com/?b=two&a=one&c=three'
308316
```
309317

318+
##### emptyQueryValue
319+
320+
Type: `string`\
321+
Default: `'preserve'`\
322+
Values: `'preserve' | 'always' | 'never'`
323+
324+
Controls how query parameters with empty values are formatted.
325+
326+
- `'preserve'` - Keep the original format (`?key` stays `?key`, `?key=` stays `?key=`). If the same key appears with both formats (`?a&a=`), all instances will use the format without `=`.
327+
- `'always'` - Always include `=` for empty values (`?key` becomes `?key=`)
328+
- `'never'` - Never include `=` for empty values (`?key=` becomes `?key`)
329+
330+
```js
331+
normalizeUrl('www.sindresorhus.com?a&b=', {emptyQueryValue: 'always'});
332+
//=> 'http://sindresorhus.com/?a=&b='
333+
334+
normalizeUrl('www.sindresorhus.com?a&b=', {emptyQueryValue: 'never'});
335+
//=> 'http://sindresorhus.com/?a&b'
336+
```
337+
310338
##### removePath
311339

312340
Type: `boolean`\

0 commit comments

Comments
 (0)