/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import React from 'react'; import { Text, Box } from 'ink'; import { Colors } from '../colors.js'; import { colorizeCode } from './CodeColorizer.js'; interface MarkdownDisplayProps { text: string; } function MarkdownDisplayComponent({ text, }: MarkdownDisplayProps): React.ReactElement { if (!text) return <>; const lines = text.split('\n'); const headerRegex = /^ *(#{1,4}) +(.*)/; const codeFenceRegex = /^ *(`{3,}|~{3,}) *(\S*?) *$/; const ulItemRegex = /^([ \t]*)([-*+]) +(.*)/; const olItemRegex = /^([ \t]*)(\d+)\. +(.*)/; const hrRegex = /^ *([-*_] *){3,} *$/; const contentBlocks: React.ReactNode[] = []; let inCodeBlock = false; let codeBlockContent: string[] = []; let codeBlockLang: string | null = null; let codeBlockFence = ''; lines.forEach((line, index) => { const key = `line-${index}`; if (inCodeBlock) { const fenceMatch = line.match(codeFenceRegex); if ( fenceMatch && fenceMatch[1].startsWith(codeBlockFence[0]) && fenceMatch[1].length >= codeBlockFence.length ) { contentBlocks.push( _renderCodeBlock(key, codeBlockContent, codeBlockLang), ); inCodeBlock = false; codeBlockContent = []; codeBlockLang = null; codeBlockFence = ''; } else { codeBlockContent.push(line); } return; } 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; } else if (hrMatch) { contentBlocks.push( --- , ); } else if (headerMatch) { const level = headerMatch[1].length; const headerText = headerMatch[2]; const renderedHeaderText = _renderInline(headerText); let headerNode: React.ReactNode = null; switch (level) { case 1: headerNode = ( {renderedHeaderText} ); break; case 2: headerNode = ( {renderedHeaderText} ); break; case 3: headerNode = {renderedHeaderText}; break; case 4: headerNode = ( {renderedHeaderText} ); break; default: headerNode = {renderedHeaderText}; break; } if (headerNode) contentBlocks.push({headerNode}); } else if (ulMatch) { const leadingWhitespace = ulMatch[1]; const marker = ulMatch[2]; const itemText = ulMatch[3]; contentBlocks.push( _renderListItem(key, itemText, 'ul', marker, leadingWhitespace), ); } else if (olMatch) { const leadingWhitespace = olMatch[1]; const marker = olMatch[2]; const itemText = olMatch[3]; contentBlocks.push( _renderListItem(key, itemText, 'ol', marker, leadingWhitespace), ); } else { const renderedLine = _renderInline(line); if (renderedLine.length > 0 || line.length > 0) { contentBlocks.push( {renderedLine} , ); } else if (line.trim().length === 0) { if (contentBlocks.length > 0 && !inCodeBlock) { contentBlocks.push(); } } } }); if (inCodeBlock) { contentBlocks.push( _renderCodeBlock(`line-eof`, codeBlockContent, codeBlockLang), ); } return <>{contentBlocks}; } // Helper functions (adapted from static methods of MarkdownRenderer) function _renderInline(text: string): React.ReactNode[] { const nodes: React.ReactNode[] = []; let lastIndex = 0; const inlineRegex = /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|.*?<\/u>)/g; let match; while ((match = inlineRegex.exec(text)) !== null) { 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}`; 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 ) { renderedNode = ( {fullMatch.slice(2, -2)} ); } else if ( fullMatch.startsWith('`') && fullMatch.endsWith('`') && fullMatch.length > 1 ) { const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s); if (codeMatch && codeMatch[2]) { renderedNode = ( {codeMatch[2]} ); } else { renderedNode = ( {fullMatch.slice(1, -1)} ); } } else if ( fullMatch.startsWith('[') && fullMatch.includes('](') && fullMatch.endsWith(')') ) { const linkMatch = fullMatch.match(/\[(.*?)\]\((.*?)\)/); if (linkMatch) { const linkText = linkMatch[1]; const url = linkMatch[2]; renderedNode = ( {linkText} ({url}) ); } } else if ( fullMatch.startsWith('') && fullMatch.endsWith('') && fullMatch.length > 6 ) { renderedNode = ( {fullMatch.slice(3, -4)} ); } } catch (e) { console.error('Error parsing inline markdown part:', fullMatch, e); renderedNode = null; } nodes.push(renderedNode ?? {fullMatch}); lastIndex = inlineRegex.lastIndex; } if (lastIndex < text.length) { nodes.push({text.slice(lastIndex)}); } return nodes.filter((node) => node !== null); } function _renderCodeBlock( key: string, content: string[], lang: string | null, ): React.ReactNode { const fullContent = content.join('\n'); const colorizedCode = colorizeCode(fullContent, lang); return ( {colorizedCode} ); } function _renderListItem( key: string, text: string, type: 'ul' | 'ol', marker: string, leadingWhitespace: string = '', ): React.ReactNode { const renderedText = _renderInline(text); const prefix = type === 'ol' ? `${marker}. ` : `${marker} `; const prefixWidth = prefix.length; const indentation = leadingWhitespace.length; return ( {prefix} {renderedText} ); } export const MarkdownDisplay = React.memo(MarkdownDisplayComponent);