Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useMemo } from "react";
import { View } from "react-native";
import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless";
import { repairUnclosedAngleLinkDestinations } from "@t3tools/client-runtime/markdown-links";

import {
nativeMarkdownChunkSpacing,
Expand Down Expand Up @@ -52,9 +53,12 @@ export function SelectableMarkdownText({
marginBottom = 0,
}: SelectableMarkdownTextProps) {
const chunks = useMemo(() => {
// Same unclosed-angle repair as web ChatMarkdown: the native parser
// also leaves `[label](<path)` as raw text without the closing `>`.
const repaired = repairUnclosedAngleLinkDestinations(markdown);
const parsedDocument = nativeMarkdownWithAuthoredWindowsPaths(
parseMarkdownWithOptions(markdown, { gfm: true, html: true, math: false }),
markdown,
parseMarkdownWithOptions(repaired, { gfm: true, html: true, math: false }),
repaired,
);
const document = preserveSoftBreaks
? nativeMarkdownWithPreservedSoftBreaks(parsedDocument)
Expand Down
61 changes: 61 additions & 0 deletions apps/mobile/modules/t3-markdown-text/src/markdownLinks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { repairUnclosedAngleLinkDestinations } from "@t3tools/client-runtime/markdown-links";
import type { MarkdownNode } from "react-native-nitro-markdown/headless";
import { describe, expect, it } from "vite-plus/test";

import { resolveMarkdownLinkPresentation } from "./markdownLinks";
import { nativeMarkdownTextRuns } from "./nativeMarkdownText";

/**
* md4c turns well-formed `[file](<local/path/file.md>)` into a link node
* whose href is the destination between the angle brackets. The native
* parser never builds a node for the unclosed shape, so this mirrors what
* it yields once the source is repaired.
*/
function parsedLinkNodeFor(markdown: string): MarkdownNode {
const repaired = repairUnclosedAngleLinkDestinations(markdown);
const href = repaired.slice(repaired.indexOf("(<") + 2, repaired.lastIndexOf(">)"));
const label = repaired.slice(repaired.indexOf("[") + 1, repaired.indexOf("]"));
const link = { type: "link", href, children: [{ type: "text", content: label }] };
return { type: "document", children: [{ type: "paragraph", children: [link] }] } as MarkdownNode;
}

describe("unclosed angle-bracket file links", () => {
it.each([
["[file](<local/path/file.md)", "[file](<local/path/file.md>)"],
[
"[Receipts](</Users/dara/Downloads/Lime Ride Artifacts/Bike Receipts)",
"[Receipts](</Users/dara/Downloads/Lime Ride Artifacts/Bike Receipts>)",
],
["[Open](<C:/Users/shawn/project/src/main.ts)", "[Open](<C:/Users/shawn/project/src/main.ts>)"],
["- [file](<local/path/file.md)", "- [file](<local/path/file.md>)"],
])("repairs %s before parsing", (source, expected) => {
expect(repairUnclosedAngleLinkDestinations(source)).toBe(expected);
});

it.each([
"[file](<local/path/file.md>)",
"[site](<https://example.com/docs)",
"```\n[file](<local/path/file.md)\n```",
])("leaves %s alone", (source) => {
expect(repairUnclosedAngleLinkDestinations(source)).toBe(source);
});

it("renders the repaired destination as a file chip run", () => {
expect(resolveMarkdownLinkPresentation("local/path/file.md")).toMatchObject({
kind: "file",
label: "file.md",
});
expect(nativeMarkdownTextRuns(parsedLinkNodeFor("[file](<local/path/file.md)"))).toMatchObject([
{ text: "file.md", href: "local/path/file.md", fileIcon: "markdown" },
]);
});

it("leaves a non-file destination without a chip", () => {
expect(repairUnclosedAngleLinkDestinations("[site](<https://example.com/docs)")).toBe(
"[site](<https://example.com/docs)",
);
expect(resolveMarkdownLinkPresentation("<https://example.com/docs")).toMatchObject({
kind: "link",
});
});
});
88 changes: 88 additions & 0 deletions apps/web/src/components/ChatMarkdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -947,3 +947,91 @@ describe("ChatMarkdown Windows file links", () => {
expect(html).not.toContain("chat-markdown-file-link");
});
});

describe("ChatMarkdown unclosed angle-bracket file links", () => {
it.each([true, false])(
"renders the reporter's missing closer as a file chip with parseRawHtml=%s",
(parseRawHtml) => {
const html = renderToStaticMarkup(
<ChatMarkdown
cwd="/tmp/project"
text="[file](<local/path/file.md)"
lineBreaks={!parseRawHtml}
parseRawHtml={parseRawHtml}
/>,
);

expect(html).toContain("chat-markdown-file-link");
expect(html).toContain("file.md");
expect(html).not.toContain("&lt;local/path/file.md");
},
);

it.each([true, false])(
"renders unclosed destinations with spaces and Windows paths as file chips with parseRawHtml=%s",
(parseRawHtml) => {
const spaced = renderToStaticMarkup(
<ChatMarkdown
cwd="/tmp/project"
text="[Receipts](</Users/dara/Downloads/Lime Ride Artifacts/Bike Receipts)"
lineBreaks={!parseRawHtml}
parseRawHtml={parseRawHtml}
/>,
);
const windows = renderToStaticMarkup(
<ChatMarkdown
cwd="C:/Users/shawn/project"
text="[Open](<C:/Users/shawn/project/src/main.ts)"
lineBreaks={!parseRawHtml}
parseRawHtml={parseRawHtml}
/>,
);
const listed = renderToStaticMarkup(
<ChatMarkdown
cwd="/tmp/project"
text="- [file](<local/path/file.md)"
lineBreaks={!parseRawHtml}
parseRawHtml={parseRawHtml}
/>,
);

for (const html of [spaced, windows, listed]) {
expect(html).toContain("chat-markdown-file-link");
}
expect(spaced).toContain("Bike Receipts");
expect(windows).toContain("main.ts");
expect(listed).toContain("<li>");
},
);

it.each([true, false])(
"leaves other unclosed destinations as text with parseRawHtml=%s",
(parseRawHtml) => {
for (const text of [
"[site](<https://example.com/docs)",
"[note](<hello world)",
"[settings](</chat/settings)",
]) {
const html = renderToStaticMarkup(
<ChatMarkdown
cwd="/tmp/project"
text={text}
lineBreaks={!parseRawHtml}
parseRawHtml={parseRawHtml}
/>,
);

expect(html).not.toContain("chat-markdown-file-link");
}
},
);

it("leaves the unclosed shape inside code spans literal", () => {
const html = renderToStaticMarkup(
<ChatMarkdown cwd="/tmp/project" text="`[file](</tmp/project/src/main.ts)`" />,
);

expect(html).not.toContain("chat-markdown-file-link");
expect(html).toContain("&lt;/tmp/project/src/main.ts");
});
});
15 changes: 11 additions & 4 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ import {
classifyMarkdownImageSource,
markdownImageSourceFragment,
} from "@t3tools/client-runtime/markdown-images";
import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-links";
import {
inlineCodeFilePathCandidate,
repairUnclosedAngleLinkDestinations,
} from "@t3tools/client-runtime/markdown-links";
import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference";
import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
import * as Cause from "effect/Cause";
Expand Down Expand Up @@ -3357,18 +3360,22 @@ function ChatMarkdown({
extraRemarkPlugins = EMPTY_REMARK_PLUGINS,
...props
}: ChatMarkdownProps) {
// Codex can emit angle-bracket destinations without the closing `>`,
// which CommonMark leaves as raw text. Repairing up front keeps the
// parsed tree, the link pre-scan, and source offsets on the same string.
const repairedText = useMemo(() => repairUnclosedAngleLinkDestinations(text), [text]);
const {
componentState,
handleCopy,
markdownRef,
markdownUrlTransform,
localMediaPreview,
setLocalMediaPreview,
} = useChatMarkdownState({ text, ...props });
} = useChatMarkdownState({ text: repairedText, ...props });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
const incrementalParsing =
props.isStreaming === true &&
extraRemarkPlugins.length === 0 &&
/(?:^|\n) {0,3}(?:`{3}|~{3})/.test(text);
/(?:^|\n) {0,3}(?:`{3}|~{3})/.test(repairedText);
const remarkPlugins = useMemo(
() => [
...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS),
Expand Down Expand Up @@ -3400,7 +3407,7 @@ function ChatMarkdown({
components={CHAT_MARKDOWN_COMPONENTS}
urlTransform={markdownUrlTransform}
>
{text}
{repairedText}
</ReactMarkdown>
</ChatMarkdownRendererContext>
{localMediaPreview ? (
Expand Down
39 changes: 39 additions & 0 deletions packages/client-runtime/src/markdownLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
inlineCodeFilePathCandidate,
parseFileUrlHref,
parseMarkdownFileLink,
repairUnclosedAngleLinkDestinations,
splitFilePathPosition,
workspaceRelativeFilePath,
} from "./markdownLinks.ts";
Expand Down Expand Up @@ -141,6 +142,44 @@ describe("parseMarkdownFileLink", () => {
});
});

describe("repairUnclosedAngleLinkDestinations", () => {
it.each([
["[file](<local/path/file.md)", "[file](<local/path/file.md>)"],
["[notes](</home/me/notes.md)", "[notes](</home/me/notes.md>)"],
[
"[Receipts](</Users/dara/Downloads/Lime Ride Artifacts/Bike Receipts)",
"[Receipts](</Users/dara/Downloads/Lime Ride Artifacts/Bike Receipts>)",
],
["[Open](<C:/Users/shawn/project/src/main.ts)", "[Open](<C:/Users/shawn/project/src/main.ts>)"],
[
String.raw`[Open](<C:\Users\shawn\project\src\main.ts)`,
String.raw`[Open](<C:\Users\shawn\project\src\main.ts>)`,
],
["- [file](<local/path/file.md)", "- [file](<local/path/file.md>)"],
["![shot](</tmp/shot.png)", "![shot](</tmp/shot.png>)"],
["[a](<src/a.ts) and [b](<src/b.ts)", "[a](<src/a.ts>) and [b](<src/b.ts>)"],
])("closes %s", (source, expected) => {
expect(repairUnclosedAngleLinkDestinations(source)).toBe(expected);
});

it.each([
"[file](<local/path/file.md>)",
"[file](local/path/file.md)",
"[site](<https://example.com/docs)",
"[note](<hello world)",
"[settings](</chat/settings)",
'<div class="x">hi</div>',
"<https://example.com>",
"```\n[file](<local/path/file.md)\n```",
"`[file](<local/path/file.md)`",
"[file](<)",
"[file](<local/path/file.md",
'[a](<local/path/file.md "title")',
])("leaves %s alone", (source) => {
expect(repairUnclosedAngleLinkDestinations(source)).toBe(source);
});
});

describe("fileBasename", () => {
it.each([
["/tmp/favicons/", "favicons"],
Expand Down
30 changes: 30 additions & 0 deletions packages/client-runtime/src/markdownLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,3 +344,33 @@ export function workspaceRelativeFilePath(
if (!pathForCompare.startsWith(`${rootForCompare}/`)) return null;
return normalizedPath.slice(normalizedRoot.length + 1);
}

const FENCED_CODE_SEGMENT_PATTERN = /(```[\s\S]*?(?:```|$))/;

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.

🟡 Medium src/markdownLinks.ts:348

repairUnclosedAngleLinkDestinations rewrites link-looking text inside ~~~ and indented code blocks, so a code sample such as ~~~\n[file](<src/a.ts)\n~~~ is changed to include a closing > that the user never wrote. Because the replacement only skips segments matched by FENCED_CODE_SEGMENT_PATTERN, it must recognize all Markdown code-block forms, including tilde fences and indented blocks, before applying PROSE_UNCLOSED_ANGLE_LINK_PATTERN.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/markdownLinks.ts around line 348:

`repairUnclosedAngleLinkDestinations` rewrites link-looking text inside `~~~` and indented code blocks, so a code sample such as `~~~\n[file](<src/a.ts)\n~~~` is changed to include a closing `>` that the user never wrote. Because the replacement only skips segments matched by `FENCED_CODE_SEGMENT_PATTERN`, it must recognize all Markdown code-block forms, including tilde fences and indented blocks, before applying `PROSE_UNCLOSED_ANGLE_LINK_PATTERN`.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
const PROSE_UNCLOSED_ANGLE_LINK_PATTERN = /(`[^`\n]+`)|\[([^\]\n]*)\]\(\s*<([^<>\n]+?)\s*\)/g;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

/**
* Closes Codex's unclosed angle-bracket destinations (`[label](<path)`) so
* they parse as the same file link the well-formed shape produces. Only
* destinations that already read as file paths are repaired; fenced code,
* inline code, and every other malformed shape stay exactly as written.
*/
export function repairUnclosedAngleLinkDestinations(markdown: string): string {
// Fast path: an unclosed angle destination always contains "](<" in a row.
if (!markdown.includes("](<")) return markdown;
return markdown
.split(FENCED_CODE_SEGMENT_PATTERN)
.map((segment, index) =>
index % 2 === 1
? segment
: segment.replace(
PROSE_UNCLOSED_ANGLE_LINK_PATTERN,
(match: string, codeSpan: string | undefined, label: string, destination: string) => {
if (codeSpan !== undefined) return match;
const path = destination.trim();
if (parseMarkdownFileLink(path) === null) return match;
return `[${label}](<${path}>)`;
},
),
)
.join("");
}
Loading