decode-uri-component
Version:
A better decodeURIComponent
173 lines (136 loc) • 4.14 kB
JavaScript
// Matches one or more consecutive percent-encoded bytes (e.g. `%C3%A5`).
const token = '%[a-f0-9]{2}';
const multiMatcher = new RegExp(`(${token})+`, 'gi');
const hexPair = /^[a-f\d]{2}$/i;
// Read a `%XX` sequence at `position`, returning the byte value and where to continue scanning.
function parsePercentByte(input, position) {
if (input.codePointAt(position) !== 37 || position + 3 > input.length) {
return;
}
const digits = input.slice(position + 1, position + 3);
if (!hexPair.test(digits)) {
return;
}
return {byte: Number.parseInt(digits, 16), next: position + 3};
}
/**
* Return how many bytes a UTF-8 code point needs based on its lead byte.
* Returns 0 for continuation bytes and other invalid lead bytes.
*/
function utf8SequenceLength(byte) {
if (byte <= 0x7F) {
return 1;
}
// Start at 0xC2 to exclude overlong 2-byte encodings (0xC0/0xC1).
if (byte >= 0xC2 && byte <= 0xDF) {
return 2;
}
if (byte >= 0xE0 && byte <= 0xEF) {
return 3;
}
if (byte >= 0xF0 && byte <= 0xF4) {
return 4;
}
return 0;
}
function isContinuationByte(byte) {
return byte >= 0x80 && byte <= 0xBF;
}
/**
* Decode as much of `input` as possible without throwing.
*Scans left-to-right in O(n), decoding valid UTF-8 runs and leaving the rest literal.
*/
function decode(input) {
try {
return decodeURIComponent(input);
} catch {
let output = '';
let position = 0;
while (position < input.length) {
if (input.codePointAt(position) !== 37) {
output += input.charAt(position);
position++;
continue;
}
const firstByte = parsePercentByte(input, position);
// `%` not followed by two hex digits (e.g. `%` or `%G`).
if (!firstByte) {
output += input.charAt(position);
position++;
continue;
}
const sequenceLength = utf8SequenceLength(firstByte.byte);
// Continuation byte or invalid lead byte — emit one `%XX` literally.
if (sequenceLength === 0) {
output += input.slice(position, position + 3);
position += 3;
continue;
}
let end = firstByte.next;
let validSequence = true;
for (let index = 1; index < sequenceLength; index++) {
const nextByte = parsePercentByte(input, end);
if (!nextByte || !isContinuationByte(nextByte.byte)) {
validSequence = false;
break;
}
end = nextByte.next;
}
if (validSequence) {
const encodedSequence = input.slice(position, end);
try {
output += decodeURIComponent(encodedSequence);
position = end;
continue;
} catch {
// Invalid UTF-8 despite correct structure — emit the first byte literally.
}
}
// Missing continuation bytes or decode failure — emit the first byte literally.
output += input.slice(position, position + 3);
position += 3;
}
return output;
}
}
function customDecodeURIComponent(input) {
// Keep track of all the replacements and prefill the map with the `BOM`
const replaceMap = {
'%FE%FF': '\uFFFD\uFFFD',
'%FF%FE': '\uFFFD\uFFFD',
};
// Find percent-encoded runs separated by literal text or lone `%` characters.
let match = multiMatcher.exec(input);
while (match) {
try {
// Decode as big chunks as possible
replaceMap[match[0]] = decodeURIComponent(match[0]);
} catch {
const result = decode(match[0]);
if (result !== match[0]) {
replaceMap[match[0]] = result;
}
}
match = multiMatcher.exec(input);
}
// Add `%C2` at the end of the map to make sure it does not replace the combinator before everything else
replaceMap['%C2'] = '\uFFFD';
const entries = Object.keys(replaceMap);
for (const key of entries) {
// Replace all decoded components
input = input.replace(new RegExp(key, 'g'), replaceMap[key]);
}
return input;
}
export default function decodeUriComponent(encodedURI) {
if (typeof encodedURI !== 'string') {
throw new TypeError(`Expected \`encodedURI\` to be of type \`string\`, got \`${typeof encodedURI}\``);
}
try {
// Try the built in decoder first
return decodeURIComponent(encodedURI);
} catch {
// Fallback to a more advanced decoder
return customDecodeURIComponent(encodedURI);
}
}