fix(tui): render inline markdown formatting with ANSI codes
Block-level renderer methods (paragraph, heading, blockquote, list) were using raw token.text instead of this.parser.parseInline(tokens), causing bold, italic, and inline code to never render. Add table renderer with aligned columns and box-drawing separators. Remove unused marked-terminal dependency (incompatible with marked v17). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,8 +7,37 @@ describe('renderMarkdown', () => {
|
||||
expect(result).toContain('Hello world');
|
||||
});
|
||||
|
||||
it('renders bold text', () => {
|
||||
it('renders bold text with ANSI bold code', () => {
|
||||
const result = renderMarkdown('This is **bold** text');
|
||||
expect(result).toContain('\x1b[1m');
|
||||
expect(result).toContain('bold');
|
||||
expect(result).toContain('\x1b[0m');
|
||||
});
|
||||
|
||||
it('renders italic text with ANSI italic code', () => {
|
||||
const result = renderMarkdown('This is *italic* text');
|
||||
expect(result).toContain('\x1b[3m');
|
||||
expect(result).toContain('italic');
|
||||
expect(result).toContain('\x1b[0m');
|
||||
});
|
||||
|
||||
it('renders inline code with ANSI cyan code', () => {
|
||||
const result = renderMarkdown('Use `console.log()` for debugging');
|
||||
expect(result).toContain('\x1b[36m');
|
||||
expect(result).toContain('console.log()');
|
||||
expect(result).toContain('\x1b[0m');
|
||||
});
|
||||
|
||||
it('renders headings with bold + underline', () => {
|
||||
const result = renderMarkdown('# My Heading');
|
||||
expect(result).toContain('\x1b[1m\x1b[4m');
|
||||
expect(result).toContain('My Heading');
|
||||
expect(result).toContain('\x1b[0m');
|
||||
});
|
||||
|
||||
it('renders heading with inline formatting', () => {
|
||||
const result = renderMarkdown('## A **bold** heading');
|
||||
expect(result).toContain('\x1b[1m');
|
||||
expect(result).toContain('bold');
|
||||
});
|
||||
|
||||
@@ -18,14 +47,66 @@ describe('renderMarkdown', () => {
|
||||
expect(result).toContain('x');
|
||||
});
|
||||
|
||||
it('renders inline code', () => {
|
||||
const result = renderMarkdown('Use `console.log()` for debugging');
|
||||
expect(result).toContain('console.log()');
|
||||
it('renders code blocks with unknown language without crashing', () => {
|
||||
const result = renderMarkdown('```madeuplang\nsome code\n```');
|
||||
expect(result).toContain('some code');
|
||||
});
|
||||
|
||||
it('renders lists', () => {
|
||||
it('renders unordered lists', () => {
|
||||
const result = renderMarkdown('- Item 1\n- Item 2');
|
||||
expect(result).toContain('•');
|
||||
expect(result).toContain('Item 1');
|
||||
expect(result).toContain('Item 2');
|
||||
});
|
||||
|
||||
it('renders inline formatting inside list items', () => {
|
||||
const result = renderMarkdown('- **bold** item\n- `code` item');
|
||||
expect(result).toContain('\x1b[1m');
|
||||
expect(result).toContain('bold');
|
||||
expect(result).toContain('\x1b[36m');
|
||||
expect(result).toContain('code');
|
||||
});
|
||||
|
||||
it('renders blockquotes with │ prefix', () => {
|
||||
const result = renderMarkdown('> This is a quote');
|
||||
expect(result).toContain('│');
|
||||
expect(result).toContain('This is a quote');
|
||||
});
|
||||
|
||||
it('renders inline formatting inside blockquotes', () => {
|
||||
const result = renderMarkdown('> A **bold** quote');
|
||||
expect(result).toContain('│');
|
||||
expect(result).toContain('\x1b[1m');
|
||||
expect(result).toContain('bold');
|
||||
});
|
||||
|
||||
it('renders tables with │ separator', () => {
|
||||
const result = renderMarkdown('| Col A | Col B |\n|-------|-------|\n| 1 | 2 |\n| 3 | 4 |');
|
||||
expect(result).toContain('│');
|
||||
expect(result).toContain('Col A');
|
||||
expect(result).toContain('Col B');
|
||||
expect(result).toContain('1');
|
||||
expect(result).toContain('4');
|
||||
});
|
||||
|
||||
it('renders table headers in bold', () => {
|
||||
const result = renderMarkdown('| Name | Value |\n|------|-------|\n| a | b |');
|
||||
// Header cells should have bold ANSI
|
||||
const lines = result.split('\n');
|
||||
const headerLine = lines[0];
|
||||
expect(headerLine).toContain('\x1b[1m');
|
||||
});
|
||||
|
||||
it('renders horizontal rules', () => {
|
||||
const result = renderMarkdown('---');
|
||||
expect(result).toContain('─');
|
||||
});
|
||||
|
||||
it('renders links with underline and blue URL', () => {
|
||||
const result = renderMarkdown('[click here](https://example.com)');
|
||||
expect(result).toContain('\x1b[4m');
|
||||
expect(result).toContain('click here');
|
||||
expect(result).toContain('\x1b[34m');
|
||||
expect(result).toContain('https://example.com');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,8 @@ function convertHtmlToTerminal(text: string): string {
|
||||
}
|
||||
|
||||
// Custom renderer as an object for marked.use()
|
||||
// Methods that contain inline children use this.parser.parseInline(tokens)
|
||||
// Methods that contain block children use this.parser.parse(tokens)
|
||||
const terminalRenderer: RendererObject = {
|
||||
code({ text, lang }: Tokens.Code): string {
|
||||
try {
|
||||
@@ -59,34 +61,85 @@ const terminalRenderer: RendererObject = {
|
||||
return `\x1b[3m${text}\x1b[0m`; // Italic
|
||||
},
|
||||
|
||||
paragraph({ text }: Tokens.Paragraph): string {
|
||||
return text + '\n';
|
||||
paragraph({ tokens }: Tokens.Paragraph): string {
|
||||
return this.parser.parseInline(tokens) + '\n';
|
||||
},
|
||||
|
||||
list({ items, ordered }: Tokens.List): string {
|
||||
list({ items, ordered, start }: Tokens.List): string {
|
||||
let body = '';
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
const bullet = ordered ? `${i + 1}. ` : ' • ';
|
||||
body += bullet + item.text + '\n';
|
||||
const bullet = ordered ? `${(start || 1) + i}. ` : ' • ';
|
||||
const content = this.parser.parse(item.tokens).replace(/\n$/, '');
|
||||
body += bullet + content + '\n';
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
listitem({ text }: Tokens.ListItem): string {
|
||||
return text;
|
||||
listitem({ tokens }: Tokens.ListItem): string {
|
||||
return this.parser.parse(tokens);
|
||||
},
|
||||
|
||||
heading({ text }: Tokens.Heading): string {
|
||||
return `\x1b[1m\x1b[4m${text}\x1b[0m\n`; // Bold + underline
|
||||
heading({ tokens, depth }: Tokens.Heading): string {
|
||||
const text = this.parser.parseInline(tokens);
|
||||
const prefix = depth <= 2 ? '' : '#'.repeat(depth) + ' ';
|
||||
return `\x1b[1m\x1b[4m${prefix}${text}\x1b[0m\n`; // Bold + underline
|
||||
},
|
||||
|
||||
link({ text, href }: Tokens.Link): string {
|
||||
return `\x1b[4m${text}\x1b[0m (\x1b[34m${href}\x1b[0m)`; // Underline text, blue URL
|
||||
},
|
||||
|
||||
blockquote({ text }: Tokens.Blockquote): string {
|
||||
return text.split('\n').map((line: string) => ` │ ${line}`).join('\n') + '\n';
|
||||
blockquote({ tokens }: Tokens.Blockquote): string {
|
||||
const body = this.parser.parse(tokens);
|
||||
return body.split('\n').map((line: string) => ` │ ${line}`).join('\n') + '\n';
|
||||
},
|
||||
|
||||
table({ header, rows, align }: Tokens.Table): string {
|
||||
// Render cell contents
|
||||
const headerTexts = header.map((cell: Tokens.TableCell) =>
|
||||
this.parser.parseInline(cell.tokens)
|
||||
);
|
||||
const rowTexts = rows.map((row: Tokens.TableCell[]) =>
|
||||
row.map((cell: Tokens.TableCell) => this.parser.parseInline(cell.tokens))
|
||||
);
|
||||
|
||||
// Calculate column widths (strip ANSI for measurement)
|
||||
const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
const colWidths = headerTexts.map((h: string, i: number) => {
|
||||
const cellWidths = rowTexts.map((row: string[]) => stripAnsi(row[i] || '').length);
|
||||
return Math.max(stripAnsi(h).length, ...cellWidths);
|
||||
});
|
||||
|
||||
// Pad cell to width respecting ANSI codes
|
||||
const pad = (text: string, width: number, alignment: string | null) => {
|
||||
const visible = stripAnsi(text).length;
|
||||
const diff = width - visible;
|
||||
if (diff <= 0) return text;
|
||||
if (alignment === 'right') return ' '.repeat(diff) + text;
|
||||
if (alignment === 'center') {
|
||||
const left = Math.floor(diff / 2);
|
||||
return ' '.repeat(left) + text + ' '.repeat(diff - left);
|
||||
}
|
||||
return text + ' '.repeat(diff);
|
||||
};
|
||||
|
||||
// Build header row
|
||||
const headerRow = ' ' + headerTexts.map((h: string, i: number) =>
|
||||
`\x1b[1m${pad(h, colWidths[i], align[i])}\x1b[0m`
|
||||
).join(' │ ');
|
||||
|
||||
// Build separator
|
||||
const separator = '─' + colWidths.map((w: number) => '─'.repeat(w)).join('─┼─') + '─';
|
||||
|
||||
// Build data rows
|
||||
const dataRows = rowTexts.map((row: string[]) =>
|
||||
' ' + row.map((cell: string, i: number) =>
|
||||
pad(cell, colWidths[i], align[i])
|
||||
).join(' │ ')
|
||||
);
|
||||
|
||||
return [headerRow, separator, ...dataRows].join('\n') + '\n';
|
||||
},
|
||||
|
||||
hr(): string {
|
||||
|
||||
Reference in New Issue
Block a user