diff options
| author | Taylor Mullen <[email protected]> | 2025-05-15 00:36:08 -0700 |
|---|---|---|
| committer | N. Taylor Mullen <[email protected]> | 2025-05-15 21:57:10 -0700 |
| commit | 6cb6f47b56154220a1adc03984e42813e0cb5dc1 (patch) | |
| tree | 441af16a3bf196f2ee95f96716d267cf15c9d04b /packages/cli/src/ui/utils/MarkdownRenderer.tsx | |
| parent | 59e8fcb4096b2f4f3841150035aa995aaebc0e97 (diff) | |
Refactor: Replace MarkdownRenderer with MarkdownDisplay component
- This commit refactors the Markdown rendering logic within the CLI UI.
The existing `MarkdownRenderer.tsx` class-based component has been
replaced with a new functional component `MarkdownDisplay.tsx`.
- The `MarkdownDisplay` component is a React.memoized component for
improved performance and maintains the same core Markdown parsing
and rendering capabilities.
Diffstat (limited to 'packages/cli/src/ui/utils/MarkdownRenderer.tsx')
| -rw-r--r-- | packages/cli/src/ui/utils/MarkdownRenderer.tsx | 369 |
1 files changed, 0 insertions, 369 deletions
diff --git a/packages/cli/src/ui/utils/MarkdownRenderer.tsx b/packages/cli/src/ui/utils/MarkdownRenderer.tsx deleted file mode 100644 index e1a48042..00000000 --- a/packages/cli/src/ui/utils/MarkdownRenderer.tsx +++ /dev/null @@ -1,369 +0,0 @@ -/** - * @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'; - -/** - * 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``, <u>underline</u> - * @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; - const inlineRegex = - /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|<u>.*?<\/u>)/g; - let match; - - while ((match = inlineRegex.exec(text)) !== null) { - // 1. Add plain text before the match - if (match.index > lastIndex) { - nodes.push( - <Text key={`t-${lastIndex}`}> - {text.slice(lastIndex, match.index)} - </Text>, - ); - } - - 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 = ( - <Text key={key} bold> - {fullMatch.slice(2, -2)} - </Text> - ); - } else if ( - ((fullMatch.startsWith('*') && fullMatch.endsWith('*')) || - (fullMatch.startsWith('_') && fullMatch.endsWith('_'))) && - fullMatch.length > 2 - ) { - renderedNode = ( - <Text key={key} italic> - {fullMatch.slice(1, -1)} - </Text> - ); - } else if ( - fullMatch.startsWith('~~') && - fullMatch.endsWith('~~') && - fullMatch.length > 4 - ) { - // Strikethrough as gray text - renderedNode = ( - <Text key={key} strikethrough> - {fullMatch.slice(2, -2)} - </Text> - ); - } 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 = ( - <Text key={key} color={Colors.AccentPurple}> - {codeMatch[2]} - </Text> - ); - } else { - // Fallback for simple or non-matching cases - renderedNode = ( - <Text key={key} color={Colors.AccentPurple}> - {fullMatch.slice(1, -1)} - </Text> - ); - } - } 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 = ( - <Text key={key}> - {linkText} - <Text color={Colors.AccentBlue}> ({url})</Text> - </Text> - ); - } - } else if ( - fullMatch.startsWith('<u>') && - fullMatch.endsWith('</u>') && - fullMatch.length > 6 - ) { - // ***** NEW: Handle underline tag ***** - // Use slice(3, -4) to remove <u> and </u> - renderedNode = ( - <Text key={key} underline> - {fullMatch.slice(3, -4)} - </Text> - ); - } - } 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 ?? <Text key={key}>{fullMatch}</Text>); - 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 key={`t-${lastIndex}`}>{text.slice(lastIndex)}</Text>); - } - - // 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 { - const fullContent = content.join('\n'); - const colorizedCode = colorizeCode(fullContent, lang); - - return ( - <Box key={key} flexDirection="column" padding={1}> - {colorizedCode} - </Box> - ); - } - - /** - * Helper to render a list item (ordered or unordered). - */ - private static _renderListItem( - key: string, - text: string, - type: 'ul' | 'ol', - marker: string, - leadingWhitespace: 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; - const indentation = leadingWhitespace.length; - - return ( - <Box key={key} paddingLeft={indentation + 1} flexDirection="row"> - <Box width={prefixWidth}> - <Text>{prefix}</Text> - </Box> - <Box flexGrow={1}> - <Text wrap="wrap">{renderedText}</Text> - </Box> - </Box> - ); - } - - /** - * 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. - */ - 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 = /^([ \t]*)([-*+]) +(.*)/; // Unordered list item, captures leading spaces, bullet and text - const olItemRegex = /^([ \t]*)(\d+)\. +(.*)/; // Ordered list item, captures leading spaces, 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 ~~~) - - 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 = ''; - } 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; - } else if (hrMatch) { - // Render Horizontal Rule (simple dashed line) - // Use box with height and border character, or just Text with dashes - contentBlocks.push( - <Box key={key}> - <Text dimColor>---</Text> - </Box>, - ); - } 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 = ( - <Text bold color={Colors.AccentCyan}> - {renderedHeaderText} - </Text> - ); - break; - case 2: - headerNode = ( - <Text bold color={Colors.AccentBlue}> - {renderedHeaderText} - </Text> - ); - break; - case 3: - headerNode = <Text bold>{renderedHeaderText}</Text>; - break; - case 4: - headerNode = ( - <Text italic color={Colors.SubtleComment}> - {renderedHeaderText} - </Text> - ); - break; - default: - headerNode = <Text>{renderedHeaderText}</Text>; - break; - } - if (headerNode) contentBlocks.push(<Box key={key}>{headerNode}</Box>); - } else if (ulMatch) { - const leadingWhitespace = ulMatch[1]; - const marker = ulMatch[2]; // *, -, or + - const itemText = ulMatch[3]; - // If previous line was not UL, maybe add spacing? For now, just render item. - contentBlocks.push( - MarkdownRenderer._renderListItem( - key, - itemText, - 'ul', - marker, - leadingWhitespace, - ), - ); - } else if (olMatch) { - const leadingWhitespace = olMatch[1]; - const marker = olMatch[2]; // The number - const itemText = olMatch[3]; - contentBlocks.push( - MarkdownRenderer._renderListItem( - key, - itemText, - 'ol', - marker, - leadingWhitespace, - ), - ); - } else { - // --- Regular line (Paragraph or Empty line) --- - // 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( - <Box key={key}> - <Text wrap="wrap">{renderedLine}</Text> - </Box>, - ); - } 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 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(<Box key={key} height={1} />); - } - } - } - }); - - // Handle unclosed code block at the end of the input - if (inCodeBlock) { - contentBlocks.push( - MarkdownRenderer._renderCodeBlock( - `line-eof`, - codeBlockContent, - codeBlockLang, - ), - ); - } - - return contentBlocks; - } -} |
