"use client";
import { memo, useMemo } from "react";
import { CopyIconButton } from "./CopyIconButton";
import { MathSpan } from "./MathSpan";
/**
* Safe, dependency-free markdown for chat answers: lists, tables, code blocks
* (with a copy button), quotes, headings, inline code/bold/italic/links and
* $...$ math. It parses to React nodes — nothing is ever set as HTML.
*/
type Block =
| { type: "p"; lines: string[] }
| { type: "heading"; level: number; text: string }
| { type: "list"; ordered: boolean; items: { text: string; depth: number }[] }
| { type: "code"; lang: string; code: string }
| { type: "table"; head: string[]; rows: string[][] }
| { type: "quote"; lines: string[] }
| { type: "math"; tex: string }
| { type: "hr" };
const FENCE = /^(```|~~~)\s*([\w+-]*)\s*$/;
const HEADING = /^(#{1,6})\s+(.*)$/;
const LIST_ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/;
const QUOTE = /^>\s?(.*)$/;
const HR = /^\s*([-*_])\1{2,}\s*$/;
function isTableSeparator(line: string): boolean {
return /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(line) && line.includes("-");
}
function splitTableRow(line: string): string[] {
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
return trimmed.split("|").map((cell) => cell.trim());
}
export function parseBlocks(text: string): Block[] {
const blocks: Block[] = [];
const lines = text.replace(/\r\n/g, "\n").split("\n");
let index = 0;
const flushParagraph = (collected: string[]) => {
const joined = collected.join("\n").trim();
if (joined) blocks.push({ type: "p", lines: collected });
};
let paragraph: string[] = [];
while (index < lines.length) {
const line = lines[index];
const fence = FENCE.exec(line);
if (fence) {
flushParagraph(paragraph);
paragraph = [];
const lang = fence[2] ?? "";
const code: string[] = [];
index += 1;
while (index < lines.length && !FENCE.test(lines[index])) {
code.push(lines[index]);
index += 1;
}
index += 1; // closing fence (or end of stream)
blocks.push({ type: "code", lang, code: code.join("\n") });
continue;
}
if (HR.test(line)) {
flushParagraph(paragraph);
paragraph = [];
blocks.push({ type: "hr" });
index += 1;
continue;
}
const heading = HEADING.exec(line);
if (heading) {
flushParagraph(paragraph);
paragraph = [];
blocks.push({ type: "heading", level: heading[1].length, text: heading[2].trim() });
index += 1;
continue;
}
// $$ ... $$ on its own line(s)
if (line.trim().startsWith("$$")) {
flushParagraph(paragraph);
paragraph = [];
const trimmed = line.trim();
if (trimmed.length > 4 && trimmed.endsWith("$$")) {
blocks.push({ type: "math", tex: trimmed.slice(2, -2).trim() });
index += 1;
continue;
}
const math: string[] = [trimmed.slice(2)];
index += 1;
while (index < lines.length && !lines[index].trim().endsWith("$$")) {
math.push(lines[index]);
index += 1;
}
if (index < lines.length) {
math.push(lines[index].trim().slice(0, -2));
index += 1;
}
blocks.push({ type: "math", tex: math.join("\n").trim() });
continue;
}
const quote = QUOTE.exec(line);
if (quote) {
flushParagraph(paragraph);
paragraph = [];
const collected = [quote[1]];
index += 1;
while (index < lines.length) {
const next = QUOTE.exec(lines[index]);
if (!next) break;
collected.push(next[1]);
index += 1;
}
blocks.push({ type: "quote", lines: collected });
continue;
}
const item = LIST_ITEM.exec(line);
if (item) {
flushParagraph(paragraph);
paragraph = [];
const ordered = /\d/.test(item[2]);
const items: { text: string; depth: number }[] = [];
while (index < lines.length) {
const next = LIST_ITEM.exec(lines[index]);
if (!next) break;
const depth = Math.min(2, Math.floor(next[1].replace(/\t/g, " ").length / 2));
items.push({ text: next[3], depth });
index += 1;
}
blocks.push({ type: "list", ordered, items });
continue;
}
// Table: header row followed by a separator row.
if (line.includes("|") && index + 1 < lines.length && isTableSeparator(lines[index + 1])) {
flushParagraph(paragraph);
paragraph = [];
const head = splitTableRow(line);
index += 2;
const rows: string[][] = [];
while (index < lines.length && lines[index].includes("|") && lines[index].trim()) {
rows.push(splitTableRow(lines[index]));
index += 1;
}
blocks.push({ type: "table", head, rows });
continue;
}
if (!line.trim()) {
flushParagraph(paragraph);
paragraph = [];
index += 1;
continue;
}
paragraph.push(line);
index += 1;
}
flushParagraph(paragraph);
return blocks;
}
const INLINE =
/(\*\*[^*]+\*\*|__[^_]+__|`[^`]+`|\[[^\]]+\]\([^)\s]+\)|\$\$[^$]+\$\$|\$[^$\n]+\$|\*[^*\n]+\*|_[^_\n]+_)/g;
function inlineNodes(text: string, keyPrefix: string): React.ReactNode[] {
return text.split(INLINE).map((part, index) => {
const key = `${keyPrefix}-${index}`;
if (!part) return null;
if (part.startsWith("**") && part.endsWith("**") && part.length > 4) {
return {part.slice(2, -2)};
}
if (part.startsWith("__") && part.endsWith("__") && part.length > 4) {
return {part.slice(2, -2)};
}
if (part.startsWith("`") && part.endsWith("`") && part.length > 2) {
return (
{part.slice(1, -1)}
);
}
if (part.startsWith("$$") && part.endsWith("$$") && part.length > 4) {
return
{block.code}
| {inlineNodes(cell, `${key}-h-${cellIndex}`)} | ))}
|---|
| {inlineNodes(row[cellIndex] ?? "", `${key}-r-${rowIndex}-${cellIndex}`)} | ))}
{inlineNodes(block.lines.join("\n"), key)}); case "math": return
{inlineNodes(block.lines.join("\n"), key)}
); } } export const Markdown = memo(function Markdown({ text }: { text: string }) { const blocks = useMemo(() => parseBlocks(text), [text]); return