11// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs
22const DATA_URL_DEFAULT_MIME_TYPE = 'text/plain' ;
33const DATA_URL_DEFAULT_CHARSET = 'us-ascii' ;
4+ const encodedReservedCharactersPattern = '%(?:3A|2F|3F|23|5B|5D|40|21|24|26|27|28|29|2A|2B|2C|3B|3D)' ;
5+ const temporaryEncodedReservedTokenBase = '__normalize_url_encoded_reserved__' ;
6+ const temporaryEncodedReservedTokenPattern = / _ _ n o r m a l i z e _ u r l _ e n c o d e d _ r e s e r v e d _ _ ( \d + ) _ _ / g;
7+ const hasEncodedReservedCharactersRegex = new RegExp ( encodedReservedCharactersPattern , 'i' ) ;
8+ const encodedReservedCharactersRegex = new RegExp ( encodedReservedCharactersPattern , 'gi' ) ;
49
510const testParameter = ( name , filters ) => Array . isArray ( filters ) && filters . some ( filter => {
611 if ( filter instanceof RegExp ) {
@@ -56,6 +61,63 @@ const getKeysWithoutEquals = search => {
5661 return keys ;
5762} ;
5863
64+ const getTemporaryEncodedReservedTokenPrefix = search => {
65+ let decodedSearch = search ;
66+
67+ try {
68+ decodedSearch = decodeURIComponent ( search ) ;
69+ } catch {
70+ decodedSearch = new URLSearchParams ( search ) . toString ( ) ;
71+ }
72+
73+ const getUsedTokenIndexes = value => {
74+ const indexes = new Set ( ) ;
75+
76+ for ( const match of value . matchAll ( temporaryEncodedReservedTokenPattern ) ) {
77+ indexes . add ( Number . parseInt ( match [ 1 ] , 10 ) ) ;
78+ }
79+
80+ return indexes ;
81+ } ;
82+
83+ const usedTokenIndexes = getUsedTokenIndexes ( search ) ;
84+ for ( const tokenIndex of getUsedTokenIndexes ( decodedSearch ) ) {
85+ usedTokenIndexes . add ( tokenIndex ) ;
86+ }
87+
88+ let tokenIndex = 0 ;
89+ while ( usedTokenIndexes . has ( tokenIndex ) ) {
90+ tokenIndex ++ ;
91+ }
92+
93+ return `${ temporaryEncodedReservedTokenBase } ${ tokenIndex } __` ;
94+ } ;
95+
96+ const sortSearchParameters = ( searchParameters , encodedReservedTokenRegex ) => {
97+ if ( ! encodedReservedTokenRegex ) {
98+ searchParameters . sort ( ) ;
99+ return searchParameters . toString ( ) ;
100+ }
101+
102+ const getSortableKey = key => key . replace ( encodedReservedTokenRegex , ( _ , hexCode ) => String . fromCodePoint ( Number . parseInt ( hexCode , 16 ) ) ) ;
103+ const entries = [ ...searchParameters . entries ( ) ] ;
104+ entries . sort ( ( [ leftKey ] , [ rightKey ] ) => {
105+ const left = getSortableKey ( leftKey ) ;
106+ const right = getSortableKey ( rightKey ) ;
107+ return left < right ? - 1 : left > right ? 1 : 0 ;
108+ } ) ;
109+
110+ return new URLSearchParams ( entries ) . toString ( ) ;
111+ } ;
112+
113+ const decodeReservedTokens = ( value , encodedReservedTokenRegex ) => {
114+ if ( ! encodedReservedTokenRegex ) {
115+ return value ;
116+ }
117+
118+ return value . replace ( encodedReservedTokenRegex , ( _ , hexCode ) => String . fromCodePoint ( Number . parseInt ( hexCode , 16 ) ) ) ;
119+ } ;
120+
59121const normalizeEmptyQueryParameters = ( search , emptyQueryValue , originalSearch ) => {
60122 const isAlways = emptyQueryValue === 'always' ;
61123 const isNever = emptyQueryValue === 'never' ;
@@ -94,8 +156,8 @@ const normalizeEmptyQueryParameters = (search, emptyQueryValue, originalSearch)
94156 return formatEmptyValue ( normalizeKey ( key ) ) ;
95157 }
96158
97- // Normalize + to %20 in key, decode %3D to = in values (= is safe unencoded in query values)
98- return `${ normalizeKey ( key ) } =${ value . replace ( / % 3 D / gi , '=' ) } ` ;
159+ // Normalize + to %20 in key.
160+ return `${ normalizeKey ( key ) } =${ value } ` ;
99161 } ;
100162
101163 const params = search . slice ( 1 ) . split ( '&' ) . filter ( Boolean ) ;
@@ -308,14 +370,22 @@ export default function normalizeUrl(urlString, options) {
308370
309371 // Capture original query params format before any searchParams modifications
310372 const originalSearch = urlObject . search ;
373+ let encodedReservedTokenRegex ;
374+
375+ if ( options . sortQueryParameters && hasEncodedReservedCharactersRegex . test ( originalSearch ) ) {
376+ const encodedReservedTokenPrefix = getTemporaryEncodedReservedTokenPrefix ( originalSearch ) ;
377+ urlObject . search = originalSearch . replace ( encodedReservedCharactersRegex , match => `${ encodedReservedTokenPrefix } ${ match . slice ( 1 ) . toUpperCase ( ) } ` ) ;
378+ encodedReservedTokenRegex = new RegExp ( `${ encodedReservedTokenPrefix } ([0-9A-F]{2})` , 'g' ) ;
379+ }
380+
311381 const hasKeepQueryParameters = Array . isArray ( options . keepQueryParameters ) ;
312382 const searchParams = urlObject . searchParams ;
313383
314384 // Remove query unwanted parameters
315385 if ( ! hasKeepQueryParameters && Array . isArray ( options . removeQueryParameters ) && options . removeQueryParameters . length > 0 ) {
316386 // eslint-disable-next-line unicorn/no-useless-spread -- We are intentionally spreading to get a copy.
317387 for ( const key of [ ...searchParams . keys ( ) ] ) {
318- if ( testParameter ( key , options . removeQueryParameters ) ) {
388+ if ( testParameter ( decodeReservedTokens ( key , encodedReservedTokenRegex ) , options . removeQueryParameters ) ) {
319389 searchParams . delete ( key ) ;
320390 }
321391 }
@@ -329,7 +399,7 @@ export default function normalizeUrl(urlString, options) {
329399 if ( hasKeepQueryParameters && options . keepQueryParameters . length > 0 ) {
330400 // eslint-disable-next-line unicorn/no-useless-spread -- We are intentionally spreading to get a copy.
331401 for ( const key of [ ...searchParams . keys ( ) ] ) {
332- if ( ! testParameter ( key , options . keepQueryParameters ) ) {
402+ if ( ! testParameter ( decodeReservedTokens ( key , encodedReservedTokenRegex ) , options . keepQueryParameters ) ) {
333403 searchParams . delete ( key ) ;
334404 }
335405 }
@@ -339,11 +409,15 @@ export default function normalizeUrl(urlString, options) {
339409
340410 // Sort query parameters
341411 if ( options . sortQueryParameters ) {
342- urlObject . searchParams . sort ( ) ;
412+ urlObject . search = sortSearchParameters ( urlObject . searchParams , encodedReservedTokenRegex ) ;
413+
414+ // Sorting and serializing encode the search parameters, so we need to decode them again.
415+ // Protect &%#? and %2B from decoding (would break URL structure or change meaning) by double-encoding them first.
416+ urlObject . search = decodeURIComponent ( urlObject . search . replace ( / % (?: 2 6 | 2 3 | 3 F | 2 5 | 2 B ) / gi, match => `%25${ match . slice ( 1 ) } ` ) ) ;
343417
344- // Calling `.sort()` encodes the search parameters, so we need to decode them again.
345- // Protect &=%#? %25 and %2B from decoding (would break URL structure or change meaning) by double-encoding them first.
346- urlObject . search = decodeURIComponent ( urlObject . search . replace ( / % (?: 2 6 | 3 D | 2 3 | 3 F | 2 5 | 2 B ) / gi , match => `%25 ${ match . slice ( 1 ) } ` ) ) ;
418+ if ( encodedReservedTokenRegex ) {
419+ urlObject . search = urlObject . search . replace ( encodedReservedTokenRegex , '%$1' ) ;
420+ }
347421 }
348422
349423 // Normalize empty query parameter values
0 commit comments