// Copyright 2026 GitCover Commons gUG (haftungsbeschränkt) // Author: Axel Druschel // SPDX-License-Identifier: Apache-2.0 using System.Text; using System.Text.RegularExpressions; using Markdig; using Markdig.Extensions.Tables; using Markdig.Syntax; using Markdig.Syntax.Inlines; namespace GitCover.WebStaticBuilder; /// /// Renders a Markdig AST to ANSI-styled console output. No external /// dependency beyond Markdig (already referenced). Supports headings, /// paragraphs, lists, code blocks, blockquotes, tables, thematic breaks, /// and inline formatting (bold, italic, code, links). /// public static class MarkdownConsoleRenderer { // ANSI SGR codes private const string Reset = "\x1b[0m"; private const string Bold = "\x1b[1m"; private const string Dim = "\x1b[2m"; private const string Italic = "\x1b[3m"; private const string Underline = "\x1b[4m"; private const string FgCyan = "\x1b[36m"; private const string FgBrightCyan = "\x1b[96m"; private const string FgBrightYellow = "\x1b[93m"; private const string FgBrightRed = "\x1b[91m"; private const string FgBrightGreen = "\x1b[92m"; private const string FgBrightBlack = "\x1b[90m"; /// Render a Markdown document to the console. public static void Render(string markdown, TextWriter writer) { var pipeline = new MarkdownPipelineBuilder() .UseAdvancedExtensions() .Build(); var doc = Markdig.Markdown.Parse(markdown, pipeline); RenderBlocks(doc, writer, 0); } private static void RenderBlocks(ContainerBlock container, TextWriter w, int indent) { foreach (var block in container) RenderBlock(block, w, indent); } private static void RenderBlock(Block block, TextWriter w, int indent) { var pad = new string(' ', indent * 2); switch (block) { case HeadingBlock h: RenderHeading(h, w); break; case ParagraphBlock p: w.WriteLine(pad + RenderInlines(p.Inline!)); w.WriteLine(); break; case ListBlock lb: RenderList(lb, w, indent); break; case QuoteBlock qb: RenderQuote(qb, w, indent); break; case FencedCodeBlock fc: RenderCodeBlock(fc.Lines.ToString(), w, indent, fc.Info); break; case CodeBlock cb: RenderCodeBlock(cb.Lines.ToString(), w, indent, null); break; case ThematicBreakBlock: w.WriteLine(pad + $"{Dim}{'─'.ToString().PadLeft(Console.BufferWidth > 0 ? Math.Min(Console.BufferWidth, 60) : 60, '─')}{Reset}"); w.WriteLine(); break; case Table table: RenderTable(table, w, indent); break; default: // Fallback: try to extract text content if (block is ContainerBlock cb2) RenderBlocks(cb2, w, indent); break; } } private static void RenderHeading(HeadingBlock h, TextWriter w) { var text = RenderInlines(h.Inline!); var prefix = h.Level switch { 1 => $"{Bold}{FgBrightYellow}", 2 => $"{Bold}{FgBrightCyan}", 3 => $"{Bold}{FgBrightGreen}", _ => $"{Bold}", }; w.WriteLine($"{prefix}{text}{Reset}"); w.WriteLine(); } private static void RenderList(ListBlock lb, TextWriter w, int indent) { var pad = new string(' ', indent * 2); int idx = 1; foreach (ListItemBlock item in lb) { var marker = lb.IsOrdered ? $"{idx}{lb.OrderedDelimiter} " : "• "; var firstBlock = item.FirstOrDefault(); if (firstBlock is ParagraphBlock p) { w.WriteLine($"{pad}{marker}{RenderInlines(p.Inline!)}"); } else if (firstBlock != null) { w.WriteLine($"{pad}{marker}"); RenderBlock(firstBlock, w, indent + 1); } // Sub-blocks beyond the first paragraph (e.g., nested lists) for (int i = 1; i < item.Count; i++) { if (item[i] is ListBlock subList) RenderList(subList, w, indent + 1); else RenderBlock(item[i], w, indent + 1); } if (lb.IsOrdered) idx++; } w.WriteLine(); } private static void RenderQuote(QuoteBlock qb, TextWriter w, int indent) { var pad = new string(' ', indent * 2); var savedColor = Console.ForegroundColor; Console.ForegroundColor = ConsoleColor.DarkGray; foreach (var sub in qb) { if (sub is ParagraphBlock p) w.WriteLine($"{pad}{Dim}│ {RenderInlines(p.Inline!)}{Reset}"); else RenderBlock(sub, w, indent + 1); } Console.ForegroundColor = savedColor; w.WriteLine(); } private static void RenderCodeBlock(string code, TextWriter w, int indent, string? lang) { var pad = new string(' ', indent * 2); var lines = code.TrimEnd('\n', '\r').Split('\n'); w.WriteLine($"{pad}{Dim}```{lang ?? ""}{Reset}"); foreach (var line in lines) w.WriteLine($"{pad}{FgBrightCyan}{line}{Reset}"); w.WriteLine($"{pad}{Dim}```{Reset}"); w.WriteLine(); } private static void RenderTable(Table table, TextWriter w, int indent) { var pad = new string(' ', indent * 2); // First pass: collect cell texts and compute column widths var rows = new List>(); foreach (TableRow row in table) { var cells = new List(); foreach (TableCell cell in row) { var cellText = new StringBuilder(); foreach (var sub in cell) { if (sub is ParagraphBlock p) cellText.Append(RenderInlines(p.Inline!)); } cells.Add(cellText.ToString().Trim()); } rows.Add(cells); } if (rows.Count == 0) return; var colCount = rows.Max(r => r.Count); var widths = new int[colCount]; foreach (var row in rows) for (int c = 0; c < row.Count; c++) widths[c] = Math.Max(widths[c], row[c].Length); // Render rows bool isFirstRow = true; foreach (var row in rows) { var parts = new List(); for (int c = 0; c < row.Count; c++) { var cellText = c < row.Count ? row[c] : ""; var padded = cellText.PadRight(widths[c]); parts.Add(isFirstRow ? $"{Bold}{padded}{Reset}" : padded); } w.WriteLine($"{pad}│ {string.Join(" │ ", parts)} │"); if (isFirstRow) { // Separator var sep = string.Join("─┬─", widths.Select(wd => new string('─', wd + 2))); w.WriteLine($"{pad}├{sep}┤"); isFirstRow = false; } } w.WriteLine(); } /// Render inline elements to a single ANSI-styled string. private static string RenderInlines(ContainerInline inlines) { var sb = new StringBuilder(); foreach (var il in inlines) RenderInline(il, sb); return sb.ToString(); } private static void RenderInline(Inline inline, StringBuilder sb) { switch (inline) { case LiteralInline lit: sb.Append(lit.Content.ToString()); break; case EmphasisInline emp: var code = emp.DelimiterCount == 2 ? Bold : Italic; sb.Append(code); foreach (var child in emp) RenderInline(child, sb); sb.Append(Reset); if (emp.DelimiterCount == 2) sb.Append(""); // bold doesn't need reset of italic break; case CodeInline ci: sb.Append($"{FgBrightRed}{ci.Content}{Reset}"); break; case LinkInline link: var text = new StringBuilder(); foreach (var child in link) RenderInline(child, text); if (link.IsImage) sb.Append($"{Dim}[image: {text}{Reset}{Dim}]{Reset}"); else if (link.Url != null) sb.Append($"{Underline}{FgCyan}{text}{Reset} {Dim}({link.Url}){Reset}"); else sb.Append(text.ToString()); break; case LineBreakInline: sb.AppendLine(); break; case HtmlInline html: sb.Append($"{Dim}{html.Tag}{Reset}"); break; case ContainerInline container: foreach (var child in container) RenderInline(child, sb); break; default: // DelimiterInline, etc. — extract literal content sb.Append(inline.ToString()); break; } } }