diff --git a/gui/src/components/chat/utils/chatLinks.test.ts b/gui/src/components/chat/utils/chatLinks.test.ts new file mode 100644 index 00000000..7c93cd29 --- /dev/null +++ b/gui/src/components/chat/utils/chatLinks.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "bun:test"; + +import { getChatLinkMatches } from "./chatLinks"; + +function firstUrl(text: string): string | undefined { + return getChatLinkMatches(text).find((match) => match.kind === "url")?.value; +} + +describe("getChatLinkMatches URL vs Markdown delimiters", () => { + test("strips trailing ** from a bold-wrapped bare URL", () => { + // Regression for #197: Cmd-hover underlined the closing ** and opened + // http://localhost:3003** instead of http://localhost:3003. + expect(firstUrl("**http://localhost:3003**")).toBe("http://localhost:3003"); + }); + + test("strips a single trailing * from an italic-wrapped URL", () => { + expect(firstUrl("*https://example.com*")).toBe("https://example.com"); + }); + + test("strips trailing ~~ from a strikethrough-wrapped URL", () => { + expect(firstUrl("~~https://example.com~~")).toBe("https://example.com"); + }); + + test("strips Markdown delimiters interleaved with trailing punctuation", () => { + expect(firstUrl("**http://localhost:3003.**")).toBe("http://localhost:3003"); + }); + + test("preserves a legitimate interior asterisk in the URL path", () => { + expect(firstUrl("see http://a.com/x*y here")).toBe("http://a.com/x*y"); + }); + + test("preserves legitimate trailing asterisks on unwrapped URLs", () => { + expect(firstUrl("https://example.com/path*")).toBe("https://example.com/path*"); + expect(firstUrl("https://example.com/?q=*")).toBe("https://example.com/?q=*"); + }); + + test("preserves legitimate tildes in unwrapped URLs", () => { + expect(firstUrl("https://example.com/~user")).toBe("https://example.com/~user"); + expect(firstUrl("https://example.com/~")).toBe("https://example.com/~"); + }); + + test("removes only the matching closing delimiter", () => { + expect(firstUrl("*https://example.com/path**")).toBe("https://example.com/path*"); + expect(firstUrl("~~https://example.com/path~~~")).toBe("https://example.com/path~"); + }); + + test("still balances closing parens in a Wikipedia-style URL", () => { + expect(firstUrl("https://en.wikipedia.org/wiki/Foo_(bar)")).toBe( + "https://en.wikipedia.org/wiki/Foo_(bar)", + ); + }); + + test("still trims a wrapping paren and trailing sentence punctuation", () => { + expect(firstUrl("(https://example.com)")).toBe("https://example.com"); + expect(firstUrl("visit https://example.com.")).toBe("https://example.com"); + }); + + test("reports the correct span end after trimming the delimiters", () => { + const [match] = getChatLinkMatches("**http://localhost:3003**"); + expect(match).toMatchObject({ + kind: "url", + start: 2, + end: 2 + "http://localhost:3003".length, + value: "http://localhost:3003", + }); + }); + + test("strips stacked and mixed trailing emphasis delimiters", () => { + expect(firstUrl("***https://example.com***")).toBe("https://example.com"); + expect(firstUrl("~~*https://example.com*~~")).toBe("https://example.com"); + }); + + test("never splits a multi-byte trailing character (surrogate-safe)", () => { + // Trimming only removes ASCII markers, so a URL that legitimately ends in a + // multi-byte glyph (accents, emoji) is preserved byte-for-byte, never cut + // mid-codepoint. + expect(firstUrl("**https://example.com/éé**")).toBe("https://example.com/éé"); + expect(firstUrl("see https://example.com/p\u{1F600}")).toBe("https://example.com/p\u{1F600}"); + }); +}); diff --git a/gui/src/components/chat/utils/chatLinks.ts b/gui/src/components/chat/utils/chatLinks.ts index 6d3d1df3..1d8fdb84 100644 --- a/gui/src/components/chat/utils/chatLinks.ts +++ b/gui/src/components/chat/utils/chatLinks.ts @@ -58,29 +58,72 @@ export function isMailtoUrl(href: string): boolean { } function trimLinkCandidate(candidate: string): string { - let value = candidate.replace(TRAILING_PUNCTUATION_PATTERN, ""); + let value = candidate; + + let trimmed = true; + while (trimmed && value.length > 0) { + trimmed = false; + + const withoutPunctuation = value.replace(TRAILING_PUNCTUATION_PATTERN, ""); + if (withoutPunctuation.length !== value.length) { + value = withoutPunctuation; + trimmed = true; + continue; + } - while (value.length > 0) { const lastChar = value.at(-1); - if (lastChar == null || !TRAILING_CLOSERS.has(lastChar)) { + if (lastChar == null) { break; } - const opener = lastChar === ")" ? "(" : lastChar === "]" ? "[" : lastChar === "}" ? "{" : null; - if (opener != null) { - const openerCount = value.split(opener).length - 1; - const closerCount = value.split(lastChar).length - 1; - if (closerCount <= openerCount) { - break; + if (TRAILING_CLOSERS.has(lastChar)) { + const opener = + lastChar === ")" ? "(" : lastChar === "]" ? "[" : lastChar === "}" ? "{" : null; + if (opener != null) { + const openerCount = value.split(opener).length - 1; + const closerCount = value.split(lastChar).length - 1; + if (closerCount <= openerCount) { + break; + } } - } - value = value.slice(0, -1); + value = value.slice(0, -1); + trimmed = true; + } } return value; } +function markdownClosingDelimiterBefore(text: string, start: number): string | null { + const opening = text.slice(0, start).match(/[~*]+$/u)?.[0]; + if (opening == null) { + return null; + } + + for (let index = 0; index < opening.length; index += 1) { + if (opening[index] === "*") { + continue; + } + if (opening[index] !== "~" || opening[index + 1] !== "~") { + return null; + } + index += 1; + } + + return [...opening].reverse().join(""); +} + +function trimMarkdownWrappedLinkCandidate(text: string, start: number, candidate: string): string { + const value = trimLinkCandidate(candidate); + const closingDelimiter = markdownClosingDelimiterBefore(text, start); + if (closingDelimiter == null || !value.endsWith(closingDelimiter)) { + return value; + } + + return trimLinkCandidate(value.slice(0, -closingDelimiter.length)); +} + function safeDecode(value: string): string { try { return decodeURIComponent(value); @@ -402,7 +445,7 @@ export function getChatLinkMatches( URL_PATTERN.lastIndex = 0; let urlMatch: RegExpExecArray | null; while ((urlMatch = URL_PATTERN.exec(text)) !== null) { - const value = trimLinkCandidate(urlMatch[0]); + const value = trimMarkdownWrappedLinkCandidate(text, urlMatch.index, urlMatch[0]); if (value.length === 0) { continue; }