diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx index 6e118212690e..172c8add5d5e 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx @@ -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, @@ -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](`. + 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) diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.test.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.test.ts new file mode 100644 index 000000000000..a0645510a7da --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.test.ts @@ -0,0 +1,62 @@ +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]()` 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]()"], + [ + "[Receipts]()", + ], + ["[Open]()"], + ["- [file]()"], + ])("repairs %s before parsing", (source, expected) => { + expect(repairUnclosedAngleLinkDestinations(source)).toBe(expected); + }); + + it.each([ + "[file]()", + "[site]( { + 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]( { + expect(repairUnclosedAngleLinkDestinations("[site]( { 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( + , + ); + + expect(html).toContain("chat-markdown-file-link"); + expect(html).toContain("file.md"); + expect(html).not.toContain("<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( + , + ); + const windows = renderToStaticMarkup( + , + ); + const listed = renderToStaticMarkup( + , + ); + + 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("
  • "); + }, + ); + + it.each([true, false])( + "leaves other unclosed destinations as text with parseRawHtml=%s", + (parseRawHtml) => { + for (const text of [ + "[site](, + ); + + expect(html).not.toContain("chat-markdown-file-link"); + } + }, + ); + + it("leaves the unclosed shape inside code spans literal", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).not.toContain("chat-markdown-file-link"); + expect(html).toContain("</tmp/project/src/main.ts"); + }); + + it("keeps task toggles on the original offsets when a repair precedes them", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + let renderer: ReactTestRenderer | undefined; + let editedText: string | undefined; + const original = "[a]( ( + { + editedText = setMarkdownTaskChecked(text, markerOffset, checked); + renderer!.update(message(editedText)); + }} + /> + ); + + try { + await act(async () => { + renderer = create(message(original)); + }); + const mounted = renderer!; + const input = mounted.root.findAllByType("input")[0]!; + const listItem = mounted.root.findAllByType("li")[0]!; + // The `[` sits at offset 17 in the original text; the inserted `>` + // shifts the repaired offset to 18. + expect(listItem.props["data-task-marker-offset"]).toBe(17); + const { onChange } = input.props as ComponentProps<"input">; + if (!onChange) throw new Error("Task checkbox has no edit handler"); + await act(async () => { + onChange({ + currentTarget: { + checked: true, + closest: () => ({ + dataset: { taskMarkerOffset: String(listItem.props["data-task-marker-offset"]) }, + }), + }, + } as unknown as Parameters[0]); + }); + + expect(editedText).toBe("[a]( renderer?.unmount()); + vi.unstubAllGlobals(); + } + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index ea02607cc115..ca27b82962b3 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -50,7 +50,11 @@ import { classifyMarkdownImageSource, markdownImageSourceFragment, } from "@t3tools/client-runtime/markdown-images"; -import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-links"; +import { + inlineCodeFilePathCandidate, + mapRepairedOffsetToOriginal, + repairUnclosedAngleLinkDestinationsDetailed, +} 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"; @@ -278,6 +282,7 @@ export function shouldUseMarkdownFileBrowserPrimaryAction(input: { const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; const EMPTY_REMARK_PLUGINS: NonNullable = []; +const EMPTY_REPAIR_OFFSETS: readonly number[] = []; const ARTIFACT_TEMPLATE_ICON_BY_KIND = { document: FileTextIcon, @@ -2322,6 +2327,7 @@ function areMarkdownFileLinkPropsEqual( function useChatMarkdownState({ text, + repairOffsets = EMPTY_REPAIR_OFFSETS, cwd, threadRef, pullRequestPanelRef, @@ -2336,7 +2342,7 @@ function useChatMarkdownState({ renderContextReference, headingLevelOffset = 0, githubMedia = false, -}: ChatMarkdownProps) { +}: ChatMarkdownProps & { readonly repairOffsets?: readonly number[] }) { const { resolvedTheme } = useTheme(); const [localMediaPreview, setLocalMediaPreview] = useState(null); const markdownRef = useRef(null); @@ -2746,6 +2752,7 @@ function useChatMarkdownState({ linkedThreadPullRequestFor, resolveThreadPullRequest, resolvedTheme, + repairOffsets, serverConfig, skills, text, @@ -2777,6 +2784,7 @@ function useChatMarkdownState({ linkedThreadPullRequestFor, resolveThreadPullRequest, resolvedTheme, + repairOffsets, serverConfig, skills, text, @@ -2867,12 +2875,16 @@ const CHAT_MARKDOWN_COMPONENTS = { ); }, li: function MarkdownListItem({ node, children, ...props }) { - const { text, skills } = use(ChatMarkdownRendererContext); + const { text, skills, repairOffsets } = use(ChatMarkdownRendererContext); const listItemStart = node?.position?.start.offset; const markerOffset = typeof listItemStart === "number" ? findTaskListMarkerOffset(text, listItemStart) : null; + // Repairs insert `>`, so a marker after one sits further along in the + // rendered text than in the source the toggle edits. Map it back. + const originalMarkerOffset = + markerOffset === null ? null : mapRepairedOffsetToOriginal(repairOffsets, markerOffset); return ( -
  • +
  • {renderSkillInlineMarkdownChildren(children, skills)}
  • ); @@ -3357,6 +3369,13 @@ 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. + // The insertion offsets map repaired positions (task markers) back to the + // source the toggle edits. + const repairedMarkdown = useMemo(() => repairUnclosedAngleLinkDestinationsDetailed(text), [text]); + const repairedText = repairedMarkdown.text; const { componentState, handleCopy, @@ -3364,11 +3383,15 @@ function ChatMarkdown({ markdownUrlTransform, localMediaPreview, setLocalMediaPreview, - } = useChatMarkdownState({ text, ...props }); + } = useChatMarkdownState({ + text: repairedText, + repairOffsets: repairedMarkdown.insertedOffsets, + ...props, + }); 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), @@ -3400,7 +3423,7 @@ function ChatMarkdown({ components={CHAT_MARKDOWN_COMPONENTS} urlTransform={markdownUrlTransform} > - {text} + {repairedText} {localMediaPreview ? ( diff --git a/packages/client-runtime/src/markdownLinks.test.ts b/packages/client-runtime/src/markdownLinks.test.ts index deea6960478e..a9b9d9ccc2aa 100644 --- a/packages/client-runtime/src/markdownLinks.test.ts +++ b/packages/client-runtime/src/markdownLinks.test.ts @@ -3,8 +3,11 @@ import { describe, expect, it } from "vite-plus/test"; import { fileBasename, inlineCodeFilePathCandidate, + mapRepairedOffsetToOriginal, parseFileUrlHref, parseMarkdownFileLink, + repairUnclosedAngleLinkDestinations, + repairUnclosedAngleLinkDestinationsDetailed, splitFilePathPosition, workspaceRelativeFilePath, } from "./markdownLinks.ts"; @@ -141,6 +144,72 @@ describe("parseMarkdownFileLink", () => { }); }); +describe("repairUnclosedAngleLinkDestinations", () => { + it.each([ + ["[file]()"], + ["[notes]()"], + [ + "[Receipts]()", + ], + ["[Open]()"], + [ + String.raw`[Open]()`, + ], + ["- [file]()"], + ["![shot]()"], + ["[a]() and [b]()"], + ["``[a]()"], + ["para\n continued [a]()"], + ])("closes %s", (source, expected) => { + expect(repairUnclosedAngleLinkDestinations(source)).toBe(expected); + }); + + it.each([ + "[file]()", + "[file](local/path/file.md)", + "[site](hi', + "", + "```\n[file]( { + expect(repairUnclosedAngleLinkDestinations(source)).toBe(source); + }); + + it("reports the repaired text and insertion offsets", () => { + expect(repairUnclosedAngleLinkDestinationsDetailed("[a]()", + insertedOffsets: [14], + }); + expect(repairUnclosedAngleLinkDestinationsDetailed("[a](https://example.com)")).toEqual({ + text: "[a](https://example.com)", + insertedOffsets: [], + }); + }); + + it.each([ + [[], 7, 7], + [[14], 5, 5], + [[14], 19, 18], + [[4, 10], 12, 10], + ])("maps repaired offset %s to %s", (inserted, repaired, expected) => { + expect(mapRepairedOffsetToOriginal(inserted, repaired)).toBe(expected); + }); +}); + describe("fileBasename", () => { it.each([ ["/tmp/favicons/", "favicons"], diff --git a/packages/client-runtime/src/markdownLinks.ts b/packages/client-runtime/src/markdownLinks.ts index 0a32f5061083..5bb46bf0c60b 100644 --- a/packages/client-runtime/src/markdownLinks.ts +++ b/packages/client-runtime/src/markdownLinks.ts @@ -344,3 +344,174 @@ export function workspaceRelativeFilePath( if (!pathForCompare.startsWith(`${rootForCompare}/`)) return null; return normalizedPath.slice(normalizedRoot.length + 1); } + +const UNCLOSED_ANGLE_LINK_PATTERN = /\[([^\]\n]*)\]\(\s*<([^<>\n]+?)\s*\)/g; +const FENCE_OPENER_PATTERN = /^ {0,3}(`{3,}|~{3,})/; +const INDENTED_CODE_PATTERN = /^(?: {4}|\t)/; +const BLANK_LINE_PATTERN = /^\s*$/; + +export interface UnclosedAngleLinkRepair { + readonly text: string; + /** Repaired-string offsets of each inserted `>`, ascending. */ + readonly insertedOffsets: readonly number[]; +} + +interface FenceMarker { + readonly char: "`" | "~"; + readonly length: number; +} + +function fenceOpener(line: string): FenceMarker | null { + const match = FENCE_OPENER_PATTERN.exec(line); + const marker = match?.[1]; + if (!marker) return null; + // An info string on a backtick fence cannot itself contain backticks. + if (marker.startsWith("`") && line.slice(match[0].length).includes("`")) return null; + return { char: marker.startsWith("`") ? "`" : "~", length: marker.length }; +} + +function isFenceCloser(line: string, fence: FenceMarker): boolean { + let index = 0; + while (index < 3 && line[index] === " ") index += 1; + if (line[index] !== fence.char) return false; + let end = index; + while (line[end] === fence.char) end += 1; + if (end - index < fence.length) return false; + return line.slice(end).trim() === ""; +} + +/** End offset (exclusive) of a backtick run of exactly `length`, or -1. */ +function closingBacktickRunEnd(line: string, from: number, length: number): number { + for (let index = from; index <= line.length - length; index += 1) { + if (line[index] !== "`") continue; + let end = index; + while (line[end] === "`") end += 1; + if (end - index === length) return end; + index = end - 1; + } + return -1; +} + +/** + * Maps an offset in repaired text back to the source. Each repair inserts + * exactly one `>`, so every earlier insertion shifts the offset down by one. + */ +export function mapRepairedOffsetToOriginal( + insertedOffsets: readonly number[], + repairedOffset: number, +): number { + let shift = 0; + for (const inserted of insertedOffsets) { + if (inserted > repairedOffset) break; + shift += 1; + } + return repairedOffset - shift; +} + +/** + * Closes Codex's unclosed angle-bracket destinations (`[label]( + chunk.replace( + UNCLOSED_ANGLE_LINK_PATTERN, + (match: string, label: string, destination: string, offset: number) => { + const path = destination.trim(); + if (parseMarkdownFileLink(path) === null) return match; + insertedOffsets.push(chunkBase + offset + match.length); + return `[${label}](<${path}>)`; + }, + ); + const repairProseLine = ( + line: string, + lineBase: number, + ): { text: string; continuedSpan: number } => { + let result = ""; + let cursor = 0; + let continuedSpan = 0; + while (cursor < line.length) { + const runStart = line.indexOf("`", cursor); + if (runStart === -1) { + result += repairChunk(line.slice(cursor), lineBase + result.length); + break; + } + let runEnd = runStart; + while (line[runEnd] === "`") runEnd += 1; + const closeEnd = closingBacktickRunEnd(line, runEnd, runEnd - runStart); + result += repairChunk(line.slice(cursor, runStart), lineBase + result.length); + if (closeEnd === -1) { + result += line.slice(runStart); + continuedSpan = runEnd - runStart; + break; + } + result += line.slice(runStart, closeEnd); + cursor = closeEnd; + } + return { text: result, continuedSpan }; + }; + + const lines = markdown.split("\n"); + const repairedLines: string[] = []; + let fence: FenceMarker | null = null; + let spanLength = 0; + let prevBlank = true; + let prevCodeBlock = false; + let base = 0; + for (const line of lines) { + let repairedLine: string; + if (fence !== null) { + if (isFenceCloser(line, fence)) fence = null; + repairedLine = line; + prevBlank = false; + prevCodeBlock = true; + } else if (spanLength > 0) { + const end = closingBacktickRunEnd(line, 0, spanLength); + if (end === -1) { + repairedLine = line; + prevBlank = false; + prevCodeBlock = false; + } else { + spanLength = 0; + repairedLine = line.slice(0, end) + repairChunk(line.slice(end), base + end); + prevBlank = BLANK_LINE_PATTERN.test(line); + prevCodeBlock = false; + } + } else { + const opener = fenceOpener(line); + if (opener !== null) { + fence = opener; + repairedLine = line; + prevBlank = false; + prevCodeBlock = true; + } else if (INDENTED_CODE_PATTERN.test(line) && (prevBlank || prevCodeBlock)) { + repairedLine = line; + prevBlank = false; + prevCodeBlock = true; + } else { + const repaired = repairProseLine(line, base); + repairedLine = repaired.text; + spanLength = repaired.continuedSpan; + prevBlank = BLANK_LINE_PATTERN.test(line); + prevCodeBlock = false; + } + } + repairedLines.push(repairedLine); + base += repairedLine.length + 1; + } + return { text: repairedLines.join("\n"), insertedOffsets }; +} + +export function repairUnclosedAngleLinkDestinations(markdown: string): string { + return repairUnclosedAngleLinkDestinationsDetailed(markdown).text; +}