using System.Text; using System.Text.RegularExpressions; using Spectre.Console; namespace NozCli.Rendering; /// Terminal markdown renderer — converts common markdown to Spectre.Console output. /// Handles: headings, bold/italic, inline code, fenced code blocks, /// bullet/numbered lists, blockquotes, horizontal rules. public static partial class MarkdownRenderer { public static void Render(string markdown) { var lines = markdown.ReplaceLineEndings("\n").Split('\n'); var inCode = false; var codeLang = ""; var codeBlock = new StringBuilder(); AnsiConsole.WriteLine(); for (var i = 0; i < lines.Length; i++) { var line = lines[i]; // ── Fenced code block ──────────────────────────────────────────── if (line.TrimStart().StartsWith("```")) { if (!inCode) { inCode = true; codeLang = line.TrimStart()[3..].Trim(); codeBlock.Clear(); } else { inCode = false; RenderCodeBlock(codeBlock.ToString().TrimEnd(), codeLang); } continue; } if (inCode) { codeBlock.AppendLine(line); continue; } // ── Horizontal rule ────────────────────────────────────────────── if (HrRegex().IsMatch(line)) { AnsiConsole.Write(new Rule() { Style = new Style(new Color(60, 60, 80)) }); continue; } // ── Headings ───────────────────────────────────────────────────── if (line.StartsWith("### ")) { AnsiConsole.MarkupLine($"\n [bold white]{Markup.Escape(line[4..])}[/]"); continue; } if (line.StartsWith("## ")) { AnsiConsole.MarkupLine($"\n[bold cyan]{Markup.Escape(line[3..])}[/]"); continue; } if (line.StartsWith("# ")) { AnsiConsole.MarkupLine($"\n[bold underline cyan]{Markup.Escape(line[2..])}[/]\n"); continue; } // ── Blockquote ─────────────────────────────────────────────────── if (line.StartsWith("> ")) { var content = ApplyInline(line[2..]); AnsiConsole.MarkupLine($"[grey46]┃[/] [dim italic]{content}[/]"); continue; } // ── Bullet list ────────────────────────────────────────────────── var bulletMatch = BulletRegex().Match(line); if (bulletMatch.Success) { var indent = bulletMatch.Groups[1].Value.Length / 2; var content = ApplyInline(bulletMatch.Groups[2].Value); var pad = new string(' ', indent * 2); AnsiConsole.MarkupLine($"{pad} [dim cyan]•[/] {content}"); continue; } // ── Numbered list ──────────────────────────────────────────────── var numMatch = NumberedRegex().Match(line); if (numMatch.Success) { var num = numMatch.Groups[1].Value; var content = ApplyInline(numMatch.Groups[2].Value); AnsiConsole.MarkupLine($" [dim cyan]{num}.[/] {content}"); continue; } // ── Empty line ─────────────────────────────────────────────────── if (string.IsNullOrWhiteSpace(line)) { AnsiConsole.WriteLine(); continue; } // ── Paragraph ──────────────────────────────────────────────────── AnsiConsole.MarkupLine($" {ApplyInline(line)}"); } AnsiConsole.WriteLine(); } // ── Code block rendering ───────────────────────────────────────────────── private static void RenderCodeBlock(string code, string lang) { var header = lang.Length > 0 ? $"[dim]{Markup.Escape(lang)}[/]" : "[dim]code[/]"; var panel = new Panel(new Text(code, new Style(new Color(200, 200, 220)))) { Header = new PanelHeader($" {header} "), Border = BoxBorder.Rounded, BorderStyle = new Style(new Color(60, 60, 80)), Padding = new Padding(1, 0), }; AnsiConsole.WriteLine(); AnsiConsole.Write(panel); } // ── Inline formatting ──────────────────────────────────────────────────── private static string ApplyInline(string raw) { // Collect all pattern matches, escape text between them. // This ensures brackets/special chars in regular text don't break Spectre markup. (Regex regex, Func format)[] patterns = [ (InlineCodeRegex(), m => $"[bold cyan on grey7] {Markup.Escape(m.Groups[1].Value)} [/]"), (BoldRegex(), m => $"[bold]{Markup.Escape(m.Groups[1].Value)}[/]"), (ItalicRegex(), m => $"[italic]{Markup.Escape(m.Groups[1].Value)}[/]"), ]; var spans = new List<(int start, int end, string replacement)>(); foreach (var (regex, format) in patterns) { foreach (Match m in regex.Matches(raw)) { // Skip if overlapping with an already-claimed span if (spans.Exists(s => m.Index < s.end && m.Index + m.Length > s.start)) continue; spans.Add((m.Index, m.Index + m.Length, format(m))); } } spans.Sort((a, b) => a.start.CompareTo(b.start)); var sb = new StringBuilder(); var pos = 0; foreach (var (start, end, replacement) in spans) { if (pos < start) sb.Append(Markup.Escape(raw[pos..start])); sb.Append(replacement); pos = end; } if (pos < raw.Length) sb.Append(Markup.Escape(raw[pos..])); return sb.ToString(); } // ── Compiled regexes (AOT-safe via [GeneratedRegex]) ──────────────────── [GeneratedRegex(@"^[-*_]{3,}\s*$")] private static partial Regex HrRegex(); [GeneratedRegex(@"^( *)[-*+] (.+)$")] private static partial Regex BulletRegex(); [GeneratedRegex(@"^(\d+)\. (.+)$")] private static partial Regex NumberedRegex(); [GeneratedRegex(@"`([^`]+)`")] private static partial Regex InlineCodeRegex(); [GeneratedRegex(@"\*\*([^*]+)\*\*")] private static partial Regex BoldRegex(); [GeneratedRegex(@"(?