summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/utils/MarkdownRenderer.tsx
blob: fc8c2b0cf5998ce7f4c88fa56a2615876c602239 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
import React from 'react';
import { Text, Box } from 'ink';

/**
 * 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;
        // 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
            }


            // 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 {
        // 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;

        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>
        );
    }


    /**
     * 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(<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

                // 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} />);
                    }
                }
            }
        });

        // Handle unclosed code block at the end of the input
        if (inCodeBlock) {
            contentBlocks.push(MarkdownRenderer._renderCodeBlock(`line-eof`, codeBlockContent, codeBlockLang));
        }

        return contentBlocks;
    }
}