Switch code, scripts and docs to English; drop plan.md references
This commit is contained in:
1 parent
76f1ad7825
commit
c4c7ca2ee4
15 files changed
+532
-540
No files matched your search
@@ -0,0 +1,37 @@
|
||||
@page "/{Slug}"
|
||||
@using Elternbeirat.Web.Services
|
||||
@inject PageService PageService
|
||||
|
||||
@if (_page is null)
|
||||
{
|
||||
<PageTitle>Nicht gefunden</PageTitle>
|
||||
}
|
||||
else
|
||||
{
|
||||
<PageTitle>@_page.Title</PageTitle>
|
||||
<article>
|
||||
@((MarkupString)_page.ContentHtml)
|
||||
</article>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string Slug { get; set; } = "";
|
||||
|
||||
private Page? _page;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_page = PageService.Find(Slug);
|
||||
|
||||
// Unknown slug -> 404, so UseStatusCodePagesWithReExecute serves the
|
||||
// /not-found page instead of an empty 200 response.
|
||||
if (_page is null && HttpContext is not null)
|
||||
{
|
||||
HttpContext.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
}
|
||||
}
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
@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; }
|
||||
}
|
||||
File renamed without changes.
@@ -11,8 +11,8 @@
|
||||
<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. -->
|
||||
<!-- Content ships as files inside the image, not in a volume. Copy it to
|
||||
the output directory so the container can find it. -->
|
||||
<ItemGroup>
|
||||
<Content Include="Content\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -7,16 +7,16 @@ 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<SeitenService>();
|
||||
// Content is read once at startup and cached -> singleton.
|
||||
builder.Services.AddSingleton<PageService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// NPM terminiert TLS und ist der einzige Weg zum Container (kein Port-Mapping
|
||||
// im Produktivbetrieb, siehe plan.md AE-4). Ohne UseForwardedHeaders sieht die
|
||||
// App jede Anfrage als HTTP und mit der Proxy-IP statt der Client-IP.
|
||||
// KnownNetworks/KnownProxies bewusst geleert, weil ausschliesslich NPM den
|
||||
// Container erreicht.
|
||||
// NPM terminates TLS and is the only way to reach the container (no port
|
||||
// mapping in production). Without UseForwardedHeaders the app sees every request
|
||||
// as HTTP and with the proxy IP instead of the client IP.
|
||||
// KnownNetworks/KnownProxies are deliberately empty because only NPM reaches
|
||||
// the container.
|
||||
app.UseForwardedHeaders(new ForwardedHeadersOptions
|
||||
{
|
||||
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
|
||||
@@ -28,9 +28,8 @@ app.UseForwardedHeaders(new ForwardedHeadersOptions
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
// Kein UseHsts() und kein UseHttpsRedirection(): NPM setzt HSTS und
|
||||
// terminiert TLS. Beides hier wuerde hinter dem Proxy eine
|
||||
// Redirect-Schleife erzeugen (plan.md AE-4).
|
||||
// No UseHsts() and no UseHttpsRedirection(): NPM sets HSTS and terminates
|
||||
// TLS. Both here would create a redirect loop behind the proxy.
|
||||
}
|
||||
|
||||
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace Elternbeirat.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// A static content page, read from a Markdown file in <c>Content/pages/</c>.
|
||||
/// The file name (without extension) is the slug.
|
||||
/// </summary>
|
||||
public sealed class Page
|
||||
{
|
||||
/// <summary>
|
||||
/// URL identifier of the page, taken from the file name without umlauts
|
||||
/// (e.g. <c>vorstandsteam</c>). It appears in the route <c>/{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>
|
||||
/// The page body rendered from Markdown to HTML (front matter excluded).
|
||||
/// </summary>
|
||||
public required string ContentHtml { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using Markdig;
|
||||
using Markdig.Extensions.Yaml;
|
||||
using Markdig.Syntax;
|
||||
|
||||
namespace Elternbeirat.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the static content pages from <c>Content/pages/*.md</c>, renders them
|
||||
/// to HTML once at startup and keeps them in memory. Registered as a singleton
|
||||
/// because the content lives in the image and does not change at runtime.
|
||||
/// </summary>
|
||||
public sealed class PageService
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, Page> _pagesBySlug;
|
||||
|
||||
/// <summary>
|
||||
/// Reads every Markdown page from the <c>Content/pages</c> directory below
|
||||
/// the application root and caches it.
|
||||
/// </summary>
|
||||
public PageService(IWebHostEnvironment environment)
|
||||
{
|
||||
var pipeline = new MarkdownPipelineBuilder()
|
||||
.UseYamlFrontMatter()
|
||||
.Build();
|
||||
|
||||
var directory = Path.Combine(environment.ContentRootPath, "Content", "pages");
|
||||
var pages = new Dictionary<string, Page>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (Directory.Exists(directory))
|
||||
{
|
||||
foreach (var path in Directory.EnumerateFiles(directory, "*.md"))
|
||||
{
|
||||
var page = Read(path, pipeline);
|
||||
pages[page.Slug] = page;
|
||||
}
|
||||
}
|
||||
|
||||
_pagesBySlug = pages;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the page for the given slug, or <c>null</c> if there is none.
|
||||
/// The lookup is case-insensitive.
|
||||
/// </summary>
|
||||
public Page? Find(string slug) =>
|
||||
_pagesBySlug.GetValueOrDefault(slug);
|
||||
|
||||
private static Page Read(string path, MarkdownPipeline pipeline)
|
||||
{
|
||||
var slug = Path.GetFileNameWithoutExtension(path);
|
||||
var source = File.ReadAllText(path);
|
||||
|
||||
var document = Markdown.Parse(source, pipeline);
|
||||
var title = ReadTitle(document, source) ?? slug;
|
||||
var html = Markdown.ToHtml(source, pipeline);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user