Files
Portfolio/src/lib/markdown.tsx
T

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>;
}