entities
Version:
Encode & decode XML and HTML entities with ease & speed
188 lines (167 loc) • 6.22 kB
text/typescript
/**
* Get the named reference for an XML special character or U+00A0.
* @param char Code unit matched by one of the escape regexes.
*/
function getEscape(char: number): string {
return char === 34
? """
: char === 38
? "&"
: char === 39
? "'"
: char === 60
? "<"
: char === 62
? ">"
: " ";
}
/**
* Read a code point at a given index.
* @param input String to read the code point from.
* @param index Current read position in the input string.
* @returns The code point at `index`, or `undefined` if `index` is out of range.
* @deprecated Use `String.prototype.codePointAt` directly instead; this export
* will be removed in the next major.
*/
export const getCodePoint: (input: string, index: number) => number = (
input: string,
index: number,
): number => input.codePointAt(index)!;
/**
* Bitset for ASCII characters that need to be escaped in XML.
*/
export const XML_BITSET_VALUE = 0x50_00_00_c4; // 32..63 -> 34 ("),38 (&),39 ('),60 (<),62 (>)
/**
* Matches exactly the characters `encodeXML` escapes: the five XML special
* characters plus every non-ASCII code unit (lone surrogates included — no
* `u` flag). Kept in sync with `XML_BITSET_VALUE`.
*
* Shared with `encodeNonAsciiHTML` in `encode.ts`. Because the regex is
* stateful (`g` flag), every call site must set `lastIndex` before use.
*/
// eslint-disable-next-line unicorn/prefer-unicode-code-point-escapes -- the `\u{...}` form requires the `u` flag, which we deliberately omit so lone surrogates match by code unit
export const xmlEncodeRegex: RegExp = /["&'<>\u0080-\uFFFF]/g;
/**
* Whether `code` (a UTF-16 code unit) is escaped by {@link encodeXML}: a
* non-ASCII unit, or one of the five XML specials flagged in
* `XML_BITSET_VALUE` (which is only meaningful for code units 32-63).
* @param code Code unit to test.
*/
function isXmlEscapable(code: number): boolean {
return (
code >= 0x80 ||
(code >= 32 && code < 64 && ((XML_BITSET_VALUE >>> code) & 1) === 1)
);
}
/**
* Encodes all non-ASCII characters, as well as characters not valid in XML
* documents using XML entities.
*
* If a character has no equivalent entity, a numeric hexadecimal reference
* (eg. `ü`) will be used.
* @param input Input string to encode.
*/
export function encodeXML(input: string): string {
const { length } = input;
let out: string | undefined;
let last = 0;
let index = 0;
while (index < length) {
const char = input.charCodeAt(index);
/*
* Find the next character to escape: scan a short window inline
* (escapable characters cluster in markup-heavy input), then fall
* back to the regex, which skips clean spans in native code.
*/
if (!isXmlEscapable(char)) {
const bound = Math.min(index + 32, length);
let next = index + 1;
while (next < bound && !isXmlEscapable(input.charCodeAt(next))) {
next++;
}
if (next < bound) {
index = next;
continue;
}
if (next >= length) break;
xmlEncodeRegex.lastIndex = next;
if (!xmlEncodeRegex.test(input)) break;
index = xmlEncodeRegex.lastIndex - 1;
continue;
}
if (out === undefined) out = input.substring(0, index);
else if (last !== index) out += input.substring(last, index);
if (char < 64) {
// Known replacement
out += getEscape(char);
last = index += 1;
continue;
}
// Non-ASCII: encode as numeric entity (handle surrogate pair)
const cp = input.codePointAt(index)!;
out += `&#x${cp.toString(16)};`;
if (cp !== char) index++; // Skip trailing surrogate
last = index += 1;
}
if (out === undefined) return input;
if (last < length) out += input.substr(last);
return out;
}
/**
* Encodes all non-ASCII characters, as well as characters not valid in XML
* documents using numeric hexadecimal reference (eg. `ü`).
*
* Have a look at `escapeUTF8` if you want a more concise output at the expense
* of reduced transportability.
* @param data String to escape.
*/
export const escape: typeof encodeXML = encodeXML;
/**
* Escape `data` using `re`, mapping each matched character to its entity.
* Every match is one UTF-16 code unit, so its index is `lastIndex - 1`.
* @param re Global regex matching exactly the characters to escape
* (`"`, `&`, `'`, `<`, `>`, `\u00A0` at most).
* @param data String to escape.
*/
function escapeWithRegex(re: RegExp, data: string): string {
re.lastIndex = 0;
if (!re.test(data)) return data;
let out = "";
let last = 0;
do {
const index = re.lastIndex - 1;
if (last !== index) out += data.substring(last, index);
const char = data.charCodeAt(index);
out += getEscape(char);
last = index + 1;
} while (re.test(data));
return out + data.substring(last);
}
const xmlEscapeRegex = /["&'<>]/g;
/**
* Encodes all characters not valid in XML documents using XML entities.
*
* Note that the output will be character-set dependent.
* @param data String to escape.
*/
export function escapeUTF8(data: string): string {
return escapeWithRegex(xmlEscapeRegex, data);
}
const attributeEscapeRegex = /["&\u{A0}]/gu;
/**
* Encodes all characters that have to be escaped in HTML attributes,
* following {@link https://html.spec.whatwg.org/multipage/parsing.html#escapingString}.
* @param data String to escape.
*/
export function escapeAttribute(data: string): string {
return escapeWithRegex(attributeEscapeRegex, data);
}
const textEscapeRegex = /[&<>\u{A0}]/gu;
/**
* Encodes all characters that have to be escaped in HTML text,
* following {@link https://html.spec.whatwg.org/multipage/parsing.html#escapingString}.
* @param data String to escape.
*/
export function escapeText(data: string): string {
return escapeWithRegex(textEscapeRegex, data);
}