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&copy;.png`, String.raw`C:\me\.t3\a©.png`], + [String.raw`C:\me\.t3\&copy;.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![shot](${path})\n\n
`)).toEqual([path]); + }); + it("ignores code delimiters in HTML attributes and comments when collecting screenshots", () => { const markdown = '
\nProof\nScreenshot\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`![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"), + ); + + 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](C:\a\)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`![shot](<\\\\server\\share\\_shot.png>)`, + 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`![example](C:\Users\me\.t3\shot.png)` + "`", + `![real](${path})`, + ].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 = `![shot](${path})`; + + 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(`![shot](<${destination}>)`)).toEqual([path]); + } + }); + + it("leaves URLs, relative destinations, and literal Markdown examples alone", () => { + const markdown = [ + String.raw`![remote](https://example.com/a\_b.png?path=C:\repo\©&encoded=%5C#part)`, + String.raw`![file](file:///C:/me/a\_b.png) ![relative](.\a\_b.png) \*not\*`, + "`" + String.raw`![example](C:\me\.t3\shot.png)` + "`", + "```md\n" + String.raw`![example](\\server\share\_shot.png)` + "\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, ), ),