// 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 ?? []) .OfType().ToList(); 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"); sb.Append("
\n"); return sb.ToString(); } // Group by Becken name; null group → solo card keyed by animal name var groups = animals .GroupBy(a => Str(a["group"]) ?? ("__solo__" + (Str(a["name"]) ?? ""))) .ToList(); sb.Append("
\n"); foreach (var grp in groups) { var isSolo = grp.Key.StartsWith("__solo__", StringComparison.Ordinal); var groupName = isSolo ? null : grp.Key; var memberList = grp.ToList(); // Determine group gender label var genders = memberList.Select(a => Str(a["gender"]) ?? "unknown").Distinct().ToList(); string genderLabel = genders.Count == 1 ? genders[0] switch { "female" => "Weibchen", "male" => "Männchen", _ => "" } : genders.Any(g => g != "unknown") ? "Gemischt" : ""; sb.Append("
\n"); sb.Append("
\n"); if (!string.IsNullOrEmpty(groupName)) sb.Append($"

{H(groupName)}

\n"); var metaParts = new List(); if (!string.IsNullOrEmpty(genderLabel)) metaParts.Add(H(genderLabel)); metaParts.Add("Verfügbar"); sb.Append($" {string.Join(" · ", metaParts)}\n"); sb.Append("
\n"); // Gallery: first photo of each animal in the group var gallery = memberList .Select(a => ( url: (a["photos"] as JsonArray ?? []).FirstOrDefault()?.GetValue(), name: Str(a["name"]) ?? "")) .Where(x => !string.IsNullOrEmpty(x.url)) .ToList(); if (gallery.Count > 0) { sb.Append("
\n"); foreach (var (url, name) in gallery) sb.Append($" \"{H(name)}\"\n"); sb.Append("
\n"); } // Per-animal detail rows sb.Append("
    \n"); foreach (var a in memberList) { var name = H(Str(a["name"]) ?? ""); var farbe = Str(a["farbschlag"]); var dob = Str(a["dateOfBirth"]); var note = Str(a["characterNote"]); var saleText = Str(a["aiSaleText"]); sb.Append("
  • \n"); sb.Append($" {name}\n"); var details = new List(); if (!string.IsNullOrEmpty(farbe)) details.Add(H(farbe)); if (!string.IsNullOrEmpty(dob) && DateOnly.TryParse(dob, out var dob2)) details.Add($"* {dob2.ToString("MMMM yyyy", System.Globalization.CultureInfo.GetCultureInfo("de-DE"))}"); if (details.Count > 0) sb.Append($" {string.Join(" · ", details)}\n"); var personalityText = note ?? saleText; if (!string.IsNullOrWhiteSpace(personalityText)) sb.Append($" {H(personalityText)}\n"); sb.Append("
  • \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}
"""; } 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", ["impressum"] = "Impressum", ["datenschutz"] = "Datenschutz", }; 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: #fdf7f0; --color-surface: #fff; --color-surface-warm: #fef9f4; --color-text: #2a2118; --color-muted: #7a6d62; --color-accent: #b5331a; --color-accent-hover: #8f2815; --color-accent-soft: #fdf0ed; --color-border: #e8ddd4; --color-badge-free: #2d7a4a; --color-badge-free-bg: #e6f4ec; --font-body: Georgia, "Times New Roman", serif; --font-ui: system-ui, -apple-system, sans-serif; --max-w: 900px; --radius: 10px; } body { font-family: var(--font-body); background: var(--color-bg); color: var(--color-text); line-height: 1.7; font-size: 1rem; } a { color: var(--color-accent); text-decoration: none; } a:hover { text-decoration: underline; color: var(--color-accent-hover); } img { max-width: 100%; height: auto; display: block; } p { margin-bottom: .75rem; } /* ── Header ── */ .site-header { background: var(--color-surface); border-bottom: 2px solid var(--color-border); padding: 0 1rem; display: flex; align-items: stretch; gap: 0; min-height: 56px; } .site-logo { font-family: var(--font-body); font-weight: 700; font-size: 1.15rem; color: var(--color-text); white-space: nowrap; display: flex; align-items: center; padding-right: 1.5rem; border-right: 1px solid var(--color-border); text-decoration: none; flex-shrink: 0; } .site-logo:hover { color: var(--color-accent); text-decoration: none; } .site-nav { display: flex; align-items: center; gap: 0; overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; padding-left: .5rem; } .site-nav::-webkit-scrollbar { display: none; } .site-nav-link { font-family: var(--font-ui); font-size: .875rem; color: var(--color-muted); padding: .5rem .65rem; border-radius: 6px; white-space: nowrap; transition: color .15s, background .15s; } .site-nav-link:hover { color: var(--color-accent); background: var(--color-accent-soft); text-decoration: none; } .site-nav-link[aria-current="page"] { color: var(--color-accent); background: var(--color-accent-soft); font-weight: 600; } /* ── Main ── */ .site-main { max-width: var(--max-w); margin: 2.5rem auto; padding: 0 1rem 4rem; } /* ── Footer ── */ .site-footer { background: var(--color-surface); border-top: 2px solid var(--color-border); padding: 1.75rem 1rem; text-align: center; font-family: var(--font-ui); font-size: .85rem; color: var(--color-muted); } .site-footer-nav { margin-top: .6rem; display: flex; justify-content: center; gap: 1.25rem; } .site-footer-link { color: var(--color-muted); font-size: .8rem; } .site-footer-link:hover { color: var(--color-accent); } /* ── CMS blocks ── */ .cms-heading { margin: 2rem 0 .6rem; line-height: 1.3; } h1.cms-heading { font-size: 2rem; margin-top: 0; } h2.cms-heading { font-size: 1.5rem; } h3.cms-heading { font-size: 1.2rem; } .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: .35rem; } .cms-richtext hr { border: none; border-top: 1px solid var(--color-border); margin: 1.5rem 0; } .cms-image { margin: 1.75rem 0; } .cms-image img { border-radius: var(--radius); box-shadow: 0 2px 8px rgba(0,0,0,.08); } .cms-gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: .75rem; margin: 1.75rem 0; } .cms-gallery-item img { border-radius: 6px; aspect-ratio: 1; object-fit: cover; } .cms-contact { font-style: normal; font-family: var(--font-ui); display: flex; flex-direction: column; gap: .5rem; margin: 1.25rem 0; background: var(--color-surface-warm); border: 1px solid var(--color-border); border-radius: var(--radius); padding: 1.25rem; } .cms-contact-row { display: flex; gap: .6rem; align-items: flex-start; } /* ── Abgabetiere — group cards ── */ .cms-abgabe { margin: 1.25rem 0; } .cms-abgabe-intro { margin-bottom: 1.5rem; font-size: 1.05rem; } .cms-abgabe-empty { color: var(--color-muted); font-style: italic; } .cms-group-list { display: flex; flex-direction: column; gap: 2rem; } .cms-group-card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius); overflow: hidden; box-shadow: 0 2px 6px rgba(0,0,0,.06); } .cms-group-header { padding: 1rem 1.25rem .75rem; display: flex; flex-wrap: wrap; align-items: center; gap: .5rem .75rem; border-bottom: 1px solid var(--color-border); background: var(--color-surface-warm); } .cms-group-name { font-family: var(--font-body); font-size: 1.2rem; font-weight: 700; color: var(--color-text); } .cms-group-meta { font-family: var(--font-ui); font-size: .875rem; color: var(--color-muted); display: flex; align-items: center; gap: .5rem; } /* availability badge */ .cms-badge { display: inline-block; font-size: .75rem; font-weight: 600; font-family: var(--font-ui); padding: .2rem .55rem; border-radius: 20px; letter-spacing: .02em; text-transform: uppercase; } .cms-badge--free { background: var(--color-badge-free-bg); color: var(--color-badge-free); } /* group photo strip */ .cms-group-gallery { display: flex; gap: .375rem; overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; padding: .75rem 1.25rem; background: #f5ede4; } .cms-group-gallery::-webkit-scrollbar { display: none; } .cms-group-photo { width: 140px; height: 140px; object-fit: cover; border-radius: 6px; flex-shrink: 0; } /* animal detail list */ .cms-animal-list { list-style: none; padding: .75rem 1.25rem 1.25rem; display: flex; flex-direction: column; gap: .875rem; } .cms-animal-row { display: flex; flex-direction: column; gap: .15rem; padding-bottom: .875rem; border-bottom: 1px solid var(--color-border); } .cms-animal-row:last-child { border-bottom: none; padding-bottom: 0; } .cms-animal-name { font-family: var(--font-body); font-size: 1.05rem; font-weight: 700; color: var(--color-text); } .cms-animal-detail { font-family: var(--font-ui); font-size: .875rem; color: var(--color-muted); } .cms-animal-note { font-style: italic; font-size: .925rem; color: var(--color-text); margin-top: .1rem; } /* ── Responsive ── */ @media (max-width: 600px) { .site-header { padding: 0 .75rem; min-height: 50px; } .site-logo { font-size: 1rem; padding-right: 1rem; } h1.cms-heading { font-size: 1.5rem; } .site-main { margin-top: 1.5rem; } .cms-group-photo { width: 110px; height: 110px; } } @media (min-width: 700px) { .cms-animal-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem; } .cms-animal-row { border-bottom: none; padding-bottom: 0; border: 1px solid var(--color-border); border-radius: 8px; padding: .75rem; } } """; // ── 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; } }