Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 9 additions & 22 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ import {
} from "../markdown-clipboard";
import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation";
import {
normalizeMarkdownLinkDestination,
extractMarkdownLinkHrefs,
normalizeMarkdownLinkHrefKey,
resolveInlineCodeFileLinkMeta,
resolveMarkdownFileLinkMeta,
rewriteMarkdownFileUriHref,
Expand Down Expand Up @@ -747,7 +748,6 @@ interface MarkdownFileLinkProps {
className?: string | undefined;
}

const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g;
const MARKDOWN_FILE_LINK_CLASS_NAME =
"chat-markdown-file-link cursor-pointer transition-colors hover:bg-accent/70";

Expand Down Expand Up @@ -826,21 +826,6 @@ function extractInlineCodeSpans(text: string): string[] {
return spans;
}

function extractMarkdownLinkHrefs(text: string): string[] {
const hrefs: string[] = [];
for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) {
const href = match[1]?.trim();
if (!href) continue;
hrefs.push(href);
}
return hrefs;
}

function normalizeMarkdownLinkHrefKey(href: string): string {
const normalizedHref = normalizeMarkdownLinkDestination(href);
return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref;
}

const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none";

/** Hosts whose favicon request already failed this session — skip straight to the globe. */
Expand Down Expand Up @@ -1282,11 +1267,13 @@ function ChatMarkdown({
NonNullable<ReturnType<typeof resolveMarkdownFileLinkMeta>>
>();
for (const href of extractMarkdownLinkHrefs(text)) {
const normalizedHref = normalizeMarkdownLinkHrefKey(href);
if (metaByHref.has(normalizedHref)) continue;
const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd);
const key = normalizeMarkdownLinkHrefKey(href);
if (metaByHref.has(key)) continue;
// Resolve the path from the original href (single decode); the decoded key
// is only used to match the render-time anchor href.
const meta = resolveMarkdownFileLinkMeta(href, cwd);
if (meta) {
metaByHref.set(normalizedHref, meta);
metaByHref.set(key, meta);
}
}
return metaByHref;
Expand Down Expand Up @@ -1522,7 +1509,7 @@ function ChatMarkdown({

return fileLinkChip(
fileLinkMeta,
`[${fileLinkMeta.basename}](${normalizedHref})`,
`[${fileLinkMeta.basename}](${href ?? normalizedHref})`,
props.className,
);
},
Expand Down
78 changes: 78 additions & 0 deletions apps/web/src/markdown-links.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vite-plus/test";

import {
extractMarkdownLinkHrefs,
normalizeMarkdownLinkHrefKey,
resolveInlineCodeFileLinkMeta,
resolveMarkdownFileLinkMeta,
resolveMarkdownFileLinkTarget,
Expand Down Expand Up @@ -35,6 +37,82 @@ describe("rewriteMarkdownFileUriHref", () => {
});
});

describe("extractMarkdownLinkHrefs", () => {
it("extracts bare destinations", () => {
expect(extractMarkdownLinkHrefs("[control](/tmp/link%20repro/manifest.tsv)")).toEqual([
"/tmp/link%20repro/manifest.tsv",
]);
});

it("extracts angle-bracket destinations that contain spaces", () => {
expect(extractMarkdownLinkHrefs("[angle](</tmp/link repro/manifest.tsv>)")).toEqual([
"/tmp/link repro/manifest.tsv",
]);
});

it("keeps balanced parentheses inside bare destinations", () => {
expect(extractMarkdownLinkHrefs("[parens](/tmp/a(1).txt)")).toEqual(["/tmp/a(1).txt"]);
});

it("ignores a trailing title after the destination", () => {
expect(extractMarkdownLinkHrefs('[t](/tmp/a.txt "title")')).toEqual(["/tmp/a.txt"]);
});

it("finds the label end across escaped brackets", () => {
expect(extractMarkdownLinkHrefs("[foo \\] bar](/tmp/a.txt)")).toEqual(["/tmp/a.txt"]);
});

it("finds the label end across balanced nested brackets", () => {
expect(extractMarkdownLinkHrefs("[foo [bar]](/tmp/a.txt)")).toEqual(["/tmp/a.txt"]);
});

it("does not extract link-looking sequences inside a title", () => {
expect(extractMarkdownLinkHrefs('[t](/tmp/a.txt "see [x](/tmp/should-not-match.txt)")')).toEqual(
["/tmp/a.txt"],
);
});

it("extracts every link in a multi-line message", () => {
const text = [
"[angle](</tmp/link repro/manifest.tsv>)",
"[parens](/tmp/a(1).txt)",
"[control](/tmp/link%20repro/manifest.tsv)",
].join("\n\n");
expect(extractMarkdownLinkHrefs(text)).toEqual([
"/tmp/link repro/manifest.tsv",
"/tmp/a(1).txt",
"/tmp/link%20repro/manifest.tsv",
]);
});
});

describe("normalizeMarkdownLinkHrefKey", () => {
it("matches a percent-encoded href to its unencoded source destination", () => {
// The angle-bracket source keeps a literal space; react-markdown renders it
// as %20. Both must normalize to the same key so the file link is detected.
expect(normalizeMarkdownLinkHrefKey("/tmp/link repro/manifest.tsv")).toBe(
normalizeMarkdownLinkHrefKey("/tmp/link%20repro/manifest.tsv"),
);
});

it("is stable for bare destinations with balanced parentheses", () => {
expect(normalizeMarkdownLinkHrefKey("/tmp/a(1).txt")).toBe("/tmp/a(1).txt");
});

it("matches a file URI key to its rewritten-path render form", () => {
// Pre-scan sees the raw file:// href; after markdownUrlTransform the anchor
// href is the rewritten path. Both must produce the same lookup key.
const fromFileUri = normalizeMarkdownLinkHrefKey(
"file:///Users/julius/project/file%2520name.md",
);
const fromRewrittenPath = normalizeMarkdownLinkHrefKey(
"/Users/julius/project/file%2520name.md",
);
expect(fromFileUri).toBe(fromRewrittenPath);
expect(fromFileUri).toBe("/Users/julius/project/file%20name.md");
});
});

describe("resolveMarkdownFileLinkTarget", () => {
it("resolves absolute posix file paths", () => {
expect(resolveMarkdownFileLinkTarget("/Users/julius/project/AGENTS.md")).toBe(
Expand Down
186 changes: 186 additions & 0 deletions apps/web/src/markdown-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown
Contributor

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

skipMarkdownLinkTitleAndClose treats 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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c9550c9. Configure here.

}

/**
* 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[] {
Comment thread
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unclosed label stops link scan

Medium Severity

When findMarkdownLinkLabelEnd can't find a balanced closing bracket for an opening [, extractMarkdownLinkHrefs stops scanning the entire text. This means any valid inline links appearing later in the message are missed, preventing them from being recognized as file links and causing them to render incorrectly, even if react-markdown parses them fine.

Fix in Cursor Fix in Web

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);
}
Comment thread
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;
Expand Down
Loading