/** * HTML entity decoding shared by both halves of web-access. * * `web_fetch` decodes entities while turning HTML into readable text, and * `web_search` decodes them in provider snippets. The table lives here because * the two halves do not import each other. */ const NAMED_ENTITIES: Record = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", hellip: "…", mdash: "—", ndash: "–", }; /** * A tag's attribute span, from after its name to the closing `>`. Quoted values * are consumed whole, so a `>` inside one does not end the tag early; an unpaired * quote falls through to the plain-character branch. */ export const TAG_ATTRIBUTES = `(?:"[^"]*"|'[^']*'|[^>])*`; /** Strip tags (replacing each with `separator`) and decode entities. */ export function stripTagsAndDecode(html: string, separator = ""): string { return decodeEntities(html.replace(new RegExp(`<${TAG_ATTRIBUTES}>`, "g"), separator)); } /** Decode named and numeric HTML entities. Unknown entities are left as-is. */ export function decodeEntities(input: string): string { return input.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g, (match, body: string) => { if (body.startsWith("#")) { const hex = body[1] === "x" || body[1] === "X"; const code = Number.parseInt(body.slice(hex ? 2 : 1), hex ? 16 : 10); if (!Number.isFinite(code) || code < 0 || code > 0x10ffff) return match; try { return String.fromCodePoint(code); } catch { return match; } } return NAMED_ENTITIES[body.toLowerCase()] ?? match; }); }