325 lines
8.8 KiB
TypeScript
325 lines
8.8 KiB
TypeScript
/**
|
|
* Markdown Rendering Utility
|
|
* Converts markdown text to styled React components
|
|
*/
|
|
|
|
import React from 'react';
|
|
|
|
/**
|
|
* Parse inline markdown syntax (code, bold, italic, links)
|
|
* Converts inline markdown elements within a text string to React components
|
|
*/
|
|
function parseInlineMarkdown(text: string): React.ReactNode {
|
|
const parts: React.ReactNode[] = [];
|
|
let remaining = text;
|
|
let keyIndex = 0;
|
|
|
|
while (remaining.length > 0) {
|
|
// Inline code
|
|
const codeMatch = remaining.match(/^`([^`]+)`/);
|
|
if (codeMatch) {
|
|
parts.push(
|
|
<code key={keyIndex++} className="bg-zinc-800 px-1.5 py-0.5 rounded text-[#697565] text-sm font-mono">
|
|
{codeMatch[1]}
|
|
</code>
|
|
);
|
|
remaining = remaining.slice(codeMatch[0].length);
|
|
continue;
|
|
}
|
|
|
|
// Bold
|
|
const boldMatch = remaining.match(/^\*\*([^*]+)\*\*/);
|
|
if (boldMatch) {
|
|
parts.push(<strong key={keyIndex++} className="font-semibold text-white">{boldMatch[1]}</strong>);
|
|
remaining = remaining.slice(boldMatch[0].length);
|
|
continue;
|
|
}
|
|
|
|
// Italic
|
|
const italicMatch = remaining.match(/^\*([^*]+)\*/);
|
|
if (italicMatch) {
|
|
parts.push(<em key={keyIndex++} className="italic">{italicMatch[1]}</em>);
|
|
remaining = remaining.slice(italicMatch[0].length);
|
|
continue;
|
|
}
|
|
|
|
// Links
|
|
const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
|
|
if (linkMatch) {
|
|
parts.push(
|
|
<a key={keyIndex++} href={linkMatch[2]} className="text-[#697565] hover:text-[#8a9a7e] underline" target="_blank" rel="noopener noreferrer">
|
|
{linkMatch[1]}
|
|
</a>
|
|
);
|
|
remaining = remaining.slice(linkMatch[0].length);
|
|
continue;
|
|
}
|
|
|
|
// Regular character
|
|
const nextSpecial = remaining.search(/[`*\[]/);
|
|
if (nextSpecial === -1) {
|
|
parts.push(remaining);
|
|
break;
|
|
} else if (nextSpecial === 0) {
|
|
parts.push(remaining[0]);
|
|
remaining = remaining.slice(1);
|
|
} else {
|
|
parts.push(remaining.slice(0, nextSpecial));
|
|
remaining = remaining.slice(nextSpecial);
|
|
}
|
|
}
|
|
|
|
return parts.length === 1 ? parts[0] : <>{parts}</>;
|
|
}
|
|
|
|
/**
|
|
* Render markdown content to React components
|
|
* Supports headers, lists, code blocks, tables, blockquotes, and inline formatting
|
|
*/
|
|
export function renderMarkdown(content: string): React.ReactNode {
|
|
const lines = content.split('\n');
|
|
const elements: React.ReactNode[] = [];
|
|
let i = 0;
|
|
let listItems: { type: 'ul' | 'ol'; items: React.ReactNode[] } | null = null;
|
|
let codeBlock: { language: string; lines: string[] } | null = null;
|
|
let tableRows: string[][] | null = null;
|
|
|
|
const flushList = () => {
|
|
if (listItems) {
|
|
if (listItems.type === 'ul') {
|
|
elements.push(
|
|
<ul key={`ul-${elements.length}`} className="list-disc list-inside space-y-2 my-4 text-zinc-300">
|
|
{listItems.items}
|
|
</ul>
|
|
);
|
|
} else {
|
|
elements.push(
|
|
<ol key={`ol-${elements.length}`} className="list-decimal list-inside space-y-2 my-4 text-zinc-300">
|
|
{listItems.items}
|
|
</ol>
|
|
);
|
|
}
|
|
listItems = null;
|
|
}
|
|
};
|
|
|
|
const flushTable = () => {
|
|
if (tableRows && tableRows.length > 0) {
|
|
const header = tableRows[0];
|
|
const body = tableRows.slice(2); // Skip header separator row
|
|
elements.push(
|
|
<div key={`table-${elements.length}`} className="overflow-x-auto my-6">
|
|
<table className="min-w-full border-collapse">
|
|
<thead>
|
|
<tr className="border-b border-zinc-700">
|
|
{header.map((cell, idx) => (
|
|
<th key={idx} className="px-4 py-2 text-left text-zinc-200 font-semibold">
|
|
{cell.trim()}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{body.map((row, rowIdx) => (
|
|
<tr key={rowIdx} className="border-b border-zinc-800">
|
|
{row.map((cell, cellIdx) => (
|
|
<td key={cellIdx} className="px-4 py-2 text-zinc-300">
|
|
{cell.trim()}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
tableRows = null;
|
|
}
|
|
};
|
|
|
|
while (i < lines.length) {
|
|
const line = lines[i];
|
|
|
|
// Code block start/end
|
|
if (line.startsWith('```')) {
|
|
if (codeBlock) {
|
|
// End code block
|
|
elements.push(
|
|
<pre key={`code-${elements.length}`} className="bg-zinc-900 border border-zinc-800 rounded-lg p-4 overflow-x-auto my-6">
|
|
<code className="text-sm font-mono text-zinc-300">
|
|
{codeBlock.lines.join('\n')}
|
|
</code>
|
|
</pre>
|
|
);
|
|
codeBlock = null;
|
|
} else {
|
|
// Start code block
|
|
flushList();
|
|
flushTable();
|
|
const language = line.slice(3).trim();
|
|
codeBlock = { language, lines: [] };
|
|
}
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
// Inside code block
|
|
if (codeBlock) {
|
|
codeBlock.lines.push(line);
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
// Table row
|
|
if (line.startsWith('|') && line.endsWith('|')) {
|
|
flushList();
|
|
const cells = line.slice(1, -1).split('|');
|
|
if (!tableRows) {
|
|
tableRows = [];
|
|
}
|
|
tableRows.push(cells);
|
|
i++;
|
|
continue;
|
|
} else if (tableRows) {
|
|
flushTable();
|
|
}
|
|
|
|
// Headers
|
|
if (line.startsWith('#### ')) {
|
|
flushList();
|
|
elements.push(
|
|
<h4 key={`h4-${elements.length}`} className="text-lg font-semibold text-white mt-5 mb-2">
|
|
{parseInlineMarkdown(line.slice(5))}
|
|
</h4>
|
|
);
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith('### ')) {
|
|
flushList();
|
|
elements.push(
|
|
<h3 key={`h3-${elements.length}`} className="text-xl font-semibold text-white mt-6 mb-3">
|
|
{parseInlineMarkdown(line.slice(4))}
|
|
</h3>
|
|
);
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith('## ')) {
|
|
flushList();
|
|
elements.push(
|
|
<h2 key={`h2-${elements.length}`} className="text-2xl font-bold text-white mt-8 mb-4">
|
|
{parseInlineMarkdown(line.slice(3))}
|
|
</h2>
|
|
);
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith('# ')) {
|
|
flushList();
|
|
elements.push(
|
|
<h1 key={`h1-${elements.length}`} className="text-3xl font-bold text-white mt-8 mb-4">
|
|
{parseInlineMarkdown(line.slice(2))}
|
|
</h1>
|
|
);
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
// Unordered list items
|
|
if (line.match(/^[-*]\s/)) {
|
|
if (!listItems || listItems.type !== 'ul') {
|
|
flushList();
|
|
listItems = { type: 'ul', items: [] };
|
|
}
|
|
listItems.items.push(
|
|
<li key={`li-${listItems.items.length}`}>
|
|
{parseInlineMarkdown(line.replace(/^[-*]\s/, ''))}
|
|
</li>
|
|
);
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
// Ordered list items
|
|
if (line.match(/^\d+\.\s/)) {
|
|
if (!listItems || listItems.type !== 'ol') {
|
|
flushList();
|
|
listItems = { type: 'ol', items: [] };
|
|
}
|
|
listItems.items.push(
|
|
<li key={`li-${listItems.items.length}`}>
|
|
{parseInlineMarkdown(line.replace(/^\d+\.\s/, ''))}
|
|
</li>
|
|
);
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
// Checkbox list items
|
|
if (line.match(/^[-*]\s\[[ x]\]/i)) {
|
|
if (!listItems || listItems.type !== 'ul') {
|
|
flushList();
|
|
listItems = { type: 'ul', items: [] };
|
|
}
|
|
const checked = line.match(/^[-*]\s\[x\]/i);
|
|
const itemContent = line.replace(/^[-*]\s\[[ x]\]\s?/i, '');
|
|
listItems.items.push(
|
|
<li key={`li-${listItems.items.length}`} className="flex items-center gap-2">
|
|
<span className={checked ? 'text-green-500' : 'text-zinc-500'}>
|
|
{checked ? '✓' : '○'}
|
|
</span>
|
|
{parseInlineMarkdown(itemContent)}
|
|
</li>
|
|
);
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
// Horizontal rule
|
|
if (line.match(/^[-*_]{3,}$/)) {
|
|
flushList();
|
|
elements.push(<hr key={`hr-${elements.length}`} className="border-zinc-700 my-8" />);
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
// Blockquote
|
|
if (line.startsWith('> ')) {
|
|
flushList();
|
|
elements.push(
|
|
<blockquote key={`quote-${elements.length}`} className="border-l-4 border-[#697565] pl-4 my-4 text-zinc-400 italic">
|
|
{parseInlineMarkdown(line.slice(2))}
|
|
</blockquote>
|
|
);
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
// Empty line
|
|
if (!line.trim()) {
|
|
flushList();
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
// Regular paragraph
|
|
flushList();
|
|
elements.push(
|
|
<p key={`p-${elements.length}`} className="text-zinc-300 mb-4 leading-relaxed">
|
|
{parseInlineMarkdown(line)}
|
|
</p>
|
|
);
|
|
i++;
|
|
}
|
|
|
|
// Flush any remaining items
|
|
flushList();
|
|
flushTable();
|
|
|
|
return <div className="prose prose-invert prose-zinc max-w-none">{elements}</div>;
|
|
}
|