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>;
}