webstatic status: Markdown-Rendering im Terminal + LICENSE mit Dep-Lizenzen

- MarkdownConsoleRenderer.cs: eigener Markdig-AST→ANSI-Renderer (keine
  neue NuGet-Dependency). Unterstützt Headings, Paragraphs, Listen,
  Code-Blöcke, Blockquotes, Tabellen, Thematic Breaks, Inline-Formatierung
  (bold/italic/code/links). ANSI-Styling mit Farben.
- Program.cs: 'webstatic status' / 'webstatic --status' Subcommand —
  rendert README.md der Site im Terminal (Default: cwd, --root überschreibt).
  Graceful error wenn README.md fehlt. Help-Eintrag ergänzt.
- LICENSE: Third-Party-Dependency-Lizenzen ergänzt (Markdig BSD-2-Clause,
  YamlDotNet MIT) mit vollständigen Lizenztexten, Links und Kompatibilitäts-Tabelle.
- README.md: aktualisiert als Status-Dokument (neuer status-Befehl,
  CLI-Befehle-Tabelle, bereinigte Environments, LICENSE-Verweis).
This commit is contained in:
2026-07-11 17:27:05 +02:00
parent 18c2108d81
commit 6ca81679d9
4 changed files with 385 additions and 2 deletions
+265
View File
@@ -0,0 +1,265 @@
using System.Text;
using System.Text.RegularExpressions;
using Markdig;
using Markdig.Extensions.Tables;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
namespace GCBoK.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;
}
}
}
+29
View File
@@ -96,6 +96,13 @@ if (argsList.Contains("init"))
return;
}
// Subcommand: status — render the site's README.md to the console
if (argsList.Contains("status") || argsList.Contains("--status"))
{
ShowStatus(root);
return;
}
// Build
// "development" folds work, preview and review into a single local/dev environment.
var envName = "development";
@@ -130,6 +137,7 @@ Aufruf:
webstatic build [Optionen] Website bauen
webstatic serve [--port N] Lokalen Server starten (Vorschau)
webstatic init <name> Beispiel-Site anlegen
webstatic status README.md der Site im Terminal anzeigen
webstatic translate --check Fehlende Übersetzungen melden
webstatic translate --draft Übersetzungs-Entwürfe erzeugen
webstatic --version Version (Assembly + Git-Commit) anzeigen
@@ -382,3 +390,24 @@ static string MimeType(string file)
_ => "application/octet-stream",
};
}
static void ShowStatus(string rootDir)
{
// Search for README.md in the site root (case-insensitive)
var readme = Directory.GetFiles(rootDir, "README.md", SearchOption.TopDirectoryOnly)
.FirstOrDefault()
?? Directory.GetFiles(rootDir, "readme.md", SearchOption.TopDirectoryOnly)
.FirstOrDefault();
if (readme == null)
{
Console.Error.WriteLine($"ERROR: no README.md found in {rootDir}");
Console.Error.WriteLine(" The status command renders the site's README.md to the console.");
Console.Error.WriteLine(" Specify --root <dir> if the site is not in the current directory.");
Environment.ExitCode = 1;
return;
}
var markdown = File.ReadAllText(readme);
MarkdownConsoleRenderer.Render(markdown, Console.Out);
}