From 76f1ad7825ca87c536e5af9117274d53d4848e3b Mon Sep 17 00:00:00 2001 From: tleininger Date: Mon, 21 Sep 2026 15:08:04 +0200 Subject: [PATCH] Add Markdown content pipeline for static pages --- .gitignore | 6 +- .../Components/Pages/Inhaltsseite.razor | 41 +++++++++ .../Content/seiten/vorstandsteam.md | 18 ++++ Elternbeirat.Web/Elternbeirat.Web.csproj | 10 +++ Elternbeirat.Web/Program.cs | 4 + Elternbeirat.Web/Services/Seite.cs | 25 ++++++ Elternbeirat.Web/Services/SeitenService.cs | 86 +++++++++++++++++++ 7 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 Elternbeirat.Web/Components/Pages/Inhaltsseite.razor create mode 100644 Elternbeirat.Web/Content/seiten/vorstandsteam.md create mode 100644 Elternbeirat.Web/Services/Seite.cs create mode 100644 Elternbeirat.Web/Services/SeitenService.cs diff --git a/.gitignore b/.gitignore index f400df3..fcf70a4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,8 @@ obj/ /packages/ riderModule.iml /_ReSharper.Caches/ -.idea/ \ No newline at end of file +.idea/ + +# Altbestand der WordPress-Seite (Sichtung/Migration, kann DB-Dumps mit +# personenbezogenen Daten und grosse Binaerdateien enthalten) -- nie ins Repo. +/backup/ \ No newline at end of file diff --git a/Elternbeirat.Web/Components/Pages/Inhaltsseite.razor b/Elternbeirat.Web/Components/Pages/Inhaltsseite.razor new file mode 100644 index 0000000..e522ce2 --- /dev/null +++ b/Elternbeirat.Web/Components/Pages/Inhaltsseite.razor @@ -0,0 +1,41 @@ +@page "/{Slug}" +@using Elternbeirat.Web.Services +@inject SeitenService SeitenService + +@if (_seite is null) +{ + Nicht gefunden +} +else +{ + @_seite.Titel +
+ @((MarkupString)_seite.InhaltHtml) +
+} + +@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; } +} diff --git a/Elternbeirat.Web/Content/seiten/vorstandsteam.md b/Elternbeirat.Web/Content/seiten/vorstandsteam.md new file mode 100644 index 0000000..000d88b --- /dev/null +++ b/Elternbeirat.Web/Content/seiten/vorstandsteam.md @@ -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.* diff --git a/Elternbeirat.Web/Elternbeirat.Web.csproj b/Elternbeirat.Web/Elternbeirat.Web.csproj index fca27e2..85fec05 100644 --- a/Elternbeirat.Web/Elternbeirat.Web.csproj +++ b/Elternbeirat.Web/Elternbeirat.Web.csproj @@ -7,4 +7,14 @@ true + + + + + + + + + diff --git a/Elternbeirat.Web/Program.cs b/Elternbeirat.Web/Program.cs index 9bdcf2f..86815da 100644 --- a/Elternbeirat.Web/Program.cs +++ b/Elternbeirat.Web/Program.cs @@ -1,4 +1,5 @@ using Elternbeirat.Web.Components; +using Elternbeirat.Web.Services; using Microsoft.AspNetCore.HttpOverrides; var builder = WebApplication.CreateBuilder(args); @@ -6,6 +7,9 @@ var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddRazorComponents(); +// Inhalte werden beim Start einmalig eingelesen und gecacht -> Singleton. +builder.Services.AddSingleton(); + var app = builder.Build(); // NPM terminiert TLS und ist der einzige Weg zum Container (kein Port-Mapping diff --git a/Elternbeirat.Web/Services/Seite.cs b/Elternbeirat.Web/Services/Seite.cs new file mode 100644 index 0000000..426ee4a --- /dev/null +++ b/Elternbeirat.Web/Services/Seite.cs @@ -0,0 +1,25 @@ +namespace Elternbeirat.Web.Services; + +/// +/// Eine statische Inhaltsseite, eingelesen aus einer Markdown-Datei in +/// Content/seiten/. Der Dateiname (ohne Endung) ist der Slug. +/// +public sealed class Seite +{ + /// + /// URL-Kennung der Seite, abgeleitet aus dem Dateinamen ohne Umlaute + /// (z. B. vorstandsteam). Erscheint in der Route /{slug}. + /// + public required string Slug { get; init; } + + /// + /// Anzeigetitel aus dem YAML-Frontmatter (titel:). Fällt auf den + /// Slug zurück, wenn kein Titel gesetzt ist. + /// + public required string Titel { get; init; } + + /// + /// Der aus Markdown gerenderte HTML-Rumpf der Seite (ohne Frontmatter). + /// + public required string InhaltHtml { get; init; } +} diff --git a/Elternbeirat.Web/Services/SeitenService.cs b/Elternbeirat.Web/Services/SeitenService.cs new file mode 100644 index 0000000..957225e --- /dev/null +++ b/Elternbeirat.Web/Services/SeitenService.cs @@ -0,0 +1,86 @@ +using Markdig; +using Markdig.Extensions.Yaml; +using Markdig.Syntax; + +namespace Elternbeirat.Web.Services; + +/// +/// Liest die statischen Inhaltsseiten aus Content/seiten/*.md 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). +/// +public sealed class SeitenService +{ + private readonly IReadOnlyDictionary _seitenNachSlug; + + /// + /// Liest alle Markdown-Seiten aus dem Verzeichnis Content/seiten + /// unterhalb des Anwendungsstamms ein und cached sie. + /// + public SeitenService(IWebHostEnvironment umgebung) + { + var pipeline = new MarkdownPipelineBuilder() + .UseYamlFrontMatter() + .Build(); + + var verzeichnis = Path.Combine(umgebung.ContentRootPath, "Content", "seiten"); + var seiten = new Dictionary(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; + } + + /// + /// Liefert die Seite zum angegebenen Slug oder null, wenn es keine + /// gibt. Groß-/Kleinschreibung wird ignoriert. + /// + 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 }; + } + + /// + /// Zieht den titel:-Wert aus dem YAML-Frontmatter. Bewusst simpel + /// gehalten (eine Zeile schlüssel: wert) — für strukturiertere + /// Metadaten kommt später YamlDotNet. + /// + private static string? LiesTitel(MarkdownDocument dokument, string quelle) + { + var block = dokument.Descendants().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; + } +}