Skip to content
Merged
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
78 changes: 78 additions & 0 deletions apps/web/src/components/ChatMarkdown.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -73,6 +75,82 @@ function codeButton(renderer: ReactTestRenderer, label: string) {
return button.props as ComponentProps<typeof Button>;
}

describe("ChatMarkdown bare anchor placeholders", () => {
it.each(["<A>", "<a>", "<a >", "<a/>", "<A/>", "<a />"])(
"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 <a href="https://example.com">the link</a>.`;
const document = new DOMParser().parseFromString(
renderToStaticMarkup(<ChatMarkdown cwd="/tmp/project" text={text} />),
"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.each(["</a> ", "<div>more</div>\n</a>"])(
"preserves a paired anchor closing in the raw block %s",
(closing) => {
const document = new DOMParser().parseFromString(
renderToStaticMarkup(
<ChatMarkdown cwd="/tmp/project" text={`See <a>label\n\n${closing}\n\nfinish`} />,
),
"text/html",
);
expect(document.querySelector("p")?.textContent).toBe("See label");
},
);

it("preserves a paired anchor after comment-looking raw text", () => {
const document = new DOMParser().parseFromString(
renderToStaticMarkup(
<ChatMarkdown cwd="/tmp/project" text="See <a>label<script><!-- </script> --></a>" />,
),
"text/html",
);
expect(document.querySelector("p")?.textContent).toBe("See label -->");
});

it.each(["<!-- </a> -->", '<div title="</a>">more</div>', '<script>"</a>"</script>'])(
"ignores apparent closing anchors inside %s",
(html) => {
const document = new DOMParser().parseFromString(
renderToStaticMarkup(
<ChatMarkdown cwd="/tmp/project" text={`Before <A>.\n\n${html}\n\nAfter.`} />,
),
"text/html",
);
expect(document.querySelector("p")?.textContent).toBe("Before <A>.");
expect(document.querySelectorAll("a")).toHaveLength(0);
},
);

it("preserves paired HTML anchors, details, markdown links, and inline code", () => {
const text =
'Bare <a>label</a>, <a id="section"></a>, `<A>`, and [docs](https://example.com).\n\n<details><summary>More</summary>Details</details>';
const document = new DOMParser().parseFromString(
renderToStaticMarkup(<ChatMarkdown cwd="/tmp/project" text={text} />),
"text/html",
);

expect([...document.querySelectorAll("a")].map((link) => link.textContent)).toEqual([
"label",
"",
"docs",
]);
expect(document.querySelector("code")?.textContent).toBe("<A>");
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);
Expand Down
50 changes: 50 additions & 0 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ function orderedListGutterStyle(

type MarkdownImageHastNode = {
type?: string;
value?: string;
tagName?: string;
properties?: Record<string, unknown>;
children?: MarkdownImageHastNode[];
Expand Down Expand Up @@ -448,6 +449,54 @@ function markStandaloneImages(node: MarkdownImageHastNode) {
});
}

/** Keep unmatched inline `<A>` placeholders from opening an HTML link over later blocks. */
function rehypePreserveBareAnchorPlaceholders() {
return (tree: MarkdownImageHastNode) => {
const anchors: Array<MarkdownImageHastNode | null> = [];
let rawTextTag: string | undefined;
const visit = (node: MarkdownImageHastNode) => {
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.
const tags = /<!--[\s\S]*?(?:-->|$)|<\/?[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(`</${rawTextTag}\\s*>`, "gi") : tags;
matcher.lastIndex = offset;
const match = matcher.exec(node.value);
if (!match) break;
const [tag] = match;
offset = matcher.lastIndex;
if (rawTextTag) {
rawTextTag = undefined;
continue;
}
if (tag.startsWith("<!--")) continue;
const closing = /^<\/([a-z]+)\s*>$/i.exec(tag)?.[1]?.toLowerCase();
const opening = /^<([a-z]+)(?:\s|\/?>)/i.exec(tag)?.[1]?.toLowerCase();
if (
opening &&
/^(?:script|style|textarea|title|xmp|iframe|noembed|noframes|plaintext)$/.test(opening)
) {
rawTextTag = opening;
} else if (opening === "a") {
anchors.push(node.value === tag && /^<a\s*\/?>$/i.test(tag) ? node : null);
} else if (closing === "a") {
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) => {
Expand Down Expand Up @@ -514,6 +563,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [
] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>;

const CHAT_MARKDOWN_REHYPE_PLUGINS = [
rehypePreserveBareAnchorPlaceholders,
rehypeRaw,
rehypePreserveImageSourceMeta,
[rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA],
Expand Down
Loading