feat(tui): add markdown rendering utility

This commit is contained in:
William Valentin
2026-02-05 10:52:00 -08:00
parent 0950296cf0
commit da2bb57488
2 changed files with 111 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import { renderMarkdown } from './markdown.js';
describe('renderMarkdown', () => {
it('renders plain text unchanged', () => {
const result = renderMarkdown('Hello world');
expect(result).toContain('Hello world');
});
it('renders bold text', () => {
const result = renderMarkdown('This is **bold** text');
expect(result).toContain('bold');
});
it('renders code blocks', () => {
const result = renderMarkdown('```javascript\nconst x = 1;\n```');
expect(result).toContain('const');
expect(result).toContain('x');
});
it('renders inline code', () => {
const result = renderMarkdown('Use `console.log()` for debugging');
expect(result).toContain('console.log()');
});
it('renders lists', () => {
const result = renderMarkdown('- Item 1\n- Item 2');
expect(result).toContain('Item 1');
expect(result).toContain('Item 2');
});
});
+80
View File
@@ -0,0 +1,80 @@
import { marked, Renderer, type Tokens } from 'marked';
import { highlight } from 'cli-highlight';
// Create a custom renderer extending the base Renderer
class TerminalRenderer extends Renderer {
code({ text, lang }: Tokens.Code): string {
try {
return '\n' + highlight(text, { language: lang || 'plaintext' }) + '\n';
} catch {
return '\n' + text + '\n';
}
}
codespan({ text }: Tokens.Codespan): string {
return `\x1b[36m${text}\x1b[0m`; // Cyan for inline code
}
strong({ tokens }: Tokens.Strong): string {
const text = this.parser.parseInline(tokens);
return `\x1b[1m${text}\x1b[0m`; // Bold
}
em({ tokens }: Tokens.Em): string {
const text = this.parser.parseInline(tokens);
return `\x1b[3m${text}\x1b[0m`; // Italic
}
paragraph({ tokens }: Tokens.Paragraph): string {
return this.parser.parseInline(tokens) + '\n';
}
list({ items, ordered }: Tokens.List): string {
let body = '';
for (let i = 0; i < items.length; i++) {
const item = items[i];
const bullet = ordered ? `${i + 1}. ` : ' • ';
body += bullet + this.parser.parseInline(item.tokens) + '\n';
}
return body;
}
listitem({ tokens }: Tokens.ListItem): string {
return this.parser.parseInline(tokens);
}
heading({ tokens }: Tokens.Heading): string {
const text = this.parser.parseInline(tokens);
return `\x1b[1m\x1b[4m${text}\x1b[0m\n`; // Bold + underline
}
link({ tokens, href }: Tokens.Link): string {
const text = this.parser.parseInline(tokens);
return `\x1b[4m${text}\x1b[0m (\x1b[34m${href}\x1b[0m)`; // Underline text, blue URL
}
blockquote({ tokens }: Tokens.Blockquote): string {
const text = this.parser.parse(tokens);
return text.split('\n').map((line: string) => `${line}`).join('\n') + '\n';
}
hr(): string {
return '─'.repeat(40) + '\n';
}
}
// Configure marked with our renderer
marked.use({ renderer: new TerminalRenderer() });
export function renderMarkdown(text: string): string {
try {
const rendered = marked.parse(text);
// marked.parse can return string | Promise<string>, we only use sync
if (typeof rendered === 'string') {
return rendered.trim();
}
return text;
} catch {
return text;
}
}