diff --git a/apps/dashboard/src/components/repo/repo-markdown-files.test.ts b/apps/dashboard/src/components/repo/repo-markdown-files.test.ts
new file mode 100644
index 0000000..4365040
--- /dev/null
+++ b/apps/dashboard/src/components/repo/repo-markdown-files.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from "vitest";
+import { resolveGitHubMarkdownAssetUrl } from "./repo-markdown-files";
+
+describe("resolveGitHubMarkdownAssetUrl", () => {
+ const context = {
+ owner: "jakemor",
+ repo: "kanna",
+ ref: "main",
+ path: "README.md",
+ };
+
+ it("rebases root README relative assets to GitHub raw content", () => {
+ expect(
+ resolveGitHubMarkdownAssetUrl(context, "assets/screenshot.png"),
+ ).toBe(
+ "https://raw.githubusercontent.com/jakemor/kanna/main/assets/screenshot.png",
+ );
+ });
+
+ it("rebases root-relative assets from the repository root", () => {
+ expect(resolveGitHubMarkdownAssetUrl(context, "/assets/icon.png")).toBe(
+ "https://raw.githubusercontent.com/jakemor/kanna/main/assets/icon.png",
+ );
+ });
+
+ it("resolves nested README assets relative to the markdown file", () => {
+ expect(
+ resolveGitHubMarkdownAssetUrl(
+ { ...context, path: "docs/guides/README.md" },
+ "../assets/demo image.png",
+ ),
+ ).toBe(
+ "https://raw.githubusercontent.com/jakemor/kanna/main/docs/assets/demo%20image.png",
+ );
+ });
+
+ it("handles refs with slashes (e.g. feature branches)", () => {
+ expect(
+ resolveGitHubMarkdownAssetUrl(
+ { ...context, ref: "feature/auth" },
+ "assets/screenshot.png",
+ ),
+ ).toBe(
+ "https://raw.githubusercontent.com/jakemor/kanna/feature/auth/assets/screenshot.png",
+ );
+ });
+
+ it("keeps absolute and anchor URLs unchanged", () => {
+ expect(
+ resolveGitHubMarkdownAssetUrl(
+ context,
+ "https://img.shields.io/badge.svg",
+ ),
+ ).toBe("https://img.shields.io/badge.svg");
+ expect(
+ resolveGitHubMarkdownAssetUrl(context, "//example.com/badge.svg"),
+ ).toBe("//example.com/badge.svg");
+ expect(resolveGitHubMarkdownAssetUrl(context, "#install")).toBe("#install");
+ });
+});
diff --git a/apps/dashboard/src/components/repo/repo-markdown-files.tsx b/apps/dashboard/src/components/repo/repo-markdown-files.tsx
index 8c460c9..e01e3df 100644
--- a/apps/dashboard/src/components/repo/repo-markdown-files.tsx
+++ b/apps/dashboard/src/components/repo/repo-markdown-files.tsx
@@ -5,7 +5,14 @@ import {
useQuery,
useQueryClient,
} from "@tanstack/react-query";
-import { lazy, Suspense, useEffect, useMemo, useState } from "react";
+import {
+ lazy,
+ Suspense,
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+} from "react";
import {
type GitHubQueryScope,
githubRepoFileContentQueryOptions,
@@ -142,6 +149,15 @@ function MarkdownFileContent({
placeholderData: keepPreviousData,
});
+ const resolveAssetUrl = useCallback(
+ (url: string) =>
+ resolveGitHubMarkdownAssetUrl(
+ { owner, repo, ref: currentRef, path },
+ url,
+ ),
+ [owner, repo, currentRef, path],
+ );
+
if (contentQuery.isLoading) {
return (
@@ -174,10 +190,57 @@ function MarkdownFileContent({
}
>
-
+
{contentQuery.data}
);
}
+
+const ABSOLUTE_MARKDOWN_URL_RE = /^[a-z][a-z\d+\-.]*:|^\/\//iu;
+
+export function resolveGitHubMarkdownAssetUrl(
+ {
+ owner,
+ repo,
+ ref,
+ path,
+ }: { owner: string; repo: string; ref: string; path: string },
+ url: string,
+) {
+ const trimmedUrl = url.trim();
+ if (
+ !trimmedUrl ||
+ trimmedUrl.startsWith("#") ||
+ ABSOLUTE_MARKDOWN_URL_RE.test(trimmedUrl)
+ ) {
+ return trimmedUrl;
+ }
+
+ const rootUrl = `https://raw.githubusercontent.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodePath(ref)}/`;
+ const directoryPath = encodePath(getDirectoryPath(path));
+ const baseUrl = trimmedUrl.startsWith("/")
+ ? rootUrl
+ : directoryPath
+ ? new URL(`${directoryPath}/`, rootUrl).toString()
+ : rootUrl;
+
+ return new URL(trimmedUrl.replace(/^\/+/u, ""), baseUrl).toString();
+}
+
+function getDirectoryPath(path: string) {
+ const lastSlashIndex = path.lastIndexOf("/");
+ return lastSlashIndex === -1 ? "" : path.slice(0, lastSlashIndex);
+}
+
+function encodePath(path: string) {
+ return path
+ .split("/")
+ .filter(Boolean)
+ .map((segment) => encodeURIComponent(segment))
+ .join("/");
+}
diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx
index 5f5788c..9bbb885 100644
--- a/packages/ui/src/components/markdown.tsx
+++ b/packages/ui/src/components/markdown.tsx
@@ -1,5 +1,13 @@
import { Md } from "@m2d/react-markdown/client";
-import { Suspense, use, useCallback, useRef, useState } from "react";
+import {
+ createContext,
+ Suspense,
+ use,
+ useCallback,
+ useContext,
+ useRef,
+ useState,
+} from "react";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
import { remarkAlert } from "remark-github-blockquote-alert";
@@ -49,6 +57,40 @@ const highlighterPromise: Promise =
const htmlCache = new Map>();
+export type MarkdownAssetUrlResolver = (url: string) => string;
+
+const MarkdownAssetUrlResolverContext =
+ createContext(null);
+
+function useResolvedAssetUrl(url: string | undefined) {
+ const resolveAssetUrl = useContext(MarkdownAssetUrlResolverContext);
+ if (!url || !resolveAssetUrl) return url;
+ return resolveAssetUrl(url);
+}
+
+function resolveAssetSrcSet(
+ srcSet: string | undefined,
+ resolveAssetUrl: MarkdownAssetUrlResolver | null,
+) {
+ if (!srcSet || !resolveAssetUrl) return srcSet;
+
+ return srcSet
+ .split(",")
+ .map((candidate) => {
+ const trimmed = candidate.trim();
+ if (!trimmed) return candidate;
+
+ const match = trimmed.match(/^(\S+)(\s+.+)?$/u);
+ if (!match) return candidate;
+
+ const url = match[1];
+ if (/^data:/iu.test(url)) return candidate;
+
+ return `${resolveAssetUrl(url)}${match[2] ?? ""}`;
+ })
+ .join(", ");
+}
+
export function highlightCode(code: string, lang: string): Promise {
const key = `${lang}:${code}`;
const cached = htmlCache.get(key);
@@ -152,7 +194,7 @@ function ShikiCode({ code, lang }: { code: string; lang: string }) {
);
}
-// eslint-disable-next-line @typescript-eslint/no-explicit-any -- component overrides receive union props from @m2d/react-markdown
+// biome-ignore lint/suspicious/noExplicitAny: component overrides receive union props from @m2d/react-markdown
const components: Record> = {
h1: ({ node: _, children, ...props }) => (
> = {
hr: ({ node: _, ...props }) => (
),
- img: ({ node: _, alt, ...props }) => (
+ img: ({ node: _, alt, src, ...props }) => (
),
+ source: ({ node: _, src, srcSet, srcset, ...props }) => {
+ const resolveAssetUrl = useContext(MarkdownAssetUrlResolverContext);
+ const resolvedSrcSet = resolveAssetSrcSet(
+ srcSet ?? srcset,
+ resolveAssetUrl,
+ );
+
+ return (
+
+ );
+ },
table: ({ node: _, children, ...props }) => (
@@ -364,19 +422,23 @@ const components: Record> = {
export function Markdown({
children,
className,
+ resolveAssetUrl,
}: {
children: string;
className?: string;
+ resolveAssetUrl?: MarkdownAssetUrlResolver;
}) {
return (
-
-
- {children}
-
-
+
+
+
+ {children}
+
+
+
);
}