1+ // Matches one or more consecutive percent-encoded bytes (e.g. `%C3%A5`).
12const token = '%[a-f0-9]{2}' ;
2- const singleMatcher = new RegExp ( '(' + token + ')|([^%]+?)' , 'gi' ) ;
3- const multiMatcher = new RegExp ( '(' + token + ')+' , 'gi' ) ;
3+ const multiMatcher = new RegExp ( `(${ token } )+` , 'gi' ) ;
44
5- function decodeComponents ( components , split ) {
6- try {
7- // Try to decode the entire string first
8- return [ decodeURIComponent ( components . join ( '' ) ) ] ;
9- } catch {
10- // Do nothing
5+ const hexPair = / ^ [ a - f \d ] { 2 } $ / i ;
6+
7+ // Read a `%XX` sequence at `position`, returning the byte value and where to continue scanning.
8+ function parsePercentByte ( input , position ) {
9+ if ( input . codePointAt ( position ) !== 37 || position + 3 > input . length ) {
10+ return ;
1111 }
1212
13- if ( components . length === 1 ) {
14- return components ;
13+ const digits = input . slice ( position + 1 , position + 3 ) ;
14+
15+ if ( ! hexPair . test ( digits ) ) {
16+ return ;
1517 }
1618
17- split = split || 1 ;
19+ return { byte : Number . parseInt ( digits , 16 ) , next : position + 3 } ;
20+ }
21+
22+ /**
23+ * Return how many bytes a UTF-8 code point needs based on its lead byte.
24+ * Returns 0 for continuation bytes and other invalid lead bytes.
25+ */
26+ function utf8SequenceLength ( byte ) {
27+ if ( byte <= 0x7F ) {
28+ return 1 ;
29+ }
1830
19- // Split the array in 2 parts
20- const left = components . slice ( 0 , split ) ;
21- const right = components . slice ( split ) ;
31+ // Start at 0xC2 to exclude overlong 2-byte encodings (0xC0/0xC1).
32+ if ( byte >= 0xC2 && byte <= 0xDF ) {
33+ return 2 ;
34+ }
2235
23- return Array . prototype . concat . call ( [ ] , decodeComponents ( left ) , decodeComponents ( right ) ) ;
36+ if ( byte >= 0xE0 && byte <= 0xEF ) {
37+ return 3 ;
38+ }
39+
40+ if ( byte >= 0xF0 && byte <= 0xF4 ) {
41+ return 4 ;
42+ }
43+
44+ return 0 ;
2445}
2546
47+ function isContinuationByte ( byte ) {
48+ return byte >= 0x80 && byte <= 0xBF ;
49+ }
50+
51+ /**
52+ * Decode as much of `input` as possible without throwing.
53+ *Scans left-to-right in O(n), decoding valid UTF-8 runs and leaving the rest literal.
54+ */
2655function decode ( input ) {
2756 try {
2857 return decodeURIComponent ( input ) ;
2958 } catch {
30- let tokens = input . match ( singleMatcher ) || [ ] ;
59+ let output = '' ;
60+ let position = 0 ;
61+
62+ while ( position < input . length ) {
63+ if ( input . codePointAt ( position ) !== 37 ) {
64+ output += input . charAt ( position ) ;
65+ position ++ ;
66+ continue ;
67+ }
3168
32- for ( let i = 1 ; i < tokens . length ; i ++ ) {
33- input = decodeComponents ( tokens , i ) . join ( '' ) ;
69+ const firstByte = parsePercentByte ( input , position ) ;
3470
35- tokens = input . match ( singleMatcher ) || [ ] ;
71+ // `%` not followed by two hex digits (e.g. `%` or `%G`).
72+ if ( ! firstByte ) {
73+ output += input . charAt ( position ) ;
74+ position ++ ;
75+ continue ;
76+ }
77+
78+ const sequenceLength = utf8SequenceLength ( firstByte . byte ) ;
79+
80+ // Continuation byte or invalid lead byte — emit one `%XX` literally.
81+ if ( sequenceLength === 0 ) {
82+ output += input . slice ( position , position + 3 ) ;
83+ position += 3 ;
84+ continue ;
85+ }
86+
87+ let end = firstByte . next ;
88+ let validSequence = true ;
89+
90+ for ( let index = 1 ; index < sequenceLength ; index ++ ) {
91+ const nextByte = parsePercentByte ( input , end ) ;
92+
93+ if ( ! nextByte || ! isContinuationByte ( nextByte . byte ) ) {
94+ validSequence = false ;
95+ break ;
96+ }
97+
98+ end = nextByte . next ;
99+ }
100+
101+ if ( validSequence ) {
102+ const encodedSequence = input . slice ( position , end ) ;
103+
104+ try {
105+ output += decodeURIComponent ( encodedSequence ) ;
106+ position = end ;
107+ continue ;
108+ } catch {
109+ // Invalid UTF-8 despite correct structure — emit the first byte literally.
110+ }
111+ }
112+
113+ // Missing continuation bytes or decode failure — emit the first byte literally.
114+ output += input . slice ( position , position + 3 ) ;
115+ position += 3 ;
36116 }
37117
38- return input ;
118+ return output ;
39119 }
40120}
41121
@@ -46,7 +126,9 @@ function customDecodeURIComponent(input) {
46126 '%FF%FE' : '\uFFFD\uFFFD' ,
47127 } ;
48128
129+ // Find percent-encoded runs separated by literal text or lone `%` characters.
49130 let match = multiMatcher . exec ( input ) ;
131+
50132 while ( match ) {
51133 try {
52134 // Decode as big chunks as possible
@@ -77,7 +159,7 @@ function customDecodeURIComponent(input) {
77159
78160export default function decodeUriComponent ( encodedURI ) {
79161 if ( typeof encodedURI !== 'string' ) {
80- throw new TypeError ( ' Expected `encodedURI` to be of type `string`, got `' + typeof encodedURI + '`' ) ;
162+ throw new TypeError ( ` Expected \ `encodedURI\ ` to be of type \ `string\ `, got \` ${ typeof encodedURI } \`` ) ;
81163 }
82164
83165 try {
0 commit comments