/** Minimal markdown → HTML renderer for AI-generated text. */ function esc(s: string): string { return s.replace(/&/g, '&').replace(//g, '>'); } function inline(s: string): string { return s .replace(/`([^`\n]+)`/g, '$1') .replace(/\*\*([^*\n]+)\*\*/g, '$1') .replace(/\*([^*\n]+)\*/g, '$1'); } export function markdownToHtml(text: string): string { const codeBlocks: string[] = []; // Extract fenced code blocks first const withPlaceholders = text.replace(/```([^\n]*)\n?([\s\S]*?)```/g, (_m, _lang, code: string) => { const i = codeBlocks.length; codeBlocks.push(`
${esc(code.replace(/\n$/, ''))}
`); return `\x00CODE${i}\x00`; }); const out: string[] = []; const blocks = withPlaceholders.split(/\n{2,}/); for (const block of blocks) { const b = block.trim(); if (!b) continue; // Code block placeholder (whole block) const cp = b.match(/^\x00CODE(\d+)\x00$/); if (cp) { out.push(codeBlocks[+cp[1]]); continue; } // Heading const h3 = b.match(/^###\s+(.+)/); const h2 = b.match(/^##\s+(.+)/); const h1 = b.match(/^#\s+(.+)/); if (h3) { out.push(`
${inline(esc(h3[1]))}
`); continue; } if (h2) { out.push(`

${inline(esc(h2[1]))}

`); continue; } if (h1) { out.push(`

${inline(esc(h1[1]))}

`); continue; } // Process line-by-line (mixed lists + prose) const lines = b.split('\n'); const parts: string[] = []; let listType: 'ul' | 'ol' | null = null; const closeList = () => { if (listType) { parts.push(listType === 'ul' ? '' : ''); listType = null; } }; for (const line of lines) { const t = line.trim(); // Inline code placeholder const icp = t.match(/^\x00CODE(\d+)\x00$/); if (icp) { closeList(); parts.push(codeBlocks[+icp[1]]); continue; } if (/^[-*+]\s/.test(t)) { if (listType !== 'ul') { closeList(); parts.push('