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.
- package/index.d.ts +48 -1
- package/index.js +213 -34
- package/package.json +6 -8
- 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
|
|
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
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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()
|
|
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
|
-
|
|
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.
|
|
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
|
|
175
|
-
result += remnant.
|
|
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).
|
|
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 [...
|
|
232
|
-
if (testParameter(key, options.removeQueryParameters)) {
|
|
233
|
-
|
|
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 (!
|
|
418
|
+
if (!hasKeepQueryParameters && options.removeQueryParameters === true) {
|
|
239
419
|
urlObject.search = '';
|
|
240
420
|
}
|
|
241
421
|
|
|
242
422
|
// Keep wanted query parameters
|
|
243
|
-
if (
|
|
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 [...
|
|
246
|
-
if (!testParameter(key, options.keepQueryParameters)) {
|
|
247
|
-
|
|
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
|
-
|
|
255
|
-
urlObject.searchParams.sort();
|
|
436
|
+
urlObject.search = sortSearchParameters(urlObject.searchParams, encodedReservedTokenRegex);
|
|
256
437
|
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
263
|
-
|
|
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": "
|
|
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": ">=
|
|
20
|
+
"node": ">=20"
|
|
21
21
|
},
|
|
22
22
|
"scripts": {
|
|
23
|
-
"
|
|
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": "^
|
|
47
|
-
"
|
|
48
|
-
"
|
|
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
|
-
|
|
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.
|
|
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`\
|