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 }) => ( {alt} ), + 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} + +
+
); }