diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 985e943cb39..a61c6e25893 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -128,6 +128,10 @@ const highlightedCodeCache = new LRUCache( MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, ); +const WINDOWS_DRIVE_LINK_PROTOCOLS = Array.from( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", +); + function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null { const firstLineEnd = markdown.indexOf("\n", listItemStart); const firstLine = markdown.slice( @@ -147,7 +151,10 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { }, protocols: { ...defaultSchema.protocols, - href: [...(defaultSchema.protocols?.href ?? []), "file"], + // rehype-sanitize runs before react-markdown's urlTransform. A Windows + // drive letter looks like a protocol (`C:`), so allow drive letters here; + // urlTransform only preserves values with a drive-path shape afterward. + href: [...(defaultSchema.protocols?.href ?? []), "file", ...WINDOWS_DRIVE_LINK_PROTOCOLS], }, } satisfies Parameters[0]; diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 9fc29613867..cdc1cb799d3 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -21,17 +21,39 @@ describe("rewriteMarkdownFileUriHref", () => { }); it("normalizes file uri hrefs for windows drive paths", () => { + const canonicalHref = "/D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx#L69"; expect( rewriteMarkdownFileUriHref( "file:///D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx#L69", ), - ).toBe("D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx#L69"); + ).toBe(canonicalHref); + expect(rewriteMarkdownFileUriHref(canonicalHref)).toBe(canonicalHref); }); it("unwraps angle-bracketed file uri hrefs", () => { expect( rewriteMarkdownFileUriHref(" "), - ).toBe("D:/Programme/t3code/apps/web/src/markdown-links.ts"); + ).toBe("/D:/Programme/t3code/apps/web/src/markdown-links.ts"); + }); + + it("canonicalizes windows drive hrefs for the custom markdown file renderer", () => { + expect(rewriteMarkdownFileUriHref("C:/Users/mike/project/src/main.ts:42")).toBe( + "/C:/Users/mike/project/src/main.ts:42", + ); + expect(rewriteMarkdownFileUriHref("C:\\Users\\mike\\project\\src\\main.ts:42")).toBe( + "/C:/Users/mike/project/src/main.ts:42", + ); + expect(rewriteMarkdownFileUriHref("C:%5CUsers%5Cmike%5Cproject%5Csrc%5Cmain.ts:42")).toBe( + "/C:/Users/mike/project/src/main.ts:42", + ); + expect(rewriteMarkdownFileUriHref("C://Users/mike/project/src/main.ts:42")).toBe( + "/C:/Users/mike/project/src/main.ts:42", + ); + }); + + it("ignores non-file hrefs", () => { + expect(rewriteMarkdownFileUriHref("https://example.com/docs")).toBeNull(); + expect(rewriteMarkdownFileUriHref("x:command")).toBeNull(); }); }); diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index a6dba941b8a..8f299d14d0a 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -103,9 +103,18 @@ function parseFileUrlHref( export function rewriteMarkdownFileUriHref(href: string | undefined): string | null { if (!href) return null; const normalizedHref = normalizeMarkdownLinkDestination(href); + // Markdown parsing can encode backslashes or duplicate the drive separator. + const windowsDriveHref = normalizedHref + .replaceAll(/%5c/gi, "/") + .replaceAll(/\\/g, "/") + .replace(/^\/?([A-Za-z]):\/+/, "/$1:/"); + // A leading slash keeps the drive letter from being treated as a URL scheme. + if (/^\/[A-Za-z]:\//.test(windowsDriveHref)) return windowsDriveHref; + const target = parseFileUrlHref(normalizedHref, { decodePath: false }); if (!target) return null; - return `${target.path}${target.hash}`; + const path = WINDOWS_DRIVE_PATH_PATTERN.test(target.path) ? `/${target.path}` : target.path; + return `${path}${target.hash}`; } function looksLikePosixFilesystemPath(path: string): boolean {