normalize-url 8.1.1 → 9.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/index.d.ts +48 -1
  2. package/index.js +213 -34
  3. package/package.json +6 -8
  4. package/readme.md +51 -3
package/index.d.ts CHANGED
@@ -4,6 +4,26 @@ export type Options = {
4
4
  */
5
5
  readonly defaultProtocol?: 'https' | 'http';
6
6
 
7
+ /**
8
+ Protocols to normalize in addition to the built-in ones (`https`, `http`, `file`, `data`).
9
+
10
+ Useful for HTTP-like custom protocols such as Electron schemes or app-specific protocols.
11
+
12
+ The protocols should be specified without `:`.
13
+
14
+ @default undefined
15
+
16
+ @example
17
+ ```
18
+ normalizeUrl('sindre://www.sorhus.com', {customProtocols: ['sindre']});
19
+ //=> 'sindre://sorhus.com'
20
+
21
+ normalizeUrl('sindre://www.sorhus.com/foo/', {customProtocols: ['sindre']});
22
+ //=> 'sindre://sorhus.com/foo'
23
+ ```
24
+ */
25
+ readonly customProtocols?: readonly string[];
26
+
7
27
  /**
8
28
  Prepends `defaultProtocol` to the URL if it's protocol-relative.
9
29
 
@@ -147,6 +167,8 @@ export type Options = {
147
167
  /**
148
168
  Removes query parameters that matches any of the provided strings or regexes.
149
169
 
170
+ Global and sticky regex flags are stripped.
171
+
150
172
  @default [/^utm_\w+/i]
151
173
 
152
174
  @example
@@ -182,6 +204,8 @@ export type Options = {
182
204
 
183
205
  __Note__: It overrides the `removeQueryParameters` option.
184
206
 
207
+ Global and sticky regex flags are stripped.
208
+
185
209
  @default undefined
186
210
 
187
211
  @example
@@ -233,8 +257,11 @@ export type Options = {
233
257
 
234
258
  /**
235
259
  Removes the default directory index file from path that matches any of the provided strings or regexes.
260
+
236
261
  When `true`, the regex `/^index\.[a-z]+$/` is used.
237
262
 
263
+ Global and sticky regex flags are stripped.
264
+
238
265
  @default false
239
266
 
240
267
  @example
@@ -279,6 +306,26 @@ export type Options = {
279
306
  */
280
307
  readonly sortQueryParameters?: boolean;
281
308
 
309
+ /**
310
+ Controls how query parameters with empty values are formatted.
311
+
312
+ - `'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 `=`.
313
+ - `'always'` - Always include `=` for empty values (`?key` becomes `?key=`)
314
+ - `'never'` - Never include `=` for empty values (`?key=` becomes `?key`)
315
+
316
+ @default 'preserve'
317
+
318
+ @example
319
+ ```
320
+ normalizeUrl('www.sindresorhus.com?a&b=', {emptyQueryValue: 'always'});
321
+ //=> 'http://sindresorhus.com/?a=&b='
322
+
323
+ normalizeUrl('www.sindresorhus.com?a&b=', {emptyQueryValue: 'never'});
324
+ //=> 'http://sindresorhus.com/?a&b'
325
+ ```
326
+ */
327
+ readonly emptyQueryValue?: 'preserve' | 'always' | 'never';
328
+
282
329
  /**
283
330
  Removes the entire URL path, leaving only the domain.
284
331
 
@@ -322,7 +369,7 @@ export type Options = {
322
369
  /**
323
370
  [Normalize](https://en.wikipedia.org/wiki/URL_normalization) a URL.
324
371
 
325
- URLs with custom protocols are not normalized and just passed through by default. Supported protocols are: `https`, `http`, `file`, and `data`.
372
+ URLs with custom protocols are not normalized and just passed through by default. Supported protocols are: `https`, `http`, `file`, and `data`. Use the `customProtocols` option to add support for additional protocols.
326
373
 
327
374
  Human-friendly URLs with basic auth (for example, `user:password@sindresorhus.com`) are not handled because basic auth conflicts with custom protocols. [Basic auth URLs are also deprecated.](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#access_using_credentials_in_the_url)
328
375
 
package/index.js CHANGED
@@ -2,7 +2,23 @@
2
2
  const DATA_URL_DEFAULT_MIME_TYPE = 'text/plain';
3
3
  const DATA_URL_DEFAULT_CHARSET = 'us-ascii';
4
4
 
5
- const testParameter = (name, filters) => filters.some(filter => filter instanceof RegExp ? filter.test(name) : filter === name);
5
+ const encodedReservedCharactersPattern = '%(?:3A|2F|3F|23|5B|5D|40|21|24|26|27|28|29|2A|2B|2C|3B|3D)';
6
+ const temporaryEncodedReservedTokenBase = '__normalize_url_encoded_reserved__';
7
+ const temporaryEncodedReservedTokenPattern = /__normalize_url_encoded_reserved__(\d+)__/g;
8
+ const hasEncodedReservedCharactersRegex = new RegExp(encodedReservedCharactersPattern, 'i');
9
+ const encodedReservedCharactersRegex = new RegExp(encodedReservedCharactersPattern, 'gi');
10
+
11
+ const testParameter = (name, filters) => Array.isArray(filters) && filters.some(filter => {
12
+ if (filter instanceof RegExp) {
13
+ if (filter.flags.includes('g') || filter.flags.includes('y')) {
14
+ return new RegExp(filter.source, filter.flags.replaceAll(/[gy]/g, '')).test(name);
15
+ }
16
+
17
+ return filter.test(name);
18
+ }
19
+
20
+ return filter === name;
21
+ });
6
22
 
7
23
  const supportedProtocols = new Set([
8
24
  'https:',
@@ -10,16 +26,160 @@ const supportedProtocols = new Set([
10
26
  'file:',
11
27
  ]);
12
28
 
13
- const hasCustomProtocol = urlString => {
29
+ const normalizeCustomProtocolOption = protocol => {
30
+ if (typeof protocol !== 'string') {
31
+ return undefined;
32
+ }
33
+
34
+ const normalizedProtocol = protocol.trim().toLowerCase().replace(/:$/, '');
35
+ return normalizedProtocol === '' ? undefined : `${normalizedProtocol}:`;
36
+ };
37
+
38
+ const getCustomProtocol = urlString => {
14
39
  try {
15
40
  const {protocol} = new URL(urlString);
41
+ const hasAuthority = urlString.slice(0, protocol.length + 2).toLowerCase() === `${protocol}//`;
42
+
43
+ // Avoid treating "localhost:port" (e.g. "localhost:9802") as a custom protocol.
44
+ if (protocol === 'localhost:' && !hasAuthority && /^\d{1,5}([/?#]|$)/.test(urlString.slice(protocol.length))) {
45
+ return undefined;
46
+ }
47
+
48
+ if (protocol.endsWith(':')
49
+ && (!protocol.includes('.') || hasAuthority)
50
+ && !supportedProtocols.has(protocol)) {
51
+ return protocol;
52
+ }
53
+ } catch {}
54
+
55
+ return undefined;
56
+ };
57
+
58
+ const decodeQueryKey = value => {
59
+ try {
60
+ return decodeURIComponent(value.replaceAll('+', '%20'));
61
+ } catch {
62
+ // Match URLSearchParams behavior for malformed percent-encoding.
63
+ return new URLSearchParams(`${value}=`).keys().next().value;
64
+ }
65
+ };
16
66
 
17
- return protocol.endsWith(':')
18
- && !protocol.includes('.')
19
- && !supportedProtocols.has(protocol);
67
+ const getKeysWithoutEquals = search => {
68
+ const keys = new Set();
69
+ if (!search) {
70
+ return keys;
71
+ }
72
+
73
+ for (const part of search.slice(1).split('&')) {
74
+ if (part && !part.includes('=')) {
75
+ keys.add(decodeQueryKey(part));
76
+ }
77
+ }
78
+
79
+ return keys;
80
+ };
81
+
82
+ const getTemporaryEncodedReservedTokenPrefix = search => {
83
+ let decodedSearch = search;
84
+
85
+ try {
86
+ decodedSearch = decodeURIComponent(search);
20
87
  } catch {
21
- return false;
88
+ decodedSearch = new URLSearchParams(search).toString();
22
89
  }
90
+
91
+ const getUsedTokenIndexes = value => {
92
+ const indexes = new Set();
93
+
94
+ for (const match of value.matchAll(temporaryEncodedReservedTokenPattern)) {
95
+ indexes.add(Number.parseInt(match[1], 10));
96
+ }
97
+
98
+ return indexes;
99
+ };
100
+
101
+ const usedTokenIndexes = getUsedTokenIndexes(search);
102
+ for (const tokenIndex of getUsedTokenIndexes(decodedSearch)) {
103
+ usedTokenIndexes.add(tokenIndex);
104
+ }
105
+
106
+ let tokenIndex = 0;
107
+ while (usedTokenIndexes.has(tokenIndex)) {
108
+ tokenIndex++;
109
+ }
110
+
111
+ return `${temporaryEncodedReservedTokenBase}${tokenIndex}__`;
112
+ };
113
+
114
+ const sortSearchParameters = (searchParameters, encodedReservedTokenRegex) => {
115
+ if (!encodedReservedTokenRegex) {
116
+ searchParameters.sort();
117
+ return searchParameters.toString();
118
+ }
119
+
120
+ const getSortableKey = key => key.replace(encodedReservedTokenRegex, (_, hexCode) => String.fromCodePoint(Number.parseInt(hexCode, 16)));
121
+ const entries = [...searchParameters.entries()];
122
+ entries.sort(([leftKey], [rightKey]) => {
123
+ const left = getSortableKey(leftKey);
124
+ const right = getSortableKey(rightKey);
125
+ return left < right ? -1 : (left > right ? 1 : 0);
126
+ });
127
+
128
+ return new URLSearchParams(entries).toString();
129
+ };
130
+
131
+ const decodeReservedTokens = (value, encodedReservedTokenRegex) => {
132
+ if (!encodedReservedTokenRegex) {
133
+ return value;
134
+ }
135
+
136
+ return value.replace(encodedReservedTokenRegex, (_, hexCode) => String.fromCodePoint(Number.parseInt(hexCode, 16)));
137
+ };
138
+
139
+ const normalizeEmptyQueryParameters = (search, emptyQueryValue, originalSearch) => {
140
+ const isAlways = emptyQueryValue === 'always';
141
+ const isNever = emptyQueryValue === 'never';
142
+ const keysWithoutEquals = (isAlways || isNever) ? undefined : getKeysWithoutEquals(originalSearch);
143
+
144
+ const normalizeKey = key => key.replaceAll('+', '%20');
145
+ const formatEmptyValue = normalizedKey => {
146
+ if (isAlways) {
147
+ return `${normalizedKey}=`;
148
+ }
149
+
150
+ if (isNever) {
151
+ return normalizedKey;
152
+ }
153
+
154
+ return keysWithoutEquals.has(decodeQueryKey(normalizedKey)) ? normalizedKey : `${normalizedKey}=`;
155
+ };
156
+
157
+ const normalizeParameter = parameter => {
158
+ const equalIndex = parameter.indexOf('=');
159
+
160
+ if (equalIndex === -1) {
161
+ // Normalize + to %20 (+ means space in query strings)
162
+ return formatEmptyValue(normalizeKey(parameter));
163
+ }
164
+
165
+ const key = parameter.slice(0, equalIndex);
166
+ const value = parameter.slice(equalIndex + 1);
167
+
168
+ if (value === '') {
169
+ if (key === '') {
170
+ return '=';
171
+ }
172
+
173
+ // Normalize + to %20 (+ means space in query strings)
174
+ return formatEmptyValue(normalizeKey(key));
175
+ }
176
+
177
+ // Normalize + to %20 in key.
178
+ return `${normalizeKey(key)}=${value}`;
179
+ };
180
+
181
+ const parameters = search.slice(1).split('&').filter(Boolean);
182
+ return parameters.length === 0 ? '' : `?${parameters.map(x => normalizeParameter(x)).join('&')}`;
23
183
  };
24
184
 
25
185
  const normalizeDataURL = (urlString, {stripHash}) => {
@@ -38,7 +198,7 @@ const normalizeDataURL = (urlString, {stripHash}) => {
38
198
  }
39
199
 
40
200
  // Lowercase MIME type
41
- const mimeType = mediaType.shift()?.toLowerCase() ?? '';
201
+ const mimeType = mediaType.shift().toLowerCase();
42
202
  const attributes = mediaType
43
203
  .map(attribute => {
44
204
  let [key, value = ''] = attribute.split('=').map(string => string.trim());
@@ -88,6 +248,7 @@ export default function normalizeUrl(urlString, options) {
88
248
  sortQueryParameters: true,
89
249
  removePath: false,
90
250
  transformPath: false,
251
+ emptyQueryValue: 'preserve',
91
252
  ...options,
92
253
  };
93
254
 
@@ -103,7 +264,13 @@ export default function normalizeUrl(urlString, options) {
103
264
  return normalizeDataURL(urlString, options);
104
265
  }
105
266
 
106
- if (hasCustomProtocol(urlString)) {
267
+ const customProtocols = Array.isArray(options.customProtocols) ? options.customProtocols : [];
268
+ const normalizedCustomProtocols = new Set(customProtocols
269
+ .map(protocol => normalizeCustomProtocolOption(protocol))
270
+ .filter(Boolean));
271
+
272
+ const customProtocol = getCustomProtocol(urlString);
273
+ if (customProtocol && !normalizedCustomProtocols.has(customProtocol)) {
107
274
  return urlString;
108
275
  }
109
276
 
@@ -111,7 +278,7 @@ export default function normalizeUrl(urlString, options) {
111
278
  const isRelativeUrl = !hasRelativeProtocol && /^\.*\//.test(urlString);
112
279
 
113
280
  // Prepend protocol
114
- if (!isRelativeUrl) {
281
+ if (!isRelativeUrl && !customProtocol) {
115
282
  urlString = urlString.replace(/^(?!(?:\w+:)?\/\/)|^\/\//, options.defaultProtocol);
116
283
  }
117
284
 
@@ -166,13 +333,13 @@ export default function normalizeUrl(urlString, options) {
166
333
  const protocolAtIndex = match.index;
167
334
  const intermediate = urlObject.pathname.slice(lastIndex, protocolAtIndex);
168
335
 
169
- result += intermediate.replace(/\/{2,}/g, '/');
336
+ result += intermediate.replaceAll(/\/{2,}/g, '/');
170
337
  result += protocol;
171
338
  lastIndex = protocolAtIndex + protocol.length;
172
339
  }
173
340
 
174
- const remnant = urlObject.pathname.slice(lastIndex, urlObject.pathname.length);
175
- result += remnant.replace(/\/{2,}/g, '/');
341
+ const remnant = urlObject.pathname.slice(lastIndex);
342
+ result += remnant.replaceAll(/\/{2,}/g, '/');
176
343
 
177
344
  urlObject.pathname = result;
178
345
  }
@@ -180,7 +347,7 @@ export default function normalizeUrl(urlString, options) {
180
347
  // Decode URI octets
181
348
  if (urlObject.pathname) {
182
349
  try {
183
- urlObject.pathname = decodeURI(urlObject.pathname).replace(/\\/g, '%5C');
350
+ urlObject.pathname = decodeURI(urlObject.pathname).replaceAll('\\', '%5C');
184
351
  } catch {}
185
352
  }
186
353
 
@@ -225,49 +392,61 @@ export default function normalizeUrl(urlString, options) {
225
392
  }
226
393
  }
227
394
 
395
+ // Capture original query params format before any searchParams modifications
396
+ const originalSearch = urlObject.search;
397
+ let encodedReservedTokenRegex;
398
+
399
+ if (options.sortQueryParameters && hasEncodedReservedCharactersRegex.test(originalSearch)) {
400
+ const encodedReservedTokenPrefix = getTemporaryEncodedReservedTokenPrefix(originalSearch);
401
+ urlObject.search = originalSearch.replaceAll(encodedReservedCharactersRegex, match => `${encodedReservedTokenPrefix}${match.slice(1).toUpperCase()}`);
402
+ encodedReservedTokenRegex = new RegExp(`${encodedReservedTokenPrefix}([0-9A-F]{2})`, 'g');
403
+ }
404
+
405
+ const hasKeepQueryParameters = Array.isArray(options.keepQueryParameters);
406
+ const {searchParams} = urlObject;
407
+
228
408
  // Remove query unwanted parameters
229
- if (Array.isArray(options.removeQueryParameters)) {
409
+ if (!hasKeepQueryParameters && Array.isArray(options.removeQueryParameters) && options.removeQueryParameters.length > 0) {
230
410
  // eslint-disable-next-line unicorn/no-useless-spread -- We are intentionally spreading to get a copy.
231
- for (const key of [...urlObject.searchParams.keys()]) {
232
- if (testParameter(key, options.removeQueryParameters)) {
233
- urlObject.searchParams.delete(key);
411
+ for (const key of [...searchParams.keys()]) {
412
+ if (testParameter(decodeReservedTokens(key, encodedReservedTokenRegex), options.removeQueryParameters)) {
413
+ searchParams.delete(key);
234
414
  }
235
415
  }
236
416
  }
237
417
 
238
- if (!Array.isArray(options.keepQueryParameters) && options.removeQueryParameters === true) {
418
+ if (!hasKeepQueryParameters && options.removeQueryParameters === true) {
239
419
  urlObject.search = '';
240
420
  }
241
421
 
242
422
  // Keep wanted query parameters
243
- if (Array.isArray(options.keepQueryParameters) && options.keepQueryParameters.length > 0) {
423
+ if (hasKeepQueryParameters && options.keepQueryParameters.length > 0) {
244
424
  // eslint-disable-next-line unicorn/no-useless-spread -- We are intentionally spreading to get a copy.
245
- for (const key of [...urlObject.searchParams.keys()]) {
246
- if (!testParameter(key, options.keepQueryParameters)) {
247
- urlObject.searchParams.delete(key);
425
+ for (const key of [...searchParams.keys()]) {
426
+ if (!testParameter(decodeReservedTokens(key, encodedReservedTokenRegex), options.keepQueryParameters)) {
427
+ searchParams.delete(key);
248
428
  }
249
429
  }
430
+ } else if (hasKeepQueryParameters) {
431
+ urlObject.search = '';
250
432
  }
251
433
 
252
434
  // Sort query parameters
253
435
  if (options.sortQueryParameters) {
254
- const originalSearch = urlObject.search;
255
- urlObject.searchParams.sort();
436
+ urlObject.search = sortSearchParameters(urlObject.searchParams, encodedReservedTokenRegex);
256
437
 
257
- // Calling `.sort()` encodes the search parameters, so we need to decode them again.
258
- try {
259
- urlObject.search = decodeURIComponent(urlObject.search);
260
- } catch {}
438
+ // Sorting and serializing encode the search parameters, so we need to decode them again.
439
+ // Protect &%#? and %2B from decoding (would break URL structure or change meaning) by double-encoding them first.
440
+ urlObject.search = decodeURIComponent(urlObject.search.replaceAll(/%(?:26|23|3f|25|2b)/gi, match => `%25${match.slice(1)}`));
261
441
 
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}`);
442
+ if (encodedReservedTokenRegex) {
443
+ urlObject.search = urlObject.search.replace(encodedReservedTokenRegex, '%$1');
268
444
  }
269
445
  }
270
446
 
447
+ // Normalize empty query parameter values
448
+ urlObject.search = normalizeEmptyQueryParameters(urlObject.search, options.emptyQueryValue, originalSearch);
449
+
271
450
  if (options.removeTrailingSlash) {
272
451
  urlObject.pathname = urlObject.pathname.replace(/\/$/, '');
273
452
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "normalize-url",
3
- "version": "8.1.1",
3
+ "version": "9.0.1",
4
4
  "description": "Normalize a URL",
5
5
  "license": "MIT",
6
6
  "repository": "sindresorhus/normalize-url",
@@ -17,11 +17,10 @@
17
17
  },
18
18
  "sideEffects": false,
19
19
  "engines": {
20
- "node": ">=14.16"
20
+ "node": ">=20"
21
21
  },
22
22
  "scripts": {
23
- "//test": "xo && c8 ava && tsd",
24
- "test": "c8 ava && tsd"
23
+ "test": "xo && ava && tsd"
25
24
  },
26
25
  "files": [
27
26
  "index.js",
@@ -43,10 +42,9 @@
43
42
  "canonical"
44
43
  ],
45
44
  "devDependencies": {
46
- "ava": "^5.0.1",
47
- "c8": "^7.12.0",
48
- "tsd": "^0.24.1",
49
- "xo": "^0.52.4"
45
+ "ava": "^6.4.1",
46
+ "tsd": "^0.33.0",
47
+ "xo": "^1.2.3"
50
48
  },
51
49
  "c8": {
52
50
  "reporter": [
package/readme.md CHANGED
@@ -4,7 +4,8 @@
4
4
 
5
5
  Useful when you need to display, store, deduplicate, sort, compare, etc, URLs.
6
6
 
7
- **Note:** This package does **not** do URL sanitization. [Garbage in, garbage out.](https://en.wikipedia.org/wiki/Garbage_in,_garbage_out) If you use this in a server context and accept URLs as user input, it's up to you to protect against invalid URLs, [path traversal attacks](https://owasp.org/www-community/attacks/Path_Traversal), etc.
7
+ > [!NOTE]
8
+ > This package does **not** do URL sanitization. [Garbage in, garbage out.](https://en.wikipedia.org/wiki/Garbage_in,_garbage_out) If you use this in a server context and accept URLs as user input, it's up to you to protect against invalid URLs, [path traversal attacks](https://owasp.org/www-community/attacks/Path_Traversal), etc.
8
9
 
9
10
  ## Install
10
11
 
@@ -28,7 +29,7 @@ normalizeUrl('//www.sindresorhus.com:80/../baz?b=bar&a=foo');
28
29
 
29
30
  ### normalizeUrl(url, options?)
30
31
 
31
- URLs with custom protocols are not normalized and just passed through by default. Supported protocols are: `https`, `http`, `file`, and `data`.
32
+ URLs with custom protocols are not normalized and just passed through by default. Supported protocols are: `https`, `http`, `file`, and `data`. Use the [`customProtocols`](#customprotocols) option to add support for additional protocols.
32
33
 
33
34
  Human-friendly URLs with basic auth (for example, `user:password@sindresorhus.com`) are not handled because basic auth conflicts with custom protocols. [Basic auth URLs are also deprecated.](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#access_using_credentials_in_the_url)
34
35
 
@@ -48,6 +49,25 @@ Type: `string`\
48
49
  Default: `'http'`\
49
50
  Values: `'https' | 'http'`
50
51
 
52
+ ##### customProtocols
53
+
54
+ Type: `string[]`\
55
+ Default: `undefined`
56
+
57
+ Protocols to normalize in addition to the built-in ones (`https`, `http`, `file`, `data`).
58
+
59
+ Useful for HTTP-like custom protocols such as Electron schemes or app-specific protocols.
60
+
61
+ The protocols should be specified without `:`.
62
+
63
+ ```js
64
+ normalizeUrl('sindre://www.sorhus.com', {customProtocols: ['sindre']});
65
+ //=> 'sindre://sorhus.com'
66
+
67
+ normalizeUrl('sindre://www.sorhus.com/foo/', {customProtocols: ['sindre']});
68
+ //=> 'sindre://sorhus.com/foo'
69
+ ```
70
+
51
71
  ##### normalizeProtocol
52
72
 
53
73
  Type: `boolean`\
@@ -187,6 +207,8 @@ Default: `[/^utm_\w+/i]`
187
207
 
188
208
  Remove query parameters that matches any of the provided strings or regexes.
189
209
 
210
+ Global and sticky regex flags are stripped.
211
+
190
212
  ```js
191
213
  normalizeUrl('www.sindresorhus.com?foo=bar&ref=test_ref', {
192
214
  removeQueryParameters: ['ref']
@@ -221,6 +243,8 @@ Keeps only query parameters that matches any of the provided strings or regexes.
221
243
 
222
244
  **Note:** It overrides the `removeQueryParameters` option.
223
245
 
246
+ Global and sticky regex flags are stripped.
247
+
224
248
  ```js
225
249
  normalizeUrl('https://sindresorhus.com?foo=bar&ref=unicorn', {
226
250
  keepQueryParameters: ['ref']
@@ -268,7 +292,11 @@ normalizeUrl('https://sindresorhus.com/', {removeSingleSlash: false});
268
292
  Type: `boolean | Array<RegExp | string>`\
269
293
  Default: `false`
270
294
 
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.
295
+ Removes the default directory index file from path that matches any of the provided strings or regexes.
296
+
297
+ When `true`, the regex `/^index\.[a-z]+$/` is used.
298
+
299
+ Global and sticky regex flags are stripped.
272
300
 
273
301
  ```js
274
302
  normalizeUrl('www.sindresorhus.com/foo/default.php', {
@@ -307,6 +335,26 @@ normalizeUrl('www.sindresorhus.com?b=two&a=one&c=three', {
307
335
  //=> 'http://sindresorhus.com/?b=two&a=one&c=three'
308
336
  ```
309
337
 
338
+ ##### emptyQueryValue
339
+
340
+ Type: `string`\
341
+ Default: `'preserve'`\
342
+ Values: `'preserve' | 'always' | 'never'`
343
+
344
+ Controls how query parameters with empty values are formatted.
345
+
346
+ - `'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 `=`.
347
+ - `'always'` - Always include `=` for empty values (`?key` becomes `?key=`)
348
+ - `'never'` - Never include `=` for empty values (`?key=` becomes `?key`)
349
+
350
+ ```js
351
+ normalizeUrl('www.sindresorhus.com?a&b=', {emptyQueryValue: 'always'});
352
+ //=> 'http://sindresorhus.com/?a=&b='
353
+
354
+ normalizeUrl('www.sindresorhus.com?a&b=', {emptyQueryValue: 'never'});
355
+ //=> 'http://sindresorhus.com/?a&b'
356
+ ```
357
+
310
358
  ##### removePath
311
359
 
312
360
  Type: `boolean`\