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