normalize-url 8.1.0 → 9.0.0

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 +219 -48
  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,155 @@ 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
+ if (protocol.endsWith(':')
44
+ && (!protocol.includes('.') || hasAuthority)
45
+ && !supportedProtocols.has(protocol)) {
46
+ return protocol;
47
+ }
48
+ } catch {}
49
+
50
+ return undefined;
51
+ };
52
+
53
+ const decodeQueryKey = value => {
54
+ try {
55
+ return decodeURIComponent(value.replaceAll('+', '%20'));
56
+ } catch {
57
+ // Match URLSearchParams behavior for malformed percent-encoding.
58
+ return new URLSearchParams(`${value}=`).keys().next().value;
59
+ }
60
+ };
61
+
62
+ const getKeysWithoutEquals = search => {
63
+ const keys = new Set();
64
+ if (!search) {
65
+ return keys;
66
+ }
16
67
 
17
- return protocol.endsWith(':')
18
- && !protocol.includes('.')
19
- && !supportedProtocols.has(protocol);
68
+ for (const part of search.slice(1).split('&')) {
69
+ if (part && !part.includes('=')) {
70
+ keys.add(decodeQueryKey(part));
71
+ }
72
+ }
73
+
74
+ return keys;
75
+ };
76
+
77
+ const getTemporaryEncodedReservedTokenPrefix = search => {
78
+ let decodedSearch = search;
79
+
80
+ try {
81
+ decodedSearch = decodeURIComponent(search);
20
82
  } catch {
21
- return false;
83
+ decodedSearch = new URLSearchParams(search).toString();
22
84
  }
85
+
86
+ const getUsedTokenIndexes = value => {
87
+ const indexes = new Set();
88
+
89
+ for (const match of value.matchAll(temporaryEncodedReservedTokenPattern)) {
90
+ indexes.add(Number.parseInt(match[1], 10));
91
+ }
92
+
93
+ return indexes;
94
+ };
95
+
96
+ const usedTokenIndexes = getUsedTokenIndexes(search);
97
+ for (const tokenIndex of getUsedTokenIndexes(decodedSearch)) {
98
+ usedTokenIndexes.add(tokenIndex);
99
+ }
100
+
101
+ let tokenIndex = 0;
102
+ while (usedTokenIndexes.has(tokenIndex)) {
103
+ tokenIndex++;
104
+ }
105
+
106
+ return `${temporaryEncodedReservedTokenBase}${tokenIndex}__`;
107
+ };
108
+
109
+ const sortSearchParameters = (searchParameters, encodedReservedTokenRegex) => {
110
+ if (!encodedReservedTokenRegex) {
111
+ searchParameters.sort();
112
+ return searchParameters.toString();
113
+ }
114
+
115
+ const getSortableKey = key => key.replace(encodedReservedTokenRegex, (_, hexCode) => String.fromCodePoint(Number.parseInt(hexCode, 16)));
116
+ const entries = [...searchParameters.entries()];
117
+ entries.sort(([leftKey], [rightKey]) => {
118
+ const left = getSortableKey(leftKey);
119
+ const right = getSortableKey(rightKey);
120
+ return left < right ? -1 : (left > right ? 1 : 0);
121
+ });
122
+
123
+ return new URLSearchParams(entries).toString();
124
+ };
125
+
126
+ const decodeReservedTokens = (value, encodedReservedTokenRegex) => {
127
+ if (!encodedReservedTokenRegex) {
128
+ return value;
129
+ }
130
+
131
+ return value.replace(encodedReservedTokenRegex, (_, hexCode) => String.fromCodePoint(Number.parseInt(hexCode, 16)));
132
+ };
133
+
134
+ const normalizeEmptyQueryParameters = (search, emptyQueryValue, originalSearch) => {
135
+ const isAlways = emptyQueryValue === 'always';
136
+ const isNever = emptyQueryValue === 'never';
137
+ const keysWithoutEquals = (isAlways || isNever) ? undefined : getKeysWithoutEquals(originalSearch);
138
+
139
+ const normalizeKey = key => key.replaceAll('+', '%20');
140
+ const formatEmptyValue = normalizedKey => {
141
+ if (isAlways) {
142
+ return `${normalizedKey}=`;
143
+ }
144
+
145
+ if (isNever) {
146
+ return normalizedKey;
147
+ }
148
+
149
+ return keysWithoutEquals.has(decodeQueryKey(normalizedKey)) ? normalizedKey : `${normalizedKey}=`;
150
+ };
151
+
152
+ const normalizeParameter = parameter => {
153
+ const equalIndex = parameter.indexOf('=');
154
+
155
+ if (equalIndex === -1) {
156
+ // Normalize + to %20 (+ means space in query strings)
157
+ return formatEmptyValue(normalizeKey(parameter));
158
+ }
159
+
160
+ const key = parameter.slice(0, equalIndex);
161
+ const value = parameter.slice(equalIndex + 1);
162
+
163
+ if (value === '') {
164
+ if (key === '') {
165
+ return '=';
166
+ }
167
+
168
+ // Normalize + to %20 (+ means space in query strings)
169
+ return formatEmptyValue(normalizeKey(key));
170
+ }
171
+
172
+ // Normalize + to %20 in key.
173
+ return `${normalizeKey(key)}=${value}`;
174
+ };
175
+
176
+ const parameters = search.slice(1).split('&').filter(Boolean);
177
+ return parameters.length === 0 ? '' : `?${parameters.map(x => normalizeParameter(x)).join('&')}`;
23
178
  };
24
179
 
25
180
  const normalizeDataURL = (urlString, {stripHash}) => {
@@ -29,18 +184,16 @@ const normalizeDataURL = (urlString, {stripHash}) => {
29
184
  throw new Error(`Invalid URL: ${urlString}`);
30
185
  }
31
186
 
32
- let {type, data, hash} = match.groups;
187
+ const {type, data, hash} = match.groups;
33
188
  const mediaType = type.split(';');
34
- hash = stripHash ? '' : hash;
35
189
 
36
- let isBase64 = false;
37
- if (mediaType[mediaType.length - 1] === 'base64') {
190
+ const isBase64 = mediaType.at(-1) === 'base64';
191
+ if (isBase64) {
38
192
  mediaType.pop();
39
- isBase64 = true;
40
193
  }
41
194
 
42
195
  // Lowercase MIME type
43
- const mimeType = mediaType.shift()?.toLowerCase() ?? '';
196
+ const mimeType = mediaType.shift().toLowerCase();
44
197
  const attributes = mediaType
45
198
  .map(attribute => {
46
199
  let [key, value = ''] = attribute.split('=').map(string => string.trim());
@@ -58,9 +211,7 @@ const normalizeDataURL = (urlString, {stripHash}) => {
58
211
  })
59
212
  .filter(Boolean);
60
213
 
61
- const normalizedMediaType = [
62
- ...attributes,
63
- ];
214
+ const normalizedMediaType = [...attributes];
64
215
 
65
216
  if (isBase64) {
66
217
  normalizedMediaType.push('base64');
@@ -70,7 +221,8 @@ const normalizeDataURL = (urlString, {stripHash}) => {
70
221
  normalizedMediaType.unshift(mimeType);
71
222
  }
72
223
 
73
- return `data:${normalizedMediaType.join(';')},${isBase64 ? data.trim() : data}${hash ? `#${hash}` : ''}`;
224
+ const hashPart = stripHash || !hash ? '' : `#${hash}`;
225
+ return `data:${normalizedMediaType.join(';')},${isBase64 ? data.trim() : data}${hashPart}`;
74
226
  };
75
227
 
76
228
  export default function normalizeUrl(urlString, options) {
@@ -91,6 +243,7 @@ export default function normalizeUrl(urlString, options) {
91
243
  sortQueryParameters: true,
92
244
  removePath: false,
93
245
  transformPath: false,
246
+ emptyQueryValue: 'preserve',
94
247
  ...options,
95
248
  };
96
249
 
@@ -106,7 +259,13 @@ export default function normalizeUrl(urlString, options) {
106
259
  return normalizeDataURL(urlString, options);
107
260
  }
108
261
 
109
- if (hasCustomProtocol(urlString)) {
262
+ const customProtocols = Array.isArray(options.customProtocols) ? options.customProtocols : [];
263
+ const normalizedCustomProtocols = new Set(customProtocols
264
+ .map(protocol => normalizeCustomProtocolOption(protocol))
265
+ .filter(Boolean));
266
+
267
+ const customProtocol = getCustomProtocol(urlString);
268
+ if (customProtocol && !normalizedCustomProtocols.has(customProtocol)) {
110
269
  return urlString;
111
270
  }
112
271
 
@@ -114,7 +273,7 @@ export default function normalizeUrl(urlString, options) {
114
273
  const isRelativeUrl = !hasRelativeProtocol && /^\.*\//.test(urlString);
115
274
 
116
275
  // Prepend protocol
117
- if (!isRelativeUrl) {
276
+ if (!isRelativeUrl && !customProtocol) {
118
277
  urlString = urlString.replace(/^(?!(?:\w+:)?\/\/)|^\/\//, options.defaultProtocol);
119
278
  }
120
279
 
@@ -169,13 +328,13 @@ export default function normalizeUrl(urlString, options) {
169
328
  const protocolAtIndex = match.index;
170
329
  const intermediate = urlObject.pathname.slice(lastIndex, protocolAtIndex);
171
330
 
172
- result += intermediate.replace(/\/{2,}/g, '/');
331
+ result += intermediate.replaceAll(/\/{2,}/g, '/');
173
332
  result += protocol;
174
333
  lastIndex = protocolAtIndex + protocol.length;
175
334
  }
176
335
 
177
- const remnant = urlObject.pathname.slice(lastIndex, urlObject.pathname.length);
178
- result += remnant.replace(/\/{2,}/g, '/');
336
+ const remnant = urlObject.pathname.slice(lastIndex);
337
+ result += remnant.replaceAll(/\/{2,}/g, '/');
179
338
 
180
339
  urlObject.pathname = result;
181
340
  }
@@ -183,7 +342,7 @@ export default function normalizeUrl(urlString, options) {
183
342
  // Decode URI octets
184
343
  if (urlObject.pathname) {
185
344
  try {
186
- urlObject.pathname = decodeURI(urlObject.pathname).replace(/\\/g, '%5C');
345
+ urlObject.pathname = decodeURI(urlObject.pathname).replaceAll('\\', '%5C');
187
346
  } catch {}
188
347
  }
189
348
 
@@ -193,12 +352,12 @@ export default function normalizeUrl(urlString, options) {
193
352
  }
194
353
 
195
354
  if (Array.isArray(options.removeDirectoryIndex) && options.removeDirectoryIndex.length > 0) {
196
- let pathComponents = urlObject.pathname.split('/');
197
- const lastComponent = pathComponents[pathComponents.length - 1];
355
+ const pathComponents = urlObject.pathname.split('/').filter(Boolean);
356
+ const lastComponent = pathComponents.at(-1);
198
357
 
199
- if (testParameter(lastComponent, options.removeDirectoryIndex)) {
200
- pathComponents = pathComponents.slice(0, -1);
201
- urlObject.pathname = pathComponents.slice(1).join('/') + '/';
358
+ if (lastComponent && testParameter(lastComponent, options.removeDirectoryIndex)) {
359
+ pathComponents.pop();
360
+ urlObject.pathname = pathComponents.length > 0 ? `/${pathComponents.join('/')}/` : '/';
202
361
  }
203
362
  }
204
363
 
@@ -228,49 +387,61 @@ export default function normalizeUrl(urlString, options) {
228
387
  }
229
388
  }
230
389
 
390
+ // Capture original query params format before any searchParams modifications
391
+ const originalSearch = urlObject.search;
392
+ let encodedReservedTokenRegex;
393
+
394
+ if (options.sortQueryParameters && hasEncodedReservedCharactersRegex.test(originalSearch)) {
395
+ const encodedReservedTokenPrefix = getTemporaryEncodedReservedTokenPrefix(originalSearch);
396
+ urlObject.search = originalSearch.replaceAll(encodedReservedCharactersRegex, match => `${encodedReservedTokenPrefix}${match.slice(1).toUpperCase()}`);
397
+ encodedReservedTokenRegex = new RegExp(`${encodedReservedTokenPrefix}([0-9A-F]{2})`, 'g');
398
+ }
399
+
400
+ const hasKeepQueryParameters = Array.isArray(options.keepQueryParameters);
401
+ const {searchParams} = urlObject;
402
+
231
403
  // Remove query unwanted parameters
232
- if (Array.isArray(options.removeQueryParameters)) {
404
+ if (!hasKeepQueryParameters && Array.isArray(options.removeQueryParameters) && options.removeQueryParameters.length > 0) {
233
405
  // eslint-disable-next-line unicorn/no-useless-spread -- We are intentionally spreading to get a copy.
234
- for (const key of [...urlObject.searchParams.keys()]) {
235
- if (testParameter(key, options.removeQueryParameters)) {
236
- urlObject.searchParams.delete(key);
406
+ for (const key of [...searchParams.keys()]) {
407
+ if (testParameter(decodeReservedTokens(key, encodedReservedTokenRegex), options.removeQueryParameters)) {
408
+ searchParams.delete(key);
237
409
  }
238
410
  }
239
411
  }
240
412
 
241
- if (!Array.isArray(options.keepQueryParameters) && options.removeQueryParameters === true) {
413
+ if (!hasKeepQueryParameters && options.removeQueryParameters === true) {
242
414
  urlObject.search = '';
243
415
  }
244
416
 
245
417
  // Keep wanted query parameters
246
- if (Array.isArray(options.keepQueryParameters) && options.keepQueryParameters.length > 0) {
418
+ if (hasKeepQueryParameters && options.keepQueryParameters.length > 0) {
247
419
  // eslint-disable-next-line unicorn/no-useless-spread -- We are intentionally spreading to get a copy.
248
- for (const key of [...urlObject.searchParams.keys()]) {
249
- if (!testParameter(key, options.keepQueryParameters)) {
250
- urlObject.searchParams.delete(key);
420
+ for (const key of [...searchParams.keys()]) {
421
+ if (!testParameter(decodeReservedTokens(key, encodedReservedTokenRegex), options.keepQueryParameters)) {
422
+ searchParams.delete(key);
251
423
  }
252
424
  }
425
+ } else if (hasKeepQueryParameters) {
426
+ urlObject.search = '';
253
427
  }
254
428
 
255
429
  // Sort query parameters
256
430
  if (options.sortQueryParameters) {
257
- const originalSearch = urlObject.search;
258
- urlObject.searchParams.sort();
431
+ urlObject.search = sortSearchParameters(urlObject.searchParams, encodedReservedTokenRegex);
259
432
 
260
- // Calling `.sort()` encodes the search parameters, so we need to decode them again.
261
- try {
262
- urlObject.search = decodeURIComponent(urlObject.search);
263
- } catch {}
433
+ // Sorting and serializing encode the search parameters, so we need to decode them again.
434
+ // Protect &%#? and %2B from decoding (would break URL structure or change meaning) by double-encoding them first.
435
+ urlObject.search = decodeURIComponent(urlObject.search.replaceAll(/%(?:26|23|3f|25|2b)/gi, match => `%25${match.slice(1)}`));
264
436
 
265
- // Fix parameters that originally had no equals sign but got one added by URLSearchParams
266
- const partsWithoutEquals = originalSearch.slice(1).split('&').filter(p => p && !p.includes('='));
267
- for (const part of partsWithoutEquals) {
268
- const decoded = decodeURIComponent(part);
269
- // Only replace at word boundaries to avoid partial matches
270
- urlObject.search = urlObject.search.replace(`?${decoded}=`, `?${decoded}`).replace(`&${decoded}=`, `&${decoded}`);
437
+ if (encodedReservedTokenRegex) {
438
+ urlObject.search = urlObject.search.replace(encodedReservedTokenRegex, '%$1');
271
439
  }
272
440
  }
273
441
 
442
+ // Normalize empty query parameter values
443
+ urlObject.search = normalizeEmptyQueryParameters(urlObject.search, options.emptyQueryValue, originalSearch);
444
+
274
445
  if (options.removeTrailingSlash) {
275
446
  urlObject.pathname = urlObject.pathname.replace(/\/$/, '');
276
447
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "normalize-url",
3
- "version": "8.1.0",
3
+ "version": "9.0.0",
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`\