From d70d933492ad7926a0cd4cac3e2cd88d4d219882 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 7 Oct 2026 00:07:45 +0000 Subject: [PATCH 1/4] fix(web): preserve bare anchor placeholders in markdown --- apps/web/src/components/ChatMarkdown.test.tsx | 41 +++++++++++++++++++ apps/web/src/components/ChatMarkdown.tsx | 24 +++++++++++ 2 files changed, 65 insertions(+) diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 5585dc3ddf40..885c14546989 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -1,3 +1,5 @@ +// @vitest-environment jsdom + import { EnvironmentId } from "@t3tools/contracts"; import { act, type ComponentProps, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; @@ -73,6 +75,45 @@ function codeButton(renderer: ReactTestRenderer, label: string) { return button.props as ComponentProps; } +describe("ChatMarkdown bare anchor placeholders", () => { + it.each(["", "", ""])( + "preserves unmatched %s without linking later blocks", + (token) => { + const text = `- **"From ${token}"** appears in the header.\n\n- **Tests:** cover inheritance.\n\nThe deferred move continues on B.\n\nSee the link.`; + const document = new DOMParser().parseFromString( + renderToStaticMarkup(), + "text/html", + ); + + expect(document.querySelector("strong")?.textContent).toBe(`"From ${token}"`); + expect([...document.querySelectorAll("a")].map((link) => link.textContent)).toEqual([ + "the link", + ]); + expect(document.querySelectorAll("li")).toHaveLength(2); + expect( + [...document.querySelectorAll("p")].map((paragraph) => paragraph.textContent), + ).toContain("The deferred move continues on B."); + }, + ); + + it("preserves paired HTML anchors, details, markdown links, and inline code", () => { + const text = + 'Bare label, , ``, and [docs](https://example.com).\n\n
MoreDetails
'; + const document = new DOMParser().parseFromString( + renderToStaticMarkup(), + "text/html", + ); + + expect([...document.querySelectorAll("a")].map((link) => link.textContent)).toEqual([ + "label", + "", + "docs", + ]); + expect(document.querySelector("code")?.textContent).toBe("
"); + expect(document.querySelector("[data-markdown-details]")?.textContent).toContain("More"); + }); +}); + describe("ChatMarkdown context references", () => { it("renders text and image references through the chip renderer, with readable fallback", async () => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index a56db444d200..edd5c8b164cb 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -394,6 +394,7 @@ function orderedListGutterStyle( type MarkdownImageHastNode = { type?: string; + value?: string; tagName?: string; properties?: Record; children?: MarkdownImageHastNode[]; @@ -448,6 +449,28 @@ function markStandaloneImages(node: MarkdownImageHastNode) { }); } +/** Keep unmatched inline `` placeholders from opening an HTML link over later blocks. */ +function rehypePreserveBareAnchorPlaceholders() { + return (tree: MarkdownImageHastNode) => { + const anchors: Array = []; + const visit = (node: MarkdownImageHastNode) => { + if (node.type === "raw" && typeof node.value === "string") { + if (/^)/i.test(node.value)) { + anchors.push(/^$/i.test(node.value) ? node : null); + } else if (/^<\/a\s*>$/i.test(node.value)) { + anchors.pop(); + } + } + node.children?.forEach(visit); + }; + + visit(tree); + for (const anchor of anchors) { + if (anchor) anchor.type = "text"; + } + }; +} + /** Carries authored image source metadata through the sanitizer to the image renderer. */ function rehypePreserveImageSourceMeta() { return (tree: MarkdownImageHastNode) => { @@ -514,6 +537,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ ] satisfies NonNullable; const CHAT_MARKDOWN_REHYPE_PLUGINS = [ + rehypePreserveBareAnchorPlaceholders, rehypeRaw, rehypePreserveImageSourceMeta, [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], From 3b5ec4969673282b04597bc63ba9528d84c3501c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 7 Oct 2026 00:18:13 +0000 Subject: [PATCH 2/4] fix(web): preserve anchor pairs across raw html blocks --- apps/web/src/components/ChatMarkdown.test.tsx | 27 +++++++++++++++++++ apps/web/src/components/ChatMarkdown.tsx | 27 ++++++++++++++++--- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 885c14546989..6f3da6e3a9fe 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -96,6 +96,33 @@ describe("ChatMarkdown bare anchor placeholders", () => { }, ); + it.each([" ", "
more
\n"])( + "preserves a paired anchor closing in the raw block %s", + (closing) => { + const document = new DOMParser().parseFromString( + renderToStaticMarkup( + label\n\n${closing}\n\nfinish`} />, + ), + "text/html", + ); + expect(document.querySelector("p")?.textContent).toBe("See label"); + }, + ); + + it.each(["", '
more
', ''])( + "ignores apparent closing anchors inside %s", + (html) => { + const document = new DOMParser().parseFromString( + renderToStaticMarkup( + .\n\n${html}\n\nAfter.`} />, + ), + "text/html", + ); + expect(document.querySelector("p")?.textContent).toBe("Before ."); + expect(document.querySelectorAll("a")).toHaveLength(0); + }, + ); + it("preserves paired HTML anchors, details, markdown links, and inline code", () => { const text = 'Bare label, , ``, and [docs](https://example.com).\n\n
MoreDetails
'; diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index edd5c8b164cb..7faa38019bef 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -453,12 +453,31 @@ function markStandaloneImages(node: MarkdownImageHastNode) { function rehypePreserveBareAnchorPlaceholders() { return (tree: MarkdownImageHastNode) => { const anchors: Array = []; + let rawTextTag: string | undefined; const visit = (node: MarkdownImageHastNode) => { if (node.type === "raw" && typeof node.value === "string") { - if (/^)/i.test(node.value)) { - anchors.push(/^$/i.test(node.value) ? node : null); - } else if (/^<\/a\s*>$/i.test(node.value)) { - anchors.pop(); + // Raw blocks can contain several tags. Consume whole tags, quoted attributes, + // and comments so text resembling a closing anchor cannot pair a placeholder. + for (const [tag] of node.value.matchAll( + /|$)|<\/?[A-Za-z](?:[^"'<>]|"[^"]*"|'[^']*')*>/g, + )) { + if (tag.startsWith("
" />, + ), + "text/html", + ); + expect(document.querySelector("p")?.textContent).toBe("See label -->"); + }); + it.each(["", '
more
', ''])( "ignores apparent closing anchors inside %s", (html) => { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 7eca42003145..5389cae83d67 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -458,15 +458,22 @@ function rehypePreserveBareAnchorPlaceholders() { if (node.type === "raw" && typeof node.value === "string") { // Raw blocks can contain several tags. Consume whole tags, quoted attributes, // and comments so text resembling a closing anchor cannot pair a placeholder. - for (const [tag] of node.value.matchAll( - /|$)|<\/?[A-Za-z](?:[^"'<>]|"[^"]*"|'[^']*')*>/g, - )) { - if (tag.startsWith("|$)|<\/?[A-Za-z](?:[^"'<>]|"[^"]*"|'[^']*')*>/g; + let offset = 0; + while (rawTextTag !== "plaintext") { + // Raw text ends at its closing tag even inside comment-looking text. + const matcher = rawTextTag ? new RegExp(``, "gi") : tags; + matcher.lastIndex = offset; + const match = matcher.exec(node.value); + if (!match) break; + const [tag] = match; + offset = matcher.lastIndex; if (rawTextTag) { - if (rawTextTag !== "plaintext" && closing === rawTextTag) rawTextTag = undefined; + rawTextTag = undefined; continue; } + if (tag.startsWith("