Bonsai-Chat-WebGPU / src /app /components /MarkdownContent.tsx
Valeriy Selitskiy
Release v1.2.2 interactive chat workspace
21ad36a
Raw
History Blame Contribute Delete
3.86 kB
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>;
}