import React from 'react';
import { Text, Box } from 'ink';
/**
* A utility class to render a subset of Markdown into Ink components.
* Handles H1-H4, Lists (ul/ol, no nesting), Code Blocks,
* and inline styles (bold, italic, strikethrough, code, links).
*/
export class MarkdownRenderer {
/**
* Renders INLINE markdown elements using an iterative approach.
* Supports: **bold**, *italic*, _italic_, ~~strike~~, [link](url), `code`, ``code``, underline
* @param text The string segment to parse for inline styles.
* @returns An array of React nodes (Text components or strings).
*/
private static _renderInline(text: string): React.ReactNode[] {
const nodes: React.ReactNode[] = [];
let lastIndex = 0;
// UPDATED Regex: Added .*?<\/u> pattern
const inlineRegex = /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|.*?<\/u>)/g;
let match;
while ((match = inlineRegex.exec(text)) !== null) {
// 1. Add plain text before the match
if (match.index > lastIndex) {
nodes.push({text.slice(lastIndex, match.index)});
}
const fullMatch = match[0];
let renderedNode: React.ReactNode = null;
const key = `m-${match.index}`; // Base key for matched part
// 2. Determine type of match and render accordingly
try {
if (fullMatch.startsWith('**') && fullMatch.endsWith('**') && fullMatch.length > 4) {
renderedNode = {fullMatch.slice(2, -2)};
} else if (((fullMatch.startsWith('*') && fullMatch.endsWith('*')) || (fullMatch.startsWith('_') && fullMatch.endsWith('_'))) && fullMatch.length > 2) {
renderedNode = {fullMatch.slice(1, -1)};
} else if (fullMatch.startsWith('~~') && fullMatch.endsWith('~~') && fullMatch.length > 4) {
// Strikethrough as gray text
renderedNode = {fullMatch.slice(2, -2)};
} else if (fullMatch.startsWith('`') && fullMatch.endsWith('`') && fullMatch.length > 1) {
// Code: Try to match varying numbers of backticks
const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s);
if (codeMatch && codeMatch[2]) {
renderedNode = {codeMatch[2]};
} else { // Fallback for simple or non-matching cases
renderedNode = {fullMatch.slice(1, -1)};
}
} else if (fullMatch.startsWith('[') && fullMatch.includes('](') && fullMatch.endsWith(')')) {
// Link: Extract text and URL
const linkMatch = fullMatch.match(/\[(.*?)\]\((.*?)\)/);
if (linkMatch) {
const linkText = linkMatch[1];
const url = linkMatch[2];
// Render link text then URL slightly dimmed/colored
renderedNode = (
{linkText}
({url})
);
}
} else if (fullMatch.startsWith('') && fullMatch.endsWith('') && fullMatch.length > 6) {
// ***** NEW: Handle underline tag *****
// Use slice(3, -4) to remove and
renderedNode = {fullMatch.slice(3, -4)};
}
} catch (e) {
// In case of regex or slicing errors, fallback to literal rendering
console.error("Error parsing inline markdown part:", fullMatch, e);
renderedNode = null; // Ensure fallback below is used
}
// 3. Add the rendered node or the literal text if parsing failed
nodes.push(renderedNode ?? {fullMatch});
lastIndex = inlineRegex.lastIndex; // Move index past the current match
}
// 4. Add any remaining plain text after the last match
if (lastIndex < text.length) {
nodes.push({text.slice(lastIndex)});
}
// Filter out potential nulls if any error occurred without fallback
return nodes.filter(node => node !== null);
}
/**
* Helper to render a code block.
*/
private static _renderCodeBlock(key: string, content: string[], lang: string | null): React.ReactNode {
// Basic styling for code block
return (
{lang && {lang}}
{/* Render each line preserving whitespace (within Text component) */}
{content.map((line, idx) => (
{line}
))}
);
}
/**
* Helper to render a list item (ordered or unordered).
*/
private static _renderListItem(key: string, text: string, type: 'ul' | 'ol', marker: string): React.ReactNode {
const renderedText = MarkdownRenderer._renderInline(text); // Allow inline styles in list items
const prefix = type === 'ol' ? `${marker} ` : `${marker} `; // e.g., "1. " or "* "
const prefixWidth = prefix.length;
return (
{prefix}
{renderedText}
);
}
/**
* Renders a full markdown string, handling block elements (headers, lists, code blocks)
* and applying inline styles. This is the main public static method.
* @param text The full markdown string to render.
* @returns An array of React nodes representing markdown blocks.
*/
public static render(text: string): React.ReactNode[] {
if (!text) return [];
const lines = text.split('\n');
// Regexes for block elements
const headerRegex = /^ *(#{1,4}) +(.*)/;
const codeFenceRegex = /^ *(`{3,}|~{3,}) *(\S*?) *$/; // ```lang or ``` or ~~~
const ulItemRegex = /^ *([-*+]) +(.*)/; // Unordered list item, captures bullet and text
const olItemRegex = /^ *(\d+)\. +(.*)/; // Ordered list item, captures number and text
const hrRegex = /^ *([-*_] *){3,} *$/; // Horizontal rule
const contentBlocks: React.ReactNode[] = [];
// State for parsing across lines
let inCodeBlock = false;
let codeBlockContent: string[] = [];
let codeBlockLang: string | null = null;
let codeBlockFence = ''; // Store the type of fence used (``` or ~~~)
let inListType: 'ul' | 'ol' | null = null; // Track current list type to group items
lines.forEach((line, index) => {
const key = `line-${index}`;
// --- State 1: Inside a Code Block ---
if (inCodeBlock) {
const fenceMatch = line.match(codeFenceRegex);
// Check for closing fence, matching the opening one and length
if (fenceMatch && fenceMatch[1].startsWith(codeBlockFence[0]) && fenceMatch[1].length >= codeBlockFence.length) {
// End of code block - render it
contentBlocks.push(MarkdownRenderer._renderCodeBlock(key, codeBlockContent, codeBlockLang));
// Reset state
inCodeBlock = false;
codeBlockContent = [];
codeBlockLang = null;
codeBlockFence = '';
inListType = null; // Ensure list context is reset
} else {
// Add line to current code block content
codeBlockContent.push(line);
}
return; // Process next line
}
// --- State 2: Not Inside a Code Block ---
// Check for block element starts in rough order of precedence/commonness
const codeFenceMatch = line.match(codeFenceRegex);
const headerMatch = line.match(headerRegex);
const ulMatch = line.match(ulItemRegex);
const olMatch = line.match(olItemRegex);
const hrMatch = line.match(hrRegex);
if (codeFenceMatch) {
inCodeBlock = true;
codeBlockFence = codeFenceMatch[1];
codeBlockLang = codeFenceMatch[2] || null;
inListType = null; // Starting code block breaks list
} else if (hrMatch) {
// Render Horizontal Rule (simple dashed line)
// Use box with height and border character, or just Text with dashes
contentBlocks.push(---);
inListType = null; // HR breaks list
} else if (headerMatch) {
const level = headerMatch[1].length;
const headerText = headerMatch[2];
const renderedHeaderText = MarkdownRenderer._renderInline(headerText);
let headerNode: React.ReactNode = null;
switch (level) { /* ... (header styling as before) ... */
case 1: headerNode = {renderedHeaderText}; break;
case 2: headerNode = {renderedHeaderText}; break;
case 3: headerNode = {renderedHeaderText}; break;
case 4: headerNode = {renderedHeaderText}; break;
}
if (headerNode) contentBlocks.push({headerNode});
inListType = null; // Header breaks list
} else if (ulMatch) {
const marker = ulMatch[1]; // *, -, or +
const itemText = ulMatch[2];
// If previous line was not UL, maybe add spacing? For now, just render item.
contentBlocks.push(MarkdownRenderer._renderListItem(key, itemText, 'ul', marker));
inListType = 'ul'; // Set/maintain list context
} else if (olMatch) {
const marker = olMatch[1]; // The number
const itemText = olMatch[2];
contentBlocks.push(MarkdownRenderer._renderListItem(key, itemText, 'ol', marker));
inListType = 'ol'; // Set/maintain list context
} else {
// --- Regular line (Paragraph or Empty line) ---
inListType = null; // Any non-list line breaks the list sequence
// Render line content if it's not blank, applying inline styles
const renderedLine = MarkdownRenderer._renderInline(line);
if (renderedLine.length > 0 || line.length > 0) { // Render lines with content or only whitespace
contentBlocks.push(
{renderedLine}
);
} else if (line.trim().length === 0) { // Handle specifically empty lines
// Add minimal space for blank lines between paragraphs/blocks
if (contentBlocks.length > 0 && !inCodeBlock) { // Avoid adding space inside code block state (handled above)
const previousBlock = contentBlocks[contentBlocks.length - 1];
// Avoid adding multiple blank lines consecutively easily - check if previous was also blank?
// For now, add a minimal spacer for any blank line outside code blocks.
contentBlocks.push();
}
}
}
});
// Handle unclosed code block at the end of the input
if (inCodeBlock) {
contentBlocks.push(MarkdownRenderer._renderCodeBlock(`line-eof`, codeBlockContent, codeBlockLang));
}
return contentBlocks;
}
}