Skip to content
Draft
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
4 changes: 3 additions & 1 deletion packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@
"@radix-ui/react-switch": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.1.4",
"@react-email/components": "^1.0.2",
"@react-email/render": "^2.0.0",
Expand Down Expand Up @@ -171,6 +172,7 @@
"react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.1",
"recharts": "^2.15.3",
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
Expand Down
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+"/";
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

IMAGE_BASE_URL is hardcoded to GitHub's raw.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.tsx Line 42). Hardcoding the URL to GitHub means image resolution will break for all non-GitHub repositories.

Consider either:

  1. Passing an image base URL from the server component (which has access to code host info).
  2. Making the URL construction code-host-aware.
🤖 Prompt for AI Agents
In
`@packages/web/src/app/`[domain]/browse/[...path]/components/pureMarkDownPreviewPanel.tsx
at line 22, IMAGE_BASE_URL is currently hardcoded to GitHub's raw domain which
breaks image resolution for non-GitHub repos; update
pureMarkDownPreviewPanel.tsx to build the image base URL from the repository's
code host instead of assuming raw.githubusercontent.com: either accept a
precomputed imageBaseUrl prop from the server component (which knows the code
host) or add code-host-aware logic that inspects repoName/revisionName and
composes the correct raw file URL for GitHub, GitLab, Bitbucket, and ADO; change
the constant IMAGE_BASE_URL to use the new prop or host-aware builder and adjust
any references in the component to use that value.

Copy link
Contributor

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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@brendan-kellam Thanks 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
Learnt from: brendan-kellam
Repo: sourcebot-dev/sourcebot PR: 863
File: packages/web/src/app/[domain]/browse/[...path]/components/pureMarkDownPreviewPanel.tsx:22-22
Timestamp: 2026-02-08T00:36:03.683Z
Learning: In the Sourcebot codebase, the preferred architecture for serving markdown assets (images, videos, etc.) in preview panels is to serve them through Sourcebot's own APIs rather than constructing direct URLs to code hosts like raw.githubusercontent.com. This provides a uniform solution across all supported code hosts (GitHub, GitLab, Bitbucket, Azure DevOps).

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

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
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Hardcoded bg-white and unconditional dark class cause theme issues.

  • bg-white doesn't respect the app's theme system. Use bg-background for both modes.
  • The dark class on the <article> is applied unconditionally, which will cause github-markdown-css to always render dark-mode styles, even when the app is in light mode.
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 next-themes) to conditionally apply github-markdown-css's dark/light mode class.

As per coding guidelines, "Use Tailwind color classes directly (e.g., border-border, bg-card, text-foreground) instead of CSS variable syntax (e.g., border-[var(--border)])".

🤖 Prompt for AI Agents
In
`@packages/web/src/app/`[domain]/browse/[...path]/components/pureMarkDownPreviewPanel.tsx
around lines 25 - 26, In pureMarkDownPreviewPanel.tsx replace the hardcoded
bg-white on the wrapper with bg-background and remove the unconditional "dark"
class on the <article>; instead wire the component to the app theme (e.g., use
next-themes or the existing theme context) and conditionally add the "dark"
class to the article only when the current theme is dark so github-markdown-css
toggles correctly, and ensure you use Tailwind tokens like
bg-background/border-border/text-foreground rather than CSS variable syntax;
update the JSX in the PureMarkDownPreviewPanel component to read the theme and
apply classes accordingly.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image +1

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@brendan-kellam The Comment image shows the CHANGELOG.md file rendered in preview mode with proper markdown formatting—version sections, issue links, and structured headings are all displaying correctly in dark theme. The PR description images demonstrate the README preview working beautifully with the Sourcebot branding, badges, and features section.

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 bg-white and unconditional dark class), or would you like me to help with that implementation? Happy to provide guidance on wiring this up to next-themes if needed.


🧠 Learnings used
Learnt from: CR
Repo: sourcebot-dev/sourcebot PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-02-05T22:59:12.488Z
Learning: Applies to **/*.{jsx,tsx} : Use Tailwind color classes directly (e.g., `border-border`, `bg-card`, `text-foreground`) instead of CSS variable syntax (e.g., `border-[var(--border)]`)

<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeHighlight]}
Comment on lines +27 to +29
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Security: rehype-raw without rehype-sanitize enables XSS.

rehypeRaw passes raw HTML through to the rendered output. Without rehypeSanitize (already a project dependency), any markdown source containing <script>, <iframe>, onerror handlers, etc., will execute in the user's browser. This is especially dangerous since the markdown content comes from external repositories.

🔒 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 — rehypeSanitize must come after rehypeRaw:

-                        rehypePlugins={[rehypeRaw, rehypeHighlight]}
+                        rehypePlugins={[rehypeRaw, rehypeSanitize, rehypeHighlight]}

You may need to extend defaultSchema to allowlist elements like <video>, <source>, etc., that your custom components rely on.

🤖 Prompt for AI Agents
In
`@packages/web/src/app/`[domain]/browse/[...path]/components/pureMarkDownPreviewPanel.tsx
around lines 27 - 29, The ReactMarkdown rendering currently uses rehypeRaw
without sanitization (see ReactMarkdown component and the rehypePlugins array
containing rehypeRaw and rehypeHighlight); import and add rehypeSanitize to the
rehypePlugins list immediately after rehypeRaw so raw HTML is sanitized before
rendering, and if your UI requires additional tags/attributes (e.g., video,
source, iframe attrs), extend rehypeSanitize's schema (starting from
defaultSchema) to allowlist those elements/attributes before passing it to
rehypeSanitize.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@brendan-kellam Thanks for acknowledging this! Given the critical nature of the XSS vulnerability, I'd recommend addressing this before merging the PR. The fix is straightforward—just adding rehype-sanitize to the pipeline—but it's essential for safely rendering markdown from external repositories.

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Video src URLs are not resolved for relative paths.

The img and source handlers rewrite relative URLs using IMAGE_BASE_URL, but the video handler passes src through as-is. Relative video paths in markdown will be broken.

🤖 Prompt for AI Agents
In
`@packages/web/src/app/`[domain]/browse/[...path]/components/pureMarkDownPreviewPanel.tsx
around lines 86 - 99, The video renderer in pureMarkDownPreviewPanel.tsx (the
video: ({ src = "", ...props }) => { ... } handler) currently passes src through
unchanged, so relative video paths break; update this handler to rewrite
relative src the same way the img and source handlers do (use the same
IMAGE_BASE_URL or the existing resolve logic used for img/source) before
rendering the <video>, ensuring the src is resolved for both absolute and
relative URLs while preserving {...props} and controls.


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
Expand Up @@ -3,16 +3,16 @@ import { PathHeader } from "@/app/[domain]/components/pathHeader";
import { Separator } from "@/components/ui/separator";
import { cn, getCodeHostInfoForRepo, isServiceError } from "@/lib/utils";
import Image from "next/image";
import { PureCodePreviewPanel } from "./pureCodePreviewPanel";
import { SourcePreviewPanelClient } from "./codePreviewPanelClient";
import { getFileSource } from '@/features/git';

interface CodePreviewPanelProps {
interface SourcePreviewPanelProps {
path: string;
repoName: string;
revisionName?: string;
}

export const CodePreviewPanel = async ({ path, repoName, revisionName }: CodePreviewPanelProps) => {
export const SourcePreviewPanel = async ({ path, repoName, revisionName }: SourcePreviewPanelProps) => {
const [fileSourceResponse, repoInfoResponse] = await Promise.all([
getFileSource({
path,
Expand Down Expand Up @@ -74,7 +74,7 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName }: CodePre
)}
</div>
<Separator />
<PureCodePreviewPanel
<SourcePreviewPanelClient
source={fileSourceResponse.source}
language={fileSourceResponse.language}
repoName={repoName}
Expand Down
4 changes: 2 additions & 2 deletions packages/web/src/app/[domain]/browse/[...path]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Suspense } from "react";
import { getBrowseParamsFromPathParam } from "../hooks/utils";
import { CodePreviewPanel } from "./components/codePreviewPanel";
import { SourcePreviewPanel } from "./components/sourcePreviewPanel";
import { Loader2 } from "lucide-react";
import { TreePreviewPanel } from "./components/treePreviewPanel";
import { Metadata } from "next";
Expand Down Expand Up @@ -90,7 +90,7 @@ export default async function BrowsePage(props: BrowsePageProps) {
</div>
}>
{pathType === 'blob' ? (
<CodePreviewPanel
<SourcePreviewPanel
path={path}
repoName={repoName}
revisionName={revisionName}
Expand Down
61 changes: 61 additions & 0 deletions packages/web/src/components/ui/toggle-group.tsx
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 }
Loading