/** * Lightweight markdown renderer for chat messages. * Handles: bold, italic, code, tables, lists, blockquotes, links. * No external dependency — pure React. */ import React from "react"; export function renderMarkdown(text: string): React.ReactNode { const lines = text.split("\n"); const elements: React.ReactNode[] = []; let i = 0; while (i < lines.length) { const line = lines[i]; // Table detection: line with | separators if (line.includes("|") && line.trim().startsWith("|")) { const tableLines: string[] = []; while (i < lines.length && lines[i].includes("|") && lines[i].trim().startsWith("|")) { tableLines.push(lines[i]); i++; } if (tableLines.length >= 2) { elements.push(); continue; } } // Blockquote if (line.startsWith("> ")) { elements.push(
{renderInline(line.slice(2))}
); i++; continue; } // Numbered list if (/^\d+\.\s/.test(line)) { const listItems: string[] = []; while (i < lines.length && /^\d+\.\s/.test(lines[i])) { listItems.push(lines[i].replace(/^\d+\.\s/, "")); i++; } elements.push(
    {listItems.map((item, j) => (
  1. {renderInline(item)}
  2. ))}
); continue; } // Bullet list if (line.startsWith("- ") || line.startsWith("* ")) { const listItems: string[] = []; while (i < lines.length && (lines[i].startsWith("- ") || lines[i].startsWith("* "))) { listItems.push(lines[i].slice(2)); i++; } elements.push( ); continue; } // Empty line → spacer if (line.trim() === "") { elements.push(
); i++; continue; } // Regular paragraph elements.push(

{renderInline(line)}

); i++; } return <>{elements}; } function MarkdownTable({ lines }: { lines: string[] }) { const parseRow = (line: string): string[] => line.split("|").slice(1, -1).map((cell) => cell.trim()); const headers = parseRow(lines[0]); // Skip separator row (line with ---) const dataStart = lines[1]?.includes("---") ? 2 : 1; const rows = lines.slice(dataStart).map(parseRow); // Detect alignment from separator row const alignments: ("left" | "center" | "right")[] = []; if (lines[1]?.includes("---")) { const sepCells = parseRow(lines[1]); for (const cell of sepCells) { if (cell.startsWith(":") && cell.endsWith(":")) alignments.push("center"); else if (cell.endsWith(":")) alignments.push("right"); else alignments.push("left"); } } return (
{headers.map((h, i) => ( ))} {rows.map((row, ri) => ( {row.map((cell, ci) => ( ))} ))}
{renderInline(h)}
{renderInline(cell)}
); } function renderInline(text: string): React.ReactNode { // Process inline markdown: **bold**, *italic*, `code`, [link](url) const parts: React.ReactNode[] = []; let remaining = text; let key = 0; while (remaining.length > 0) { // Bold: **text** const boldMatch = remaining.match(/^([\s\S]*?)\*\*(.+?)\*\*([\s\S]*)/); if (boldMatch) { if (boldMatch[1]) parts.push({boldMatch[1]}); parts.push({boldMatch[2]}); remaining = boldMatch[3]; continue; } // Inline code: `text` const codeMatch = remaining.match(/^([\s\S]*?)`(.+?)`([\s\S]*)/); if (codeMatch) { if (codeMatch[1]) parts.push({codeMatch[1]}); parts.push( {codeMatch[2]} ); remaining = codeMatch[3]; continue; } // Link: [text](url) const linkMatch = remaining.match(/^([\s\S]*?)\[(.+?)\]\((.+?)\)([\s\S]*)/); if (linkMatch) { if (linkMatch[1]) parts.push({linkMatch[1]}); parts.push( {linkMatch[2]} ); remaining = linkMatch[4]; continue; } // Italic: *text* const italicMatch = remaining.match(/^([\s\S]*?)\*(.+?)\*([\s\S]*)/); if (italicMatch) { if (italicMatch[1]) parts.push({italicMatch[1]}); parts.push({italicMatch[2]}); remaining = italicMatch[3]; continue; } // No match — push the rest parts.push({remaining}); break; } return <>{parts}; }