diff --git a/src/__tests__/unit/title-fetch.test.ts b/src/__tests__/unit/title-fetch.test.ts
index 26f77b4..e18ab28 100644
--- a/src/__tests__/unit/title-fetch.test.ts
+++ b/src/__tests__/unit/title-fetch.test.ts
@@ -2,6 +2,15 @@ import { describe, expect, it, vi, afterEach } from "vitest";
import { extractTitle, fetchPageTitle, isPublicHttpUrl } from "../../title-fetch";
describe("extractTitle", () => {
+ it("decodes entities once, not recursively", () => {
+ expect(extractTitle("
a < b")).toBe("a < b");
+ expect(extractTitle("&")).toBe("&");
+ });
+
+ it("decodes astral numeric references to a single code point", () => {
+ expect(extractTitle("Hi 😀")).toBe("Hi \u{1F600}");
+ });
+
it("extracts a plain title", () => {
expect(extractTitle("Hello World")).toBe("Hello World");
});
diff --git a/src/title-fetch.ts b/src/title-fetch.ts
index 3ab68db..3d0a1a5 100644
--- a/src/title-fetch.ts
+++ b/src/title-fetch.ts
@@ -208,15 +208,15 @@ 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: chained replaces would decode "<" twice into "<".
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(/&(?:#x([0-9a-f]+)|#(\d+)|(amp|lt|gt|quot));/gi, (whole, hex, dec, name) => {
+ if (name) return named[name.toLowerCase()];
+ const code = hex ? parseInt(hex, 16) : parseInt(dec, 10);
+ return code <= 0x10ffff ? String.fromCodePoint(code) : whole;
+ })
.trim();
return raw || null;