Skip to content

Commit 04e9eb8

Browse files
[codex] Fix README screenshot asset URLs (#109)
* Fix README image asset URLs * add more edge cases --------- Co-authored-by: Prem Sathisha Etagi <premsathisha@users.noreply.github.com> Co-authored-by: Alan Daniel <stylesshjs@gmail.com>
1 parent 20bb03e commit 04e9eb8

3 files changed

Lines changed: 199 additions & 14 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { describe, expect, it } from "vitest";
2+
import { resolveGitHubMarkdownAssetUrl } from "./repo-markdown-files";
3+
4+
describe("resolveGitHubMarkdownAssetUrl", () => {
5+
const context = {
6+
owner: "jakemor",
7+
repo: "kanna",
8+
ref: "main",
9+
path: "README.md",
10+
};
11+
12+
it("rebases root README relative assets to GitHub raw content", () => {
13+
expect(
14+
resolveGitHubMarkdownAssetUrl(context, "assets/screenshot.png"),
15+
).toBe(
16+
"https://raw.githubusercontent.com/jakemor/kanna/main/assets/screenshot.png",
17+
);
18+
});
19+
20+
it("rebases root-relative assets from the repository root", () => {
21+
expect(resolveGitHubMarkdownAssetUrl(context, "/assets/icon.png")).toBe(
22+
"https://raw.githubusercontent.com/jakemor/kanna/main/assets/icon.png",
23+
);
24+
});
25+
26+
it("resolves nested README assets relative to the markdown file", () => {
27+
expect(
28+
resolveGitHubMarkdownAssetUrl(
29+
{ ...context, path: "docs/guides/README.md" },
30+
"../assets/demo image.png",
31+
),
32+
).toBe(
33+
"https://raw.githubusercontent.com/jakemor/kanna/main/docs/assets/demo%20image.png",
34+
);
35+
});
36+
37+
it("handles refs with slashes (e.g. feature branches)", () => {
38+
expect(
39+
resolveGitHubMarkdownAssetUrl(
40+
{ ...context, ref: "feature/auth" },
41+
"assets/screenshot.png",
42+
),
43+
).toBe(
44+
"https://raw.githubusercontent.com/jakemor/kanna/feature/auth/assets/screenshot.png",
45+
);
46+
});
47+
48+
it("keeps absolute and anchor URLs unchanged", () => {
49+
expect(
50+
resolveGitHubMarkdownAssetUrl(
51+
context,
52+
"https://img.shields.io/badge.svg",
53+
),
54+
).toBe("https://img.shields.io/badge.svg");
55+
expect(
56+
resolveGitHubMarkdownAssetUrl(context, "//example.com/badge.svg"),
57+
).toBe("//example.com/badge.svg");
58+
expect(resolveGitHubMarkdownAssetUrl(context, "#install")).toBe("#install");
59+
});
60+
});

apps/dashboard/src/components/repo/repo-markdown-files.tsx

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,14 @@ import {
55
useQuery,
66
useQueryClient,
77
} from "@tanstack/react-query";
8-
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
8+
import {
9+
lazy,
10+
Suspense,
11+
useCallback,
12+
useEffect,
13+
useMemo,
14+
useState,
15+
} from "react";
916
import {
1017
type GitHubQueryScope,
1118
githubRepoFileContentQueryOptions,
@@ -142,6 +149,15 @@ function MarkdownFileContent({
142149
placeholderData: keepPreviousData,
143150
});
144151

152+
const resolveAssetUrl = useCallback(
153+
(url: string) =>
154+
resolveGitHubMarkdownAssetUrl(
155+
{ owner, repo, ref: currentRef, path },
156+
url,
157+
),
158+
[owner, repo, currentRef, path],
159+
);
160+
145161
if (contentQuery.isLoading) {
146162
return (
147163
<div className="flex flex-col gap-3 p-6">
@@ -174,10 +190,57 @@ function MarkdownFileContent({
174190
</div>
175191
}
176192
>
177-
<Markdown className="prose prose-sm dark:prose-invert max-w-none">
193+
<Markdown
194+
className="prose prose-sm dark:prose-invert max-w-none"
195+
resolveAssetUrl={resolveAssetUrl}
196+
>
178197
{contentQuery.data}
179198
</Markdown>
180199
</Suspense>
181200
</div>
182201
);
183202
}
203+
204+
const ABSOLUTE_MARKDOWN_URL_RE = /^[a-z][a-z\d+\-.]*:|^\/\//iu;
205+
206+
export function resolveGitHubMarkdownAssetUrl(
207+
{
208+
owner,
209+
repo,
210+
ref,
211+
path,
212+
}: { owner: string; repo: string; ref: string; path: string },
213+
url: string,
214+
) {
215+
const trimmedUrl = url.trim();
216+
if (
217+
!trimmedUrl ||
218+
trimmedUrl.startsWith("#") ||
219+
ABSOLUTE_MARKDOWN_URL_RE.test(trimmedUrl)
220+
) {
221+
return trimmedUrl;
222+
}
223+
224+
const rootUrl = `https://raw.githubusercontent.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodePath(ref)}/`;
225+
const directoryPath = encodePath(getDirectoryPath(path));
226+
const baseUrl = trimmedUrl.startsWith("/")
227+
? rootUrl
228+
: directoryPath
229+
? new URL(`${directoryPath}/`, rootUrl).toString()
230+
: rootUrl;
231+
232+
return new URL(trimmedUrl.replace(/^\/+/u, ""), baseUrl).toString();
233+
}
234+
235+
function getDirectoryPath(path: string) {
236+
const lastSlashIndex = path.lastIndexOf("/");
237+
return lastSlashIndex === -1 ? "" : path.slice(0, lastSlashIndex);
238+
}
239+
240+
function encodePath(path: string) {
241+
return path
242+
.split("/")
243+
.filter(Boolean)
244+
.map((segment) => encodeURIComponent(segment))
245+
.join("/");
246+
}

packages/ui/src/components/markdown.tsx

Lines changed: 74 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import { Md } from "@m2d/react-markdown/client";
2-
import { Suspense, use, useCallback, useRef, useState } from "react";
2+
import {
3+
createContext,
4+
Suspense,
5+
use,
6+
useCallback,
7+
useContext,
8+
useRef,
9+
useState,
10+
} from "react";
311
import rehypeRaw from "rehype-raw";
412
import remarkGfm from "remark-gfm";
513
import { remarkAlert } from "remark-github-blockquote-alert";
@@ -49,6 +57,40 @@ const highlighterPromise: Promise<Highlighter> =
4957

5058
const htmlCache = new Map<string, Promise<string>>();
5159

60+
export type MarkdownAssetUrlResolver = (url: string) => string;
61+
62+
const MarkdownAssetUrlResolverContext =
63+
createContext<MarkdownAssetUrlResolver | null>(null);
64+
65+
function useResolvedAssetUrl(url: string | undefined) {
66+
const resolveAssetUrl = useContext(MarkdownAssetUrlResolverContext);
67+
if (!url || !resolveAssetUrl) return url;
68+
return resolveAssetUrl(url);
69+
}
70+
71+
function resolveAssetSrcSet(
72+
srcSet: string | undefined,
73+
resolveAssetUrl: MarkdownAssetUrlResolver | null,
74+
) {
75+
if (!srcSet || !resolveAssetUrl) return srcSet;
76+
77+
return srcSet
78+
.split(",")
79+
.map((candidate) => {
80+
const trimmed = candidate.trim();
81+
if (!trimmed) return candidate;
82+
83+
const match = trimmed.match(/^(\S+)(\s+.+)?$/u);
84+
if (!match) return candidate;
85+
86+
const url = match[1];
87+
if (/^data:/iu.test(url)) return candidate;
88+
89+
return `${resolveAssetUrl(url)}${match[2] ?? ""}`;
90+
})
91+
.join(", ");
92+
}
93+
5294
export function highlightCode(code: string, lang: string): Promise<string> {
5395
const key = `${lang}:${code}`;
5496
const cached = htmlCache.get(key);
@@ -152,7 +194,7 @@ function ShikiCode({ code, lang }: { code: string; lang: string }) {
152194
);
153195
}
154196

155-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- component overrides receive union props from @m2d/react-markdown
197+
// biome-ignore lint/suspicious/noExplicitAny: component overrides receive union props from @m2d/react-markdown
156198
const components: Record<string, React.FC<any>> = {
157199
h1: ({ node: _, children, ...props }) => (
158200
<h1
@@ -267,13 +309,29 @@ const components: Record<string, React.FC<any>> = {
267309
hr: ({ node: _, ...props }) => (
268310
<hr className="my-4 border-border" {...props} />
269311
),
270-
img: ({ node: _, alt, ...props }) => (
312+
img: ({ node: _, alt, src, ...props }) => (
271313
<img
272314
className="inline-block max-w-full rounded-lg my-2"
273315
alt={alt}
316+
src={useResolvedAssetUrl(src)}
274317
{...props}
275318
/>
276319
),
320+
source: ({ node: _, src, srcSet, srcset, ...props }) => {
321+
const resolveAssetUrl = useContext(MarkdownAssetUrlResolverContext);
322+
const resolvedSrcSet = resolveAssetSrcSet(
323+
srcSet ?? srcset,
324+
resolveAssetUrl,
325+
);
326+
327+
return (
328+
<source
329+
src={useResolvedAssetUrl(src)}
330+
srcSet={resolvedSrcSet}
331+
{...props}
332+
/>
333+
);
334+
},
277335
table: ({ node: _, children, ...props }) => (
278336
<div className="flex flex-col overflow-hidden mb-2 rounded-lg border border-border bg-surface-0">
279337
<table className="w-full text-sm border-collapse" {...props}>
@@ -364,19 +422,23 @@ const components: Record<string, React.FC<any>> = {
364422
export function Markdown({
365423
children,
366424
className,
425+
resolveAssetUrl,
367426
}: {
368427
children: string;
369428
className?: string;
429+
resolveAssetUrl?: MarkdownAssetUrlResolver;
370430
}) {
371431
return (
372-
<div className={cn("not-prose text-foreground", className)}>
373-
<Md
374-
remarkPlugins={[remarkGfm, remarkAlert]}
375-
rehypePlugins={[rehypeRaw]}
376-
components={components}
377-
>
378-
{children}
379-
</Md>
380-
</div>
432+
<MarkdownAssetUrlResolverContext.Provider value={resolveAssetUrl ?? null}>
433+
<div className={cn("not-prose text-foreground", className)}>
434+
<Md
435+
remarkPlugins={[remarkGfm, remarkAlert]}
436+
rehypePlugins={[rehypeRaw]}
437+
components={components}
438+
>
439+
{children}
440+
</Md>
441+
</div>
442+
</MarkdownAssetUrlResolverContext.Provider>
381443
);
382444
}

0 commit comments

Comments
 (0)