550 lines
17 KiB
TypeScript
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;
|