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.
- package/index.d.ts +48 -1
- package/index.js +219 -48
- 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,155 @@ 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
|
+
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
+
const {type, data, hash} = match.groups;
|
|
33
188
|
const mediaType = type.split(';');
|
|
34
|
-
hash = stripHash ? '' : hash;
|
|
35
189
|
|
|
36
|
-
|
|
37
|
-
if (
|
|
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()
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
178
|
-
result += remnant.
|
|
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).
|
|
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
|
-
|
|
197
|
-
const lastComponent = pathComponents
|
|
355
|
+
const pathComponents = urlObject.pathname.split('/').filter(Boolean);
|
|
356
|
+
const lastComponent = pathComponents.at(-1);
|
|
198
357
|
|
|
199
|
-
if (testParameter(lastComponent, options.removeDirectoryIndex)) {
|
|
200
|
-
pathComponents
|
|
201
|
-
urlObject.pathname = pathComponents.
|
|
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 [...
|
|
235
|
-
if (testParameter(key, options.removeQueryParameters)) {
|
|
236
|
-
|
|
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 (!
|
|
413
|
+
if (!hasKeepQueryParameters && options.removeQueryParameters === true) {
|
|
242
414
|
urlObject.search = '';
|
|
243
415
|
}
|
|
244
416
|
|
|
245
417
|
// Keep wanted query parameters
|
|
246
|
-
if (
|
|
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 [...
|
|
249
|
-
if (!testParameter(key, options.keepQueryParameters)) {
|
|
250
|
-
|
|
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
|
-
|
|
258
|
-
urlObject.searchParams.sort();
|
|
431
|
+
urlObject.search = sortSearchParameters(urlObject.searchParams, encodedReservedTokenRegex);
|
|
259
432
|
|
|
260
|
-
//
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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": "
|
|
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": ">=
|
|
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`\
|