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
14 changes: 6 additions & 8 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ import {
} from "../markdown-clipboard";
import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation";
import {
normalizeMarkdownLinkDestination,
normalizeMarkdownFileLinkHrefKey,
remarkRewriteWindowsFileLinks,
resolveInlineCodeFileLinkMeta,
resolveMarkdownFileLinkMeta,
rewriteMarkdownFileUriHref,
Expand Down Expand Up @@ -162,6 +163,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {

const CHAT_MARKDOWN_REMARK_PLUGINS = [
remarkGfm,
remarkRewriteWindowsFileLinks,
remarkGithubAlerts,
remarkNormalizeListItemIndentation,
remarkPreserveCodeMeta,
Expand All @@ -170,6 +172,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [

const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [
remarkGfm,
remarkRewriteWindowsFileLinks,
remarkGithubAlerts,
remarkNormalizeListItemIndentation,
remarkBreaks,
Expand Down Expand Up @@ -884,11 +887,6 @@ function extractMarkdownLinkHrefs(text: string): string[] {
return hrefs;
}

function normalizeMarkdownLinkHrefKey(href: string): string {
const normalizedHref = normalizeMarkdownLinkDestination(href);
return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref;
}

const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none";

/** Hosts whose favicon request already failed this session — skip straight to the globe. */
Expand Down Expand Up @@ -1349,7 +1347,7 @@ function ChatMarkdown({
NonNullable<ReturnType<typeof resolveMarkdownFileLinkMeta>>
>();
for (const href of extractMarkdownLinkHrefs(text)) {
const normalizedHref = normalizeMarkdownLinkHrefKey(href);
const normalizedHref = normalizeMarkdownFileLinkHrefKey(href);
if (metaByHref.has(normalizedHref)) continue;
const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd);
if (meta) {
Expand Down Expand Up @@ -1543,7 +1541,7 @@ function ChatMarkdown({
);
},
a({ node, href, children, ...props }) {
const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : "";
const normalizedHref = href ? normalizeMarkdownFileLinkHrefKey(href) : "";
const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null;
if (!fileLinkMeta) {
const faviconHost = resolveExternalWebLinkHost(href);
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/filePathDisplay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ describe("formatWorkspaceRelativePath", () => {
).toBe("t3code/apps/web/src/session-logic.ts:501");
});

it("keeps absolute windows paths outside the workspace unchanged", () => {
expect(
formatWorkspaceRelativePath(
"C:/Users/mike/dev-stuff/other-project/src/main.ts",
"C:/Users/mike/dev-stuff/t3code",
),
).toBe("C:/Users/mike/dev-stuff/other-project/src/main.ts");
});

it("keeps paths already rooted at the workspace label stable", () => {
expect(
formatWorkspaceRelativePath(
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/filePathDisplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ function stripRelativePrefixes(path: string): string {
return path.replace(/^\.\/+/, "").replace(/^\/+/, "");
}

function isAbsolutePath(path: string): boolean {
return path.startsWith("/") || /^[A-Za-z]:\//.test(path);
}

export function formatWorkspaceRelativePath(
pathWithPosition: string,
workspaceRoot: string | undefined,
Expand All @@ -44,7 +48,7 @@ export function formatWorkspaceRelativePath(
} else if (pathForCompare.startsWith(workspaceWithSeparator)) {
const relativeSuffix = normalizedPath.slice(normalizedWorkspaceRoot.length + 1);
displayPath = `${workspaceLabel}/${relativeSuffix}`;
} else if (!normalizedPath.startsWith("/")) {
} else if (!isAbsolutePath(normalizedPath)) {
const relativePath = stripRelativePrefixes(normalizedPath);
displayPath = pathForCompare.startsWith(workspaceLabelWithSeparator)
? normalizedPath
Expand Down
56 changes: 56 additions & 0 deletions apps/web/src/markdown-links-rendering.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { renderToStaticMarkup } from "react-dom/server";
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import { describe, expect, it } from "vite-plus/test";

import { remarkRewriteWindowsFileLinks, rewriteMarkdownFileUriHref } from "./markdown-links";

const sanitizeSchema = {
...defaultSchema,
protocols: {
...defaultSchema.protocols,
href: [...(defaultSchema.protocols?.href ?? []), "file"],
},
} satisfies Parameters<typeof rehypeSanitize>[0];

function renderMarkdown(markdown: string): string {
return renderToStaticMarkup(
<ReactMarkdown
remarkPlugins={[remarkRewriteWindowsFileLinks]}
rehypePlugins={[[rehypeSanitize, sanitizeSchema]]}
urlTransform={(href) => rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href)}
>
{markdown}
</ReactMarkdown>,
);
}

describe("Windows markdown file link rendering", () => {
it("preserves a drive-path href through HTML sanitization", () => {
const path = "C:/Users/mike/dev-stuff/t3code/apps/web/src/markdown-links.ts";

expect(renderMarkdown(`[markdown-links.ts](${path}) is open above.`)).toContain(
`<a href="${path}">markdown-links.ts</a>`,
);
});

it("canonicalizes a backslash drive-path href through HTML sanitization", () => {
const path = "C:\\Users\\mike\\dev-stuff\\t3code\\apps\\web\\src\\markdown-links.ts";

expect(renderMarkdown(`[markdown-links.ts](${path}) is open above.`)).toContain(
'<a href="C:/Users/mike/dev-stuff/t3code/apps/web/src/markdown-links.ts">markdown-links.ts</a>',
);
});

it("percent-encodes a unicode drive-path href through HTML sanitization", () => {
const path = "C:/Users/mike/dev-stuff/文档/apps/web/src/markdown-links.ts";

expect(renderMarkdown(`[markdown-links.ts](${path}) is open above.`)).toContain(
'<a href="C:/Users/mike/dev-stuff/%E6%96%87%E6%A1%A3/apps/web/src/markdown-links.ts">markdown-links.ts</a>',
);
});

it("still removes unsafe schemes", () => {
expect(renderMarkdown("[unsafe](javascript:alert(1))")).not.toContain("href=");
});
});
41 changes: 41 additions & 0 deletions apps/web/src/markdown-links.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,45 @@
import { describe, expect, it } from "vite-plus/test";

import {
normalizeMarkdownFileLinkHrefKey,
normalizeMarkdownLinkDestination,
resolveInlineCodeFileLinkMeta,
resolveMarkdownFileLinkMeta,
resolveMarkdownFileLinkTarget,
rewriteMarkdownFileUriHref,
} from "./markdown-links";

describe("normalizeMarkdownLinkDestination", () => {
it("canonicalizes backslashes in windows drive paths", () => {
expect(
normalizeMarkdownLinkDestination(
"C:\\Users\\mike\\dev-stuff\\t3code\\apps\\web\\src\\markdown-links.ts",
),
).toBe("C:/Users/mike/dev-stuff/t3code/apps/web/src/markdown-links.ts");
});
});

describe("normalizeMarkdownFileLinkHrefKey", () => {
it("uses the same key for raw and encoded unicode drive paths", () => {
const encodedPath = "C:/Users/mike/dev-stuff/%E6%96%87%E6%A1%A3/apps/web/src/markdown-links.ts";

expect(
normalizeMarkdownFileLinkHrefKey(
"C:\\Users\\mike\\dev-stuff\\文档\\apps\\web\\src\\markdown-links.ts",
),
).toBe(encodedPath);
expect(normalizeMarkdownFileLinkHrefKey(encodedPath)).toBe(encodedPath);
});

it("preserves existing encoded octets", () => {
expect(
normalizeMarkdownFileLinkHrefKey(
"C:/Users/mike/dev-stuff/t3code/apps/web/src/file%2520name.ts",
),
).toBe("C:/Users/mike/dev-stuff/t3code/apps/web/src/file%2520name.ts");
});
});

describe("rewriteMarkdownFileUriHref", () => {
it("rewrites file uri hrefs into direct path hrefs", () => {
expect(rewriteMarkdownFileUriHref("file:///Users/julius/project/src/main.ts#L42")).toBe(
Expand Down Expand Up @@ -36,6 +69,14 @@ describe("rewriteMarkdownFileUriHref", () => {
});

describe("resolveMarkdownFileLinkTarget", () => {
it("resolves windows drive paths", () => {
expect(
resolveMarkdownFileLinkTarget(
"C:/Users/mike/dev-stuff/t3code/apps/web/src/markdown-links.ts",
),
).toBe("C:/Users/mike/dev-stuff/t3code/apps/web/src/markdown-links.ts");
});

it("resolves absolute posix file paths", () => {
expect(resolveMarkdownFileLinkTarget("/Users/julius/project/AGENTS.md")).toBe(
"/Users/julius/project/AGENTS.md",
Expand Down
42 changes: 41 additions & 1 deletion apps/web/src/markdown-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@ function unwrapMarkdownLinkDestination(value: string): string {
}

export function normalizeMarkdownLinkDestination(value: string): string {
return unwrapMarkdownLinkDestination(value.trim());
const normalizedValue = unwrapMarkdownLinkDestination(value.trim());
return WINDOWS_DRIVE_PATH_PATTERN.test(normalizedValue)
? normalizedValue.replaceAll("\\", "/")
: normalizedValue;
}

function stripSearchAndHash(value: string): { path: string; hash: string } {
Expand Down Expand Up @@ -108,6 +111,43 @@ export function rewriteMarkdownFileUriHref(href: string | undefined): string | n
return `${target.path}${target.hash}`;
}

export function normalizeMarkdownFileLinkHrefKey(href: string): string {
const normalizedHref = normalizeMarkdownLinkDestination(href);
const rewrittenHref = rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref;
if (!WINDOWS_DRIVE_PATH_PATTERN.test(rewrittenHref)) return rewrittenHref;

const target = parseFileUrlHref(`file:///${rewrittenHref}`, { decodePath: false });
return target ? `${target.path}${target.hash}` : rewrittenHref;
}

interface MarkdownLinkNode {
type?: string;
url?: unknown;
children?: MarkdownLinkNode[];
}

/**
* rehype-sanitize reads the drive letter in `C:/path` as a URL scheme and
* removes the href before react-markdown's URL transform can normalize it.
* Convert only Windows drive-path destinations into the already-allowed file
* URI form while the document is still mdast.
*/
export function remarkRewriteWindowsFileLinks() {
return (tree: MarkdownLinkNode) => {
const visit = (node: MarkdownLinkNode) => {
if ((node.type === "link" || node.type === "definition") && typeof node.url === "string") {
const normalizedUrl = normalizeMarkdownLinkDestination(node.url);
if (WINDOWS_DRIVE_PATH_PATTERN.test(normalizedUrl)) {
node.url = `file:///${normalizedUrl}`;
Comment thread
cursor[bot] marked this conversation as resolved.
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
node.children?.forEach(visit);
};

visit(tree);
};
}

function looksLikePosixFilesystemPath(path: string): boolean {
if (!path.startsWith("/")) return false;
if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true;
Expand Down
Loading