feat(web): resolve relative links/images in markdown against the repo

Quentin Gliech and Claude Opus 4.6 (1M context) created

add optional repoContext prop to Markdown component with repo, ref,
and basePath. When set, relative URLs in markdown content are
rewritten to point to the code browser (/$repo/blob/$ref/path).

this makes images and links in READMEs like ![](../misc/diagram.png)
or [docs](doc/) resolve correctly against the current directory
and ref in the code browser.

issue comments don't pass repoContext, so their links are unaffected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Change summary

webui2/src/components/content/Markdown.tsx    | 45 ++++++++++++++++++--
webui2/src/routes/$repo/_code/tree/$ref/$.tsx |  5 +
2 files changed, 43 insertions(+), 7 deletions(-)

Detailed changes

webui2/src/components/content/Markdown.tsx 🔗

@@ -1,3 +1,4 @@
+import { useMemo } from "react";
 import ReactMarkdown from "react-markdown";
 import rehypeAutolinkHeadings from "rehype-autolink-headings";
 import rehypeExternalLinks from "rehype-external-links";
@@ -11,10 +12,6 @@ import { cn } from "@/lib/utils";
 
 // Sanitization schema: start from the safe default and allow a small set of
 // presentational/structural HTML tags commonly found in READMEs.
-// Script, style, iframe, object, embed and event-handler attributes are
-// blocked by the default schema and remain blocked.
-// rehype-autolink-headings injects <a> with aria-hidden and class, so we
-// allow those attributes on anchors.
 const sanitizeSchema = {
   ...defaultSchema,
   tagNames: [...(defaultSchema.tagNames ?? []), "details", "summary", "picture", "source"],
@@ -28,11 +25,46 @@ const sanitizeSchema = {
 interface MarkdownProps {
   content: string;
   className?: string;
+  /** When set, relative links/images are resolved against the code browser. */
+  repoContext?: {
+    repo: string;
+    ref: string;
+    /** Directory containing the markdown file (e.g. "doc" for doc/README.md). */
+    basePath: string;
+  };
+}
+
+function isRelativeUrl(url: string): boolean {
+  // Absolute URLs, protocol-relative, anchors, and data URIs are not relative
+  return !/^(?:[a-z][a-z0-9+.-]*:|\/\/|#|data:)/i.test(url);
+}
+
+function resolveRelativePath(basePath: string, relativePath: string): string {
+  const parts = basePath ? basePath.split("/") : [];
+  for (const segment of relativePath.split("/")) {
+    if (segment === "..") {
+      parts.pop();
+    } else if (segment !== "." && segment !== "") {
+      parts.push(segment);
+    }
+  }
+  return parts.join("/");
 }
 
 // Renders a Markdown string with GitHub-flavoured extensions (tables, task
-// lists, strikethrough). Used in Timeline comments and NewBugPage preview.
-export function Markdown({ content, className }: MarkdownProps) {
+// lists, strikethrough). Used in Timeline comments and code browser READMEs.
+export function Markdown({ content, className, repoContext }: MarkdownProps) {
+  // Build a urlTransform that rewrites relative URLs to the code browser
+  const urlTransform = useMemo(() => {
+    if (!repoContext) return undefined;
+    const { repo, ref, basePath } = repoContext;
+    return (url: string) => {
+      if (!isRelativeUrl(url)) return url;
+      const resolved = resolveRelativePath(basePath, url);
+      return `/${repo}/blob/${ref}/${resolved}`;
+    };
+  }, [repoContext]);
+
   return (
     <div
       className={cn(
@@ -52,6 +84,7 @@ export function Markdown({ content, className }: MarkdownProps) {
           [rehypeAutolinkHeadings, { behavior: "append" }],
           [rehypeExternalLinks, { target: "_blank", rel: ["noopener", "noreferrer"] }],
         ]}
+        urlTransform={urlTransform}
       >
         {content}
       </ReactMarkdown>

webui2/src/routes/$repo/_code/tree/$ref/$.tsx 🔗

@@ -116,7 +116,10 @@ function TreeView() {
         <div className="rounded-md border">
           <div className="text-muted-foreground border-b px-4 py-2 text-xs font-medium">README</div>
           <div className="px-6 py-4">
-            <Markdown content={readme} />
+            <Markdown
+              content={readme}
+              repoContext={{ repo, ref: currentRef, basePath: currentPath }}
+            />
           </div>
         </div>
       )}