Skip to content
Draft
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
202 changes: 202 additions & 0 deletions frontend/app/element/markdown-link.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { openLink } from "@/app/store/global";
import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import type React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MarkdownLink } from "./markdown-link";

vi.mock("@/app/store/global", () => ({ openLink: vi.fn() }));
vi.mock("@/app/store/wshclientapi", () => ({ RpcApi: { FileJoinCommand: vi.fn() } }));
vi.mock("@/app/store/wshrpcutil", () => ({ TabRpcClient: {} }));

const localOpts: MarkdownResolveOpts = { connName: null, baseDir: "C:/docs/examples" };

function makeLink(href: string, resolveOpts = localOpts, onOpenFile = vi.fn(async (_path: string) => {})) {
const setFocusedHeading = vi.fn();
const link = MarkdownLink({
props: { href, children: "Example" },
resolveOpts,
onOpenFile,
setFocusedHeading,
});
const event = { preventDefault: vi.fn() } as unknown as React.MouseEvent;
return {
link,
onOpenFile,
setFocusedHeading,
event,
click: () => link.props.onClick(event),
};
}

describe("Markdown file links", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(RpcApi.FileJoinCommand).mockResolvedValue({ path: "C:/docs/examples/target" } as FileInfo);
});

afterEach(() => {
vi.restoreAllMocks();
});

it.each([
{ connName: null, baseDir: "C:/docs/examples", baseUri: "wsh://local/C:/docs/examples" },
{ connName: "local", baseDir: "/docs/examples", baseUri: "wsh://local//docs/examples" },
{ connName: "example-host", baseDir: "/docs/examples", baseUri: "wsh://example-host//docs/examples" },
{ connName: "wsl://Ubuntu", baseDir: "/docs/examples", baseUri: "wsh://wsl://Ubuntu//docs/examples" },
])(
"resolves MD/PDF/WAV links on $connName using the document directory",
async ({ connName, baseDir, baseUri }) => {
for (const href of ["chapter.md", "diagram.pdf", "sample.wav"]) {
vi.mocked(RpcApi.FileJoinCommand).mockClear();
const resolvedPath = `${baseDir}/${href}`;
vi.mocked(RpcApi.FileJoinCommand).mockResolvedValue({ path: resolvedPath } as FileInfo);
const { click, event, onOpenFile, setFocusedHeading } = makeLink(href, { connName, baseDir });
await click();
expect(event.preventDefault).toHaveBeenCalledOnce();
expect(RpcApi.FileJoinCommand).toHaveBeenCalledExactlyOnceWith(TabRpcClient, [baseUri, href]);
expect(onOpenFile).toHaveBeenCalledExactlyOnceWith(resolvedPath);
expect(openLink).not.toHaveBeenCalled();
expect(setFocusedHeading).not.toHaveBeenCalled();
}
}
);

it.each([
["../Other%20Notes.md", "../Other Notes.md"],
["./subfolder/chapter.md", "./subfolder/chapter.md"],
["../../chapter.md", "../../chapter.md"],
["folder/", "folder/"],
["/docs/chapter.md", "/docs/chapter.md"],
["~/chapter.md", "~/chapter.md"],
["notes%23draft%3F.md", "notes#draft?.md"],
["100%25.md", "100%.md"],
["literal%2520space.md", "literal%20space.md"],
["%E6%97%A5%E8%A8%98.md", "日記.md"],
["chapter.md#section", "chapter.md"],
["sample.wav?download=1#t=2", "sample.wav"],
])("passes the decoded path for %s to the connection-aware backend", async (href, path) => {
const { click, onOpenFile } = makeLink(href);
await click();
expect(RpcApi.FileJoinCommand).toHaveBeenCalledExactlyOnceWith(TabRpcClient, [
"wsh://local/C:/docs/examples",
path,
]);
expect(onOpenFile).toHaveBeenCalledExactlyOnceWith("C:/docs/examples/target");
expect(openLink).not.toHaveBeenCalled();
});

it("preserves same-document heading navigation", async () => {
const { click, setFocusedHeading, onOpenFile } = makeLink("#section");
await click();
expect(setFocusedHeading).toHaveBeenCalledExactlyOnceWith("#section");
expect(RpcApi.FileJoinCommand).not.toHaveBeenCalled();
expect(onOpenFile).not.toHaveBeenCalled();
expect(openLink).not.toHaveBeenCalled();
});

it.each([
"https://example.com/chapter.md#section",
"HTTP://example.com/",
"mailto:reader@example.com",
"tel:+123456789",
"file:///C:/docs/chapter.md",
"wsh://example-host/docs/chapter.md",
"custom+scheme://example/path",
"//example.com/chapter.md",
])("preserves existing external dispatch for %s", async (href) => {
const { click, onOpenFile, setFocusedHeading } = makeLink(href);
await click();
expect(openLink).toHaveBeenCalledExactlyOnceWith(href);
expect(RpcApi.FileJoinCommand).not.toHaveBeenCalled();
expect(onOpenFile).not.toHaveBeenCalled();
expect(setFocusedHeading).not.toHaveBeenCalled();
});

it.each([null, undefined, ""])("does not dispatch missing/empty href %s", async (href) => {
const { click, event, onOpenFile } = makeLink(href);
await click();
expect(event.preventDefault).toHaveBeenCalledOnce();
expect(openLink).not.toHaveBeenCalled();
expect(RpcApi.FileJoinCommand).not.toHaveBeenCalled();
expect(onOpenFile).not.toHaveBeenCalled();
});

it("leaves Markdown outside file previews on its existing route", async () => {
const { click, onOpenFile } = makeLink("chapter.md", null);
await click();
expect(openLink).toHaveBeenCalledExactlyOnceWith("chapter.md");
expect(RpcApi.FileJoinCommand).not.toHaveBeenCalled();
expect(onOpenFile).not.toHaveBeenCalled();
});

it("requires a preview navigation callback before resolving paths", async () => {
const link = MarkdownLink({
props: { href: "chapter.md" },
resolveOpts: localOpts,
setFocusedHeading: vi.fn(),
});
await link.props.onClick({ preventDefault: vi.fn() } as unknown as React.MouseEvent);
expect(openLink).toHaveBeenCalledExactlyOnceWith("chapter.md");
expect(RpcApi.FileJoinCommand).not.toHaveBeenCalled();
});

it.each(["bad%ZZ.md", "incomplete%.md"])("contains malformed encoding in %s without OS fallback", async (href) => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const { click, onOpenFile } = makeLink(href);
await click();
expect(warn).toHaveBeenCalledOnce();
expect(RpcApi.FileJoinCommand).not.toHaveBeenCalled();
expect(onOpenFile).not.toHaveBeenCalled();
expect(openLink).not.toHaveBeenCalled();
});

it("does not treat a query-only destination as a file", async () => {
const { click, onOpenFile } = makeLink("?download=1");
await click();
expect(RpcApi.FileJoinCommand).not.toHaveBeenCalled();
expect(onOpenFile).not.toHaveBeenCalled();
expect(openLink).not.toHaveBeenCalled();
});

it("contains backend resolution errors without external fallback", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const error = new Error("connection unavailable");
vi.mocked(RpcApi.FileJoinCommand).mockRejectedValue(error);
const { click, onOpenFile } = makeLink("chapter.md");
await click();
expect(warn).toHaveBeenCalledExactlyOnceWith("Failed to open Markdown file link:", "chapter.md", error);
expect(onOpenFile).not.toHaveBeenCalled();
expect(openLink).not.toHaveBeenCalled();
});

it("awaits and contains preview navigation errors", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const error = new Error("navigation unavailable");
const onOpenFile = vi.fn(async (_path: string) => {
throw error;
});
const { click } = makeLink("chapter.md", localOpts, onOpenFile);
await click();
expect(onOpenFile).toHaveBeenCalledOnce();
expect(warn).toHaveBeenCalledExactlyOnceWith("Failed to open Markdown file link:", "chapter.md", error);
expect(openLink).not.toHaveBeenCalled();
});

it("retains the original href and escapes link text in the rendered anchor", () => {
const markup = renderToStaticMarkup(
<MarkdownLink
props={{ href: "../Other%20Notes.md", children: "<script>Example</script>" }}
setFocusedHeading={vi.fn()}
/>
);
expect(markup).toContain('href="../Other%20Notes.md"');
expect(markup).toContain("&lt;script&gt;Example&lt;/script&gt;");
expect(RpcApi.FileJoinCommand).not.toHaveBeenCalled();
expect(openLink).not.toHaveBeenCalled();
});
});
51 changes: 51 additions & 0 deletions frontend/app/element/markdown-link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { formatRemoteUri } from "@/util/waveutil";
import type React from "react";
import { openLink } from "../store/global";

export const MarkdownLink = ({
setFocusedHeading,
props,
resolveOpts,
onOpenFile,
}: {
props: React.AnchorHTMLAttributes<HTMLAnchorElement>;
setFocusedHeading: (href: string) => void;
resolveOpts?: MarkdownResolveOpts;
onOpenFile?: (path: string) => Promise<void>;
}) => {
const onClick = async (e: React.MouseEvent) => {
e.preventDefault();
const href = props.href;
if (!href) {
return;
}
if (href.startsWith("#")) {
setFocusedHeading(href);
} else if (resolveOpts && onOpenFile && !/^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(href)) {
try {
// Remove URL suffixes before decoding, so escaped # and ? remain part of the filename.
const path = decodeURIComponent(href.split(/[?#]/, 1)[0]);
if (!path) {
return;
}
const baseDirUri = formatRemoteUri(resolveOpts.baseDir, resolveOpts.connName);
const fileInfo = await RpcApi.FileJoinCommand(TabRpcClient, [baseDirUri, path]);
await onOpenFile(fileInfo.path);
} catch (err) {
console.warn("Failed to open Markdown file link:", href, err);
}
} else {
openLink(href);
}
};
return (
<a href={props.href} onClick={onClick} className="text-accent hover:underline">
{props.children}
</a>
);
};
33 changes: 9 additions & 24 deletions frontend/app/element/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { CopyButton } from "@/app/element/copybutton";
import { createContentBlockPlugin } from "@/app/element/markdown-contentblock-plugin";
import { MarkdownLink } from "@/app/element/markdown-link";
import {
MarkdownContentBlockType,
resolveRemoteFile,
Expand All @@ -22,7 +23,6 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import rehypeSlug from "rehype-slug";
import RemarkFlexibleToc, { TocItem } from "remark-flexible-toc";
import remarkGfm from "remark-gfm";
import { openLink } from "../store/global";
import { IconButton } from "./iconbutton";
import "./markdown.scss";

Expand All @@ -38,28 +38,6 @@ const initializeMermaid = async () => {
}
};

const Link = ({
setFocusedHeading,
props,
}: {
props: React.AnchorHTMLAttributes<HTMLAnchorElement>;
setFocusedHeading: (href: string) => void;
}) => {
const onClick = (e: React.MouseEvent) => {
e.preventDefault();
if (props.href.startsWith("#")) {
setFocusedHeading(props.href);
} else {
openLink(props.href);
}
};
return (
<a href={props.href} onClick={onClick} className="text-accent hover:underline">
{props.children}
</a>
);
};

const Heading = ({ props, hnum }: { props: React.HTMLAttributes<HTMLHeadingElement>; hnum: number }) => {
return (
<div id={props.id} className={clsx("heading", `is-${hnum}`)}>
Expand Down Expand Up @@ -300,6 +278,7 @@ type MarkdownProps = {
contentClassName?: string;
onClickExecute?: (cmd: string) => void;
resolveOpts?: MarkdownResolveOpts;
onOpenFile?: (path: string) => Promise<void>;
scrollable?: boolean;
rehype?: boolean;
fontSizeOverride?: number;
Expand All @@ -314,6 +293,7 @@ const Markdown = ({
className,
contentClassName,
resolveOpts,
onOpenFile,
fontSizeOverride,
fixedFontSizeOverride,
scrollable = true,
Expand Down Expand Up @@ -349,7 +329,12 @@ const Markdown = ({

const markdownComponents: Partial<Components> = {
a: (props: React.HTMLAttributes<HTMLAnchorElement>) => (
<Link props={props} setFocusedHeading={setFocusedHeading} />
<MarkdownLink
props={props}
setFocusedHeading={setFocusedHeading}
resolveOpts={resolveOpts}
onOpenFile={onOpenFile}
/>
),
p: (props: React.HTMLAttributes<HTMLParagraphElement>) => <div className="paragraph" {...props} />,
h1: (props: React.HTMLAttributes<HTMLHeadingElement>) => <Heading props={props} hnum={1} />,
Expand Down
Loading