diff --git a/apps/mobile/src/lib/markdownDocument.ts b/apps/mobile/src/lib/markdownDocument.ts
index af8fe2d02a15f..a3e2aa2f0aad1 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;
@@ -75,7 +76,7 @@ function attribute(tag: string, name: string): string | null {
}
function markdownDestination(url: string): string {
- return `<${url.replace(/&/g, "\\&").replace(//g, "%3E").replace(/\|/g, "%7C").replace(/\n/g, "")}>`;
+ return `<${url.replace(/[\\&]/g, "\\$&").replace(//g, "%3E").replace(/\|/g, "%7C").replace(/\n/g, "")}>`;
}
const ANCHOR_TAG = /^<\/?a[\s/>]/i;
@@ -382,13 +383,60 @@ export function markdownWithTableBreaks(markdown: string): string {
return transformNativeMarkdown(markdown, undefined, true);
}
+interface DestinationToken {
+ readonly start: { readonly offset: number };
+ readonly end: { readonly offset: number };
+}
+
+// Preserve Windows paths before Enriched's private AST parse, rewriting only parsed destinations.
+// Keep encoded runs and \&; treat other singleton punctuation escapes as separators.
+// Three backslashes preserve a separator plus its escape. For ambiguous raw \&,
+// encode the separator explicitly (\\\&) or use an HTML src attribute.
+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)));
}
export function markdownForNativeRendering(markdown: string, fileLink: RegExp): string {
// HTML wrappers can expose Markdown that wasn't in the first AST, so normalize them first.
- return transformNativeMarkdown(markdownWithHtml(markdown), fileLink, true);
+ return transformNativeMarkdown(
+ markdownWithWindowsPathDestinations(markdownWithHtml(markdown)),
+ fileLink,
+ true,
+ );
}
/** Native media slots only cover standalone images; nested file embeds must become links. */
diff --git a/apps/mobile/src/lib/markdownHtml.test.ts b/apps/mobile/src/lib/markdownHtml.test.ts
index e27cf812ed436..3da12e95d35c3 100644
--- a/apps/mobile/src/lib/markdownHtml.test.ts
+++ b/apps/mobile/src/lib/markdownHtml.test.ts
@@ -41,6 +41,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..76478fef2bae8
--- /dev/null
+++ b/apps/mobile/src/lib/markdownWindowsPaths.test.ts
@@ -0,0 +1,158 @@
+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 {
+ markdownForNativeRendering,
+ markdownWithFileEmbeds,
+ 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(markdownForNativeRendering(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.each([
+ { name: "JavaScript", parse: parseMarkdownDocument },
+ { name: "native", parse: nativeDocument },
+ ])(
+ "preserves linked HTML Windows images in tables through the $name parser",
+ async ({ parse }) => {
+ const document = await parse(
+ [
+ "| Preview | Status |",
+ "| - | - |",
+ String.raw`| 
| done |`,
+ ].join("\n"),
+ );
+
+ expect(destinations(document)).toEqual([
+ "https://example.com/?a=1%7C2",
+ String.raw`C:\me\.t3\©.png`,
+ String.raw`\\server\share\_shot.png`,
+ ]);
+ const table = document.children?.[0];
+ expect(document.children).toHaveLength(1);
+ expect(table?.type.toLowerCase()).toBe("table");
+ expect(table).toMatchObject({ children: [{}, {}] });
+ },
+ );
+
+ 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("preserves Windows destinations when turning file embeds into native links", async () => {
+ const paths = [String.raw`C:\repo\.t3\report.csv`, String.raw`\\server\share\_report.pdf`];
+ const source = `\n\n![ref][file]\n\n[file]: <${paths[1]}>`;
+ const content = markdownWithInlineFileLinks(
+ markdownWithWindowsPathDestinations(markdownWithHtml(source)),
+ ENRICHED_INLINE_FILE_LINK_REGEX,
+ );
+ const files = markdownWithFileEmbeds(content, () => "Report");
+ const document = await parseMarkdown(markdownWithTableBreaks(files.markdown));
+
+ expect(files.fileHrefs).toEqual(new Set(paths));
+ expect(destinations(document)).toEqual(paths);
+ expect(document.children?.flatMap((node) => node.children?.map((child) => child.type))).toEqual(
+ ["Link", "Link"],
+ );
+ });
+
+ 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 e3887fdb85967..78620d6542bd9 100644
--- a/apps/mobile/src/native/EnrichedMarkdownText.tsx
+++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx
@@ -10,6 +10,7 @@ import {
markdownWithHtml,
markdownWithInlineFileLinks,
markdownWithTableBreaks,
+ markdownWithWindowsPathDestinations,
} from "../lib/markdownDocument";
import { isValidElement, useCallback, useMemo, useState, type ReactNode } from "react";
import { Platform, Share, StyleSheet, View } from "react-native";
@@ -165,7 +166,7 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) {
};
}
const content = markdownWithInlineFileLinks(
- markdownWithHtml(props.markdown),
+ markdownWithWindowsPathDestinations(markdownWithHtml(props.markdown)),
ENRICHED_INLINE_FILE_LINK_REGEX,
);
const files = markdownWithFileEmbeds(content, fileEmbedLabel);