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("Hallo", 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("", html);
+ Assert.Contains("- ", 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", "", Blocks()));
+ var html = SiteRenderer.Render(snap)["index.html"];
+ Assert.DoesNotContain("");
+ Assert.DoesNotContain("