mirror of
https://github.com/molvqingtai/WebChat.git
synced 2025-11-25 19:27:34 +08:00
- Create safeUrl utility function to filter malicious URLs - Allow safe protocols: http/https/mailto/xmpp/data:image/video/audio - Block dangerous protocols: javascript/vbscript/file/about - Apply safeUrl to Markdown links and Avatar images - Auto-render image URLs as <img> in Markdown - Add skipAnimationFrameInResizeObserver for better performance 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
120 lines
4.6 KiB
TypeScript
120 lines
4.6 KiB
TypeScript
import { type FC } from 'react'
|
|
import ReactMarkdown from 'react-markdown'
|
|
import remarkGfm from 'remark-gfm'
|
|
import remarkBreaks from 'remark-breaks'
|
|
import { cn, safeUrl } from '@/utils'
|
|
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
|
|
|
export interface MarkdownProps {
|
|
children?: string
|
|
className?: string
|
|
}
|
|
|
|
/**
|
|
* Sanitize URL to prevent XSS attacks
|
|
* Supports http/https URLs, data URLs (for images), mailto, xmpp, and relative URLs
|
|
* https://github.com/remarkjs/react-markdown/blob/baad6c53764e34c4ead41e2eaba176acfc87538a/lib/index.js#L293
|
|
*/
|
|
const urlTransform = (value: string) => safeUrl(value)
|
|
|
|
const Markdown: FC<MarkdownProps> = ({ children = '', className }) => {
|
|
return (
|
|
<div className={cn(className, 'prose prose-sm prose-slate break-words dark:text-slate-50')}>
|
|
<ReactMarkdown
|
|
urlTransform={urlTransform}
|
|
components={{
|
|
h1: ({ className, ...props }) => (
|
|
<h1 className={cn('my-2 mt-0 font-semibold text-2xl dark:text-slate-50', className)} {...props} />
|
|
),
|
|
h2: ({ className, ...props }) => (
|
|
<h2 className={cn('mb-2 mt-0 font-semibold dark:text-slate-50', className)} {...props} />
|
|
),
|
|
h3: ({ className, ...props }) => (
|
|
<h3 className={cn('mb-2 mt-0 font-semibold dark:text-slate-50', className)} {...props} />
|
|
),
|
|
h4: ({ className, ...props }) => (
|
|
<h4 className={cn('mb-2 mt-0 font-semibold dark:text-slate-50', className)} {...props} />
|
|
),
|
|
img: ({ className, alt, ...props }) => (
|
|
<img className={cn('my-2 max-w-[100%] rounded', className)} alt={alt} {...props} />
|
|
),
|
|
strong: ({ className, ...props }) => <strong className={cn('dark:text-slate-50', className)} {...props} />,
|
|
a: ({ className, href, ...props }) => {
|
|
// Check if link is an image URL
|
|
const isImage = href && /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)$/i.test(href)
|
|
return isImage ? (
|
|
<img src={href} alt="" className={cn('my-2 max-w-[100%] rounded', className)} />
|
|
) : (
|
|
<a
|
|
className={cn('text-blue-500', className)}
|
|
href={href}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
{...props}
|
|
/>
|
|
)
|
|
},
|
|
ul: ({ className, ...props }) => {
|
|
Reflect.deleteProperty(props, 'ordered')
|
|
return <ul className={cn('text-sm [&:not([depth="0"])]:my-0 ', className)} {...props} />
|
|
},
|
|
input: ({ className, ...props }) => <input className={cn('my-0', className)} {...props} />,
|
|
table: ({ className, ...props }) => (
|
|
<div className="my-2 w-full">
|
|
<ScrollArea scrollLock={false}>
|
|
<table className={cn('my-0 w-full rounded-md', className)} {...props} />
|
|
<ScrollBar orientation="horizontal" />
|
|
</ScrollArea>
|
|
</div>
|
|
),
|
|
tr: ({ className, ...props }) => {
|
|
return <tr className={cn('m-0 border-t p-0 even:bg-muted', className)} {...props} />
|
|
},
|
|
th: ({ className, ...props }) => {
|
|
return (
|
|
<th
|
|
className={cn(
|
|
'border px-3 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right',
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
)
|
|
},
|
|
td: ({ className, ...props }) => {
|
|
return (
|
|
<td
|
|
className={cn(
|
|
'border px-3 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right',
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
)
|
|
},
|
|
pre: ({ className, ...props }) => <pre className={cn('my-2', className)} {...props} />,
|
|
/**
|
|
* TODO: Code highlight
|
|
* @see https://github.com/remarkjs/react-markdown/issues/680
|
|
* @see https://shiki.style/guide/install#usage
|
|
*
|
|
*/
|
|
code: ({ className, ...props }) => (
|
|
<ScrollArea className="overscroll-y-auto" scrollLock={false}>
|
|
<code className={cn('text-sm', className)} {...props}></code>
|
|
<ScrollBar orientation="horizontal" />
|
|
</ScrollArea>
|
|
)
|
|
}}
|
|
remarkPlugins={[remarkGfm, remarkBreaks]}
|
|
>
|
|
{children}
|
|
</ReactMarkdown>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
Markdown.displayName = 'Markdown'
|
|
|
|
export { Markdown }
|