Skip to content

Commit 1a4f3c4

Browse files
t3dotggclaudeamv146
authored
fix(mobile): Windows image paths and non-ASCII file links open like desktop (#25802)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: amv146 <39172404+amv146@users.noreply.github.com>
1 parent 2202787 commit 1a4f3c4

7 files changed

Lines changed: 106 additions & 17 deletions

File tree

‎apps/mobile/src/lib/markdownDocument.ts‎

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { Root, RootContent } from "mdast";
2-
import { unified } from "unified";
2+
import { unified, type Processor } from "unified";
33
import remarkParse from "remark-parse";
44
import remarkGfm from "remark-gfm";
5+
import { isWindowsAbsolutePath } from "@t3tools/shared/path";
56

67
const parser = unified().use(remarkParse).use(remarkGfm);
78
export type MarkdownNode = Root | RootContent;
@@ -382,13 +383,62 @@ export function markdownWithTableBreaks(markdown: string): string {
382383
return transformNativeMarkdown(markdown, undefined, true);
383384
}
384385

386+
// `>` covers a destination continued on a blockquote line.
387+
const WINDOWS_DESTINATION = /\](?:\(|:)[\s>]*<?(?:[A-Za-z]:[\\/]|\\\\)/;
388+
389+
interface DestinationToken {
390+
readonly start: { readonly offset: number };
391+
readonly end: { readonly offset: number };
392+
}
393+
394+
/**
395+
* Markdown reads `C:\work\.t3` as `C:\work.t3`. Web keeps authored Windows destinations
396+
* literally (see `remarkKeepWindowsPaths` in shared), so escape their backslashes to give
397+
* both parsers here the same path. `\&` stays an escape, as `markdownWithHtml` emits it.
398+
* Expects authored Markdown: already escaped output would be escaped twice.
399+
*/
400+
function markdownWithWindowsPathDestinations(markdown: string): string {
401+
// Skips the extra parse for the common message that has no Windows destination.
402+
if (!WINDOWS_DESTINATION.test(markdown)) return markdown;
403+
const edits: MarkdownEdit[] = [];
404+
function record(
405+
this: { resume(): string; sliceSerialize(token: DestinationToken): string },
406+
token: DestinationToken,
407+
) {
408+
this.resume();
409+
const authored = this.sliceSerialize(token);
410+
if (!isWindowsAbsolutePath(authored) || !authored.includes("\\") || /[<>]/.test(authored))
411+
return;
412+
const start = token.start.offset;
413+
const end = token.end.offset;
414+
const escaped = authored.replace(/\\(?!&)/g, "\\\\");
415+
// A bare destination ends at an unescaped `)`, so wrap it to keep `\)` inside.
416+
const angled = markdown[start - 1] === "<";
417+
edits.push({ start, end, content: angled ? escaped : `<${escaped}>` });
418+
}
419+
unified()
420+
.use(remarkParse)
421+
.use(remarkGfm)
422+
.use(function (this: Processor) {
423+
(this.data().fromMarkdownExtensions ??= []).push({
424+
exit: { resourceDestinationString: record, definitionDestinationString: record },
425+
});
426+
})
427+
.parse(markdown);
428+
return applyMarkdownEdits(markdown, edits);
429+
}
430+
431+
// HTML wrappers can expose Markdown that wasn't in the first AST, so normalize them first.
432+
export function markdownForNativeParsing(markdown: string): string {
433+
return markdownWithWindowsPathDestinations(markdownWithHtml(markdown));
434+
}
435+
385436
export function parseMarkdownDocument(markdown: string): Root {
386-
return parser.parse(markdownWithHtml(markdown));
437+
return parser.parse(markdownForNativeParsing(markdown));
387438
}
388439

389440
export function markdownForNativeRendering(markdown: string, fileLink: RegExp): string {
390-
// HTML wrappers can expose Markdown that wasn't in the first AST, so normalize them first.
391-
return transformNativeMarkdown(markdownWithHtml(markdown), fileLink, true);
441+
return transformNativeMarkdown(markdownForNativeParsing(markdown), fileLink, true);
392442
}
393443

394444
/** Native media slots only cover standalone images; nested file embeds must become links. */

‎apps/mobile/src/lib/markdownHtml.test.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from "vite-plus/test";
22
import {
3+
markdownForNativeRendering,
34
markdownHtmlContent,
45
markdownWithHtml,
56
markdownWithFileEmbeds,
@@ -9,6 +10,7 @@ import {
910
import { markdownImageSources } from "./markdownImageSources";
1011
import { markdownFileEmbedLabel, resolveMarkdownMediaPreview } from "./markdownMedia";
1112
import { resolveMarkdownLinkPresentation } from "./markdownLinks";
13+
import { ENRICHED_INLINE_FILE_LINK_REGEX } from "./enrichedLinkPresentation";
1214
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
1315
import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown";
1416

@@ -296,3 +298,35 @@ describe("file embeds in native thread and file Markdown", () => {
296298
expect(converted.fileHrefs.has(href)).toBe(true);
297299
});
298300
});
301+
302+
describe("Windows paths in native Markdown", () => {
303+
// regression: #23722 Markdown escapes ate the separator before `.t3` and `_build`
304+
it("keeps backslashes in Windows image and link destinations", async () => {
305+
const image = String.raw`C:\work\.t3\_build\shot.png`;
306+
const link = String.raw`\\server\share\.t3\notes.md`;
307+
const markdown = [
308+
`![shot](${image} "Shot")`,
309+
String.raw`![paren](C:\a\)b.png)`,
310+
`[notes](<${link}>)`,
311+
`<img src="${image}">`,
312+
String.raw`![amp](C:\R&D\.t3\a&amp;b.png)`,
313+
String.raw`<img src="C:\R&amp;D\.t3\b.png">`,
314+
"> ![quoted](\n> " + image + ")",
315+
].join("\n\n");
316+
317+
const native = await parseMarkdown(
318+
markdownForNativeRendering(markdown, ENRICHED_INLINE_FILE_LINK_REGEX),
319+
);
320+
const urls: string[] = [];
321+
const collect = (node: { attributes?: { url?: string }; children?: unknown[] }) => {
322+
if (node.attributes?.url) urls.push(node.attributes.url);
323+
node.children?.forEach((child) => collect(child as typeof node));
324+
};
325+
collect(native);
326+
const paren = String.raw`C:\a\)b.png`;
327+
const amp = String.raw`C:\R&D\.t3\a&b.png`;
328+
const htmlAmp = String.raw`C:\R&D\.t3\b.png`;
329+
expect(urls).toEqual([image, paren, link, image, amp, htmlAmp, image]);
330+
expect(markdownImageSources(markdown)).toEqual([image, paren, amp, htmlAmp]);
331+
});
332+
});

‎apps/mobile/src/lib/markdownInlineFileLinks.test.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { ENRICHED_INLINE_FILE_LINK_REGEX } from "./enrichedLinkPresentation";
77
import {
88
markdownForNativeRendering,
99
markdownWithInlineFileLinks,
10-
parseMarkdownDocument,
1110
markdownWithHtml,
1211
} from "./markdownDocument";
1312

@@ -87,7 +86,7 @@ describe("inline code that names a file", () => {
8786

8887
it("links to the exact path when it holds backslashes or ampersands", () => {
8988
for (const path of ["C:\\repo\\_private\\file.ts", "./src/a&copy;.ts", "./src/[id].ts"]) {
90-
const paragraph = parseMarkdownDocument(rewrite(`\`${path}\``)).children[0];
89+
const paragraph = parser.parse(rewrite(`\`${path}\``)).children[0];
9190
const link = paragraph?.type === "paragraph" ? paragraph.children[0] : undefined;
9291
expect(link?.type === "link" && link.url).toBe(path);
9392
expect(

‎apps/mobile/src/lib/markdownLinks.ts‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -265,11 +265,7 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese
265265
}
266266
} catch {}
267267

268-
const target =
269-
parseMarkdownFileLink(normalized) ??
270-
(/^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+$/.test(normalized)
271-
? { path: normalized }
272-
: null);
268+
const target = parseMarkdownFileLink(normalized);
273269
if (target) {
274270
return {
275271
kind: "file",

‎apps/mobile/src/native/EnrichedMarkdownText.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
import {
88
markdownForNativeRendering,
99
markdownWithFileEmbeds,
10-
markdownWithHtml,
10+
markdownForNativeParsing,
1111
markdownWithInlineFileLinks,
1212
markdownWithTableBreaks,
1313
} from "../lib/markdownDocument";
@@ -165,7 +165,7 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) {
165165
};
166166
}
167167
const content = markdownWithInlineFileLinks(
168-
markdownWithHtml(props.markdown),
168+
markdownForNativeParsing(props.markdown),
169169
ENRICHED_INLINE_FILE_LINK_REGEX,
170170
);
171171
const files = markdownWithFileEmbeds(content, fileEmbedLabel);

‎packages/shared/src/markdownLinks.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,15 @@ describe("parseMarkdownFileLink", () => {
5454
expect(parseMarkdownFileLink("docs/My%20Folder/")?.path).toBe("docs/My Folder/");
5555
});
5656

57+
// regression: #16096 relative links with non-ASCII names open as files
58+
it("recognizes relative paths with non-ASCII names", () => {
59+
expect(parseMarkdownFileLink("pastexam/線性代數/exam_易志偉.pdf")?.path).toBe(
60+
"pastexam/線性代數/exam_易志偉.pdf",
61+
);
62+
expect(parseMarkdownFileLink("Résumé%20final.pdf")?.path).toBe("Résumé final.pdf");
63+
expect(parseMarkdownFileLink("資料.備份")?.path).toBe("資料.備份");
64+
});
65+
5766
it("accepts conventional extensionless names with or without a position", () => {
5867
expect(parseMarkdownFileLink("Makefile")).toEqual({ path: "Makefile" });
5968
expect(parseMarkdownFileLink("Dockerfile:8")).toEqual({ path: "Dockerfile", line: 8 });

‎packages/shared/src/markdownLinks.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@ import {
1414

1515
const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;
1616

17-
// The final segment is optional so an explicit folder link may end in `/`
18-
// (`patches/`, `docs/My Folder/`). A bare name with no slash still fails.
17+
// Names may use any script (`線性代數/考試.pdf`). The final segment is optional so an
18+
// explicit folder link may end in `/` (`patches/`, `docs/My Folder/`). A bare name with no
19+
// slash still fails.
1920
const RELATIVE_FILE_PATH_PATTERN =
20-
/^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2})?$/;
21+
/^(?:[\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;
2122

2223
const RELATIVE_FILE_NAME_PATTERN =
23-
/^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
24+
/^[\p{L}\p{M}\p{N}._-]+(?: +[\p{L}\p{M}\p{N}._-]+)*\.[\p{L}\p{M}\p{N}_-]+(?::\d+){0,2}$/u;
2425

2526
const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/;
2627

0 commit comments

Comments
 (0)