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://";