Skip to content
Closed
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
56 changes: 52 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 @@ -75,7 +76,7 @@ function attribute(tag: string, name: string): string | null {
}

function markdownDestination(url: string): string {
return `<${url.replace(/&/g, "\\&").replace(/</g, "%3C").replace(/>/g, "%3E").replace(/\|/g, "%7C").replace(/\n/g, "")}>`;
return `<${url.replace(/[\\&]/g, "\\$&").replace(/</g, "%3C").replace(/>/g, "%3E").replace(/\|/g, "%7C").replace(/\n/g, "")}>`;
}

const ANCHOR_TAG = /^<\/?a[\s/>]/i;
Expand Down Expand Up @@ -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. */
Expand Down
15 changes: 15 additions & 0 deletions apps/mobile/src/lib/markdownHtml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@ describe("HTML in native Markdown", () => {
expect(paragraph.children[0]).toMatchObject({ type: "image", alt: "&lt;" });
});

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&amp;copy;.png`, String.raw`C:\me\.t3\a&copy;.png`],
[String.raw`C:\me\.t3\&amp;copy;.png`, String.raw`C:\me\.t3\&copy;.png`],
]) {
expect(markdownImageSources(`<img src="${src}">`)).toEqual([path]);
}
});

it("repairs Windows Markdown destinations exposed by HTML wrappers", () => {
const path = String.raw`C:/me\.t3/shot.png`;
expect(markdownImageSources(`<details>\n\n![shot](${path})\n\n</details>`)).toEqual([path]);
});

it("ignores code delimiters in HTML attributes and comments when collecting screenshots", () => {
const markdown =
'<details data-example="`">\n<summary>Proof</summary>\n<img src="https://example.com/shot.png" alt="Screenshot">\n<!-- ` -->\n</details>';
Expand Down
158 changes: 158 additions & 0 deletions apps/mobile/src/lib/markdownWindowsPaths.test.ts
Original file line number Diff line number Diff line change
@@ -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`| <a href="https://example.com/?a=1&#124;2"><img src="C:\me\.t3\&amp;copy;.png"></a><br><img src="\\server\share\_shot.png"> | done |`,
].join("\n"),
);

expect(destinations(document)).toEqual([
"https://example.com/?a=1%7C2",
String.raw`C:\me\.t3\&copy;.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`<img src="C:\me\.t3\&amp;copy;.png">`,
"`" + String.raw`C:\repo\_private\a&copy;.ts` + "`",
].join("\n\n"),
);

expect(destinations(document)).toEqual([
String.raw`\\server\share\_shot.png`,
String.raw`C:\me\.t3\&copy;.png`,
String.raw`C:\repo\_private\a&copy;.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\&copy;.png`, String.raw`C:\me\a&copy;.png`],
[String.raw`C:\me\\\&copy;.png`, String.raw`C:\me\&copy;.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\&copy;&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);
});
});
3 changes: 2 additions & 1 deletion apps/mobile/src/native/EnrichedMarkdownText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
Loading