diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index c85340c715b..48ad0e189e7 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -64,6 +64,7 @@ import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation import { normalizeMarkdownLinkDestination, resolveMarkdownFileLinkMeta, + resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref, } from "../markdown-links"; import { readLocalApi } from "../localApi"; @@ -122,6 +123,8 @@ const EMPTY_MARKDOWN_SKILLS: ReadonlyArray attribute !== "title"), + "*": [ + ...(defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), + FILE_LINK_PARENT_SUFFIX_PROPERTY, + ], code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta"], }, protocols: { @@ -222,6 +228,7 @@ function extractPreCodeMeta(node: unknown): string | undefined { type MarkdownAstNode = { type?: string; + url?: string; meta?: unknown; data?: { hProperties?: Record; @@ -747,7 +754,6 @@ interface MarkdownFileLinkProps { className?: string | undefined; } -const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g; const MARKDOWN_FILE_LINK_CLASS_NAME = "chat-markdown-file-link cursor-pointer transition-colors hover:bg-accent/70"; @@ -811,19 +817,42 @@ function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray): Map< return suffixByPath; } -function extractMarkdownLinkHrefs(text: string): string[] { - const hrefs: string[] = []; - for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) { - const href = match[1]?.trim(); - if (!href) continue; - hrefs.push(href); - } - return hrefs; -} +function createFileLinkDisambiguationPlugin(cwd: string | undefined) { + return () => (tree: MarkdownAstNode) => { + const fileLinks: Array<{ + node: MarkdownAstNode; + meta: NonNullable>; + }> = []; + + const visit = (node: MarkdownAstNode) => { + if (node.type === "link" && typeof node.url === "string") { + const meta = resolveMarkdownFileLinkMeta(node.url, cwd); + if (meta) { + fileLinks.push({ node, meta }); + } + } + for (const child of node.children ?? []) { + visit(child); + } + }; -function normalizeMarkdownLinkHrefKey(href: string): string { - const normalizedHref = normalizeMarkdownLinkDestination(href); - return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; + visit(tree); + + const suffixByPath = buildFileLinkParentSuffixByPath( + fileLinks.map((fileLink) => fileLink.meta.filePath), + ); + for (const { node, meta } of fileLinks) { + const parentSuffix = suffixByPath.get(meta.filePath); + if (!parentSuffix) continue; + node.data = { + ...node.data, + hProperties: { + ...node.data?.hProperties, + [FILE_LINK_PARENT_SUFFIX_PROPERTY]: parentSuffix, + }, + }; + } + }; } const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; @@ -1272,28 +1301,22 @@ function ChatMarkdown({ serverConfig?.availableEditors ?? [], ); const diffThemeName = resolveDiffThemeName(resolvedTheme); - const markdownFileLinkMetaByHref = useMemo(() => { - const metaByHref = new Map< - string, - NonNullable> - >(); - for (const href of extractMarkdownLinkHrefs(text)) { - const normalizedHref = normalizeMarkdownLinkHrefKey(href); - if (metaByHref.has(normalizedHref)) continue; - const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd); - if (meta) { - metaByHref.set(normalizedHref, meta); - } - } - return metaByHref; - }, [cwd, text]); - const fileLinkParentSuffixByPath = useMemo(() => { - const filePaths = [...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath); - return buildFileLinkParentSuffixByPath(filePaths); - }, [markdownFileLinkMetaByHref]); - const markdownUrlTransform = useCallback((href: string) => { - return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); - }, []); + const remarkPlugins = useMemo( + () => [ + ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS), + createFileLinkDisambiguationPlugin(cwd), + ], + [cwd, lineBreaks], + ); + const markdownUrlTransform = useCallback( + (href: string) => { + return ( + rewriteMarkdownFileUriHref(href) ?? + (resolveMarkdownFileLinkTarget(href, cwd) ? href : defaultUrlTransform(href)) + ); + }, + [cwd], + ); // Re-emit highlighted content as markdown so copying out of the rendered // view keeps links, emphasis, lists, and code fences intact. const handleCopy = useCallback((event: ReactClipboardEvent) => { @@ -1390,8 +1413,12 @@ function ChatMarkdown({ ); }, a({ node, href, children, ...props }) { - const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; - const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; + const normalizedHref = href + ? (rewriteMarkdownFileUriHref(href) ?? normalizeMarkdownLinkDestination(href)) + : ""; + const fileLinkMeta = normalizedHref + ? resolveMarkdownFileLinkMeta(normalizedHref, cwd) + : null; if (!fileLinkMeta) { const faviconHost = resolveExternalLinkHost(href); const isSameDocumentLink = href?.startsWith("#") ?? false; @@ -1468,9 +1495,13 @@ function ChatMarkdown({ ); } - const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath); + const anchorProps = props as typeof props & Record; + const parentSuffix = + typeof anchorProps[FILE_LINK_PARENT_SUFFIX_DATA_ATTRIBUTE] === "string" + ? anchorProps[FILE_LINK_PARENT_SUFFIX_DATA_ATTRIBUTE] + : undefined; const labelParts = [fileLinkMeta.basename]; - if (typeof parentSuffix === "string" && parentSuffix.length > 0) { + if (parentSuffix) { labelParts.push(parentSuffix); } if (fileLinkMeta.line) { @@ -1539,10 +1570,9 @@ function ChatMarkdown({ }, }), [ + cwd, diffThemeName, - fileLinkParentSuffixByPath, isStreaming, - markdownFileLinkMetaByHref, onTaskListChange, openInPreferredEditor, openExternalLinkInPreview, @@ -1563,9 +1593,7 @@ function ChatMarkdown({ onCopy={handleCopy} > { + it("unescapes markdown punctuation when normalizing destinations", () => { + expect( + resolveMarkdownFileLinkTarget("apps/web/src/routes/\\(chat\\)/\\[id\\].tsx", "/repo"), + ).toBe("/repo/apps/web/src/routes/(chat)/[id].tsx"); + }); + + it("preserves windows path separators before punctuation", () => { + expect(resolveMarkdownFileLinkTarget(String.raw`C:\src\(group)\page.tsx`)).toBe( + String.raw`C:\src\(group)\page.tsx`, + ); + }); +}); + describe("rewriteMarkdownFileUriHref", () => { it("rewrites file uri hrefs into direct path hrefs", () => { expect(rewriteMarkdownFileUriHref("file:///Users/julius/project/src/main.ts#L42")).toBe( @@ -26,12 +40,6 @@ describe("rewriteMarkdownFileUriHref", () => { ), ).toBe("D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx#L69"); }); - - it("unwraps angle-bracketed file uri hrefs", () => { - expect( - rewriteMarkdownFileUriHref(" "), - ).toBe("D:/Programme/t3code/apps/web/src/markdown-links.ts"); - }); }); describe("resolveMarkdownFileLinkTarget", () => { @@ -59,6 +67,31 @@ describe("resolveMarkdownFileLinkTarget", () => { ); }); + it("resolves relative paths with route group and dynamic segment characters", () => { + expect( + resolveMarkdownFileLinkTarget("apps/web/src/routes/(chat)/[threadId].tsx", "/repo/project"), + ).toBe("/repo/project/apps/web/src/routes/(chat)/[threadId].tsx"); + }); + + it("resolves encoded route group and dynamic segment characters", () => { + expect( + resolveMarkdownFileLinkTarget( + "apps/web/src/routes/%28chat%29/%5BthreadId%5D.tsx", + "/repo/project", + ), + ).toBe("/repo/project/apps/web/src/routes/(chat)/[threadId].tsx"); + }); + + it("preserves support for conservative extensionless relative file paths", () => { + expect(resolveMarkdownFileLinkTarget("scripts/release", "/repo/project")).toBe( + "/repo/project/scripts/release", + ); + }); + + it("does not treat ambiguous encoded relative web links as file paths", () => { + expect(resolveMarkdownFileLinkTarget("docs/user%20guide", "/repo/project")).toBeNull(); + }); + it("maps #L line anchors to editor line suffixes", () => { expect(resolveMarkdownFileLinkTarget("/Users/julius/project/src/main.ts#L42C7")).toBe( "/Users/julius/project/src/main.ts:42:7", @@ -115,14 +148,6 @@ describe("resolveMarkdownFileLinkTarget", () => { ).toBe("D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx:69"); }); - it("resolves angle-bracketed windows drive paths", () => { - expect( - resolveMarkdownFileLinkTarget( - "", - ), - ).toBe("D:/Programme/t3code/apps/web/src/components/ChatMarkdown.tsx:1"); - }); - it("does not treat app routes as file links", () => { expect(resolveMarkdownFileLinkTarget("/chat/settings")).toBeNull(); }); diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index 1e24de8bb1d..0054e66f1e2 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -3,10 +3,10 @@ import { resolvePathLinkTarget, splitPathAndPosition } from "./terminal-links"; const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; +const WINDOWS_BACKSLASH_PATH_PATTERN = /^(?:[A-Za-z]:\\|\\\\)/; const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/; const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; -const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/; -const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; +const CONSERVATIVE_RELATIVE_PATH_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/; const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/; const POSIX_FILE_ROOT_PREFIXES = [ @@ -40,12 +40,14 @@ function safeDecode(value: string): string { } } -function unwrapMarkdownLinkDestination(value: string): string { - return value.startsWith("<") && value.endsWith(">") ? value.slice(1, -1) : value; -} +const MARKDOWN_ESCAPE_PATTERN = /\\([!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])/g; export function normalizeMarkdownLinkDestination(value: string): string { - return unwrapMarkdownLinkDestination(value.trim()); + const trimmedValue = value.trim(); + if (WINDOWS_BACKSLASH_PATH_PATTERN.test(trimmedValue)) { + return trimmedValue; + } + return trimmedValue.replace(MARKDOWN_ESCAPE_PATTERN, "$1"); } function stripSearchAndHash(value: string): { path: string; hash: string } { @@ -113,7 +115,24 @@ function isLikelyPathCandidate(path: string): boolean { if (WINDOWS_DRIVE_PATH_PATTERN.test(path) || WINDOWS_UNC_PATH_PATTERN.test(path)) return true; if (RELATIVE_PATH_PREFIX_PATTERN.test(path)) return true; if (path.startsWith("/")) return looksLikePosixFilesystemPath(path); - return RELATIVE_FILE_PATH_PATTERN.test(path) || RELATIVE_FILE_NAME_PATTERN.test(path); + const { path: actualPath } = splitPathAndPosition(path); + if (actualPath.length === 0) return false; + if (["\0", "\r", "\n", '"', "'", "`", "<", ">"].some((char) => actualPath.includes(char))) { + return false; + } + + const normalizedPath = actualPath.replaceAll("\\", "/"); + const segments = normalizedPath.split("/"); + if (segments.length > 1) { + if (segments.some((segment) => segment.length === 0)) return false; + const basename = segments.at(-1) ?? ""; + return ( + /\.[A-Za-z0-9_-]+$/.test(basename) || + segments.every((segment) => CONSERVATIVE_RELATIVE_PATH_SEGMENT_PATTERN.test(segment)) + ); + } + + return /\.[A-Za-z0-9_-]+$/.test(actualPath); } function isRelativePath(path: string): boolean {