diff --git a/src/__tests__/unit/title-fetch.test.ts b/src/__tests__/unit/title-fetch.test.ts index 26f77b4..bf65fca 100644 --- a/src/__tests__/unit/title-fetch.test.ts +++ b/src/__tests__/unit/title-fetch.test.ts @@ -26,6 +26,15 @@ describe("extractTitle", () => { expect(extractTitle(" Hello \n World ")).toBe("Hello World"); }); + it("decodes each entity once, so an escaped entity stays literal text", () => { + expect(extractTitle("Use &lt;b&gt; for bold")).toBe("Use <b> for bold"); + expect(extractTitle("&#39;quoted&#39;")).toBe("'quoted'"); + }); + + it("decodes code points above the BMP", () => { + expect(extractTitle("Party 🎉")).toBe("Party \u{1F389}"); + }); + it("decodes HTML entities", () => { expect(extractTitle("Tom & Jerry 's")).toBe("Tom & Jerry 's"); }); diff --git a/src/title-fetch.ts b/src/title-fetch.ts index 3ab68db..4740c3b 100644 --- a/src/title-fetch.ts +++ b/src/title-fetch.ts @@ -208,15 +208,16 @@ export function extractTitle(html: string): string | null { const match = html.match(/]*>([\s\S]*?)<\/title>/i); if (!match) return null; + const named: Record = { amp: "&", lt: "<", gt: ">", quot: '"' }; + // One pass, so "&lt;" decodes to the text "<" and not to "<". const raw = match[1] .replace(/\s+/g, " ") - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/'/g, "'") - .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10))) + .replace(/&(?:(amp|lt|gt|quot)|#x27|#(\d+));/g, (whole, name, code) => { + if (name) return named[name]; + if (code === undefined) return "'"; + const point = parseInt(code, 10); + return point <= 0x10ffff ? String.fromCodePoint(point) : whole; + }) .trim(); return raw || null;