Markdown.tsx

  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}