From cfc697a96d2e716a75e1c3b7f0f34fce81abaf1e Mon Sep 17 00:00:00 2001 From: Taylor Mullen Date: Thu, 17 Apr 2025 18:06:21 -0400 Subject: Run `npm run format` - Also updated README.md accordingly. Part of https://b.corp.google.com/issues/411384603 --- packages/cli/src/ui/utils/MarkdownRenderer.tsx | 567 +++++++++++++++---------- 1 file changed, 340 insertions(+), 227 deletions(-) (limited to 'packages/cli/src/ui/utils') 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``, 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; + // UPDATED Regex: Added .*?<\/u> pattern + const inlineRegex = + /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|.*?<\/u>)/g; + let match; - /** - * 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; - // UPDATED Regex: Added .*?<\/u> pattern - 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 - } + 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 - // 4. Add any remaining plain text after the last match - if (lastIndex < text.length) { - nodes.push({text.slice(lastIndex)}); + // 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 + } - // 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 ?? {fullMatch}); + 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 ( - - {lang && {lang}} - {/* Render each line preserving whitespace (within Text component) */} - {content.map((line, idx) => ( - {line} - ))} - - ); + // 4. Add any remaining plain text after the last match + if (lastIndex < text.length) { + nodes.push({text.slice(lastIndex)}); } - /** - * 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; - - return ( - - - {prefix} - - - {renderedText} - - - ); - } + // 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 { + // Basic styling for code block + return ( + + {lang && {lang}} + {/* Render each line preserving whitespace (within Text component) */} + {content.map((line, idx) => ( + {line} + ))} + + ); + } + + /** + * 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; + + 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. + */ + public 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 = /^ *([-*+]) +(.*)/; // Unordered list item, captures bullet and text + const olItemRegex = /^ *(\d+)\. +(.*)/; // Ordered list item, captures 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 ~~~) + let inListType: 'ul' | 'ol' | null = null; // Track current list type to group items - /** - * 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 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 - - 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 - - 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 = ''; - inListType = null; // Ensure list context is reset - } 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; - 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(---); - 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 = {renderedHeaderText}; break; - case 2: headerNode = {renderedHeaderText}; break; - case 3: headerNode = {renderedHeaderText}; break; - case 4: headerNode = {renderedHeaderText}; break; - } - if (headerNode) contentBlocks.push({headerNode}); - 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 - - // 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 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(); - } - } - } - }); - - // Handle unclosed code block at the end of the input - if (inCodeBlock) { - contentBlocks.push(MarkdownRenderer._renderCodeBlock(`line-eof`, codeBlockContent, codeBlockLang)); + 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 = ''; + inListType = null; // Ensure list context is reset + } 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; + 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( + + --- + , + ); + 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 = ( + + {renderedHeaderText} + + ); + break; + case 2: + headerNode = ( + + {renderedHeaderText} + + ); + break; + case 3: + headerNode = {renderedHeaderText}; + break; + case 4: + headerNode = ( + + {renderedHeaderText} + + ); + break; } + if (headerNode) contentBlocks.push({headerNode}); + 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 - return contentBlocks; + // 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 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(); + } + } + } + }); + + // 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; + } +} -- cgit v1.2.3