Skip to content

Commit d9bc16d

Browse files
committed
fix(webview): mask reference link definitions before mention rewriting
Address CodeRabbit finding on #1257: - Add definition to MENTION_MASK_NODE_TYPES so a reference link destination (e.g. [docs]: @/docs/readme.md) is masked before prepareMentions() runs. Without it, the destination was rewritten to a mention placeholder and that placeholder became the reference link's href, corrupting the href with control characters instead of rendering a mention span. - Regression test: the reference link keeps its original href, the destination never becomes a mention span, a real mention in the body stays actionable, and no placeholder control characters leak into the output.
1 parent 630309c commit d9bc16d

2 files changed

Lines changed: 48 additions & 9 deletions

File tree

webview-ui/src/components/common/MarkdownBlock.tsx

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,20 @@ const MENTION_PLACEHOLDER_CHAR = "\u0001"
2626
const MENTION_PLACEHOLDER_REGEX = new RegExp(`${MENTION_PLACEHOLDER_CHAR}(\\d+)${MENTION_PLACEHOLDER_CHAR}`, "g")
2727

2828
// mdast node types whose raw source regions must never be mention-rewritten:
29-
// code blocks (fenced or indented), inline code, links, images, raw HTML, and
30-
// math all render as literal or non-text content.
31-
const MENTION_MASK_NODE_TYPES = new Set(["code", "inlineCode", "link", "image", "html", "inlineMath", "math"])
29+
// code blocks (fenced or indented), inline code, links, images, raw HTML, math,
30+
// and reference link definitions all render as literal or non-text content
31+
// (rewriting a definition's destination would corrupt the reference link's
32+
// href instead of producing a mention span).
33+
const MENTION_MASK_NODE_TYPES = new Set([
34+
"code",
35+
"inlineCode",
36+
"link",
37+
"image",
38+
"html",
39+
"inlineMath",
40+
"math",
41+
"definition",
42+
])
3243

3344
/**
3445
* Rewrite mention patterns in the RAW markdown string before remark tokenizes
@@ -44,10 +55,10 @@ const MENTION_MASK_NODE_TYPES = new Set(["code", "inlineCode", "link", "image",
4455
* Matching runs on the raw string so the shared regex's boundary rules apply
4556
* unchanged (replacing literal regions with spaces would turn a preceding `)`
4657
* or backtick into whitespace and make non-mentions actionable). Literal / non-
47-
* text regions (code, links, images, HTML, math) are marked via a throwaway
48-
* mdast parse with the exact positions remark sees, and a match whose range
49-
* intersects one of them is discarded so mentions inside such regions stay
50-
* inert.
58+
* text regions (code, links, images, HTML, math, reference link definitions)
59+
* are marked via a throwaway mdast parse with the exact positions remark sees,
60+
* and a match whose range intersects one of them is discarded so mentions
61+
* inside such regions stay inert.
5162
*/
5263
function prepareMentions(markdown: string): { preparedMarkdown: string; mentions: string[] } {
5364
if (!markdown) {
@@ -58,8 +69,9 @@ function prepareMentions(markdown: string): { preparedMarkdown: string; mentions
5869
// reported positions match what remark will tokenize. Mark literal and
5970
// non-text regions (mdast positions carry absolute source offsets): a
6071
// mention inside any of them must stay inert, because code and links render
61-
// as literal/interactive content, and images, raw HTML, and math keep their
62-
// source text unchanged.
72+
// as literal/interactive content, images, raw HTML, and math keep their
73+
// source text unchanged, and a reference link definition's destination
74+
// becomes the link's href (rewriting it would corrupt the href).
6375
const tree = unified().use(remarkParse).use(remarkGfm).use(remarkMath).parse(markdown)
6476

6577
const isMasked = new Array<boolean>(markdown.length).fill(false)

webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,33 @@ describe("MarkdownBlock", () => {
358358
expect(mockPostMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "openMention" }))
359359
})
360360

361+
it("keeps reference link destinations inert and preserves the original href", async () => {
362+
// A reference definition's destination renders as the reference link's
363+
// href, so rewriting it to a mention placeholder would corrupt the href
364+
// (control characters instead of the original path) rather than produce
365+
// a mention span. The whole definition region must stay masked.
366+
const markdown = "[docs]: @/docs/readme.md\n\nSee [docs] and @problems."
367+
const { container } = render(<MarkdownBlock markdown={markdown} mentions />)
368+
369+
await screen.findByText(/See/, { exact: false })
370+
371+
// The reference link keeps its original href, untouched by mention
372+
// preprocessing, and the destination never becomes a mention span.
373+
const anchor = container.querySelector("a")!
374+
expect(anchor).toHaveAttribute("href", "@/docs/readme.md")
375+
expect(anchor.textContent).toBe("docs")
376+
expect(container.querySelectorAll("a span.mention-context-highlight").length).toBe(0)
377+
378+
// Masking the definition must not affect a real mention in the body.
379+
const mentions = container.querySelectorAll("span.mention-context-highlight")
380+
expect(mentions.length).toBe(1)
381+
expect(mentions[0].textContent).toBe("@problems")
382+
383+
// No placeholder control characters leak into the rendered output.
384+
expect(container.textContent).not.toContain("\u0001")
385+
expect(anchor.getAttribute("href")).not.toContain("\u0001")
386+
})
387+
361388
it("keeps the shared regex boundary rules when a mention directly follows a link or inline code", async () => {
362389
// Matching must run on the raw string so the shared regex's start boundary
363390
// sees the real characters: with no whitespace after a closing `)` or a

0 commit comments

Comments
 (0)