Tech4biz-channel/Channel-Frontend/src/components/ui/MarkdownViewer.tsx
2026-07-24 17:05:44 +05:30

550 lines
17 KiB
TypeScript

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 <strong key={idx} className={isDark ? "font-extrabold text-white" : "font-extrabold text-ink-900"}>{part.text}</strong>;
case "italic":
return <em key={idx} className={isDark ? "italic text-slate-200" : "italic text-ink-800"}>{part.text}</em>;
case "code":
return (
<code key={idx} className={isDark ? "bg-slate-900 border border-slate-700 text-emerald-300 rounded px-1.5 py-0.5 text-xs font-mono" : "bg-ink-100 border border-ink-200 rounded px-1.5 py-0.5 text-xs font-mono text-emerald-700"}>
{part.text}
</code>
);
case "link":
return (
<a
key={idx}
href={part.url}
target="_blank"
rel="noopener noreferrer"
className={isDark ? "text-emerald-400 hover:text-emerald-300 font-semibold underline break-all inline-flex items-center gap-0.5" : "text-primary-600 hover:text-primary-800 font-semibold underline break-all inline-flex items-center gap-0.5"}
>
{part.text}
</a>
);
default:
return <span key={idx}>{part.text}</span>;
}
});
};
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 (
<Component key={key} className={listClass}>
{block.items?.map((item, idx) => (
<li key={idx}>{renderInlineText(item, isDark)}</li>
))}
</Component>
);
};
const renderHeadingBlock = (block: MarkdownBlock, key: string, isDark: boolean): React.ReactNode => {
const level = block.type.slice(1);
const classes: Record<string, string> = {
"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 (
<Component key={key} className={classes[level] || ""}>
{renderInlineText(block.content, isDark)}
</Component>
);
};
const renderTableBlock = (block: MarkdownBlock, key: string, isDark: boolean): React.ReactNode => {
if (!block.headers || block.headers.length === 0) return null;
return (
<div key={key} className="my-3 w-full overflow-x-auto rounded-xl border border-slate-800 bg-slate-950/90 shadow-xl max-w-full">
<table className="w-full text-left border-collapse min-w-[320px]">
<thead>
<tr className="bg-slate-900/90 border-b border-slate-800 text-[11px] font-extrabold text-emerald-400 uppercase tracking-wider">
{block.headers.map((h, i) => (
<th key={i} className="py-2.5 px-3 border-r last:border-r-0 border-slate-800/80 font-bold whitespace-nowrap">
{renderInlineText(h, isDark)}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 text-xs">
{(block.rows || []).map((row, rIdx) => (
<tr key={rIdx} className="hover:bg-slate-900/60 transition-colors odd:bg-slate-950/40 even:bg-slate-900/30">
{row.map((cell, cIdx) => (
<td key={cIdx} className="py-2.5 px-3 border-r last:border-r-0 border-slate-800/60 text-slate-200 leading-relaxed font-sans">
{renderInlineText(cell, isDark)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
};
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 (
<blockquote key={key} className={`border-l-4 border-emerald-500 pl-3 py-1 italic ${isDark ? 'bg-slate-900/60 text-slate-200' : 'bg-primary-50/20 text-ink-700'} my-2 rounded-r-lg`}>
{renderInlineText(block.content, isDark)}
</blockquote>
);
case "ul":
case "ol":
return renderListBlock(block, key, isDark);
case "code":
return (
<div key={key} className="my-3 rounded-xl border border-slate-800 bg-slate-950 text-slate-100 p-3 overflow-x-auto shadow-inner relative group select-text max-w-full">
{block.language && (
<div className="absolute right-3 top-2 text-[9px] uppercase font-bold text-slate-400 select-none">
{block.language}
</div>
)}
<pre className="font-mono text-xs leading-relaxed overflow-x-auto">
{block.content}
</pre>
</div>
);
case "hr":
return <hr key={key} className={`my-4 ${isDark ? 'border-slate-800' : 'border-ink-200'}`} />;
default:
return (
<p key={key} className={`text-xs ${isDark ? 'text-slate-100' : 'text-ink-800'} leading-relaxed my-1.5`}>
{renderInlineText(block.content, isDark)}
</p>
);
}
};
export interface MarkdownViewerProps {
markdown: string;
variant?: 'light' | 'dark' | 'auto';
}
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({ markdown, variant = 'auto' }) => {
const blocks = parseMarkdown(markdown);
const isDark = variant === 'dark';
return (
<div className={`w-full text-left select-text font-sans leading-relaxed break-words overflow-hidden ${isDark ? 'text-slate-100' : 'text-ink-800'}`}>
{blocks.map((block, idx) => renderBlock(block, idx, isDark))}
</div>
);
};
export default MarkdownViewer;