Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions apps/dashboard/src/components/repo/repo-markdown-files.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
67 changes: 65 additions & 2 deletions apps/dashboard/src/components/repo/repo-markdown-files.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<div className="flex flex-col gap-3 p-6">
Expand Down Expand Up @@ -174,10 +190,57 @@ function MarkdownFileContent({
</div>
}
>
<Markdown className="prose prose-sm dark:prose-invert max-w-none">
<Markdown
className="prose prose-sm dark:prose-invert max-w-none"
resolveAssetUrl={resolveAssetUrl}
>
{contentQuery.data}
</Markdown>
</Suspense>
</div>
);
}

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("/");
}
86 changes: 74 additions & 12 deletions packages/ui/src/components/markdown.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -49,6 +57,40 @@ const highlighterPromise: Promise<Highlighter> =

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

export type MarkdownAssetUrlResolver = (url: string) => string;

const MarkdownAssetUrlResolverContext =
createContext<MarkdownAssetUrlResolver | null>(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<string> {
const key = `${lang}:${code}`;
const cached = htmlCache.get(key);
Expand Down Expand Up @@ -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<string, React.FC<any>> = {
h1: ({ node: _, children, ...props }) => (
<h1
Expand Down Expand Up @@ -267,13 +309,29 @@ const components: Record<string, React.FC<any>> = {
hr: ({ node: _, ...props }) => (
<hr className="my-4 border-border" {...props} />
),
img: ({ node: _, alt, ...props }) => (
img: ({ node: _, alt, src, ...props }) => (
<img
className="inline-block max-w-full rounded-lg my-2"
alt={alt}
src={useResolvedAssetUrl(src)}
{...props}
/>
),
source: ({ node: _, src, srcSet, srcset, ...props }) => {
const resolveAssetUrl = useContext(MarkdownAssetUrlResolverContext);
const resolvedSrcSet = resolveAssetSrcSet(
srcSet ?? srcset,
resolveAssetUrl,
);

return (
<source
src={useResolvedAssetUrl(src)}
srcSet={resolvedSrcSet}
{...props}
/>
);
},
table: ({ node: _, children, ...props }) => (
<div className="flex flex-col overflow-hidden mb-2 rounded-lg border border-border bg-surface-0">
<table className="w-full text-sm border-collapse" {...props}>
Expand Down Expand Up @@ -364,19 +422,23 @@ const components: Record<string, React.FC<any>> = {
export function Markdown({
children,
className,
resolveAssetUrl,
}: {
children: string;
className?: string;
resolveAssetUrl?: MarkdownAssetUrlResolver;
}) {
return (
<div className={cn("not-prose text-foreground", className)}>
<Md
remarkPlugins={[remarkGfm, remarkAlert]}
rehypePlugins={[rehypeRaw]}
components={components}
>
{children}
</Md>
</div>
<MarkdownAssetUrlResolverContext.Provider value={resolveAssetUrl ?? null}>
<div className={cn("not-prose text-foreground", className)}>
<Md
remarkPlugins={[remarkGfm, remarkAlert]}
rehypePlugins={[rehypeRaw]}
components={components}
>
{children}
</Md>
</div>
</MarkdownAssetUrlResolverContext.Provider>
);
}
Loading