1import { Link } from "@tanstack/react-router";
2import { useMemo } from "react";
3import ReactMarkdown from "react-markdown";
4import rehypeAutolinkHeadings from "rehype-autolink-headings";
5import rehypeExternalLinks from "rehype-external-links";
6import rehypeRaw from "rehype-raw";
7import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
8import rehypeSlug from "rehype-slug";
9import remarkEmoji from "remark-emoji";
10import remarkGfm from "remark-gfm";
11
12import { cn } from "@/lib/utils";
13
14// Sanitization schema: start from the safe default and allow a small set of
15// presentational/structural HTML tags commonly found in READMEs.
16const sanitizeSchema = {
17 ...defaultSchema,
18 tagNames: [...(defaultSchema.tagNames ?? []), "details", "summary", "picture", "source"],
19 attributes: {
20 ...defaultSchema.attributes,
21 a: [...(defaultSchema.attributes?.a ?? []), "aria-hidden", "class"],
22 "*": [...(defaultSchema.attributes?.["*"] ?? []), "id"],
23 },
24};
25
26export interface RepoContext {
27 repo: string;
28 ref: string;
29 /** Directory containing the markdown file (e.g. "doc" for doc/README.md). */
30 basePath: string;
31}
32
33interface MarkdownProps {
34 content: string;
35 className?: string;
36 /** When set, relative links/images are resolved against the repo. */
37 repoContext?: RepoContext;
38}
39
40function isRelativeUrl(url: string): boolean {
41 return !/^(?:[a-z][a-z0-9+.-]*:|\/\/|#|data:)/i.test(url);
42}
43
44function resolveRelativePath(basePath: string, relativePath: string): string {
45 const parts = basePath ? basePath.split("/") : [];
46 for (const segment of relativePath.split("/")) {
47 if (segment === "..") {
48 parts.pop();
49 } else if (segment !== "." && segment !== "") {
50 parts.push(segment);
51 }
52 }
53 return parts.join("/");
54}
55
56const IMAGE_EXTENSIONS = new Set([
57 "png",
58 "jpg",
59 "jpeg",
60 "gif",
61 "svg",
62 "webp",
63 "avif",
64 "ico",
65 "bmp",
66]);
67
68function isImagePath(path: string): boolean {
69 const ext = path.split(".").pop()?.toLowerCase() ?? "";
70 return IMAGE_EXTENSIONS.has(ext);
71}
72
73// Renders a Markdown string with GitHub-flavoured extensions (tables, task
74// lists, strikethrough). Used in Timeline comments and code browser READMEs.
75export function Markdown({ content, className, repoContext }: MarkdownProps) {
76 // Rewrite image src to /gitraw for raw content serving.
77 // Links are handled by the custom `a` component below.
78 const urlTransform = useMemo(() => {
79 if (!repoContext) return undefined;
80 const { repo, ref, basePath } = repoContext;
81 return (url: string) => {
82 if (!isRelativeUrl(url)) return url;
83 const resolved = resolveRelativePath(basePath, url);
84 if (isImagePath(resolved)) {
85 return `/gitraw/${repo}/${ref}/${resolved}`;
86 }
87 // Non-image relative URLs are handled by the `a` component override,
88 // but urlTransform runs first, so we still need to return something.
89 // Return the resolved path prefixed so the `a` component can detect it.
90 return `/${repo}/blob/${ref}/${resolved}`;
91 };
92 }, [repoContext]);
93
94 const components = useMemo(() => {
95 if (!repoContext) return undefined;
96 const { repo, ref, basePath } = repoContext;
97 const gitrawPrefix = `/gitraw/${repo}/${ref}/`;
98 return {
99 img: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => {
100 // Wrap repo-local images in a Link to the blob view
101 if (src?.startsWith(gitrawPrefix)) {
102 const path = src.slice(gitrawPrefix.length);
103 return (
104 <Link to="/$repo/blob/$ref/$" params={{ repo, ref, _splat: path }}>
105 <img src={src} alt={alt} {...props} />
106 </Link>
107 );
108 }
109 return <img src={src} alt={alt} {...props} />;
110 },
111 a: ({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
112 if (!href) return <a {...props}>{children}</a>;
113
114 // Anchor links stay as-is
115 if (href.startsWith("#"))
116 return (
117 <a href={href} {...props}>
118 {children}
119 </a>
120 );
121
122 // Check if this is a relative URL that we should route client-side.
123 // After urlTransform, repo-local links look like /{repo}/blob/{ref}/{path}
124 const prefix = `/${repo}/blob/${ref}/`;
125 if (href.startsWith(prefix)) {
126 const path = href.slice(prefix.length);
127 return (
128 <Link to="/$repo/blob/$ref/$" params={{ repo, ref, _splat: path }} {...props}>
129 {children}
130 </Link>
131 );
132 }
133
134 // Also handle raw relative URLs that urlTransform didn't process
135 // (shouldn't happen but defensive)
136 if (isRelativeUrl(href)) {
137 const resolved = resolveRelativePath(basePath, href);
138 return (
139 <Link to="/$repo/blob/$ref/$" params={{ repo, ref, _splat: resolved }} {...props}>
140 {children}
141 </Link>
142 );
143 }
144
145 // External links — render as normal anchor
146 return (
147 <a href={href} {...props}>
148 {children}
149 </a>
150 );
151 },
152 };
153 }, [repoContext]);
154
155 return (
156 <div
157 className={cn(
158 "prose prose-sm dark:prose-invert max-w-none",
159 "prose-pre:bg-muted prose-pre:text-foreground",
160 "prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded-sm prose-code:text-sm prose-code:before:content-none prose-code:after:content-none",
161 "prose-img:inline prose-img:my-0",
162 className,
163 )}
164 >
165 <ReactMarkdown
166 remarkPlugins={[remarkGfm, remarkEmoji]}
167 rehypePlugins={[
168 rehypeRaw,
169 [rehypeSanitize, sanitizeSchema],
170 rehypeSlug,
171 [rehypeAutolinkHeadings, { behavior: "append" }],
172 [rehypeExternalLinks, { target: "_blank", rel: ["noopener", "noreferrer"] }],
173 ]}
174 urlTransform={urlTransform}
175 components={components}
176 >
177 {content}
178 </ReactMarkdown>
179 </div>
180 );
181}