From 6cb6f47b56154220a1adc03984e42813e0cb5dc1 Mon Sep 17 00:00:00 2001 From: Taylor Mullen Date: Thu, 15 May 2025 00:36:08 -0700 Subject: 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. --- packages/cli/src/ui/utils/MarkdownRenderer.tsx | 369 ------------------------- 1 file changed, 369 deletions(-) delete mode 100644 packages/cli/src/ui/utils/MarkdownRenderer.tsx (limited to 'packages/cli/src/ui/utils/MarkdownRenderer.tsx') 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``, 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; - 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 { - const fullContent = content.join('\n'); - const colorizedCode = colorizeCode(fullContent, lang); - - return ( - - {colorizedCode} - - ); - } - - /** - * 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 ( - - - {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. - */ - 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( - - --- - , - ); - } 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; - default: - headerNode = {renderedHeaderText}; - break; - } - if (headerNode) contentBlocks.push({headerNode}); - } 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( - - {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 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; - } -} -- cgit v1.2.3