+}
+
+@code {
+ private static readonly CultureInfo _german = CultureInfo.GetCultureInfo("de-DE");
+}
diff --git a/Elternbeirat.Web/Content/beitraege/neue-sporthalle-eroeffnet.md b/Elternbeirat.Web/Content/beitraege/neue-sporthalle-eroeffnet.md
new file mode 100644
index 0000000..1bfe16d
--- /dev/null
+++ b/Elternbeirat.Web/Content/beitraege/neue-sporthalle-eroeffnet.md
@@ -0,0 +1,11 @@
+---
+titel: Neue Sporthalle feierlich eröffnet
+datum: 2025-10-05
+---
+
+# Neue Sporthalle feierlich eröffnet
+
+Die neue Sporthalle der IGMH ist eröffnet. Schülerinnen, Schüler und Lehrkräfte
+haben damit deutlich mehr Platz für Sportunterricht und Arbeitsgemeinschaften.
+
+*TODO: Bericht und Fotos ergänzen.*
diff --git a/Elternbeirat.Web/Content/beitraege/neuer-vorstand-gewaehlt.md b/Elternbeirat.Web/Content/beitraege/neuer-vorstand-gewaehlt.md
new file mode 100644
index 0000000..04948ec
--- /dev/null
+++ b/Elternbeirat.Web/Content/beitraege/neuer-vorstand-gewaehlt.md
@@ -0,0 +1,12 @@
+---
+titel: Neuer Vorstand des Elternbeirats gewählt
+datum: 2025-02-26
+---
+
+# Neuer Vorstand des Elternbeirats gewählt
+
+Bei der Vollversammlung hat der Elternbeirat der IGMH einen neuen Vorstand
+gewählt. Das Team bedankt sich für das entgegengebrachte Vertrauen und freut
+sich auf die gemeinsame Arbeit im neuen Schuljahr.
+
+*TODO: Namen und Ämter des neuen Vorstands ergänzen (nach Freigabe).*
diff --git a/Elternbeirat.Web/Program.cs b/Elternbeirat.Web/Program.cs
index 87aebe5..0eb725c 100644
--- a/Elternbeirat.Web/Program.cs
+++ b/Elternbeirat.Web/Program.cs
@@ -7,8 +7,9 @@ var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorComponents();
-// Content is read once at startup and cached -> singleton.
+// Content is read once at startup and cached -> singletons.
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
var app = builder.Build();
diff --git a/Elternbeirat.Web/Services/FrontMatter.cs b/Elternbeirat.Web/Services/FrontMatter.cs
new file mode 100644
index 0000000..950ddab
--- /dev/null
+++ b/Elternbeirat.Web/Services/FrontMatter.cs
@@ -0,0 +1,42 @@
+using Markdig;
+using Markdig.Extensions.Yaml;
+using Markdig.Syntax;
+
+namespace Elternbeirat.Web.Services;
+
+///
+/// Reads simple key: value pairs from a Markdown file's YAML front
+/// matter. Kept deliberately minimal (one value per line, no nesting) — this is
+/// enough for titles and dates. YamlDotNet will take over once we need
+/// structured metadata. Front-matter keys stay German because the Markdown
+/// files are edited by German-speaking editors.
+///
+public static class FrontMatter
+{
+ ///
+ /// Returns the value of the given front-matter key, or null if the
+ /// file has no front matter or the key is missing. The key match is
+ /// case-insensitive; surrounding quotes on the value are removed.
+ ///
+ public static string? Read(string key, MarkdownDocument document, string source)
+ {
+ var block = document.Descendants().FirstOrDefault();
+ if (block is null)
+ {
+ return null;
+ }
+
+ var prefix = key + ":";
+ var frontMatter = source.Substring(block.Span.Start, block.Span.Length);
+ foreach (var line in frontMatter.Split('\n'))
+ {
+ var trimmed = line.Trim();
+ if (trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ {
+ return trimmed[prefix.Length..].Trim().Trim('"');
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/Elternbeirat.Web/Services/PageService.cs b/Elternbeirat.Web/Services/PageService.cs
index 8183c9d..a1cc490 100644
--- a/Elternbeirat.Web/Services/PageService.cs
+++ b/Elternbeirat.Web/Services/PageService.cs
@@ -1,6 +1,4 @@
using Markdig;
-using Markdig.Extensions.Yaml;
-using Markdig.Syntax;
namespace Elternbeirat.Web.Services;
@@ -51,36 +49,9 @@ public sealed class PageService
var source = File.ReadAllText(path);
var document = Markdown.Parse(source, pipeline);
- var title = ReadTitle(document, source) ?? slug;
+ var title = FrontMatter.Read("titel", document, source) ?? slug;
var html = Markdown.ToHtml(source, pipeline);
return new Page { Slug = slug, Title = title, ContentHtml = html };
}
-
- ///
- /// Pulls the titel: value out of the YAML front matter. Kept
- /// deliberately simple (a single key: value line) — YamlDotNet will
- /// take over once we need structured metadata. The key stays German because
- /// the Markdown files are edited by German-speaking editors.
- ///
- private static string? ReadTitle(MarkdownDocument document, string source)
- {
- var block = document.Descendants().FirstOrDefault();
- if (block is null)
- {
- return null;
- }
-
- var frontMatter = source.Substring(block.Span.Start, block.Span.Length);
- foreach (var line in frontMatter.Split('\n'))
- {
- var trimmed = line.Trim();
- if (trimmed.StartsWith("titel:", StringComparison.OrdinalIgnoreCase))
- {
- return trimmed["titel:".Length..].Trim().Trim('"');
- }
- }
-
- return null;
- }
}
diff --git a/Elternbeirat.Web/Services/Post.cs b/Elternbeirat.Web/Services/Post.cs
new file mode 100644
index 0000000..9c36a06
--- /dev/null
+++ b/Elternbeirat.Web/Services/Post.cs
@@ -0,0 +1,33 @@
+namespace Elternbeirat.Web.Services;
+
+///
+/// A news post ("Beitrag"), read from a Markdown file in
+/// Content/beitraege/. Unlike a , a post has a date and
+/// is shown in a chronological list. The file name (without extension) is the
+/// slug.
+///
+public sealed class Post
+{
+ ///
+ /// URL identifier of the post, taken from the file name without umlauts.
+ /// It appears in the route /beitraege/{slug}.
+ ///
+ public required string Slug { get; init; }
+
+ ///
+ /// Display title from the YAML front matter (titel:). Falls back to
+ /// the slug when no title is set.
+ ///
+ public required string Title { get; init; }
+
+ ///
+ /// Publication date from the front matter (datum:). Used to sort the
+ /// list newest first.
+ ///
+ public required DateOnly Date { get; init; }
+
+ ///
+ /// The post body rendered from Markdown to HTML (front matter excluded).
+ ///
+ public required string ContentHtml { get; init; }
+}
diff --git a/Elternbeirat.Web/Services/PostService.cs b/Elternbeirat.Web/Services/PostService.cs
new file mode 100644
index 0000000..3aa631e
--- /dev/null
+++ b/Elternbeirat.Web/Services/PostService.cs
@@ -0,0 +1,86 @@
+using System.Globalization;
+using Markdig;
+
+namespace Elternbeirat.Web.Services;
+
+///
+/// Reads the news posts from Content/beitraege/*.md, renders them to HTML
+/// once at startup and keeps them in memory, sorted newest first. Registered as
+/// a singleton because the content lives in the image and does not change at
+/// runtime.
+///
+public sealed class PostService
+{
+ private readonly IReadOnlyList _posts;
+ private readonly IReadOnlyDictionary _postsBySlug;
+
+ ///
+ /// Reads every Markdown post from the Content/beitraege directory
+ /// below the application root and caches it.
+ ///
+ public PostService(IWebHostEnvironment environment)
+ {
+ var pipeline = new MarkdownPipelineBuilder()
+ .UseYamlFrontMatter()
+ .Build();
+
+ var directory = Path.Combine(environment.ContentRootPath, "Content", "beitraege");
+ var posts = new List();
+
+ if (Directory.Exists(directory))
+ {
+ foreach (var path in Directory.EnumerateFiles(directory, "*.md"))
+ {
+ posts.Add(Read(path, pipeline));
+ }
+ }
+
+ // Newest first for the list; a stable slug order breaks date ties.
+ posts.Sort((a, b) =>
+ {
+ var byDate = b.Date.CompareTo(a.Date);
+ return byDate != 0 ? byDate : string.CompareOrdinal(a.Slug, b.Slug);
+ });
+
+ _posts = posts;
+ _postsBySlug = posts.ToDictionary(p => p.Slug, StringComparer.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// All posts, newest first. Used for the overview list.
+ ///
+ public IReadOnlyList All => _posts;
+
+ ///
+ /// Returns the post for the given slug, or null if there is none.
+ /// The lookup is case-insensitive.
+ ///
+ public Post? Find(string slug) =>
+ _postsBySlug.GetValueOrDefault(slug);
+
+ private static Post Read(string path, MarkdownPipeline pipeline)
+ {
+ var slug = Path.GetFileNameWithoutExtension(path);
+ var source = File.ReadAllText(path);
+
+ var document = Markdown.Parse(source, pipeline);
+ var title = FrontMatter.Read("titel", document, source) ?? slug;
+ var date = ParseDate(FrontMatter.Read("datum", document, source));
+ var html = Markdown.ToHtml(source, pipeline);
+
+ return new Post { Slug = slug, Title = title, Date = date, ContentHtml = html };
+ }
+
+ ///
+ /// Parses the datum: value as an ISO date (yyyy-MM-dd). Falls back to
+ /// when the value is missing or malformed,
+ /// so a single bad file does not crash startup — it just sorts last.
+ ///
+ private static DateOnly ParseDate(string? value)
+ {
+ return DateOnly.TryParse(value, CultureInfo.InvariantCulture,
+ DateTimeStyles.None, out var date)
+ ? date
+ : DateOnly.MinValue;
+ }
+}
diff --git a/Elternbeirat.Web/wwwroot/app.css b/Elternbeirat.Web/wwwroot/app.css
index 4a2497d..0a3e15d 100644
--- a/Elternbeirat.Web/wwwroot/app.css
+++ b/Elternbeirat.Web/wwwroot/app.css
@@ -22,6 +22,37 @@ h1:focus {
outline: none;
}
+/* News list on /beitraege */
+.post-list {
+ list-style: none;
+ padding: 0;
+}
+
+.post-list li {
+ padding: 0.75rem 0;
+ border-bottom: 1px solid #eee;
+}
+
+.post-list a {
+ font-size: 1.1rem;
+ text-decoration: none;
+}
+
+.post-list time,
+.post-meta time {
+ display: block;
+ color: #777;
+ font-size: 0.85rem;
+}
+
+.post-meta {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 1rem;
+ margin-bottom: 1rem;
+}
+
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}