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.
This commit is contained in:
parent
59e8fcb409
commit
6cb6f47b56
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Text, Box } from 'ink';
|
import { Text, Box } from 'ink';
|
||||||
import { MarkdownRenderer } from '../../utils/MarkdownRenderer.js';
|
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||||
import { Colors } from '../../colors.js';
|
import { Colors } from '../../colors.js';
|
||||||
|
|
||||||
interface GeminiMessageProps {
|
interface GeminiMessageProps {
|
||||||
|
@ -16,7 +16,6 @@ interface GeminiMessageProps {
|
||||||
export const GeminiMessage: React.FC<GeminiMessageProps> = ({ text }) => {
|
export const GeminiMessage: React.FC<GeminiMessageProps> = ({ text }) => {
|
||||||
const prefix = '✦ ';
|
const prefix = '✦ ';
|
||||||
const prefixWidth = prefix.length;
|
const prefixWidth = prefix.length;
|
||||||
const renderedBlocks = MarkdownRenderer.render(text);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="row">
|
<Box flexDirection="row">
|
||||||
|
@ -24,7 +23,7 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({ text }) => {
|
||||||
<Text color={Colors.AccentPurple}>{prefix}</Text>
|
<Text color={Colors.AccentPurple}>{prefix}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box flexGrow={1} flexDirection="column">
|
<Box flexGrow={1} flexDirection="column">
|
||||||
{renderedBlocks}
|
<MarkdownDisplay text={text} />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box } from 'ink';
|
import { Box } from 'ink';
|
||||||
import { MarkdownRenderer } from '../../utils/MarkdownRenderer.js';
|
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||||
|
|
||||||
interface GeminiMessageContentProps {
|
interface GeminiMessageContentProps {
|
||||||
text: string;
|
text: string;
|
||||||
|
@ -23,11 +23,10 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const originalPrefix = '✦ ';
|
const originalPrefix = '✦ ';
|
||||||
const prefixWidth = originalPrefix.length;
|
const prefixWidth = originalPrefix.length;
|
||||||
const renderedBlocks = MarkdownRenderer.render(text);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" paddingLeft={prefixWidth}>
|
<Box flexDirection="column" paddingLeft={prefixWidth}>
|
||||||
{renderedBlocks}
|
<MarkdownDisplay text={text} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -10,7 +10,7 @@ import Spinner from 'ink-spinner';
|
||||||
import { IndividualToolCallDisplay, ToolCallStatus } from '../../types.js';
|
import { IndividualToolCallDisplay, ToolCallStatus } from '../../types.js';
|
||||||
import { DiffRenderer } from './DiffRenderer.js';
|
import { DiffRenderer } from './DiffRenderer.js';
|
||||||
import { Colors } from '../../colors.js';
|
import { Colors } from '../../colors.js';
|
||||||
import { MarkdownRenderer } from '../../utils/MarkdownRenderer.js';
|
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||||
|
|
||||||
export const ToolMessage: React.FC<IndividualToolCallDisplay> = ({
|
export const ToolMessage: React.FC<IndividualToolCallDisplay> = ({
|
||||||
name,
|
name,
|
||||||
|
@ -60,7 +60,7 @@ export const ToolMessage: React.FC<IndividualToolCallDisplay> = ({
|
||||||
{/* Use default text color (white) or gray instead of dimColor */}
|
{/* Use default text color (white) or gray instead of dimColor */}
|
||||||
{typeof resultDisplay === 'string' && (
|
{typeof resultDisplay === 'string' && (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
{MarkdownRenderer.render(resultDisplay)}
|
<MarkdownDisplay text={resultDisplay} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{typeof resultDisplay === 'object' && (
|
{typeof resultDisplay === 'object' && (
|
||||||
|
|
|
@ -0,0 +1,301 @@
|
||||||
|
/**
|
||||||
|
* @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(
|
||||||
|
<Box key={key}>
|
||||||
|
<Text dimColor>---</Text>
|
||||||
|
</Box>,
|
||||||
|
);
|
||||||
|
} 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 = (
|
||||||
|
<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];
|
||||||
|
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(
|
||||||
|
<Box key={key}>
|
||||||
|
<Text wrap="wrap">{renderedLine}</Text>
|
||||||
|
</Box>,
|
||||||
|
);
|
||||||
|
} else if (line.trim().length === 0) {
|
||||||
|
if (contentBlocks.length > 0 && !inCodeBlock) {
|
||||||
|
contentBlocks.push(<Box key={key} height={1} />);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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>.*?<\/u>)/g;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = inlineRegex.exec(text)) !== null) {
|
||||||
|
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}`;
|
||||||
|
|
||||||
|
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
|
||||||
|
) {
|
||||||
|
renderedNode = (
|
||||||
|
<Text key={key} strikethrough>
|
||||||
|
{fullMatch.slice(2, -2)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
fullMatch.startsWith('`') &&
|
||||||
|
fullMatch.endsWith('`') &&
|
||||||
|
fullMatch.length > 1
|
||||||
|
) {
|
||||||
|
const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s);
|
||||||
|
if (codeMatch && codeMatch[2]) {
|
||||||
|
renderedNode = (
|
||||||
|
<Text key={key} color={Colors.AccentPurple}>
|
||||||
|
{codeMatch[2]}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
renderedNode = (
|
||||||
|
<Text key={key} color={Colors.AccentPurple}>
|
||||||
|
{fullMatch.slice(1, -1)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
fullMatch.startsWith('[') &&
|
||||||
|
fullMatch.includes('](') &&
|
||||||
|
fullMatch.endsWith(')')
|
||||||
|
) {
|
||||||
|
const linkMatch = fullMatch.match(/\[(.*?)\]\((.*?)\)/);
|
||||||
|
if (linkMatch) {
|
||||||
|
const linkText = linkMatch[1];
|
||||||
|
const url = linkMatch[2];
|
||||||
|
renderedNode = (
|
||||||
|
<Text key={key}>
|
||||||
|
{linkText}
|
||||||
|
<Text color={Colors.AccentBlue}> ({url})</Text>
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
fullMatch.startsWith('<u>') &&
|
||||||
|
fullMatch.endsWith('</u>') &&
|
||||||
|
fullMatch.length > 6
|
||||||
|
) {
|
||||||
|
renderedNode = (
|
||||||
|
<Text key={key} underline>
|
||||||
|
{fullMatch.slice(3, -4)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing inline markdown part:', fullMatch, e);
|
||||||
|
renderedNode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.push(renderedNode ?? <Text key={key}>{fullMatch}</Text>);
|
||||||
|
lastIndex = inlineRegex.lastIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
nodes.push(<Text key={`t-${lastIndex}`}>{text.slice(lastIndex)}</Text>);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Box key={key} flexDirection="column" padding={1}>
|
||||||
|
{colorizedCode}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MarkdownDisplay = React.memo(MarkdownDisplayComponent);
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in New Issue