summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx')
-rw-r--r--packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx162
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);
+};