From b7e5c1523659a08edb36e9b8e2daf5cfb9007e99 Mon Sep 17 00:00:00 2001 From: ValeraZSD Date: Mon, 21 Sep 2026 02:27:51 +0200 Subject: [PATCH 1/5] fix: render images whose destination has spaces CommonMark ends an unquoted link destination at the first space, so an agent writing ![shot](C:\dir with spaces\a.png) delivers the whole line as literal text on every surface, and the workspace-image relay and preview machinery built for that destination goes unused. Repair such destinations to the angle-quoted form at the text level, which both clients already unwrap when classifying, before parsing. Fenced code, inline code, and destinations that parse on their own are left as written; link syntax is left for a separate pass. The same repair serves web, desktop (which serves the web client), and mobile. --- .../src/features/threads/ThreadFeed.tsx | 7 +- apps/web/src/components/ChatMarkdown.tsx | 12 +- .../ChatMarkdown.workspace-images.test.tsx | 14 ++ .../client-runtime/src/markdownLinks.test.ts | 59 ++++++++ packages/client-runtime/src/markdownLinks.ts | 130 ++++++++++++++++++ 5 files changed, 216 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 20848cd2ad10..5507f0970b85 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -36,6 +36,7 @@ import { classifyMarkdownImageSource, markdownImageSourceFragment, } from "@t3tools/client-runtime/markdown-images"; +import { repairMarkdownImageDestinations } from "@t3tools/client-runtime/markdown-links"; import { resolveViewedImageAsset } from "@t3tools/client-runtime/work-log/presentation"; import { renderCodexFileCitationsAsMarkdown, @@ -788,7 +789,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { readonly skills?: ReadonlyArray | undefined; }) { const segments = useMemo( - () => splitCodexArtifactTemplateMarkdown(props.markdown), + () => splitCodexArtifactTemplateMarkdown(repairMarkdownImageDestinations(props.markdown)), [props.markdown], ); @@ -1515,7 +1516,9 @@ function renderFeedEntry( ); } const isUser = message.role === "user"; - const renderedText = renderAssistantCitationsAsText(message.text); + const renderedText = renderAssistantCitationsAsText( + repairMarkdownImageDestinations(message.text), + ); const styles = isUser ? markdownStyles.user : markdownStyles.assistant; const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt); const attachments = message.attachments ?? []; diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 3b398dd5100e..64d5c7ce8e96 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -49,7 +49,10 @@ import { classifyMarkdownImageSource, markdownImageSourceFragment, } from "@t3tools/client-runtime/markdown-images"; -import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-links"; +import { + inlineCodeFilePathCandidate, + repairMarkdownImageDestinations, +} 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"; @@ -3288,6 +3291,7 @@ function ChatMarkdown({ extraRemarkPlugins = EMPTY_REMARK_PLUGINS, ...props }: ChatMarkdownProps) { + const repairedText = repairMarkdownImageDestinations(text); const { componentState, handleCopy, @@ -3295,11 +3299,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), @@ -3331,7 +3335,7 @@ function ChatMarkdown({ components={CHAT_MARKDOWN_COMPONENTS} urlTransform={markdownUrlTransform} > - {text} + {repairedText} {localMediaPreview ? ( diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index bab751c74816..f50dc2e4cedd 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -172,6 +172,20 @@ describe("ChatMarkdown workspace images", () => { expect(html).not.toContain("Image unavailable"); }); + it("loads a workspace path with spaces an agent wrote unquoted", () => { + const html = render("![Settings → General](D:\\my projects\\demo shots\\cmp-side-by-side.png)"); + + expect(testState.resources).toEqual([ + { + _tag: "media-file", + threadId: threadRef.threadId, + path: "D:\\my projects\\demo shots\\cmp-side-by-side.png", + }, + ]); + expect(html).toContain("https://signed.test/workspace-image.svg"); + expect(html).not.toContain("Image unavailable"); + }); + it("normalizes a drive-absolute src in raw image HTML", () => { const html = render(String.raw`raw`); diff --git a/packages/client-runtime/src/markdownLinks.test.ts b/packages/client-runtime/src/markdownLinks.test.ts index 4aea947b87d6..be41432c453a 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, + repairMarkdownImageDestinations, splitFilePathPosition, workspaceRelativeFilePath, } from "./markdownLinks.ts"; @@ -168,3 +169,61 @@ describe("workspaceRelativeFilePath", () => { expect(workspaceRelativeFilePath(path, workspaceRoot)).toBe(relativePath); }); }); + +describe("repairMarkdownImageDestinations", () => { + it("angle-quotes a Windows path with spaces an agent wrote as the destination", () => { + const markdown = "![Settings → General](D:\\my projects\\demo shots\\cmp-side-by-side.png)"; + expect(repairMarkdownImageDestinations(markdown)).toBe( + "![Settings → General]()", + ); + }); + + it("angle-quotes a POSIX path with spaces and keeps its hash", () => { + expect(repairMarkdownImageDestinations("![chart](/tmp/my charts/growth.png#L2)")).toBe( + "![chart]()", + ); + }); + + it("angle-quotes every image on a line and holds balanced parens in the destination", () => { + expect( + repairMarkdownImageDestinations("![a](C:\\pics\\Screenshot (1).png) ![b](/tmp/x y.png)"), + ).toBe("![a]() ![b]()"); + }); + + it("leaves destinations that parse on their own as written", () => { + expect(repairMarkdownImageDestinations("![shot](shots/cmp.png)")).toBe( + "![shot](shots/cmp.png)", + ); + expect(repairMarkdownImageDestinations("![shot]()")).toBe( + "![shot]()", + ); + expect(repairMarkdownImageDestinations('![shot](shots/a.png "Title")')).toBe( + '![shot](shots/a.png "Title")', + ); + expect(repairMarkdownImageDestinations("![shot](shots/'a b'.png)")).toBe( + "![shot](shots/'a b'.png)", + ); + }); + + it("leaves prose without a path separator as written", () => { + expect(repairMarkdownImageDestinations("![figure](one and two)")).toBe( + "![figure](one and two)", + ); + }); + + it("leaves fenced code, inline code, and link syntax alone", () => { + const markdown = [ + "```text", + "![a](C:\\dir with spaces\\a.png)", + "```", + "`![b](C:\\d e\\b.png)`", + "[docs](C:\\d e\\docs.md)", + ].join("\n"); + expect(repairMarkdownImageDestinations(markdown)).toBe(markdown); + }); + + it("keeps an unclosed fence inert to the end", () => { + const markdown = "```text\n![a](C:\\d e\\a.png)"; + expect(repairMarkdownImageDestinations(markdown)).toBe(markdown); + }); +}); diff --git a/packages/client-runtime/src/markdownLinks.ts b/packages/client-runtime/src/markdownLinks.ts index 29a337e49c42..31410b469647 100644 --- a/packages/client-runtime/src/markdownLinks.ts +++ b/packages/client-runtime/src/markdownLinks.ts @@ -1,6 +1,8 @@ import { isWindowsAbsolutePath } from "@t3tools/shared/path"; const SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN = /^\/[A-Za-z]:[\\/]/; +const IMAGE_OPEN_PATTERN = /!\[([^\][\n]*)\]\(/g; +const FENCE_LINE_PATTERN = /^ {0,3}(`{3,}|~{3,})/; const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; const RELATIVE_FILE_PATH_PATTERN = /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/; @@ -339,3 +341,131 @@ export function workspaceRelativeFilePath( if (!pathForCompare.startsWith(`${rootForCompare}/`)) return null; return normalizedPath.slice(normalizedRoot.length + 1); } + +interface CodeSpan { + readonly start: number; + readonly end: number; +} + +function inlineCodeSpans(line: string): CodeSpan[] { + const spans: CodeSpan[] = []; + let index = 0; + while (index < line.length) { + if (line[index] === "\\") { + index += 2; + continue; + } + if (line[index] !== "`") { + index += 1; + continue; + } + let runEnd = index; + while (line[runEnd] === "`") runEnd += 1; + const delimiter = line.slice(index, runEnd); + const close = line.indexOf(delimiter, runEnd); + if (close < 0) { + // An unterminated opener takes the rest of the line. + spans.push({ start: index, end: line.length }); + break; + } + spans.push({ start: index, end: close + delimiter.length }); + index = close + delimiter.length; + } + return spans; +} + +function linkDestinationEnd(line: string, openParenIndex: number): number { + let depth = 1; + for (let index = openParenIndex + 1; index < line.length; index += 1) { + const character = line[index]; + if (character === "(") depth += 1; + if (character === ")") { + depth -= 1; + if (depth === 0) return index; + } + } + return -1; +} + +function isRepairableImageDestination(destination: string): boolean { + // Only a whitespace-bearing path shape is repaired; a destination that + // already parses, quotes a title, or reads as prose is left as written + // rather than guessed at. + if (!/\s/.test(destination)) return false; + if (destination.includes('"') || destination.includes("'")) return false; + if (destination.includes("<") || destination.includes(">")) return false; + return destination.includes("/") || destination.includes("\\"); +} + +function repairImageDestinationsOnLine(line: string): string { + const codeSpans = inlineCodeSpans(line); + let output = ""; + let copiedFrom = 0; + IMAGE_OPEN_PATTERN.lastIndex = 0; + let match = IMAGE_OPEN_PATTERN.exec(line); + while (match !== null) { + const matchIndex = match.index; + const openParenIndex = matchIndex + match[0].length - 1; + const closeParenIndex = linkDestinationEnd(line, openParenIndex); + const inCode = codeSpans.some((span) => matchIndex >= span.start && matchIndex < span.end); + if (closeParenIndex >= 0 && !inCode) { + const destination = line.slice(openParenIndex + 1, closeParenIndex); + if (isRepairableImageDestination(destination)) { + output += `${line.slice(copiedFrom, openParenIndex + 1)}<${destination}>`; + copiedFrom = closeParenIndex; + IMAGE_OPEN_PATTERN.lastIndex = closeParenIndex + 1; + } + } + match = IMAGE_OPEN_PATTERN.exec(line); + } + if (copiedFrom === 0) return line; + return output + line.slice(copiedFrom); +} + +/** + * Angle-quotes image destinations a parser would reject. CommonMark ends an + * unquoted destination at the first space, so an agent writing + * `![shot](C:\dir with spaces\a.png)` delivers the whole line as literal + * text — the renderer never sees an image, and every workspace-path relay + * and preview this app already builds for that destination goes unused. An + * angle-quoted destination is the CommonMark form that holds spaces, and + * both clients already unwrap `<...>` when classifying, so the rewrite is + * invisible downstream. Fenced code, inline code, and destinations that + * parse on their own are left exactly as written, and link syntax is left + * for a separate pass. + */ +export function repairMarkdownImageDestinations(markdown: string): string { + if (!markdown.includes("![") || !/\s/.test(markdown)) return markdown; + + const lines = markdown.split("\n"); + let fenceCharacter: string | null = null; + let fenceLength = 0; + let repaired = false; + for (const [index, line] of lines.entries()) { + const fence = FENCE_LINE_PATTERN.exec(line); + if (fence !== null) { + const marker = fence[1]; + const character = marker?.[0]; + if (marker !== undefined && character !== undefined) { + if (fenceCharacter === null) { + fenceCharacter = character; + fenceLength = marker.length; + } else if ( + character === fenceCharacter && + marker.length >= fenceLength && + marker === line.trim() + ) { + fenceCharacter = null; + } + } + continue; + } + if (fenceCharacter !== null) continue; + const next = repairImageDestinationsOnLine(line); + if (next !== line) { + lines[index] = next; + repaired = true; + } + } + return repaired ? lines.join("\n") : markdown; +} From ac5d8783f17fc390ef9fcd86a53b119d6ac7ede8 Mon Sep 17 00:00:00 2001 From: ValeraZSD Date: Mon, 21 Sep 2026 14:16:33 +0200 Subject: [PATCH 2/5] fix: repair only real Markdown image syntax Review found the repair reaching text that is meant to be read literally, and leaving some paths it did repair subtly wrong. Code spans are scanned per block instead of per line, so a span that closes on a later line still shields what is inside it, and an opener that never closes is literal text rather than a span running to the end of the line. Indented code, raw HTML blocks and an escaped image marker are left alone, and a backtick fence whose info string carries a backtick opens no fence. The destination scan steps over escapes, so an escaped parenthesis belongs to the path, and alt text may carry escaped or nested brackets. The destination it writes doubles its backslashes: a backslash before punctuation is an escape inside a destination too, so the single form delivered D:\shots.cache\a b.png for a path through .cache. One helper writes that destination for both the repair and copy-as-markdown, which had been putting a destination no other reader parses on the clipboard. The web client memoises the repair on the text it is given. --- apps/web/src/components/ChatMarkdown.tsx | 8 +- .../ChatMarkdown.workspace-images.test.tsx | 32 ++- .../client-runtime/src/markdownLinks.test.ts | 80 +++++- packages/client-runtime/src/markdownLinks.ts | 234 ++++++++++++++---- 4 files changed, 287 insertions(+), 67 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 64d5c7ce8e96..209b931f0c9b 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -51,6 +51,7 @@ import { } from "@t3tools/client-runtime/markdown-images"; import { inlineCodeFilePathCandidate, + markdownImageDestination, repairMarkdownImageDestinations, } from "@t3tools/client-runtime/markdown-links"; import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; @@ -1303,7 +1304,10 @@ function markdownImageCopy(alt: string, src: string, title: string | undefined): const escapedAlt = alt.replaceAll("\\", "\\\\").replaceAll("[", "\\[").replaceAll("]", "\\]"); const titleSuffix = title === undefined ? "" : ` "${title.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; - return `![${escapedAlt}](${src}${titleSuffix})`; + // ReactMarkdown hands back the parsed source, so a path with a space + // arrives here unquoted and would paste as text that no other Markdown + // reader renders as an image. + return `![${escapedAlt}](${markdownImageDestination(src)}${titleSuffix})`; } /** @@ -3291,7 +3295,7 @@ function ChatMarkdown({ extraRemarkPlugins = EMPTY_REMARK_PLUGINS, ...props }: ChatMarkdownProps) { - const repairedText = repairMarkdownImageDestinations(text); + const repairedText = useMemo(() => repairMarkdownImageDestinations(text), [text]); const { componentState, handleCopy, diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index f50dc2e4cedd..65a112642699 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -78,7 +78,12 @@ function renderFilePreview(cwd: string, relativePath: string): string { } function copiedMarkdownFrom(html: string): string { - const copy = /data-markdown-copy="([^"]*)"/.exec(html)?.[1]?.replaceAll(""", '"'); + const copy = /data-markdown-copy="([^"]*)"/ + .exec(html)?.[1] + ?.replaceAll(""", '"') + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("&", "&"); expect(copy).toBeDefined(); return copy ?? ""; } @@ -173,19 +178,40 @@ describe("ChatMarkdown workspace images", () => { }); it("loads a workspace path with spaces an agent wrote unquoted", () => { - const html = render("![Settings → General](D:\\my projects\\demo shots\\cmp-side-by-side.png)"); + const html = render(String.raw`![Settings → General](D:\my projects\demo shots\cmp.png)`); expect(testState.resources).toEqual([ { _tag: "media-file", threadId: threadRef.threadId, - path: "D:\\my projects\\demo shots\\cmp-side-by-side.png", + path: String.raw`D:\my projects\demo shots\cmp.png`, }, ]); expect(html).toContain("https://signed.test/workspace-image.svg"); expect(html).not.toContain("Image unavailable"); }); + it("keeps a repaired path's separators through the parser's own escapes", () => { + const html = render(String.raw`![cache](D:\my projects\.cache\a b.png)`); + + expect(testState.resources).toEqual([ + { + _tag: "media-file", + threadId: threadRef.threadId, + path: String.raw`D:\my projects\.cache\a b.png`, + }, + ]); + expect(html).not.toContain("Image unavailable"); + }); + + it("copies a spaced destination in a form another Markdown reader can parse", () => { + const html = render(String.raw`shot`); + + expect(copiedMarkdownFrom(html)).toBe( + String.raw`![shot]()`, + ); + }); + it("normalizes a drive-absolute src in raw image HTML", () => { const html = render(String.raw`raw`); diff --git a/packages/client-runtime/src/markdownLinks.test.ts b/packages/client-runtime/src/markdownLinks.test.ts index be41432c453a..5c0ffd8af4a7 100644 --- a/packages/client-runtime/src/markdownLinks.test.ts +++ b/packages/client-runtime/src/markdownLinks.test.ts @@ -172,9 +172,17 @@ describe("workspaceRelativeFilePath", () => { describe("repairMarkdownImageDestinations", () => { it("angle-quotes a Windows path with spaces an agent wrote as the destination", () => { - const markdown = "![Settings → General](D:\\my projects\\demo shots\\cmp-side-by-side.png)"; + const markdown = String.raw`![Settings → General](D:\my projects\demo shots\cmp.png)`; expect(repairMarkdownImageDestinations(markdown)).toBe( - "![Settings → General]()", + String.raw`![Settings → General]()`, + ); + }); + + it("doubles the backslashes so a path segment starting with punctuation survives", () => { + // `\.` and `\_` are escapes inside a destination: written once, the parser + // hands back `D:\shots.cache\a b.png` and the file is never found. + expect(repairMarkdownImageDestinations(String.raw`![a](D:\shots\.cache\_x\a b.png)`)).toBe( + String.raw`![a]()`, ); }); @@ -186,8 +194,25 @@ describe("repairMarkdownImageDestinations", () => { it("angle-quotes every image on a line and holds balanced parens in the destination", () => { expect( - repairMarkdownImageDestinations("![a](C:\\pics\\Screenshot (1).png) ![b](/tmp/x y.png)"), - ).toBe("![a]() ![b]()"); + repairMarkdownImageDestinations( + String.raw`![a](C:\pics\Screenshot (1).png) ![b](/tmp/x y.png)`, + ), + ).toBe(String.raw`![a]() ![b]()`); + }); + + it("keeps a parenthesis escaped inside the destination", () => { + expect(repairMarkdownImageDestinations(String.raw`![x](C:\dir with spaces\report\).png)`)).toBe( + String.raw`![x]()`, + ); + }); + + it("repairs alt text carrying escaped and nested brackets", () => { + expect(repairMarkdownImageDestinations(String.raw`![see \[this\]](/tmp/a b.png)`)).toBe( + String.raw`![see \[this\]]()`, + ); + expect(repairMarkdownImageDestinations("![a [b] c](/tmp/a b.png)")).toBe( + "![a [b] c]()", + ); }); it("leaves destinations that parse on their own as written", () => { @@ -214,16 +239,55 @@ describe("repairMarkdownImageDestinations", () => { it("leaves fenced code, inline code, and link syntax alone", () => { const markdown = [ "```text", - "![a](C:\\dir with spaces\\a.png)", + String.raw`![a](C:\dir with spaces\a.png)`, "```", - "`![b](C:\\d e\\b.png)`", - "[docs](C:\\d e\\docs.md)", + "`" + String.raw`![b](C:\d e\b.png)` + "`", + String.raw`[docs](C:\d e\docs.md)`, ].join("\n"); expect(repairMarkdownImageDestinations(markdown)).toBe(markdown); }); it("keeps an unclosed fence inert to the end", () => { - const markdown = "```text\n![a](C:\\d e\\a.png)"; + const markdown = "```text\n" + String.raw`![a](C:\d e\a.png)`; expect(repairMarkdownImageDestinations(markdown)).toBe(markdown); }); + + it("leaves a code span that closes on a later line alone", () => { + const markdown = ["`literal", String.raw`![x](C:\d e\x.png)`, "text`"].join("\n"); + expect(repairMarkdownImageDestinations(markdown)).toBe(markdown); + }); + + it("repairs past a backtick that never closes, because it is literal text", () => { + expect( + repairMarkdownImageDestinations(String.raw`a ` + "`" + String.raw` ![x](/tmp/a b.png)`), + ).toBe(String.raw`a ` + "`" + String.raw` ![x]()`); + }); + + it("leaves an escaped image marker alone", () => { + const markdown = String.raw`\![x](C:\d e\x.png)`; + expect(repairMarkdownImageDestinations(markdown)).toBe(markdown); + }); + + it("leaves a raw HTML block alone", () => { + const inline = String.raw`
![x](C:\d e\x.png)
`; + expect(repairMarkdownImageDestinations(inline)).toBe(inline); + const block = ["
", String.raw`![x](C:\d e\x.png)`, "
"].join("\n"); + expect(repairMarkdownImageDestinations(block)).toBe(block); + }); + + it("leaves an indented code block alone but repairs a paragraph's own lines", () => { + const code = "text\n\n" + String.raw` ![x](C:\d e\x.png)`; + expect(repairMarkdownImageDestinations(code)).toBe(code); + expect(repairMarkdownImageDestinations("text\n" + String.raw` ![x](/tmp/a b.png)`)).toBe( + "text\n" + String.raw` ![x]()`, + ); + }); + + it("repairs under a line that only looks like a fence", () => { + // A backtick fence's info string may not contain a backtick, so the line + // below opens no fence and the image under it is live Markdown. + expect( + repairMarkdownImageDestinations("```js`x\n" + String.raw`![a](/tmp/a b.png)` + "\n```"), + ).toBe("```js`x\n" + String.raw`![a]()` + "\n```"); + }); }); diff --git a/packages/client-runtime/src/markdownLinks.ts b/packages/client-runtime/src/markdownLinks.ts index 31410b469647..adf408149ee2 100644 --- a/packages/client-runtime/src/markdownLinks.ts +++ b/packages/client-runtime/src/markdownLinks.ts @@ -1,8 +1,17 @@ import { isWindowsAbsolutePath } from "@t3tools/shared/path"; const SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN = /^\/[A-Za-z]:[\\/]/; -const IMAGE_OPEN_PATTERN = /!\[([^\][\n]*)\]\(/g; -const FENCE_LINE_PATTERN = /^ {0,3}(`{3,}|~{3,})/; +// Alt text carries escapes and one level of balanced brackets, both of which +// CommonMark allows; `[^\]]*` alone leaves `![see \[this\]](…)` unrepaired. +const IMAGE_OPEN_PATTERN = new RegExp( + String.raw`!\[(?:[^\][\\\n]|\\.|\[(?:[^\][\\\n]|\\.)*\])*\]\(`, + "g", +); +const FENCE_LINE_PATTERN = /^ {0,3}(`{3,}|~{3,})(.*)$/; +const FENCE_CLOSE_PATTERN = /^ {0,3}(`{3,}|~{3,})[ \t]*$/; +const INDENTED_CODE_PATTERN = /^(?: {4}|\t)/; +const RAW_HTML_BLOCK_OPEN_PATTERN = /^ {0,3}<(?:pre|script|style|textarea)(?:[\s/>]|$)/i; +const RAW_HTML_BLOCK_CLOSE_PATTERN = /<\/(?:pre|script|style|textarea)>/i; const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; const RELATIVE_FILE_PATH_PATTERN = /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/; @@ -347,37 +356,78 @@ interface CodeSpan { readonly end: number; } -function inlineCodeSpans(line: string): CodeSpan[] { +function backtickRunEnd(text: string, start: number): number { + let end = start; + while (text[end] === "`") end += 1; + return end; +} + +/** Index of the run that closes a code span: one of exactly its own length. */ +function closingBacktickRun(text: string, from: number, length: number): number { + let index = from; + while (index < text.length) { + if (text[index] !== "`") { + index += 1; + continue; + } + const end = backtickRunEnd(text, index); + if (end - index === length) return index; + index = end; + } + return -1; +} + +/** + * The code spans in one block of Markdown. Scanned over the whole block and + * not a line, because a span opened on one line closes on another; an opener + * that never closes is a literal backtick, so it covers nothing. + */ +function inlineCodeSpans(text: string): CodeSpan[] { const spans: CodeSpan[] = []; let index = 0; - while (index < line.length) { - if (line[index] === "\\") { + while (index < text.length) { + if (text[index] === "\\") { index += 2; continue; } - if (line[index] !== "`") { + if (text[index] !== "`") { index += 1; continue; } - let runEnd = index; - while (line[runEnd] === "`") runEnd += 1; - const delimiter = line.slice(index, runEnd); - const close = line.indexOf(delimiter, runEnd); + const runEnd = backtickRunEnd(text, index); + const length = runEnd - index; + const close = closingBacktickRun(text, runEnd, length); if (close < 0) { - // An unterminated opener takes the rest of the line. - spans.push({ start: index, end: line.length }); - break; + index = runEnd; + continue; } - spans.push({ start: index, end: close + delimiter.length }); - index = close + delimiter.length; + spans.push({ start: index, end: close + length }); + index = close + length; } return spans; } -function linkDestinationEnd(line: string, openParenIndex: number): number { +/** Whether an odd run of backslashes escapes the character at `index`. */ +function isEscapedAt(text: string, index: number): boolean { + let backslashes = 0; + for (let scan = index - 1; scan >= 0 && text[scan] === "\\"; scan -= 1) backslashes += 1; + return backslashes % 2 === 1; +} + +/** + * Index of the `)` closing a link destination, or -1. An escaped character is + * stepped over, so a parenthesis escaped inside a path belongs to the path, + * and a line ending ends the search: a destination never spans lines. + */ +function linkDestinationEnd(text: string, openParenIndex: number): number { let depth = 1; - for (let index = openParenIndex + 1; index < line.length; index += 1) { - const character = line[index]; + for (let index = openParenIndex + 1; index < text.length; index += 1) { + const character = text[index]; + if (character === "\\") { + index += 1; + continue; + } + if (character === "\n") return -1; if (character === "(") depth += 1; if (character === ")") { depth -= 1; @@ -397,29 +447,73 @@ function isRepairableImageDestination(destination: string): boolean { return destination.includes("/") || destination.includes("\\"); } -function repairImageDestinationsOnLine(line: string): string { - const codeSpans = inlineCodeSpans(line); +/** + * A literal path written as a destination a parser reads back unchanged. + * Angle brackets are what holds a space; doubling the backslashes is what + * keeps `\.claude` and `\_next` whole, since a backslash before punctuation + * is an escape inside a destination too — `` parses + * as `C:\shots.cache\a b.png` and resolves to nothing. + */ +export function markdownImageDestination(destination: string): string { + if (!/\s/.test(destination)) return destination; + const escaped = destination + .replaceAll("\\", "\\\\") + .replaceAll("<", "\\<") + .replaceAll(">", "\\>"); + return `<${escaped}>`; +} + +function repairImageDestinationsInBlock(text: string): string { + const codeSpans = inlineCodeSpans(text); let output = ""; let copiedFrom = 0; + let repaired = false; IMAGE_OPEN_PATTERN.lastIndex = 0; - let match = IMAGE_OPEN_PATTERN.exec(line); + let match = IMAGE_OPEN_PATTERN.exec(text); while (match !== null) { const matchIndex = match.index; const openParenIndex = matchIndex + match[0].length - 1; - const closeParenIndex = linkDestinationEnd(line, openParenIndex); + const closeParenIndex = linkDestinationEnd(text, openParenIndex); const inCode = codeSpans.some((span) => matchIndex >= span.start && matchIndex < span.end); - if (closeParenIndex >= 0 && !inCode) { - const destination = line.slice(openParenIndex + 1, closeParenIndex); + if (closeParenIndex >= 0 && !inCode && !isEscapedAt(text, matchIndex)) { + const destination = text.slice(openParenIndex + 1, closeParenIndex); if (isRepairableImageDestination(destination)) { - output += `${line.slice(copiedFrom, openParenIndex + 1)}<${destination}>`; + const opening = text.slice(copiedFrom, openParenIndex + 1); + output += `${opening}${markdownImageDestination(destination)}`; copiedFrom = closeParenIndex; + repaired = true; IMAGE_OPEN_PATTERN.lastIndex = closeParenIndex + 1; } } - match = IMAGE_OPEN_PATTERN.exec(line); + match = IMAGE_OPEN_PATTERN.exec(text); } - if (copiedFrom === 0) return line; - return output + line.slice(copiedFrom); + return repaired ? output + text.slice(copiedFrom) : text; +} + +interface OpenFence { + readonly character: string; + readonly length: number; +} + +/** + * The fence a line opens, or null when it only looks like one: a backtick + * fence's info string may not itself contain a backtick, so a line of three + * backticks followed by one opens nothing and the Markdown under it is + * ordinary text. + */ +function fenceOpener(line: string): OpenFence | null { + const match = FENCE_LINE_PATTERN.exec(line); + const marker = match?.[1]; + const character = marker?.[0]; + if (marker === undefined || character === undefined) return null; + if (character === "`" && (match?.[2] ?? "").includes("`")) return null; + return { character, length: marker.length }; +} + +function closesFence(line: string, fence: OpenFence): boolean { + const marker = FENCE_CLOSE_PATTERN.exec(line)?.[1]; + if (marker === undefined) return false; + return marker[0] === fence.character && marker.length >= fence.length; } /** @@ -430,42 +524,74 @@ function repairImageDestinationsOnLine(line: string): string { * and preview this app already builds for that destination goes unused. An * angle-quoted destination is the CommonMark form that holds spaces, and * both clients already unwrap `<...>` when classifying, so the rewrite is - * invisible downstream. Fenced code, inline code, and destinations that - * parse on their own are left exactly as written, and link syntax is left - * for a separate pass. + * invisible downstream. Everywhere Markdown is shown literally — fenced and + * indented code, code spans, raw HTML blocks, an escaped `\!` — is left + * exactly as written, as are destinations that parse on their own; link + * syntax is left for a separate pass. */ export function repairMarkdownImageDestinations(markdown: string): string { if (!markdown.includes("![") || !/\s/.test(markdown)) return markdown; const lines = markdown.split("\n"); - let fenceCharacter: string | null = null; - let fenceLength = 0; + const repairedLines = [...lines]; + let blockLines: number[] = []; + let fence: OpenFence | null = null; + let inRawHtmlBlock = false; + let inIndentedCode = false; let repaired = false; + + // A code span may cross lines but never a blank one, so each run of text + // lines is repaired as one block. + const flushBlock = (): void => { + if (blockLines.length === 0) return; + const text = blockLines.map((index) => lines[index] ?? "").join("\n"); + const next = repairImageDestinationsInBlock(text); + if (next !== text) { + const nextLines = next.split("\n"); + blockLines.forEach((lineIndex, offset) => { + repairedLines[lineIndex] = nextLines[offset] ?? lines[lineIndex] ?? ""; + }); + repaired = true; + } + blockLines = []; + }; + for (const [index, line] of lines.entries()) { - const fence = FENCE_LINE_PATTERN.exec(line); if (fence !== null) { - const marker = fence[1]; - const character = marker?.[0]; - if (marker !== undefined && character !== undefined) { - if (fenceCharacter === null) { - fenceCharacter = character; - fenceLength = marker.length; - } else if ( - character === fenceCharacter && - marker.length >= fenceLength && - marker === line.trim() - ) { - fenceCharacter = null; - } - } + if (closesFence(line, fence)) fence = null; continue; } - if (fenceCharacter !== null) continue; - const next = repairImageDestinationsOnLine(line); - if (next !== line) { - lines[index] = next; - repaired = true; + if (inRawHtmlBlock) { + if (RAW_HTML_BLOCK_CLOSE_PATTERN.test(line)) inRawHtmlBlock = false; + continue; + } + if (line.trim() === "") { + flushBlock(); + inIndentedCode = false; + continue; + } + const opener = fenceOpener(line); + if (opener !== null) { + flushBlock(); + fence = opener; + inIndentedCode = false; + continue; + } + if (RAW_HTML_BLOCK_OPEN_PATTERN.test(line)) { + flushBlock(); + inRawHtmlBlock = !RAW_HTML_BLOCK_CLOSE_PATTERN.test(line); + inIndentedCode = false; + continue; } + // Four spaces are code only where a block can start; the same indent + // under a paragraph is a continuation line, and that is ordinary text. + if (inIndentedCode || (blockLines.length === 0 && INDENTED_CODE_PATTERN.test(line))) { + inIndentedCode = true; + continue; + } + blockLines.push(index); } - return repaired ? lines.join("\n") : markdown; + flushBlock(); + + return repaired ? repairedLines.join("\n") : markdown; } From 1c0fa255767e46e8063944c5a98b504e307fdd93 Mon Sep 17 00:00:00 2001 From: ValeraZSD Date: Mon, 21 Sep 2026 14:18:37 +0200 Subject: [PATCH 3/5] test: cover a UNC destination with spaces --- packages/client-runtime/src/markdownLinks.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/client-runtime/src/markdownLinks.test.ts b/packages/client-runtime/src/markdownLinks.test.ts index 5c0ffd8af4a7..6c9c7a15ec94 100644 --- a/packages/client-runtime/src/markdownLinks.test.ts +++ b/packages/client-runtime/src/markdownLinks.test.ts @@ -186,6 +186,12 @@ describe("repairMarkdownImageDestinations", () => { ); }); + it("keeps a UNC path's leading pair of separators", () => { + expect(repairMarkdownImageDestinations(String.raw`![a](\\server\share\my shots\a b.png)`)).toBe( + String.raw`![a](<\\\\server\\share\\my shots\\a b.png>)`, + ); + }); + it("angle-quotes a POSIX path with spaces and keeps its hash", () => { expect(repairMarkdownImageDestinations("![chart](/tmp/my charts/growth.png#L2)")).toBe( "![chart]()", From 76ad13693e01984e9e9b2f9a27ec33740e460a1c Mon Sep 17 00:00:00 2001 From: ValeraZSD Date: Mon, 21 Sep 2026 14:52:55 +0200 Subject: [PATCH 4/5] fix: skip every block that shows Markdown raw A second review round found three more contexts the walker read wrongly. An HTML block is any of CommonMark's seven forms, not only the four raw-text tags: a comment, a processing instruction, a declaration, CDATA and the block tag list all show what is inside them as written, and a complete tag alone on a line opens one too where a block can start. A paragraph that merely opens with an inline tag is still a paragraph, so an image in it is still repaired. An indented code block ends at the first unindented line, so the paragraph after it is ordinary text; the walker was carrying the code state to the next blank line and skipping that paragraph. A CRLF document kept its carriage return, which a closing fence pattern does not accept, so a fence opened and never closed and nothing after it was ever repaired. The line patterns now read the line without its terminator, and the line keeps it. --- .../client-runtime/src/markdownLinks.test.ts | 32 +++++++++++ packages/client-runtime/src/markdownLinks.ts | 54 ++++++++++++++++--- 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/packages/client-runtime/src/markdownLinks.test.ts b/packages/client-runtime/src/markdownLinks.test.ts index 6c9c7a15ec94..3d8ea6082263 100644 --- a/packages/client-runtime/src/markdownLinks.test.ts +++ b/packages/client-runtime/src/markdownLinks.test.ts @@ -274,6 +274,32 @@ describe("repairMarkdownImageDestinations", () => { expect(repairMarkdownImageDestinations(markdown)).toBe(markdown); }); + it("leaves every CommonMark HTML block alone", () => { + for (const markdown of [ + "
\n" + String.raw`![x](/tmp/a b.png)` + "\n
", + String.raw``, + "", + "\n\n
\n" + String.raw`![x](/tmp/a b.png)` + "\n
", + ]) { + expect(repairMarkdownImageDestinations(markdown)).toBe(markdown); + } + }); + + it("repairs a paragraph that opens with an inline tag", () => { + // `em` is not a block tag and the tag is not alone on the line, so this is + // a paragraph and the image in it is live Markdown. + expect(repairMarkdownImageDestinations("hi ![x](/tmp/a b.png)")).toBe( + "hi ![x]()", + ); + }); + + it("closes a fence and repairs past it in a CRLF document", () => { + const markdown = "```text\r\n" + String.raw`![a](C:\d e\a.png)` + "\r\n```\r\n"; + expect(repairMarkdownImageDestinations(markdown + String.raw`![b](/tmp/c d.png)`)).toBe( + markdown + String.raw`![b]()`, + ); + }); + it("leaves a raw HTML block alone", () => { const inline = String.raw`
![x](C:\d e\x.png)
`; expect(repairMarkdownImageDestinations(inline)).toBe(inline); @@ -281,6 +307,12 @@ describe("repairMarkdownImageDestinations", () => { expect(repairMarkdownImageDestinations(block)).toBe(block); }); + it("ends an indented code block at the first unindented line", () => { + expect(repairMarkdownImageDestinations(" code\n" + String.raw`![x](/tmp/a b.png)`)).toBe( + " code\n" + String.raw`![x]()`, + ); + }); + it("leaves an indented code block alone but repairs a paragraph's own lines", () => { const code = "text\n\n" + String.raw` ![x](C:\d e\x.png)`; expect(repairMarkdownImageDestinations(code)).toBe(code); diff --git a/packages/client-runtime/src/markdownLinks.ts b/packages/client-runtime/src/markdownLinks.ts index adf408149ee2..0fc0fa4a7622 100644 --- a/packages/client-runtime/src/markdownLinks.ts +++ b/packages/client-runtime/src/markdownLinks.ts @@ -10,8 +10,25 @@ const IMAGE_OPEN_PATTERN = new RegExp( const FENCE_LINE_PATTERN = /^ {0,3}(`{3,}|~{3,})(.*)$/; const FENCE_CLOSE_PATTERN = /^ {0,3}(`{3,}|~{3,})[ \t]*$/; const INDENTED_CODE_PATTERN = /^(?: {4}|\t)/; -const RAW_HTML_BLOCK_OPEN_PATTERN = /^ {0,3}<(?:pre|script|style|textarea)(?:[\s/>]|$)/i; -const RAW_HTML_BLOCK_CLOSE_PATTERN = /<\/(?:pre|script|style|textarea)>/i; +// CommonMark's block tag list, which is what separates an HTML block from a +// paragraph that happens to open with a tag. +const HTML_BLOCK_TAGS = + "address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul"; +const HTML_BLOCKS = [ + { + start: /^ {0,3}<\/?(?:pre|script|style|textarea)(?:[\s/>]|$)/i, + end: /<\/(?:pre|script|style|textarea)>/i, + }, + { start: /^ {0,3}/ }, + { start: /^ {0,3}<\?/, end: /\?>/ }, + { start: /^ {0,3}/ }, + { start: /^ {0,3}/ }, + { start: new RegExp(`^ {0,3}]|$)`, "i"), end: "blank" }, +] as const; +// A complete tag alone on its line is a block too, but only where a block can +// start — inside a paragraph it is ordinary inline HTML. +const HTML_BLOCK_STANDALONE_TAG_PATTERN = + /^ {0,3}(?:<[A-Za-z][A-Za-z0-9-]*(?:\s+[^<>]*?)?\/?>|<\/[A-Za-z][A-Za-z0-9-]*\s*>)[ \t]*$/; const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; const RELATIVE_FILE_PATH_PATTERN = /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/; @@ -516,6 +533,21 @@ function closesFence(line: string, fence: OpenFence): boolean { return marker[0] === fence.character && marker.length >= fence.length; } +type HtmlBlockEnd = RegExp | "blank"; + +/** + * The condition that ends the raw HTML block this line opens, or null. All + * seven CommonMark forms: Markdown inside any of them is shown as written, so + * repairing there would rewrite what the reader is meant to see. + */ +function htmlBlockEnd(line: string, insideParagraph: boolean): HtmlBlockEnd | null { + for (const block of HTML_BLOCKS) { + if (block.start.test(line)) return block.end; + } + if (!insideParagraph && HTML_BLOCK_STANDALONE_TAG_PATTERN.test(line)) return "blank"; + return null; +} + /** * Angle-quotes image destinations a parser would reject. CommonMark ends an * unquoted destination at the first space, so an agent writing @@ -536,7 +568,7 @@ export function repairMarkdownImageDestinations(markdown: string): string { const repairedLines = [...lines]; let blockLines: number[] = []; let fence: OpenFence | null = null; - let inRawHtmlBlock = false; + let htmlEnd: HtmlBlockEnd | null = null; let inIndentedCode = false; let repaired = false; @@ -556,13 +588,16 @@ export function repairMarkdownImageDestinations(markdown: string): string { blockLines = []; }; - for (const [index, line] of lines.entries()) { + for (const [index, rawLine] of lines.entries()) { + // A CRLF document keeps its `\r`; the line patterns must not see it, and + // the line itself keeps it so the text is rewritten and not reformatted. + const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; if (fence !== null) { if (closesFence(line, fence)) fence = null; continue; } - if (inRawHtmlBlock) { - if (RAW_HTML_BLOCK_CLOSE_PATTERN.test(line)) inRawHtmlBlock = false; + if (htmlEnd !== null) { + if (htmlEnd === "blank" ? line.trim() === "" : htmlEnd.test(line)) htmlEnd = null; continue; } if (line.trim() === "") { @@ -577,14 +612,17 @@ export function repairMarkdownImageDestinations(markdown: string): string { inIndentedCode = false; continue; } - if (RAW_HTML_BLOCK_OPEN_PATTERN.test(line)) { + const html = htmlBlockEnd(line, blockLines.length > 0); + if (html !== null) { flushBlock(); - inRawHtmlBlock = !RAW_HTML_BLOCK_CLOSE_PATTERN.test(line); + htmlEnd = html !== "blank" && html.test(line) ? null : html; inIndentedCode = false; continue; } // Four spaces are code only where a block can start; the same indent // under a paragraph is a continuation line, and that is ordinary text. + // An unindented line ends the code block it was carrying. + if (!INDENTED_CODE_PATTERN.test(line)) inIndentedCode = false; if (inIndentedCode || (blockLines.length === 0 && INDENTED_CODE_PATTERN.test(line))) { inIndentedCode = true; continue; From 18c4ad6396a3a64528b178daaa86a9ca0a32b781 Mon Sep 17 00:00:00 2001 From: ValeraZSD Date: Mon, 21 Sep 2026 15:01:13 +0200 Subject: [PATCH 5/5] fix: end the paragraph a heading closes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A standalone tag opens an HTML block only where a block can start, and the walker decided that by asking whether it had buffered any line yet — which a heading or a thematic break satisfies, though neither leaves a paragraph open. So a tag under a heading was read as inline HTML and an image beneath it was rewritten inside a raw HTML block, where the reader is meant to see it as written. Each of those lines now ends its own block, and an image inside a heading is still repaired. --- .../client-runtime/src/markdownLinks.test.ts | 21 +++++++++++++++++++ packages/client-runtime/src/markdownLinks.ts | 7 +++++++ 2 files changed, 28 insertions(+) diff --git a/packages/client-runtime/src/markdownLinks.test.ts b/packages/client-runtime/src/markdownLinks.test.ts index 3d8ea6082263..bf9e57170a8d 100644 --- a/packages/client-runtime/src/markdownLinks.test.ts +++ b/packages/client-runtime/src/markdownLinks.test.ts @@ -285,6 +285,27 @@ describe("repairMarkdownImageDestinations", () => { } }); + it("knows a heading ends the paragraph a tag could not interrupt", () => { + // A standalone tag opens an HTML block only where a block can start, and a + // heading or thematic break above it ends the line's block. + for (const before of ["# H", "---", "H\n==="]) { + const markdown = `${before}\n\n` + String.raw`![x](/tmp/a b.png)`; + expect(repairMarkdownImageDestinations(markdown)).toBe(markdown); + } + // A paragraph or a list item above it is still open, so these are text. + for (const before of ["text", "- item"]) { + expect( + repairMarkdownImageDestinations(`${before}\n\n` + String.raw`![x](/tmp/a b.png)`), + ).toBe(`${before}\n\n` + String.raw`![x]()`); + } + }); + + it("repairs an image inside a heading", () => { + expect(repairMarkdownImageDestinations(String.raw`# ![x](/tmp/a b.png)`)).toBe( + String.raw`# ![x]()`, + ); + }); + it("repairs a paragraph that opens with an inline tag", () => { // `em` is not a block tag and the tag is not alone on the line, so this is // a paragraph and the image in it is live Markdown. diff --git a/packages/client-runtime/src/markdownLinks.ts b/packages/client-runtime/src/markdownLinks.ts index 0fc0fa4a7622..b23430cbc175 100644 --- a/packages/client-runtime/src/markdownLinks.ts +++ b/packages/client-runtime/src/markdownLinks.ts @@ -29,6 +29,10 @@ const HTML_BLOCKS = [ // start — inside a paragraph it is ordinary inline HTML. const HTML_BLOCK_STANDALONE_TAG_PATTERN = /^ {0,3}(?:<[A-Za-z][A-Za-z0-9-]*(?:\s+[^<>]*?)?\/?>|<\/[A-Za-z][A-Za-z0-9-]*\s*>)[ \t]*$/; +// A heading or a thematic break is a leaf block: it ends on its own line, so +// the paragraph it looked like it was continuing is closed after it. +const PARAGRAPH_ENDING_LINE_PATTERN = + /^ {0,3}(?:#{1,6}(?:[ \t]|$)|=+[ \t]*$|(?:\*[ \t]*){3,}$|(?:-[ \t]*){3,}$|(?:_[ \t]*){3,}$)/; const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; const RELATIVE_FILE_PATH_PATTERN = /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/; @@ -628,6 +632,9 @@ export function repairMarkdownImageDestinations(markdown: string): string { continue; } blockLines.push(index); + // A heading can hold an image, so it is repaired — but nothing after it is + // a continuation of it, and the next line may open a block of its own. + if (PARAGRAPH_ENDING_LINE_PATTERN.test(line)) flushBlock(); } flushBlock();