Coverage for src / osiris_cli / markdown_renderer.py: 0%
80 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-27 17:41 +0200
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-27 17:41 +0200
1#!/usr/bin/env python3
2"""
3Clean markdown renderer for Osiris CLI
4Converts markdown to beautiful terminal output WITHOUT escape code pollution
5"""
7from rich.console import Console
8from rich.text import Text
9import re
11console = Console()
14class CleanMarkdownRenderer:
15 """
16 Clean markdown renderer that outputs directly without escape codes
17 """
19 COLORS = {
20 "h1": "bold bright_cyan",
21 "h2": "bold cyan",
22 "h3": "bold blue",
23 "h4": "bold",
24 "bold": "bold white",
25 "italic": "italic",
26 "code": "yellow on grey11",
27 "link": "blue underline",
28 }
30 def __init__(self, console_instance=None):
31 self.console = console_instance or Console()
32 self.buffer = ""
34 def process_line(self, line: str) -> Text:
35 """Process a single line of markdown and return styled Text"""
36 text = Text()
38 # Handle headings
39 if line.startswith('#### '):
40 text.append(line[5:].strip(), style=self.COLORS["h4"])
41 return text
42 elif line.startswith('### '):
43 text.append(line[4:].strip(), style=self.COLORS["h3"])
44 return text
45 elif line.startswith('## '):
46 text.append(line[3:].strip(), style=self.COLORS["h2"])
47 return text
48 elif line.startswith('# '):
49 text.append(line[2:].strip(), style=self.COLORS["h1"])
50 return text
52 # Handle lists
53 if line.strip().startswith(('- ', '* ', '+ ')):
54 text.append(' • ', style="cyan")
55 line = line.strip()[2:]
56 elif re.match(r'^\d+\.\s', line.strip()):
57 match = re.match(r'^(\d+)\.\s', line.strip())
58 text.append(f' {match.group(1)}. ', style="cyan")
59 line = line.strip()[len(match.group(0)):]
61 # Process inline formatting
62 remaining = line
63 while remaining:
64 # Bold
65 bold_match = re.search(r'\*\*(.+?)\*\*', remaining)
66 # Italic
67 italic_match = re.search(r'\*(.+?)\*', remaining)
68 # Code
69 code_match = re.search(r'`(.+?)`', remaining)
71 # Find earliest match
72 matches = []
73 if bold_match:
74 matches.append(('bold', bold_match.start(), bold_match.end(), bold_match.group(1)))
75 if italic_match and (not bold_match or italic_match.start() < bold_match.start()):
76 matches.append(('italic', italic_match.start(), italic_match.end(), italic_match.group(1)))
77 if code_match:
78 matches.append(('code', code_match.start(), code_match.end(), code_match.group(1)))
80 if not matches:
81 text.append(remaining)
82 break
84 # Sort by position
85 matches.sort(key=lambda x: x[1])
86 match_type, start, end, content = matches[0]
88 # Add text before match
89 if start > 0:
90 text.append(remaining[:start])
92 # Add styled content
93 if match_type == 'bold':
94 text.append(content, style=self.COLORS["bold"])
95 elif match_type == 'italic':
96 text.append(content, style=self.COLORS["italic"])
97 elif match_type == 'code':
98 text.append(content, style=self.COLORS["code"])
100 # Continue with remaining text
101 remaining = remaining[end:]
103 return text
105 def render_chunk(self, chunk: str) -> bool:
106 """
107 Process a chunk of text
108 Returns True if content was rendered, False if buffering
109 """
110 self.buffer += chunk
112 # Process complete lines only
113 rendered_any = False
114 while '\n' in self.buffer:
115 line, self.buffer = self.buffer.split('\n', 1)
116 if line.strip():
117 styled_text = self.process_line(line)
118 self.console.print(styled_text)
119 rendered_any = True
120 else:
121 self.console.print()
122 rendered_any = True
124 return rendered_any
126 def flush(self):
127 """Render any remaining content"""
128 if self.buffer.strip():
129 styled_text = self.process_line(self.buffer)
130 self.console.print(styled_text)
131 self.buffer = ""
134# Quick test
135if __name__ == "__main__":
136 renderer = CleanMarkdownRenderer()
138 test_text = """## Test Heading
139This is **bold** and this is *italic* and `code`.
141### Subheading
142- Item 1
143- Item 2
145#### Smaller heading
146Some normal text here."""
148 for line in test_text.split('\n'):
149 renderer.render_chunk(line + '\n')
150 renderer.flush()