entities
Version:
Encode & decode XML and HTML entities with ease & speed
1,129 lines • 44.3 kB
JavaScript
import { codePointToString, replaceCodePoint, replaceCodePointXML, } from "./decode-codepoint.js";
import { htmlDecodeData } from "./generated/decode-data-html.js";
import { BUCKET_HASH_1, BUCKET_HASH_2, CHAR_REMAP, initDecodeData, pairIndex, } from "./internal/decode-data-format.js";
// Scalar constants avoid runtime enum objects with isolatedModules enabled.
const CHAR_AMP = 38; // "&"
const CHAR_NUM = 35; // "#"
const CHAR_SEMI = 59; // ";"
const CHAR_EQUALS = 61; // "="
const CHAR_ZERO = 48; // "0"
const CHAR_LOWER_A = 97; // "a"
const CHAR_LOWER_G = 103; // "g"
const CHAR_LOWER_L = 108; // "l"
const CHAR_LOWER_M = 109; // "m"
const CHAR_LOWER_O = 111; // "o"
const CHAR_LOWER_P = 112; // "p"
const CHAR_LOWER_Q = 113; // "q"
const CHAR_LOWER_S = 115; // "s"
const CHAR_LOWER_T = 116; // "t"
const CHAR_LOWER_U = 117; // "u"
const CHAR_LOWER_X = 120; // "x"
const CHAR_UPPER_A = 65; // "A"
/** Bit that needs to be set to convert an upper case ASCII character to lower case */
const TO_LOWER_BIT = 0b10_0000;
/**
* Unsigned subtraction trick: (code - lo) >>> 0 wraps negatives to large
* values, so a single `<=` covers the entire [lo..hi] range check.
* @param code Code point to check.
*/
function isNumber(code) {
return (code - CHAR_ZERO) >>> 0 <= 9;
}
function isAlphaNumeric(code) {
return (isNumber(code) || ((code | TO_LOWER_BIT) - CHAR_LOWER_A) >>> 0 <= 25 // Z - a
);
}
/**
* Checks if the given character is a valid end character for an entity in an attribute.
*
* Attribute values that aren't terminated properly aren't parsed, and shouldn't lead to a parser error.
* See the example in https://html.spec.whatwg.org/multipage/parsing.html#named-character-reference-state
* @param code Code point to check.
*/
function isEntityInAttributeInvalidEnd(code) {
return code === CHAR_EQUALS || isAlphaNumeric(code);
}
/**
* Decoding mode for named entities.
*/
export var DecodingMode;
(function (DecodingMode) {
/** Entities in text nodes that can end with any character. */
DecodingMode[DecodingMode["Legacy"] = 0] = "Legacy";
/** Only allow entities terminated with a semicolon. */
DecodingMode[DecodingMode["Strict"] = 1] = "Strict";
/** Entities in attributes have limitations on ending characters. */
DecodingMode[DecodingMode["Attribute"] = 2] = "Attribute";
})(DecodingMode || (DecodingMode = {}));
/** Decode data for HTML entities. */
const htmlDecode = /* #__PURE__ */ initDecodeData(htmlDecodeData);
/*
* Every decoder below is specialized over these module-level constants. V8
* compiles direct references to them roughly 20% faster than property loads
* off a shared object.
*/
const htmlKeys = /* #__PURE__ */ (() => htmlDecode.keys)();
const htmlBuckets = /* #__PURE__ */ (() => htmlDecode.buckets)();
const htmlSlotMidOff = /* #__PURE__ */ (() => htmlDecode.slotMidOff)();
const htmlLengthBits = /* #__PURE__ */ (() => htmlDecode.lengthBits)();
const htmlMiddles = /* #__PURE__ */ (() => htmlDecode.middles)();
/** Hoisted for the specialized HTML cores; see `emitHtmlValue`. */
const htmlSlotValue = /* #__PURE__ */ (() => htmlDecode.slotValue)();
const htmlValues = /* #__PURE__ */ (() => htmlDecode.values)();
/**
* The replacement string for a packed `slotValue` entry. Most values need
* just one UTF-16 code unit.
* @param packed Replacement offset, legacy flag, and length from `htmlSlotValue`.
*/
function emitHtmlValue(packed) {
const off = packed >> 2;
return (packed & 1) === 0
? htmlValues.charAt(off)
: htmlValues.slice(off, off + (packed & 1) + 1);
}
/**
* Find the slot matching `text[start..start+length)`, or -1. A match proves
* every character of the span (exact key + middle comparison).
* @param text Input text.
* @param start Start of the span in `text`.
* @param length Length of the span.
*/
function findSlotHtml(text, start, length) {
const c0 = text.charCodeAt(start);
const c1 = text.charCodeAt(start + 1);
const c2 = text.charCodeAt(start + length - 2);
const c3 = text.charCodeAt(start + length - 1);
/*
* Probed spans aren't pre-filtered to alphanumerics; characters >= 0x80
* would alias mod 128 inside the packed 7-bit fields, so reject them
* before they can forge a key.
*/
if ((c0 | c1 | c2 | c3) > 127)
return -1;
const key = (c0 << 25) | (c1 << 18) | (c2 << 11) | (CHAR_REMAP[c3] << 5) | length;
let slot = 2 * (((Math.imul(key, BUCKET_HASH_1) >>> 16) * htmlBuckets) >>> 16);
for (let attempt = 0;; attempt++) {
if (htmlKeys[slot] === key &&
(length <= 4 || isMidMatchHtml(slot, text, start, length))) {
return slot;
}
if (htmlKeys[slot + 1] === key &&
(length <= 4 || isMidMatchHtml(slot + 1, text, start, length))) {
return slot + 1;
}
if (attempt === 1)
return -1;
slot =
2 * (((Math.imul(key, BUCKET_HASH_2) >>> 16) * htmlBuckets) >>> 16);
}
}
/**
* Compare the middle characters (positions 2..length-3) of the candidate at
* `slot` against the input. The key already proves the outer characters and
* the length. Names of length at most four are accepted by the caller.
* @param slot Slot of the candidate.
* @param text Input text.
* @param start Start of the span in `text`.
* @param length Length of the span.
*/
function isMidMatchHtml(slot, text, start, length) {
let wordIndex = htmlSlotMidOff[slot];
let index = start + 2;
const end = start + length - 2;
while (index + 1 < end) {
if ((text.charCodeAt(index) | (text.charCodeAt(index + 1) << 16)) !==
htmlMiddles[wordIndex++])
return false;
index += 2;
}
return (index === end ||
text.charCodeAt(index) === (htmlMiddles[wordIndex] & 0xff_ff));
}
/**
* `parseNumericEntity` packs its two results into one integer:
* `(consumed << CONSUMED_SHIFT) | codePoint`. The code point occupies the low
* 21 bits (max 0x110000, clamped before packing); `consumed` takes the upper
* 11 bits and is read back with `>>> CONSUMED_SHIFT`, so the sign bit set by a
* large `consumed` is harmless. The maximum consumed field is reserved for
* long references, whose full length is stored in `longNumericConsumed`.
* A zero return means "no numeric entity".
*/
const NUMERIC_CONSUMED_SHIFT = 21;
const NUMERIC_CODE_POINT_MASK = 0x1f_ff_ff;
const NUMERIC_CONSUMED_OVERFLOW = 0x7_ff;
/** Full length, including `&`, when the packed consumed field overflows. */
let longNumericConsumed = 0;
/**
* Recover the consumed count before the next numeric parse can overwrite
* `longNumericConsumed`. This avoids allocating a tuple for each reference.
* @param packed Packed result of `parseNumericEntity`.
*/
function unpackConsumed(packed) {
const consumed = packed >>> NUMERIC_CONSUMED_SHIFT;
return consumed === NUMERIC_CONSUMED_OVERFLOW
? longNumericConsumed
: consumed;
}
/** ASCII digit values; 0xff marks characters outside the hexadecimal range. */
const numericDigits = /* #__PURE__ */ (() => {
const digits = new Uint8Array(128).fill(0xff);
for (let digit = 0; digit < 10; digit++) {
digits[CHAR_ZERO + digit] = digit;
}
for (let digit = 0; digit < 6; digit++) {
digits[CHAR_UPPER_A + digit] = digit + 10;
digits[CHAR_LOWER_A + digit] = digit + 10;
}
return digits;
})();
/**
* Parse a numeric entity starting right after the `#`. In legacy mode the
* terminating semicolon is optional. Returns the number of characters
* consumed (counting the `&` and `#`) packed with the code point (see
* the packing constants above), or 0 if there is no valid numeric entity at this
* position.
* @param input Input string.
* @param offset Index right after the `#`.
* @param isStrict Whether a terminating semicolon is required.
*/
function parseNumericEntity(input, offset, isStrict) {
const inputLength = input.length;
let index = offset;
let codePoint = 0;
let digitsStart;
if ((input.charCodeAt(index) | TO_LOWER_BIT) === CHAR_LOWER_X) {
// Hexadecimal entity.
index += 1;
digitsStart = index;
while (index < inputLength) {
const char = input.charCodeAt(index);
if (char >= 128)
break;
const digit = numericDigits[char];
if (digit <= 15) {
codePoint = codePoint * 16 + digit;
index++;
}
else {
break;
}
}
}
else {
digitsStart = index;
while (index < inputLength) {
const char = input.charCodeAt(index);
if (char >= 128)
break;
const digit = numericDigits[char];
if (digit <= 9) {
codePoint = codePoint * 10 + digit;
index++;
}
else {
break;
}
}
}
if (index === digitsStart)
return 0;
// Clamp once after the loop instead of per digit.
if (codePoint > 0x10_ff_ff)
codePoint = 0x11_00_00;
let consumed = index - offset + 2; // Includes "#" and the "&" position.
if (index < inputLength && input.charCodeAt(index) === CHAR_SEMI) {
consumed += 1;
}
else if (isStrict) {
return 0;
}
if (consumed >= NUMERIC_CONSUMED_OVERFLOW) {
// eslint-disable-next-line unicorn/no-top-level-assignment-in-function -- deliberate side channel, read immediately by unpackConsumed
longNumericConsumed = consumed;
consumed = NUMERIC_CONSUMED_OVERFLOW;
}
return (consumed << NUMERIC_CONSUMED_SHIFT) | codePoint;
}
/**
* Find the longest legacy (semicolon-less) match for the name starting at
* `start`, using the per-class legacy length bits. Returns
* `slot << 3 | matchLength`, or -1 if there is no match. Only called after
* the exact probes failed (the miss path).
* @param input Input text.
* @param start Start of the name.
* @param maxLength Number of available run characters.
*/
function findLegacySlot(input, start, maxLength) {
let legacy = (htmlLengthBits[pairIndex(input.charCodeAt(start), input.charCodeAt(start + 1))] >>>
16) &
31;
while (legacy !== 0) {
// Longest first: the spec matches references greedily.
const top = 31 - Math.clz32(legacy);
legacy ^= 1 << top;
if (top + 2 > maxLength)
continue;
const slot = findSlotHtml(input, start, top + 2);
if (slot >= 0 && (htmlSlotValue[slot] & 2) !== 0) {
return (slot << 3) | (top + 2);
}
}
return -1;
}
/**
* Match a class containing long names without probing all its shorter lengths.
* The 32-character window bounds the scan even for invalid or unterminated runs.
* Returns `(slot << 6) | consumed`, excluding the `&`, or -1 on a miss.
* @param input Input containing the candidate name.
* @param start Start of the name.
* @param mode Decoding mode, including the rules for legacy matches.
*/
function findLongClassMatch(input, start, mode) {
const length = input.slice(start, start + 32).indexOf(";");
if ((length - 2) >>> 0 <= 29) {
const slot = findSlotHtml(input, start, length);
if (slot >= 0)
return (slot << 6) | (length + 1);
}
if (mode !== DecodingMode.Strict) {
const packed = findLegacySlot(input, start, Math.min(31, input.length - start));
if (packed >= 0) {
const length = packed & 7;
if (mode !== DecodingMode.Attribute ||
start + length >= input.length ||
!isEntityInAttributeInvalidEnd(input.charCodeAt(start + length)))
return ((packed >> 3) << 6) | length;
}
}
return -1;
}
/**
* Match one of XML's five predefined entities (name plus the terminating
* semicolon) at `start`. Returns `(consumedLength << 7) | codePoint` where
* `consumedLength` counts the name and the semicolon, or -1 if nothing
* matches. All five patterns are decided within five characters of `start`,
* so the entity set ships no decode data.
* @param input Input text.
* @param start Index of the name's first character (right after the `&`).
*/
function matchXmlEntity(input, start) {
/*
* Direct char-code compares: `startsWith` costs a builtin call per
* probe, measured ~10% of entity-dense XML decode. Loads stay inside
* the cases so the miss path (`default`) pays nothing.
*/
switch (input.charCodeAt(start)) {
case CHAR_LOWER_L: {
return input.charCodeAt(start + 1) === CHAR_LOWER_T &&
input.charCodeAt(start + 2) === CHAR_SEMI
? (3 << 7) | 0x3c
: -1;
}
case CHAR_LOWER_G: {
return input.charCodeAt(start + 1) === CHAR_LOWER_T &&
input.charCodeAt(start + 2) === CHAR_SEMI
? (3 << 7) | 0x3e
: -1;
}
case CHAR_LOWER_A: {
const c1 = input.charCodeAt(start + 1);
const c2 = input.charCodeAt(start + 2);
if (c1 === CHAR_LOWER_M &&
c2 === CHAR_LOWER_P &&
input.charCodeAt(start + 3) === CHAR_SEMI) {
return (4 << 7) | 0x26;
}
return c1 === CHAR_LOWER_P &&
c2 === CHAR_LOWER_O &&
input.charCodeAt(start + 3) === CHAR_LOWER_S &&
input.charCodeAt(start + 4) === CHAR_SEMI
? (5 << 7) | 0x27
: -1;
}
case CHAR_LOWER_Q: {
return input.charCodeAt(start + 1) === CHAR_LOWER_U &&
input.charCodeAt(start + 2) === CHAR_LOWER_O &&
input.charCodeAt(start + 3) === CHAR_LOWER_T &&
input.charCodeAt(start + 4) === CHAR_SEMI
? (5 << 7) | 0x22
: -1;
}
default: {
return -1;
}
}
}
/**
* The next `&` to resume from after emitting a replacement, given the index
* just past it. A leaf the decoders call after every emit: the common
* adjacent-entity case (`&<`) skips the `indexOf` C++ call.
* @param input Input text.
* @param last Index just past the entity that was emitted.
*/
function nextOffset(input, last) {
if (last >= input.length)
return -1;
return input.charCodeAt(last) === CHAR_AMP
? last
: input.indexOf("&", last);
}
/**
* Synchronous HTML decoder, shared by all three decoding modes.
* @param input String to decode.
* @param mode Decoding mode for named entities.
*/
function decodeHtmlText(input, mode) {
const isLegacyAllowed = mode !== DecodingMode.Strict;
let offset = input.indexOf("&");
if (offset < 0)
return input;
const inputLength = input.length;
let result = "";
let last = 0;
do {
const start = offset + 1;
if (start + 1 >= inputLength)
break;
const c0 = input.charCodeAt(start);
if (c0 === CHAR_AMP) {
// Adjacent "&&": re-enter directly, skipping indexOf.
offset = start;
continue;
}
if (c0 === CHAR_NUM) {
const packed = parseNumericEntity(input, start + 1, !isLegacyAllowed);
const consumed = unpackConsumed(packed);
if (consumed === 0) {
offset = input.indexOf("&", start);
}
else {
if (last !== offset) {
result += input.slice(last, offset);
}
result += codePointToString(packed & NUMERIC_CODE_POINT_MASK);
last = offset + consumed;
offset = nextOffset(input, last);
}
continue;
}
/*
* Named entity. The (c0,c1) class lists every length a matching
* name can have; probe `;` at each. A probe hit is fully
* verified by `findSlot`; no scanning is needed. A `;` miss at
* a legacy-marked length falls through to a direct legacy
* lookup: legacy names need no terminator.
*/
const bits = htmlLengthBits[pairIndex(c0, input.charCodeAt(start + 1))];
if ((bits & 0x80_00) !== 0) {
const packed = findLongClassMatch(input, start, mode);
if (packed >= 0) {
if (last !== offset)
result += input.slice(last, offset);
result += emitHtmlValue(htmlSlotValue[packed >> 6]);
last = start + (packed & 63);
offset = nextOffset(input, last);
}
else {
offset = input.indexOf("&", start);
}
continue;
}
let probed = bits & 0x7f_ff;
/*
* Slot in bits 6+, semicolon in bit 5, consumed name span in bits 0-4.
* This path only handles names up to 16 characters, plus their ';'.
*/
let matched = -1;
while (probed !== 0) {
const low = probed & -probed;
probed ^= low;
const length = 33 - Math.clz32(low);
const end = start + length;
// Longer probes cannot match either; lengths are ascending.
// eslint-disable-next-line unicorn/no-break-in-nested-loop -- keep the bounded probe loop inline
if (end > inputLength)
break;
const isTerminated = end < inputLength && input.charCodeAt(end) === CHAR_SEMI;
if (!(isTerminated ||
(isLegacyAllowed && ((bits >>> (length + 14)) & 1) !== 0)))
// eslint-disable-next-line unicorn/no-break-in-nested-loop -- skip impossible candidates in the inline probe loop
continue;
// Share the lookup call site, including after legacy-only inputs.
const slot = findSlotHtml(input, start, length);
// eslint-disable-next-line unicorn/no-break-in-nested-loop -- skip failed candidates in the inline probe loop
if (slot < 0)
continue;
if (isTerminated) {
matched = (slot << 6) | 32 | (length + 1);
// eslint-disable-next-line unicorn/no-break-in-nested-loop -- an exact match ends the probe loop
break;
}
if ((htmlSlotValue[slot] & 2) !== 0) {
matched = (slot << 6) | length;
}
}
if (matched >= 0) {
const length = matched & 31;
if ((matched & 32) !== 0 ||
mode !== DecodingMode.Attribute ||
start + length >= inputLength ||
!isEntityInAttributeInvalidEnd(input.charCodeAt(start + length))) {
if (last !== offset)
result += input.slice(last, offset);
result += emitHtmlValue(htmlSlotValue[matched >> 6]);
last = start + length;
offset = nextOffset(input, last);
continue;
}
}
offset = input.indexOf("&", start + 1);
} while (offset >= 0);
return result + input.slice(last);
}
/**
* Decodes an HTML string.
* @param htmlString The string to decode.
* @param mode The decoding mode.
* @returns The decoded string.
*/
export function decodeHTML(htmlString, mode = DecodingMode.Legacy) {
return decodeHtmlText(htmlString, mode);
}
/**
* Decodes an HTML string in an attribute.
* @param htmlAttribute The string to decode.
* @returns The decoded string.
*/
export function decodeHTMLAttribute(htmlAttribute) {
return decodeHtmlText(htmlAttribute, DecodingMode.Attribute);
}
/**
* Decodes an HTML string, requiring all entities to be terminated by a
* semicolon.
* @param htmlString The string to decode.
* @returns The decoded string.
*/
export function decodeHTMLStrict(htmlString) {
return decodeHtmlText(htmlString, DecodingMode.Strict);
}
/**
* Decodes an XML string, requiring all entities to be terminated by a
* semicolon.
* @param xmlString The string to decode.
* @returns The decoded string.
*/
export function decodeXML(xmlString) {
let offset = xmlString.indexOf("&");
if (offset < 0)
return xmlString;
let lastIndex = 0;
let result = "";
do {
const start = offset + 1;
if (start + 1 >= xmlString.length)
break;
let consumed = 0;
let value = "";
const c1 = xmlString.charCodeAt(start);
if (c1 === CHAR_NUM) {
const packed = parseNumericEntity(xmlString, start + 1, true);
consumed = unpackConsumed(packed);
if (consumed !== 0) {
const codePoint = packed & NUMERIC_CODE_POINT_MASK;
value =
(codePoint - 1) >>> 0 < 0xd7_ff
? String.fromCharCode(codePoint)
: String.fromCodePoint(replaceCodePointXML(codePoint));
}
}
else {
/* eslint-disable unicorn/no-break-in-nested-loop -- Keep XML name dispatch inline with the decode loop. */
switch (c1) {
// < / >
case 0x6c:
case 0x67: {
if (start + 2 < xmlString.length &&
xmlString.charCodeAt(start + 1) === 0x74 &&
xmlString.charCodeAt(start + 2) === CHAR_SEMI) {
consumed = 4;
value = c1 === 0x6c ? "<" : ">";
}
break;
}
// & / '
case 0x61: {
const c2 = xmlString.charCodeAt(start + 1);
if (start + 3 < xmlString.length &&
c2 === 0x6d &&
xmlString.charCodeAt(start + 2) === 0x70 &&
xmlString.charCodeAt(start + 3) === CHAR_SEMI) {
consumed = 5;
value = "&";
}
else if (start + 4 < xmlString.length &&
c2 === 0x70 &&
xmlString.charCodeAt(start + 2) === 0x6f &&
xmlString.charCodeAt(start + 3) === 0x73 &&
xmlString.charCodeAt(start + 4) === CHAR_SEMI) {
consumed = 6;
value = "'";
}
break;
}
// "
case 0x71: {
if (start + 4 < xmlString.length &&
xmlString.charCodeAt(start + 1) === 0x75 &&
xmlString.charCodeAt(start + 2) === 0x6f &&
xmlString.charCodeAt(start + 3) === 0x74 &&
xmlString.charCodeAt(start + 4) === CHAR_SEMI) {
consumed = 6;
value = '"';
}
break;
}
}
/* eslint-enable unicorn/no-break-in-nested-loop */
}
if (consumed > 0) {
if (lastIndex < offset)
result += xmlString.slice(lastIndex, offset);
result += value;
offset = lastIndex = offset + consumed;
}
else {
offset = start;
}
/*
* Adjacent entities (`&x;&y;`) are common in entity-dense input;
* checking the single character at `lastIndex` first skips the
* `indexOf` call (and its per-call overhead) for that case.
*/
offset =
offset < xmlString.length &&
xmlString.charCodeAt(offset) === CHAR_AMP
? offset
: xmlString.indexOf("&", offset);
} while (offset >= 0);
return result + xmlString.slice(lastIndex);
}
const STATE_ENTITY_START = 0;
const STATE_NUMERIC_START = 1;
const STATE_NUMERIC_DECIMAL = 2;
const STATE_NUMERIC_HEX = 3;
const STATE_NAMED_ENTITY = 4;
/**
* Token decoder with support of writing partial entities; the shared base
* of `HtmlEntityDecoder` and `XmlEntityDecoder`. Numeric entities are
* identical in both document types and are handled here; named entities are
* left to the subclasses.
*
* The decoder is driven by a tokenizer: after a `&`, write the following
* input (possibly in chunks). `write` returns the number of characters
* consumed once the entity is decided, or -1 while more input is needed.
* Rejection may surface later than strictly possible (the decoder buffers
* until a terminator or the name-length limit), but emitted code points,
* consumed counts, and final return values are exact.
*/
class EntityDecoderBase {
emitCodePoint;
errors;
/** The current state of the decoder. */
state = STATE_ENTITY_START;
/** Characters that were consumed while parsing an entity. */
consumed = 1;
/** Accumulated numeric code point, or a packed partial XML name. */
result = 0;
/** The mode in which the decoder is operating. */
decodeMode = DecodingMode.Strict;
/** Replacement rules for numeric character references. */
replaceNumericCodePoint = replaceCodePoint;
constructor(
/**
* The function that is called when a codepoint is decoded.
*
* For multi-byte named entities, this will be called multiple times,
* with the second codepoint, and the same `consumed` value.
* @param codepoint The decoded codepoint.
* @param consumed The number of bytes consumed by the decoder.
*/
emitCodePoint,
/** An object that is used to produce errors. */
errors) {
this.emitCodePoint = emitCodePoint;
this.errors = errors;
}
/**
* Switches between the numeric decimal and hexadecimal states.
*
* Equivalent to the `Numeric character reference state` in the HTML spec.
* @param input The string containing the entity (or a continuation of the entity).
* @param offset The current offset.
* @returns The number of characters that were consumed, or -1 if the entity is incomplete.
*/
stateNumericStart(input, offset) {
if (offset >= input.length) {
return -1;
}
if ((input.charCodeAt(offset) | TO_LOWER_BIT) === CHAR_LOWER_X) {
this.state = STATE_NUMERIC_HEX;
this.consumed += 1;
return this.stateNumericHex(input, offset + 1);
}
this.state = STATE_NUMERIC_DECIMAL;
return this.stateNumericDecimal(input, offset);
}
/**
* Parses a hexadecimal numeric entity.
*
* Equivalent to the `Hexadecimal character reference state` in the HTML spec.
* @param input The string containing the entity (or a continuation of the entity).
* @param offset The current offset.
* @returns The number of characters that were consumed, or -1 if the entity is incomplete.
*/
stateNumericHex(input, offset) {
const inputLength = input.length;
// Local accumulators; flushed before any exit.
let { result, consumed } = this;
let index = offset;
while (index < inputLength) {
const char = input.charCodeAt(index);
const digit = numericDigits[char];
if (digit <= 15) {
result = result * 16 + digit;
}
else {
this.result = result;
this.consumed = consumed;
return this.emitNumericEntity(char, 3);
}
consumed += 1;
index += 1;
}
this.result = result;
this.consumed = consumed;
return -1; // Incomplete entity
}
/**
* Parses a decimal numeric entity.
*
* Equivalent to the `Decimal character reference state` in the HTML spec.
* @param input The string containing the entity (or a continuation of the entity).
* @param offset The current offset.
* @returns The number of characters that were consumed, or -1 if the entity is incomplete.
*/
stateNumericDecimal(input, offset) {
const inputLength = input.length;
// Local accumulators; flushed before any exit.
let { result, consumed } = this;
let index = offset;
while (index < inputLength) {
const digit = input.charCodeAt(index) - CHAR_ZERO;
if (digit >>> 0 > 9) {
this.result = result;
this.consumed = consumed;
return this.emitNumericEntity(digit + CHAR_ZERO, 2);
}
result = result * 10 + digit;
consumed += 1;
index += 1;
}
this.result = result;
this.consumed = consumed;
return -1; // Incomplete entity
}
/**
* Validate and emit a numeric entity.
*
* Implements the logic from the `Hexadecimal character reference start
* state` and `Numeric character reference end state` in the HTML spec.
* @param lastCp The last code point of the entity. Used to see if the
* entity was terminated with a semicolon.
* @param expectedLength The minimum number of characters that should be
* consumed. Used to validate that at least one digit
* was consumed.
* @returns The number of characters that were consumed.
*/
emitNumericEntity(lastCp, expectedLength) {
// Ensure we consumed at least one digit.
if (this.consumed <= expectedLength) {
this.errors?.absenceOfDigitsInNumericCharacterReference(this.consumed);
return 0;
}
// Figure out if this is a legit end of the entity
if (lastCp === CHAR_SEMI) {
this.consumed += 1;
}
else if (this.decodeMode === DecodingMode.Strict) {
return 0;
}
this.emitCodePoint(this.replaceNumericCodePoint(this.result), this.consumed);
if (this.errors) {
if (lastCp !== CHAR_SEMI) {
this.errors.missingSemicolonAfterCharacterReference();
}
this.errors.validateNumericCharacterReference(this.result);
}
return this.consumed;
}
/**
* Resets the instance to make it reusable.
* @param decodeMode Entity decoding mode to use.
*/
startEntity(decodeMode) {
this.decodeMode = decodeMode;
this.state = STATE_ENTITY_START;
this.result = 0;
this.consumed = 1;
}
/**
* Write an entity to the decoder. This can be called multiple times with partial entities.
* If the entity is incomplete, the decoder will return -1.
* @param input The string containing the entity (or a continuation of the entity).
* @param offset The offset at which the entity begins. Should be 0 if this is not the first call.
* @returns The number of characters that were consumed, or -1 if the entity is incomplete.
*/
write(input, offset) {
switch (this.state) {
case STATE_ENTITY_START: {
if (input.charCodeAt(offset) === CHAR_NUM) {
this.state = STATE_NUMERIC_START;
this.consumed += 1;
return this.stateNumericStart(input, offset + 1);
}
this.state = STATE_NAMED_ENTITY;
return this.stateNamedEntity(input, offset);
}
case STATE_NUMERIC_START: {
return this.stateNumericStart(input, offset);
}
case STATE_NUMERIC_DECIMAL: {
return this.stateNumericDecimal(input, offset);
}
case STATE_NUMERIC_HEX: {
return this.stateNumericHex(input, offset);
}
default: {
// NamedEntity: the only remaining state.
return this.stateNamedEntity(input, offset);
}
}
}
/**
* Signal to the parser that the end of the input was reached.
*
* Remaining data will be emitted and relevant errors will be produced.
* @returns The number of characters consumed.
*/
end() {
switch (this.state) {
case STATE_NAMED_ENTITY: {
return this.endNamedEntity();
}
// Otherwise, emit a numeric entity if we have one.
case STATE_NUMERIC_DECIMAL: {
return this.emitNumericEntity(0, 2);
}
case STATE_NUMERIC_HEX: {
return this.emitNumericEntity(0, 3);
}
case STATE_NUMERIC_START: {
this.errors?.absenceOfDigitsInNumericCharacterReference(this.consumed);
return 0;
}
default: {
// EntityStart: return 0.
return 0;
}
}
}
}
/**
* Streaming decoder for HTML entities.
*
* When the entity fits inside the current chunk (the common case), the
* lookup runs directly on the chunk via the same length-probe scheme as the
* synchronous decoder; chunk-boundary runs use a reusable character buffer.
*/
export class HtmlEntityDecoder extends EntityDecoderBase {
/** Total name characters seen for the current named entity. */
runLength = 0;
/** Reused for ASCII alphanumerics; the 32nd character rules out an exact match. */
nameBuffer = new Uint8Array(32);
/**
* Emit the replacement for a matched slot. Values are at most two
* UTF-16 code units; like previous versions, each unit is emitted as
* its own callback (surrogate halves included).
* @param slot The matched slot.
*/
emitSlot(slot) {
const packed = htmlSlotValue[slot];
const off = packed >> 2;
this.emitCodePoint(htmlValues.charCodeAt(off), this.consumed);
if ((packed & 1) !== 0) {
this.emitCodePoint(htmlValues.charCodeAt(off + 1), this.consumed);
}
}
startEntity(decodeMode) {
super.startEntity(decodeMode);
this.runLength = 0;
}
stateNamedEntity(input, offset) {
const inputLength = input.length;
if (this.runLength === 0 && offset + 17 <= inputLength) {
/*
* Fast path: all length probes are conclusive inside this
* chunk. (Near the chunk end a probe miss could be a name
* continuing in the next chunk, so fall through to buffering.)
*/
const c0 = input.charCodeAt(offset);
const pair = pairIndex(c0, input.charCodeAt(offset + 1));
const bits = htmlLengthBits[pair];
if ((bits & 0x80_00) !== 0 && offset + 32 <= inputLength) {
const packed = findLongClassMatch(input, offset, this.decodeMode);
if (packed >= 0) {
const length = packed & 63;
this.consumed = length + 1;
this.emitSlot(packed >> 6);
if (input.charCodeAt(offset + length - 1) !== CHAR_SEMI) {
this.errors?.missingSemicolonAfterCharacterReference();
}
return this.consumed;
}
// The window decides every exact or legacy candidate.
return 0;
}
let probed = bits & 0x7f_ff;
while (probed !== 0) {
/*
* Shortest candidate first: only one length can carry the
* terminating ';' (a ';' inside a longer candidate fails its
* middle comparison), so the order is correctness-neutral,
* and the most common entities are short. Legacy matches are
* resolved after the loop, preserving exact-match
* precedence.
*/
const low = probed & -probed;
probed ^= low;
const length = 33 - Math.clz32(low);
if (input.charCodeAt(offset + length) === CHAR_SEMI) {
const slot = findSlotHtml(input, offset, length);
if (slot >= 0) {
this.consumed = length + 2;
this.emitSlot(slot);
return this.consumed;
}
}
}
if ((bits & 0x80_00) === 0) {
if (this.decodeMode !== DecodingMode.Strict) {
const packed = findLegacySlot(input, offset, 31);
if (packed >= 0) {
const matchLength = packed & 7;
const next = input.charCodeAt(offset + matchLength);
if (this.decodeMode === DecodingMode.Attribute &&
isEntityInAttributeInvalidEnd(next)) {
return 0;
}
this.consumed = matchLength + 1;
this.emitSlot(packed >> 3);
this.errors?.missingSemicolonAfterCharacterReference();
return this.consumed;
}
}
// No long names in this class: the probes were exhaustive.
return 0;
}
}
// A 32nd name character rules out every exact HTML match.
let index = offset;
let { runLength } = this;
let terminator = -1;
const scanEnd = Math.min(inputLength, offset + 32 - runLength);
while (index < scanEnd) {
const char = input.charCodeAt(index);
if (!isAlphaNumeric(char)) {
terminator = char;
break;
}
this.nameBuffer[runLength++] = char;
index++;
}
if (terminator < 0 && runLength < 32) {
this.runLength = runLength;
return -1;
}
if (terminator === CHAR_SEMI && (runLength - 2) >>> 0 <= 29) {
const slot = findBufferedHtmlSlot(this.nameBuffer, runLength);
if (slot >= 0) {
this.consumed = runLength + 2;
this.emitSlot(slot);
return this.consumed;
}
}
if (this.decodeMode !== DecodingMode.Strict && runLength >= 2) {
const packed = findBufferedLegacySlot(this.nameBuffer, runLength);
if (packed >= 0) {
const length = packed & 7;
const next = length < runLength ? this.nameBuffer[length] : terminator;
if (this.decodeMode === DecodingMode.Attribute &&
isEntityInAttributeInvalidEnd(next))
return 0;
this.consumed = length + 1;
this.emitSlot(packed >> 3);
this.errors?.missingSemicolonAfterCharacterReference();
return this.consumed;
}
}
return 0;
}
endNamedEntity() {
// Emit the longest legacy match in the buffered run, if any.
if (this.decodeMode === DecodingMode.Strict || this.runLength < 2) {
return 0;
}
const packed = findBufferedLegacySlot(this.nameBuffer, this.runLength);
if (packed < 0)
return 0;
if (this.decodeMode === DecodingMode.Attribute &&
(packed & 7) < this.runLength) {
return 0;
}
this.consumed = (packed & 7) + 1;
this.emitSlot(packed >> 3);
this.errors?.missingSemicolonAfterCharacterReference();
return this.consumed;
}
}
/**
* Find a buffered name. Its characters were already checked as ASCII
* alphanumerics while scanning, so no outer-character range check is needed.
* @param buffer Reusable character buffer.
* @param length Number of characters to match.
*/
function findBufferedHtmlSlot(buffer, length) {
const key = (buffer[0] << 25) |
(buffer[1] << 18) |
(buffer[length - 2] << 11) |
(CHAR_REMAP[buffer[length - 1]] << 5) |
length;
let slot = 2 * (((Math.imul(key, BUCKET_HASH_1) >>> 16) * htmlBuckets) >>> 16);
for (let attempt = 0;; attempt++) {
if (htmlKeys[slot] === key &&
(length <= 4 || isBufferedMiddle(slot, buffer, length)))
return slot;
if (htmlKeys[slot + 1] === key &&
(length <= 4 || isBufferedMiddle(slot + 1, buffer, length)))
return slot + 1;
if (attempt === 1)
return -1;
slot =
2 * (((Math.imul(key, BUCKET_HASH_2) >>> 16) * htmlBuckets) >>> 16);
}
}
function isBufferedMiddle(slot, buffer, length) {
let wordIndex = htmlSlotMidOff[slot];
let index = 2;
const end = length - 2;
while (index + 1 < end) {
if ((buffer[index] | (buffer[index + 1] << 16)) !==
htmlMiddles[wordIndex++])
return false;
index += 2;
}
return (index === end || buffer[index] === (htmlMiddles[wordIndex] & 0xff_ff));
}
/**
* Find the longest legacy name in the buffer, packed as `(slot << 3) | length`.
* @param buffer Reusable character buffer.
* @param length Number of buffered characters.
*/
function findBufferedLegacySlot(buffer, length) {
let legacy = (htmlLengthBits[pairIndex(buffer[0], buffer[1])] >>> 16) & 31;
while (legacy !== 0) {
const top = 31 - Math.clz32(legacy);
legacy ^= 1 << top;
if (top + 2 > length)
continue;
const slot = findBufferedHtmlSlot(buffer, top + 2);
if (slot >= 0 && (htmlSlotValue[slot] & 2) !== 0)
return (slot << 3) | (top + 2);
}
return -1;
}
/**
* Match an XML name packed in seven-bit groups, or return -1.
* @param name Packed name accumulated across chunks.
*/
function xmlCodePoint(name) {
switch (name) {
case 0x18_76_f0: {
// "amp"
return 0x26;
}
case 0xc_3c_37_f3: {
// "apos"
return 0x27;
}
case 0x33_f4: {
// "gt"
return 0x3e;
}
case 0x36_74: {
// "lt"
return 0x3c;
}
case 0xe_3d_77_f4: {
// "quot"
return 0x22;
}
default: {
return -1;
}
}
}
/**
* Streaming decoder for XML entities: the five predefined named entities
* plus numeric character references.
*/
export class XmlEntityDecoder extends EntityDecoderBase {
replaceNumericCodePoint = replaceCodePointXML;
stateNamedEntity(input, offset) {
const inputLength = input.length;
if (this.consumed === 1 && offset + 5 <= inputLength) {
// Fast path: all five patterns are decided within five chars.
const packed = matchXmlEntity(input, offset);
if (packed < 0)
return 0;
const consumed = (this.consumed = (packed >> 7) + 1);
this.emitCodePoint(packed & 127, consumed);
return consumed;
}
// XML names need at most four seven-bit characters, fitting in 28 bits.
let { result, consumed } = this;
for (let index = offset; index < inputLength; index++) {
const char = input.charCodeAt(index);
if (char === CHAR_SEMI) {
const codePoint = xmlCodePoint(result);
if (codePoint < 0)
return 0;
this.consumed = consumed + 1;
this.emitCodePoint(codePoint, this.consumed);
return this.consumed;
}
if (consumed >= 5 || (char - CHAR_LOWER_A) >>> 0 > 25)
return 0;
result = (result << 7) | char;
consumed++;
}
this.result = result;
this.consumed = consumed;
return -1;
}
endNamedEntity() {
// XML has no legacy entities; an unterminated name never matches.
return 0;
}
}
export { replaceCodePoint, replaceCodePointXML } from "./decode-codepoint.js";
//# sourceMappingURL=decode.js.map