// Small, forgiving HTML helpers. Not a parser: enough for , ,
, . const NAMED: Readonly> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" }; export function decodeEntities(s: string): string { return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, entity: string) => { const e = entity.toLowerCase(); if (e.startsWith("#")) { const code = e.startsWith("#x") ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10); // Malformed pages carry entities like �: leave them as written rather than throw. return Number.isInteger(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match; } return NAMED[e] ?? match; }); } export function parseAttributes(tag: string): Record { const out: Record = {}; const body = tag.replace(/^<\s*[\w-]+/, "").replace(/\/?\s*>$/, ""); for (const m of body.matchAll(/([^\s=/"'>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g)) { const name = m[1]!.toLowerCase(); if (!(name in out)) out[name] = decodeEntities(m[2] ?? m[3] ?? m[4] ?? ""); } return out; } export function findTags(html: string, name: string): Record[] { return [...html.matchAll(new RegExp(`<${name}\\b[^>]*>`, "gi"))].map((m) => parseAttributes(m[0])); } /** `` */ export function metaContent(html: string, key: string): string | undefined { for (const a of findTags(html, "meta")) { if ((a.property ?? a.name) === key && a.content) return a.content; } return undefined; } export function textOf(fragment: string): string { return decodeEntities(fragment.replace(/<[^>]*>/g, " ")).replace(/\s+/g, " ").trim(); }