authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-06 22:32:40-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 22:32:40-03:00
log5886bd8f19c8d4b01d7a0b36ad62a9d35f02552d
treeaf6ca5e6a41eaaaf6d94fca72ab31225d998f2b5
parent847c53df0126a225fb1a64d4bda9acfffa2b68ed
signature Signed by PGP key B5690EEEBB952194

fix(web): preserve bare anchor placeholders in markdown (#16637)


2 files changed, 128 insertions(+), 0 deletions(-)

apps/web/src/components/ChatMarkdown.test.tsx+78
......@@ -1,3 +1,5 @@
1// @vitest-environment jsdom
2
13import { EnvironmentId } from "@t3tools/contracts";
24import { act, type ComponentProps, type ReactNode } from "react";
35import { renderToStaticMarkup } from "react-dom/server";
......@@ -73,6 +75,82 @@ function codeButton(renderer: ReactTestRenderer, label: string) {
7375 return button.props as ComponentProps<typeof Button>;
7476}
7577
78describe("ChatMarkdown bare anchor placeholders", () => {
79 it.each(["<A>", "<a>", "<a >", "<a/>", "<A/>", "<a />"])(
80 "preserves unmatched %s without linking later blocks",
81 (token) => {
82 const text = `- **"From ${token}"** appears in the header.\n\n- **Tests:** cover inheritance.\n\nThe deferred move continues on B.\n\nSee <a href="https://example.com">the link</a>.`;
83 const document = new DOMParser().parseFromString(
84 renderToStaticMarkup(<ChatMarkdown cwd="/tmp/project" text={text} />),
85 "text/html",
86 );
87
88 expect(document.querySelector("strong")?.textContent).toBe(`"From ${token}"`);
89 expect([...document.querySelectorAll("a")].map((link) => link.textContent)).toEqual([
90 "the link",
91 ]);
92 expect(document.querySelectorAll("li")).toHaveLength(2);
93 expect(
94 [...document.querySelectorAll("p")].map((paragraph) => paragraph.textContent),
95 ).toContain("The deferred move continues on B.");
96 },
97 );
98
99 it.each(["</a> ", "<div>more</div>\n</a>"])(
100 "preserves a paired anchor closing in the raw block %s",
101 (closing) => {
102 const document = new DOMParser().parseFromString(
103 renderToStaticMarkup(
104 <ChatMarkdown cwd="/tmp/project" text={`See <a>label\n\n${closing}\n\nfinish`} />,
105 ),
106 "text/html",
107 );
108 expect(document.querySelector("p")?.textContent).toBe("See label");
109 },
110 );
111
112 it("preserves a paired anchor after comment-looking raw text", () => {
113 const document = new DOMParser().parseFromString(
114 renderToStaticMarkup(
115 <ChatMarkdown cwd="/tmp/project" text="See <a>label<script><!-- </script> --></a>" />,
116 ),
117 "text/html",
118 );
119 expect(document.querySelector("p")?.textContent).toBe("See label -->");
120 });
121
122 it.each(["<!-- </a> -->", '<div title="</a>">more</div>', '<script>"</a>"</script>'])(
123 "ignores apparent closing anchors inside %s",
124 (html) => {
125 const document = new DOMParser().parseFromString(
126 renderToStaticMarkup(
127 <ChatMarkdown cwd="/tmp/project" text={`Before <A>.\n\n${html}\n\nAfter.`} />,
128 ),
129 "text/html",
130 );
131 expect(document.querySelector("p")?.textContent).toBe("Before <A>.");
132 expect(document.querySelectorAll("a")).toHaveLength(0);
133 },
134 );
135
136 it("preserves paired HTML anchors, details, markdown links, and inline code", () => {
137 const text =
138 'Bare <a>label</a>, <a id="section"></a>, `<A>`, and [docs](https://example.com).\n\n<details><summary>More</summary>Details</details>';
139 const document = new DOMParser().parseFromString(
140 renderToStaticMarkup(<ChatMarkdown cwd="/tmp/project" text={text} />),
141 "text/html",
142 );
143
144 expect([...document.querySelectorAll("a")].map((link) => link.textContent)).toEqual([
145 "label",
146 "",
147 "docs",
148 ]);
149 expect(document.querySelector("code")?.textContent).toBe("<A>");
150 expect(document.querySelector("[data-markdown-details]")?.textContent).toContain("More");
151 });
152});
153
76154describe("ChatMarkdown context references", () => {
77155 it("renders text and image references through the chip renderer, with readable fallback", async () => {
78156 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
apps/web/src/components/ChatMarkdown.tsx+50
......@@ -394,6 +394,7 @@ function orderedListGutterStyle(
394394
395395type MarkdownImageHastNode = {
396396 type?: string;
397 value?: string;
397398 tagName?: string;
398399 properties?: Record<string, unknown>;
399400 children?: MarkdownImageHastNode[];
......@@ -448,6 +449,54 @@ function markStandaloneImages(node: MarkdownImageHastNode) {
448449 });
449450}
450451
452/** Keep unmatched inline `<A>` placeholders from opening an HTML link over later blocks. */
453function rehypePreserveBareAnchorPlaceholders() {
454 return (tree: MarkdownImageHastNode) => {
455 const anchors: Array<MarkdownImageHastNode | null> = [];
456 let rawTextTag: string | undefined;
457 const visit = (node: MarkdownImageHastNode) => {
458 if (node.type === "raw" && typeof node.value === "string") {
459 // Raw blocks can contain several tags. Consume whole tags, quoted attributes,
460 // and comments so text resembling a closing anchor cannot pair a placeholder.
461 const tags = /<!--[\s\S]*?(?:-->|$)|<\/?[A-Za-z](?:[^"'<>]|"[^"]*"|'[^']*')*>/g;
462 let offset = 0;
463 while (rawTextTag !== "plaintext") {
464 // Raw text ends at its closing tag even inside comment-looking text.
465 const matcher = rawTextTag ? new RegExp(`</${rawTextTag}\\s*>`, "gi") : tags;
466 matcher.lastIndex = offset;
467 const match = matcher.exec(node.value);
468 if (!match) break;
469 const [tag] = match;
470 offset = matcher.lastIndex;
471 if (rawTextTag) {
472 rawTextTag = undefined;
473 continue;
474 }
475 if (tag.startsWith("<!--")) continue;
476 const closing = /^<\/([a-z]+)\s*>$/i.exec(tag)?.[1]?.toLowerCase();
477 const opening = /^<([a-z]+)(?:\s|\/?>)/i.exec(tag)?.[1]?.toLowerCase();
478 if (
479 opening &&
480 /^(?:script|style|textarea|title|xmp|iframe|noembed|noframes|plaintext)$/.test(opening)
481 ) {
482 rawTextTag = opening;
483 } else if (opening === "a") {
484 anchors.push(node.value === tag && /^<a\s*\/?>$/i.test(tag) ? node : null);
485 } else if (closing === "a") {
486 anchors.pop();
487 }
488 }
489 }
490 node.children?.forEach(visit);
491 };
492
493 visit(tree);
494 for (const anchor of anchors) {
495 if (anchor) anchor.type = "text";
496 }
497 };
498}
499
451500/** Carries authored image source metadata through the sanitizer to the image renderer. */
452501function rehypePreserveImageSourceMeta() {
453502 return (tree: MarkdownImageHastNode) => {
......@@ -514,6 +563,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [
514563] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>;
515564
516565const CHAT_MARKDOWN_REHYPE_PLUGINS = [
566 rehypePreserveBareAnchorPlaceholders,
517567 rehypeRaw,
518568 rehypePreserveImageSourceMeta,
519569 [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA],