// 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();
}