From 96c1e6187ada23910c2d7e820579c34b646b72b4 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Mon, 30 Mar 2026 00:11:53 +0200 Subject: [PATCH] feat(web): use typed router Links for relative markdown links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit override the `a` component in ReactMarkdown to render TanStack Router Links for repo-local URLs, giving proper client-side navigation with preloading instead of full page reloads - relative links → - anchor links (#heading) → plain - external links → plain - images stay as urlTransform to /gitraw (need raw bytes) Co-Authored-By: Claude Opus 4.6 (1M context) --- webui2/src/components/content/Markdown.tsx | 58 ++++++++++++++++++++-- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/webui2/src/components/content/Markdown.tsx b/webui2/src/components/content/Markdown.tsx index 5ca0e1119d359b1b28d9dc07c9dbed798c5eef77..f062fffd6b5d475554770f9f354f91b4e5445d2d 100644 --- a/webui2/src/components/content/Markdown.tsx +++ b/webui2/src/components/content/Markdown.tsx @@ -1,3 +1,4 @@ +import { Link } from "@tanstack/react-router"; import { useMemo } from "react"; import ReactMarkdown from "react-markdown"; import rehypeAutolinkHeadings from "rehype-autolink-headings"; @@ -72,9 +73,8 @@ function isImagePath(path: string): boolean { // Renders a Markdown string with GitHub-flavoured extensions (tables, task // lists, strikethrough). Used in Timeline comments and code browser READMEs. export function Markdown({ content, className, repoContext }: MarkdownProps) { - // Rewrite relative URLs: - // - images → /gitraw/{repo}/{ref}/{path} (serves raw bytes) - // - links → /{repo}/blob/{ref}/{path} (code browser view) + // Rewrite image src to /gitraw for raw content serving. + // Links are handled by the custom `a` component below. const urlTransform = useMemo(() => { if (!repoContext) return undefined; const { repo, ref, basePath } = repoContext; @@ -84,10 +84,61 @@ export function Markdown({ content, className, repoContext }: MarkdownProps) { if (isImagePath(resolved)) { return `/gitraw/${repo}/${ref}/${resolved}`; } + // Non-image relative URLs are handled by the `a` component override, + // but urlTransform runs first, so we still need to return something. + // Return the resolved path prefixed so the `a` component can detect it. return `/${repo}/blob/${ref}/${resolved}`; }; }, [repoContext]); + const components = useMemo(() => { + if (!repoContext) return undefined; + const { repo, ref, basePath } = repoContext; + return { + a: ({ href, children, ...props }: React.AnchorHTMLAttributes) => { + if (!href) return {children}; + + // Anchor links stay as-is + if (href.startsWith("#")) + return ( + + {children} + + ); + + // Check if this is a relative URL that we should route client-side. + // After urlTransform, repo-local links look like /{repo}/blob/{ref}/{path} + const prefix = `/${repo}/blob/${ref}/`; + if (href.startsWith(prefix)) { + const path = href.slice(prefix.length); + return ( + + {children} + + ); + } + + // Also handle raw relative URLs that urlTransform didn't process + // (shouldn't happen but defensive) + if (isRelativeUrl(href)) { + const resolved = resolveRelativePath(basePath, href); + return ( + + {children} + + ); + } + + // External links — render as normal anchor + return ( + + {children} + + ); + }, + }; + }, [repoContext]); + return (
{content}