From 4dcd416ff66e4f1d0d7eff5606391bc2f9fc3117 Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 11:06:40 +0200 Subject: [PATCH] WEB-1: static site renderer (SiteRenderer, dry-run endpoint, 22 tests) GerbilManagerWebAPI/Cms/SiteRenderer.cs: - Render(JsonObject snapshot) -> IReadOnlyDictionary (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) --- GerbilManager.Tests/SiteRendererTests.cs | 331 ++++++++++++ GerbilManagerWebAPI/Cms/SiteRenderer.cs | 484 ++++++++++++++++++ GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs | 8 + 3 files changed, 823 insertions(+) create mode 100644 GerbilManager.Tests/SiteRendererTests.cs create mode 100644 GerbilManagerWebAPI/Cms/SiteRenderer.cs diff --git a/GerbilManager.Tests/SiteRendererTests.cs b/GerbilManager.Tests/SiteRendererTests.cs new file mode 100644 index 0000000..1a713d6 --- /dev/null +++ b/GerbilManager.Tests/SiteRendererTests.cs @@ -0,0 +1,331 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Nodes; +using GerbilManagerWebAPI.Cms; + +namespace GerbilManager.Tests; + +/// +/// WEB-1: static site renderer — snapshot → HTML file map. +/// All tests are pure unit tests (no DB, no HTTP); they build JsonObject +/// snapshots directly and assert on rendered HTML. +/// +public class SiteRendererTests +{ + // ── Snapshot builder helpers ───────────────────────────────────────────── + + private static JsonObject MakeSnapshot(params (string slug, string title, JsonArray blocks)[] pages) + { + var navOrder = new JsonArray(); + var pagesArr = new JsonArray(); + foreach (var (slug, title, blocks) in pages) + { + navOrder.Add(slug); + pagesArr.Add(new JsonObject + { + ["slug"] = slug, + ["title"] = title, + ["seoDescription"] = $"SEO für {title}", + ["status"] = "Published", + ["blocks"] = blocks, + }); + } + return new JsonObject + { + ["site"] = new JsonObject { ["defaultLocale"] = "de", ["navOrder"] = navOrder }, + ["pages"] = pagesArr, + }; + } + + private static JsonArray Blocks(params JsonObject[] blocks) + { + var arr = new JsonArray(); + int i = 0; + foreach (var b in blocks) { b["order"] = i++; arr.Add(b); } + return arr; + } + + private static JsonObject HeadingBlock(string text, int level = 1) => new() + { + ["type"] = "Heading", + ["data"] = new JsonObject { ["text"] = text, ["level"] = level }, + }; + + private static JsonObject RichTextBlock(string md) => new() + { + ["type"] = "RichText", + ["data"] = new JsonObject { ["markdown"] = md }, + }; + + private static JsonObject ImageBlock(string url, string alt) => new() + { + ["type"] = "Image", + ["data"] = new JsonObject { ["url"] = url, ["alt"] = alt }, + }; + + private static JsonObject ContactInfoBlock(string name, string email, string phone) => new() + { + ["type"] = "ContactInfo", + ["data"] = new JsonObject { ["name"] = name, ["email"] = email, ["phone"] = phone }, + }; + + private static JsonObject AbgabetiereBlock(string intro, params (string name, string farbe, string? group, string? photo)[] animals) + { + var animalArr = new JsonArray(); + foreach (var (name, farbe, group, photo) in animals) + { + var photos = new JsonArray(); + if (photo is not null) photos.Add(photo); + animalArr.Add(new JsonObject + { + ["name"] = name, + ["farbschlag"] = farbe, + ["group"] = group, + ["photos"] = photos, + ["aiSaleText"] = (JsonNode?)null, + }); + } + return new JsonObject + { + ["type"] = "AbgabetiereList", + ["data"] = new JsonObject { ["mode"] = "auto", ["intro"] = intro, ["animals"] = animalArr }, + }; + } + + // ── Tests ──────────────────────────────────────────────────────────────── + + [Fact] + public void Render_produces_index_html_for_start_page() + { + var snap = MakeSnapshot(("start", "Startseite", Blocks(HeadingBlock("Willkommen")))); + var files = SiteRenderer.Render(snap); + Assert.True(files.ContainsKey("index.html")); + } + + [Fact] + public void Render_produces_slug_subfolder_for_non_start_pages() + { + var snap = MakeSnapshot( + ("start", "Start", Blocks()), + ("abgabetiere", "Abgabetiere", Blocks())); + var files = SiteRenderer.Render(snap); + Assert.True(files.ContainsKey("abgabetiere/index.html")); + } + + [Fact] + public void Render_includes_css_asset() + { + var snap = MakeSnapshot(("start", "Start", Blocks())); + var files = SiteRenderer.Render(snap); + Assert.True(files.ContainsKey(SiteRenderer.CssPath)); + Assert.Contains("site-header", files[SiteRenderer.CssPath]); + } + + [Fact] + public void Page_html_is_valid_doctype_and_contains_title() + { + var snap = MakeSnapshot(("start", "Startseite", Blocks(HeadingBlock("Hallo")))); + var html = SiteRenderer.Render(snap)["index.html"]; + Assert.StartsWith("", html); + Assert.Contains("Startseite |", html); + Assert.Contains("lang=\"de\"", html); + } + + [Fact] + public void Page_html_includes_seo_description() + { + var snap = MakeSnapshot(("start", "Start", Blocks())); + // seoDescription is set to "SEO für Start" by our helper + var html = SiteRenderer.Render(snap)["index.html"]; + Assert.Contains("meta name=\"description\"", html); + Assert.Contains("SEO für Start", html); + } + + [Fact] + public void Nav_links_are_rendered_with_all_slugs() + { + var snap = MakeSnapshot( + ("start", "Start", Blocks()), + ("abgabetiere", "Abgabetiere", Blocks()), + ("kontakt", "Kontakt", Blocks())); + var html = SiteRenderer.Render(snap)["index.html"]; + Assert.Contains("Abgabetiere", html); + Assert.Contains("Kontakt", html); + } + + [Fact] + public void Current_page_nav_link_has_aria_current() + { + var snap = MakeSnapshot( + ("start", "Start", Blocks()), + ("abgabetiere", "Abgabetiere", Blocks())); + var abgHtml = SiteRenderer.Render(snap)["abgabetiere/index.html"]; + Assert.Contains("aria-current=\"page\"", abgHtml); + } + + [Fact] + public void Heading_block_renders_h_tag_with_text() + { + var snap = MakeSnapshot(("start", "Start", Blocks(HeadingBlock("Meine Rennmäuse", 2)))); + var html = SiteRenderer.Render(snap)["index.html"]; + Assert.Contains("<h2", html); + Assert.Contains("Meine Rennmäuse", html); + } + + [Fact] + public void RichText_block_renders_markdown_bold_and_paragraph() + { + var snap = MakeSnapshot(("start", "Start", Blocks(RichTextBlock("**Hallo** Welt")))); + var html = SiteRenderer.Render(snap)["index.html"]; + Assert.Contains("<strong>Hallo</strong>", html); + } + + [Fact] + public void RichText_block_renders_markdown_list() + { + var snap = MakeSnapshot(("start", "Start", Blocks(RichTextBlock("- Eins\n- Zwei")))); + var html = SiteRenderer.Render(snap)["index.html"]; + Assert.Contains("<ul>", html); + Assert.Contains("<li>", html); + Assert.Contains("Eins", html); + } + + [Fact] + public void Image_block_renders_img_with_alt() + { + var snap = MakeSnapshot(("start", "Start", Blocks(ImageBlock("/img/maus.jpg", "Eine Rennmaus")))); + var html = SiteRenderer.Render(snap)["index.html"]; + Assert.Contains("src=\"/img/maus.jpg\"", html); + Assert.Contains("alt=\"Eine Rennmaus\"", html); + } + + [Fact] + public void ContactInfo_block_renders_name_and_email() + { + var snap = MakeSnapshot(("kontakt", "Kontakt", + Blocks(ContactInfoBlock("Kleine Chaoten", "info@kleine-chaoten.de", "+49 123 456789")))); + var html = SiteRenderer.Render(snap)["kontakt/index.html"]; + Assert.Contains("Kleine Chaoten", html); + Assert.Contains("info@kleine-chaoten.de", html); + Assert.Contains("+49 123 456789", html); + } + + [Fact] + public void AbgabetiereList_renders_animal_cards_with_name_and_farbschlag() + { + var snap = MakeSnapshot(("abgabetiere", "Abgabetiere", + Blocks(AbgabetiereBlock("Aktuelle Tiere:", ("Krümel", "CP-Agouti", "Großbecken", "/photos/files/abc.jpg"))))); + var html = SiteRenderer.Render(snap)["abgabetiere/index.html"]; + Assert.Contains("Krümel", html); + Assert.Contains("CP-Agouti", html); + Assert.Contains("Großbecken", html); + Assert.Contains("/photos/files/abc.jpg", html); + Assert.Contains("cms-animal-card", html); + } + + [Fact] + public void AbgabetiereList_empty_shows_placeholder_text() + { + var snap = MakeSnapshot(("abgabetiere", "Abgabetiere", + Blocks(AbgabetiereBlock("")))); + var html = SiteRenderer.Render(snap)["abgabetiere/index.html"]; + Assert.Contains("cms-abgabe-empty", html); + } + + [Fact] + public void Draft_pages_are_excluded_from_output() + { + var pages = new JsonArray + { + new JsonObject { ["slug"] = "start", ["title"] = "Start", ["status"] = "Published", + ["seoDescription"] = (JsonNode?)null, ["blocks"] = new JsonArray() }, + new JsonObject { ["slug"] = "entwurf", ["title"] = "Entwurf", ["status"] = "Draft", + ["seoDescription"] = (JsonNode?)null, ["blocks"] = new JsonArray() }, + }; + var snap = new JsonObject + { + ["site"] = new JsonObject { ["defaultLocale"] = "de", ["navOrder"] = new JsonArray { "start", "entwurf" } }, + ["pages"] = pages, + }; + var files = SiteRenderer.Render(snap); + Assert.True(files.ContainsKey("index.html")); + Assert.False(files.ContainsKey("entwurf/index.html")); + } + + [Fact] + public void Html_encoding_prevents_xss_in_page_title() + { + var snap = MakeSnapshot(("start", "<script>alert(1)</script>", Blocks())); + var html = SiteRenderer.Render(snap)["index.html"]; + Assert.DoesNotContain("<script>", html); + Assert.Contains("<script>", html); + } + + [Fact] + public void Render_six_seeded_pages_produces_expected_paths() + { + var snap = MakeSnapshot( + ("start", "Startseite", Blocks()), + ("ueber-die-zucht", "Über die Zucht", Blocks()), + ("abgabetiere", "Abgabetiere", Blocks(AbgabetiereBlock(""))), + ("abgabebedingungen", "Abgabebedingungen", Blocks()), + ("farben-genetik", "Farben & Genetik", Blocks()), + ("kontakt", "Kontakt", Blocks())); + var files = SiteRenderer.Render(snap); + Assert.True(files.ContainsKey("index.html")); + Assert.True(files.ContainsKey("ueber-die-zucht/index.html")); + Assert.True(files.ContainsKey("abgabetiere/index.html")); + Assert.True(files.ContainsKey("abgabebedingungen/index.html")); + Assert.True(files.ContainsKey("farben-genetik/index.html")); + Assert.True(files.ContainsKey("kontakt/index.html")); + Assert.True(files.ContainsKey(SiteRenderer.CssPath)); + // 6 pages + 1 CSS + Assert.Equal(7, files.Count); + } + + // ── Markdown tests ─────────────────────────────────────────────────────── + + [Theory] + [InlineData("**fett**", "<strong>fett</strong>")] + [InlineData("*kursiv*", "<em>kursiv</em>")] + [InlineData("`code`", "<code>code</code>")] + public void Markdown_inline_renders_correctly(string md, string expectedFragment) + { + var html = SiteRenderer.MarkdownToHtml(md); + Assert.Contains(expectedFragment, html); + } + + [Fact] + public void Markdown_heading_renders_h_tag() + { + var html = SiteRenderer.MarkdownToHtml("## Abschnitt"); + Assert.Contains("<h2>Abschnitt</h2>", html); + } + + [Fact] + public void Markdown_xss_text_is_encoded() + { + var html = SiteRenderer.MarkdownToHtml("<script>alert(1)</script>"); + Assert.DoesNotContain("<script>", html); + } +} + +/// <summary>WEB-1: smoke-test the /api/render-site endpoint via ApiFactory.</summary> +public class RenderSiteEndpointTests : IClassFixture<ApiFactory> +{ + private readonly HttpClient _client; + public RenderSiteEndpointTests(ApiFactory factory) => _client = factory.CreateClient(); + + [Fact] + public async Task RenderSite_returns_file_list_with_css_and_pages() + { + var json = await _client.GetStringAsync("/api/render-site"); + var files = JsonSerializer.Deserialize<List<RenderEntry>>(json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; + Assert.Contains(files, f => f.Path == SiteRenderer.CssPath); + Assert.Contains(files, f => f.Path == "index.html"); + Assert.All(files, f => Assert.True(f.Size > 0)); + } + + private record RenderEntry(string Path, int Size); +} diff --git a/GerbilManagerWebAPI/Cms/SiteRenderer.cs b/GerbilManagerWebAPI/Cms/SiteRenderer.cs new file mode 100644 index 0000000..fef9644 --- /dev/null +++ b/GerbilManagerWebAPI/Cms/SiteRenderer.cs @@ -0,0 +1,484 @@ +// 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 +{ + /// <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} + + + + +
+ {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; + } +} diff --git a/GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs b/GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs index 1eaeb7c..1f00984 100644 --- a/GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs +++ b/GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs @@ -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)