query-string 9.5.1 → 10.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/base.d.ts +156 -83
- package/base.js +432 -246
- package/package.json +11 -22
- package/readme.md +89 -45
package/base.js
CHANGED
|
@@ -1,164 +1,125 @@
|
|
|
1
|
-
import decodeComponent from 'decode-uri-component';
|
|
2
1
|
import {includeKeys} from 'filter-obj';
|
|
3
2
|
import splitOnFirst from 'split-on-first';
|
|
4
3
|
|
|
5
|
-
const isNullOrUndefined = value => value === null || value === undefined;
|
|
6
|
-
|
|
7
4
|
// eslint-disable-next-line unicorn/prefer-code-point
|
|
8
|
-
const strictUriEncode = string => encodeURIComponent(string).replaceAll(/[!'()*]/
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
const strictUriEncode = string => encodeURIComponent(string).replaceAll(/[!'\(\)*]/gv, x => `%${x.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
6
|
+
|
|
7
|
+
// A value that is left out of the query string. `undefined` is always left out, and `skipNull` and `skipEmptyString` leave out the two others.
|
|
8
|
+
function isSkipped(value, options) {
|
|
9
|
+
return (
|
|
10
|
+
value === undefined
|
|
11
|
+
|| (options.skipNull && value === null)
|
|
12
|
+
|| (options.skipEmptyString && value === '')
|
|
13
|
+
);
|
|
14
|
+
}
|
|
11
15
|
|
|
12
|
-
|
|
13
|
-
|
|
16
|
+
// The suffix of the parameter name that `parse()` looks for. `index` numbers it, `bracket` and `colon-list-separator` use a fixed one, and the other formats use none.
|
|
17
|
+
function itemMarkerFor(arrayFormat) {
|
|
18
|
+
switch (arrayFormat) {
|
|
14
19
|
case 'index': {
|
|
15
|
-
return
|
|
16
|
-
const index = result.length;
|
|
17
|
-
|
|
18
|
-
if (
|
|
19
|
-
value === undefined
|
|
20
|
-
|| (options.skipNull && value === null)
|
|
21
|
-
|| (options.skipEmptyString && value === '')
|
|
22
|
-
) {
|
|
23
|
-
return result;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
if (value === null) {
|
|
27
|
-
result.push([encode(key, options), '[', index, ']'].join(''));
|
|
28
|
-
return result;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
result.push([encode(key, options), '[', encode(index, options), ']=', encode(value, options)].join(''));
|
|
32
|
-
return result;
|
|
33
|
-
};
|
|
20
|
+
return index => `[${index}]`;
|
|
34
21
|
}
|
|
35
22
|
|
|
36
23
|
case 'bracket': {
|
|
37
|
-
return
|
|
38
|
-
|
|
39
|
-
value === undefined
|
|
40
|
-
|| (options.skipNull && value === null)
|
|
41
|
-
|| (options.skipEmptyString && value === '')
|
|
42
|
-
) {
|
|
43
|
-
return result;
|
|
44
|
-
}
|
|
24
|
+
return () => '[]';
|
|
25
|
+
}
|
|
45
26
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
27
|
+
case 'colon-list-separator': {
|
|
28
|
+
return () => ':list';
|
|
29
|
+
}
|
|
50
30
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
};
|
|
31
|
+
default: {
|
|
32
|
+
return () => '';
|
|
54
33
|
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
55
36
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
value === undefined
|
|
60
|
-
|| (options.skipNull && value === null)
|
|
61
|
-
|| (options.skipEmptyString && value === '')
|
|
62
|
-
) {
|
|
63
|
-
return result;
|
|
64
|
-
}
|
|
37
|
+
function encoderForArrayFormat(options) {
|
|
38
|
+
const {arrayFormat} = options;
|
|
65
39
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return result;
|
|
69
|
-
}
|
|
40
|
+
if (['comma', 'separator', 'bracket-separator'].includes(arrayFormat)) {
|
|
41
|
+
const keyValueSeparator = arrayFormat === 'bracket-separator' ? '[]=' : '=';
|
|
70
42
|
|
|
71
|
-
|
|
43
|
+
return key => (result, value) => {
|
|
44
|
+
if (isSkipped(value, options)) {
|
|
72
45
|
return result;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
46
|
+
}
|
|
75
47
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
case 'bracket-separator': {
|
|
79
|
-
const keyValueSeparator = options.arrayFormat === 'bracket-separator'
|
|
80
|
-
? '[]='
|
|
81
|
-
: '=';
|
|
82
|
-
|
|
83
|
-
return key => (result, value) => {
|
|
84
|
-
if (
|
|
85
|
-
value === undefined
|
|
86
|
-
|| (options.skipNull && value === null)
|
|
87
|
-
|| (options.skipEmptyString && value === '')
|
|
88
|
-
) {
|
|
89
|
-
return result;
|
|
90
|
-
}
|
|
48
|
+
// Translate null to an empty string so that it doesn't serialize as 'null'
|
|
49
|
+
const item = encodeArrayItem(value === null ? '' : value, options);
|
|
91
50
|
|
|
92
|
-
|
|
93
|
-
|
|
51
|
+
if (result.length === 0) {
|
|
52
|
+
result.push([encode(key, options), keyValueSeparator, item].join(''));
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
94
55
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
56
|
+
result.push(item);
|
|
57
|
+
return result;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
99
60
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
};
|
|
103
|
-
}
|
|
61
|
+
// These formats write one parameter per item, with the marker in the name.
|
|
62
|
+
const marker = itemMarkerFor(arrayFormat);
|
|
104
63
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|| (options.skipNull && value === null)
|
|
110
|
-
|| (options.skipEmptyString && value === '')
|
|
111
|
-
) {
|
|
112
|
-
return result;
|
|
113
|
-
}
|
|
64
|
+
return key => (result, value) => {
|
|
65
|
+
if (isSkipped(value, options)) {
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
114
68
|
|
|
115
|
-
|
|
116
|
-
result.push(encode(key, options));
|
|
117
|
-
return result;
|
|
118
|
-
}
|
|
69
|
+
const name = encode(key, options) + marker(result.length);
|
|
119
70
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
71
|
+
if (value === null) {
|
|
72
|
+
result.push(name);
|
|
73
|
+
return result;
|
|
123
74
|
}
|
|
124
|
-
|
|
75
|
+
|
|
76
|
+
result.push([name, '=', encode(value, options)].join(''));
|
|
77
|
+
return result;
|
|
78
|
+
};
|
|
125
79
|
}
|
|
126
80
|
|
|
127
81
|
function parserForArrayFormat(options) {
|
|
128
|
-
let result;
|
|
129
|
-
|
|
130
82
|
switch (options.arrayFormat) {
|
|
131
83
|
case 'index': {
|
|
84
|
+
// The next free index per key for an entry without one, like `foo[]`. That is what PHP and Rack write for an array item, and what `arrayFormat: 'bracket'` produces. It is appended after the highest index seen so far, like in PHP, instead of every such entry overriding the previous one.
|
|
85
|
+
const nextIndexes = new Map();
|
|
86
|
+
|
|
132
87
|
return (key, value, accumulator) => {
|
|
133
|
-
result = /\[(
|
|
88
|
+
const result = /\[(?<index>\d*)\]$/v.exec(key);
|
|
134
89
|
|
|
135
|
-
key = key.replace(/\[
|
|
90
|
+
key = key.replace(/\[(?<index>\d*)\]$/v, '');
|
|
136
91
|
|
|
137
|
-
|
|
92
|
+
// A repeated key adds its value whether or not it has an index, like the other array formats, instead of silently dropping the earlier values. A plain entry after the first is appended like `foo[]`. A repeated index keeps the last value, like in PHP.
|
|
93
|
+
if (!result && accumulator[key] === undefined) {
|
|
138
94
|
accumulator[key] = value;
|
|
139
95
|
return;
|
|
140
96
|
}
|
|
141
97
|
|
|
142
98
|
if (accumulator[key] === undefined) {
|
|
143
99
|
accumulator[key] = {};
|
|
100
|
+
} else if (typeof accumulator[key] !== 'object' || accumulator[key] === null) {
|
|
101
|
+
// A scalar seen before the indexed entries becomes the first item, like in the other array formats. The empty key sorts before any index.
|
|
102
|
+
accumulator[key] = {'': accumulator[key]};
|
|
103
|
+
nextIndexes.set(key, 1);
|
|
144
104
|
}
|
|
145
105
|
|
|
146
|
-
|
|
106
|
+
const index = result?.groups?.index || (nextIndexes.get(key) ?? 0);
|
|
107
|
+
accumulator[key][index] = value;
|
|
108
|
+
nextIndexes.set(key, Math.max(nextIndexes.get(key) ?? 0, Number(index) + 1));
|
|
147
109
|
};
|
|
148
110
|
}
|
|
149
111
|
|
|
150
|
-
case 'bracket':
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
key = key.replace(/\[]$/, '');
|
|
112
|
+
case 'bracket':
|
|
113
|
+
case 'colon-list-separator': {
|
|
114
|
+
const marker = options.arrayFormat === 'bracket' ? /\[\]$/v : /:list$/v;
|
|
154
115
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
116
|
+
return (key, value, accumulator) => {
|
|
117
|
+
const isArray = marker.test(key);
|
|
118
|
+
key = key.replace(marker, '');
|
|
159
119
|
|
|
120
|
+
// A repeated key adds its value whether or not it has the marker, like `arrayFormat: 'none'`, instead of silently dropping the earlier values.
|
|
160
121
|
if (accumulator[key] === undefined) {
|
|
161
|
-
accumulator[key] = [value];
|
|
122
|
+
accumulator[key] = isArray ? [value] : value;
|
|
162
123
|
return;
|
|
163
124
|
}
|
|
164
125
|
|
|
@@ -171,55 +132,48 @@ function parserForArrayFormat(options) {
|
|
|
171
132
|
};
|
|
172
133
|
}
|
|
173
134
|
|
|
174
|
-
case '
|
|
135
|
+
case 'comma':
|
|
136
|
+
case 'separator': {
|
|
175
137
|
return (key, value, accumulator) => {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
if (!result) {
|
|
180
|
-
accumulator[key] = value;
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
138
|
+
// Split the raw value before decoding. A percent-encoded separator (`%2C`) is part of an item, only a literal separator delimits items. This follows RFC 6570 §3.2.8, where list members are percent-encoded and joined with a literal `,`, and matches `stringify()`, `qs`, and OpenAPI clients such as swagger-js and openapi-fetch.
|
|
139
|
+
const isArray = typeof value === 'string' && value.includes(options.arrayFormatSeparator);
|
|
140
|
+
const newValue = isArray ? value.split(options.arrayFormatSeparator).map(item => decode(item, options)) : (value === null ? value : decode(value, options));
|
|
183
141
|
|
|
184
142
|
if (accumulator[key] === undefined) {
|
|
185
|
-
accumulator[key] =
|
|
143
|
+
accumulator[key] = newValue;
|
|
186
144
|
return;
|
|
187
145
|
}
|
|
188
146
|
|
|
147
|
+
// A repeated key adds its items, like in the other array formats. The items are appended in place, since rebuilding the array for every repetition is quadratic.
|
|
189
148
|
if (!Array.isArray(accumulator[key])) {
|
|
190
|
-
accumulator[key] = [accumulator[key]
|
|
191
|
-
return;
|
|
149
|
+
accumulator[key] = [accumulator[key]];
|
|
192
150
|
}
|
|
193
151
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
case 'comma':
|
|
199
|
-
case 'separator': {
|
|
200
|
-
return (key, value, accumulator) => {
|
|
201
|
-
const isArray = typeof value === 'string' && value.includes(options.arrayFormatSeparator);
|
|
202
|
-
const newValue = isArray ? value.split(options.arrayFormatSeparator).map(item => decode(item, options)) : (value === null ? value : decode(value, options));
|
|
203
|
-
accumulator[key] = newValue;
|
|
152
|
+
const items = Array.isArray(newValue) ? newValue : [newValue];
|
|
153
|
+
for (const item of items) {
|
|
154
|
+
accumulator[key].push(item);
|
|
155
|
+
}
|
|
204
156
|
};
|
|
205
157
|
}
|
|
206
158
|
|
|
207
159
|
case 'bracket-separator': {
|
|
208
160
|
return (key, value, accumulator) => {
|
|
209
|
-
const isArray =
|
|
210
|
-
key = key.replace(/\[]
|
|
161
|
+
const isArray = /\[\]$/v.test(key);
|
|
162
|
+
key = key.replace(/\[\]$/v, '');
|
|
211
163
|
|
|
164
|
+
// Split the raw value before decoding, like `comma` and `separator`, so an item that contains the separator round-trips: `stringify({foo: ['a,b', 'c']})` gives `foo[]=a%2Cb,c`, which must parse back to `['a,b', 'c']`. Decoding first would make the separator ambiguous, which is also why swagger-js rejected encoding the separator (swagger-api/swagger-ui#9289). A value where the client percent-encoded everything, including the separators, is therefore a single item. See https://github.com/sindresorhus/query-string/issues/398
|
|
165
|
+
// A key without the marker is a single item. A repeated key adds its items either way, like `arrayFormat: 'none'`, instead of silently dropping the earlier values.
|
|
166
|
+
let items;
|
|
212
167
|
if (!isArray) {
|
|
213
|
-
|
|
214
|
-
|
|
168
|
+
items = [value === null ? value : decode(value, options)];
|
|
169
|
+
} else if (value === null) {
|
|
170
|
+
items = [];
|
|
171
|
+
} else {
|
|
172
|
+
items = value.split(options.arrayFormatSeparator).map(item => decode(item, options));
|
|
215
173
|
}
|
|
216
174
|
|
|
217
|
-
const arrayValue = value === null
|
|
218
|
-
? []
|
|
219
|
-
: decode(value, options).split(options.arrayFormatSeparator);
|
|
220
|
-
|
|
221
175
|
if (accumulator[key] === undefined) {
|
|
222
|
-
accumulator[key] =
|
|
176
|
+
accumulator[key] = isArray ? items : items[0];
|
|
223
177
|
return;
|
|
224
178
|
}
|
|
225
179
|
|
|
@@ -227,7 +181,7 @@ function parserForArrayFormat(options) {
|
|
|
227
181
|
accumulator[key] = [accumulator[key]];
|
|
228
182
|
}
|
|
229
183
|
|
|
230
|
-
for (const item of
|
|
184
|
+
for (const item of items) {
|
|
231
185
|
accumulator[key].push(item);
|
|
232
186
|
}
|
|
233
187
|
};
|
|
@@ -252,39 +206,228 @@ function parserForArrayFormat(options) {
|
|
|
252
206
|
}
|
|
253
207
|
|
|
254
208
|
function validateArrayFormatSeparator(value) {
|
|
209
|
+
// A lone surrogate is one UTF-16 code unit, but it is not a character and cannot be percent-encoded, so it would make the encoder throw a `URIError` later instead. Whitespace cannot be a separator because `parse()` ignores leading and trailing whitespace in the query. A separator that only works sometimes, like `%` or a hexadecimal digit, is documented as unsupported instead, so that the encoder can keep its best-effort behavior for it.
|
|
255
210
|
if (typeof value !== 'string' || value.length !== 1) {
|
|
256
|
-
throw new TypeError('arrayFormatSeparator must be single character
|
|
211
|
+
throw new TypeError('arrayFormatSeparator must be a single character');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (value.trim() === '') {
|
|
215
|
+
throw new TypeError('arrayFormatSeparator must not be whitespace, since parse() ignores leading and trailing whitespace in the query');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (/\p{Surrogate}/v.test(value)) {
|
|
219
|
+
throw new TypeError('arrayFormatSeparator must not be a lone surrogate, since it cannot be percent-encoded');
|
|
257
220
|
}
|
|
258
221
|
}
|
|
259
222
|
|
|
260
223
|
function encode(value, options) {
|
|
224
|
+
const type = typeof value;
|
|
225
|
+
if (type === 'function' || type === 'symbol' || (type === 'object' && value !== null)) {
|
|
226
|
+
throw new TypeError(`Unsupported value type: ${type}. Supported types are string, number, bigint, boolean, null, and undefined. Use the \`replacer\` option to transform other types.`);
|
|
227
|
+
}
|
|
228
|
+
|
|
261
229
|
if (options.encode) {
|
|
262
|
-
|
|
230
|
+
try {
|
|
231
|
+
return options.strict ? strictUriEncode(value) : encodeURIComponent(value);
|
|
232
|
+
} catch {
|
|
233
|
+
// A lone surrogate cannot be encoded as UTF-8, so `encodeURIComponent` throws for it. It is replaced with U+FFFD, like `URLSearchParams` and the `URL` hash setter do. `\p{Surrogate}` matches only a surrogate that is not part of a pair, so a valid pair is left alone.
|
|
234
|
+
return encode(String(value).replaceAll(/\p{Surrogate}/gv, '\u{FFFD}'), options);
|
|
235
|
+
}
|
|
263
236
|
}
|
|
264
237
|
|
|
265
238
|
return value;
|
|
266
239
|
}
|
|
267
240
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
241
|
+
// `parse()` only splits on a literal separator, so a separator inside an item is percent-encoded. `encodeURIComponent` leaves some ASCII characters as-is, like `-`, `.`, `_`, and `~`, so the pieces of the item between the separators are encoded separately and joined with the percent-encoded separator. The encoded item is not searched for the separator, since a separator that is a hex digit would clash with the `%XX` escapes.
|
|
242
|
+
function encodeArrayItem(value, options) {
|
|
243
|
+
// `encode()` validates the value type first, since `String()` would hide an unsupported type.
|
|
244
|
+
const encodedValue = encode(value, options);
|
|
245
|
+
const stringValue = String(value);
|
|
246
|
+
const {arrayFormatSeparator} = options;
|
|
247
|
+
if (!options.encode || !stringValue.includes(arrayFormatSeparator)) {
|
|
248
|
+
return encodedValue;
|
|
271
249
|
}
|
|
272
250
|
|
|
273
|
-
|
|
251
|
+
const percentEncodedSeparator = encodeURIComponent(arrayFormatSeparator);
|
|
252
|
+
// `encodeURIComponent` leaves a few characters as-is, like `-`, `.`, `_`, `~`, and `*`, so the separator has to be encoded by hand for those.
|
|
253
|
+
const encodedSeparator = percentEncodedSeparator === arrayFormatSeparator
|
|
254
|
+
? `%${arrayFormatSeparator.codePointAt(0).toString(16).toUpperCase().padStart(2, '0')}`
|
|
255
|
+
: percentEncodedSeparator;
|
|
256
|
+
|
|
257
|
+
return stringValue.split(arrayFormatSeparator).map(piece => encode(piece, options)).join(encodedSeparator);
|
|
274
258
|
}
|
|
275
259
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
260
|
+
// One percent sign, anywhere. Text between two of these is copied in one go, so a value with no escapes in it costs a single native scan instead of a step per character. A value that holds no escape at all, which is the common case, is returned as it is.
|
|
261
|
+
// The `lastIndex` of this and `percentEscape` is shared state, which is only safe because nothing that a caller controls runs while `decodeComponent()` is on the stack. Every user callback, which is a `types` function, a `sort` comparator, a `pick()` filter, and a `replacer`, runs after the loop that decodes the parameters. A callback, a comparator, or an `await` inside `decodeComponent()` would corrupt the two regexes, so set `lastIndex` right before each `exec` and do not reach for a `matchAll` or a `String#replace` here.
|
|
262
|
+
const percentSign = /%/gv;
|
|
263
|
+
|
|
264
|
+
// One percent escape, like `%C3`. The `y` flag makes it match only where it is anchored, so a `%` that starts nothing is not skipped over. The hexadecimal digits may be in either case, like in `%c3`.
|
|
265
|
+
const percentEscape = /%[\da-f]{2}/ivy;
|
|
266
|
+
|
|
267
|
+
// What a byte that cannot be decoded becomes, like the `URL` parser and `URLSearchParams` do.
|
|
268
|
+
const replacementCharacter = '�';
|
|
269
|
+
|
|
270
|
+
// The byte that the percent escape at `position` stands for, or -1 when there is no escape there. A `%` that is not followed by two hexadecimal digits is not an escape, and neither is one that starts the value.
|
|
271
|
+
function readPercentByte(value, position) {
|
|
272
|
+
percentEscape.lastIndex = position;
|
|
273
|
+
const match = percentEscape.exec(value);
|
|
274
|
+
return match === null ? -1 : Number.parseInt(match[0].slice(1), 16);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// The number of bytes in the UTF-8 sequence that starts with this lead byte, or zero when the byte cannot start one. That is a continuation byte (0x80 to 0xBF), and the lead bytes that are only ever invalid: 0xC0 and 0xC1 can only start an overlong encoding, and 0xF5 to 0xFF can only start a code point above U+10FFFF.
|
|
278
|
+
function utf8SequenceLength(leadByte) {
|
|
279
|
+
if (leadByte < 0x80) {
|
|
280
|
+
return 1;
|
|
279
281
|
}
|
|
280
282
|
|
|
281
|
-
if (
|
|
282
|
-
return
|
|
283
|
-
.sort((a, b) => Number(a) - Number(b))
|
|
284
|
-
.map(key => input[key]);
|
|
283
|
+
if (leadByte < 0xC2) {
|
|
284
|
+
return 0;
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
-
|
|
287
|
+
if (leadByte < 0xE0) {
|
|
288
|
+
return 2;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (leadByte < 0xF0) {
|
|
292
|
+
return 3;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return leadByte <= 0xF4 ? 4 : 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Whether the second byte of a UTF-8 sequence is valid for this lead byte. Any later byte only has to be a continuation byte. The range is narrower for the lead bytes that would otherwise allow an overlong encoding, a surrogate, or a code point above U+10FFFF.
|
|
299
|
+
function isValidSecondByte(leadByte, byte) {
|
|
300
|
+
if (byte < 0x80 || byte > 0xBF) {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
switch (leadByte) {
|
|
305
|
+
case 0xE0: {
|
|
306
|
+
return byte >= 0xA0;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
case 0xED: {
|
|
310
|
+
return byte <= 0x9F;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
case 0xF0: {
|
|
314
|
+
return byte >= 0x90;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
case 0xF4: {
|
|
318
|
+
return byte <= 0x8F;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
default: {
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Decode the UTF-8 sequence that starts with the percent escape at `position`. The decoded text and the position just past the sequence are returned.
|
|
328
|
+
function decodeSequence(value, position) {
|
|
329
|
+
const leadByte = readPercentByte(value, position);
|
|
330
|
+
|
|
331
|
+
// A `%` that is not followed by two hexadecimal digits is not an escape, so it is kept as it is.
|
|
332
|
+
if (leadByte === -1) {
|
|
333
|
+
return ['%', position + 1];
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const length = utf8SequenceLength(leadByte);
|
|
337
|
+
|
|
338
|
+
// A continuation byte with no lead byte in front of it, or a lead byte that can only ever start an invalid sequence.
|
|
339
|
+
if (length === 0) {
|
|
340
|
+
return [replacementCharacter, position + 3];
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// An ASCII byte is its own character.
|
|
344
|
+
if (length === 1) {
|
|
345
|
+
return [String.fromCodePoint(leadByte), position + 3];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// The lead byte holds the top bits of the code point, and each continuation byte holds six more. The sequence stops when a byte is missing, because the value ends, or is not valid where it sits.
|
|
349
|
+
// eslint-disable-next-line no-bitwise -- Unpacking a UTF-8 sequence is bit arithmetic by definition.
|
|
350
|
+
let codePoint = leadByte & (0x7F >> length);
|
|
351
|
+
let offset = 1;
|
|
352
|
+
|
|
353
|
+
while (offset < length) {
|
|
354
|
+
const byte = readPercentByte(value, position + (offset * 3));
|
|
355
|
+
if (!(offset === 1 ? isValidSecondByte(leadByte, byte) : (byte >= 0x80 && byte <= 0xBF))) {
|
|
356
|
+
// What was read before the sequence stopped is consumed together with it, so `%E4%80%41` is one replacement character and then `A` rather than a replacement character for every byte.
|
|
357
|
+
return [replacementCharacter, position + (offset * 3)];
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// eslint-disable-next-line no-bitwise -- Unpacking a UTF-8 sequence is bit arithmetic by definition.
|
|
361
|
+
codePoint = (codePoint << 6) | (byte & 0x3F);
|
|
362
|
+
offset++;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return [String.fromCodePoint(codePoint), position + (length * 3)];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Decode the percent escapes in `value`, like `URLSearchParams` does.
|
|
369
|
+
//
|
|
370
|
+
// `decodeURIComponent()` is not used on its own because it throws away the whole string as soon as it sees one byte that is not valid UTF-8, and a query string from the network often has one. `decodeSequence()` is what runs then, using the algorithm the `URL` parser uses: a byte that cannot be decoded becomes U+FFFD, and the bytes around it are still decoded.
|
|
371
|
+
//
|
|
372
|
+
// It is a single left-to-right pass, so the cost is linear in the length of the value. It replaces `decode-uri-component`, whose fallback for a string that `decodeURIComponent()` rejects collected every distinct percent-encoded run into a replacement map and then replaced each entry across the whole string, so a value with N distinct runs cost N passes over N bytes.
|
|
373
|
+
function decodeComponent(value) {
|
|
374
|
+
try {
|
|
375
|
+
// Almost every value is valid percent-encoded UTF-8, and the built-in decoder handles those in one pass in C++, which is far faster than the step at a time above. It gives the same answer, so this only decides which of the two paths runs.
|
|
376
|
+
return decodeURIComponent(value);
|
|
377
|
+
} catch {
|
|
378
|
+
// One byte that cannot be decoded makes the built-in decoder throw away the whole value, so the value is decoded the lenient way instead.
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
percentSign.lastIndex = 0;
|
|
382
|
+
|
|
383
|
+
let output = '';
|
|
384
|
+
let position = 0;
|
|
385
|
+
let match = percentSign.exec(value);
|
|
386
|
+
|
|
387
|
+
while (match !== null) {
|
|
388
|
+
// Everything between the last escape and this one is copied as it is.
|
|
389
|
+
output += value.slice(position, match.index);
|
|
390
|
+
|
|
391
|
+
const [decoded, nextPosition] = decodeSequence(value, match.index);
|
|
392
|
+
output += decoded;
|
|
393
|
+
position = nextPosition;
|
|
394
|
+
|
|
395
|
+
percentSign.lastIndex = position;
|
|
396
|
+
match = percentSign.exec(value);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return output + value.slice(position);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function decode(value, options) {
|
|
403
|
+
return options.decode ? decodeComponent(value) : value;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// The key as `parse()` would decode it, so two spellings of the same key, like `a+b` and `a%20b`, can be compared.
|
|
407
|
+
function decodeQueryKey(key) {
|
|
408
|
+
return decodeComponent(key.replaceAll('+', ' '));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// An option that is explicitly `undefined` uses its default, like an omitted option. That is what a caller passes through with an optional setting, like `{decode: config.decode}`. Spreading it over the defaults would instead override the default with `undefined`, so `{encode: undefined}` would write the values unencoded.
|
|
412
|
+
function definedOptions(options) {
|
|
413
|
+
const result = {};
|
|
414
|
+
|
|
415
|
+
for (const [key, value] of Object.entries(options ?? {})) {
|
|
416
|
+
if (value !== undefined) {
|
|
417
|
+
result[key] = value;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return result;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// The `index` array format collects the items in an object keyed by the index. The keys are sorted as strings first, so that two spellings of the same index, like `01` and `1`, always come out in the same order, and then numerically. The empty key is the item of a scalar seen before the indexed entries, and it sorts before any index.
|
|
425
|
+
function arrayFromIndexes(object) {
|
|
426
|
+
return Object.keys(object)
|
|
427
|
+
// eslint-disable-next-line unicorn/require-array-sort-compare -- The default string sort is what puts `01` and `1` in the same order.
|
|
428
|
+
.toSorted()
|
|
429
|
+
.toSorted((a, b) => Number(a) - Number(b))
|
|
430
|
+
.map(key => object[key]);
|
|
288
431
|
}
|
|
289
432
|
|
|
290
433
|
function removeHash(input) {
|
|
@@ -333,37 +476,31 @@ function parseValue(value, options, type) {
|
|
|
333
476
|
return value.toLowerCase() === '1';
|
|
334
477
|
}
|
|
335
478
|
|
|
336
|
-
if (type === 'string[]' && options.arrayFormat !== 'none' && typeof value === 'string') {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
if (type === 'number[]' && options.arrayFormat !== 'none' && !Number.isNaN(Number(value)) && (typeof value === 'string' && value.trim() !== '')) {
|
|
341
|
-
return [Number(value)];
|
|
479
|
+
if ((type === 'string[]' || type === 'number[]') && options.arrayFormat !== 'none' && (typeof value === 'string' || value === null)) {
|
|
480
|
+
// A single value is still an array. The item is parsed with the element type, so a value that is not a number stays a string. A valueless key is an array with a `null` item, like a valueless item of a repeated key.
|
|
481
|
+
return [parseValue(value, options, type.slice(0, -2))];
|
|
342
482
|
}
|
|
343
483
|
|
|
344
|
-
if (type === 'number' &&
|
|
484
|
+
if (type === 'number' && typeof value === 'string' && value.trim() !== '' && !Number.isNaN(Number(value))) {
|
|
345
485
|
return Number(value);
|
|
346
486
|
}
|
|
347
487
|
|
|
348
|
-
|
|
349
|
-
|
|
488
|
+
// An explicit type takes precedence over `parseNumbers` and `parseBooleans`. A value that does not match its type stays a string. With `arrayFormat: 'none'`, a single value with an array type is not an array and stays a string too.
|
|
489
|
+
if (['boolean', 'number', 'string[]', 'number[]'].includes(type)) {
|
|
490
|
+
return value;
|
|
350
491
|
}
|
|
351
492
|
|
|
352
|
-
if (
|
|
353
|
-
return
|
|
493
|
+
if (value !== null && options.parseBooleans && (value.toLowerCase() === 'true' || value.toLowerCase() === 'false')) {
|
|
494
|
+
return value.toLowerCase() === 'true';
|
|
354
495
|
}
|
|
355
496
|
|
|
356
|
-
return value;
|
|
497
|
+
return typeof value === 'string' && options.parseNumbers && value.trim() !== '' && !Number.isNaN(Number(value)) ? Number(value) : value;
|
|
357
498
|
}
|
|
358
499
|
|
|
359
500
|
export function extract(input) {
|
|
360
501
|
input = removeHash(input);
|
|
361
502
|
const queryStart = input.indexOf('?');
|
|
362
|
-
|
|
363
|
-
return '';
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
return input.slice(queryStart + 1);
|
|
503
|
+
return queryStart === -1 ? '' : input.slice(queryStart + 1);
|
|
367
504
|
}
|
|
368
505
|
|
|
369
506
|
export function parse(query, options) {
|
|
@@ -374,8 +511,8 @@ export function parse(query, options) {
|
|
|
374
511
|
arrayFormatSeparator: ',',
|
|
375
512
|
parseNumbers: false,
|
|
376
513
|
parseBooleans: false,
|
|
377
|
-
|
|
378
|
-
...options,
|
|
514
|
+
...definedOptions(options),
|
|
515
|
+
types: {__proto__: null, ...options?.types},
|
|
379
516
|
};
|
|
380
517
|
|
|
381
518
|
validateArrayFormatSeparator(options.arrayFormatSeparator);
|
|
@@ -389,13 +526,9 @@ export function parse(query, options) {
|
|
|
389
526
|
return returnValue;
|
|
390
527
|
}
|
|
391
528
|
|
|
392
|
-
query = query.trim().replace(/^[
|
|
393
|
-
|
|
394
|
-
if (!query) {
|
|
395
|
-
return returnValue;
|
|
396
|
-
}
|
|
529
|
+
query = query.trim().replace(/^[#&?]/v, '');
|
|
397
530
|
|
|
398
|
-
if (
|
|
531
|
+
if (!query || /^&+$/v.test(query)) {
|
|
399
532
|
return returnValue;
|
|
400
533
|
}
|
|
401
534
|
|
|
@@ -425,22 +558,32 @@ export function parse(query, options) {
|
|
|
425
558
|
}
|
|
426
559
|
|
|
427
560
|
// Missing `=` should be `null`:
|
|
428
|
-
//
|
|
561
|
+
// https://w3.org/TR/2012/WD-url-20120524/#collect-url-parameters
|
|
429
562
|
value = value === undefined ? null : (['comma', 'separator', 'bracket-separator'].includes(options.arrayFormat) ? value : decode(value, options));
|
|
563
|
+
|
|
564
|
+
// The key is decoded before the array format looks for its marker, so a percent-encoded marker like `foo%5B%5D` is also an array item. This is intentional. `URLSearchParams` and HTML forms percent-encode `[`, `]`, and `:` in keys, and servers like Rack and PHP decode the key before they look for the marker. The trade-off is that a key that itself ends in a marker is not distinguishable from the marker after decoding, so `stringify({'foo[]': 'x'}, {arrayFormat: 'bracket'})` does not round-trip.
|
|
430
565
|
formatter(decode(key, options), value, returnValue);
|
|
431
566
|
|
|
432
567
|
parameterStart = index + 1;
|
|
433
568
|
}
|
|
434
569
|
|
|
435
|
-
for (
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
570
|
+
for (let [key, value] of Object.entries(returnValue)) {
|
|
571
|
+
// Only the `index` array format collects the items in an object instead of an array.
|
|
572
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
573
|
+
value = arrayFromIndexes(value);
|
|
574
|
+
returnValue[key] = value;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (Array.isArray(value) && options.types[key] !== 'string') {
|
|
578
|
+
// The items are parsed with the element type. A type that is not a string, like a function, is passed through, and anything else is ignored, like it is for a scalar value.
|
|
579
|
+
const typeOption = options.types[key];
|
|
580
|
+
const type = typeof typeOption === 'string' ? typeOption.replace('[]', '') : typeOption;
|
|
581
|
+
|
|
582
|
+
for (const [index, item] of value.entries()) {
|
|
583
|
+
value[index] = parseValue(item, options, type);
|
|
441
584
|
}
|
|
442
|
-
} else if (
|
|
443
|
-
returnValue[key] =
|
|
585
|
+
} else if (Array.isArray(value) && options.types[key] === 'string') {
|
|
586
|
+
returnValue[key] = value.join(options.arrayFormatSeparator);
|
|
444
587
|
} else {
|
|
445
588
|
returnValue[key] = parseValue(value, options, options.types[key]);
|
|
446
589
|
}
|
|
@@ -451,10 +594,10 @@ export function parse(query, options) {
|
|
|
451
594
|
}
|
|
452
595
|
|
|
453
596
|
// TODO: Remove the use of `reduce`.
|
|
597
|
+
// The default sort is the documented one, and a test covers its code-unit ordering. A `sort` that is neither `false` nor a function sorts, which is how `stringify()` reads the same option, so that one options object works for both. Passing the option straight to `toSorted()` instead threw for any other value, with a message about a comparison function.
|
|
454
598
|
// eslint-disable-next-line unicorn/no-array-reduce
|
|
455
|
-
return (options.sort ===
|
|
456
|
-
|
|
457
|
-
result[key] = Boolean(value) && typeof value === 'object' && !Array.isArray(value) ? keysSorter(value) : value;
|
|
599
|
+
return Object.keys(returnValue).toSorted(typeof options.sort === 'function' ? options.sort : undefined).reduce((result, key) => {
|
|
600
|
+
result[key] = returnValue[key];
|
|
458
601
|
return result;
|
|
459
602
|
}, Object.create(null));
|
|
460
603
|
}
|
|
@@ -469,46 +612,39 @@ export function stringify(object, options) {
|
|
|
469
612
|
strict: true,
|
|
470
613
|
arrayFormat: 'none',
|
|
471
614
|
arrayFormatSeparator: ',',
|
|
472
|
-
...options,
|
|
615
|
+
...definedOptions(options),
|
|
473
616
|
};
|
|
474
617
|
|
|
475
618
|
validateArrayFormatSeparator(options.arrayFormatSeparator);
|
|
476
619
|
|
|
477
|
-
const shouldFilter = key => (
|
|
478
|
-
(options.skipNull && isNullOrUndefined(object[key]))
|
|
479
|
-
|| (options.skipEmptyString && object[key] === '')
|
|
480
|
-
);
|
|
481
|
-
|
|
482
620
|
const formatter = encoderForArrayFormat(options);
|
|
483
621
|
|
|
622
|
+
// Copying into a plain object intentionally drops a key named `__proto__`, since assigning it sets the prototype instead of a property.
|
|
484
623
|
const objectCopy = {};
|
|
485
624
|
|
|
625
|
+
// `Object.fromEntries()` would define the key as a real own property instead,
|
|
626
|
+
// which would keep a `__proto__` key that is meant to be dropped here.
|
|
627
|
+
// eslint-disable-next-line unicorn/prefer-object-from-entries
|
|
486
628
|
for (const [key, value] of Object.entries(object)) {
|
|
487
|
-
|
|
488
|
-
objectCopy[key] = value;
|
|
489
|
-
}
|
|
629
|
+
objectCopy[key] = value;
|
|
490
630
|
}
|
|
491
631
|
|
|
492
632
|
const keys = Object.keys(objectCopy);
|
|
493
633
|
|
|
494
634
|
if (options.sort !== false) {
|
|
495
|
-
keys.sort(options.sort);
|
|
635
|
+
keys.sort(typeof options.sort === 'function' ? options.sort : undefined);
|
|
496
636
|
}
|
|
497
637
|
|
|
498
638
|
return keys.map(key => {
|
|
499
|
-
let value =
|
|
639
|
+
let value = objectCopy[key];
|
|
500
640
|
|
|
501
641
|
// Apply replacer function if provided
|
|
502
642
|
if (options.replacer) {
|
|
503
643
|
value = options.replacer(key, value);
|
|
504
|
-
|
|
505
|
-
// If replacer returns undefined, skip this key
|
|
506
|
-
if (value === undefined) {
|
|
507
|
-
return '';
|
|
508
|
-
}
|
|
509
644
|
}
|
|
510
645
|
|
|
511
|
-
|
|
646
|
+
// `skipNull` and `skipEmptyString` look at the replaced value, like they do for array items. A replacer that returns `undefined` skips the key.
|
|
647
|
+
if (isSkipped(value, options)) {
|
|
512
648
|
return '';
|
|
513
649
|
}
|
|
514
650
|
|
|
@@ -517,12 +653,8 @@ export function stringify(object, options) {
|
|
|
517
653
|
}
|
|
518
654
|
|
|
519
655
|
if (Array.isArray(value)) {
|
|
520
|
-
if (value.length === 0 && options.arrayFormat === 'bracket-separator') {
|
|
521
|
-
return encode(key, options) + '[]';
|
|
522
|
-
}
|
|
523
|
-
|
|
524
656
|
// Apply replacer to array elements if provided
|
|
525
|
-
// Note:
|
|
657
|
+
// Note: The replacer was already applied to the array itself above. Here it is applied to each element.
|
|
526
658
|
let processedArray = value;
|
|
527
659
|
if (options.replacer) {
|
|
528
660
|
processedArray = value.map((item, index) =>
|
|
@@ -531,20 +663,31 @@ export function stringify(object, options) {
|
|
|
531
663
|
}
|
|
532
664
|
|
|
533
665
|
const result = processedArray.reduce(formatter(key), []);
|
|
666
|
+
|
|
667
|
+
// An array with no items left after skipping is empty. `bracket-separator` writes an empty array as `foo[]`, which `parse()` reads back as `[]`. The other formats have no representation for an empty array, so the key is dropped.
|
|
668
|
+
if (result.length === 0) {
|
|
669
|
+
return options.arrayFormat === 'bracket-separator' ? encode(key, options) + '[]' : '';
|
|
670
|
+
}
|
|
671
|
+
|
|
534
672
|
const arrayFormatSeparator = ['comma', 'separator', 'bracket-separator'].includes(options.arrayFormat) ? options.arrayFormatSeparator : '&';
|
|
535
673
|
return result.join(arrayFormatSeparator);
|
|
536
674
|
}
|
|
537
675
|
|
|
538
|
-
|
|
676
|
+
// With `comma` and `separator`, `parse()` splits any value on the separator, so a scalar that contains it is encoded like an array item. `bracket-separator` only splits a value with the array marker.
|
|
677
|
+
const encodedValue = options.arrayFormat === 'comma' || options.arrayFormat === 'separator' ? encodeArrayItem(value, options) : encode(value, options);
|
|
678
|
+
return encode(key, options) + '=' + encodedValue;
|
|
539
679
|
}).filter(x => x.length > 0).join('&');
|
|
540
680
|
}
|
|
541
681
|
|
|
542
682
|
export function parseUrl(url, options) {
|
|
543
683
|
options = {
|
|
544
684
|
decode: true,
|
|
545
|
-
...options,
|
|
685
|
+
...definedOptions(options),
|
|
546
686
|
};
|
|
547
687
|
|
|
688
|
+
// Leading and trailing whitespace is ignored, like `parse()` does for the query and the URL parser does for the whole URL. Otherwise a newline from pasted input would end up in `fragmentIdentifier`.
|
|
689
|
+
url = url.trim();
|
|
690
|
+
|
|
548
691
|
let [url_, hash] = splitOnFirst(url, '#');
|
|
549
692
|
|
|
550
693
|
if (url_ === undefined) {
|
|
@@ -552,9 +695,9 @@ export function parseUrl(url, options) {
|
|
|
552
695
|
}
|
|
553
696
|
|
|
554
697
|
return {
|
|
555
|
-
url: getUrlWithoutQuery(url_
|
|
698
|
+
url: getUrlWithoutQuery(url_),
|
|
556
699
|
query: parse(extract(url), options),
|
|
557
|
-
...(options
|
|
700
|
+
...(options.parseFragmentIdentifier && hash && {fragmentIdentifier: decode(hash, options)}),
|
|
558
701
|
};
|
|
559
702
|
}
|
|
560
703
|
|
|
@@ -562,29 +705,58 @@ export function stringifyUrl(object, options) {
|
|
|
562
705
|
options = {
|
|
563
706
|
encode: true,
|
|
564
707
|
strict: true,
|
|
565
|
-
|
|
566
|
-
...options,
|
|
708
|
+
...definedOptions(options),
|
|
567
709
|
};
|
|
568
710
|
|
|
569
|
-
|
|
570
|
-
const
|
|
711
|
+
// Leading and trailing whitespace is ignored, like `parseUrl()` does. `parse()` already trims the query, so whitespace after it disappeared, while whitespace before it or after the fragment identifier was written back, like `https://foo.bar/ ?a=1`.
|
|
712
|
+
const inputUrl = object.url.trim();
|
|
571
713
|
|
|
572
|
-
const
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
714
|
+
const url = getUrlWithoutQuery(removeHash(inputUrl));
|
|
715
|
+
const queryFromUrl = extract(inputUrl);
|
|
716
|
+
|
|
717
|
+
// When the output is not encoded, keep the existing query as-is instead of decoding it. Decoding without re-encoding would corrupt it.
|
|
718
|
+
// The query in the URL is written back from the plain string values, so `parseNumbers`, `parseBooleans`, and `types` passed along with the stringify options, like when the same options object is shared with `parseUrl()`, do not rewrite a value like `007` as `7`.
|
|
719
|
+
let query = parse(queryFromUrl, {
|
|
720
|
+
sort: false,
|
|
721
|
+
...options,
|
|
722
|
+
decode: options.encode,
|
|
723
|
+
parseNumbers: false,
|
|
724
|
+
parseBooleans: false,
|
|
725
|
+
types: undefined,
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
const queryFromObject = Object.fromEntries(Object.entries(object.query ?? {}));
|
|
729
|
+
|
|
730
|
+
// The query in the URL is not decoded without encoding, so its keys are compared in their decoded form instead, like they are when the output is encoded. Otherwise a key like `%61` in the URL would not be overridden by an `a` in `query`.
|
|
731
|
+
if (!options.encode) {
|
|
732
|
+
const overrides = new Map();
|
|
733
|
+
|
|
734
|
+
for (const [key, value] of Object.entries(queryFromObject)) {
|
|
735
|
+
const decodedKey = decodeQueryKey(key);
|
|
736
|
+
if (!overrides.has(decodedKey)) {
|
|
737
|
+
overrides.set(decodedKey, {key, value});
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
query = Object.fromEntries(Object.entries(query).map(([key, value]) => {
|
|
742
|
+
const override = overrides.get(decodeQueryKey(key));
|
|
743
|
+
return override ? [override.key, override.value] : [key, value];
|
|
744
|
+
}));
|
|
745
|
+
}
|
|
576
746
|
|
|
577
|
-
let queryString = stringify(query, options);
|
|
747
|
+
let queryString = stringify({...query, ...queryFromObject}, options);
|
|
578
748
|
queryString &&= `?${queryString}`;
|
|
579
749
|
|
|
580
|
-
let hash = getHash(
|
|
750
|
+
let hash = getHash(inputUrl);
|
|
581
751
|
if (typeof object.fragmentIdentifier === 'string') {
|
|
582
|
-
hash =
|
|
752
|
+
// An empty fragment identifier removes the fragment, like `url.hash = ''` does.
|
|
753
|
+
hash = object.fragmentIdentifier === '' ? '' : `#${object.fragmentIdentifier}`;
|
|
583
754
|
|
|
584
|
-
if (options
|
|
585
|
-
//
|
|
755
|
+
if (options.encode) {
|
|
756
|
+
// The fragment identifier is a decoded value, like the query values, so `parseUrl()` reads it back unchanged. That is what the `fragment` component of Go's `net/url`, Java's `URI`, and Swift's `URLComponents` does too. The `URL` hash setter instead expects an already-encoded fragment: it leaves `%` as-is, which would turn `100%` into an invalid URL and `%23top` into `#top` after parsing. So `%` is encoded here first. The URL parser also silently strips ASCII tab and newline from its input, so those are encoded first too. The setter then encodes the rest of the WHATWG fragment percent-encode set (controls, space, `"`, `<`, `>`, and backtick) and keeps `/`, `?`, and `#`, so hash routes like `#/users?id=1` stay readable. With `encode: false`, the fragment identifier is written as-is, like the query.
|
|
757
|
+
// The fragment percent-encode set does not depend on the scheme, so the caller's URL is deliberately not parsed here; it may not even be parseable.
|
|
586
758
|
const throwawayUrl = new URL('https://query-string.invalid');
|
|
587
|
-
throwawayUrl.hash = hash;
|
|
759
|
+
throwawayUrl.hash = hash.replaceAll(/[\t\n\r%]/gv, character => `%${character.codePointAt(0).toString(16).toUpperCase().padStart(2, '0')}`);
|
|
588
760
|
hash = throwawayUrl.hash;
|
|
589
761
|
}
|
|
590
762
|
}
|
|
@@ -593,18 +765,32 @@ export function stringifyUrl(object, options) {
|
|
|
593
765
|
}
|
|
594
766
|
|
|
595
767
|
export function pick(input, filter, options) {
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
768
|
+
// The keys array is a filter, not an order. Filtering with a predicate keeps the parsed object's key order, which matters with `sort: false`. Integer-index keys are enumerated first in numeric order, and repeated keys are grouped together.
|
|
769
|
+
if (Array.isArray(filter)) {
|
|
770
|
+
const filterSet = new Set(filter);
|
|
771
|
+
// eslint-disable-next-line unicorn/no-useless-recursion -- The array case is the same operation as the predicate case, so it delegates instead of duplicating the loop.
|
|
772
|
+
return pick(input, key => filterSet.has(key), options);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
options = definedOptions(options);
|
|
601
776
|
|
|
602
|
-
|
|
777
|
+
// `parseNumbers`, `parseBooleans`, and `types` only apply to the `value` the predicate receives. The URL is written back from the plain string values, so a value like `007` is kept as-is instead of being rewritten as `7`.
|
|
778
|
+
// The query is decoded exactly when it is encoded again, so a value like `a%20b` is not percent-encoded a second time. When the output is not encoded, the input is not decoded either, since decoding without re-encoding would corrupt it. The `decode` option is ignored: decoding the predicate's view differently from the output would give the two views different keys, so a percent-encoded key, or a `+` in a key, would not find its value.
|
|
779
|
+
// An `encode` that is not a boolean is read for its truthiness, the same way `stringify()` reads it, so that `null` turns encoding off. Reading it as nullish instead would decode the query and then write it back unencoded, which puts a decoded `&` or `#` into the URL. `definedOptions()` has already dropped an `encode` that is `undefined`, so that is the only way to mean the default.
|
|
780
|
+
const shouldDecode = options.encode === undefined ? true : Boolean(options.encode);
|
|
781
|
+
const {url, query} = parseUrl(input, {
|
|
782
|
+
...options,
|
|
783
|
+
decode: shouldDecode,
|
|
784
|
+
parseNumbers: false,
|
|
785
|
+
parseBooleans: false,
|
|
786
|
+
types: undefined,
|
|
787
|
+
});
|
|
788
|
+
const typedQuery = parse(extract(input), {...options, decode: shouldDecode});
|
|
603
789
|
|
|
790
|
+
// Keep the fragment identifier as-is. Decoding and re-encoding it could change it.
|
|
604
791
|
return stringifyUrl({
|
|
605
|
-
url,
|
|
606
|
-
query: includeKeys(query, filter),
|
|
607
|
-
fragmentIdentifier,
|
|
792
|
+
url: url + getHash(input),
|
|
793
|
+
query: includeKeys(query, key => filter(key, typedQuery[key])),
|
|
608
794
|
}, options);
|
|
609
795
|
}
|
|
610
796
|
|