-
Notifications
You must be signed in to change notification settings - Fork 3.7k
fix(web): recognize markdown file links with angle brackets and parentheses #5222
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -108,6 +108,192 @@ export function rewriteMarkdownFileUriHref(href: string | undefined): string | n | |
| return `${target.path}${target.hash}`; | ||
| } | ||
|
|
||
| /** | ||
| * Find the `]` that closes the link label starting at `labelStart` (`[`). | ||
| * | ||
| * Tracks bracket depth and honors backslash escapes so escaped brackets (`\]`, | ||
| * `\[`) and balanced nested runs (`[foo [bar]]`) are handled the way CommonMark | ||
| * does, instead of stopping at the first `]`. Returns -1 when no matching close | ||
| * bracket exists. | ||
| */ | ||
| function findMarkdownLinkLabelEnd(text: string, labelStart: number): number { | ||
| let depth = 0; | ||
| for (let index = labelStart; index < text.length; index += 1) { | ||
| const char = text[index]; | ||
| if (char === "\\") { | ||
| index += 1; | ||
| continue; | ||
| } | ||
| if (char === "[") { | ||
| depth += 1; | ||
| continue; | ||
| } | ||
| if (char === "]") { | ||
| depth -= 1; | ||
| if (depth === 0) return index; | ||
| } | ||
| } | ||
| return -1; | ||
| } | ||
|
|
||
| /** | ||
| * Advance past an inline link's optional title and its closing `)`, starting | ||
| * from the character after the destination. Returns the index just after the | ||
| * closing parenthesis so a `[label](url)` sequence embedded in a title is not | ||
| * mistaken for a real link on the next scan iteration. | ||
| */ | ||
| function skipMarkdownLinkTitleAndClose(text: string, start: number): number { | ||
| const length = text.length; | ||
| const isSpace = (char: string | undefined): boolean => | ||
| char === " " || char === "\t" || char === "\n" || char === "\r"; | ||
|
|
||
| let pos = start; | ||
| while (pos < length && isSpace(text[pos])) pos += 1; | ||
|
|
||
| const opener = text[pos]; | ||
| if (opener === '"' || opener === "'" || opener === "(") { | ||
| const closer = opener === "(" ? ")" : opener; | ||
| pos += 1; | ||
| while (pos < length) { | ||
| const char = text[pos]; | ||
| if (char === undefined) break; | ||
| if (char === "\\" && pos + 1 < length) { | ||
| pos += 2; | ||
| continue; | ||
| } | ||
| if (char === closer) { | ||
| pos += 1; | ||
| break; | ||
| } | ||
| pos += 1; | ||
| } | ||
| while (pos < length && isSpace(text[pos])) pos += 1; | ||
| } | ||
|
|
||
| if (text[pos] === ")") pos += 1; | ||
| return pos; | ||
| } | ||
|
|
||
| /** | ||
| * Extract the destination of every markdown inline link found in `text`. | ||
| * | ||
| * This mirrors the CommonMark link grammar closely enough for file link | ||
| * classification: it resolves the label end across escaped/nested brackets, and | ||
| * understands both the angle-bracket destination form (`[label](<dest with | ||
| * spaces>)`) and the bare form, where the destination may contain balanced | ||
| * parentheses (`[label](/tmp/a(1).txt)`). It also skips the optional title so a | ||
| * `[..](..)` sequence inside a title is not extracted. The previous single-regex | ||
| * implementation captured none of this, so links whose paths held spaces or | ||
| * parentheses were never recognized as file links. | ||
| */ | ||
| export function extractMarkdownLinkHrefs(text: string): string[] { | ||
|
macroscopeapp[bot] marked this conversation as resolved.
|
||
| const hrefs: string[] = []; | ||
| const length = text.length; | ||
| let index = 0; | ||
|
|
||
| while (index < length) { | ||
| const labelStart = text.indexOf("[", index); | ||
| if (labelStart === -1) break; | ||
|
|
||
| const labelEnd = findMarkdownLinkLabelEnd(text, labelStart); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unclosed label stops link scanMedium Severity When Reviewed by Cursor Bugbot for commit c9550c9. Configure here. |
||
| if (labelEnd === -1) break; | ||
|
|
||
| if (text[labelEnd + 1] !== "(") { | ||
| index = labelEnd + 1; | ||
| continue; | ||
| } | ||
|
|
||
| let pos = labelEnd + 2; | ||
| while (pos < length && (text[pos] === " " || text[pos] === "\t")) { | ||
| pos += 1; | ||
| } | ||
|
|
||
| let destination = ""; | ||
|
|
||
| if (text[pos] === "<") { | ||
| // Angle-bracket destination: may contain spaces, ends at an unescaped ">". | ||
| pos += 1; | ||
| let closed = false; | ||
| while (pos < length) { | ||
| const char = text[pos]; | ||
| if (char === undefined) break; | ||
| if (char === "\\" && pos + 1 < length) { | ||
| destination += text[pos + 1]; | ||
| pos += 2; | ||
| continue; | ||
| } | ||
| if (char === "\n") break; | ||
| if (char === ">") { | ||
| closed = true; | ||
| pos += 1; | ||
| break; | ||
| } | ||
| destination += char; | ||
| pos += 1; | ||
| } | ||
| if (!closed) { | ||
| index = labelEnd + 2; | ||
| continue; | ||
| } | ||
| } else { | ||
| // Bare destination: balanced parentheses are part of the destination; it | ||
| // ends at whitespace, a control character, or an unbalanced ")". | ||
| let depth = 0; | ||
| while (pos < length) { | ||
| const char = text[pos]; | ||
| if (char === undefined) break; | ||
| if (char === "\\" && pos + 1 < length) { | ||
| destination += text[pos + 1]; | ||
| pos += 2; | ||
| continue; | ||
| } | ||
| if (char === " " || char === "\t" || char === "\n" || char.charCodeAt(0) < 0x20) { | ||
| break; | ||
| } | ||
| if (char === "(") { | ||
| depth += 1; | ||
| destination += char; | ||
| pos += 1; | ||
| continue; | ||
| } | ||
| if (char === ")") { | ||
| if (depth === 0) break; | ||
| depth -= 1; | ||
| destination += char; | ||
| pos += 1; | ||
| continue; | ||
| } | ||
| destination += char; | ||
| pos += 1; | ||
| } | ||
| } | ||
|
|
||
| const href = destination.trim(); | ||
| if (href.length > 0) hrefs.push(href); | ||
| index = Math.max(skipMarkdownLinkTitleAndClose(text, pos), labelEnd + 2); | ||
| } | ||
|
|
||
| return hrefs; | ||
| } | ||
|
|
||
| /** | ||
| * Canonical lookup key for a markdown link destination. | ||
| * | ||
| * react-markdown percent-encodes some destination characters when it renders a | ||
| * link (a literal space becomes `%20`), while the raw text the link was authored | ||
| * from may contain the unencoded character. Decoding both the rendered href and | ||
| * the destination scanned out of the source text lets the file-link classifier | ||
| * match the two. `file://` destinations are decoded the same way after rewriting | ||
| * so the pre-scan key (built from the raw `file://` href) agrees with the | ||
| * render-time key (built from the already-rewritten path `markdownUrlTransform` | ||
| * hands to the anchor). This key is only for map lookup; the file path itself is | ||
| * resolved separately from the original href to avoid double-decoding. | ||
| */ | ||
| export function normalizeMarkdownLinkHrefKey(href: string): string { | ||
| const normalizedHref = normalizeMarkdownLinkDestination(href); | ||
| return safeDecode(rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref); | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| function looksLikePosixFilesystemPath(path: string): boolean { | ||
| if (!path.startsWith("/")) return false; | ||
| if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true; | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Parenthesized title breaks on first paren
Low Severity
skipMarkdownLinkTitleAndClosetreats a parenthesized link title by scanning until the first), without balancing nested parentheses. Destinations or nested[text](url)inside a(title)stop the scan early, so the scanner can treat title content as later links or advance the index incorrectly compared to quoted-title handling.Reviewed by Cursor Bugbot for commit c9550c9. Configure here.