summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/utils/MarkdownRenderer.tsx
diff options
context:
space:
mode:
authorTaylor Mullen <[email protected]>2025-05-15 00:36:08 -0700
committerN. Taylor Mullen <[email protected]>2025-05-15 21:57:10 -0700
commit6cb6f47b56154220a1adc03984e42813e0cb5dc1 (patch)
tree441af16a3bf196f2ee95f96716d267cf15c9d04b /packages/cli/src/ui/utils/MarkdownRenderer.tsx
parent59e8fcb4096b2f4f3841150035aa995aaebc0e97 (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.tsx369
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;
- }
-}