authorgravatar for 76637177+SunkenInTime@users.noreply.github.comDara Adedeji <76637177+SunkenInTime@users.noreply.github.com> 2026-10-02 13:33:23-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-02 10:33:23-07:00
log6c8fed35dded9ff71c5b46807125457acbb76be6
treea477a66c285db00f75dba3b3431b336590b9c4c6
parent4804036e04a025a35c46c02100d3c1706cd4fa47
signature Signed by PGP key B5690EEEBB952194

fix(markdown): keep Windows paths intact in link and image destinations (#12615)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

5 files changed, 152 insertions(+), 5 deletions(-)

apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx+5-5
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 nativeMarkdownChunkSpacing,6 nativeMarkdownChunkSpacing,
7 nativeMarkdownDocumentChunks,7 nativeMarkdownDocumentChunks,
8 nativeMarkdownDocumentRuns,8 nativeMarkdownDocumentRuns,
9 nativeMarkdownWithAuthoredWindowsPaths,
9 nativeMarkdownWithPreservedSoftBreaks,10 nativeMarkdownWithPreservedSoftBreaks,
10} from "./nativeMarkdownText";11} from "./nativeMarkdownText";
11import { MarkdownImageRendererContext, NativeMarkdownBlock } from "./NativeMarkdownBlock";12import { MarkdownImageRendererContext, NativeMarkdownBlock } from "./NativeMarkdownBlock";
...@@ -51,11 +52,10 @@ export function SelectableMarkdownText({...@@ -51,11 +52,10 @@ export function SelectableMarkdownText({
51 marginBottom = 0,52 marginBottom = 0,
52}: SelectableMarkdownTextProps) {53}: SelectableMarkdownTextProps) {
53 const chunks = useMemo(() => {54 const chunks = useMemo(() => {
54 const parsedDocument = parseMarkdownWithOptions(markdown, {55 const parsedDocument = nativeMarkdownWithAuthoredWindowsPaths(
55 gfm: true,56 parseMarkdownWithOptions(markdown, { gfm: true, html: true, math: false }),
56 html: true,57 markdown,
57 math: false,58 );
58 });
59 const document = preserveSoftBreaks59 const document = preserveSoftBreaks
60 ? nativeMarkdownWithPreservedSoftBreaks(parsedDocument)60 ? nativeMarkdownWithPreservedSoftBreaks(parsedDocument)
61 : parsedDocument;61 : parsedDocument;
apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts+30
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import type { MarkdownNode } from "react-native-nitro-markdown/headless";1import type { MarkdownNode } from "react-native-nitro-markdown/headless";
2import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";2import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
3import { imageMimeType } from "@t3tools/shared/image";3import { imageMimeType } from "@t3tools/shared/image";
4import { isWindowsAbsolutePath } from "@t3tools/shared/path";
4import { videoMimeType } from "@t3tools/shared/video";5import { videoMimeType } from "@t3tools/shared/video";
5/**6/**
6 * Every accent shares a lightness so no kind reads heavier than another; only hue carries7 * 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...@@ -518,6 +519,35 @@ export function nativeMarkdownWithPreservedSoftBreaks(node: MarkdownNode): Markd
518 };519 };
519}520}
520521
522const WINDOWS_DESTINATION_PATTERN = /\](?:\(|:)\s*<?((?:[A-Za-z]:|\\\\)(?:\\.|[^\s()<>\\])*)/g;
523const MARKDOWN_ESCAPE_PATTERN = /\\([!-/:-@[-`{-~])/g;
524
525/**
526 * md4c reads the `\.` in `C:\me\.t3\shot.png` as an escape, even in a link destination.
527 * Every backslash in a Windows path is a separator, so link and image paths go back to
528 * the destination as written in `markdown`. A parsed path that more than one written
529 * destination could have produced stays as parsed.
530 */
531export function nativeMarkdownWithAuthoredWindowsPaths(
532 node: MarkdownNode,
533 markdown: string,
534): MarkdownNode {
535 const authoredByParsed = new Map<string, string | null>();
536 for (const [, authored = ""] of markdown.matchAll(WINDOWS_DESTINATION_PATTERN)) {
537 if (!isWindowsAbsolutePath(authored)) continue;
538 const parsed = authored.replace(MARKDOWN_ESCAPE_PATTERN, "$1");
539 const known = authoredByParsed.get(parsed);
540 authoredByParsed.set(parsed, known === undefined || known === authored ? authored : null);
541 }
542 if (authoredByParsed.size === 0) return node;
543 const restore = (current: MarkdownNode): MarkdownNode => {
544 const href = current.href && authoredByParsed.get(current.href);
545 const children = current.children?.map(restore);
546 return { ...current, ...(href ? { href } : {}), ...(children ? { children } : {}) };
547 };
548 return restore(node);
549}
550
521function appendBlockTerminator(551function appendBlockTerminator(
522 runs: NativeMarkdownTextRun[],552 runs: NativeMarkdownTextRun[],
523 context: RunContext,553 context: RunContext,
apps/mobile/src/lib/nativeMarkdownText.test.ts+44
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7 nativeMarkdownDocumentRuns,7 nativeMarkdownDocumentRuns,
8 nativeMarkdownListItemBlocks,8 nativeMarkdownListItemBlocks,
9 nativeMarkdownTextRuns,9 nativeMarkdownTextRuns,
10 nativeMarkdownWithAuthoredWindowsPaths,
10 nativeMarkdownWithPreservedSoftBreaks,11 nativeMarkdownWithPreservedSoftBreaks,
11 nativeMarkdownContextCopyRanges,12 nativeMarkdownContextCopyRanges,
12 contextChipPresentation,13 contextChipPresentation,
...@@ -187,6 +188,49 @@ describe("nativeMarkdownTextRuns", () => {...@@ -187,6 +188,49 @@ describe("nativeMarkdownTextRuns", () => {
187 ]);188 ]);
188 });189 });
189190
191 it("keeps Windows path backslashes the parser reads as escapes", () => {
192 const markdown = [
193 String.raw`![shot](C:\Users\me\.t3\_build\shot.png "Shot")`,
194 String.raw`[settings](C:\Users\me\.claude\settings.json) and [site](https://example.com/a\.b)`,
195 "![ref][ref]",
196 String.raw`[ref]: \\wsl.localhost\Ubuntu\.t3\ref.png`,
197 ].join("\n\n");
198 // md4c drops each backslash that precedes punctuation.
199 const node: MarkdownNode = {
200 type: "document",
201 children: [
202 { type: "image", href: String.raw`C:\Users\me.t3_build\shot.png` },
203 { type: "link", href: String.raw`C:\Users\me.claude\settings.json` },
204 { type: "link", href: "https://example.com/a.b" },
205 { type: "image", href: String.raw`\wsl.localhost\Ubuntu.t3\ref.png` },
206 ],
207 };
208
209 expect(
210 nativeMarkdownWithAuthoredWindowsPaths(node, markdown).children?.map(({ href }) => href),
211 ).toEqual([
212 String.raw`C:\Users\me\.t3\_build\shot.png`,
213 String.raw`C:\Users\me\.claude\settings.json`,
214 "https://example.com/a.b",
215 String.raw`\\wsl.localhost\Ubuntu\.t3\ref.png`,
216 ]);
217 });
218
219 it("leaves a Windows path as parsed when two written paths could have produced it", () => {
220 const markdown = [
221 String.raw`\`![example](C:\Users\me\.t3\shot.png)\``,
222 String.raw`![real](C:\Users\me.t3\shot.png)`,
223 ].join("\n\n");
224 const node: MarkdownNode = {
225 type: "document",
226 children: [{ type: "image", href: String.raw`C:\Users\me.t3\shot.png` }],
227 };
228
229 expect(nativeMarkdownWithAuthoredWindowsPaths(node, markdown).children?.[0]?.href).toBe(
230 String.raw`C:\Users\me.t3\shot.png`,
231 );
232 });
233
190 it("normalizes common inline HTML and entities", () => {234 it("normalizes common inline HTML and entities", () => {
191 const node: MarkdownNode = {235 const node: MarkdownNode = {
192 type: "paragraph",236 type: "paragraph",
apps/web/src/components/ChatMarkdown.tsx+34
...@@ -89,6 +89,8 @@ import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations";...@@ -89,6 +89,8 @@ import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations";
89import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences";89import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences";
90import { AssistantCitationChip } from "./chat/AssistantCitationChip";90import { AssistantCitationChip } from "./chat/AssistantCitationChip";
91import remarkGfm from "remark-gfm";91import remarkGfm from "remark-gfm";
92import type { Processor } from "unified";
93import { isWindowsAbsolutePath } from "@t3tools/shared/path";
92import { remarkGithubAlerts } from "../markdown-github-alerts";94import { remarkGithubAlerts } from "../markdown-github-alerts";
93import {95import {
94 artifactTemplateFromHastProperties,96 artifactTemplateFromHastProperties,
...@@ -490,6 +492,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {...@@ -490,6 +492,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
490492
491const CHAT_MARKDOWN_REMARK_PLUGINS = [493const CHAT_MARKDOWN_REMARK_PLUGINS = [
492 remarkGfm,494 remarkGfm,
495 remarkKeepWindowsPathDestinations,
493 remarkGithubAlerts,496 remarkGithubAlerts,
494 remarkNormalizeListItemIndentation,497 remarkNormalizeListItemIndentation,
495 remarkCodexDirectives,498 remarkCodexDirectives,
...@@ -499,6 +502,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [...@@ -499,6 +502,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [
499502
500const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [503const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [
501 remarkGfm,504 remarkGfm,
505 remarkKeepWindowsPathDestinations,
502 remarkGithubAlerts,506 remarkGithubAlerts,
503 remarkNormalizeListItemIndentation,507 remarkNormalizeListItemIndentation,
504 remarkCodexDirectives,508 remarkCodexDirectives,
...@@ -633,6 +637,36 @@ function remarkPreserveCodeMeta() {...@@ -633,6 +637,36 @@ function remarkPreserveCodeMeta() {
633 };637 };
634}638}
635639
640interface DestinationCompileContext {
641 readonly stack: ReadonlyArray<{ readonly type: string; url?: string }>;
642 resume(): string;
643 sliceSerialize(token: unknown): string;
644}
645
646function keepWindowsPathDestination(this: DestinationCompileContext, token: unknown) {
647 const decoded = this.resume();
648 const authored = this.sliceSerialize(token);
649 const node = this.stack.at(-1);
650 // Character references still need decoding, so those destinations keep the parsed URL.
651 if (node)
652 node.url = isWindowsAbsolutePath(authored) && !authored.includes("&") ? authored : decoded;
653}
654
655/**
656 * CommonMark reads the `\.` in `C:\me\.t3\shot.png` as an escape, even in a link
657 * destination. Every backslash in a Windows path is a separator, so link, image, and
658 * definition destinations that are Windows paths keep the text as written.
659 */
660function remarkKeepWindowsPathDestinations(this: Processor) {
661 const data = this.data();
662 (data.fromMarkdownExtensions ??= []).push({
663 exit: {
664 resourceDestinationString: keepWindowsPathDestination,
665 definitionDestinationString: keepWindowsPathDestination,
666 },
667 });
668}
669
636/**670/**
637 * Preserve Windows drive links as allowed `file:` URLs before sanitization.671 * Preserve Windows drive links as allowed `file:` URLs before sanitization.
638 * The same traversal tags inline code while it can still be distinguished672 * The same traversal tags inline code while it can still be distinguished
apps/web/src/components/ChatMarkdown.workspace-images.test.tsx+39
...@@ -185,6 +185,45 @@ describe("ChatMarkdown workspace images", () => {...@@ -185,6 +185,45 @@ describe("ChatMarkdown workspace images", () => {
185 expect(html).toContain("https://signed.test/workspace-image.svg");185 expect(html).toContain("https://signed.test/workspace-image.svg");
186 });186 });
187187
188 it("keeps Windows path backslashes that CommonMark would read as escapes", () => {
189 const html = render(
190 [
191 String.raw`![inline](C:\Users\shawn\.t3\_build\workspace-image.svg)`,
192 "![reference][shot]",
193 String.raw`[shot]: C:\Users\shawn\.t3\workspace-image.svg`,
194 String.raw`[settings](C:\Users\shawn\.claude\settings.json)`,
195 String.raw`![unc](\\wsl.localhost\Ubuntu\.t3\workspace-image.svg)`,
196 ].join("\n\n"),
197 );
198
199 expect(testState.resources).toEqual([
200 {
201 _tag: "media-file",
202 threadId: threadRef.threadId,
203 path: String.raw`C:\Users\shawn\.t3\_build\workspace-image.svg`,
204 },
205 {
206 _tag: "media-file",
207 threadId: threadRef.threadId,
208 path: "C:/Users/shawn/.t3/workspace-image.svg",
209 },
210 {
211 _tag: "media-file",
212 threadId: threadRef.threadId,
213 path: String.raw`\\wsl.localhost\Ubuntu\.t3\workspace-image.svg`,
214 },
215 ]);
216 expect(html).toContain('href="C:/Users/shawn/.claude/settings.json"');
217 });
218
219 it("still decodes character references in Windows image paths", () => {
220 render("![amp](C:/Users/shawn/a&amp;b.svg)");
221
222 expect(testState.resources).toEqual([
223 { _tag: "media-file", threadId: threadRef.threadId, path: "C:/Users/shawn/a&b.svg" },
224 ]);
225 });
226
188 it("keeps a tall image placeholder and loaded image at the same proportional bounds", () => {227 it("keeps a tall image placeholder and loaded image at the same proportional bounds", () => {
189 const markdown = '<img src=".t3/workspace-image.svg" alt="sized" width="96" height="128">';228 const markdown = '<img src=".t3/workspace-image.svg" alt="sized" width="96" height="128">';
190 const loadedStyle = firstInlineStyle(render(markdown));229 const loadedStyle = firstInlineStyle(render(markdown));