From 9fa216ba84501629ce04c8586aae15f6495aa67f Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Sun, 29 Mar 2026 23:32:31 +0200 Subject: [PATCH] feat(web): resolve relative links/images in markdown against the repo 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) --- webui2/src/components/content/Markdown.tsx | 45 ++++++++++++++++--- webui2/src/routes/$repo/_code/tree/$ref/$.tsx | 5 ++- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/webui2/src/components/content/Markdown.tsx b/webui2/src/components/content/Markdown.tsx index a5244323339ed20f73b45b1275d60b55c0804981..390412391681e92364b4b95ebce17ea47ca6fe61 100644 --- a/webui2/src/components/content/Markdown.tsx +++ b/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 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 (
{content} diff --git a/webui2/src/routes/$repo/_code/tree/$ref/$.tsx b/webui2/src/routes/$repo/_code/tree/$ref/$.tsx index f98d80af74669d591d2e1486b52d975f3d1b1ad4..98c7818bb17015b38a2d83c42d2e4d83e4faff12 100644 --- a/webui2/src/routes/$repo/_code/tree/$ref/$.tsx +++ b/webui2/src/routes/$repo/_code/tree/$ref/$.tsx @@ -116,7 +116,10 @@ function TreeView() {
README
- +
)}