| |
| |
| |
| |
| |
|
|
| import React from "react"; |
|
|
| export function renderMarkdown(text: string): React.ReactNode { |
| const lines = text.split("\n"); |
| const elements: React.ReactNode[] = []; |
| let i = 0; |
|
|
| while (i < lines.length) { |
| const line = lines[i]; |
|
|
| |
| if (line.includes("|") && line.trim().startsWith("|")) { |
| const tableLines: string[] = []; |
| while (i < lines.length && lines[i].includes("|") && lines[i].trim().startsWith("|")) { |
| tableLines.push(lines[i]); |
| i++; |
| } |
| if (tableLines.length >= 2) { |
| elements.push(<MarkdownTable key={`tbl-${i}`} lines={tableLines} />); |
| continue; |
| } |
| } |
|
|
| |
| if (line.startsWith("> ")) { |
| elements.push( |
| <blockquote key={`bq-${i}`} className="border-l-3 border-accent/30 pl-3 my-2 text-foreground-dim italic"> |
| {renderInline(line.slice(2))} |
| </blockquote> |
| ); |
| i++; |
| continue; |
| } |
|
|
| |
| if (/^\d+\.\s/.test(line)) { |
| const listItems: string[] = []; |
| while (i < lines.length && /^\d+\.\s/.test(lines[i])) { |
| listItems.push(lines[i].replace(/^\d+\.\s/, "")); |
| i++; |
| } |
| elements.push( |
| <ol key={`ol-${i}`} className="list-decimal list-inside my-2 space-y-1"> |
| {listItems.map((item, j) => ( |
| <li key={j} className="text-sm leading-relaxed">{renderInline(item)}</li> |
| ))} |
| </ol> |
| ); |
| continue; |
| } |
|
|
| |
| if (line.startsWith("- ") || line.startsWith("* ")) { |
| const listItems: string[] = []; |
| while (i < lines.length && (lines[i].startsWith("- ") || lines[i].startsWith("* "))) { |
| listItems.push(lines[i].slice(2)); |
| i++; |
| } |
| elements.push( |
| <ul key={`ul-${i}`} className="list-disc list-inside my-2 space-y-1"> |
| {listItems.map((item, j) => ( |
| <li key={j} className="text-sm leading-relaxed">{renderInline(item)}</li> |
| ))} |
| </ul> |
| ); |
| continue; |
| } |
|
|
| |
| if (line.trim() === "") { |
| elements.push(<div key={`sp-${i}`} className="h-2" />); |
| i++; |
| continue; |
| } |
|
|
| |
| elements.push( |
| <p key={`p-${i}`} className="text-sm leading-relaxed"> |
| {renderInline(line)} |
| </p> |
| ); |
| i++; |
| } |
|
|
| return <>{elements}</>; |
| } |
|
|
| function MarkdownTable({ lines }: { lines: string[] }) { |
| const parseRow = (line: string): string[] => |
| line.split("|").slice(1, -1).map((cell) => cell.trim()); |
|
|
| const headers = parseRow(lines[0]); |
| |
| const dataStart = lines[1]?.includes("---") ? 2 : 1; |
| const rows = lines.slice(dataStart).map(parseRow); |
|
|
| |
| const alignments: ("left" | "center" | "right")[] = []; |
| if (lines[1]?.includes("---")) { |
| const sepCells = parseRow(lines[1]); |
| for (const cell of sepCells) { |
| if (cell.startsWith(":") && cell.endsWith(":")) alignments.push("center"); |
| else if (cell.endsWith(":")) alignments.push("right"); |
| else alignments.push("left"); |
| } |
| } |
|
|
| return ( |
| <div className="my-3 rounded-lg border border-border overflow-x-auto"> |
| <table className="w-full text-sm"> |
| <thead> |
| <tr className="bg-muted/60"> |
| {headers.map((h, i) => ( |
| <th |
| key={i} |
| className="px-3 py-2 text-[10px] uppercase tracking-wider font-semibold text-muted-fg text-left border-b border-border" |
| style={{ textAlign: alignments[i] || "left" }} |
| > |
| {renderInline(h)} |
| </th> |
| ))} |
| </tr> |
| </thead> |
| <tbody> |
| {rows.map((row, ri) => ( |
| <tr key={ri} className="hover:bg-muted/20"> |
| {row.map((cell, ci) => ( |
| <td |
| key={ci} |
| className="px-3 py-2 border-b border-border-subtle" |
| style={{ textAlign: alignments[ci] || "left" }} |
| > |
| {renderInline(cell)} |
| </td> |
| ))} |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| </div> |
| ); |
| } |
|
|
| function renderInline(text: string): React.ReactNode { |
| |
| const parts: React.ReactNode[] = []; |
| let remaining = text; |
| let key = 0; |
|
|
| while (remaining.length > 0) { |
| |
| const boldMatch = remaining.match(/^([\s\S]*?)\*\*(.+?)\*\*([\s\S]*)/); |
| if (boldMatch) { |
| if (boldMatch[1]) parts.push(<span key={key++}>{boldMatch[1]}</span>); |
| parts.push(<strong key={key++} className="font-semibold text-foreground">{boldMatch[2]}</strong>); |
| remaining = boldMatch[3]; |
| continue; |
| } |
|
|
| |
| const codeMatch = remaining.match(/^([\s\S]*?)`(.+?)`([\s\S]*)/); |
| if (codeMatch) { |
| if (codeMatch[1]) parts.push(<span key={key++}>{codeMatch[1]}</span>); |
| parts.push( |
| <code key={key++} className="inline-code text-[0.85em] px-1.5 py-0.5 rounded bg-muted border border-border-subtle font-mono text-accent"> |
| {codeMatch[2]} |
| </code> |
| ); |
| remaining = codeMatch[3]; |
| continue; |
| } |
|
|
| |
| const linkMatch = remaining.match(/^([\s\S]*?)\[(.+?)\]\((.+?)\)([\s\S]*)/); |
| if (linkMatch) { |
| if (linkMatch[1]) parts.push(<span key={key++}>{linkMatch[1]}</span>); |
| parts.push( |
| <a key={key++} href={linkMatch[3]} target="_blank" rel="noopener noreferrer" |
| className="text-accent hover:underline">{linkMatch[2]}</a> |
| ); |
| remaining = linkMatch[4]; |
| continue; |
| } |
|
|
| |
| const italicMatch = remaining.match(/^([\s\S]*?)\*(.+?)\*([\s\S]*)/); |
| if (italicMatch) { |
| if (italicMatch[1]) parts.push(<span key={key++}>{italicMatch[1]}</span>); |
| parts.push(<em key={key++}>{italicMatch[2]}</em>); |
| remaining = italicMatch[3]; |
| continue; |
| } |
|
|
| |
| parts.push(<span key={key++}>{remaining}</span>); |
| break; |
| } |
|
|
| return <>{parts}</>; |
| } |
|
|