// EntityEncoder.js import { buildTries } from './entityTries.js'; // NOTE: no static import of ALL_ENTITIES / entities.js here — that import // was the actual tree-shaking leak (see constructor comment below). // Replacement strings indexed by char code — direct array access, no hashing const XML_UNSAFE_REPLACEMENT = new Array(128); XML_UNSAFE_REPLACEMENT[38] = '&'; // & XML_UNSAFE_REPLACEMENT[60] = '<'; // < XML_UNSAFE_REPLACEMENT[62] = '>'; // > XML_UNSAFE_REPLACEMENT[34] = '"'; // " XML_UNSAFE_REPLACEMENT[39] = '''; // ' // Typed bitmask for O(1) "is this ASCII code XML-unsafe?" check const IS_XML_UNSAFE = new Uint8Array(128); IS_XML_UNSAFE[38] = 1; IS_XML_UNSAFE[60] = 1; IS_XML_UNSAFE[62] = 1; IS_XML_UNSAFE[34] = 1; IS_XML_UNSAFE[39] = 1; // Fast pre-scan: bail out immediately if nothing needs encoding const NEEDS_PROCESSING = /[&<>"'\u0080-\uFFFF]/; export default class EntityEncoder { constructor(options = {}) { this.encodeXmlSafe = options.encodeXmlSafe !== false; this.encodeAllNamed = options.encodeAllNamed !== false; this.maxReplacements = options.maxReplacements || 0; this.replacementsCount = 0; // Tries are only built when named-entity encoding is actually requested, // and only from what the caller explicitly hands in. There is no // built-in default entity set imported here — that static import was // exactly what pinned the entire ALL_ENTITIES data (all categories) into // every consumer's bundle regardless of what they actually used. A `||` // fallback to a statically-imported default does NOT tree-shake: the // import is still "used" from the bundler's point of view even if the // runtime branch never executes. // // BREAKING CHANGE from earlier versions: `new EntityEncoder()` with no // `namedEntities` and `encodeAllNamed !== false` now throws instead of // silently encoding the full built-in set. Callers who want the old // "encode everything" behavior must opt in explicitly: // import { ALL_ENTITIES } from '@nodable/entities'; // new EntityEncoder({ namedEntities: ALL_ENTITIES }); if (this.encodeAllNamed) { if (!options.namedEntities) { throw new Error( '[EntityEncoder] encodeAllNamed is true but no `namedEntities` was ' + 'provided. Pass a specific set (e.g. { ...COMMON_HTML, ...CURRENCY }) ' + 'or the full built-in set via `namedEntities: ALL_ENTITIES` imported ' + 'from \'@nodable/entities\'. This is required so bundlers can ' + 'tree-shake unused entity categories out of your build; set ' + '`encodeAllNamed: false` if you don\'t need named-entity encoding at all.' ); } const tries = buildTries(options.namedEntities); this.trie1 = tries.trie1; this.trie2 = tries.trie2; this.trie3 = tries.trie3; } else { this.trie1 = null; this.trie2 = null; this.trie3 = null; } } encode(str) { if (typeof str !== 'string' || str.length === 0) return str; if (!NEEDS_PROCESSING.test(str)) return str; const maxRep = this.maxReplacements; if (maxRep > 0 && this.replacementsCount >= maxRep) return str; // Hoist to locals — avoids `this` property lookup inside the hot loop const encodeXmlSafe = this.encodeXmlSafe; const encodeAllNamed = this.encodeAllNamed; const trie1 = this.trie1; const trie2 = this.trie2; const trie3 = this.trie3; const len = str.length; let result = ''; let last = 0; let i = 0; let limitReached = false; // ── Main loop: runs to len-2 so trie3 never needs a bounds check ──────── // The last 2 characters are handled by the tail block below. const mainEnd = len - 2; // i <= mainEnd guarantees i+1 and i+2 are valid while (i <= mainEnd && !limitReached) { const c0 = str.charCodeAt(i); // ── ASCII branch ─────────────────────────────────────────────────── if (c0 < 128) { if (encodeXmlSafe && IS_XML_UNSAFE[c0] === 1) { result += str.substring(last, i) + XML_UNSAFE_REPLACEMENT[c0]; last = ++i; if (maxRep > 0) { this.replacementsCount++; if (this.replacementsCount >= maxRep) { limitReached = true; break; } } } else { // Bulk-skip: advance to the next interesting position without // touching the outer loop overhead on every safe character i++; while (i <= mainEnd && !limitReached) { const c = str.charCodeAt(i); if (c >= 128 || (encodeXmlSafe && IS_XML_UNSAFE[c] === 1)) break; i++; } } continue; } // ── Non-ASCII: integer-keyed trie lookup ─────────────────────────── // No bounds checks needed for c1/c2 because i <= mainEnd guarantees // i+1 and i+2 are both within the string. let matchedEntity = null; let advance = 1; if (encodeAllNamed) { // Try 3-char match first (longest wins) const mid3 = trie3.get(c0); if (mid3 !== undefined) { const c1 = str.charCodeAt(i + 1); const inner3 = mid3.get(c1); if (inner3 !== undefined) { const c2 = str.charCodeAt(i + 2); const candidate = inner3.get(c2); if (candidate !== undefined) { matchedEntity = candidate; advance = 3; } } } // Try 2-char match if (matchedEntity === null) { const inner2 = trie2.get(c0); if (inner2 !== undefined) { const c1 = str.charCodeAt(i + 1); const candidate = inner2.get(c1); if (candidate !== undefined) { matchedEntity = candidate; advance = 2; } } } // Try 1-char match if (matchedEntity === null) { const candidate = trie1.get(c0); if (candidate !== undefined) { matchedEntity = candidate; } } } if (matchedEntity !== null) { result += str.substring(last, i) + matchedEntity; i += advance; last = i; if (maxRep > 0) { this.replacementsCount++; if (this.replacementsCount >= maxRep) { limitReached = true; break; } } } else { i++; } } // ── Tail: handle the last 1-2 characters (no 3-char match possible) ──── while (i < len && !limitReached) { const c0 = str.charCodeAt(i); if (c0 < 128) { if (encodeXmlSafe && IS_XML_UNSAFE[c0] === 1) { result += str.substring(last, i) + XML_UNSAFE_REPLACEMENT[c0]; last = ++i; if (maxRep > 0) { this.replacementsCount++; if (this.replacementsCount >= maxRep) { limitReached = true; break; } } } else { i++; } continue; } // Non-ASCII tail — only 2-char and 1-char matches are possible here let matchedEntity = null; let advance = 1; if (encodeAllNamed) { if (i + 1 < len) { const inner2 = trie2.get(c0); if (inner2 !== undefined) { const c1 = str.charCodeAt(i + 1); const candidate = inner2.get(c1); if (candidate !== undefined) { matchedEntity = candidate; advance = 2; } } } if (matchedEntity === null) { const candidate = trie1.get(c0); if (candidate !== undefined) { matchedEntity = candidate; } } } if (matchedEntity !== null) { result += str.substring(last, i) + matchedEntity; i += advance; last = i; if (maxRep > 0) { this.replacementsCount++; if (this.replacementsCount >= maxRep) { limitReached = true; break; } } } else { i++; } } // ── Flush any remaining literal suffix ──────────────────────────────── if (last < len) result += str.substring(last); return result; } reset() { this.replacementsCount = 0; } }