Spaces:
Running
Running
| import type { ReactNode } from 'react'; | |
| import type { CodeTheme } from '../../lib/code-highlighter'; | |
| import { StreamingCodeBlock } from './StreamingCodeBlock'; | |
| interface MarkdownContentProps { | |
| content: string; | |
| theme: CodeTheme; | |
| streaming?: boolean; | |
| } | |
| function safeLink(value: string): string | null { | |
| try { | |
| const url = new URL(value, globalThis.location?.href ?? 'https://localhost/'); | |
| return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| function inlineMarkdown(value: string): ReactNode[] { | |
| const parts = value.split(/(`[^`\n]+`|\[[^\]\n]+\]\([^\s)]+\))/g); | |
| return parts.map((part, index) => { | |
| if (part.startsWith('`') && part.endsWith('`')) { | |
| return <code key={index}>{part.slice(1, -1)}</code>; | |
| } | |
| const link = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/); | |
| if (link) { | |
| const href = safeLink(link[2] ?? ''); | |
| if (href) { | |
| return <a key={index} href={href} target="_blank" rel="noreferrer">{link[1]}</a>; | |
| } | |
| } | |
| return part; | |
| }); | |
| } | |
| function renderTextBlock(lines: string[], key: string): ReactNode { | |
| const heading = lines.length === 1 ? lines[0]?.match(/^(#{1,4})\s+(.+)$/) : null; | |
| if (heading) { | |
| const level = heading[1]?.length ?? 2; | |
| const content = inlineMarkdown(heading[2] ?? ''); | |
| if (level === 1) return <h3 key={key}>{content}</h3>; | |
| if (level === 2) return <h4 key={key}>{content}</h4>; | |
| return <h5 key={key}>{content}</h5>; | |
| } | |
| if (lines.every((line) => /^[-*]\s+/.test(line))) { | |
| return <ul key={key}>{lines.map((line, index) => <li key={index}>{inlineMarkdown(line.replace(/^[-*]\s+/, ''))}</li>)}</ul>; | |
| } | |
| if (lines.every((line) => /^\d+\.\s+/.test(line))) { | |
| return <ol key={key}>{lines.map((line, index) => <li key={index}>{inlineMarkdown(line.replace(/^\d+\.\s+/, ''))}</li>)}</ol>; | |
| } | |
| if (lines.every((line) => /^>\s?/.test(line))) { | |
| return <blockquote key={key}>{inlineMarkdown(lines.map((line) => line.replace(/^>\s?/, '')).join('\n'))}</blockquote>; | |
| } | |
| return <p key={key}>{lines.flatMap((line, index) => [ | |
| ...(index > 0 ? [<br key={`br-${index}`} />] : []), | |
| ...inlineMarkdown(line), | |
| ])}</p>; | |
| } | |
| export function MarkdownContent({ content, theme, streaming = false }: MarkdownContentProps) { | |
| const nodes: ReactNode[] = []; | |
| const lines = content.replace(/\r\n/g, '\n').split('\n'); | |
| let text: string[] = []; | |
| let code: string[] | null = null; | |
| let language = ''; | |
| const flushText = () => { | |
| if (text.length > 0) { | |
| nodes.push(renderTextBlock(text, `text-${nodes.length}`)); | |
| text = []; | |
| } | |
| }; | |
| for (const line of lines) { | |
| const fence = line.match(/^```\s*([^\s`]*)/); | |
| if (fence) { | |
| if (code === null) { | |
| flushText(); | |
| code = []; | |
| language = fence[1] ?? ''; | |
| } else { | |
| nodes.push( | |
| <StreamingCodeBlock | |
| key={`code-${nodes.length}`} | |
| code={code.join('\n')} | |
| language={language} | |
| theme={theme} | |
| complete | |
| />, | |
| ); | |
| code = null; | |
| language = ''; | |
| } | |
| continue; | |
| } | |
| if (code !== null) { | |
| code.push(line); | |
| } else if (!line.trim()) { | |
| flushText(); | |
| } else { | |
| const previous = text.at(-1); | |
| const listKindChanged = previous !== undefined && ( | |
| /^[-*]\s+/.test(previous) !== /^[-*]\s+/.test(line) | |
| || /^\d+\.\s+/.test(previous) !== /^\d+\.\s+/.test(line) | |
| ); | |
| if (listKindChanged) flushText(); | |
| text.push(line); | |
| } | |
| } | |
| if (code !== null) { | |
| nodes.push( | |
| <StreamingCodeBlock | |
| key={`code-${nodes.length}`} | |
| code={code.join('\n')} | |
| language={language} | |
| theme={theme} | |
| complete={!streaming} | |
| />, | |
| ); | |
| } | |
| flushText(); | |
| return <div className="markdown-content">{nodes}</div>; | |
| } | |