- Introduced `ExpandableCollapsible` component to manage the visibility of lengthy content, allowing users to toggle between expanded and collapsed views. - Updated `renderTestOutput` to utilize the new component for displaying test results, JSON outputs, and object representations, enhancing user experience by reducing clutter. - Enhanced `Markdown` component to support fenced code blocks, improving the rendering of code snippets with language labels and better styling. These changes improve the readability and organization of content within the application, providing users with a more interactive and manageable interface.
185 lines
5.0 KiB
JavaScript
185 lines
5.0 KiB
JavaScript
// Lightweight Markdown renderer – handles the subset used by the AI:
|
||
// ## Headings, **bold**, bullet lists, numbered lists, fenced ``` code ```, line breaks
|
||
|
||
export default function Markdown({ text }) {
|
||
if (!text) return null
|
||
|
||
const lines = text.split('\n')
|
||
const elements = []
|
||
let i = 0
|
||
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'
|
||
}
|
||
|
||
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]
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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>
|
||
}
|