From 1149f3aaac274ee88ed09d373d1f1a60c20b4733 Mon Sep 17 00:00:00 2001 From: Prem Sathisha Etagi Date: Mon, 13 Apr 2026 17:15:09 -0700 Subject: [PATCH 1/2] Fix README image asset URLs --- .../repo/repo-markdown-files.test.ts | 49 +++++++++++ .../components/repo/repo-markdown-files.tsx | 67 ++++++++++++++- packages/ui/src/components/markdown.tsx | 83 ++++++++++++++++--- 3 files changed, 185 insertions(+), 14 deletions(-) create mode 100644 apps/dashboard/src/components/repo/repo-markdown-files.test.ts 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..d523f39 --- /dev/null +++ b/apps/dashboard/src/components/repo/repo-markdown-files.test.ts @@ -0,0 +1,49 @@ +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("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..3abc4d0 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 url; + } + + const rootUrl = `https://raw.githubusercontent.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(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..fdae23d 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,37 @@ 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 || /^\s*data:/iu.test(srcSet)) 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; + + return `${resolveAssetUrl(match[1])}${match[2] ?? ""}`; + }) + .join(", "); +} + export function highlightCode(code: string, lang: string): Promise { const key = `${lang}:${code}`; const cached = htmlCache.get(key); @@ -152,7 +191,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 +419,23 @@ const components: Record> = { export function Markdown({ children, className, + resolveAssetUrl, }: { children: string; className?: string; + resolveAssetUrl?: MarkdownAssetUrlResolver; }) { return ( -
- - {children} - -
+ +
+ + {children} + +
+
); } From f8a35a54de93fc4e01b22137e1d5e63a29dc16d6 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Mon, 13 Apr 2026 21:22:02 -0400 Subject: [PATCH 2/2] add more edge cases --- .../src/components/repo/repo-markdown-files.test.ts | 11 +++++++++++ .../src/components/repo/repo-markdown-files.tsx | 4 ++-- packages/ui/src/components/markdown.tsx | 7 +++++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/dashboard/src/components/repo/repo-markdown-files.test.ts b/apps/dashboard/src/components/repo/repo-markdown-files.test.ts index d523f39..4365040 100644 --- a/apps/dashboard/src/components/repo/repo-markdown-files.test.ts +++ b/apps/dashboard/src/components/repo/repo-markdown-files.test.ts @@ -34,6 +34,17 @@ describe("resolveGitHubMarkdownAssetUrl", () => { ); }); + 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( diff --git a/apps/dashboard/src/components/repo/repo-markdown-files.tsx b/apps/dashboard/src/components/repo/repo-markdown-files.tsx index 3abc4d0..e01e3df 100644 --- a/apps/dashboard/src/components/repo/repo-markdown-files.tsx +++ b/apps/dashboard/src/components/repo/repo-markdown-files.tsx @@ -218,10 +218,10 @@ export function resolveGitHubMarkdownAssetUrl( trimmedUrl.startsWith("#") || ABSOLUTE_MARKDOWN_URL_RE.test(trimmedUrl) ) { - return url; + return trimmedUrl; } - const rootUrl = `https://raw.githubusercontent.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(ref)}/`; + const rootUrl = `https://raw.githubusercontent.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodePath(ref)}/`; const directoryPath = encodePath(getDirectoryPath(path)); const baseUrl = trimmedUrl.startsWith("/") ? rootUrl diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index fdae23d..9bbb885 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -72,7 +72,7 @@ function resolveAssetSrcSet( srcSet: string | undefined, resolveAssetUrl: MarkdownAssetUrlResolver | null, ) { - if (!srcSet || !resolveAssetUrl || /^\s*data:/iu.test(srcSet)) return srcSet; + if (!srcSet || !resolveAssetUrl) return srcSet; return srcSet .split(",") @@ -83,7 +83,10 @@ function resolveAssetSrcSet( const match = trimmed.match(/^(\S+)(\s+.+)?$/u); if (!match) return candidate; - return `${resolveAssetUrl(match[1])}${match[2] ?? ""}`; + const url = match[1]; + if (/^data:/iu.test(url)) return candidate; + + return `${resolveAssetUrl(url)}${match[2] ?? ""}`; }) .join(", "); }