Skip to content

Commit ec39aa1

Browse files
authored
Moved markdown parsing logic to a separate util file (#20526)
1 parent 7a1f2f3 commit ec39aa1

4 files changed

Lines changed: 219 additions & 212 deletions

File tree

packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx

Lines changed: 1 addition & 210 deletions
Original file line numberDiff line numberDiff line change
@@ -6,223 +6,14 @@
66

77
import React from 'react';
88
import { Text } from 'ink';
9-
import chalk from 'chalk';
10-
import {
11-
resolveColor,
12-
INK_SUPPORTED_NAMES,
13-
INK_NAME_TO_HEX_MAP,
14-
} from '../themes/color-utils.js';
15-
import { theme } from '../semantic-colors.js';
16-
import { debugLogger } from '@google/gemini-cli-core';
9+
import { parseMarkdownToANSI } from './markdownParsingUtils.js';
1710
import { stripUnsafeCharacters } from './textUtils.js';
1811

19-
// Constants for Markdown parsing
20-
const BOLD_MARKER_LENGTH = 2; // For "**"
21-
const ITALIC_MARKER_LENGTH = 1; // For "*" or "_"
22-
const STRIKETHROUGH_MARKER_LENGTH = 2; // For "~~")
23-
const INLINE_CODE_MARKER_LENGTH = 1; // For "`"
24-
const UNDERLINE_TAG_START_LENGTH = 3; // For "<u>"
25-
const UNDERLINE_TAG_END_LENGTH = 4; // For "</u>"
26-
2712
interface RenderInlineProps {
2813
text: string;
2914
defaultColor?: string;
3015
}
3116

32-
/**
33-
* Helper to apply color to a string using ANSI escape codes,
34-
* consistent with how Ink's colorize works.
35-
*/
36-
const ansiColorize = (str: string, color: string | undefined): string => {
37-
if (!color) return str;
38-
const resolved = resolveColor(color);
39-
if (!resolved) return str;
40-
41-
if (resolved.startsWith('#')) {
42-
return chalk.hex(resolved)(str);
43-
}
44-
45-
const mappedHex = INK_NAME_TO_HEX_MAP[resolved];
46-
if (mappedHex) {
47-
return chalk.hex(mappedHex)(str);
48-
}
49-
50-
if (INK_SUPPORTED_NAMES.has(resolved)) {
51-
switch (resolved) {
52-
case 'black':
53-
return chalk.black(str);
54-
case 'red':
55-
return chalk.red(str);
56-
case 'green':
57-
return chalk.green(str);
58-
case 'yellow':
59-
return chalk.yellow(str);
60-
case 'blue':
61-
return chalk.blue(str);
62-
case 'magenta':
63-
return chalk.magenta(str);
64-
case 'cyan':
65-
return chalk.cyan(str);
66-
case 'white':
67-
return chalk.white(str);
68-
case 'gray':
69-
case 'grey':
70-
return chalk.gray(str);
71-
default:
72-
return str;
73-
}
74-
}
75-
76-
return str;
77-
};
78-
79-
/**
80-
* Converts markdown text into a string with ANSI escape codes.
81-
* This mirrors the parsing logic in InlineMarkdownRenderer.tsx
82-
*/
83-
export const parseMarkdownToANSI = (
84-
text: string,
85-
defaultColor?: string,
86-
): string => {
87-
const baseColor = defaultColor ?? theme.text.primary;
88-
// Early return for plain text without markdown or URLs
89-
if (!/[*_~`<[https?:]/.test(text)) {
90-
return ansiColorize(text, baseColor);
91-
}
92-
93-
let result = '';
94-
const inlineRegex =
95-
/(\*\*\*.*?\*\*\*|\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|<u>.*?<\/u>|https?:\/\/\S+)/g;
96-
let lastIndex = 0;
97-
let match;
98-
99-
while ((match = inlineRegex.exec(text)) !== null) {
100-
if (match.index > lastIndex) {
101-
result += ansiColorize(text.slice(lastIndex, match.index), baseColor);
102-
}
103-
104-
const fullMatch = match[0];
105-
let styledPart = '';
106-
107-
try {
108-
if (
109-
fullMatch.endsWith('***') &&
110-
fullMatch.startsWith('***') &&
111-
fullMatch.length > (BOLD_MARKER_LENGTH + ITALIC_MARKER_LENGTH) * 2
112-
) {
113-
styledPart = chalk.bold(
114-
chalk.italic(
115-
parseMarkdownToANSI(
116-
fullMatch.slice(
117-
BOLD_MARKER_LENGTH + ITALIC_MARKER_LENGTH,
118-
-BOLD_MARKER_LENGTH - ITALIC_MARKER_LENGTH,
119-
),
120-
baseColor,
121-
),
122-
),
123-
);
124-
} else if (
125-
fullMatch.endsWith('**') &&
126-
fullMatch.startsWith('**') &&
127-
fullMatch.length > BOLD_MARKER_LENGTH * 2
128-
) {
129-
styledPart = chalk.bold(
130-
parseMarkdownToANSI(
131-
fullMatch.slice(BOLD_MARKER_LENGTH, -BOLD_MARKER_LENGTH),
132-
baseColor,
133-
),
134-
);
135-
} else if (
136-
fullMatch.length > ITALIC_MARKER_LENGTH * 2 &&
137-
((fullMatch.startsWith('*') && fullMatch.endsWith('*')) ||
138-
(fullMatch.startsWith('_') && fullMatch.endsWith('_'))) &&
139-
!/\w/.test(text.substring(match.index - 1, match.index)) &&
140-
!/\w/.test(
141-
text.substring(inlineRegex.lastIndex, inlineRegex.lastIndex + 1),
142-
) &&
143-
!/\S[./\\]/.test(text.substring(match.index - 2, match.index)) &&
144-
!/[./\\]\S/.test(
145-
text.substring(inlineRegex.lastIndex, inlineRegex.lastIndex + 2),
146-
)
147-
) {
148-
styledPart = chalk.italic(
149-
parseMarkdownToANSI(
150-
fullMatch.slice(ITALIC_MARKER_LENGTH, -ITALIC_MARKER_LENGTH),
151-
baseColor,
152-
),
153-
);
154-
} else if (
155-
fullMatch.startsWith('~~') &&
156-
fullMatch.endsWith('~~') &&
157-
fullMatch.length > STRIKETHROUGH_MARKER_LENGTH * 2
158-
) {
159-
styledPart = chalk.strikethrough(
160-
parseMarkdownToANSI(
161-
fullMatch.slice(
162-
STRIKETHROUGH_MARKER_LENGTH,
163-
-STRIKETHROUGH_MARKER_LENGTH,
164-
),
165-
baseColor,
166-
),
167-
);
168-
} else if (
169-
fullMatch.startsWith('`') &&
170-
fullMatch.endsWith('`') &&
171-
fullMatch.length > INLINE_CODE_MARKER_LENGTH
172-
) {
173-
const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s);
174-
if (codeMatch && codeMatch[2]) {
175-
styledPart = ansiColorize(codeMatch[2], theme.text.accent);
176-
}
177-
} else if (
178-
fullMatch.startsWith('[') &&
179-
fullMatch.includes('](') &&
180-
fullMatch.endsWith(')')
181-
) {
182-
const linkMatch = fullMatch.match(/\[(.*?)\]\((.*?)\)/);
183-
if (linkMatch) {
184-
const linkText = linkMatch[1];
185-
const url = linkMatch[2];
186-
styledPart =
187-
parseMarkdownToANSI(linkText, baseColor) +
188-
ansiColorize(' (', baseColor) +
189-
ansiColorize(url, theme.text.link) +
190-
ansiColorize(')', baseColor);
191-
}
192-
} else if (
193-
fullMatch.startsWith('<u>') &&
194-
fullMatch.endsWith('</u>') &&
195-
fullMatch.length >
196-
UNDERLINE_TAG_START_LENGTH + UNDERLINE_TAG_END_LENGTH - 1
197-
) {
198-
styledPart = chalk.underline(
199-
parseMarkdownToANSI(
200-
fullMatch.slice(
201-
UNDERLINE_TAG_START_LENGTH,
202-
-UNDERLINE_TAG_END_LENGTH,
203-
),
204-
baseColor,
205-
),
206-
);
207-
} else if (fullMatch.match(/^https?:\/\//)) {
208-
styledPart = ansiColorize(fullMatch, theme.text.link);
209-
}
210-
} catch (e) {
211-
debugLogger.warn('Error parsing inline markdown part:', fullMatch, e);
212-
styledPart = '';
213-
}
214-
215-
result += styledPart || ansiColorize(fullMatch, baseColor);
216-
lastIndex = inlineRegex.lastIndex;
217-
}
218-
219-
if (lastIndex < text.length) {
220-
result += ansiColorize(text.slice(lastIndex), baseColor);
221-
}
222-
223-
return result;
224-
};
225-
22617
const RenderInlineInternal: React.FC<RenderInlineProps> = ({
22718
text: rawText,
22819
defaultColor,

packages/cli/src/ui/utils/TableRenderer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
widestLineFromStyledChars,
1818
} from 'ink';
1919
import { theme } from '../semantic-colors.js';
20-
import { parseMarkdownToANSI } from './InlineMarkdownRenderer.js';
20+
import { parseMarkdownToANSI } from './markdownParsingUtils.js';
2121
import { stripUnsafeCharacters } from './textUtils.js';
2222

2323
interface TableRendererProps {

packages/cli/src/ui/utils/markdownParsingUtils.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import { describe, it, expect, beforeAll, vi } from 'vitest';
88
import chalk from 'chalk';
9-
import { parseMarkdownToANSI } from './InlineMarkdownRenderer.js';
9+
import { parseMarkdownToANSI } from './markdownParsingUtils.js';
1010

1111
// Mock the theme to use explicit colors instead of empty strings from the default theme.
1212
// This ensures that ansiColorize actually applies ANSI codes that we can verify.

0 commit comments

Comments
 (0)