@@ -6,6 +6,15 @@ const isUUID = RegExp.prototype.test.bind(/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\d
66/** @type {(value: string) => boolean } */
77const isIPv4 = RegExp . prototype . test . bind ( / ^ (?: (?: 2 5 [ 0 - 5 ] | 2 [ 0 - 4 ] \d | 1 \d { 2 } | [ 1 - 9 ] \d | \d ) \. ) { 3 } (?: 2 5 [ 0 - 5 ] | 2 [ 0 - 4 ] \d | 1 \d { 2 } | [ 1 - 9 ] \d | \d ) $ / u)
88
9+ /** @type {(value: string) => boolean } */
10+ const isHexPair = RegExp . prototype . test . bind ( / ^ [ \d a - f ] { 2 } $ / iu)
11+
12+ /** @type {(value: string) => boolean } */
13+ const isUnreserved = RegExp . prototype . test . bind ( / ^ [ \d a - z \- . _ ~ ] $ / iu)
14+
15+ /** @type {(value: string) => boolean } */
16+ const isPathCharacter = RegExp . prototype . test . bind ( / ^ [ \d a - z \- . _ ~ ! $ & ' ( ) * + , ; = : @ / ] $ / iu)
17+
918/**
1019 * @param {Array<string> } input
1120 * @returns {string }
@@ -264,31 +273,106 @@ function removeDotSegments (path) {
264273}
265274
266275/**
267- * @param {import('../types/index').URIComponent } component
268- * @param {boolean } esc
269- * @returns {import('../types/index').URIComponent }
276+ * Normalizes percent escapes and optionally decodes only unreserved ASCII bytes.
277+ * Reserved delimiters such as `%2F` and `%2E` stay escaped.
278+ *
279+ * @param {string } input
280+ * @param {boolean } [decodeUnreserved=false]
281+ * @returns {string }
270282 */
271- function normalizeComponentEncoding ( component , esc ) {
272- const func = esc !== true ? escape : unescape
273- if ( component . scheme !== undefined ) {
274- component . scheme = func ( component . scheme )
275- }
276- if ( component . userinfo !== undefined ) {
277- component . userinfo = func ( component . userinfo )
283+ function normalizePercentEncoding ( input , decodeUnreserved = false ) {
284+ if ( input . indexOf ( '%' ) === - 1 ) {
285+ return input
278286 }
279- if ( component . host !== undefined ) {
280- component . host = func ( component . host )
281- }
282- if ( component . path !== undefined ) {
283- component . path = func ( component . path )
287+
288+ let output = ''
289+
290+ for ( let i = 0 ; i < input . length ; i ++ ) {
291+ if ( input [ i ] === '%' && i + 2 < input . length ) {
292+ const hex = input . slice ( i + 1 , i + 3 )
293+ if ( isHexPair ( hex ) ) {
294+ const normalizedHex = hex . toUpperCase ( )
295+ const decoded = String . fromCharCode ( parseInt ( normalizedHex , 16 ) )
296+
297+ if ( decodeUnreserved && isUnreserved ( decoded ) ) {
298+ output += decoded
299+ } else {
300+ output += '%' + normalizedHex
301+ }
302+
303+ i += 2
304+ continue
305+ }
306+ }
307+
308+ output += input [ i ]
284309 }
285- if ( component . query !== undefined ) {
286- component . query = func ( component . query )
310+
311+ return output
312+ }
313+
314+ /**
315+ * Normalizes path data without turning reserved escapes into live path syntax.
316+ * Valid escapes are uppercased, raw unsafe characters are escaped, and only
317+ * unreserved bytes that are not `.` are decoded.
318+ *
319+ * @param {string } input
320+ * @returns {string }
321+ */
322+ function normalizePathEncoding ( input ) {
323+ let output = ''
324+
325+ for ( let i = 0 ; i < input . length ; i ++ ) {
326+ if ( input [ i ] === '%' && i + 2 < input . length ) {
327+ const hex = input . slice ( i + 1 , i + 3 )
328+ if ( isHexPair ( hex ) ) {
329+ const normalizedHex = hex . toUpperCase ( )
330+ const decoded = String . fromCharCode ( parseInt ( normalizedHex , 16 ) )
331+
332+ if ( decoded !== '.' && isUnreserved ( decoded ) ) {
333+ output += decoded
334+ } else {
335+ output += '%' + normalizedHex
336+ }
337+
338+ i += 2
339+ continue
340+ }
341+ }
342+
343+ if ( isPathCharacter ( input [ i ] ) ) {
344+ output += input [ i ]
345+ } else {
346+ output += escape ( input [ i ] )
347+ }
287348 }
288- if ( component . fragment !== undefined ) {
289- component . fragment = func ( component . fragment )
349+
350+ return output
351+ }
352+
353+ /**
354+ * Escapes a component while preserving existing valid percent escapes.
355+ *
356+ * @param {string } input
357+ * @returns {string }
358+ */
359+ function escapePreservingEscapes ( input ) {
360+ let output = ''
361+
362+ for ( let i = 0 ; i < input . length ; i ++ ) {
363+ if ( input [ i ] === '%' && i + 2 < input . length ) {
364+ const hex = input . slice ( i + 1 , i + 3 )
365+ if ( isHexPair ( hex ) ) {
366+ output += '%' + hex . toUpperCase ( )
367+ i += 2
368+ continue
369+ }
370+ }
371+
372+ output += escape ( input [ i ] )
290373 }
291- return component
374+
375+ return output
292376}
293377
294378/**
@@ -327,7 +411,9 @@ function recomposeAuthority (component) {
327411module . exports = {
328412 nonSimpleDomain,
329413 recomposeAuthority,
330- normalizeComponentEncoding,
414+ normalizePercentEncoding,
415+ normalizePathEncoding,
416+ escapePreservingEscapes,
331417 removeDotSegments,
332418 isIPv4,
333419 isUUID,
0 commit comments