// System.Net.WebUtility intentionally NOT used — its HtmlEncode encodes all non-ASCII // characters as named entities (e.g. ä→ä), which is wrong for UTF-8 pages. using System.Text; using System.Text.Json.Nodes; using System.Text.RegularExpressions; namespace GerbilManagerWebAPI.Cms { /// /// 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. /// public static class SiteRenderer { public const string SiteName = "Kleine Chaoten"; public const string CssPath = "assets/site.css"; // ── Public entry point ─────────────────────────────────────────────── /// /// Renders the snapshot to a flat file map (relative-path → file-content). /// Only Published pages are included. The "start" page maps to /// index.html; all others to {slug}/index.html. /// public static IReadOnlyDictionary Render(JsonObject snapshot) { var files = new Dictionary(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() ?? 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() ?? 2; level = Math.Clamp(level, 1, 6); var text = H(Str(d["text"]) ?? ""); return $"\n{text}\n"; } private static string RenderRichText(JsonObject d) { var md = Str(d["markdown"]) ?? ""; return $"\n
{MarkdownToHtml(md)}
\n"; } private static string RenderImage(JsonObject d) { var url = H(Str(d["url"]) ?? ""); var alt = H(Str(d["alt"]) ?? ""); return $"\n
\"{alt}\"
\n"; } private static string RenderGallery(JsonObject d) { var images = d["images"] as JsonArray ?? []; var sb = new StringBuilder("\n
\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($" \n"); } sb.Append("
\n"); return sb.ToString(); } private static string RenderContactInfo(JsonObject d) { var sb = new StringBuilder("\n
\n"); void Row(string? val, string icon) { if (!string.IsNullOrWhiteSpace(val)) sb.AppendLine($" {icon} {H(val)}"); } Row(Str(d["name"]), "👤"); Row(Str(d["address"]), "📍"); var phone = Str(d["phone"]); if (!string.IsNullOrWhiteSpace(phone)) sb.AppendLine($" 📞 {H(phone)}"); var email = Str(d["email"]); if (!string.IsNullOrWhiteSpace(email)) sb.AppendLine($" ✉️ {H(email)}"); sb.Append("
\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
\n"); if (!string.IsNullOrWhiteSpace(intro)) sb.Append($"

{H(intro)}

\n"); if (animals.Count == 0) { sb.Append("

Zurzeit stehen keine Tiere zur Abgabe bereit.

\n"); } else { sb.Append("
\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("
\n"); // Profile photo (first photo) var firstPhoto = photos.FirstOrDefault()?.GetValue(); if (!string.IsNullOrEmpty(firstPhoto)) sb.Append($" \"{name}\"\n"); sb.Append("
\n"); sb.Append($"

{name}

\n"); if (!string.IsNullOrEmpty(farbe)) sb.Append($"

{farbe}

\n"); if (!string.IsNullOrWhiteSpace(group)) sb.Append($"

Gruppe: {H(group)}

\n"); if (!string.IsNullOrWhiteSpace(saleText)) sb.Append($"

{H(saleText)}

\n"); sb.Append("
\n"); sb.Append("
\n"); } sb.Append("
\n"); } sb.Append("
\n"); return sb.ToString(); } // ── Page template ──────────────────────────────────────────────────── private static string PageHtml( string title, string? seoDesc, string body, IReadOnlyList navSlugs, string currentSlug, string cssHref) { var fullTitle = H($"{title} | {SiteName}"); var metaDesc = seoDesc is null ? "" : $"\n "; var navBase = currentSlug == "start" ? "" : "../"; var navHtml = BuildNav(navSlugs, currentSlug, navBase); return $""" {metaDesc} {fullTitle}
{body}

© {H(SiteName)} — Mongolische Rennmäuse

"""; } private static string BuildNav(IReadOnlyList slugs, string current, string navBase) { var labels = new Dictionary(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($" {H(label)}"); } 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 ? "" : ""); 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($"{InlineHtml(hm.Groups[2].Value)}"); continue; } // Unordered list var ulm = Regex.Match(line, @"^[-*+]\s+(.+)$"); if (ulm.Success) { if (!inList || isOrdered) { CloseList(); sb.AppendLine("
    "); inList = true; isOrdered = false; } sb.AppendLine($"
  • {InlineHtml(ulm.Groups[1].Value)}
  • "); continue; } // Ordered list var olm = Regex.Match(line, @"^\d+\.\s+(.+)$"); if (olm.Success) { if (!inList || !isOrdered) { CloseList(); sb.AppendLine("
      "); inList = true; isOrdered = true; } sb.AppendLine($"
    1. {InlineHtml(olm.Groups[1].Value)}
    2. "); continue; } CloseList(); // Blank line if (string.IsNullOrWhiteSpace(line)) { sb.AppendLine(); continue; } // Horizontal rule if (Regex.IsMatch(line, @"^(-{3,}|\*{3,}|_{3,})$")) { sb.AppendLine("
      "); continue; } // Regular paragraph sb.AppendLine($"

      {InlineHtml(line)}

      "); } 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, @"\*\*(.+?)\*\*", "$1"); // Italic *text* (single, not preceded/followed by *) text = Regex.Replace(text, @"(?$1"); // Inline code `text` text = Regex.Replace(text, @"`(.+?)`", "$1"); // Links [text](url) — note: url is already HTML-encoded by H() text = Regex.Replace(text, @"\[(.+?)\]\((.+?)\)", "$1"); 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 NavSlugs(JsonObject snapshot) { var navArr = snapshot["site"]?["navOrder"] as JsonArray; if (navArr is null) return []; return navArr.Select(n => n?.GetValue()).Where(s => s is not null).ToList()!; } /// /// 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. /// private static string H(string? s) { if (s is null) return ""; return s.Replace("&", "&") .Replace("<", "<") .Replace(">", ">") .Replace("\"", """) .Replace("'", "'"); } /// Extract a string value from a JsonNode; returns null if not a string. private static string? Str(JsonNode? node) => node is JsonValue v && v.TryGetValue(out var s) ? s : null; } }