diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx index 8e59256b12b8..6e118212690e 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx @@ -6,6 +6,7 @@ import { nativeMarkdownChunkSpacing, nativeMarkdownDocumentChunks, nativeMarkdownDocumentRuns, + nativeMarkdownWithAuthoredWindowsPaths, nativeMarkdownWithPreservedSoftBreaks, } from "./nativeMarkdownText"; import { MarkdownImageRendererContext, NativeMarkdownBlock } from "./NativeMarkdownBlock"; @@ -51,11 +52,10 @@ export function SelectableMarkdownText({ marginBottom = 0, }: SelectableMarkdownTextProps) { const chunks = useMemo(() => { - const parsedDocument = parseMarkdownWithOptions(markdown, { - gfm: true, - html: true, - math: false, - }); + const parsedDocument = nativeMarkdownWithAuthoredWindowsPaths( + parseMarkdownWithOptions(markdown, { gfm: true, html: true, math: false }), + markdown, + ); const document = preserveSoftBreaks ? nativeMarkdownWithPreservedSoftBreaks(parsedDocument) : parsedDocument; diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts index 04682874db0c..a02a4a587153 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -1,6 +1,7 @@ import type { MarkdownNode } from "react-native-nitro-markdown/headless"; import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; import { imageMimeType } from "@t3tools/shared/image"; +import { isWindowsAbsolutePath } from "@t3tools/shared/path"; import { videoMimeType } from "@t3tools/shared/video"; /** * Every accent shares a lightness so no kind reads heavier than another; only hue carries @@ -518,6 +519,35 @@ export function nativeMarkdownWithPreservedSoftBreaks(node: MarkdownNode): Markd }; } +const WINDOWS_DESTINATION_PATTERN = /\](?:\(|:)\s*\\])*)/g; +const MARKDOWN_ESCAPE_PATTERN = /\\([!-/:-@[-`{-~])/g; + +/** + * md4c reads the `\.` in `C:\me\.t3\shot.png` as an escape, even in a link destination. + * Every backslash in a Windows path is a separator, so link and image paths go back to + * the destination as written in `markdown`. A parsed path that more than one written + * destination could have produced stays as parsed. + */ +export function nativeMarkdownWithAuthoredWindowsPaths( + node: MarkdownNode, + markdown: string, +): MarkdownNode { + const authoredByParsed = new Map(); + for (const [, authored = ""] of markdown.matchAll(WINDOWS_DESTINATION_PATTERN)) { + if (!isWindowsAbsolutePath(authored)) continue; + const parsed = authored.replace(MARKDOWN_ESCAPE_PATTERN, "$1"); + const known = authoredByParsed.get(parsed); + authoredByParsed.set(parsed, known === undefined || known === authored ? authored : null); + } + if (authoredByParsed.size === 0) return node; + const restore = (current: MarkdownNode): MarkdownNode => { + const href = current.href && authoredByParsed.get(current.href); + const children = current.children?.map(restore); + return { ...current, ...(href ? { href } : {}), ...(children ? { children } : {}) }; + }; + return restore(node); +} + function appendBlockTerminator( runs: NativeMarkdownTextRun[], context: RunContext, diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index d934a306aa01..fca30deea89a 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -7,6 +7,7 @@ import { nativeMarkdownDocumentRuns, nativeMarkdownListItemBlocks, nativeMarkdownTextRuns, + nativeMarkdownWithAuthoredWindowsPaths, nativeMarkdownWithPreservedSoftBreaks, nativeMarkdownContextCopyRanges, contextChipPresentation, @@ -187,6 +188,49 @@ describe("nativeMarkdownTextRuns", () => { ]); }); + it("keeps Windows path backslashes the parser reads as escapes", () => { + const markdown = [ + String.raw`![shot](C:\Users\me\.t3\_build\shot.png "Shot")`, + String.raw`[settings](C:\Users\me\.claude\settings.json) and [site](https://example.com/a\.b)`, + "![ref][ref]", + String.raw`[ref]: \\wsl.localhost\Ubuntu\.t3\ref.png`, + ].join("\n\n"); + // md4c drops each backslash that precedes punctuation. + const node: MarkdownNode = { + type: "document", + children: [ + { type: "image", href: String.raw`C:\Users\me.t3_build\shot.png` }, + { type: "link", href: String.raw`C:\Users\me.claude\settings.json` }, + { type: "link", href: "https://example.com/a.b" }, + { type: "image", href: String.raw`\wsl.localhost\Ubuntu.t3\ref.png` }, + ], + }; + + expect( + nativeMarkdownWithAuthoredWindowsPaths(node, markdown).children?.map(({ href }) => href), + ).toEqual([ + String.raw`C:\Users\me\.t3\_build\shot.png`, + String.raw`C:\Users\me\.claude\settings.json`, + "https://example.com/a.b", + String.raw`\\wsl.localhost\Ubuntu\.t3\ref.png`, + ]); + }); + + it("leaves a Windows path as parsed when two written paths could have produced it", () => { + const markdown = [ + String.raw`\`![example](C:\Users\me\.t3\shot.png)\``, + String.raw`![real](C:\Users\me.t3\shot.png)`, + ].join("\n\n"); + const node: MarkdownNode = { + type: "document", + children: [{ type: "image", href: String.raw`C:\Users\me.t3\shot.png` }], + }; + + expect(nativeMarkdownWithAuthoredWindowsPaths(node, markdown).children?.[0]?.href).toBe( + String.raw`C:\Users\me.t3\shot.png`, + ); + }); + it("normalizes common inline HTML and entities", () => { const node: MarkdownNode = { type: "paragraph", diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 4a1f616aed3b..ea02607cc115 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -89,6 +89,8 @@ import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; import { AssistantCitationChip } from "./chat/AssistantCitationChip"; import remarkGfm from "remark-gfm"; +import type { Processor } from "unified"; +import { isWindowsAbsolutePath } from "@t3tools/shared/path"; import { remarkGithubAlerts } from "../markdown-github-alerts"; import { artifactTemplateFromHastProperties, @@ -490,6 +492,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { const CHAT_MARKDOWN_REMARK_PLUGINS = [ remarkGfm, + remarkKeepWindowsPathDestinations, remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkCodexDirectives, @@ -499,6 +502,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkGfm, + remarkKeepWindowsPathDestinations, remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkCodexDirectives, @@ -633,6 +637,36 @@ function remarkPreserveCodeMeta() { }; } +interface DestinationCompileContext { + readonly stack: ReadonlyArray<{ readonly type: string; url?: string }>; + resume(): string; + sliceSerialize(token: unknown): string; +} + +function keepWindowsPathDestination(this: DestinationCompileContext, token: unknown) { + const decoded = this.resume(); + const authored = this.sliceSerialize(token); + const node = this.stack.at(-1); + // Character references still need decoding, so those destinations keep the parsed URL. + if (node) + node.url = isWindowsAbsolutePath(authored) && !authored.includes("&") ? authored : decoded; +} + +/** + * CommonMark reads the `\.` in `C:\me\.t3\shot.png` as an escape, even in a link + * destination. Every backslash in a Windows path is a separator, so link, image, and + * definition destinations that are Windows paths keep the text as written. + */ +function remarkKeepWindowsPathDestinations(this: Processor) { + const data = this.data(); + (data.fromMarkdownExtensions ??= []).push({ + exit: { + resourceDestinationString: keepWindowsPathDestination, + definitionDestinationString: keepWindowsPathDestination, + }, + }); +} + /** * Preserve Windows drive links as allowed `file:` URLs before sanitization. * The same traversal tags inline code while it can still be distinguished diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index bab751c74816..a9c285e1f97b 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -185,6 +185,45 @@ describe("ChatMarkdown workspace images", () => { expect(html).toContain("https://signed.test/workspace-image.svg"); }); + it("keeps Windows path backslashes that CommonMark would read as escapes", () => { + const html = render( + [ + String.raw`![inline](C:\Users\shawn\.t3\_build\workspace-image.svg)`, + "![reference][shot]", + String.raw`[shot]: C:\Users\shawn\.t3\workspace-image.svg`, + String.raw`[settings](C:\Users\shawn\.claude\settings.json)`, + String.raw`![unc](\\wsl.localhost\Ubuntu\.t3\workspace-image.svg)`, + ].join("\n\n"), + ); + + expect(testState.resources).toEqual([ + { + _tag: "media-file", + threadId: threadRef.threadId, + path: String.raw`C:\Users\shawn\.t3\_build\workspace-image.svg`, + }, + { + _tag: "media-file", + threadId: threadRef.threadId, + path: "C:/Users/shawn/.t3/workspace-image.svg", + }, + { + _tag: "media-file", + threadId: threadRef.threadId, + path: String.raw`\\wsl.localhost\Ubuntu\.t3\workspace-image.svg`, + }, + ]); + expect(html).toContain('href="C:/Users/shawn/.claude/settings.json"'); + }); + + it("still decodes character references in Windows image paths", () => { + render("![amp](C:/Users/shawn/a&b.svg)"); + + expect(testState.resources).toEqual([ + { _tag: "media-file", threadId: threadRef.threadId, path: "C:/Users/shawn/a&b.svg" }, + ]); + }); + it("keeps a tall image placeholder and loaded image at the same proportional bounds", () => { const markdown = 'sized'; const loadedStyle = firstInlineStyle(render(markdown));