diff --git a/src/frontends/tui/markdown.test.ts b/src/frontends/tui/markdown.test.ts new file mode 100644 index 0000000..ec1ca0e --- /dev/null +++ b/src/frontends/tui/markdown.test.ts @@ -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'); + }); +}); diff --git a/src/frontends/tui/markdown.ts b/src/frontends/tui/markdown.ts new file mode 100644 index 0000000..c879fd4 --- /dev/null +++ b/src/frontends/tui/markdown.ts @@ -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, we only use sync + if (typeof rendered === 'string') { + return rendered.trim(); + } + return text; + } catch { + return text; + } +}