Spaces:
Running
Running
File size: 3,864 Bytes
0ed8124 21ad36a 0ed8124 21ad36a 0ed8124 21ad36a 0ed8124 21ad36a 0ed8124 21ad36a 0ed8124 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 | 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>;
}
|