summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/utils/MarkdownRenderer.tsx
blob: 20b50939c5e28fa1b003911796e16e1083f66055 (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
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
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;
  }
}