Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 54 additions & 4 deletions apps/mobile/src/lib/markdownDocument.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -382,13 +383,62 @@ export function markdownWithTableBreaks(markdown: string): string {
return transformNativeMarkdown(markdown, undefined, true);
}

// `>` covers a destination continued on a blockquote line.
const WINDOWS_DESTINATION = /\](?:\(|:)[\s>]*<?(?:[A-Za-z]:[\\/]|\\\\)/;

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. `\&` stays an escape, as `markdownWithHtml` emits it.
* Expects authored Markdown: already escaped output would be escaped twice.
*/
function markdownWithWindowsPathDestinations(markdown: string): string {
// Skips the extra parse for the common message that has no Windows destination.
if (!WINDOWS_DESTINATION.test(markdown)) 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 = token.start.offset;
const end = token.end.offset;
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}>` });
}
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. */
Expand Down
34 changes: 34 additions & 0 deletions apps/mobile/src/lib/markdownHtml.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vite-plus/test";
import {
markdownForNativeRendering,
markdownHtmlContent,
markdownWithHtml,
markdownWithFileEmbeds,
Expand All @@ -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";

Expand Down Expand Up @@ -296,3 +298,35 @@ 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}>)`,
`<img src="${image}">`,
String.raw`![amp](C:\R&D\.t3\a&amp;b.png)`,
String.raw`<img src="C:\R&amp;D\.t3\b.png">`,
"> ![quoted](\n> " + image + ")",
].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);
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]);
});
});
3 changes: 1 addition & 2 deletions apps/mobile/src/lib/markdownInlineFileLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { ENRICHED_INLINE_FILE_LINK_REGEX } from "./enrichedLinkPresentation";
import {
markdownForNativeRendering,
markdownWithInlineFileLinks,
parseMarkdownDocument,
markdownWithHtml,
} from "./markdownDocument";

Expand Down Expand Up @@ -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&copy;.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(
Expand Down
6 changes: 1 addition & 5 deletions apps/mobile/src/lib/markdownLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/native/EnrichedMarkdownText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
import {
markdownForNativeRendering,
markdownWithFileEmbeds,
markdownWithHtml,
markdownForNativeParsing,
markdownWithInlineFileLinks,
markdownWithTableBreaks,
} from "../lib/markdownDocument";
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions packages/shared/src/markdownLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ 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");
expect(parseMarkdownFileLink("資料.備份")?.path).toBe("資料.備份");
});

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 });
Expand Down
9 changes: 5 additions & 4 deletions packages/shared/src/markdownLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}._-]+)*\.[\p{L}\p{M}\p{N}_-]+(?::\d+){0,2}$/u;

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

Expand Down
Loading