import { marked } from 'marked'
import hljs from 'highlight.js'
// Configure marked once
marked.setOptions({ breaks: true, gfm: true })
// Custom renderer to add code block header + copy button
const renderer = new marked.Renderer()
renderer.code = function({ text, lang }) {
const language = lang && hljs.getLanguage(lang) ? lang : 'plaintext'
const highlighted = hljs.highlight(text, { language }).value
return `<div class="code-block"><div class="code-header"><span>${language}</span><button class="btn-icon copy-btn" data-code="${encodeURIComponent(text)}" title="Copy"><i class="ph ph-copy"></i></button></div><pre><code class="hljs language-${language}">${highlighted}</code></pre></div>`
}
marked.use({ renderer })
// Fix malformed GFM tables:
// 1. Missing separator row — inject one between header and first data row.
// 2. Mixed separator/data row — inject proper separator before it.
// 3. Spurious all-separator rows inside the table body (model uses them as
// visual dividers) — strip them once the real header separator has been seen.
function fixTables(text) {
const lines = text.split('\n')
const out = []
const isPipeRow = l => l.trim().startsWith('|') && l.includes('|', 1)
const isSepCell = c => /^:?-+:?$/.test(c.trim())
const colCount = l => l.split('|').slice(1, -1).length
let seenSep = false // have we emitted the header separator for the current table?
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const prev = out[out.length - 1] ?? ''
if (!isPipeRow(line)) {
// Leaving table context
seenSep = false
out.push(line)
continue
}
const cells = line.split('|').slice(1, -1)
const allSep = cells.every(isSepCell)
const noneSep = cells.every(c => !isSepCell(c))
if (allSep) {
if (!seenSep) {
// First separator row — keep it as the header separator
seenSep = true
out.push(line)
}
// Subsequent all-separator rows are spurious visual dividers — drop them
continue
}
if (isPipeRow(prev)) {
if (!seenSep) {
if (!noneSep) {
// Mixed row: mangled separator — inject a clean one, treat this line as data
out.push('| ' + Array(colCount(prev)).fill('---').join(' | ') + ' |')
seenSep = true
} else if (i + 1 < lines.length && isPipeRow(lines[i + 1])) {
// Separator row is missing entirely — inject it
out.push('| ' + Array(colCount(prev)).fill('---').join(' | ') + ' |')
seenSep = true
}
}
}
out.push(line)
}
return out.join('\n')
}
const LATEX_SYMBOLS = [
// Arrows
[/\$\\Leftrightarrow\$/g, '⇔'],
[/\$\\leftrightarrow\$/g, '↔'],
[/\$\\Rightarrow\$/g, '⇒'],
[/\$\\Leftarrow\$/g, '⇐'],
[/\$\\rightarrow\$/g, '→'],
[/\$\\leftarrow\$/g, '←'],
[/\$\\to\$/g, '→'],
[/\$\\gets\$/g, '←'],
[/\$\\uparrow\$/g, '↑'],
[/\$\\downarrow\$/g, '↓'],
[/\$\\nearrow\$/g, '↗'],
[/\$\\searrow\$/g, '↘'],
// Math
[/\$\\approx\$/g, '≈'],
[/\$\\neq\$/g, '≠'],
[/\$\\leq\$/g, '≤'],
[/\$\\geq\$/g, '≥'],
[/\$\\times\$/g, '×'],
[/\$\\div\$/g, '÷'],
[/\$\\pm\$/g, '±'],
[/\$\\infty\$/g, '∞'],
[/\$\\cdot\$/g, '·'],
[/\$\\ldots\$/g, '…'],
[/\$\\in\$/g, '∈'],
[/\$\\notin\$/g, '∉'],
[/\$\\subset\$/g, '⊂'],
[/\$\\cup\$/g, '∪'],
[/\$\\cap\$/g, '∩'],
[/\$\\forall\$/g, '∀'],
[/\$\\exists\$/g, '∃'],
[/\$\\neg\$/g, '¬'],
[/\$\\land\$/g, '∧'],
[/\$\\lor\$/g, '∨'],
]
function fixLatexSymbols(text) {
for (const [re, sym] of LATEX_SYMBOLS) text = text.replace(re, sym)
return text
}
export function renderMarkdown(text) {
const html = marked.parse(fixTables(fixLatexSymbols(text ?? '')))
// Wrap tables in a scrollable container for mobile (matches <table> with or without attributes)
return html.replace(/<table(\s[^>]*)?>/g, '<div class="table-wrap"><table$1>').replace(/<\/table>/g, '</table></div>')
}
// Attach copy button listeners after v-html renders
export function attachCopyButtons(el) {
el?.querySelectorAll('.copy-btn').forEach(btn => {
btn.onclick = () => {
const code = decodeURIComponent(btn.dataset.code ?? '')
const done = () => {
btn.innerHTML = '<i class="ph ph-check"></i>'
setTimeout(() => { btn.innerHTML = '<i class="ph ph-copy"></i>' }, 1500)
}
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(code).then(done)
} else {
const el = document.createElement('textarea')
el.value = code
el.style.cssText = 'position:fixed;opacity:0'
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
done()
}
}
})
}