diff --git a/openbot/app/src/components/duet/page-text.tsx b/openbot/app/src/components/duet/page-text.tsx index c73c05f9b..43d3f67ba 100644 --- a/openbot/app/src/components/duet/page-text.tsx +++ b/openbot/app/src/components/duet/page-text.tsx @@ -6,16 +6,23 @@ 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); + // A period or comma can sit just inside the closing bracket. Trim, peel + // unmatched closers, and trim again until the suffix stops changing. + 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..bf83430ba 100644 --- a/openbot/app/tests/pages.test.tsx +++ b/openbot/app/tests/pages.test.tsx @@ -943,6 +943,21 @@ test("source links keep balanced URL parentheses and support HTTP sources", () = expect(view.container.textContent).toBe(text); }); +test("a bracketed citation drops sentence punctuation from the link", () => { + const text = + "(See https://example.com/pricing.) [See https://example.org/plans,] (https://example.org/plans). 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.org/plans", + "https://example.com/wiki/Project_(software)", + ]); + expect(view.container.textContent).toBe(text); +}); + test("source text never turns markup, unsafe schemes or URL credentials into links", () => { const text = " javascript:alert(1) data:text/html,unsafe https://user:secret@example.com/private https://";