Skip to content

Commit 39ce586

Browse files
feat: add markdown preview with toggle to view raw source
Fixes #794
1 parent 6b79cbf commit 39ce586

File tree

5 files changed

+306
-3
lines changed

5 files changed

+306
-3
lines changed

packages/web/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@
8181
"@radix-ui/react-switch": "^1.2.4",
8282
"@radix-ui/react-tabs": "^1.1.2",
8383
"@radix-ui/react-toast": "^1.2.2",
84-
"@radix-ui/react-toggle": "^1.1.0",
84+
"@radix-ui/react-toggle": "^1.1.10",
85+
"@radix-ui/react-toggle-group": "^1.1.11",
8586
"@radix-ui/react-tooltip": "^1.1.4",
8687
"@react-email/components": "^1.0.2",
8788
"@react-email/render": "^2.0.0",
@@ -139,6 +140,7 @@
139140
"escape-string-regexp": "^5.0.0",
140141
"fast-deep-equal": "^3.1.3",
141142
"fuse.js": "^7.0.0",
143+
"github-markdown-css": "^5.9.0",
142144
"google-auth-library": "^10.1.0",
143145
"graphql": "^16.9.0",
144146
"http-status-codes": "^2.3.0",
@@ -169,6 +171,7 @@
169171
"react-markdown": "^10.1.0",
170172
"react-resizable-panels": "^2.1.1",
171173
"recharts": "^2.15.3",
174+
"rehype-highlight": "^7.0.2",
172175
"rehype-raw": "^7.0.0",
173176
"rehype-sanitize": "^6.0.0",
174177
"remark-gfm": "^4.0.1",

packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { PathHeader } from "@/app/[domain]/components/pathHeader";
33
import { Separator } from "@/components/ui/separator";
44
import { cn, getCodeHostInfoForRepo, isServiceError } from "@/lib/utils";
55
import Image from "next/image";
6-
import { PureCodePreviewPanel } from "./pureCodePreviewPanel";
6+
import { CodePreviewPanelClient } from "./codePreviewPanelClient";
77
import { getFileSource } from '@/features/git';
88

99
interface CodePreviewPanelProps {
@@ -74,7 +74,7 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName }: CodePre
7474
)}
7575
</div>
7676
<Separator />
77-
<PureCodePreviewPanel
77+
<CodePreviewPanelClient
7878
source={fileSourceResponse.source}
7979
language={fileSourceResponse.language}
8080
repoName={repoName}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
'use client';
2+
3+
import { useState } from "react";
4+
import { PureCodePreviewPanel } from "./pureCodePreviewPanel";
5+
import { PureMarkDownPreviewPanel } from "./pureMarkDownPreviewPanel";
6+
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
7+
8+
interface CodePreviewPanelClientProps {
9+
path: string;
10+
repoName: string;
11+
revisionName: string;
12+
source: string;
13+
language: string;
14+
}
15+
16+
export const CodePreviewPanelClient = ({
17+
source,
18+
language,
19+
path,
20+
repoName,
21+
revisionName,
22+
}: CodePreviewPanelClientProps) => {
23+
const [viewMode, setViewMode] = useState<string>("preview");
24+
const isMarkdown = language.toLowerCase() === "gcc machine description" || language.toLowerCase() === "md" || path.toLocaleLowerCase().endsWith(".md") || path.toLocaleLowerCase().endsWith(".markdown");
25+
26+
console.log({language,path,repoName,revisionName});
27+
return (
28+
<>
29+
{isMarkdown && (
30+
<>
31+
<div className="p-2 border-b flex">
32+
<ToggleGroup
33+
type="single"
34+
defaultValue="preview"
35+
value={viewMode}
36+
onValueChange={(value) => value && setViewMode(value)}
37+
>
38+
<ToggleGroupItem
39+
value="preview"
40+
aria-label="Preview"
41+
className="w-fit px-4"
42+
>
43+
Preview
44+
</ToggleGroupItem>
45+
<ToggleGroupItem
46+
value="code"
47+
aria-label="Code"
48+
className="w-fit px-4"
49+
>
50+
Code
51+
</ToggleGroupItem>
52+
</ToggleGroup>
53+
</div>
54+
</>
55+
)}
56+
{isMarkdown && viewMode === "preview" ? (
57+
<PureMarkDownPreviewPanel source={source} repoName={repoName} revisionName={revisionName} />
58+
) : (
59+
<PureCodePreviewPanel
60+
source={source}
61+
language={language}
62+
repoName={repoName}
63+
path={path}
64+
revisionName={revisionName}
65+
/>
66+
)}
67+
</>
68+
);
69+
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
'use client';
2+
3+
import { ScrollArea } from "@/components/ui/scroll-area";
4+
import ReactMarkdown from "react-markdown";
5+
import remarkGfm from "remark-gfm";
6+
import rehypeHighlight from "rehype-highlight";
7+
import "github-markdown-css/github-markdown.css";
8+
import "highlight.js/styles/github-dark.css";
9+
import rehypeRaw from "rehype-raw";
10+
11+
interface PureMarkDownPreviewPanelProps {
12+
source: string;
13+
repoName: string;
14+
revisionName: string;
15+
}
16+
17+
export const PureMarkDownPreviewPanel = ({
18+
source,
19+
repoName,
20+
revisionName,
21+
}: PureMarkDownPreviewPanelProps) => {
22+
const IMAGE_BASE_URL = "https://raw.githubusercontent.com/"+repoName.split("/").slice(1).join("/")+"/"+revisionName+"/";
23+
return (
24+
<ScrollArea className="h-full overflow-auto flex-1">
25+
<div className="w-full flex justify-center bg-white dark:bg-background">
26+
<article className="markdown-body dark dark:bg-background w-full max-w-4xl px-6 py-10">
27+
<ReactMarkdown
28+
remarkPlugins={[remarkGfm]}
29+
rehypePlugins={[rehypeRaw, rehypeHighlight]}
30+
components={{
31+
pre: ({ children }) => (
32+
<pre className="rounded-md overflow-x-auto">
33+
{children}
34+
</pre>
35+
),
36+
37+
source: ({ srcSet = "", ...props }) => {
38+
if (typeof srcSet !== "string") return null;
39+
40+
let resolvedSrcset = srcSet;
41+
42+
if (
43+
srcSet.startsWith(".github/") ||
44+
!srcSet.startsWith("http")
45+
) {
46+
resolvedSrcset =
47+
IMAGE_BASE_URL +
48+
srcSet.replace(/^\.\//, "");
49+
}
50+
51+
return (
52+
<source
53+
srcSet={resolvedSrcset}
54+
{...props}
55+
/>
56+
);
57+
},
58+
59+
img: ({ src = "", alt, ...props }) => {
60+
if (typeof src !== "string") return null;
61+
62+
let resolvedSrc = src;
63+
64+
if (
65+
src.startsWith(".github/") ||
66+
(!src.startsWith("http://") &&
67+
!src.startsWith("https://"))
68+
) {
69+
resolvedSrc =
70+
IMAGE_BASE_URL +
71+
src.replace(/^\.\//, "");
72+
}
73+
74+
return (
75+
// eslint-disable-next-line @next/next/no-img-element
76+
<img
77+
src={resolvedSrc}
78+
alt={alt || ""}
79+
className="max-w-full h-auto"
80+
loading="lazy"
81+
{...props}
82+
/>
83+
);
84+
},
85+
86+
video: ({ src = "", ...props }) => {
87+
return (
88+
<video
89+
src={src}
90+
controls
91+
preload="metadata"
92+
className="max-w-full h-auto my-4"
93+
{...props}
94+
>
95+
Your browser does not support the video
96+
tag.
97+
</video>
98+
);
99+
},
100+
101+
code({ className, children, ...props }) {
102+
const isBlock =
103+
className?.startsWith("language-");
104+
105+
if (!isBlock) {
106+
return (
107+
<code
108+
className="px-1 py-0.5 rounded"
109+
{...props}
110+
>
111+
{children}
112+
</code>
113+
);
114+
}
115+
116+
return (
117+
<code className={className} {...props}>
118+
{children}
119+
</code>
120+
);
121+
},
122+
123+
table: ({ children }) => (
124+
<div className="overflow-x-auto">
125+
<table>{children}</table>
126+
</div>
127+
),
128+
129+
a: ({ children, href, ...props }) => {
130+
// Check if link is a video URL
131+
if (
132+
href &&
133+
href.match(
134+
/^https:\/\/github\.com\/user-attachments\/assets\/.+$/,
135+
)
136+
) {
137+
return (
138+
<video
139+
src={href}
140+
controls
141+
preload="metadata"
142+
className="max-w-full h-auto my-4"
143+
>
144+
Your browser does not support the
145+
video tag.
146+
</video>
147+
);
148+
}
149+
150+
return (
151+
<a
152+
href={href}
153+
target="_blank"
154+
rel="noopener noreferrer"
155+
className="text-blue-600 hover:underline"
156+
{...props}
157+
>
158+
{children}
159+
</a>
160+
);
161+
},
162+
}}
163+
>
164+
{source}
165+
</ReactMarkdown>
166+
</article>
167+
</div>
168+
</ScrollArea>
169+
);
170+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"use client"
2+
3+
import * as React from "react"
4+
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
5+
import { type VariantProps } from "class-variance-authority"
6+
7+
import { cn } from "@/lib/utils"
8+
import { toggleVariants } from "@/components/ui/toggle"
9+
10+
const ToggleGroupContext = React.createContext<
11+
VariantProps<typeof toggleVariants>
12+
>({
13+
size: "default",
14+
variant: "default",
15+
})
16+
17+
const ToggleGroup = React.forwardRef<
18+
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
19+
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
20+
VariantProps<typeof toggleVariants>
21+
>(({ className, variant, size, children, ...props }, ref) => (
22+
<ToggleGroupPrimitive.Root
23+
ref={ref}
24+
className={cn("flex items-center justify-center gap-1", className)}
25+
{...props}
26+
>
27+
<ToggleGroupContext.Provider value={{ variant, size }}>
28+
{children}
29+
</ToggleGroupContext.Provider>
30+
</ToggleGroupPrimitive.Root>
31+
))
32+
33+
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
34+
35+
const ToggleGroupItem = React.forwardRef<
36+
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
37+
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
38+
VariantProps<typeof toggleVariants>
39+
>(({ className, children, variant, size, ...props }, ref) => {
40+
const context = React.useContext(ToggleGroupContext)
41+
42+
return (
43+
<ToggleGroupPrimitive.Item
44+
ref={ref}
45+
className={cn(
46+
toggleVariants({
47+
variant: context.variant || variant,
48+
size: context.size || size,
49+
}),
50+
className
51+
)}
52+
{...props}
53+
>
54+
{children}
55+
</ToggleGroupPrimitive.Item>
56+
)
57+
})
58+
59+
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
60+
61+
export { ToggleGroup, ToggleGroupItem }

0 commit comments

Comments
 (0)