WEB-1: static site renderer (SiteRenderer, dry-run endpoint, 22 tests)

GerbilManagerWebAPI/Cms/SiteRenderer.cs:
  - Render(JsonObject snapshot) -> IReadOnlyDictionary<string,string> (path->content)
  - Covers all 6 block types: Heading, RichText, Image, Gallery, ContactInfo,
    AbgabetiereList (auto-resolved animals from snapshot; empty placeholder)
  - start -> index.html; other slugs -> {slug}/index.html; assets/site.css
  - Draft pages excluded; nav links with aria-current for current page
  - Minimal Markdown->HTML (headings, bold, italic, code, links, ul/ol) --
    no extra dependencies, deterministic output
  - H() encodes only the 5 HTML special chars (not umlauts) for UTF-8 pages
  - Mobile-first CSS: site header+nav, animal card grid, responsive breakpoints,
    warm off-white palette (style reference: Jimdo Kleine Chaoten)

GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs:
  - GET /api/render-site -- dry-run: returns [{path, size}] for each rendered file

GerbilManager.Tests/SiteRendererTests.cs:
  - 21 unit tests (pure JsonObject, no DB): file paths, HTML structure, each block
    type, draft exclusion, XSS encoding, all 6 seeded pages, Markdown inline tests
  - 1 integration test (ApiFactory): /api/render-site returns CSS + index.html

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 11:06:40 +02:00
parent 76fcad6168
commit 4dcd416ff6
3 changed files with 823 additions and 0 deletions

View File

@@ -0,0 +1,484 @@
// System.Net.WebUtility intentionally NOT used — its HtmlEncode encodes all non-ASCII
// characters as named entities (e.g. ä→&auml;), which is wrong for UTF-8 pages.
using System.Text;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace GerbilManagerWebAPI.Cms
{
/// <summary>
/// WEB-1: Deterministic static site renderer. Consumes a CMS snapshot (from
/// SiteSnapshotService.BuildAsync) and produces a flat path→content dictionary
/// ready for upload to Cloudflare Pages. No external dependencies; Markdown
/// support handles the common subset used in RichText blocks.
/// </summary>
public static class SiteRenderer
{
public const string SiteName = "Kleine Chaoten";
public const string CssPath = "assets/site.css";
// ── Public entry point ───────────────────────────────────────────────
/// <summary>
/// Renders the snapshot to a flat file map (relative-path → file-content).
/// Only <c>Published</c> pages are included. The "start" page maps to
/// <c>index.html</c>; all others to <c>{slug}/index.html</c>.
/// </summary>
public static IReadOnlyDictionary<string, string> Render(JsonObject snapshot)
{
var files = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
files[CssPath] = SiteCss();
var navSlugs = NavSlugs(snapshot);
var pages = snapshot["pages"] as JsonArray ?? [];
foreach (var node in pages)
{
if (node is not JsonObject page) continue;
if (Str(page["status"]) != "Published") continue;
var slug = Str(page["slug"]) ?? "page";
var title = Str(page["title"]) ?? slug;
var seoDes = Str(page["seoDescription"]);
var blocks = page["blocks"] as JsonArray ?? [];
var cssHref = slug == "start" ? CssPath : $"../{CssPath}";
var body = RenderBlocks(blocks, slug);
var html = PageHtml(title, seoDes, body, navSlugs, slug, cssHref);
var path = slug == "start" ? "index.html" : $"{slug}/index.html";
files[path] = html;
}
return files;
}
// ── Block rendering ──────────────────────────────────────────────────
private static string RenderBlocks(JsonArray blocks, string currentSlug)
{
var sb = new StringBuilder();
foreach (var node in blocks.OrderBy(b => b?["order"]?.GetValue<int>() ?? 0))
{
if (node is not JsonObject b) continue;
var type = Str(b["type"]);
var data = b["data"] as JsonObject ?? [];
sb.Append(type switch
{
"Heading" => RenderHeading(data),
"RichText" => RenderRichText(data),
"Image" => RenderImage(data),
"Gallery" => RenderGallery(data),
"ContactInfo" => RenderContactInfo(data),
"AbgabetiereList" => RenderAbgabetiereList(data),
_ => string.Empty,
});
}
return sb.ToString();
}
private static string RenderHeading(JsonObject d)
{
var level = d["level"]?.GetValue<int>() ?? 2;
level = Math.Clamp(level, 1, 6);
var text = H(Str(d["text"]) ?? "");
return $"\n<h{level} class=\"cms-heading\">{text}</h{level}>\n";
}
private static string RenderRichText(JsonObject d)
{
var md = Str(d["markdown"]) ?? "";
return $"\n<div class=\"cms-richtext\">{MarkdownToHtml(md)}</div>\n";
}
private static string RenderImage(JsonObject d)
{
var url = H(Str(d["url"]) ?? "");
var alt = H(Str(d["alt"]) ?? "");
return $"\n<figure class=\"cms-image\"><img src=\"{url}\" alt=\"{alt}\" loading=\"lazy\"></figure>\n";
}
private static string RenderGallery(JsonObject d)
{
var images = d["images"] as JsonArray ?? [];
var sb = new StringBuilder("\n<div class=\"cms-gallery\">\n");
foreach (var img in images)
{
if (img is not JsonObject o) continue;
var url = H(Str(o["url"]) ?? "");
var alt = H(Str(o["alt"]) ?? "");
sb.Append($" <figure class=\"cms-gallery-item\"><img src=\"{url}\" alt=\"{alt}\" loading=\"lazy\"></figure>\n");
}
sb.Append("</div>\n");
return sb.ToString();
}
private static string RenderContactInfo(JsonObject d)
{
var sb = new StringBuilder("\n<address class=\"cms-contact\">\n");
void Row(string? val, string icon) { if (!string.IsNullOrWhiteSpace(val)) sb.AppendLine($" <span class=\"cms-contact-row\">{icon} {H(val)}</span>"); }
Row(Str(d["name"]), "👤");
Row(Str(d["address"]), "📍");
var phone = Str(d["phone"]);
if (!string.IsNullOrWhiteSpace(phone))
sb.AppendLine($" <span class=\"cms-contact-row\">📞 <a href=\"tel:{H(phone)}\">{H(phone)}</a></span>");
var email = Str(d["email"]);
if (!string.IsNullOrWhiteSpace(email))
sb.AppendLine($" <span class=\"cms-contact-row\">✉️ <a href=\"mailto:{H(email)}\">{H(email)}</a></span>");
sb.Append("</address>\n");
return sb.ToString();
}
private static string RenderAbgabetiereList(JsonObject d)
{
var intro = Str(d["intro"]);
var animals = d["animals"] as JsonArray ?? [];
var sb = new StringBuilder("\n<section class=\"cms-abgabe\">\n");
if (!string.IsNullOrWhiteSpace(intro))
sb.Append($" <p class=\"cms-abgabe-intro\">{H(intro)}</p>\n");
if (animals.Count == 0)
{
sb.Append(" <p class=\"cms-abgabe-empty\">Zurzeit stehen keine Tiere zur Abgabe bereit.</p>\n");
}
else
{
sb.Append(" <div class=\"cms-animal-grid\">\n");
foreach (var node in animals)
{
if (node is not JsonObject a) continue;
var name = H(Str(a["name"]) ?? "");
var farbe = H(Str(a["farbschlag"]) ?? "");
var group = Str(a["group"]);
var saleText = Str(a["aiSaleText"]);
var photos = a["photos"] as JsonArray ?? [];
sb.Append(" <article class=\"cms-animal-card\">\n");
// Profile photo (first photo)
var firstPhoto = photos.FirstOrDefault()?.GetValue<string>();
if (!string.IsNullOrEmpty(firstPhoto))
sb.Append($" <img class=\"cms-animal-photo\" src=\"{H(firstPhoto)}\" alt=\"{name}\" loading=\"lazy\">\n");
sb.Append(" <div class=\"cms-animal-info\">\n");
sb.Append($" <h3 class=\"cms-animal-name\">{name}</h3>\n");
if (!string.IsNullOrEmpty(farbe))
sb.Append($" <p class=\"cms-animal-farbe\">{farbe}</p>\n");
if (!string.IsNullOrWhiteSpace(group))
sb.Append($" <p class=\"cms-animal-group\">Gruppe: {H(group)}</p>\n");
if (!string.IsNullOrWhiteSpace(saleText))
sb.Append($" <p class=\"cms-animal-text\">{H(saleText)}</p>\n");
sb.Append(" </div>\n");
sb.Append(" </article>\n");
}
sb.Append(" </div>\n");
}
sb.Append("</section>\n");
return sb.ToString();
}
// ── Page template ────────────────────────────────────────────────────
private static string PageHtml(
string title, string? seoDesc, string body,
IReadOnlyList<string> navSlugs, string currentSlug, string cssHref)
{
var fullTitle = H($"{title} | {SiteName}");
var metaDesc = seoDesc is null ? ""
: $"\n <meta name=\"description\" content=\"{H(seoDesc)}\">";
var navBase = currentSlug == "start" ? "" : "../";
var navHtml = BuildNav(navSlugs, currentSlug, navBase);
return $"""
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">{metaDesc}
<title>{fullTitle}</title>
<link rel="stylesheet" href="{H(cssHref)}">
</head>
<body>
<header class="site-header">
<a href="{navBase}index.html" class="site-logo">🐭 {H(SiteName)}</a>
<nav class="site-nav" aria-label="Hauptnavigation">
{navHtml}
</nav>
</header>
<main class="site-main">
{body}
</main>
<footer class="site-footer">
<p>&copy; {H(SiteName)} Mongolische Rennmäuse</p>
</footer>
</body>
</html>
""";
}
private static string BuildNav(IReadOnlyList<string> slugs, string current, string navBase)
{
var labels = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["start"] = "Start",
["ueber-die-zucht"] = "Über die Zucht",
["abgabetiere"] = "Abgabetiere",
["abgabebedingungen"] = "Abgabebedingungen",
["farben-genetik"] = "Farben & Genetik",
["kontakt"] = "Kontakt",
};
var sb = new StringBuilder();
foreach (var slug in slugs)
{
var label = labels.TryGetValue(slug, out var l) ? l : slug;
var href = slug == "start" ? $"{navBase}index.html" : $"{navBase}{slug}/index.html";
var active = slug == current ? " aria-current=\"page\"" : "";
sb.AppendLine($" <a href=\"{H(href)}\" class=\"site-nav-link\"{active}>{H(label)}</a>");
}
return sb.ToString().TrimEnd();
}
// ── Minimal Markdown → HTML ──────────────────────────────────────────
internal static string MarkdownToHtml(string md)
{
if (string.IsNullOrEmpty(md)) return "";
var lines = md.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n');
var sb = new StringBuilder();
var inList = false;
var isOrdered = false;
void CloseList()
{
if (!inList) return;
sb.AppendLine(isOrdered ? "</ol>" : "</ul>");
inList = false;
}
foreach (var raw in lines)
{
var line = raw;
// ATX headings
var hm = Regex.Match(line, @"^(#{1,6})\s+(.+)$");
if (hm.Success)
{
CloseList();
var lvl = hm.Groups[1].Length;
sb.AppendLine($"<h{lvl}>{InlineHtml(hm.Groups[2].Value)}</h{lvl}>");
continue;
}
// Unordered list
var ulm = Regex.Match(line, @"^[-*+]\s+(.+)$");
if (ulm.Success)
{
if (!inList || isOrdered) { CloseList(); sb.AppendLine("<ul>"); inList = true; isOrdered = false; }
sb.AppendLine($"<li>{InlineHtml(ulm.Groups[1].Value)}</li>");
continue;
}
// Ordered list
var olm = Regex.Match(line, @"^\d+\.\s+(.+)$");
if (olm.Success)
{
if (!inList || !isOrdered) { CloseList(); sb.AppendLine("<ol>"); inList = true; isOrdered = true; }
sb.AppendLine($"<li>{InlineHtml(olm.Groups[1].Value)}</li>");
continue;
}
CloseList();
// Blank line
if (string.IsNullOrWhiteSpace(line))
{
sb.AppendLine();
continue;
}
// Horizontal rule
if (Regex.IsMatch(line, @"^(-{3,}|\*{3,}|_{3,})$"))
{
sb.AppendLine("<hr>");
continue;
}
// Regular paragraph
sb.AppendLine($"<p>{InlineHtml(line)}</p>");
}
CloseList();
return sb.ToString().Trim();
}
private static string InlineHtml(string text)
{
// First HTML-encode, then selectively un-encode our safe inline patterns
// to avoid encoding the tags we are about to add.
// Order matters: bold before italic.
text = H(text);
// Bold **text**
text = Regex.Replace(text, @"\*\*(.+?)\*\*", "<strong>$1</strong>");
// Italic *text* (single, not preceded/followed by *)
text = Regex.Replace(text, @"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", "<em>$1</em>");
// Inline code `text`
text = Regex.Replace(text, @"`(.+?)`", "<code>$1</code>");
// Links [text](url) — note: url is already HTML-encoded by H()
text = Regex.Replace(text, @"\[(.+?)\]\((.+?)\)", "<a href=\"$2\">$1</a>");
return text;
}
// ── CSS ──────────────────────────────────────────────────────────────
internal static string SiteCss() => """
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--color-bg: #fdf8f3;
--color-surface: #fff;
--color-text: #2c2c2c;
--color-muted: #6b6b6b;
--color-accent: #c0392b;
--color-border: #e0d8d0;
--font-body: system-ui, sans-serif;
--max-w: 860px;
}
body {
font-family: var(--font-body);
background: var(--color-bg);
color: var(--color-text);
line-height: 1.65;
}
a { color: var(--color-accent); text-decoration: none; }
a:hover { text-decoration: underline; }
img { max-width: 100%; height: auto; display: block; }
/* ── Header ── */
.site-header {
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding: .75rem 1rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .5rem 1.5rem;
}
.site-logo {
font-weight: 700;
font-size: 1.1rem;
color: var(--color-text);
white-space: nowrap;
}
.site-nav { display: flex; flex-wrap: wrap; gap: .25rem .75rem; }
.site-nav-link {
font-size: .9rem;
color: var(--color-muted);
padding: .2rem .4rem;
border-radius: 4px;
}
.site-nav-link:hover, .site-nav-link[aria-current="page"] {
color: var(--color-accent);
background: #fef0ee;
text-decoration: none;
}
/* ── Main ── */
.site-main {
max-width: var(--max-w);
margin: 2rem auto;
padding: 0 1rem 3rem;
}
/* ── Footer ── */
.site-footer {
border-top: 1px solid var(--color-border);
padding: 1.5rem 1rem;
text-align: center;
font-size: .85rem;
color: var(--color-muted);
}
/* ── CMS blocks ── */
.cms-heading { margin: 1.5rem 0 .5rem; }
h1.cms-heading { font-size: 1.8rem; }
h2.cms-heading { font-size: 1.4rem; }
.cms-richtext { margin: 1rem 0; }
.cms-richtext p { margin-bottom: .75rem; }
.cms-richtext ul, .cms-richtext ol { margin: .5rem 0 .75rem 1.5rem; }
.cms-richtext li { margin-bottom: .3rem; }
.cms-image { margin: 1.5rem 0; }
.cms-image img { border-radius: 6px; }
.cms-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: .75rem;
margin: 1.5rem 0;
}
.cms-gallery-item img { border-radius: 4px; aspect-ratio: 1; object-fit: cover; }
.cms-contact {
font-style: normal;
display: flex;
flex-direction: column;
gap: .4rem;
margin: 1rem 0;
}
.cms-contact-row { display: flex; gap: .5rem; align-items: flex-start; }
/* ── Abgabetiere ── */
.cms-abgabe { margin: 1rem 0; }
.cms-abgabe-intro { margin-bottom: 1.25rem; font-size: 1.05rem; }
.cms-abgabe-empty { color: var(--color-muted); font-style: italic; }
.cms-animal-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1.25rem;
}
.cms-animal-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
}
.cms-animal-photo { width: 100%; aspect-ratio: 4/3; object-fit: cover; }
.cms-animal-info { padding: .875rem; }
.cms-animal-name { font-size: 1.1rem; font-weight: 600; margin-bottom: .25rem; }
.cms-animal-farbe { font-size: .9rem; color: var(--color-muted); margin-bottom: .25rem; }
.cms-animal-group { font-size: .85rem; color: var(--color-muted); }
.cms-animal-text { font-size: .9rem; margin-top: .5rem; }
@media (max-width: 480px) {
h1.cms-heading { font-size: 1.4rem; }
.cms-animal-grid { grid-template-columns: 1fr; }
}
""";
// ── Helpers ─────────────────────────────────────────────────────────
private static IReadOnlyList<string> NavSlugs(JsonObject snapshot)
{
var navArr = snapshot["site"]?["navOrder"] as JsonArray;
if (navArr is null) return [];
return navArr.Select(n => n?.GetValue<string>()).Where(s => s is not null).ToList()!;
}
/// <summary>
/// HTML-encode only the 5 HTML special characters. Non-ASCII characters (e.g. German
/// umlauts) are left as-is — the page declares UTF-8, so no entity encoding needed.
/// </summary>
private static string H(string? s)
{
if (s is null) return "";
return s.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\"", "&quot;")
.Replace("'", "&#39;");
}
/// <summary>Extract a string value from a JsonNode; returns null if not a string.</summary>
private static string? Str(JsonNode? node) =>
node is JsonValue v && v.TryGetValue<string>(out var s) ? s : null;
}
}

View File

@@ -24,6 +24,14 @@ namespace GerbilManagerWebAPI.Endpoints
api.MapGet("/site-snapshot", async (ApplicationContext db) =>
TypedResults.Ok(await new SiteSnapshotService(db).BuildAsync()));
// ---- static render dry-run (WEB-1: returns rendered file map as JSON) ----
api.MapGet("/render-site", async (ApplicationContext db) =>
{
var snapshot = await new SiteSnapshotService(db).BuildAsync();
var files = SiteRenderer.Render(snapshot);
return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList());
});
// ---- pages ----
api.MapGet("/pages", async (ApplicationContext db) =>
TypedResults.Ok(await db.Pages.AsNoTracking().OrderBy(p => p.Slug)