Markdown Renderer (legacy)
react-markdown based renderer. Consider using pretext-md instead.
markdown/markdown-renderer.tsx
'use client'
import { cn } from "@/lib/utils"
import ZoomableImage from "../ui/infsh/zoomable-image"
import React, { memo, useMemo } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { isCloudInferenceUrl, isVideoUrl, getYouTubeVideoId, stripHtmlComments } from './helpers'
import { YouTubeEmbed } from './youtube-embed'
import { CompactCodeBlock } from '../components/infsh/code-block'
export interface MarkdownRendererProps {
content: string
className?: string
/** Compact mode for dense UI like chat */
compact?: boolean
/** Custom file preview renderer for cloud URLs */
renderFilePreview?: (props: { uri: string; filename: string }) => React.ReactNode
/** Custom image renderer for cloud URLs */
renderCloudImage?: (props: { src: string; alt?: string }) => React.ReactNode
}
const textSizeMap = {
default: {
h1: 'text-xl',
h2: 'text-lg',
h3: 'text-md',
h4: 'text-base',
h5: 'text-sm',
h6: 'text-sm',
p: 'text-sm',
li: 'text-sm',
code: 'text-xs',
pre: 'text-xs',
blockquote: 'text-sm',
ul: 'text-sm',
ol: 'text-sm',
th: 'text-sm',
td: 'text-sm',
},
compact: {
h1: 'text-xl',
h2: 'text-lg',
h3: 'text-base',
h4: 'text-xs',
h5: 'text-xs',
h6: 'text-xs',
p: 'text-xs',
li: 'text-xs',
code: 'text-xs',
pre: 'text-xs',
blockquote: 'text-xs',
ul: 'text-xs',
ol: 'text-xs',
th: 'text-xs',
td: 'text-xs',
},
}
export const MarkdownRenderer = memo(function MarkdownRenderer({
content,
className,
compact = false,
renderFilePreview,
renderCloudImage,
}: MarkdownRendererProps) {
const processedContent = useMemo(
() => stripHtmlComments(content),
[content]
)
const textSize = compact ? textSizeMap.compact : textSizeMap.default
const fontClass = 'legible'
const paragraphClass = cn(textSize.p, 'break-words whitespace-pre-wrap my-0 py-0 text-justify leading-relaxed', fontClass)
return (
<div className={className}>
<Markdown
remarkPlugins={[[remarkGfm, { singleTilde: false }]]}
components={{
// Block elements
h1: ({ children }) => <h1 className={cn(textSize.h1, 'font-bold my-0 [&:first-child]:mt-0 mt-8 py-0', fontClass)}>{children}</h1>,
h2: ({ children }) => <h2 className={cn(textSize.h2, 'font-bold my-0 [&:first-child]:mt-0 mt-8 py-0', fontClass)}>{children}</h2>,
h3: ({ children }) => <h3 className={cn(textSize.h3, 'font-bold my-0 [&:first-child]:mt-0 mt-8 py-0', fontClass)}>{children}</h3>,
h4: ({ children }) => <h4 className={cn(textSize.h4, 'font-bold my-0 [&:first-child]:mt-0 mt-8 py-0', fontClass)}>{children}</h4>,
h5: ({ children }) => <h5 className={cn(textSize.h5, 'font-bold my-0 [&:first-child]:mt-0 mt-8 py-0', fontClass)}>{children}</h5>,
h6: ({ children }) => <h6 className={cn(textSize.h6, 'font-bold my-0 [&:first-child]:mt-0 mt-8 py-0', fontClass)}>{children}</h6>,
p: ({ children }) => {
// Detect if paragraph contains block-level elements that can't be inside <p>
// This includes YouTube embeds, cloud file previews, and images
const hasBlockContent = React.Children.toArray(children).some((child) => {
if (!React.isValidElement(child)) return false
const props = child.props as { href?: string; src?: string }
// Check for YouTube links
if (props.href && getYouTubeVideoId(props.href)) return true
// Check for cloud URLs
if (props.href && isCloudInferenceUrl(props.href)) return true
// Check for images
if (props.src) return true
return false
})
// Use div for block content to avoid invalid <div> inside <p> nesting
return hasBlockContent && !compact ? (
<div className={paragraphClass}>{children}</div>
) : (
<p className={paragraphClass}>{children}</p>
)
},
blockquote: ({ children }) => (
<blockquote className={cn('border-l-4 pl-4 my-2 py-0', fontClass)}>{children}</blockquote>
),
// Lists
ul: ({ children }) => <ul className={cn('list-disc list-outside ml-5 my-0 mt-2 py-0', fontClass)}>{children}</ul>,
ol: ({ children }) => <ol className={cn('list-decimal list-outside ml-5 my-0 mt-2 py-0', fontClass)}>{children}</ol>,
li: ({ children }) => <li className={cn(textSize.li, 'my-0 mt-2 py-0 pl-1', fontClass)}>{children}</li>,
// Inline elements
strong: ({ children }) => <strong className={cn('font-bold', fontClass)}>{children}</strong>,
em: ({ children }) => <em className={cn('italic', fontClass)}>{children}</em>,
code(props) {
const { children, className } = props
const hasLang = /language-(\w+)/.test(className || '')
const hasNewlines = String(children).includes('\n')
const isBlock = hasLang || hasNewlines
if (isBlock) {
return <code className={cn(textSize.code, fontClass, className)}>{children}</code>
}
// Inline code
return (
<span className="inline bg-muted/30 rounded px-1 text-brand-pink/80">
<code className={cn(textSize.code, fontClass)}>{children}</code>
</span>
)
},
pre: ({ children }) => (
<CompactCodeBlock textSize={textSize.pre} className={cn(textSize.pre, fontClass)}>
{children}
</CompactCodeBlock>
),
// Links and images
a: ({ href, children }) => {
if (href && !compact) {
// YouTube videos
const videoId = getYouTubeVideoId(href)
if (videoId) {
return <YouTubeEmbed videoId={videoId} />
}
// Cloud video URLs
if (isCloudInferenceUrl(href) && isVideoUrl(href)) {
return (
<video
src={href}
controls
playsInline
className="my-6 rounded-xl w-full max-w-2xl"
/>
)
}
// Cloud file previews
if (isCloudInferenceUrl(href) && renderFilePreview) {
return renderFilePreview({
uri: href,
filename: typeof children === 'string' ? children : 'file',
})
}
// Default link
return (
<a
href={href}
className={cn('hover:underline', fontClass)}
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
)
}
return <>{children}</>
},
img: ({ src, alt }) => {
if (!src || typeof src !== 'string') return null
if (isCloudInferenceUrl(src) && renderCloudImage) {
return renderCloudImage({ src, alt })
}
return (
<ZoomableImage
src={src}
alt={alt}
className={cn('max-w-full h-auto my-0 py-0 rounded', fontClass)}
/>
)
},
// Table elements
table: ({ children }) => (
<div className={cn('min-w-0 overflow-x-auto my-2 border border-border rounded-md', fontClass)}>
<table className="">{children}</table>
</div>
),
thead: ({ children }) => <thead className={cn('border-b border-border', fontClass)}>{children}</thead>,
tbody: ({ children }) => <tbody className={fontClass}>{children}</tbody>,
tr: ({ children }) => <tr className="border-b border-border last:border-0">{children}</tr>,
th: ({ children }) => <th className={cn('px-3 py-1.5 text-left text-xs text-muted-foreground', fontClass)}>{children}</th>,
td: ({ children }) => <td className={cn('px-3 py-1.5 text-sm', fontClass)}>{children}</td>,
// Horizontal rule
hr: () => <hr className="my-2 border-t" />,
// Strikethrough
s: ({ children }) => <s className="line-through">{children}</s>,
}}
>
{processedContent}
</Markdown>
</div>
)
})
No selection tags yet — empty means no evidence, not lack of support.
DependenciesBundle weight · light
react-markdownremark-gfm