Initial commit

This commit is contained in:
mats committed 2026-09-22 08:41:20 +02:00
commit 89053153cc
8152 files changed
+2086461

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
// Adapted from https://github.com/mathiasbynens/he/blob/36afe179392226cf1b6ccdb16ebbb7a5a844d93a/src/he.js#L106-L134
/**
* C1 Unicode control character reference replacements (code points 128–159).
* Index i gives the replacement for code point 128+i; 0 means "no replacement".
*/
const c1: number[] = [
8364, 0, 8218, 402, 8222, 8230, 8224, 8225, 710, 8240, 352, 8249, 338, 0,
381, 0, 0, 8216, 8217, 8220, 8221, 8226, 8211, 8212, 732, 8482, 353, 8250,
339, 0, 382, 376,
];
/**
* True for NUL, UTF-16 surrogates, and values past U+10FFFF.
* @param codePoint Unicode code point to check.
*/
function isInvalidCodePoint(codePoint: number): boolean {
return (
codePoint === 0 ||
(codePoint >= 0xd8_00 && codePoint <= 0xdf_ff) ||
codePoint > 0x10_ff_ff
);
}
/**
* Replace the given code point with U+FFFD if it is NUL (0), a surrogate, or
* outside the valid Unicode range. Code points in the C1 controls range
* (128–159) are remapped to their Windows-1252 equivalents, following the
* HTML spec. All other code points are returned unchanged.
* @param codePoint Unicode code point to convert.
*/
export function replaceCodePoint(codePoint: number): number {
if (isInvalidCodePoint(codePoint)) {
return 0xff_fd;
}
if (codePoint >= 128 && codePoint <= 159) {
return c1[codePoint - 128] || codePoint;
}
return codePoint;
}
/**
* XML numeric character references are the referenced Unicode code point.
* Invalid values still become U+FFFD; the HTML Windows-1252 C1 remap is not
* applied.
* @see https://www.w3.org/TR/xml/#NT-CharRef
* @param codePoint Unicode code point to convert.
*/
export function replaceCodePointXML(codePoint: number): number {
return isInvalidCodePoint(codePoint) ? 0xff_fd : codePoint;
}
/**
* Convert the code point of a decoded numeric entity to a string, replacing
* invalid values.
*
* Fast path for plain BMP code points: [1..0x7F] and [0xA0..0xD7FF] pass
* `replaceCodePoint` unchanged (no NUL, C1 remap, surrogate, or out-of-range
* handling) and fit a single charCode. 0xd760 = 0xD800 (the first surrogate)
* - 0xA0.
* @param codePoint Unicode code point to convert.
*/
export function codePointToString(codePoint: number): string {
return (codePoint - 1) >>> 0 < 0x7f || (codePoint - 0xa0) >>> 0 < 0xd7_60
? String.fromCharCode(codePoint)
: String.fromCodePoint(replaceCodePoint(codePoint));
}
+1368
View File
File diff suppressed because it is too large. Load diff
+337
View File
@@ -0,0 +1,337 @@
import { XML_BITSET_VALUE, xmlEncodeRegex } from "./escape.js";
import htmlTrieData from "./generated/encode-html.js";
/**
* A node inside the encoding trie used by `encode.ts`.
*
* There are two physical shapes to minimize allocations and lookup cost:
*
* 1. Leaf node (string)
* - A plain string (already in the form `"&name;"`).
* - Represents a terminal match with no children.
*
* 2. Branch / value node (object)
*/
type EncodeTrieNode =
| string
| { value: string | null; next: Map<number, EncodeTrieNode> };
/**
* Flat lookup table for ASCII entity values (code points 0–127).
*
* Built once at startup from the full trie. For ASCII characters that need
* encoding, a direct `asciiEntities[charCode]` array index is much faster
* than `Map.get(charCode)` because it avoids hashing and bucket lookup.
*
* A few ASCII characters also start multi-code-point entities
* (e.g. `<` + U+20D2 → `&nvlt;`). For those, the table holds the branch
* node itself (children plus the single-char value), so the ASCII path
* never consults `htmlTrie`.
*/
const asciiEntities: (EncodeTrieNode | null)[] = /* #__PURE__ */ Array.from(
{ length: 128 },
() => null,
);
const htmlTrie: Map<number, EncodeTrieNode> = (() => {
/**
* Parse a compact encode trie string into a Map keyed by code point.
*
* The serialized format (produced by `scripts/write-encode-map.ts`) stores
* entries in ascending code-point order with delta encoding:
*
* [gap]name;[{children}]
*
* - `gap` is a base-10 integer giving `currentKey - previousKey - 1`.
* The very first entry stores the absolute key. A gap of 0 is omitted.
* - `name;` is the entity name (without `&` prefix) terminated by `;`.
* Because gaps use only digits `[0-9]` and entity names always start with
* a letter `[A-Za-z]`, no additional delimiter is needed.
* - `{…}` is an optional children block using the same scheme recursively.
* Children represent the second code unit of multi-character entities
* (e.g. `<` + U+20D2 → `&nvlt;`).
*/
const trie = new Map<number, EncodeTrieNode>();
const data = htmlTrieData;
let cursor = 0;
let lastKey = -1;
function readGap(): number {
let value = 0;
let ch: number;
while (
(ch = data.charCodeAt(cursor)) >= 48 /* '0' */ &&
ch <= 57 /* '9' */
) {
value = value * 10 + ch - 48;
cursor++;
}
return value;
}
function readEntity(): string {
const semi = data.indexOf(";", cursor);
const entity = `&${data.substring(cursor, semi)};`;
cursor = semi + 1;
return entity;
}
const astralEntries: [number, string][] = [];
while (cursor < data.length) {
lastKey += readGap() + 1;
const entityValue =
data.charCodeAt(cursor) === 123 /* '{' */ ? null : readEntity();
if (data.charCodeAt(cursor) === 123 /* '{' */) {
cursor++; // Skip '{'
const next = new Map<number, EncodeTrieNode>();
let childKey = -1;
while (data.charCodeAt(cursor) !== 125 /* '}' */) {
childKey += readGap() + 1;
next.set(childKey, readEntity());
}
const branch = { value: entityValue, next };
trie.set(lastKey, branch);
cursor++; // Skip '}'
// ASCII fast path holds the branch node itself (children + value).
if (lastKey < 0x80) {
asciiEntities[lastKey] = branch;
}
} else if (lastKey < 0x80) {
asciiEntities[lastKey] = entityValue;
} else if (lastKey > 0xff_ff) {
astralEntries.push([lastKey, entityValue!]);
} else {
trie.set(lastKey, entityValue!);
}
}
/*
* Batch-insert astral entries as surrogate-pair trie nodes.
* Entries are sorted by code point, so same-high-surrogate groups
* are contiguous — no intermediate grouping Map needed.
*/
let astralIndex = 0;
while (astralIndex < astralEntries.length) {
const hi =
0xd8_00 | ((astralEntries[astralIndex][0] - 0x1_00_00) >> 10);
const children: [number, string][] = [];
while (
astralIndex < astralEntries.length &&
(0xd8_00 | ((astralEntries[astralIndex][0] - 0x1_00_00) >> 10)) ===
hi
) {
const lo =
0xdc_00 |
((astralEntries[astralIndex][0] - 0x1_00_00) & 0x3_ff);
children.push([lo, astralEntries[astralIndex][1]]);
astralIndex++;
}
trie.set(hi, { value: null, next: new Map(children) });
}
return trie;
})();
/**
* Bitset covering ASCII code points 0–127. Each of the four 32-bit words
* covers a 32-code-point range. A set bit means "this character needs
* encoding" when used with `encodeHTML`.
*/
const HTML_BITSET = /* #__PURE__ */ new Uint32Array([
0x16_00, // 09 (\t), 0A (\n), 0C (\f)
0xfc_00_ff_fe, // 21-2D (!-.), 2E (.), 2F (/), 3A-3F (:;<=>?)
0xf8_00_00_01, // 40 (@), 5B-5F ([\]^_)
0x38_00_00_01, // 60 (`), 7B-7D ({|})
]);
const XML_BITSET = /* #__PURE__ */ new Uint32Array([0, XML_BITSET_VALUE, 0, 0]);
/*
* Regex equivalent of `HTML_BITSET` (plus all non-ASCII code units, lone
* surrogates included — no `u` flag). Must stay in sync with the bitset:
* the scan uses the regex to find candidates and the bitset to re-check
* adjacent characters. The XML equivalent is `xmlEncodeRegex`, shared with
* `escape.ts`.
*/
// 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
const HTML_ENCODE_RE = /[\t\n\f!-/:-@[-`{-}\u0080-\uFFFF]/g;
const numericReference = (cp: number) => `&#x${cp.toString(16)};`;
/**
* Encodes all characters in the input using HTML entities. This includes
* characters that are valid ASCII characters in HTML documents, such as `#`.
*
* To get a more compact output, consider using the `encodeNonAsciiHTML`
* function, which will only encode characters that are not valid in HTML
* documents, as well as non-ASCII characters.
*
* If a character has no equivalent entity, a numeric hexadecimal reference
* (eg. `&#xfc;`) will be used.
* @param input Input string to encode.
*/
export function encodeHTML(input: string): string {
return encodeHTMLTrieRe(HTML_BITSET, HTML_ENCODE_RE, input);
}
/**
* Encodes all non-ASCII characters, as well as characters not valid in HTML
* documents using HTML entities. This function will not encode characters that
* are valid in HTML documents, such as `#`.
*
* If a character has no equivalent entity, a numeric hexadecimal reference
* (eg. `&#xfc;`) will be used.
* @param input Input string to encode.
*/
export function encodeNonAsciiHTML(input: string): string {
return encodeHTMLTrieRe(XML_BITSET, xmlEncodeRegex, input);
}
/**
* Whether `code` (a UTF-16 code unit) must be encoded: any non-ASCII unit, or
* an ASCII unit flagged in `bitset`.
* @param bitset Bitset of ASCII characters to encode.
* @param code Code unit to test.
*/
function isEncodable(bitset: Uint32Array, code: number): boolean {
return code >= 0x80 || ((bitset[code >>> 5] >>> code) & 1) === 1;
}
/*
* The inline scan beats the regex jump only for short gaps (measured
* break-even is ~7 characters; below it the per-call regex overhead
* dominates, above it the regex skips clean spans faster in native code).
* So we scan at most `INLINE_SCAN_WINDOW` characters inline before deferring
* to the regex. Any gap long enough to exhaust the window switches to
* long-gap mode, which skips the inline scan entirely; we stay in that mode
* while total gaps are at least `LONG_GAP_THRESHOLD` characters long and
* drop back to the inline scan as soon as a shorter gap appears. Gaps are
* always measured from where they start, so every window-exhausting gap
* enters long-gap mode and no gap length pays both scans repeatedly.
*/
/** @internal Exported for tests; not re-exported from the package entry. */
export const INLINE_SCAN_WINDOW = 16;
const LONG_GAP_THRESHOLD = 8;
function encodeHTMLTrieRe(
bitset: Uint32Array,
re: RegExp,
input: string,
): string {
const { length } = input;
let out: string | undefined;
let last = 0; // Start of the next untouched slice.
let index = 0;
let wasLongGap = false; // The previous gap was long; skip the inline scan.
while (index < length) {
let char = input.charCodeAt(index);
/*
* Find the next encodable character (one matching `bitset`, or any
* non-ASCII unit). Gaps between entities in dense text are only a
* few characters, so scan a short window inline first; the regex —
* which matches exactly the same characters and skips clean spans
* in native code — runs for longer gaps. Gap lengths cluster, so
* after a window-exhausting gap the window is skipped until a gap
* shorter than LONG_GAP_THRESHOLD reappears. Once located, the
* character is captured in `char` and control falls through to the
* encode logic below instead of re-testing it on the next loop
* iteration.
*/
if (!isEncodable(bitset, char)) {
const gapStart = index;
let next = index + 1;
let wasFound = false;
if (!wasLongGap) {
const bound = Math.min(index + INLINE_SCAN_WINDOW, length);
while (
next < bound &&
!isEncodable(bitset, (char = input.charCodeAt(next)))
) {
next++;
}
if (next < bound) {
// `char` already holds the encodable unit at `next`.
index = next;
wasFound = true;
} else if (next >= length) {
break;
}
}
if (!wasFound) {
/*
* Every match is a single code unit, so `test` pins it at
* `lastIndex - 1` without allocating a match object.
*/
re.lastIndex = next;
if (!re.test(input)) break;
index = re.lastIndex - 1;
/*
* Reaching the regex from the inline scan means the gap
* exhausted the window, so it is long by definition; once
* in long-gap mode, stay until a gap shorter than the
* threshold appears.
*/
wasLongGap =
!wasLongGap || index - gapStart >= LONG_GAP_THRESHOLD;
char = input.charCodeAt(index);
}
}
// Lazy-init: copy the prefix before the first character that needs encoding.
if (out == null) out = input.substring(0, index);
else if (last !== index) out += input.substring(last, index);
if (char < 0x80) {
const node = asciiEntities[char];
if (typeof node === "object" && node !== null) {
// Multi-code-point entity first (e.g. < + U+20D2 → &nvlt;).
if (index + 1 < length) {
const value = node.next.get(input.charCodeAt(index + 1));
if (value != null) {
out += value;
last = index += 2;
continue;
}
}
out += node.value ?? numericReference(char);
} else {
out += node ?? numericReference(char);
}
} else {
// Non-ASCII: full trie lookup with multi-char entity support.
let node: EncodeTrieNode | undefined | null = htmlTrie.get(char);
if (typeof node === "object") {
if (index + 1 < length) {
const value = node.next.get(input.charCodeAt(index + 1));
if (value != null) {
out += value;
last = index += 2;
continue;
}
}
node = node.value;
}
if (node == null) {
// No named entity exists; emit a hexadecimal numeric reference.
const cp = input.codePointAt(index)!;
out += numericReference(cp);
// Astral code points consume two UTF-16 code units.
if (cp !== char) index++;
} else {
out += node;
}
}
last = index += 1;
}
// If nothing needed encoding, return the original string (avoids allocation).
if (out == null) return input;
if (last < length) out += input.substr(last);
return out;
}
+187
View File
@@ -0,0 +1,187 @@
/**
* 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
? "&quot;"
: char === 38
? "&amp;"
: char === 39
? "&apos;"
: char === 60
? "&lt;"
: char === 62
? "&gt;"
: "&nbsp;";
}
/**
* 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. `&#xfc;`) 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. `&#xfc;`).
*
* 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);
}
+12
View File
@@ -0,0 +1,12 @@
// Generated using scripts/write-decode-map.ts
import { decodeTrieDict } from "../internal/decode-shared.js";
/** Packed HTML decode trie data. */
export const htmlDecodeTree: Uint16Array = /* #__PURE__ */ decodeTrieDict(
"!}.&u%}'&}*'~!6*)%&,~!J~!J~%L~y<~!R,~~%Lu~~#GD~~#|)1#%}^%}2%+#.##%##%}&%##%'#%##&%#%#'%#&#%#&#'#%%#&#%##%#)%''%&%#%#'%#%%#%%}%%%#%#&(23#%%#&-%0%('1#(##%#'##+%'*.:1}#%#6-+(%'%%#%%%}#L'2351&('%}&/N'(0(/*-%(%%}#'+&T%7.2}#&%&#%#36/5##%&%%#&#%%#))2%%##%&&'0~!#*+&'%1~!%).'3q?&%'1~!.##%6(~!+%%%(Gw'rT~!E#<nA%#jZ~!H%(~!42##~!*31&~!G%U~#)5~#`3~!J~!Z~%]~%Y~%C~!q~!u~#kz~%#~!6'~!D~!U~!?~#T~!c%~!G#'~%7|~!G~!J~!G&~#pb~(Df}#%}*&}#%##%##%##&#-}&'#'&%#.++}%mI,#,@&(}*%}*'%&##&#%##%}&0}#.},U},%}+%}&%}#%##&}B%(}(%}+%)})%##%#&}&%##%&}<%}>%#%&}*%}(%}9%}/%})%}*%}*%}?&}&%}3%}&*#%})%#%#)}#&#-#+*%E%%'%'#%}#*V##&##I}#&&##%&%#&&Qf%%))w/0+&%#(#.%-''''++++7}>%4'',##1,#%#&%##&#'##&#*#9)%&%}#*}%,#+P(%A&%#'&##wSD',9E00#y#@}(+}&%&>~!#~!X}#*}(&&}(&}(,%}%&#+&}#&}I%#%}%)#(},'%#*}4%%#%}(''}#/##(##),%-##%%)#&}(.}&%#&}%%}*&#%},&&}&%}#%*'#%})%}D&}&%}-&}6&#&}-,%}#%})-(~+`~,=?~I9'9%~!,#%})%})%}@%}?%}(~!?~#<~#pP~#BG~#=1#%K+~#?#~%;)~#A~#mF1~#A'~'X%'~#lR~#N~'N~#r~#m#-~#i'?%#'%~#B%##%,%#~#_%#0%~#]732~,w~2+#:&#%&'0%&>%}#>##F+)#%&&#(+_}4&}-%}(&}@&}O7Fdf0@+/v4}&WU##&/0#&'('B#%}.%}'+#%}#%%&#&%#%##+#&#)#6#'#.},%}c%},%#%##%&#&%#&~#>'*-.%##%##%}#%%}%'~#)D1}#%*&~#_%%'(~#S2%'.}#~#=##*'*-%}&'%'##&&~'E%.#&~#M4}%%##&'%#~#O1##%&#'+~#<B%##%%'%+~#;#@%}#&%#&&%#(~#H1}'%'##&&~#?A}&'~#D#%32}'&&&&~#[}'(#%}'~#;C})&}%%#%~#=&%,3}%'(#%%~#^'#&&)#%'~#Y%-~#d-%'~#^%%&#&&&}#~#b~2t*&'~&(~&@~0%~e~3}%*''0})&}+~!9##-}#%-hD*)1fC#%/&/fB#40~!+#)*4~!+~!K'&:~!/*7~!.#~!H~!L':~%x&~!H#~!*~%1~!I#~!+A~#p'~!F~~#-#~,,(~.Z~!V~%;'B'mq-W~!N~%I%#&&#&}#%},%%}'%}+X#%}#&}(%}'%}<%}#%}%%'}'%}:~![)9@~%>~#UA%-%##&~!C%~!-.9:~!1~!-^2/:a~!y,D*J#-5)/4~%23,~#G~!L1~!0X3`~!2+~!!0-~&E~!W~!o,>Y&]~%cZx_&~#O*9#A#'#+I'%#)~!0B*-5A+-((F&*M#)(-7-5+'-3a5Vi~!Y~!?+[)%3),ERHm~!+:D,VG.+)?fB%%*(%)'(#&80%1'8`K8?`+'Z#&O&'H5#*9)A%%5&3))0%39+.*7#()&&*=4@**L)<'_&*+..;(#*+)./&0#3)%')-8(4ixD(&.}%,('aI:,)%,k2231T)I'#/-W7,/'Q#.'Y24+h')37</31&83##&0#),H(?'&?/1##%#&&#%''-%&&&#(&''&#.-'%#%%(,')*'&#&#'##%(%(#%('#&##%%%%('%#%#%%#%#&%##h>w+v<ayvyvcg.uuhKr}g/v|g>u9i[~>g5uI~=RvdwEg;v/g;uk!!TTSx]@RT!U!#!@VBRUU!'UTe-d0c`e&gSdicedFcrdTaqb.kYcAohdYd@a3e+d}dMdtd.aJ#bqcK`dle/e.e'dwdPdodddjbEb}ogd^ofdpduc6j?l%d{drdqc)d7bacOdQ%T#Y)X.sR[yH>6Vyv3[xwLu>vo'!*.[yBacahoj>6Rew3[xqdZa#!a&#^(X-[yG>6Vyu3[xvg3sEr|g.u/Ri9db0T#^(Xa)!-[y;>6Vylg4wKs{JwNZt3@3r=c4Z([xlg;wKt!cpq's@v7A'*a(a+!-a#[y<3Dt?3Dt'>6Vym3[xmg9rxsNJwLZt4~?r?db1T#`-!(Xa,!0[yS>6Vz%NuQs.g4wKtnJwNZtS@3r>c4Z([y%g;wKtrdga8!a(!#&T*Y-Xa#!a0<or[yc3Dtq>6Vz43[y3JwNZtf@3s!Ju}!%Dti:pm3c_%X#tjB5pkd6q!r]u?voC'*-a.a2!0a&a+[yI3DtI3Ds~3DtH>6Vyw3[xx;:s#~<5pKJwNZtE@3r~d`a)!a2T#a.(!+U.X1[yT3Dt`3Dtv>6Vz&3[y&g9rxwzcxstPu.<rAJwLZtT~?r@dZa%!a.&^*Za(/Reu[ya>6Vz23[y1g3sEr}wkg{NuQRg{ci(U#5@b`~,cg#U(2WnH5wugcRh7dX#T(Y,a'Ta!!a,[yZ<]mj>6Vz,3[y+Pv#5ReZKu+=,%!H}7ABwkaS?Rh:BcW(X#<]mrj:ubv/ARekdg%!(!a.*Ta(Y.X1!#sP>Rl*Dt6[y>>6Vyo3Wf*jOvuumvuRgRJuq*!:9<B@bX~3jVv&v@s@5Re[d/rQt{uAvo&a&a*)a2!,0Wf!3Dt0=Bs'>6Re}3[xy~<5s%JwJZt1~Gs)c;&!#2sJkNuXvzq7rxu,Re8dka4!a8(aEZ+a@Y.X1Xa)[yd=Bs(3DtP>6Vz53[y4cX#X&Re:avRe9~<5s&JwJZtQ~Gs*i^rzvdRg+Jv{%!2sbB@bX}kdga,!Za?&^*T1/!a'Dt+[y6>6Vyf3Wf%g/u;s4hGu6?Rh-JvZ,!c%#&RoX54Rivj7uyvf8RgTKvZB%*!2sGh<vu5Rgq<=C::9bb~#dZ#T&Ta6Y.X*Dt>[y93Wf)coZ(T,6VyifluvRgC@95@B@bX~/hFu34cC#T,k/unq8w8Q5RkUklwQuzunq8w8Q5Rk8d/rJu?v8w9)-&!a0a;a&aIWejg3sEr/h1s<DtDJvyZqY5aws3Jvy!&Wei~Hr1:au5@Bag>23E~5c:Z&bX};kKv?w&unuVu5Rjc;>bs)#~@:Rh.=ay<a]C;b`}Vd6s/t{uAvoaxa()!a,a7%-a#a2Dt,[yF2Wo[>6Vyt3[xuNuPRi&NuPwpi#RoWh?vf8Ri%Jv]!%Ri:KvxD!.'2WeAjZu`q9rxu,Re7woeAg-unLq(qA_/*2Wg_g3u5q^9:4E}/jTrxrzv=Wkkd~0UX#^^Xa-a1a5T&a=U1a'*aEa]!a*aPaA-adok[y54Rn>;:p3~Dp5g9rpsFNvZqjg3uJp4~<5p0Pw;5qlJwNZt*@3p1Pw:5p/Ou!5p2JvG'!6Vye=<qnJvh_[xhg3v,Rh3kOwOw-sDuev/Re^dha[a%!%!a+#Ta7)-5TaCaO!aka!a)sf[yb2>Rl!9ARiq5E}Qg=ucRkBE|oJrJ_@Wk~@Wk{JrJ_@Wk|@WkyJrJ_@Wk}@WkzJvO_[y2g-vMRmiKuYC!)&>Ri;>Ri<@3RkNc](X#@9Rk=g5vuRmhKvDB!+'=]meg3u4Rmgd)#Y'Vz3CARmfd`a+!%T'!+#Ta1Ta6TaM-sTDt9[yA9sYd'%Y#s[[xpj:ueunaXRgEjRq,v-vuqdd2'`#6Rev<32@5>:2<E}5xIo9a*X#Y(;5RePJvD_g>vyRgNj8w)v8<wggs:RgXiZt|vjx,hSq3ah!-(~@:Ro/Ou!5RhWj^v(pyw8unRhUdx-UY#^Ua.a3a70!)%UX1TaDa)'omRiRRhE[y:3Dsz=Br,>6Vyj3[xkg6ruwjcqsrPw;5r*Ku]D'Zt-@3r(~?r.i[vwv]dU1a--U#`a4(g/vsRhPOu!5RhLj:rmu9Wo!~@:wdh@g/vsRiTjXuvvNr}:RhBj^v(pyw8unRn]dz1UYa'a+^Y(!aETZalaRY.Ta?a4[yDJw1!#qLsW>6Vyrfzq-pLflpwRe|Js>%!Dt@3Dt&Jvy_[xs~HrnjMuwpsw'RecKu+D#'!t<~Grl~?rjg5u-x,gwp{ah!-(~@:Rg~Ou!5Rh'jXuvvNr}:Rh#cW#X/c;&!#2sLi[v7u7RgpJv)(!iLrxu,Re6j7v@s@5Se[e7d`aW!Za(a`T.a#!a3!&aDa-!9)Dt_=6s+3[x~~DR|h~DS6avhGun5RkZj3w)v-]mkKunB!&*]kb97R|i<ARk<c:Z(6Vy}Juh'!wziMRoS:F|vkLuauJv5vtvQRh1d='T+Y#VyO~DR|jcF#T'7R|g97R|kJv3'!ay<Rj,Jvh&!:ReXcsa6*a+#a#_aIRf9aLRf?c,Z&Rf5Rf7c.Z&Rf;Rf>cQ#%T'p-Rf8Rf=ct#%'(*!,p,Rf4p+Rf6Rf:Rf<d~'Ua%U*^UYa(!a,-!#a4YaTalaEX0a8a<Weo3Dt/3Dsx=Br93Wen~Dr;~<5p<JwNZt2@3p=Pw:5p;Ou!5r3c7&!#:p>3Ds}KvGB)_6Vyk2sM=<r7x'eovA(!hFu1ARf}cV#X&@r5j6rvwQa^Rf3c=Za'wkghJv__g;unRggA53B9=b^}%j6uduo5Jq;!(hIv%2Re`Ou4ARe_e%a#^^^Xa&!a*a2!&a6YaP!*ad!#a:aE/5Rn?[y@>6Vyp;:pE~DrY~<5pBJwNZt8@3pCh=rt3rWPw:5pAJup_[xoNuPpF9c!#'45pD5ARn)d8#X'X*3@rU72s]h>v<<sSjJpqvewOJq/(!hNw'5ReBk0s2u3w/w'5ReE5@Jq.!a+JQ!&WeU23d(#Y&RjG5]jBk!u7w&u0udARjEe#+^^^Ub#!a2/a`Z(agT1!a-a;|@TaG!aS[yV=Re~fow'RguNuPRe?bLine truncated
13_494,
2713,
49,
25,
61,
);
+7
View File
@@ -0,0 +1,7 @@
// Generated using scripts/write-decode-map.ts
/** Packed XML decode trie data. */
export const xmlDecodeTree: Uint16Array = /* #__PURE__ */ new Uint16Array([
512, 26_465, 29_036, 7, 0, 2, 4, 116, 24_638, 116, 24_636, 8693, 29_807,
24_610, 621, 1, 0, 0, 3, 112, 24_614, 111, 115, 24_615,
]);
+3
View File
@@ -0,0 +1,3 @@
// Generated using scripts/write-encode-map.ts
export default "9Tab;NewLine;22excl;quot;num;dollar;percnt;amp;apos;lpar;rpar;ast;plus;comma;1period;sol;10colon;semi;lt;{8402nvlt;}equals;{8421bne;}gt;{8402nvgt;}quest;commat;26lbrack;bsol;rbrack;Hat;lowbar;DiacriticalGrave;5{106fjlig;}20lbrace;verbar;rbrace;34nbsp;iexcl;cent;pound;curren;yen;brvbar;sect;die;copy;ordf;laquo;not;shy;circledR;macr;deg;PlusMinus;sup2;sup3;acute;micro;para;centerdot;cedil;sup1;ordm;raquo;frac14;frac12;frac34;iquest;Agrave;Aacute;Acirc;Atilde;Auml;angst;AElig;Ccedil;Egrave;Eacute;Ecirc;Euml;Igrave;Iacute;Icirc;Iuml;ETH;Ntilde;Ograve;Oacute;Ocirc;Otilde;Ouml;times;Oslash;Ugrave;Uacute;Ucirc;Uuml;Yacute;THORN;szlig;agrave;aacute;acirc;atilde;auml;aring;aelig;ccedil;egrave;eacute;ecirc;euml;igrave;iacute;icirc;iuml;eth;ntilde;ograve;oacute;ocirc;otilde;ouml;div;oslash;ugrave;uacute;ucirc;uuml;yacute;thorn;yuml;Amacr;amacr;Abreve;abreve;Aogon;aogon;Cacute;cacute;Ccirc;ccirc;Cdot;cdot;Ccaron;ccaron;Dcaron;dcaron;Dstrok;dstrok;Emacr;emacr;2Edot;edot;Eogon;eogon;Ecaron;ecaron;Gcirc;gcirc;Gbreve;gbreve;Gdot;gdot;Gcedil;1Hcirc;hcirc;Hstrok;hstrok;Itilde;itilde;Imacr;imacr;2Iogon;iogon;Idot;imath;IJlig;ijlig;Jcirc;jcirc;Kcedil;kcedil;kgreen;Lacute;lacute;Lcedil;lcedil;Lcaron;lcaron;Lmidot;lmidot;Lstrok;lstrok;Nacute;nacute;Ncedil;ncedil;Ncaron;ncaron;napos;ENG;eng;Omacr;omacr;2Odblac;odblac;OElig;oelig;Racute;racute;Rcedil;rcedil;Rcaron;rcaron;Sacute;sacute;Scirc;scirc;Scedil;scedil;Scaron;scaron;Tcedil;tcedil;Tcaron;tcaron;Tstrok;tstrok;Utilde;utilde;Umacr;umacr;Ubreve;ubreve;Uring;uring;Udblac;udblac;Uogon;uogon;Wcirc;wcirc;Ycirc;ycirc;Yuml;Zacute;zacute;Zdot;zdot;Zcaron;zcaron;19fnof;34imped;63gacute;65jmath;142circ;caron;16breve;DiacriticalDot;ring;ogon;DiacriticalTilde;dblac;51DownBreve;127Alpha;Beta;Gamma;Delta;Epsilon;Zeta;Eta;Theta;Iota;Kappa;Lambda;Mu;Nu;Xi;Omicron;Pi;Rho;1Sigma;Tau;Upsilon;Phi;Chi;Psi;ohm;7alpha;beta;gamma;delta;epsi;zeta;eta;theta;iota;kappa;lambda;mu;nu;xi;omicron;pi;rho;sigmaf;sigma;tau;upsi;phi;chi;psi;omega;7thetasym;Upsi;2phiv;piv;5Gammad;digamma;18kappav;rhov;3epsiv;backepsilon;10IOcy;DJcy;GJcy;Jukcy;DScy;Iukcy;YIcy;Jsercy;LJcy;NJcy;TSHcy;KJcy;1Ubrcy;DZcy;Acy;Bcy;Vcy;Gcy;Dcy;IEcy;ZHcy;Zcy;Icy;Jcy;Kcy;Lcy;Mcy;Ncy;Ocy;Pcy;Rcy;Scy;Tcy;Ucy;Fcy;KHcy;TScy;CHcy;SHcy;SHCHcy;HARDcy;Ycy;SOFTcy;Ecy;YUcy;YAcy;acy;bcy;vcy;gcy;dcy;iecy;zhcy;zcy;icy;jcy;kcy;lcy;mcy;ncy;ocy;pcy;rcy;scy;tcy;ucy;fcy;khcy;tscy;chcy;shcy;shchcy;hardcy;ycy;softcy;ecy;yucy;yacy;1iocy;djcy;gjcy;jukcy;dscy;iukcy;yicy;jsercy;ljcy;njcy;tshcy;kjcy;1ubrcy;dzcy;7074ensp;emsp;emsp13;emsp14;1numsp;puncsp;ThinSpace;hairsp;NegativeMediumSpace;zwnj;zwj;lrm;rlm;dash;2ndash;mdash;horbar;Verbar;1lsquo;CloseCurlyQuote;lsquor;1ldquo;CloseCurlyDoubleQuote;bdquo;1dagger;Dagger;bull;2nldr;hellip;9permil;pertenk;prime;Prime;tprime;backprime;3lsaquo;rsaquo;3oline;2caret;1hybull;frasl;10bsemi;7qprime;7MediumSpace;{8202ThickSpace;}NoBreak;af;InvisibleTimes;ic;72euro;46tdot;DotDot;37complexes;2incare;4gscr;hamilt;Hfr;Hopf;planckh;hbar;imagline;Ifr;lagran;ell;1naturals;numero;copysr;weierp;Popf;Qopf;realine;real;reals;rx;3trade;1integers;2mho;zeetrf;iiota;2bernou;Cayleys;1escr;Escr;Fouriertrf;1Mellintrf;order;alefsym;beth;gimel;daleth;12CapitalDifferentialD;dd;ee;ii;10frac13;frac23;frac15;frac25;frac35;frac45;frac16;frac56;frac18;frac38;frac58;frac78;49larr;ShortUpArrow;rarr;darr;harr;updownarrow;nwarr;nearr;LowerRightArrow;LowerLeftArrow;nlarr;nrarr;1rarrw;{824nrarrw;}Larr;Uarr;Rarr;Darr;larrtl;rarrtl;LeftTeeArrow;mapstoup;map;DownTeeArrow;1hookleftarrow;hookrightarrow;larrlp;looparrowright;harrw;nharr;1lsh;rsh;ldsh;rdsh;1crarr;cularr;curarr;2circlearrowleft;circlearrowright;leftharpoonup;DownLeftVector;RightUpVector;LeftUpVector;rharu;DownRightVector;dharr;dharl;RightArrowLeftArrow;udarr;LeftArrowRightArrow;leftleftarrows;upuparrows;rightrightarrows;ddarr;leftrightharpoons;Equilibrium;nlArr;nhArr;nrArr;DoubleLeftArrow;DoubleUpArrow;DoubleRightArrow;dArr;DoubleLeftRightArrow;DoubleUpDownArrow;nwArr;neArr;seArr;swArr;lAarr;rAarr;1zigrarr;6larrb;rarrb;15DownArrowUpArrow;7loarr;roarr;hoarr;forall;comp;part;{824npart;}exist;nexist;empty;1Del;Element;NotElement;1ni;notni;2prod;coprod;sum;minus;MinusPlus;dotplus;1Backslash;lowast;compfn;1radic;2prop;infin;angrt;ang;{8402nang;}angmsd;angsph;mid;nmid;DoubleVerticalBar;NotDoubleVerticalBar;and;or;cap;{65024caps;}cup;{65024cups;}int;Int;iiint;conint;Conint;Cconint;cwint;ClockwiseContourIntegral;awconint;there4;becaus;ratio;Colon;dotminus;1mDDot;homtht;sim;{8402nvsim;}backsim;{817race;}ac;{819acE;}acd;VerticalTilde;NotTilde;eqsim;{824nesim;}sime;NotTildeEqual;cong;simne;ncong;ap;nap;ape;apid;{824napid;}backcong;asympeq;{8402nvap;}bump;{824nbump;}bumpe;{824nbumpe;}doteq;{824nedot;}doteqdot;efDot;erDot;Assign;ecolon;ecir;circeq;1wedgeq;veeeq;1triangleq;2equest;ne;Congruent;{8421bnequiv;}nequiv;1le;{8402nvle;}ge;{8402nvge;}lE;{824nlE;}gE;{824ngE;}lnE;{65024lvertneqq;}gnE;{65024gvertneqq;}ll;{824nLtv;7577nLt;}gg;{824nGtv;7577nGt;}between;NotCupCap;nless;ngt;nle;nge;lesssim;GreaterLine truncated
+160
View File
@@ -0,0 +1,160 @@
import { type DecodingMode, decodeHTML, decodeXML } from "./decode.js";
import { encodeHTML, encodeNonAsciiHTML } from "./encode.js";
import {
encodeXML,
escapeAttribute,
escapeText,
escapeUTF8,
} from "./escape.js";
/** The level of entities to support. */
export enum EntityLevel {
/** Support only XML entities. */
XML = 0,
/** Support HTML entities, which are a superset of XML entities. */
HTML = 1,
}
/**
* Encoding strategy used by `encode`.
*/
export enum EncodingMode {
/**
* The output is UTF-8 encoded. Only characters that need escaping within
* XML will be escaped.
*/
UTF8,
/**
* The output consists only of ASCII characters. Characters that need
* escaping within HTML, and characters that aren't ASCII characters will
* be escaped.
*/
ASCII,
/**
* Encode all characters that have an equivalent entity, as well as all
* characters that are not ASCII characters.
*/
Extensive,
/**
* Encode all characters that have to be escaped in HTML attributes,
* following {@link https://html.spec.whatwg.org/multipage/parsing.html#escapingString}.
*/
Attribute,
/**
* Encode all characters that have to be escaped in HTML text,
* following {@link https://html.spec.whatwg.org/multipage/parsing.html#escapingString}.
*/
Text,
}
/**
* Options for `decode`.
*/
export interface DecodingOptions {
/**
* The level of entities to support.
* @default {@link EntityLevel.XML}
*/
level?: EntityLevel;
/**
* Decoding mode. If `Legacy`, will support legacy entities not terminated
* with a semicolon (`;`).
*
* Always `Strict` for XML. For HTML, set this to
* {@link DecodingMode.Attribute} if you are parsing an attribute value.
* @default {@link DecodingMode.Legacy}
*/
mode?: DecodingMode | undefined;
}
/**
* Decodes a string with entities.
* @param input String to decode.
* @param options Decoding options.
*/
export function decode(
input: string,
options: DecodingOptions | EntityLevel = EntityLevel.XML,
): string {
const level = typeof options === "number" ? options : options.level;
if (level === EntityLevel.HTML) {
const mode = typeof options === "object" ? options.mode : undefined;
return decodeHTML(input, mode);
}
return decodeXML(input);
}
/**
* Options for `encode`.
*/
export interface EncodingOptions {
/**
* The level of entities to support.
* @default {@link EntityLevel.XML}
*/
level?: EntityLevel;
/**
* Output format.
* @default {@link EncodingMode.Extensive}
*/
mode?: EncodingMode;
}
/**
* Encodes a string with entities.
* @param input String to encode.
* @param options Encoding options.
*/
export function encode(
input: string,
options: EncodingOptions | EntityLevel = EntityLevel.XML,
): string {
const { mode = EncodingMode.Extensive, level = EntityLevel.XML } =
typeof options === "number" ? { level: options } : options;
switch (mode) {
case EncodingMode.UTF8: {
return escapeUTF8(input);
}
case EncodingMode.Attribute: {
return escapeAttribute(input);
}
case EncodingMode.Text: {
return escapeText(input);
}
case EncodingMode.ASCII: {
return (
level === EntityLevel.HTML ? encodeNonAsciiHTML : encodeXML
)(input);
}
// biome-ignore lint/complexity/noUselessSwitchCase: we get an error for the switch not being exhaustive
case EncodingMode.Extensive:
default: {
return (level === EntityLevel.HTML ? encodeHTML : encodeXML)(input);
}
}
}
export {
DecodingMode,
decodeHTML,
decodeHTMLAttribute,
decodeHTMLStrict,
decodeXML,
decodeXML as decodeXMLStrict,
EntityDecoder,
} from "./decode.js";
export {
encodeHTML,
encodeNonAsciiHTML,
} from "./encode.js";
export {
encodeXML,
escape,
escapeAttribute,
escapeText,
escapeUTF8,
} from "./escape.js";
+47
View File
@@ -0,0 +1,47 @@
/**
* Bit flags & masks for the binary trie encoding used for entity decoding.
*
* The trie is a flat `Uint16Array`. Every node starts with one header word:
*
* 15..14 VALUE_LENGTH Number of words the value occupies, +1.
* 0 = no value; 1 = value inline in bits 12..0;
* 2/3 = value in the 1/2 words after the header.
* 13 FLAG13 If VALUE_LENGTH > 0: semicolon required ("strict"
* entity; `;` is never stored as a branch).
* If VALUE_LENGTH == 0: this node is a compact run.
* 12..7 BRANCH_LENGTH Number of branches (or run length for runs).
* 6..0 JUMP_TABLE Jump-table offset / single-branch char / first
* run char (see below).
*
* Branch data follows the header and any value words. Its shape is selected
* by (JUMP_TABLE, BRANCH_LENGTH) in the header:
*
* Single branch JUMP_TABLE = the only child's char, BRANCH_LENGTH = 0.
* No branch words; the child node follows immediately.
* Jump table JUMP_TABLE = first covered char (> 0), BRANCH_LENGTH =
* table length. One word per covered char: 0 = no branch,
* otherwise the child's offset from the END of the table,
* +1 (so 0 stays the no-branch sentinel).
* Dictionary JUMP_TABLE = 0, BRANCH_LENGTH = number of branches.
* ceil(n/2) words of sorted keys packed two per word
* (low byte first), then n pointer words storing the
* child's offset from the END of the branch data.
* Compact run VALUE_LENGTH = 0, FLAG13 set. BRANCH_LENGTH = run
* length (3..63), JUMP_TABLE = first char; remaining run
* chars packed two per word after the header. The target
* node follows the packed words immediately.
*
* Pointers are end-relative (rather than relative to the pointer's own
* position) because that makes the common "child encoded right after the
* branch data" case a small constant, which compresses far better. Offsets
* to already-encoded (shared) nodes wrap via uint16 modulo arithmetic; the
* decoder masks navigation results with `& 0xff_ff` to match.
*/
export const enum BinTrieFlags {
VALUE_LENGTH = 0b1100_0000_0000_0000,
FLAG13 = 0b0010_0000_0000_0000,
BRANCH_LENGTH = 0b0001_1111_1000_0000,
JUMP_TABLE = 0b0000_0000_0111_1111,
/** Bits 12..0: the inline value of a VALUE_LENGTH = 1 header word. */
VALUE_MASK = 0b0001_1111_1111_1111,
}
+209
View File
@@ -0,0 +1,209 @@
/*
* Inverse of the encoder's SAFE alphabet (0x21..0x7E minus 0x22, 0x24, 0x5C),
* precomputed once at module load. Entries for excluded chars stay 0 but
* are never read.
*/
const BASE91_INVERSE = /* #__PURE__ */ (() => {
const table = new Uint8Array(127);
let code = 0;
for (let char = 0x21; char <= 0x7e; char++) {
if (char !== 0x22 && char !== 0x24 && char !== 0x5c) {
table[char] = code++;
}
}
return table;
})();
/**
* Decode a dictionary-encoded trie string back into its Uint16Array.
*
* Stream layout (consumed in this order):
* 1. dict1 atoms — `dict1AtomCount` uint16 values, delta+RLE encoded.
* 2. dict2 atoms — `atomCount - dict1AtomCount` values, delta+RLE.
* 3. dict2 ngrams — `ngramCount - (dictSize - dict1AtomCount)` entries,
* each a pair of slot codes that resolve to earlier slots.
* 4. dict1 ngrams — `dictSize - dict1AtomCount` entries, same shape.
* 5. data — slot codes, each expanding to one or more uint16 values.
*
* Codes use a 91-char base (printable ASCII minus `"`, `$`, `\`):
* - char1 < dictSize → 1-char code, slot = char1
* - char1 ≥ dictSize → 2-char code, slot = dictSize + (char1 - dictSize)*91 + char2
*
* Slot index → token kind:
* [0, A) dict1 atoms (1-char codes)
* [A, dictSize) dict1 ngrams (1-char codes)
* [dictSize, dictSize+D) dict2 atoms (2-char codes)
* [dictSize+D, end) dict2 ngrams (2-char codes)
*
* Both atom dicts decode before any ngram, and dict2 ngrams decode before
* dict1 ngrams. So every ngram entry references slots whose contents are
* already filled — no forward references to handle.
*
* This runs on library import. Flat typed arrays store each slot as either
* a plain value (`single`, covering every atom) or a range in a shared
* `pool` (ngrams).
* @param input Packed trie string.
* @param resultLength Expected number of uint16 values in the output.
* @param atomCount Total number of distinct uint16 values in the trie.
* @param dict1AtomCount Atoms in the 1-char range (`A` above).
* @param ngramCount Total number of ngram entries (dict1 + dict2).
* @param dictSize Number of 1-char code slots; the rest of `BASE - dictSize`
* first-byte values are 2-char codes.
*/
export function decodeTrieDict(
input: string,
resultLength: number,
atomCount: number,
dict1AtomCount: number,
ngramCount: number,
dictSize: number,
): Uint16Array {
const base = 91;
const inputLength = input.length;
// For 2-char codes, slot = char1 * base - twoCharBias + char2.
const twoCharBias = dictSize * (base - 1);
let pos = 0;
/** Read one slot code at `pos` and return its slot index, advancing pos. */
const readSlotCode = (): number => {
const c1 = BASE91_INVERSE[input.charCodeAt(pos++)];
return c1 < dictSize
? c1
: c1 * base - twoCharBias + BASE91_INVERSE[input.charCodeAt(pos++)];
};
const dict2AtomCount = atomCount - dict1AtomCount;
const slotCount = atomCount + ngramCount;
/*
* Per-slot contents: atoms (always a single value) live directly in
* `single`; ngram slots hold -1 there and expand to
* `pool[start[slot] .. start[slot] + length[slot])`.
*/
const single = new Int32Array(slotCount);
single.fill(-1, dict1AtomCount, dictSize);
single.fill(-1, dictSize + dict2AtomCount, slotCount);
const start = new Int32Array(slotCount);
const length = new Int32Array(slotCount);
/**
* Decode `count` ascending uint16 values from a delta+RLE stream into
* `single[off..off+count)`.
*
* code < 89 → delta = code
* code == 89 → run-length: next char encodes runLength-2; emit `runLength` consecutive +1 values
* code == 90, next < 90 → escape: delta = 89 + next * BASE + after-next
* code == 90, next == 90 → double-escape: extra char for very large deltas
* @param count
* @param off
*/
function decodeDelta(count: number, off: number): void {
let previous = 0;
let slot = off;
const end = off + count;
while (slot < end) {
const code = BASE91_INVERSE[input.charCodeAt(pos++)];
if (code < 89) {
previous += code;
single[slot++] = previous;
} else if (code === 89) {
let runLength = BASE91_INVERSE[input.charCodeAt(pos++)] + 2;
while (runLength--) single[slot++] = ++previous;
} else {
const next = BASE91_INVERSE[input.charCodeAt(pos++)];
previous +=
89 +
// eslint-disable-next-line unicorn/prefer-minimal-ternary -- branches read a different number of side-effecting input bytes
(next < 90
? next * base + BASE91_INVERSE[input.charCodeAt(pos++)]
: BASE91_INVERSE[input.charCodeAt(pos++)] * 8281 +
BASE91_INVERSE[input.charCodeAt(pos++)] * base +
BASE91_INVERSE[input.charCodeAt(pos++)]);
single[slot++] = previous;
}
}
}
// Streams 1 & 2: atoms decoded into their slot ranges.
decodeDelta(dict1AtomCount, 0);
decodeDelta(dict2AtomCount, dictSize);
/*
* Streams 3 & 4 are read in two passes: first collect every ngram's two
* references and derive its expanded length (each ref resolves to an earlier
* slot, so lengths are already known), which sizes the shared pool.
* Pool ranges are handed out in decode order, so the second pass fills
* the pool contiguously with a single write cursor.
*/
const references = new Int32Array(ngramCount * 2);
let poolSize = 0;
let ngramIndex = 0;
/**
* Read `count` ngram entries (each = 2 slot-code references) for the slots
* starting at `startSlot`, recording references and assigning pool ranges.
* @param count
* @param startSlot
*/
function readNgramReferences(count: number, startSlot: number): void {
for (let index = 0; index < count; index++) {
const slot = startSlot + index;
const a = readSlotCode();
const b = readSlotCode();
references[ngramIndex * 2] = a;
references[ngramIndex * 2 + 1] = b;
ngramIndex += 1;
start[slot] = poolSize;
const entryLength =
(single[a] < 0 ? length[a] : 1) +
(single[b] < 0 ? length[b] : 1);
length[slot] = entryLength;
poolSize += entryLength;
}
}
readNgramReferences(
ngramCount - dictSize + dict1AtomCount,
dictSize + dict2AtomCount,
);
readNgramReferences(dictSize - dict1AtomCount, dict1AtomCount);
// Second pass: concatenate each ngram's two halves into the pool.
const pool = new Uint16Array(poolSize);
let write = 0;
for (let index = 0; index < ngramIndex; index++) {
for (let half = 0; half < 2; half++) {
const source = references[index * 2 + half];
const value = single[source];
if (value < 0) {
let read = start[source];
const readEnd = read + length[source];
while (read < readEnd) pool[write++] = pool[read++];
} else {
pool[write++] = value;
}
}
}
// Stream 5: data. Each code expands to its slot's stored values.
const out = new Uint16Array(resultLength);
let outIndex = 0;
while (pos < inputLength) {
let slot = BASE91_INVERSE[input.charCodeAt(pos++)];
if (slot >= dictSize) {
slot =
slot * base -
twoCharBias +
BASE91_INVERSE[input.charCodeAt(pos++)];
}
const value = single[slot];
if (value < 0) {
let read = start[slot];
const readEnd = read + length[slot];
while (read < readEnd) out[outIndex++] = pool[read++];
} else {
out[outIndex++] = value;
}
}
return out;
}