rich-text.tsx
1,198 bytes
| 1 | import { Fragment } from 'react'; |
|---|---|
| 2 | import { cn } from '@/lib/utils'; |
| 3 | |
| 4 | /** |
| 5 | * Minimal, XSS-safe renderer for model output. |
| 6 | * |
| 7 | * Model text is untrusted, so we never set innerHTML - we build React nodes. |
| 8 | * We support just enough Markdown to read well: paragraphs, `**bold**`, and |
| 9 | * `inline code`. |
| 10 | */ |
| 11 | export function RichText({ text, className }: { text: string; className?: string }) { |
| 12 | const paragraphs = text.split(/\n{2,}/); |
| 13 | return ( |
| 14 | <div className={cn('prose-debate', className)}> |
| 15 | {paragraphs.map((para, i) => ( |
| 16 | <p key={i}>{renderInline(para)}</p> |
| 17 | ))} |
| 18 | </div> |
| 19 | ); |
| 20 | } |
| 21 | |
| 22 | function renderInline(text: string): React.ReactNode { |
| 23 | // Split on **bold** and `code`, keeping delimiters. |
| 24 | const parts = text.split(/(\*\*[^*]+\*\*|`[^`]+`)/g); |
| 25 | return parts.map((part, i) => { |
| 26 | if (part.startsWith('**') && part.endsWith('**')) { |
| 27 | return <strong key={i}>{part.slice(2, -2)}</strong>; |
| 28 | } |
| 29 | if (part.startsWith('`') && part.endsWith('`')) { |
| 30 | return ( |
| 31 | <code key={i} className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]"> |
| 32 | {part.slice(1, -1)} |
| 33 | </code> |
| 34 | ); |
| 35 | } |
| 36 | return <Fragment key={i}>{part}</Fragment>; |
| 37 | }); |
| 38 | } |
| 39 | |