From f6219d1a0ae08b88045445f6dbf194f070183d61 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 19:20:28 +0000 Subject: [PATCH] Decode HTML entities in page titles in a single pass Chained replaces decoded "&lt;" twice into "<", and String.fromCharCode split astral references such as 😀 into surrogate halves. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01REGVUtoAAVSPsdGVWCFLsJ --- src/__tests__/unit/title-fetch.test.ts | 9 +++++++++ src/title-fetch.ts | 14 +++++++------- 2 files changed, 16 insertions(+), 7 deletions(-) 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 &lt; b")).toBe("a < b"); + expect(extractTitle("&amp;")).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 "&lt;" 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;