import React from "react"; interface MarkdownBlock { type: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "code" | "blockquote" | "ul" | "ol" | "hr" | "p" | "table"; content: string; language?: string; items?: string[]; headers?: string[]; rows?: string[][]; } interface ParseState { blocks: MarkdownBlock[]; currentCodeBlock: { language: string; lines: string[] } | null; currentList: { type: "ul" | "ol"; items: string[] } | null; currentTableLines: string[]; currentParagraphLines: string[]; } interface InlineToken { type: "text" | "bold" | "italic" | "code" | "link"; text: string; url?: string; } const flushParagraph = (state: ParseState): void => { if (state.currentParagraphLines.length > 0) { state.blocks.push({ type: "p", content: state.currentParagraphLines.join(" ").trim(), }); state.currentParagraphLines = []; } }; const flushList = (state: ParseState): void => { if (state.currentList) { state.blocks.push({ type: state.currentList.type, content: "", items: state.currentList.items, }); state.currentList = null; } }; const flushTable = (state: ParseState): void => { if (state.currentTableLines.length > 0) { const rawLines = state.currentTableLines; state.currentTableLines = []; // Filter out separator lines like |---|---| const parsedRows = rawLines .filter(line => !/^[|\s-:]+$/.test(line.trim())) .map(line => { const cells = line.split('|').map(c => c.trim()); // Remove empty lead/trail cells from leading/trailing pipes if (cells.length > 0 && cells[0] === '') cells.shift(); if (cells.length > 0 && cells[cells.length - 1] === '') cells.pop(); return cells; }) .filter(row => row.length > 0); if (parsedRows.length > 0) { const headers = parsedRows[0]; const rows = parsedRows.slice(1); state.blocks.push({ type: "table", content: "", headers, rows, }); } } }; const handleCodeBlock = (trimmed: string, state: ParseState): boolean => { if (trimmed.startsWith("```")) { if (state.currentCodeBlock) { state.blocks.push({ type: "code", content: state.currentCodeBlock.lines.join("\n"), language: state.currentCodeBlock.language, }); state.currentCodeBlock = null; } else { flushParagraph(state); flushList(state); flushTable(state); const language = trimmed.slice(3).trim(); state.currentCodeBlock = { language, lines: [] }; } return true; } return false; }; const handleHeading = (line: string, state: ParseState): boolean => { const match = line.match(/^(#{1,6})\s+(.*)$/); if (match) { flushParagraph(state); flushList(state); flushTable(state); const level = match[1].length; state.blocks.push({ type: `h${level}` as any, content: match[2].trim(), }); return true; } return false; }; const handleBlockquote = (trimmed: string, state: ParseState): boolean => { if (trimmed.startsWith(">")) { flushParagraph(state); flushList(state); flushTable(state); state.blocks.push({ type: "blockquote", content: trimmed.replace(/^>\s*/, ""), }); return true; } return false; }; const handleLists = (line: string, state: ParseState): boolean => { const ulMatch = line.match(/^(\s*)([-*+])\s+(.*)$/); if (ulMatch) { flushParagraph(state); flushTable(state); const content = ulMatch[3].trim(); if (state.currentList && state.currentList.type === "ul") { state.currentList.items.push(content); } else { flushList(state); state.currentList = { type: "ul", items: [content] }; } return true; } const olMatch = line.match(/^(\s*)(\d+)\.\s+(.*)$/); if (olMatch) { flushParagraph(state); flushTable(state); const content = olMatch[3].trim(); if (state.currentList && state.currentList.type === "ol") { state.currentList.items.push(content); } else { flushList(state); state.currentList = { type: "ol", items: [content] }; } return true; } return false; }; const handleTableLine = (trimmed: string, state: ParseState): boolean => { // Check if line contains markdown table pipes if (trimmed.includes("|") && (trimmed.startsWith("|") || trimmed.includes(" | ") || /^[-|\s:]+$/.test(trimmed))) { flushParagraph(state); flushList(state); state.currentTableLines.push(trimmed); return true; } return false; }; const handleLine = (line: string, state: ParseState): void => { const trimmed = line.trim(); if (handleCodeBlock(trimmed, state)) { return; } if (state.currentCodeBlock) { state.currentCodeBlock.lines.push(line); return; } if (trimmed === "---" || trimmed === "***" || trimmed === "___") { flushParagraph(state); flushList(state); flushTable(state); state.blocks.push({ type: "hr", content: "" }); return; } if (handleHeading(line, state) || handleBlockquote(trimmed, state)) { return; } if (handleLists(line, state)) { return; } if (handleTableLine(trimmed, state)) { return; } if (trimmed === "") { flushParagraph(state); flushList(state); flushTable(state); return; } flushList(state); flushTable(state); state.currentParagraphLines.push(line); }; /** * Preprocesses raw markdown text to split single-line concatenated markdown table rows and merge split table rows across lines. */ const sanitizeMarkdownText = (rawText: string): string => { if (!rawText) return ""; const lines = rawText.split("\n"); const processedLines: string[] = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const trimmed = line.trim(); // If the line starts with a pipe but doesn't end with one, it is likely split across linebreaks if (trimmed.startsWith("|") && !trimmed.endsWith("|")) { let merged = line; while (i + 1 < lines.length) { const nextLine = lines[i + 1]; const nextTrimmed = nextLine.trim(); merged += " " + nextTrimmed; i++; if (nextTrimmed.endsWith("|")) { break; } } processedLines.push(merged); } else { processedLines.push(line); } } let formatted = processedLines.join("\n"); // Split inline concatenated table rows like "| Col 1 | Col 2 | | :--- | :--- | | Val 1 | Val 2 |" formatted = formatted.replace(/\|\s*\|\s*:-/g, "|\n| :-"); formatted = formatted.replace(/\|\s*\|\s*([A-Za-z0-9_*`])/g, "|\n| $1"); formatted = formatted.replace(/([^\n|])\s*(\|[\s\S]+?\|)\s*([^\n|])/g, "$1\n\n$2\n\n$3"); // Clean up repeated linebreaks formatted = formatted.replace(/\n{3,}/g, "\n\n"); return formatted; }; export const parseMarkdown = (text: string): MarkdownBlock[] => { const sanitized = sanitizeMarkdownText(text); const lines = sanitized.split("\n"); const state: ParseState = { blocks: [], currentCodeBlock: null, currentList: null, currentTableLines: [], currentParagraphLines: [], }; for (let i = 0; i < lines.length; i++) { handleLine(lines[i], state); } flushParagraph(state); flushList(state); flushTable(state); return state.blocks; }; const parseInlineLinks = (tokens: InlineToken[]): InlineToken[] => { const updated: InlineToken[] = []; for (const part of tokens) { if (part.type === "text") { const regex = /\[([^\]]+)\]\(([^)]+)\)/g; let lastIndex = 0; let match; while ((match = regex.exec(part.text)) !== null) { const before = part.text.substring(lastIndex, match.index); if (before) updated.push({ type: "text", text: before }); updated.push({ type: "link", text: match[1], url: match[2] }); lastIndex = regex.lastIndex; } const after = part.text.substring(lastIndex); if (after) updated.push({ type: "text", text: after }); } else { updated.push(part); } } return updated; }; const parseInlineUrls = (tokens: InlineToken[]): InlineToken[] => { const updated: InlineToken[] = []; for (const part of tokens) { if (part.type === "text") { const regex = /(https?:\/\/[^\s)]+)/g; let lastIndex = 0; let match; while ((match = regex.exec(part.text)) !== null) { const before = part.text.substring(lastIndex, match.index); if (before) updated.push({ type: "text", text: before }); updated.push({ type: "link", text: match[1], url: match[1] }); lastIndex = regex.lastIndex; } const after = part.text.substring(lastIndex); if (after) updated.push({ type: "text", text: after }); } else { updated.push(part); } } return updated; }; const parseInlineBold = (tokens: InlineToken[]): InlineToken[] => { const updated: InlineToken[] = []; for (const part of tokens) { if (part.type === "text") { const regex = /\*\*([^*]+)\*\*/g; let lastIndex = 0; let match; while ((match = regex.exec(part.text)) !== null) { const before = part.text.substring(lastIndex, match.index); if (before) updated.push({ type: "text", text: before }); updated.push({ type: "bold", text: match[1] }); lastIndex = regex.lastIndex; } const after = part.text.substring(lastIndex); if (after) updated.push({ type: "text", text: after }); } else { updated.push(part); } } return updated; }; const parseInlineCode = (tokens: InlineToken[]): InlineToken[] => { const updated: InlineToken[] = []; for (const part of tokens) { if (part.type === "text") { const regex = /`([^`]+)`/g; let lastIndex = 0; let match; while ((match = regex.exec(part.text)) !== null) { const before = part.text.substring(lastIndex, match.index); if (before) updated.push({ type: "text", text: before }); updated.push({ type: "code", text: match[1] }); lastIndex = regex.lastIndex; } const after = part.text.substring(lastIndex); if (after) updated.push({ type: "text", text: after }); } else { updated.push(part); } } return updated; }; const parseInlineItalic = (tokens: InlineToken[]): InlineToken[] => { const updated: InlineToken[] = []; for (const part of tokens) { if (part.type === "text") { const regex = /\*([^*]+)\*/g; let lastIndex = 0; let match; while ((match = regex.exec(part.text)) !== null) { const before = part.text.substring(lastIndex, match.index); if (before) updated.push({ type: "text", text: before }); updated.push({ type: "italic", text: match[1] }); lastIndex = regex.lastIndex; } const after = part.text.substring(lastIndex); if (after) updated.push({ type: "text", text: after }); } else { updated.push(part); } } return updated; }; export const renderInlineText = (text: string, isDark: boolean = false): React.ReactNode[] => { if (!text) return []; let tokens: InlineToken[] = [{ type: "text", text }]; tokens = parseInlineLinks(tokens); tokens = parseInlineUrls(tokens); tokens = parseInlineBold(tokens); tokens = parseInlineCode(tokens); tokens = parseInlineItalic(tokens); return tokens.map((part, idx) => { switch (part.type) { case "bold": return {part.text}; case "italic": return {part.text}; case "code": return ( {part.text} ); case "link": return ( {part.text} ); default: return {part.text}; } }); }; const renderListBlock = (block: MarkdownBlock, key: string, isDark: boolean): React.ReactNode => { const Component = block.type === "ul" ? "ul" : "ol"; const listClass = block.type === "ul" ? `list-disc pl-5 space-y-1.5 my-2 text-xs ${isDark ? 'text-slate-100' : 'text-ink-800'}` : `list-decimal pl-5 space-y-1.5 my-2 text-xs ${isDark ? 'text-slate-100' : 'text-ink-800'}`; return ( {block.items?.map((item, idx) => (
  • {renderInlineText(item, isDark)}
  • ))}
    ); }; const renderHeadingBlock = (block: MarkdownBlock, key: string, isDark: boolean): React.ReactNode => { const level = block.type.slice(1); const classes: Record = { "1": `text-lg sm:text-xl font-extrabold ${isDark ? 'text-white' : 'text-ink-900'} mt-4 mb-2 border-b border-slate-700/50 pb-1`, "2": `text-base sm:text-lg font-extrabold ${isDark ? 'text-white' : 'text-ink-900'} mt-3 mb-1.5`, "3": `text-sm sm:text-base font-bold ${isDark ? 'text-emerald-400' : 'text-ink-900'} mt-2.5 mb-1`, "4": `text-xs sm:text-sm font-bold ${isDark ? 'text-emerald-300' : 'text-ink-800'} mt-2 mb-1`, "5": `text-xs font-bold ${isDark ? 'text-slate-200' : 'text-ink-800'} mt-1.5 mb-1`, "6": `text-xs font-bold ${isDark ? 'text-slate-300' : 'text-ink-700'} mt-1 mb-0.5`, }; const Component = block.type as any; return ( {renderInlineText(block.content, isDark)} ); }; const renderTableBlock = (block: MarkdownBlock, key: string, isDark: boolean): React.ReactNode => { if (!block.headers || block.headers.length === 0) return null; return (
    {block.headers.map((h, i) => ( ))} {(block.rows || []).map((row, rIdx) => ( {row.map((cell, cIdx) => ( ))} ))}
    {renderInlineText(h, isDark)}
    {renderInlineText(cell, isDark)}
    ); }; const renderBlock = (block: MarkdownBlock, index: number, isDark: boolean): React.ReactNode => { const key = `${block.type}-${index}`; if (block.type.startsWith("h") && block.type.length === 2 && block.type !== "hr") { return renderHeadingBlock(block, key, isDark); } switch (block.type) { case "table": return renderTableBlock(block, key, isDark); case "blockquote": return (
    {renderInlineText(block.content, isDark)}
    ); case "ul": case "ol": return renderListBlock(block, key, isDark); case "code": return (
    {block.language && (
    {block.language}
    )}
                {block.content}
              
    ); case "hr": return
    ; default: return (

    {renderInlineText(block.content, isDark)}

    ); } }; export interface MarkdownViewerProps { markdown: string; variant?: 'light' | 'dark' | 'auto'; } export const MarkdownViewer: React.FC = ({ markdown, variant = 'auto' }) => { const blocks = parseMarkdown(markdown); const isDark = variant === 'dark'; return (
    {blocks.map((block, idx) => renderBlock(block, idx, isDark))}
    ); }; export default MarkdownViewer;