summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/utils/MarkdownRenderer.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui/utils/MarkdownRenderer.tsx')
-rw-r--r--packages/cli/src/ui/utils/MarkdownRenderer.tsx547
1 files changed, 330 insertions, 217 deletions
diff --git a/packages/cli/src/ui/utils/MarkdownRenderer.tsx b/packages/cli/src/ui/utils/MarkdownRenderer.tsx
index fc8c2b0c..20b50939 100644
--- a/packages/cli/src/ui/utils/MarkdownRenderer.tsx
+++ b/packages/cli/src/ui/utils/MarkdownRenderer.tsx
@@ -7,243 +7,356 @@ import { Text, Box } from 'ink';
* 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;
+ // UPDATED Regex: Added <u>.*?<\/u> pattern
+ const inlineRegex =
+ /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|<u>.*?<\/u>)/g;
+ let match;
- /**
- * 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;
- // UPDATED Regex: Added <u>.*?<\/u> pattern
- 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="yellow">{codeMatch[2]}</Text>;
- } else { // Fallback for simple or non-matching cases
- renderedNode = <Text key={key} color="yellow">{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="blue"> ({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
- }
-
+ 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>,
+ );
+ }
- // 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
- }
+ const fullMatch = match[0];
+ let renderedNode: React.ReactNode = null;
+ const key = `m-${match.index}`; // Base key for matched part
- // 4. Add any remaining plain text after the last match
- if (lastIndex < text.length) {
- nodes.push(<Text key={`t-${lastIndex}`}>{text.slice(lastIndex)}</Text>);
+ // 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="yellow">
+ {codeMatch[2]}
+ </Text>
+ );
+ } else {
+ // Fallback for simple or non-matching cases
+ renderedNode = (
+ <Text key={key} color="yellow">
+ {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="blue"> ({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
+ }
- // Filter out potential nulls if any error occurred without fallback
- return nodes.filter(node => node !== null);
+ // 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
}
- /**
- * Helper to render a code block.
- */
- private static _renderCodeBlock(key: string, content: string[], lang: string | null): React.ReactNode {
- // Basic styling for code block
- return (
- <Box key={key} borderStyle="round" paddingX={1} borderColor="gray" flexDirection="column">
- {lang && <Text dimColor> {lang}</Text>}
- {/* Render each line preserving whitespace (within Text component) */}
- {content.map((line, idx) => (
- <Text key={idx}>{line}</Text>
- ))}
- </Box>
- );
+ // 4. Add any remaining plain text after the last match
+ if (lastIndex < text.length) {
+ nodes.push(<Text key={`t-${lastIndex}`}>{text.slice(lastIndex)}</Text>);
}
- /**
- * Helper to render a list item (ordered or unordered).
- */
- private static _renderListItem(key: string, text: string, type: 'ul' | 'ol', marker: 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;
+ // Filter out potential nulls if any error occurred without fallback
+ return nodes.filter((node) => node !== null);
+ }
- return (
- <Box key={key} paddingLeft={1} flexDirection="row">
- <Box width={prefixWidth}>
- <Text>{prefix}</Text>
- </Box>
- <Box flexGrow={1}>
- <Text wrap="wrap">{renderedText}</Text>
- </Box>
- </Box>
- );
- }
+ /**
+ * Helper to render a code block.
+ */
+ private static _renderCodeBlock(
+ key: string,
+ content: string[],
+ lang: string | null,
+ ): React.ReactNode {
+ // Basic styling for code block
+ return (
+ <Box
+ key={key}
+ borderStyle="round"
+ paddingX={1}
+ borderColor="gray"
+ flexDirection="column"
+ >
+ {lang && <Text dimColor> {lang}</Text>}
+ {/* Render each line preserving whitespace (within Text component) */}
+ {content.map((line, idx) => (
+ <Text key={idx}>{line}</Text>
+ ))}
+ </Box>
+ );
+ }
+ /**
+ * Helper to render a list item (ordered or unordered).
+ */
+ private static _renderListItem(
+ key: string,
+ text: string,
+ type: 'ul' | 'ol',
+ marker: 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;
- /**
- * 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.
- */
- public static render(text: string): React.ReactNode[] {
- if (!text) return [];
+ return (
+ <Box key={key} paddingLeft={1} flexDirection="row">
+ <Box width={prefixWidth}>
+ <Text>{prefix}</Text>
+ </Box>
+ <Box flexGrow={1}>
+ <Text wrap="wrap">{renderedText}</Text>
+ </Box>
+ </Box>
+ );
+ }
- const lines = text.split('\n');
- // Regexes for block elements
- const headerRegex = /^ *(#{1,4}) +(.*)/;
- const codeFenceRegex = /^ *(`{3,}|~{3,}) *(\S*?) *$/; // ```lang or ``` or ~~~
- const ulItemRegex = /^ *([-*+]) +(.*)/; // Unordered list item, captures bullet and text
- const olItemRegex = /^ *(\d+)\. +(.*)/; // Ordered list item, captures number and text
- const hrRegex = /^ *([-*_] *){3,} *$/; // Horizontal rule
+ /**
+ * 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.
+ */
+ public static render(text: string): React.ReactNode[] {
+ if (!text) return [];
- 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 ~~~)
- let inListType: 'ul' | 'ol' | null = null; // Track current list type to group items
+ const lines = text.split('\n');
+ // Regexes for block elements
+ const headerRegex = /^ *(#{1,4}) +(.*)/;
+ const codeFenceRegex = /^ *(`{3,}|~{3,}) *(\S*?) *$/; // ```lang or ``` or ~~~
+ const ulItemRegex = /^ *([-*+]) +(.*)/; // Unordered list item, captures bullet and text
+ const olItemRegex = /^ *(\d+)\. +(.*)/; // Ordered list item, captures number and text
+ const hrRegex = /^ *([-*_] *){3,} *$/; // Horizontal rule
- lines.forEach((line, index) => {
- const key = `line-${index}`;
+ 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 ~~~)
+ let inListType: 'ul' | 'ol' | null = null; // Track current list type to group items
- // --- 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 = '';
- inListType = null; // Ensure list context is reset
- } else {
- // Add line to current code block content
- codeBlockContent.push(line);
- }
- return; // Process next line
- }
+ lines.forEach((line, index) => {
+ const key = `line-${index}`;
- // --- 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);
+ // --- 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 = '';
+ inListType = null; // Ensure list context is reset
+ } else {
+ // Add line to current code block content
+ codeBlockContent.push(line);
+ }
+ return; // Process next line
+ }
- if (codeFenceMatch) {
- inCodeBlock = true;
- codeBlockFence = codeFenceMatch[1];
- codeBlockLang = codeFenceMatch[2] || null;
- inListType = null; // Starting code block breaks list
- } 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>);
- inListType = null; // HR breaks list
- } 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="cyan">{renderedHeaderText}</Text>; break;
- case 2: headerNode = <Text bold color="blue">{renderedHeaderText}</Text>; break;
- case 3: headerNode = <Text bold>{renderedHeaderText}</Text>; break;
- case 4: headerNode = <Text italic color="gray">{renderedHeaderText}</Text>; break;
- }
- if (headerNode) contentBlocks.push(<Box key={key}>{headerNode}</Box>);
- inListType = null; // Header breaks list
- } else if (ulMatch) {
- const marker = ulMatch[1]; // *, -, or +
- const itemText = ulMatch[2];
- // If previous line was not UL, maybe add spacing? For now, just render item.
- contentBlocks.push(MarkdownRenderer._renderListItem(key, itemText, 'ul', marker));
- inListType = 'ul'; // Set/maintain list context
- } else if (olMatch) {
- const marker = olMatch[1]; // The number
- const itemText = olMatch[2];
- contentBlocks.push(MarkdownRenderer._renderListItem(key, itemText, 'ol', marker));
- inListType = 'ol'; // Set/maintain list context
- } else {
- // --- Regular line (Paragraph or Empty line) ---
- inListType = null; // Any non-list line breaks the list sequence
+ // --- 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);
- // 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 space inside code block state (handled above)
- const previousBlock = contentBlocks[contentBlocks.length - 1];
- // 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} />);
- }
- }
- }
- });
+ if (codeFenceMatch) {
+ inCodeBlock = true;
+ codeBlockFence = codeFenceMatch[1];
+ codeBlockLang = codeFenceMatch[2] || null;
+ inListType = null; // Starting code block breaks list
+ } 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>,
+ );
+ inListType = null; // HR breaks list
+ } 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="cyan">
+ {renderedHeaderText}
+ </Text>
+ );
+ break;
+ case 2:
+ headerNode = (
+ <Text bold color="blue">
+ {renderedHeaderText}
+ </Text>
+ );
+ break;
+ case 3:
+ headerNode = <Text bold>{renderedHeaderText}</Text>;
+ break;
+ case 4:
+ headerNode = (
+ <Text italic color="gray">
+ {renderedHeaderText}
+ </Text>
+ );
+ break;
+ }
+ if (headerNode) contentBlocks.push(<Box key={key}>{headerNode}</Box>);
+ inListType = null; // Header breaks list
+ } else if (ulMatch) {
+ const marker = ulMatch[1]; // *, -, or +
+ const itemText = ulMatch[2];
+ // If previous line was not UL, maybe add spacing? For now, just render item.
+ contentBlocks.push(
+ MarkdownRenderer._renderListItem(key, itemText, 'ul', marker),
+ );
+ inListType = 'ul'; // Set/maintain list context
+ } else if (olMatch) {
+ const marker = olMatch[1]; // The number
+ const itemText = olMatch[2];
+ contentBlocks.push(
+ MarkdownRenderer._renderListItem(key, itemText, 'ol', marker),
+ );
+ inListType = 'ol'; // Set/maintain list context
+ } else {
+ // --- Regular line (Paragraph or Empty line) ---
+ inListType = null; // Any non-list line breaks the list sequence
- // Handle unclosed code block at the end of the input
- if (inCodeBlock) {
- contentBlocks.push(MarkdownRenderer._renderCodeBlock(`line-eof`, codeBlockContent, codeBlockLang));
+ // 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 space inside code block state (handled above)
+ const previousBlock = contentBlocks[contentBlocks.length - 1];
+ // 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} />);
+ }
}
+ }
+ });
- return contentBlocks;
+ // Handle unclosed code block at the end of the input
+ if (inCodeBlock) {
+ contentBlocks.push(
+ MarkdownRenderer._renderCodeBlock(
+ `line-eof`,
+ codeBlockContent,
+ codeBlockLang,
+ ),
+ );
}
-} \ No newline at end of file
+
+ return contentBlocks;
+ }
+}