From 6d968fe0cbdb53ff2d012323ce1256e8bdb536a0 Mon Sep 17 00:00:00 2001
From: amv146 <39172404+amv146@users.noreply.github.com>
Date: Thu, 22 Oct 2026 10:52:51 -0700
Subject: [PATCH] fix(mobile): restore Windows Markdown paths
Restore drive and UNC destinations after the native Markdown renderer migration. Preserve encoded paths and HTML image attributes.
Fixes #23722
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
apps/mobile/src/lib/markdownDocument.ts | 51 +++++++-
apps/mobile/src/lib/markdownHtml.test.ts | 15 +++
.../src/lib/markdownWindowsPaths.test.ts | 120 ++++++++++++++++++
.../src/native/EnrichedMarkdownText.tsx | 3 +-
4 files changed, 185 insertions(+), 4 deletions(-)
create mode 100644 apps/mobile/src/lib/markdownWindowsPaths.test.ts
diff --git a/apps/mobile/src/lib/markdownDocument.ts b/apps/mobile/src/lib/markdownDocument.ts
index 42f737f16b340..2fce9f5955588 100644
--- a/apps/mobile/src/lib/markdownDocument.ts
+++ b/apps/mobile/src/lib/markdownDocument.ts
@@ -1,7 +1,8 @@
import type { Root, RootContent } from "mdast";
-import { unified } from "unified";
+import { unified, type Processor } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
+import { isWindowsAbsolutePath } from "@t3tools/shared/path";
const parser = unified().use(remarkParse).use(remarkGfm);
export type MarkdownNode = Root | RootContent;
@@ -109,7 +110,7 @@ export function markdownHtmlContent(content: string): string {
if (!src) return "";
const alt = (attribute(tag, "alt") ?? "").replace(/[\\[\]&]/g, "\\$&");
const href = src
- .replace(/&/g, "\\&")
+ .replace(/[\\&]/g, "\\$&")
.replace(//g, "%3E")
.replace(/\n/g, "");
@@ -233,6 +234,50 @@ export function markdownWithTableBreaks(markdown: string): string {
return markdown;
}
+interface DestinationToken {
+ readonly start: { readonly offset: number };
+ readonly end: { readonly offset: number };
+}
+
+// Enriched owns its AST, so preserve Windows paths before its native parse (#23722).
+// Only parsed destinations are rewritten; code examples cannot redirect real links.
+// Keep encoded runs and \& escapes. Other singleton punctuation escapes are treated
+// as raw separators: three backslashes preserve both the separator and the escape.
+// Raw \& is ambiguous; encode a separator before & explicitly (\\\&) or use HTML src.
+export function markdownWithWindowsPathDestinations(markdown: string): string {
+ if (!markdown.includes("\\")) return markdown;
+ const edits: Array<{ start: number; end: number; content: string }> = [];
+ function record(
+ this: { resume(): string; sliceSerialize(token: DestinationToken): string },
+ token: DestinationToken,
+ ) {
+ this.resume();
+ const destination = this.sliceSerialize(token);
+ if (!isWindowsAbsolutePath(destination)) return;
+ const content = destination.replace(/\\+/g, (backslashes, offset: number) => {
+ // Two leading backslashes mean raw UNC; four are already encoded.
+ if (offset === 0 && backslashes.length === 2) return backslashes.repeat(2);
+ if (backslashes.length > 1) return backslashes;
+ const next = destination[offset + 1] ?? "";
+ return next !== "&" && /[!-/:-@[-`{-~]/.test(next) ? backslashes.repeat(3) : backslashes;
+ });
+ if (content !== destination)
+ edits.push({ start: token.start.offset, end: token.end.offset, content });
+ }
+ parser()
+ .use(function (this: Processor) {
+ (this.data().fromMarkdownExtensions ??= []).push({
+ exit: { resourceDestinationString: record, definitionDestinationString: record },
+ });
+ })
+ .parse(markdown);
+ for (let index = edits.length - 1; index >= 0; index -= 1) {
+ const { start, end, content } = edits[index]!;
+ markdown = markdown.slice(0, start) + content + markdown.slice(end);
+ }
+ return markdown;
+}
+
export function parseMarkdownDocument(markdown: string): Root {
- return parser.parse(markdownWithHtml(markdown));
+ return parser.parse(markdownWithWindowsPathDestinations(markdownWithHtml(markdown)));
}
diff --git a/apps/mobile/src/lib/markdownHtml.test.ts b/apps/mobile/src/lib/markdownHtml.test.ts
index e9e1fc219696b..194ad3efbb581 100644
--- a/apps/mobile/src/lib/markdownHtml.test.ts
+++ b/apps/mobile/src/lib/markdownHtml.test.ts
@@ -31,6 +31,21 @@ describe("HTML in native Markdown", () => {
expect(paragraph.children[0]).toMatchObject({ type: "image", alt: "<" });
});
+ it("preserves literal Windows separators and once-decoded ampersands in image attributes", () => {
+ for (const [src, path] of [
+ [String.raw`C:\me\.t3\a&b.png`, String.raw`C:\me\.t3\a&b.png`],
+ [String.raw`C:\me\.t3\a©.png`, String.raw`C:\me\.t3\a©.png`],
+ [String.raw`C:\me\.t3\©.png`, String.raw`C:\me\.t3\©.png`],
+ ]) {
+ expect(markdownImageSources(`
`)).toEqual([path]);
+ }
+ });
+
+ it("repairs Windows Markdown destinations exposed by HTML wrappers", () => {
+ const path = String.raw`C:/me\.t3/shot.png`;
+ expect(markdownImageSources(`\n\n\n\n `)).toEqual([path]);
+ });
+
it("ignores code delimiters in HTML attributes and comments when collecting screenshots", () => {
const markdown =
'\nProof
\n
\n\n ';
diff --git a/apps/mobile/src/lib/markdownWindowsPaths.test.ts b/apps/mobile/src/lib/markdownWindowsPaths.test.ts
new file mode 100644
index 0000000000000..c74d92f22f9cb
--- /dev/null
+++ b/apps/mobile/src/lib/markdownWindowsPaths.test.ts
@@ -0,0 +1,120 @@
+import { describe, expect, it } from "vite-plus/test";
+// This bundled WASM build exercises the same C++ parser used by the native renderer.
+import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown";
+import type { ASTNode } from "react-native-enriched-markdown/src/web/types";
+import {
+ markdownWithHtml,
+ markdownWithInlineFileLinks,
+ markdownWithTableBreaks,
+ markdownWithWindowsPathDestinations,
+ parseMarkdownDocument,
+ type MarkdownNode,
+} from "./markdownDocument";
+import { markdownImageSources } from "./markdownImageSources";
+import { ENRICHED_INLINE_FILE_LINK_REGEX } from "./enrichedLinkPresentation";
+
+function nativeDocument(markdown: string) {
+ return parseMarkdown(
+ markdownWithTableBreaks(
+ markdownWithInlineFileLinks(
+ markdownWithWindowsPathDestinations(markdownWithHtml(markdown)),
+ ENRICHED_INLINE_FILE_LINK_REGEX,
+ ),
+ ),
+ );
+}
+
+function destinations(node: MarkdownNode | ASTNode): string[] {
+ const url = "url" in node ? node.url : "attributes" in node ? node.attributes?.url : undefined;
+ const children = "children" in node ? (node.children ?? []) : [];
+ return [...(url ? [url] : []), ...children.flatMap(destinations)];
+}
+
+describe("markdownWithWindowsPathDestinations", () => {
+ it.each([
+ { name: "JavaScript", parse: parseMarkdownDocument },
+ { name: "native", parse: nativeDocument },
+ ])("preserves the original #12615 destinations through the $name parser", async ({ parse }) => {
+ const document = await parse(
+ [
+ String.raw``,
+ 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"),
+ );
+
+ expect(destinations(document)).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("keeps an escaped closing delimiter inside one native image without trailing text", async () => {
+ const document = await nativeDocument(String.raw`shot.png "Shot")`);
+
+ expect(destinations(document)).toEqual([String.raw`C:\a\)shot.png`]);
+ expect(document.children?.[0]?.children?.map((node) => node.type)).toEqual(["Image"]);
+ expect(document.children?.[0]?.children?.[0]?.attributes?.title).toBe("Shot");
+ });
+
+ it("preserves HTML and inline-file destinations through native parsing", async () => {
+ const document = await nativeDocument(
+ [
+ String.raw``,
+ String.raw`
`,
+ "`" + String.raw`C:\repo\_private\a©.ts` + "`",
+ ].join("\n\n"),
+ );
+
+ expect(destinations(document)).toEqual([
+ String.raw`\\server\share\_shot.png`,
+ String.raw`C:\me\.t3\©.png`,
+ String.raw`C:\repo\_private\a©.ts`,
+ ]);
+ });
+
+ it("does not let a code example redirect a real image with a lookalike decoded path", () => {
+ const path = String.raw`C:\Users\me.t3\shot.png`;
+ const markdown = [
+ "`" + String.raw`` + "`",
+ ``,
+ ].join("\n\n");
+
+ expect(markdownWithWindowsPathDestinations(markdown)).toBe(markdown);
+ expect(markdownImageSources(markdown)).toEqual([path]);
+ });
+
+ it("repairs mixed paths independently of unrelated Windows prose", () => {
+ const path = String.raw`C:/Users/me\.t3/shot.png`;
+ const markdown = ``;
+
+ expect(markdownImageSources(markdown)).toEqual([path]);
+ expect(markdownImageSources(String.raw`Working in C:\repo` + "\n\n" + markdown)).toEqual([
+ path,
+ ]);
+ });
+
+ it("retains encoded separators and intentional ampersand escapes", () => {
+ for (const [destination, path] of [
+ [String.raw`C:\\me\\.t3\\shot.png`, String.raw`C:\me\.t3\shot.png`],
+ [String.raw`\\\\server\\share\\_shot.png`, String.raw`\\server\share\_shot.png`],
+ [String.raw`C:\me\a\©.png`, String.raw`C:\me\a©.png`],
+ [String.raw`C:\me\\\©.png`, String.raw`C:\me\©.png`],
+ ]) {
+ expect(markdownImageSources(``)).toEqual([path]);
+ }
+ });
+
+ it("leaves URLs, relative destinations, and literal Markdown examples alone", () => {
+ const markdown = [
+ String.raw``,
+ String.raw`  \*not\*`,
+ "`" + String.raw`` + "`",
+ "```md\n" + String.raw`` + "\n```",
+ ].join("\n\n");
+ expect(markdownWithWindowsPathDestinations(markdown)).toBe(markdown);
+ });
+});
diff --git a/apps/mobile/src/native/EnrichedMarkdownText.tsx b/apps/mobile/src/native/EnrichedMarkdownText.tsx
index 5d965dad4dc37..bb61c828029f6 100644
--- a/apps/mobile/src/native/EnrichedMarkdownText.tsx
+++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx
@@ -8,6 +8,7 @@ import {
markdownWithHtml,
markdownWithInlineFileLinks,
markdownWithTableBreaks,
+ markdownWithWindowsPathDestinations,
} from "../lib/markdownDocument";
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { Platform, Share, StyleSheet, View } from "react-native";
@@ -158,7 +159,7 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) {
() =>
markdownWithTableBreaks(
markdownWithInlineFileLinks(
- markdownWithHtml(props.markdown),
+ markdownWithWindowsPathDestinations(markdownWithHtml(props.markdown)),
ENRICHED_INLINE_FILE_LINK_REGEX,
),
),