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 <b> for bold")).toBe("Use <b> for bold");
+ expect(extractTitle("'quoted'")).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 "<" 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;