Build the content pipeline: pages, posts, events, layout #2

Merged
Tom merged 9 commits from feature/content-pipeline into main 2026-09-21 16:33:59 +02:00
10 changed files with 295 additions and 31 deletions
Showing only changes of commit 908a03a1f6 - Show all commits

No files matched your search

@@ -0,0 +1,46 @@
@page "/beitraege/{Slug}"
@using System.Globalization
@using Elternbeirat.Web.Services
@inject PostService PostService
@if (_post is null)
{
<PageTitle>Nicht gefunden</PageTitle>
}
else
{
<PageTitle>@_post.Title</PageTitle>
<article>
<p class="post-meta">
<a href="/beitraege">&larr; Alle Beiträge</a>
<time datetime="@_post.Date.ToString("yyyy-MM-dd")">
@_post.Date.ToString("d. MMMM yyyy", _german)
</time>
</p>
@((MarkupString)_post.ContentHtml)
</article>
}
@code {
private static readonly CultureInfo _german = CultureInfo.GetCultureInfo("de-DE");
[Parameter]
public string Slug { get; set; } = "";
private Post? _post;
protected override void OnParametersSet()
{
_post = PostService.Find(Slug);
// Unknown slug -> 404, so UseStatusCodePagesWithReExecute serves the
// /not-found page instead of an empty 200 response.
if (_post is null && HttpContext is not null)
{
HttpContext.Response.StatusCode = StatusCodes.Status404NotFound;
}
}
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
}
@@ -0,0 +1,31 @@
@page "/beitraege"
@using System.Globalization
@using Elternbeirat.Web.Services
@inject PostService PostService
<PageTitle>Beiträge</PageTitle>
<h1>Beiträge</h1>
@if (PostService.All.Count == 0)
{
<p>Zurzeit gibt es keine Beiträge.</p>
}
else
{
<ul class="post-list">
@foreach (var post in PostService.All)
{
<li>
<a href="@($"/beitraege/{post.Slug}")">@post.Title</a>
<time datetime="@post.Date.ToString("yyyy-MM-dd")">
@post.Date.ToString("d. MMMM yyyy", _german)
</time>
</li>
}
</ul>
}
@code {
private static readonly CultureInfo _german = CultureInfo.GetCultureInfo("de-DE");
}
@@ -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.*
@@ -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).*
+2 -1
View File
@@ -7,8 +7,9 @@ var builder = WebApplication.CreateBuilder(args);
// Add services to the container. // Add services to the container.
builder.Services.AddRazorComponents(); builder.Services.AddRazorComponents();
// Content is read once at startup and cached -> singleton. // Content is read once at startup and cached -> singletons.
builder.Services.AddSingleton<PageService>(); builder.Services.AddSingleton<PageService>();
builder.Services.AddSingleton<PostService>();
var app = builder.Build(); var app = builder.Build();
+42
View File
@@ -0,0 +1,42 @@
using Markdig;
using Markdig.Extensions.Yaml;
using Markdig.Syntax;
namespace Elternbeirat.Web.Services;
/// <summary>
/// Reads simple <c>key: value</c> 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.
/// </summary>
public static class FrontMatter
{
/// <summary>
/// Returns the value of the given front-matter key, or <c>null</c> 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.
/// </summary>
public static string? Read(string key, MarkdownDocument document, string source)
{
var block = document.Descendants<YamlFrontMatterBlock>().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;
}
}
+1 -30
View File
@@ -1,6 +1,4 @@
using Markdig; using Markdig;
using Markdig.Extensions.Yaml;
using Markdig.Syntax;
namespace Elternbeirat.Web.Services; namespace Elternbeirat.Web.Services;
@@ -51,36 +49,9 @@ public sealed class PageService
var source = File.ReadAllText(path); var source = File.ReadAllText(path);
var document = Markdown.Parse(source, pipeline); 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); var html = Markdown.ToHtml(source, pipeline);
return new Page { Slug = slug, Title = title, ContentHtml = html }; return new Page { Slug = slug, Title = title, ContentHtml = html };
} }
/// <summary>
/// Pulls the <c>titel:</c> value out of the YAML front matter. Kept
/// deliberately simple (a single <c>key: value</c> line) — YamlDotNet will
/// take over once we need structured metadata. The key stays German because
/// the Markdown files are edited by German-speaking editors.
/// </summary>
private static string? ReadTitle(MarkdownDocument document, string source)
{
var block = document.Descendants<YamlFrontMatterBlock>().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;
}
} }
+33
View File
@@ -0,0 +1,33 @@
namespace Elternbeirat.Web.Services;
/// <summary>
/// A news post ("Beitrag"), read from a Markdown file in
/// <c>Content/beitraege/</c>. Unlike a <see cref="Page"/>, a post has a date and
/// is shown in a chronological list. The file name (without extension) is the
/// slug.
/// </summary>
public sealed class Post
{
/// <summary>
/// URL identifier of the post, taken from the file name without umlauts.
/// It appears in the route <c>/beitraege/{slug}</c>.
/// </summary>
public required string Slug { get; init; }
/// <summary>
/// Display title from the YAML front matter (<c>titel:</c>). Falls back to
/// the slug when no title is set.
/// </summary>
public required string Title { get; init; }
/// <summary>
/// Publication date from the front matter (<c>datum:</c>). Used to sort the
/// list newest first.
/// </summary>
public required DateOnly Date { get; init; }
/// <summary>
/// The post body rendered from Markdown to HTML (front matter excluded).
/// </summary>
public required string ContentHtml { get; init; }
}
+86
View File
@@ -0,0 +1,86 @@
using System.Globalization;
using Markdig;
namespace Elternbeirat.Web.Services;
/// <summary>
/// Reads the news posts from <c>Content/beitraege/*.md</c>, 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.
/// </summary>
public sealed class PostService
{
private readonly IReadOnlyList<Post> _posts;
private readonly IReadOnlyDictionary<string, Post> _postsBySlug;
/// <summary>
/// Reads every Markdown post from the <c>Content/beitraege</c> directory
/// below the application root and caches it.
/// </summary>
public PostService(IWebHostEnvironment environment)
{
var pipeline = new MarkdownPipelineBuilder()
.UseYamlFrontMatter()
.Build();
var directory = Path.Combine(environment.ContentRootPath, "Content", "beitraege");
var posts = new List<Post>();
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);
}
/// <summary>
/// All posts, newest first. Used for the overview list.
/// </summary>
public IReadOnlyList<Post> All => _posts;
/// <summary>
/// Returns the post for the given slug, or <c>null</c> if there is none.
/// The lookup is case-insensitive.
/// </summary>
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 };
}
/// <summary>
/// Parses the <c>datum:</c> value as an ISO date (yyyy-MM-dd). Falls back to
/// <see cref="DateOnly.MinValue"/> when the value is missing or malformed,
/// so a single bad file does not crash startup — it just sorts last.
/// </summary>
private static DateOnly ParseDate(string? value)
{
return DateOnly.TryParse(value, CultureInfo.InvariantCulture,
DateTimeStyles.None, out var date)
? date
: DateOnly.MinValue;
}
}
+31
View File
@@ -22,6 +22,37 @@ h1:focus {
outline: none; 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]) { .valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050; outline: 1px solid #26b050;
} }