diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 8dc545d31eb0..3bc55726c831 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -74,7 +74,8 @@ import { } from "../markdown-clipboard"; import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { - normalizeMarkdownLinkDestination, + normalizeMarkdownFileLinkHrefKey, + remarkRewriteWindowsFileLinks, resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, @@ -162,6 +163,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { const CHAT_MARKDOWN_REMARK_PLUGINS = [ remarkGfm, + remarkRewriteWindowsFileLinks, remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkPreserveCodeMeta, @@ -170,6 +172,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkGfm, + remarkRewriteWindowsFileLinks, remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkBreaks, @@ -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. */ @@ -1349,7 +1347,7 @@ function ChatMarkdown({ NonNullable> >(); 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) { @@ -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); diff --git a/apps/web/src/filePathDisplay.test.ts b/apps/web/src/filePathDisplay.test.ts index ecceea09ca1e..ca2287150dfa 100644 --- a/apps/web/src/filePathDisplay.test.ts +++ b/apps/web/src/filePathDisplay.test.ts @@ -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( diff --git a/apps/web/src/filePathDisplay.ts b/apps/web/src/filePathDisplay.ts index 5a6e2a02e100..0d785fb6f08e 100644 --- a/apps/web/src/filePathDisplay.ts +++ b/apps/web/src/filePathDisplay.ts @@ -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, @@ -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 diff --git a/apps/web/src/markdown-links-rendering.test.tsx b/apps/web/src/markdown-links-rendering.test.tsx new file mode 100644 index 000000000000..be7c6f65d5ee --- /dev/null +++ b/apps/web/src/markdown-links-rendering.test.tsx @@ -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[0]; + +function renderMarkdown(markdown: string): string { + return renderToStaticMarkup( + rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href)} + > + {markdown} + , + ); +} + +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( + `markdown-links.ts`, + ); + }); + + 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( + 'markdown-links.ts', + ); + }); + + 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( + 'markdown-links.ts', + ); + }); + + it("still removes unsafe schemes", () => { + expect(renderMarkdown("[unsafe](javascript:alert(1))")).not.toContain("href="); + }); +}); diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 9fc296138672..8d6c19991fa4 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -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( @@ -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", diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index a6dba941b8ac..f6bab1474b9c 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -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 } { @@ -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}`; + } + } + 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;