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
7 changed files with 189 additions and 1 deletions
Showing only changes of commit 76f1ad7825 - Show all commits

No files matched your search

+5 -1
View File
@@ -3,4 +3,8 @@ obj/
/packages/ /packages/
riderModule.iml riderModule.iml
/_ReSharper.Caches/ /_ReSharper.Caches/
.idea/ .idea/
# Altbestand der WordPress-Seite (Sichtung/Migration, kann DB-Dumps mit
# personenbezogenen Daten und grosse Binaerdateien enthalten) -- nie ins Repo.
/backup/
@@ -0,0 +1,41 @@
@page "/{Slug}"
@using Elternbeirat.Web.Services
@inject SeitenService SeitenService
@if (_seite is null)
{
<PageTitle>Nicht gefunden</PageTitle>
}
else
{
<PageTitle>@_seite.Titel</PageTitle>
<article>
@((MarkupString)_seite.InhaltHtml)
</article>
}
@code {
[Parameter]
public string Slug { get; set; } = "";
private Seite? _seite;
protected override void OnParametersSet()
{
_seite = SeitenService.Finde(Slug);
// Unbekannter Slug -> 404, damit UseStatusCodePagesWithReExecute die
// /not-found-Seite ausliefert (statt einer leeren 200er-Antwort).
if (_seite is null)
{
var kontext = HttpContext;
if (kontext is not null)
{
kontext.Response.StatusCode = StatusCodes.Status404NotFound;
}
}
}
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
}
@@ -0,0 +1,18 @@
---
titel: Vorstandsteam
---
# Das Vorstandsteam
Der Elternbeirat der IGMH wird von einem ehrenamtlichen Vorstand geleitet.
Er vertritt die Elternschaft gegenüber Schule und Schulträger und koordiniert
die Arbeit der Klassenelternvertreter.
## Aufgaben des Vorstands
- Vertretung der Eltern in der Schulkonferenz
- Zusammenarbeit mit Schulleitung und Kollegium
- Organisation der Vollversammlungen und Sitzungen
- Ansprechpartner für Fragen rund um Mensa, Schließfächer und Förderverein
*Die namentliche Vorstellung der Vorstandsmitglieder folgt.*
+10
View File
@@ -7,4 +7,14 @@
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException> <BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<PackageReference Include="Markdig" Version="0.38.0" />
</ItemGroup>
<!-- Inhalte liegen als Dateien im Image (plan.md AE-3), nicht in einem
Volume. Ins Ausgabeverzeichnis kopieren, damit der Container sie findet. -->
<ItemGroup>
<Content Include="Content\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project> </Project>
+4
View File
@@ -1,4 +1,5 @@
using Elternbeirat.Web.Components; using Elternbeirat.Web.Components;
using Elternbeirat.Web.Services;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -6,6 +7,9 @@ var builder = WebApplication.CreateBuilder(args);
// Add services to the container. // Add services to the container.
builder.Services.AddRazorComponents(); builder.Services.AddRazorComponents();
// Inhalte werden beim Start einmalig eingelesen und gecacht -> Singleton.
builder.Services.AddSingleton<SeitenService>();
var app = builder.Build(); var app = builder.Build();
// NPM terminiert TLS und ist der einzige Weg zum Container (kein Port-Mapping // NPM terminiert TLS und ist der einzige Weg zum Container (kein Port-Mapping
+25
View File
@@ -0,0 +1,25 @@
namespace Elternbeirat.Web.Services;
/// <summary>
/// Eine statische Inhaltsseite, eingelesen aus einer Markdown-Datei in
/// <c>Content/seiten/</c>. Der Dateiname (ohne Endung) ist der Slug.
/// </summary>
public sealed class Seite
{
/// <summary>
/// URL-Kennung der Seite, abgeleitet aus dem Dateinamen ohne Umlaute
/// (z. B. <c>vorstandsteam</c>). Erscheint in der Route <c>/{slug}</c>.
/// </summary>
public required string Slug { get; init; }
/// <summary>
/// Anzeigetitel aus dem YAML-Frontmatter (<c>titel:</c>). Fällt auf den
/// Slug zurück, wenn kein Titel gesetzt ist.
/// </summary>
public required string Titel { get; init; }
/// <summary>
/// Der aus Markdown gerenderte HTML-Rumpf der Seite (ohne Frontmatter).
/// </summary>
public required string InhaltHtml { get; init; }
}
@@ -0,0 +1,86 @@
using Markdig;
using Markdig.Extensions.Yaml;
using Markdig.Syntax;
namespace Elternbeirat.Web.Services;
/// <summary>
/// Liest die statischen Inhaltsseiten aus <c>Content/seiten/*.md</c> ein,
/// rendert sie beim Start einmalig zu HTML und hält sie im Speicher.
/// Als Singleton registriert — Inhalte liegen im Image und ändern sich zur
/// Laufzeit nicht (plan.md AE-3).
/// </summary>
public sealed class SeitenService
{
private readonly IReadOnlyDictionary<string, Seite> _seitenNachSlug;
/// <summary>
/// Liest alle Markdown-Seiten aus dem Verzeichnis <c>Content/seiten</c>
/// unterhalb des Anwendungsstamms ein und cached sie.
/// </summary>
public SeitenService(IWebHostEnvironment umgebung)
{
var pipeline = new MarkdownPipelineBuilder()
.UseYamlFrontMatter()
.Build();
var verzeichnis = Path.Combine(umgebung.ContentRootPath, "Content", "seiten");
var seiten = new Dictionary<string, Seite>(StringComparer.OrdinalIgnoreCase);
if (Directory.Exists(verzeichnis))
{
foreach (var pfad in Directory.EnumerateFiles(verzeichnis, "*.md"))
{
var seite = Lies(pfad, pipeline);
seiten[seite.Slug] = seite;
}
}
_seitenNachSlug = seiten;
}
/// <summary>
/// Liefert die Seite zum angegebenen Slug oder <c>null</c>, wenn es keine
/// gibt. Groß-/Kleinschreibung wird ignoriert.
/// </summary>
public Seite? Finde(string slug) =>
_seitenNachSlug.GetValueOrDefault(slug);
private static Seite Lies(string pfad, MarkdownPipeline pipeline)
{
var slug = Path.GetFileNameWithoutExtension(pfad);
var quelle = File.ReadAllText(pfad);
var dokument = Markdown.Parse(quelle, pipeline);
var titel = LiesTitel(dokument, quelle) ?? slug;
var html = Markdown.ToHtml(quelle, pipeline);
return new Seite { Slug = slug, Titel = titel, InhaltHtml = html };
}
/// <summary>
/// Zieht den <c>titel:</c>-Wert aus dem YAML-Frontmatter. Bewusst simpel
/// gehalten (eine Zeile <c>schlüssel: wert</c>) — für strukturiertere
/// Metadaten kommt später YamlDotNet.
/// </summary>
private static string? LiesTitel(MarkdownDocument dokument, string quelle)
{
var block = dokument.Descendants<YamlFrontMatterBlock>().FirstOrDefault();
if (block is null)
{
return null;
}
var frontmatter = quelle.Substring(block.Span.Start, block.Span.Length);
foreach (var zeile in frontmatter.Split('\n'))
{
var getrimmt = zeile.Trim();
if (getrimmt.StartsWith("titel:", StringComparison.OrdinalIgnoreCase))
{
return getrimmt["titel:".Length..].Trim().Trim('"');
}
}
return null;
}
}