SHA256
- Added LICENSE.md file with Apache License 2.0 text. - Created NOTICE file for attribution of third-party components. - Updated README.md to reflect the new license and included details about the Apache-2.0 license. - Documented the license change in the diary entry for July 12, 2026. - Updated installation instructions in getting-started.md to reflect per-user installation. - Added reusable license header to all source files. - Updated various documentation files to mention the new Apache-2.0 license. - Changed legal mentions in example content to reflect the new license.
270 lines
9.3 KiB
C#
270 lines
9.3 KiB
C#
// 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;
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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";
|
|
|
|
/// <summary>Render a Markdown document to the console.</summary>
|
|
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<List<string>>();
|
|
foreach (TableRow row in table)
|
|
{
|
|
var cells = new List<string>();
|
|
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<string>();
|
|
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();
|
|
}
|
|
|
|
/// <summary>Render inline elements to a single ANSI-styled string.</summary>
|
|
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;
|
|
}
|
|
}
|
|
}
|