diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 893332f769..57e0c7c722 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -117,8 +117,9 @@ Read [src/AGENTS.md](src/AGENTS.md) for component and domain ownership. ## Code Style `core/utils/markdown.ts` reads web-fetch titles from the first nonblank line. -Match zero to three literal spaces before `# ` without trimming indentation; -mixed space/tab code blocks must fall back to the URL. Strip optional closing +Match zero to three literal spaces before a single `#` followed by an ASCII +space or tab, without trimming indentation; mixed space/tab code blocks must +fall back to the URL. Strip optional closing hashes with a backwards scan so long fetched titles cannot cause quadratic regex backtracking. Closing hashes require a preceding space/tab (or an empty title) and only ASCII spaces/tabs afterward, apart from the terminal CR in CRLF input. diff --git a/frontend/src/core/utils/markdown.ts b/frontend/src/core/utils/markdown.ts index 24697c1ab2..59383ce4a4 100644 --- a/frontend/src/core/utils/markdown.ts +++ b/frontend/src/core/utils/markdown.ts @@ -5,7 +5,7 @@ export function extractTitleFromMarkdown(markdown: string) { if (firstLine === undefined) { return undefined; } - const headingPrefix = /^ {0,3}# /.exec(firstLine); + const headingPrefix = /^ {0,3}#[ \t]/.exec(firstLine); if (!headingPrefix) { return undefined; } diff --git a/frontend/tests/unit/core/utils/markdown.test.ts b/frontend/tests/unit/core/utils/markdown.test.ts index ce77694833..91f4737edc 100644 --- a/frontend/tests/unit/core/utils/markdown.test.ts +++ b/frontend/tests/unit/core/utils/markdown.test.ts @@ -6,6 +6,27 @@ test("reads the title from a leading ATX heading", () => { expect(extractTitleFromMarkdown("# Real Title\n\nbody")).toBe("Real Title"); }); +test.each([ + ["#\tReal Title", "Real Title"], + ["#\t Real Title", "Real Title"], + ["# \tReal Title", "Real Title"], + [" #\tReal Title\r\nbody", "Real Title"], + ["#\tReal Title ###\t\r\nbody", "Real Title"], + ["#\t", undefined], + ["#\t###", undefined], +])("accepts ASCII tab separators in %j", (markdown, expected) => { + expect(extractTitleFromMarkdown(markdown)).toBe(expected); +}); + +test.each(["\u00a0", "\u2003", "\v", "\f"])( + "does not treat %j as an ATX separator", + (separator) => { + expect( + extractTitleFromMarkdown(`#${separator}Not A Title`), + ).toBeUndefined(); + }, +); + test.each([ ["# Release Notes ###", "Release Notes"], ["# Release Notes ### ", "Release Notes"],