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&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..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`![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.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`![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("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 = `![report](<${paths[0]}>)\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`![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 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);