2026-03-16 13:35:11 +01:00
|
|
|
|
// Lightweight Markdown renderer – handles the subset used by the AI:
|
2026-04-12 11:10:39 +02:00
|
|
|
|
// ## Headings, **bold**, bullet lists, numbered lists, fenced ``` code ```, line breaks
|
2026-03-16 13:35:11 +01:00
|
|
|
|
|
|
|
|
|
|
export default function Markdown({ text }) {
|
|
|
|
|
|
if (!text) return null
|
|
|
|
|
|
|
|
|
|
|
|
const lines = text.split('\n')
|
|
|
|
|
|
const elements = []
|
|
|
|
|
|
let i = 0
|
2026-04-12 11:10:39 +02:00
|
|
|
|
let blockId = 0
|
|
|
|
|
|
|
|
|
|
|
|
const codeBlockStyle = {
|
|
|
|
|
|
margin: '10px 0',
|
|
|
|
|
|
padding: 12,
|
|
|
|
|
|
background: 'var(--surface2)',
|
|
|
|
|
|
borderRadius: 8,
|
|
|
|
|
|
border: '1px solid var(--border)',
|
|
|
|
|
|
overflow: 'auto',
|
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
|
lineHeight: 1.5,
|
|
|
|
|
|
fontFamily: 'ui-monospace, Consolas, monospace'
|
|
|
|
|
|
}
|
2026-03-16 13:35:11 +01:00
|
|
|
|
|
|
|
|
|
|
const parseLine = (line) => {
|
|
|
|
|
|
// Parse inline **bold** and *italic*
|
|
|
|
|
|
const parts = []
|
|
|
|
|
|
let remaining = line
|
|
|
|
|
|
let key = 0
|
|
|
|
|
|
while (remaining.length > 0) {
|
|
|
|
|
|
const boldMatch = remaining.match(/^(.*?)\*\*(.*?)\*\*(.*)$/)
|
|
|
|
|
|
if (boldMatch) {
|
|
|
|
|
|
if (boldMatch[1]) parts.push(<span key={key++}>{boldMatch[1]}</span>)
|
|
|
|
|
|
parts.push(<strong key={key++}>{boldMatch[2]}</strong>)
|
|
|
|
|
|
remaining = boldMatch[3]
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
const italicMatch = remaining.match(/^(.*?)\*(.*?)\*(.*)$/)
|
|
|
|
|
|
if (italicMatch) {
|
|
|
|
|
|
if (italicMatch[1]) parts.push(<span key={key++}>{italicMatch[1]}</span>)
|
|
|
|
|
|
parts.push(<em key={key++}>{italicMatch[2]}</em>)
|
|
|
|
|
|
remaining = italicMatch[3]
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
parts.push(<span key={key++}>{remaining}</span>)
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
return parts.length === 1 && typeof parts[0].props?.children === 'string'
|
|
|
|
|
|
? parts[0].props.children
|
|
|
|
|
|
: parts
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
while (i < lines.length) {
|
|
|
|
|
|
const line = lines[i]
|
|
|
|
|
|
|
2026-04-12 11:10:39 +02:00
|
|
|
|
// Fenced code block: ``` or ```lang
|
|
|
|
|
|
const trimmedStart = line.trimStart()
|
|
|
|
|
|
if (trimmedStart.startsWith('```')) {
|
|
|
|
|
|
const lang = trimmedStart.slice(3).trim() || null
|
|
|
|
|
|
i++
|
|
|
|
|
|
const codeLines = []
|
|
|
|
|
|
while (i < lines.length) {
|
|
|
|
|
|
if (lines[i].trim().startsWith('```')) {
|
|
|
|
|
|
i++
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
codeLines.push(lines[i])
|
|
|
|
|
|
i++
|
|
|
|
|
|
}
|
|
|
|
|
|
const code = codeLines.join('\n')
|
|
|
|
|
|
elements.push(
|
|
|
|
|
|
<div key={`code-${blockId++}`} style={{ margin: '10px 0' }}>
|
|
|
|
|
|
{lang && (
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
fontSize: 10,
|
|
|
|
|
|
color: 'var(--text3)',
|
|
|
|
|
|
marginBottom: 6,
|
|
|
|
|
|
fontFamily: 'ui-monospace, monospace',
|
|
|
|
|
|
letterSpacing: 0.02
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{lang}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<pre style={codeBlockStyle}>
|
|
|
|
|
|
<code style={{ color: 'var(--text1)', whiteSpace: 'pre', display: 'block' }}>{code}</code>
|
|
|
|
|
|
</pre>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 13:35:11 +01:00
|
|
|
|
// Skip empty lines (add spacing)
|
|
|
|
|
|
if (line.trim() === '') {
|
|
|
|
|
|
elements.push(<div key={i} style={{ height: 8 }} />)
|
|
|
|
|
|
i++; continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// H1
|
|
|
|
|
|
if (line.startsWith('# ')) {
|
|
|
|
|
|
elements.push(
|
|
|
|
|
|
<h1 key={i} style={{ fontSize: 18, fontWeight: 700, margin: '16px 0 8px', color: 'var(--text1)' }}>
|
|
|
|
|
|
{parseLine(line.slice(2))}
|
|
|
|
|
|
</h1>
|
|
|
|
|
|
)
|
|
|
|
|
|
i++; continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// H2
|
|
|
|
|
|
if (line.startsWith('## ')) {
|
|
|
|
|
|
elements.push(
|
|
|
|
|
|
<h2 key={i} style={{ fontSize: 15, fontWeight: 700, margin: '14px 0 6px', color: 'var(--text1)',
|
|
|
|
|
|
display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
|
|
|
|
{parseLine(line.slice(3))}
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
)
|
|
|
|
|
|
i++; continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// H3
|
|
|
|
|
|
if (line.startsWith('### ')) {
|
|
|
|
|
|
elements.push(
|
|
|
|
|
|
<h3 key={i} style={{ fontSize: 14, fontWeight: 600, margin: '10px 0 4px', color: 'var(--text1)' }}>
|
|
|
|
|
|
{parseLine(line.slice(4))}
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
)
|
|
|
|
|
|
i++; continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Unordered list item
|
|
|
|
|
|
if (line.match(/^[-*] /)) {
|
|
|
|
|
|
const listItems = []
|
|
|
|
|
|
while (i < lines.length && lines[i].match(/^[-*] /)) {
|
|
|
|
|
|
listItems.push(
|
|
|
|
|
|
<li key={i} style={{ fontSize: 13, lineHeight: 1.65, color: 'var(--text2)', marginBottom: 4 }}>
|
|
|
|
|
|
{parseLine(lines[i].slice(2))}
|
|
|
|
|
|
</li>
|
|
|
|
|
|
)
|
|
|
|
|
|
i++
|
|
|
|
|
|
}
|
|
|
|
|
|
elements.push(
|
|
|
|
|
|
<ul key={`ul-${i}`} style={{ paddingLeft: 20, margin: '6px 0' }}>
|
|
|
|
|
|
{listItems}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Numbered list item
|
|
|
|
|
|
if (line.match(/^\d+\. /)) {
|
|
|
|
|
|
const listItems = []
|
|
|
|
|
|
while (i < lines.length && lines[i].match(/^\d+\. /)) {
|
|
|
|
|
|
listItems.push(
|
|
|
|
|
|
<li key={i} style={{ fontSize: 13, lineHeight: 1.65, color: 'var(--text2)', marginBottom: 4 }}>
|
|
|
|
|
|
{parseLine(lines[i].replace(/^\d+\. /, ''))}
|
|
|
|
|
|
</li>
|
|
|
|
|
|
)
|
|
|
|
|
|
i++
|
|
|
|
|
|
}
|
|
|
|
|
|
elements.push(
|
|
|
|
|
|
<ol key={`ol-${i}`} style={{ paddingLeft: 20, margin: '6px 0' }}>
|
|
|
|
|
|
{listItems}
|
|
|
|
|
|
</ol>
|
|
|
|
|
|
)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Horizontal rule
|
|
|
|
|
|
if (line.match(/^---+$/)) {
|
|
|
|
|
|
elements.push(<hr key={i} style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '12px 0' }} />)
|
|
|
|
|
|
i++; continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Normal paragraph
|
|
|
|
|
|
elements.push(
|
|
|
|
|
|
<p key={i} style={{ fontSize: 13, lineHeight: 1.7, color: 'var(--text2)', margin: '4px 0' }}>
|
|
|
|
|
|
{parseLine(line)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)
|
|
|
|
|
|
i++
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return <div>{elements}</div>
|
|
|
|
|
|
}
|