diff options
Diffstat (limited to 'packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx')
| -rw-r--r-- | packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx | 162 |
1 files changed, 162 insertions, 0 deletions
diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx new file mode 100644 index 00000000..ff8d6257 --- /dev/null +++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx @@ -0,0 +1,162 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { Text } from 'ink'; +import { Colors } from '../colors.js'; +import stringWidth from 'string-width'; + +// Constants for Markdown parsing +const BOLD_MARKER_LENGTH = 2; // For "**" +const ITALIC_MARKER_LENGTH = 1; // For "*" or "_" +const STRIKETHROUGH_MARKER_LENGTH = 2; // For "~~" +const INLINE_CODE_MARKER_LENGTH = 1; // For "`" +const UNDERLINE_TAG_START_LENGTH = 3; // For "<u>" +const UNDERLINE_TAG_END_LENGTH = 4; // For "</u>" + +interface RenderInlineProps { + text: string; +} + +const RenderInlineInternal: React.FC<RenderInlineProps> = ({ text }) => { + 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 > BOLD_MARKER_LENGTH * 2 + ) { + renderedNode = ( + <Text key={key} bold> + {fullMatch.slice(BOLD_MARKER_LENGTH, -BOLD_MARKER_LENGTH)} + </Text> + ); + } else if ( + fullMatch.length > ITALIC_MARKER_LENGTH * 2 && + ((fullMatch.startsWith('*') && fullMatch.endsWith('*')) || + (fullMatch.startsWith('_') && fullMatch.endsWith('_'))) && + !/\w/.test(text.substring(match.index - 1, match.index)) && + !/\w/.test( + text.substring(inlineRegex.lastIndex, inlineRegex.lastIndex + 1), + ) && + !/\S[./\\]/.test(text.substring(match.index - 2, match.index)) && + !/[./\\]\S/.test( + text.substring(inlineRegex.lastIndex, inlineRegex.lastIndex + 2), + ) + ) { + renderedNode = ( + <Text key={key} italic> + {fullMatch.slice(ITALIC_MARKER_LENGTH, -ITALIC_MARKER_LENGTH)} + </Text> + ); + } else if ( + fullMatch.startsWith('~~') && + fullMatch.endsWith('~~') && + fullMatch.length > STRIKETHROUGH_MARKER_LENGTH * 2 + ) { + renderedNode = ( + <Text key={key} strikethrough> + {fullMatch.slice( + STRIKETHROUGH_MARKER_LENGTH, + -STRIKETHROUGH_MARKER_LENGTH, + )} + </Text> + ); + } else if ( + fullMatch.startsWith('`') && + fullMatch.endsWith('`') && + fullMatch.length > INLINE_CODE_MARKER_LENGTH + ) { + const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s); + if (codeMatch && codeMatch[2]) { + renderedNode = ( + <Text key={key} color={Colors.AccentPurple}> + {codeMatch[2]} + </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 > + UNDERLINE_TAG_START_LENGTH + UNDERLINE_TAG_END_LENGTH - 1 // -1 because length is compared to combined length of start and end tags + ) { + renderedNode = ( + <Text key={key} underline> + {fullMatch.slice( + UNDERLINE_TAG_START_LENGTH, + -UNDERLINE_TAG_END_LENGTH, + )} + </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)}</>; +}; + +export const RenderInline = React.memo(RenderInlineInternal); + +/** + * Utility function to get the plain text length of a string with markdown formatting + * This is useful for calculating column widths in tables + */ +export const getPlainTextLength = (text: string): number => { + const cleanText = text + .replace(/\*\*(.*?)\*\*/g, '$1') + .replace(/\*(.*?)\*/g, '$1') + .replace(/_(.*?)_/g, '$1') + .replace(/~~(.*?)~~/g, '$1') + .replace(/`(.*?)`/g, '$1') + .replace(/<u>(.*?)<\/u>/g, '$1') + .replace(/\[(.*?)\]\(.*?\)/g, '$1'); + return stringWidth(cleanText); +}; |
