Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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 @@ -81,7 +81,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 @@ -169,6 +170,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+"/";
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">
Copy link
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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 +311 to +313
Copy link
Copy Markdown
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
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

+1

Copy link
Copy Markdown
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>
);
},
Copy link
Copy Markdown
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