fix(tui): preserve balanced parentheses in markdown link URL extraction (#100661) (#100697)

The markdown link regex character class [^)\s>]+ stopped at the first
closing paren, truncating URLs like wikipedia.org/wiki/URL_(disambiguation)
at the opening (. Replace with a balanced-parens pattern that supports
one level of parenthetical groups inside the URL path.

Add trimUnbalancedTrailingParens for bare URLs in prose where a trailing
) belongs to surrounding punctuation rather than the URL itself.
This commit is contained in:
ZOOWH
2026-07-06 15:47:38 +08:00
committed by GitHub
parent 7b68306949
commit 5c7c2961fb
2 changed files with 62 additions and 9 deletions

View File

@@ -47,6 +47,21 @@ describe("extractUrls", () => {
const urls = extractUrls("https://example.com/path?q=1&r=2#section");
expect(urls).toEqual(["https://example.com/path?q=1&r=2#section"]);
});
it("extracts markdown link hrefs with parentheses in the URL", () => {
const url = "https://en.wikipedia.org/wiki/URL_(disambiguation)";
expect(extractUrls(`[Wikipedia](${url})`)).toEqual([url]);
});
it("handles bare URLs with trailing closing paren as punctuation", () => {
const urls = extractUrls("(see https://example.com/path)");
expect(urls).toEqual(["https://example.com/path"]);
});
it("handles markdown link with angle brackets and parenthetical URL", () => {
const url = "https://en.wikipedia.org/wiki/Special_(film)";
expect(extractUrls(`[link](<${url}>)`)).toEqual([url]);
});
});
describe("addOsc8Hyperlinks", () => {
@@ -126,6 +141,13 @@ describe("addOsc8Hyperlinks", () => {
expect(result[0]).toContain(`\x1b]8;;${long}\x07${fragment}\x1b]8;;\x07`);
});
it("wraps URLs with parentheses in markdown rendered text", () => {
const url = "https://en.wikipedia.org/wiki/URL_(disambiguation)";
const line = `Wikipedia (${url})`;
const result = addOsc8Hyperlinks([line], [url]);
expect(result[0]).toBe(`Wikipedia (\x1b]8;;${url}\x07${url}\x1b]8;;\x07)`);
});
it("handles URL split across three lines", () => {
const fullUrl = "https://example.com/a/very/long/path/that/keeps/going/and/going";
const lines = ["https://example.com/a/very/lon", "g/path/that/keeps/going/and/g", "oing"];

View File

@@ -6,6 +6,37 @@ const ANSI_RE = new RegExp(`${SGR_PATTERN}|${OSC8_PATTERN}`, "g");
const SGR_START_RE = new RegExp(`^${SGR_PATTERN}`);
const OSC8_START_RE = new RegExp(`^${OSC8_PATTERN}`);
/** Allow one level of balanced parentheses inside a URL so markdown link
* targets like `https://en.wikipedia.org/wiki/URL_(disambiguation)` are
* fully captured instead of truncated at the first `)`. */
const URL_PATH_WITH_PARENS = /https?:\/\/[^()\s<>]*(?:\([^()\s<>]*\)[^()\s<>]*)*/g;
/** Strip trailing `)` characters that don't have a matching `(` in the URL.
* Bare URLs in prose can pick up a trailing `)` that belongs to surrounding
* punctuation, e.g. `(see https://example.com/path)` — the `)` after `path`
* is sentence punctuation, not part of the URL. */
function trimUnbalancedTrailingParens(url: string): string {
let open = 0;
for (const ch of url) {
if (ch === "(") {
open++;
} else if (ch === ")") {
open--;
}
}
// If close parens outnumber open parens, the trailing ones are not
// part of the URL — strip them.
if (open >= 0) {
return url;
}
let trimmed = url;
while (trimmed.endsWith(")") && open < 0) {
trimmed = trimmed.slice(0, -1);
open++;
}
return trimmed;
}
/**
* Extract all unique URLs from raw markdown text.
* Finds both bare URLs and markdown link hrefs [text](url).
@@ -14,20 +45,20 @@ export function extractUrls(markdown: string): string[] {
const urls = new Set<string>();
// Markdown link hrefs: [text](url), with optional <...> and optional title.
const mdLinkRe = /\[(?:[^\]]*)\]\(\s*<?(https?:\/\/[^)\s>]+)>?(?:\s+["'][^"']*["'])?\s*\)/g;
const mdLinkRe = new RegExp(
`\\[(?:[^\\]]*)\\]\\(\\s*<?(${URL_PATH_WITH_PARENS.source})>?(?:\\s+["'][^"']*["'])?\\s*\\)`,
"g",
);
let m: RegExpExecArray | null;
while ((m = mdLinkRe.exec(markdown)) !== null) {
urls.add(m[1]);
}
// Bare URLs (remove markdown links first to avoid double-matching)
const stripped = markdown.replace(
/\[(?:[^\]]*)\]\(\s*<?https?:\/\/[^)\s>]+>?(?:\s+["'][^"']*["'])?\s*\)/g,
"",
);
const bareRe = /https?:\/\/[^\s)\]>]+/g;
const stripped = markdown.replace(mdLinkRe, "");
const bareRe = /https?:\/\/[^\s\]>]+/g;
while ((m = bareRe.exec(stripped)) !== null) {
urls.add(m[0]);
urls.add(trimUnbalancedTrailingParens(m[0]));
}
return [...urls];
@@ -86,12 +117,12 @@ function findUrlRanges(
}
// Find new URL starts in visible text
const urlRe = /https?:\/\/[^\s)\]>]+/g;
const urlRe = /https?:\/\/[^\s\]>]+/g;
urlRe.lastIndex = searchFrom;
let match: RegExpExecArray | null;
while ((match = urlRe.exec(visibleText)) !== null) {
const fragment = match[0];
const fragment = trimUnbalancedTrailingParens(match[0]);
const start = match.index;
// Resolve fragment to a known URL (exact > prefix > superstring)