From a1bc5f02c01650073f1feae20b076e80dba3a3cf Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 26 Oct 2026 23:43:12 -0700 Subject: [PATCH 1/3] fix(mobile): Windows Markdown image paths and non-ASCII file links resolve on mobile Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/markdownDocument.ts | 52 +++++++++++++++++-- apps/mobile/src/lib/markdownHtml.test.ts | 28 ++++++++++ .../src/lib/markdownInlineFileLinks.test.ts | 3 +- .../src/native/EnrichedMarkdownText.tsx | 4 +- packages/shared/src/markdownLinks.test.ts | 8 +++ packages/shared/src/markdownLinks.ts | 9 ++-- 6 files changed, 92 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/lib/markdownDocument.ts b/apps/mobile/src/lib/markdownDocument.ts index af8fe2d02a15f..e3c01638ef4a0 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; @@ -382,13 +383,56 @@ export function markdownWithTableBreaks(markdown: string): string { return transformNativeMarkdown(markdown, undefined, true); } +interface DestinationToken { + readonly start: { readonly offset: number }; + readonly end: { readonly offset: number }; +} + +/** + * Markdown reads `C:\work\.t3` as `C:\work.t3`. Web keeps authored Windows destinations + * literally (see `remarkKeepWindowsPaths` in shared), so escape their backslashes to give + * both parsers here the same path. Like web, a destination with `&` keeps standard parsing. + */ +function markdownWithWindowsPathDestinations(markdown: string): string { + if (!markdown.includes("\\")) return markdown; + const edits: MarkdownEdit[] = []; + function record( + this: { resume(): string; sliceSerialize(token: DestinationToken): string }, + token: DestinationToken, + ) { + this.resume(); + const authored = this.sliceSerialize(token); + if (!isWindowsAbsolutePath(authored) || !authored.includes("\\") || /[&<>]/.test(authored)) + return; + const { start, end } = { start: token.start.offset, end: token.end.offset }; + const escaped = authored.replaceAll("\\", "\\\\"); + // A bare destination ends at an unescaped `)`, so wrap it to keep `\)` inside. + const angled = markdown[start - 1] === "<"; + edits.push({ start, end, content: angled ? escaped : `<${escaped}>` }); + } + unified() + .use(remarkParse) + .use(remarkGfm) + .use(function (this: Processor) { + (this.data().fromMarkdownExtensions ??= []).push({ + exit: { resourceDestinationString: record, definitionDestinationString: record }, + }); + }) + .parse(markdown); + return applyMarkdownEdits(markdown, edits); +} + +// HTML wrappers can expose Markdown that wasn't in the first AST, so normalize them first. +export function markdownForNativeParsing(markdown: string): string { + return markdownWithWindowsPathDestinations(markdownWithHtml(markdown)); +} + export function parseMarkdownDocument(markdown: string): Root { - return parser.parse(markdownWithHtml(markdown)); + return parser.parse(markdownForNativeParsing(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(markdownForNativeParsing(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..9073e21d7d826 100644 --- a/apps/mobile/src/lib/markdownHtml.test.ts +++ b/apps/mobile/src/lib/markdownHtml.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import { + markdownForNativeRendering, markdownHtmlContent, markdownWithHtml, markdownWithFileEmbeds, @@ -9,6 +10,7 @@ import { import { markdownImageSources } from "./markdownImageSources"; import { markdownFileEmbedLabel, resolveMarkdownMediaPreview } from "./markdownMedia"; import { resolveMarkdownLinkPresentation } from "./markdownLinks"; +import { ENRICHED_INLINE_FILE_LINK_REGEX } from "./enrichedLinkPresentation"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown"; @@ -296,3 +298,29 @@ describe("file embeds in native thread and file Markdown", () => { expect(converted.fileHrefs.has(href)).toBe(true); }); }); + +describe("Windows paths in native Markdown", () => { + // regression: #23722 Markdown escapes ate the separator before `.t3` and `_build` + it("keeps backslashes in Windows image and link destinations", async () => { + const image = String.raw`C:\work\.t3\_build\shot.png`; + const link = String.raw`\\server\share\.t3\notes.md`; + const markdown = [ + `![shot](${image} "Shot")`, + String.raw`![paren](C:\a\)b.png)`, + `[notes](<${link}>)`, + ``, + ].join("\n\n"); + + const native = await parseMarkdown( + markdownForNativeRendering(markdown, ENRICHED_INLINE_FILE_LINK_REGEX), + ); + const urls: string[] = []; + const collect = (node: { attributes?: { url?: string }; children?: unknown[] }) => { + if (node.attributes?.url) urls.push(node.attributes.url); + node.children?.forEach((child) => collect(child as typeof node)); + }; + collect(native); + expect(urls).toEqual([image, String.raw`C:\a\)b.png`, link, image]); + expect(markdownImageSources(markdown)).toEqual([image, String.raw`C:\a\)b.png`]); + }); +}); diff --git a/apps/mobile/src/lib/markdownInlineFileLinks.test.ts b/apps/mobile/src/lib/markdownInlineFileLinks.test.ts index 540ce6b89012a..a790fea7efc0a 100644 --- a/apps/mobile/src/lib/markdownInlineFileLinks.test.ts +++ b/apps/mobile/src/lib/markdownInlineFileLinks.test.ts @@ -7,7 +7,6 @@ import { ENRICHED_INLINE_FILE_LINK_REGEX } from "./enrichedLinkPresentation"; import { markdownForNativeRendering, markdownWithInlineFileLinks, - parseMarkdownDocument, markdownWithHtml, } from "./markdownDocument"; @@ -87,7 +86,7 @@ describe("inline code that names a file", () => { it("links to the exact path when it holds backslashes or ampersands", () => { for (const path of ["C:\\repo\\_private\\file.ts", "./src/a©.ts", "./src/[id].ts"]) { - const paragraph = parseMarkdownDocument(rewrite(`\`${path}\``)).children[0]; + const paragraph = parser.parse(rewrite(`\`${path}\``)).children[0]; const link = paragraph?.type === "paragraph" ? paragraph.children[0] : undefined; expect(link?.type === "link" && link.url).toBe(path); expect( diff --git a/apps/mobile/src/native/EnrichedMarkdownText.tsx b/apps/mobile/src/native/EnrichedMarkdownText.tsx index e3887fdb85967..429b1595aa198 100644 --- a/apps/mobile/src/native/EnrichedMarkdownText.tsx +++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx @@ -7,7 +7,7 @@ import { import { markdownForNativeRendering, markdownWithFileEmbeds, - markdownWithHtml, + markdownForNativeParsing, markdownWithInlineFileLinks, markdownWithTableBreaks, } from "../lib/markdownDocument"; @@ -165,7 +165,7 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) { }; } const content = markdownWithInlineFileLinks( - markdownWithHtml(props.markdown), + markdownForNativeParsing(props.markdown), ENRICHED_INLINE_FILE_LINK_REGEX, ); const files = markdownWithFileEmbeds(content, fileEmbedLabel); diff --git a/packages/shared/src/markdownLinks.test.ts b/packages/shared/src/markdownLinks.test.ts index 8693a8d7af4e3..75c83c4362ae3 100644 --- a/packages/shared/src/markdownLinks.test.ts +++ b/packages/shared/src/markdownLinks.test.ts @@ -54,6 +54,14 @@ describe("parseMarkdownFileLink", () => { expect(parseMarkdownFileLink("docs/My%20Folder/")?.path).toBe("docs/My Folder/"); }); + // regression: #16096 relative links with non-ASCII names open as files + it("recognizes relative paths with non-ASCII names", () => { + expect(parseMarkdownFileLink("pastexam/線性代數/exam_易志偉.pdf")?.path).toBe( + "pastexam/線性代數/exam_易志偉.pdf", + ); + expect(parseMarkdownFileLink("Résumé%20final.pdf")?.path).toBe("Résumé final.pdf"); + }); + it("accepts conventional extensionless names with or without a position", () => { expect(parseMarkdownFileLink("Makefile")).toEqual({ path: "Makefile" }); expect(parseMarkdownFileLink("Dockerfile:8")).toEqual({ path: "Dockerfile", line: 8 }); diff --git a/packages/shared/src/markdownLinks.ts b/packages/shared/src/markdownLinks.ts index 310128e64de05..c50310468430e 100644 --- a/packages/shared/src/markdownLinks.ts +++ b/packages/shared/src/markdownLinks.ts @@ -14,13 +14,14 @@ import { const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; -// The final segment is optional so an explicit folder link may end in `/` -// (`patches/`, `docs/My Folder/`). A bare name with no slash still fails. +// Names may use any script (`線性代數/考試.pdf`). The final segment is optional so an +// explicit folder link may end in `/` (`patches/`, `docs/My Folder/`). A bare name with no +// slash still fails. const RELATIVE_FILE_PATH_PATTERN = - /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2})?$/; + /^(?:[\p{L}\p{M}\p{N}._-]+(?: +[\p{L}\p{M}\p{N}._-]+)*\/)+(?:[\p{L}\p{M}\p{N}._-]+(?: +[\p{L}\p{M}\p{N}._-]+)*(?::\d+){0,2})?$/u; const RELATIVE_FILE_NAME_PATTERN = - /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; + /^[\p{L}\p{M}\p{N}._-]+(?: +[\p{L}\p{M}\p{N}._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/u; const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/; From b84e453c5f7ba9da3fc2fc63ea060b99b6cd2032 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 26 Oct 2026 23:54:53 -0700 Subject: [PATCH 2/3] fix(mobile): only reparse messages with Windows destinations; Unicode file extensions Co-authored-by: amv146 <39172404+amv146@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/markdownDocument.ts | 9 +++++++-- apps/mobile/src/lib/markdownLinks.ts | 6 +----- packages/shared/src/markdownLinks.test.ts | 1 + packages/shared/src/markdownLinks.ts | 2 +- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/lib/markdownDocument.ts b/apps/mobile/src/lib/markdownDocument.ts index e3c01638ef4a0..1509d3352e948 100644 --- a/apps/mobile/src/lib/markdownDocument.ts +++ b/apps/mobile/src/lib/markdownDocument.ts @@ -383,6 +383,8 @@ export function markdownWithTableBreaks(markdown: string): string { return transformNativeMarkdown(markdown, undefined, true); } +const WINDOWS_DESTINATION = /\](?:\(|:)\s*]/.test(authored)) return; - const { start, end } = { start: token.start.offset, end: token.end.offset }; + const start = token.start.offset; + const end = token.end.offset; const escaped = authored.replaceAll("\\", "\\\\"); // A bare destination ends at an unescaped `)`, so wrap it to keep `\)` inside. const angled = markdown[start - 1] === "<"; diff --git a/apps/mobile/src/lib/markdownLinks.ts b/apps/mobile/src/lib/markdownLinks.ts index 8020c1de5b13a..55030cf03b859 100644 --- a/apps/mobile/src/lib/markdownLinks.ts +++ b/apps/mobile/src/lib/markdownLinks.ts @@ -265,11 +265,7 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese } } catch {} - const target = - parseMarkdownFileLink(normalized) ?? - (/^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+$/.test(normalized) - ? { path: normalized } - : null); + const target = parseMarkdownFileLink(normalized); if (target) { return { kind: "file", diff --git a/packages/shared/src/markdownLinks.test.ts b/packages/shared/src/markdownLinks.test.ts index 75c83c4362ae3..5337700871637 100644 --- a/packages/shared/src/markdownLinks.test.ts +++ b/packages/shared/src/markdownLinks.test.ts @@ -60,6 +60,7 @@ describe("parseMarkdownFileLink", () => { "pastexam/線性代數/exam_易志偉.pdf", ); expect(parseMarkdownFileLink("Résumé%20final.pdf")?.path).toBe("Résumé final.pdf"); + expect(parseMarkdownFileLink("資料.備份")?.path).toBe("資料.備份"); }); it("accepts conventional extensionless names with or without a position", () => { diff --git a/packages/shared/src/markdownLinks.ts b/packages/shared/src/markdownLinks.ts index c50310468430e..0445fa42d8a0a 100644 --- a/packages/shared/src/markdownLinks.ts +++ b/packages/shared/src/markdownLinks.ts @@ -21,7 +21,7 @@ const RELATIVE_FILE_PATH_PATTERN = /^(?:[\p{L}\p{M}\p{N}._-]+(?: +[\p{L}\p{M}\p{N}._-]+)*\/)+(?:[\p{L}\p{M}\p{N}._-]+(?: +[\p{L}\p{M}\p{N}._-]+)*(?::\d+){0,2})?$/u; const RELATIVE_FILE_NAME_PATTERN = - /^[\p{L}\p{M}\p{N}._-]+(?: +[\p{L}\p{M}\p{N}._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/u; + /^[\p{L}\p{M}\p{N}._-]+(?: +[\p{L}\p{M}\p{N}._-]+)*\.[\p{L}\p{M}\p{N}_-]+(?::\d+){0,2}$/u; const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/; From 23121d95c41e8ddcbdf789b6d34faa4edababa06 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 27 Oct 2026 00:01:37 -0700 Subject: [PATCH 3/3] fix(mobile): keep Windows separators next to ampersands and in quoted destinations Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/markdownDocument.ts | 9 +++++---- apps/mobile/src/lib/markdownHtml.test.ts | 10 ++++++++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/lib/markdownDocument.ts b/apps/mobile/src/lib/markdownDocument.ts index 1509d3352e948..7f3e2b7d77ef4 100644 --- a/apps/mobile/src/lib/markdownDocument.ts +++ b/apps/mobile/src/lib/markdownDocument.ts @@ -383,7 +383,8 @@ export function markdownWithTableBreaks(markdown: string): string { return transformNativeMarkdown(markdown, undefined, true); } -const WINDOWS_DESTINATION = /\](?:\(|:)\s*` covers a destination continued on a blockquote line. +const WINDOWS_DESTINATION = /\](?:\(|:)[\s>]*]/.test(authored)) + if (!isWindowsAbsolutePath(authored) || !authored.includes("\\") || /[<>]/.test(authored)) return; const start = token.start.offset; const end = token.end.offset; - const escaped = authored.replaceAll("\\", "\\\\"); + const escaped = authored.replace(/\\(?!&)/g, "\\\\"); // A bare destination ends at an unescaped `)`, so wrap it to keep `\)` inside. const angled = markdown[start - 1] === "<"; edits.push({ start, end, content: angled ? escaped : `<${escaped}>` }); diff --git a/apps/mobile/src/lib/markdownHtml.test.ts b/apps/mobile/src/lib/markdownHtml.test.ts index 9073e21d7d826..7c6a5e4bccb36 100644 --- a/apps/mobile/src/lib/markdownHtml.test.ts +++ b/apps/mobile/src/lib/markdownHtml.test.ts @@ -309,6 +309,9 @@ describe("Windows paths in native Markdown", () => { String.raw`![paren](C:\a\)b.png)`, `[notes](<${link}>)`, ``, + String.raw`![amp](C:\R&D\.t3\a&b.png)`, + String.raw``, + "> ![quoted](\n> " + image + ")", ].join("\n\n"); const native = await parseMarkdown( @@ -320,7 +323,10 @@ describe("Windows paths in native Markdown", () => { node.children?.forEach((child) => collect(child as typeof node)); }; collect(native); - expect(urls).toEqual([image, String.raw`C:\a\)b.png`, link, image]); - expect(markdownImageSources(markdown)).toEqual([image, String.raw`C:\a\)b.png`]); + const paren = String.raw`C:\a\)b.png`; + const amp = String.raw`C:\R&D\.t3\a&b.png`; + const htmlAmp = String.raw`C:\R&D\.t3\b.png`; + expect(urls).toEqual([image, paren, link, image, amp, htmlAmp, image]); + expect(markdownImageSources(markdown)).toEqual([image, paren, amp, htmlAmp]); }); });