-
Notifications
You must be signed in to change notification settings - Fork 223
feat: add markdown preview with toggle to view raw source #863
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| 'use client'; | ||
|
|
||
| import { useState } from "react"; | ||
| import { PureCodePreviewPanel } from "./pureCodePreviewPanel"; | ||
| import { PureMarkDownPreviewPanel } from "./pureMarkDownPreviewPanel"; | ||
| import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; | ||
|
|
||
| interface SourcePreviewPanelClientProps { | ||
| path: string; | ||
| repoName: string; | ||
| revisionName: string; | ||
| source: string; | ||
| language: string; | ||
| } | ||
|
|
||
| export const SourcePreviewPanelClient = ({ | ||
| source, | ||
| language, | ||
| path, | ||
| repoName, | ||
| revisionName, | ||
| }: SourcePreviewPanelClientProps) => { | ||
| const [viewMode, setViewMode] = useState<string>("preview"); | ||
| const isMarkdown = language === 'Markdown'; | ||
|
|
||
| return ( | ||
| <> | ||
| {isMarkdown && ( | ||
| <> | ||
| <div className="p-2 border-b flex"> | ||
| <ToggleGroup | ||
| type="single" | ||
| defaultValue="preview" | ||
| value={viewMode} | ||
| onValueChange={(value) => value && setViewMode(value)} | ||
| > | ||
| <ToggleGroupItem | ||
| value="preview" | ||
| aria-label="Preview" | ||
| className="w-fit px-4" | ||
| > | ||
| Preview | ||
| </ToggleGroupItem> | ||
| <ToggleGroupItem | ||
| value="code" | ||
| aria-label="Code" | ||
| className="w-fit px-4" | ||
| > | ||
| Code | ||
| </ToggleGroupItem> | ||
| </ToggleGroup> | ||
| </div> | ||
| </> | ||
| )} | ||
| {isMarkdown && viewMode === "preview" ? ( | ||
| <PureMarkDownPreviewPanel source={source} repoName={repoName} revisionName={revisionName} /> | ||
| ) : ( | ||
| <PureCodePreviewPanel | ||
| source={source} | ||
| language={language} | ||
| repoName={repoName} | ||
| path={path} | ||
| revisionName={revisionName} | ||
| /> | ||
| )} | ||
| </> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,170 @@ | ||
| 'use client'; | ||
|
|
||
| import { ScrollArea } from "@/components/ui/scroll-area"; | ||
| import ReactMarkdown from "react-markdown"; | ||
| import remarkGfm from "remark-gfm"; | ||
| import rehypeHighlight from "rehype-highlight"; | ||
| import "github-markdown-css/github-markdown.css"; | ||
| import "highlight.js/styles/github-dark.css"; | ||
| import rehypeRaw from "rehype-raw"; | ||
|
|
||
| interface PureMarkDownPreviewPanelProps { | ||
| source: string; | ||
| repoName: string; | ||
| revisionName: string; | ||
| } | ||
|
|
||
| export const PureMarkDownPreviewPanel = ({ | ||
| source, | ||
| repoName, | ||
| revisionName, | ||
| }: PureMarkDownPreviewPanelProps) => { | ||
| const IMAGE_BASE_URL = "https://raw.githubusercontent.com/"+repoName.split("/").slice(1).join("/")+"/"+revisionName+"/"; | ||
| return ( | ||
| <ScrollArea className="h-full overflow-auto flex-1"> | ||
| <div className="w-full flex justify-center bg-white dark:bg-background"> | ||
| <article className="markdown-body dark dark:bg-background w-full max-w-4xl px-6 py-10"> | ||
|
Comment on lines
+25
to
+26
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hardcoded
Proposed fix- <div className="w-full flex justify-center bg-white dark:bg-background">
- <article className="markdown-body dark dark:bg-background w-full max-w-4xl px-6 py-10">
+ <div className="w-full flex justify-center bg-background">
+ <article className="markdown-body w-full max-w-4xl px-6 py-10">You'll need to integrate with the app's theme (e.g., via As per coding guidelines, "Use Tailwind color classes directly (e.g., 🤖 Prompt for AI Agents
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Thanks for the screenshot and the +1! The markdown preview feature is working well. Just to clarify—are you planning to address the theme-switching concern I flagged (the hardcoded 🧠 Learnings used |
||
| <ReactMarkdown | ||
| remarkPlugins={[remarkGfm]} | ||
| rehypePlugins={[rehypeRaw, rehypeHighlight]} | ||
|
Comment on lines
+27
to
+29
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Security:
🔒 Proposed fix: add rehype-sanitize import rehypeHighlight from "rehype-highlight";
import "github-markdown-css/github-markdown.css";
import "highlight.js/styles/github-dark.css";
import rehypeRaw from "rehype-raw";
+import rehypeSanitize, { defaultSchema } from "rehype-sanitize";Then update the plugins array — - rehypePlugins={[rehypeRaw, rehypeHighlight]}
+ rehypePlugins={[rehypeRaw, rehypeSanitize, rehypeHighlight]}You may need to extend 🤖 Prompt for AI Agents
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. +1
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| components={{ | ||
| pre: ({ children }) => ( | ||
| <pre className="rounded-md overflow-x-auto"> | ||
| {children} | ||
| </pre> | ||
| ), | ||
|
|
||
| source: ({ srcSet = "", ...props }) => { | ||
| if (typeof srcSet !== "string") return null; | ||
|
|
||
| let resolvedSrcset = srcSet; | ||
|
|
||
| if ( | ||
| srcSet.startsWith(".github/") || | ||
| !srcSet.startsWith("http") | ||
| ) { | ||
| resolvedSrcset = | ||
| IMAGE_BASE_URL + | ||
| srcSet.replace(/^\.\//, ""); | ||
| } | ||
|
|
||
| return ( | ||
| <source | ||
| srcSet={resolvedSrcset} | ||
| {...props} | ||
| /> | ||
| ); | ||
| }, | ||
|
|
||
| img: ({ src = "", alt, ...props }) => { | ||
| if (typeof src !== "string") return null; | ||
|
|
||
| let resolvedSrc = src; | ||
|
|
||
| if ( | ||
| src.startsWith(".github/") || | ||
| (!src.startsWith("http://") && | ||
| !src.startsWith("https://")) | ||
| ) { | ||
| resolvedSrc = | ||
| IMAGE_BASE_URL + | ||
| src.replace(/^\.\//, ""); | ||
| } | ||
|
|
||
| return ( | ||
| // eslint-disable-next-line @next/next/no-img-element | ||
| <img | ||
| src={resolvedSrc} | ||
| alt={alt || ""} | ||
| className="max-w-full h-auto" | ||
| loading="lazy" | ||
| {...props} | ||
| /> | ||
| ); | ||
| }, | ||
|
|
||
| video: ({ src = "", ...props }) => { | ||
| return ( | ||
| <video | ||
| src={src} | ||
| controls | ||
| preload="metadata" | ||
| className="max-w-full h-auto my-4" | ||
| {...props} | ||
| > | ||
| Your browser does not support the video | ||
| tag. | ||
| </video> | ||
| ); | ||
| }, | ||
|
Comment on lines
+86
to
+99
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Video The 🤖 Prompt for AI Agents |
||
|
|
||
| code({ className, children, ...props }) { | ||
| const isBlock = | ||
| className?.startsWith("language-"); | ||
|
|
||
| if (!isBlock) { | ||
| return ( | ||
| <code | ||
| className="px-1 py-0.5 rounded" | ||
| {...props} | ||
| > | ||
| {children} | ||
| </code> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <code className={className} {...props}> | ||
| {children} | ||
| </code> | ||
| ); | ||
| }, | ||
|
|
||
| table: ({ children }) => ( | ||
| <div className="overflow-x-auto"> | ||
| <table>{children}</table> | ||
| </div> | ||
| ), | ||
|
|
||
| a: ({ children, href, ...props }) => { | ||
| // Check if link is a video URL | ||
| if ( | ||
| href && | ||
| href.match( | ||
| /^https:\/\/github\.com\/user-attachments\/assets\/.+$/, | ||
| ) | ||
| ) { | ||
| return ( | ||
| <video | ||
| src={href} | ||
| controls | ||
| preload="metadata" | ||
| className="max-w-full h-auto my-4" | ||
| > | ||
| Your browser does not support the | ||
| video tag. | ||
| </video> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <a | ||
| href={href} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="text-blue-600 hover:underline" | ||
| {...props} | ||
| > | ||
| {children} | ||
| </a> | ||
| ); | ||
| }, | ||
| }} | ||
| > | ||
| {source} | ||
| </ReactMarkdown> | ||
| </article> | ||
| </div> | ||
| </ScrollArea> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| "use client" | ||
|
|
||
| import * as React from "react" | ||
| import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group" | ||
| import { type VariantProps } from "class-variance-authority" | ||
|
|
||
| import { cn } from "@/lib/utils" | ||
| import { toggleVariants } from "@/components/ui/toggle" | ||
|
|
||
| const ToggleGroupContext = React.createContext< | ||
| VariantProps<typeof toggleVariants> | ||
| >({ | ||
| size: "default", | ||
| variant: "default", | ||
| }) | ||
|
|
||
| const ToggleGroup = React.forwardRef< | ||
| React.ElementRef<typeof ToggleGroupPrimitive.Root>, | ||
| React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> & | ||
| VariantProps<typeof toggleVariants> | ||
| >(({ className, variant, size, children, ...props }, ref) => ( | ||
| <ToggleGroupPrimitive.Root | ||
| ref={ref} | ||
| className={cn("flex items-center justify-center gap-1", className)} | ||
| {...props} | ||
| > | ||
| <ToggleGroupContext.Provider value={{ variant, size }}> | ||
| {children} | ||
| </ToggleGroupContext.Provider> | ||
| </ToggleGroupPrimitive.Root> | ||
| )) | ||
|
|
||
| ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName | ||
|
|
||
| const ToggleGroupItem = React.forwardRef< | ||
| React.ElementRef<typeof ToggleGroupPrimitive.Item>, | ||
| React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> & | ||
| VariantProps<typeof toggleVariants> | ||
| >(({ className, children, variant, size, ...props }, ref) => { | ||
| const context = React.useContext(ToggleGroupContext) | ||
|
|
||
| return ( | ||
| <ToggleGroupPrimitive.Item | ||
| ref={ref} | ||
| className={cn( | ||
| toggleVariants({ | ||
| variant: context.variant || variant, | ||
| size: context.size || size, | ||
| }), | ||
| className | ||
| )} | ||
| {...props} | ||
| > | ||
| {children} | ||
| </ToggleGroupPrimitive.Item> | ||
| ) | ||
| }) | ||
|
|
||
| ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName | ||
|
|
||
| export { ToggleGroup, ToggleGroupItem } |

Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IMAGE_BASE_URLis hardcoded to GitHub'sraw.githubusercontent.com.This component is used in a codebase that supports multiple code hosts (GitHub, GitLab, Bitbucket, Azure DevOps — as evidenced by ADO handling in
codePreviewPanel.tsxLine 42). Hardcoding the URL to GitHub means image resolution will break for all non-GitHub repositories.Consider either:
🤖 Prompt for AI Agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think a more complete solution may be to serve up assets from our own apis instead of having to hit the code host. That is a larger change however, so I'm not opposed with having something like what you have now as a MVP solution.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@brendan-kellamThanks for the context! That makes sense—serving assets through your own APIs would indeed be a cleaner and more maintainable solution that works uniformly across all code hosts.I understand you're okay with the current GitHub-hardcoded approach as an MVP to get the feature out. Would you like me to create a follow-up issue to track the future work of serving markdown assets (images, videos, etc.) through Sourcebot's own APIs instead of direct code-host URLs? That way the technical debt is documented and can be prioritized later.
✏️ Learnings added