proteinea / src /lib /markdown.tsx
Mahmoud Eljendy
feat: Antibody Studio — AI-native antibody design workspace by Proteinea
30cc31a
/**
* 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(<MarkdownTable key={`tbl-${i}`} lines={tableLines} />);
continue;
}
}
// Blockquote
if (line.startsWith("> ")) {
elements.push(
<blockquote key={`bq-${i}`} className="border-l-3 border-accent/30 pl-3 my-2 text-foreground-dim italic">
{renderInline(line.slice(2))}
</blockquote>
);
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(
<ol key={`ol-${i}`} className="list-decimal list-inside my-2 space-y-1">
{listItems.map((item, j) => (
<li key={j} className="text-sm leading-relaxed">{renderInline(item)}</li>
))}
</ol>
);
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(
<ul key={`ul-${i}`} className="list-disc list-inside my-2 space-y-1">
{listItems.map((item, j) => (
<li key={j} className="text-sm leading-relaxed">{renderInline(item)}</li>
))}
</ul>
);
continue;
}
// Empty line → spacer
if (line.trim() === "") {
elements.push(<div key={`sp-${i}`} className="h-2" />);
i++;
continue;
}
// Regular paragraph
elements.push(
<p key={`p-${i}`} className="text-sm leading-relaxed">
{renderInline(line)}
</p>
);
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 (
<div className="my-3 rounded-lg border border-border overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/60">
{headers.map((h, i) => (
<th
key={i}
className="px-3 py-2 text-[10px] uppercase tracking-wider font-semibold text-muted-fg text-left border-b border-border"
style={{ textAlign: alignments[i] || "left" }}
>
{renderInline(h)}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri} className="hover:bg-muted/20">
{row.map((cell, ci) => (
<td
key={ci}
className="px-3 py-2 border-b border-border-subtle"
style={{ textAlign: alignments[ci] || "left" }}
>
{renderInline(cell)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
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(<span key={key++}>{boldMatch[1]}</span>);
parts.push(<strong key={key++} className="font-semibold text-foreground">{boldMatch[2]}</strong>);
remaining = boldMatch[3];
continue;
}
// Inline code: `text`
const codeMatch = remaining.match(/^([\s\S]*?)`(.+?)`([\s\S]*)/);
if (codeMatch) {
if (codeMatch[1]) parts.push(<span key={key++}>{codeMatch[1]}</span>);
parts.push(
<code key={key++} className="inline-code text-[0.85em] px-1.5 py-0.5 rounded bg-muted border border-border-subtle font-mono text-accent">
{codeMatch[2]}
</code>
);
remaining = codeMatch[3];
continue;
}
// Link: [text](url)
const linkMatch = remaining.match(/^([\s\S]*?)\[(.+?)\]\((.+?)\)([\s\S]*)/);
if (linkMatch) {
if (linkMatch[1]) parts.push(<span key={key++}>{linkMatch[1]}</span>);
parts.push(
<a key={key++} href={linkMatch[3]} target="_blank" rel="noopener noreferrer"
className="text-accent hover:underline">{linkMatch[2]}</a>
);
remaining = linkMatch[4];
continue;
}
// Italic: *text*
const italicMatch = remaining.match(/^([\s\S]*?)\*(.+?)\*([\s\S]*)/);
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;
}
// No match — push the rest
parts.push(<span key={key++}>{remaining}</span>);
break;
}
return <>{parts}</>;
}