From a7e6cabc696f009a1caa9653a7666b765f87cfe4 Mon Sep 17 00:00:00 2001 From: Anneshu Nag Date: Sun, 4 Oct 2026 15:20:25 +0530 Subject: [PATCH 1/2] fix(chat): unclosed Codex angle-bracket links render as file chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex can persist assistant text as `[file](` before `)`. CommonMark then leaves the raw source as text instead of a link on web/desktop and mobile. Repair the source at render time in shared client-runtime: close the destination when it already reads as a file path, and leave fenced code, inline code, and every other malformed shape exactly as written. Both web ChatMarkdown and the native mobile renderer apply the repair before parsing, so the same input yields the same file chip everywhere. --- .../src/SelectableMarkdownText.tsx | 8 +- .../src/markdownLinks.test.ts | 61 +++++++++++++ apps/web/src/components/ChatMarkdown.test.tsx | 88 +++++++++++++++++++ apps/web/src/components/ChatMarkdown.tsx | 15 +++- .../client-runtime/src/markdownLinks.test.ts | 39 ++++++++ packages/client-runtime/src/markdownLinks.ts | 30 +++++++ 6 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 apps/mobile/modules/t3-markdown-text/src/markdownLinks.test.ts 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..0ad8d147fa37 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.test.ts @@ -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]()` 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"); + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index ea02607cc115..9c012fb1c03c 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -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"; @@ -3357,6 +3360,10 @@ 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, @@ -3364,11 +3371,11 @@ function ChatMarkdown({ markdownUrlTransform, localMediaPreview, setLocalMediaPreview, - } = useChatMarkdownState({ text, ...props }); + } = useChatMarkdownState({ text: repairedText, ...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 +3407,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..f543b39e526b 100644 --- a/packages/client-runtime/src/markdownLinks.test.ts +++ b/packages/client-runtime/src/markdownLinks.test.ts @@ -5,6 +5,7 @@ import { inlineCodeFilePathCandidate, parseFileUrlHref, parseMarkdownFileLink, + repairUnclosedAngleLinkDestinations, splitFilePathPosition, workspaceRelativeFilePath, } from "./markdownLinks.ts"; @@ -141,6 +142,44 @@ describe("parseMarkdownFileLink", () => { }); }); +describe("repairUnclosedAngleLinkDestinations", () => { + it.each([ + ["[file]()"], + ["[notes]()"], + [ + "[Receipts]()", + ], + ["[Open]()"], + [ + String.raw`[Open]()`, + ], + ["- [file]()"], + ["![shot]()"], + ["[a]() and [b]()"], + ])("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); + }); +}); + 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..18f0e4e6775d 100644 --- a/packages/client-runtime/src/markdownLinks.ts +++ b/packages/client-runtime/src/markdownLinks.ts @@ -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]*?(?:```|$))/; +const PROSE_UNCLOSED_ANGLE_LINK_PATTERN = /(`[^`\n]+`)|\[([^\]\n]*)\]\(\s*<([^<>\n]+?)\s*\)/g; + +/** + * Closes Codex's unclosed angle-bracket destinations (`[label]( + 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(""); +} From fd4e86cd773f0257cc67a6ad11e14c90536ca4f0 Mon Sep 17 00:00:00 2001 From: Anneshu Nag Date: Sun, 4 Oct 2026 15:37:09 +0530 Subject: [PATCH 2/2] fix(chat): keep code blocks and task offsets intact in angle-link repair Address review on #11810: the render-time repair now skips tilde fences (with proper same-char/length/whitespace closing rules), indented code blocks, and multiline inline code spans via a line scanner instead of only triple-backtick segments. Task-checkbox markers are mapped back to source offsets so toggling a task after a repaired link still edits the right characters in file previews. --- .../src/markdownLinks.test.ts | 1 + apps/web/src/components/ChatMarkdown.test.tsx | 46 +++++ apps/web/src/components/ChatMarkdown.tsx | 28 ++- .../client-runtime/src/markdownLinks.test.ts | 30 +++ packages/client-runtime/src/markdownLinks.ts | 185 +++++++++++++++--- 5 files changed, 262 insertions(+), 28 deletions(-) diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.test.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.test.ts index 0ad8d147fa37..a0645510a7da 100644 --- a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.test.ts +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.test.ts @@ -35,6 +35,7 @@ describe("unclosed angle-bracket file links", () => { it.each([ "[file]()", "[site]( { expect(repairUnclosedAngleLinkDestinations(source)).toBe(source); diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 5fcd0a538eac..4a9c65f60f9d 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -1034,4 +1034,50 @@ describe("ChatMarkdown unclosed angle-bracket file links", () => { 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 9c012fb1c03c..ca27b82962b3 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -52,7 +52,8 @@ import { } from "@t3tools/client-runtime/markdown-images"; import { inlineCodeFilePathCandidate, - repairUnclosedAngleLinkDestinations, + mapRepairedOffsetToOriginal, + repairUnclosedAngleLinkDestinationsDetailed, } from "@t3tools/client-runtime/markdown-links"; import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; @@ -281,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, @@ -2325,6 +2327,7 @@ function areMarkdownFileLinkPropsEqual( function useChatMarkdownState({ text, + repairOffsets = EMPTY_REPAIR_OFFSETS, cwd, threadRef, pullRequestPanelRef, @@ -2339,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); @@ -2749,6 +2752,7 @@ function useChatMarkdownState({ linkedThreadPullRequestFor, resolveThreadPullRequest, resolvedTheme, + repairOffsets, serverConfig, skills, text, @@ -2780,6 +2784,7 @@ function useChatMarkdownState({ linkedThreadPullRequestFor, resolveThreadPullRequest, resolvedTheme, + repairOffsets, serverConfig, skills, text, @@ -2870,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)}
  • ); @@ -3363,7 +3372,10 @@ function ChatMarkdown({ // 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]); + // 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, @@ -3371,7 +3383,11 @@ function ChatMarkdown({ markdownUrlTransform, localMediaPreview, setLocalMediaPreview, - } = useChatMarkdownState({ text: repairedText, ...props }); + } = useChatMarkdownState({ + text: repairedText, + repairOffsets: repairedMarkdown.insertedOffsets, + ...props, + }); const incrementalParsing = props.isStreaming === true && extraRemarkPlugins.length === 0 && diff --git a/packages/client-runtime/src/markdownLinks.test.ts b/packages/client-runtime/src/markdownLinks.test.ts index f543b39e526b..a9b9d9ccc2aa 100644 --- a/packages/client-runtime/src/markdownLinks.test.ts +++ b/packages/client-runtime/src/markdownLinks.test.ts @@ -3,9 +3,11 @@ import { describe, expect, it } from "vite-plus/test"; import { fileBasename, inlineCodeFilePathCandidate, + mapRepairedOffsetToOriginal, parseFileUrlHref, parseMarkdownFileLink, repairUnclosedAngleLinkDestinations, + repairUnclosedAngleLinkDestinationsDetailed, splitFilePathPosition, workspaceRelativeFilePath, } from "./markdownLinks.ts"; @@ -158,6 +160,8 @@ describe("repairUnclosedAngleLinkDestinations", () => { ["- [file]()"], ["![shot]()"], ["[a]() and [b]()"], + ["``[a]()"], + ["para\n continued [a]()"], ])("closes %s", (source, expected) => { expect(repairUnclosedAngleLinkDestinations(source)).toBe(expected); }); @@ -172,12 +176,38 @@ describe("repairUnclosedAngleLinkDestinations", () => { "", "```\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", () => { diff --git a/packages/client-runtime/src/markdownLinks.ts b/packages/client-runtime/src/markdownLinks.ts index 18f0e4e6775d..5bb46bf0c60b 100644 --- a/packages/client-runtime/src/markdownLinks.ts +++ b/packages/client-runtime/src/markdownLinks.ts @@ -345,32 +345,173 @@ export function workspaceRelativeFilePath( return normalizedPath.slice(normalizedRoot.length + 1); } -const FENCED_CODE_SEGMENT_PATTERN = /(```[\s\S]*?(?:```|$))/; -const PROSE_UNCLOSED_ANGLE_LINK_PATTERN = /(`[^`\n]+`)|\[([^\]\n]*)\]\(\s*<([^<>\n]+?)\s*\)/g; +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 { - // 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(""); + return repairUnclosedAngleLinkDestinationsDetailed(markdown).text; }