diff --git a/openbot/app/src/components/duet/page-text.tsx b/openbot/app/src/components/duet/page-text.tsx index c73c05f9b..e340016ca 100644 --- a/openbot/app/src/components/duet/page-text.tsx +++ b/openbot/app/src/components/duet/page-text.tsx @@ -6,16 +6,26 @@ export function PageText({ text }: { text: string }) { let cursor = 0; for (const match of text.matchAll(/https?:\/\/[^\s<>"']+/g)) { const start = match.index; - let source = match[0].replace(/[.,;:!?]+$/, ""); - for (const [open, close] of [ - ["(", ")"], - ["[", "]"], - ]) { - while ( - source.endsWith(close) && - source.split(close).length > source.split(open).length - ) { - source = source.slice(0, -1); + // Trim trailing sentence punctuation, then drop unmatched closers — and + // repeat, because a closer can hide punctuation behind it: + // "(see https://example.com/pricing.)" trims nothing first (the "." + // is not last), drops the ")", and only then exposes the ".". + // Balanced parentheses inside a URL are preserved throughout. + let source = match[0]; + let previous = ""; + while (source !== previous) { + previous = source; + source = source.replace(/[.,;:!?]+$/, ""); + for (const [open, close] of [ + ["(", ")"], + ["[", "]"], + ]) { + while ( + source.endsWith(close) && + source.split(close).length > source.split(open).length + ) { + source = source.slice(0, -1); + } } } let url: URL; diff --git a/openbot/app/tests/pages.test.tsx b/openbot/app/tests/pages.test.tsx index 6ae974ed1..6ac9ca15f 100644 --- a/openbot/app/tests/pages.test.tsx +++ b/openbot/app/tests/pages.test.tsx @@ -931,6 +931,20 @@ test("a research page renders source URLs as links without changing its text", ( } }); +test("source links drop punctuation left behind by an unmatched closing bracket", () => { + const text = + "(See https://example.com/pricing.) [See https://example.org/plans,] (See https://example.com/wiki/Project_(software).)"; + const view = render(); + expect( + view.getAllByRole("link").map((link) => link.getAttribute("href")), + ).toEqual([ + "https://example.com/pricing", + "https://example.org/plans", + "https://example.com/wiki/Project_(software)", + ]); + expect(view.container.textContent).toBe(text); +}); + test("source links keep balanced URL parentheses and support HTTP sources", () => { const text = "https://example.com/wiki/Project_(software) http://example.org"; const view = render();