Build the content pipeline: pages, posts, events, layout #2
No files matched your search
@@ -38,8 +38,9 @@ vollständige Stand.
|
||||
Port-Mapping hat und nur über NPM erreichbar ist.
|
||||
- **Das chiseled-Runtime-Image hat keine Shell.** `HEALTHCHECK` mit `curl` oder
|
||||
`sh` schlägt dort fehl.
|
||||
- Neue Seite = Markdown in `Content/pages/`. Neuer Termin = Eintrag in
|
||||
`Content/termine.yml`. In beiden Fällen wird **kein** `.razor` angefasst.
|
||||
- Neue Seite = Markdown in `Content/pages/`. Neuer Beitrag = Markdown in
|
||||
`Content/posts/`. Neuer Termin = Eintrag in `Content/events.yml`. In allen
|
||||
Fällen wird **kein** `.razor` angefasst.
|
||||
- Slugs ohne Umlaute (`ueber-uns`, nicht `über-uns`).
|
||||
|
||||
## Regeln
|
||||
@@ -62,7 +63,9 @@ vollständige Stand.
|
||||
eingelesen und gecacht).
|
||||
- Öffentliche Typen und Methoden der `Services` bekommen XML-Doc (auf Englisch,
|
||||
leicht verständlich), Razor-Markup nicht.
|
||||
- **Code auf Englisch** — Typen, Member, Variablen, Kommentare, Skripte und Doku.
|
||||
Ausnahme: UI-Texte und Redakteurs-Inhalte bleiben Deutsch (Markdown-Inhalte,
|
||||
Frontmatter-Schlüssel wie `titel:`, sowie Slugs/Dateinamen unter `Content/`,
|
||||
z. B. `vorstandsteam`).
|
||||
- **Code durchgängig auf Englisch** — Typen, Member, Variablen, Kommentare,
|
||||
Skripte und Doku. Das gilt **auch für Domänenbegriffe**: im Code `Event`, nicht
|
||||
`Termin`; `Post`, nicht `Beitrag`. Deutsch bleibt ausschließlich, was ein
|
||||
Besucher liest oder ein Redakteur pflegt: UI-Texte, Markdown-Inhalte,
|
||||
Frontmatter-Schlüssel wie `titel:` sowie Slugs/Dateinamen unter `Content/`
|
||||
(z. B. `vorstandsteam`, `/termine`).
|
||||
@@ -38,6 +38,8 @@ public sealed class RouteSmokeTests : IClassFixture<WebApplicationFactory<Progra
|
||||
"/beitraege",
|
||||
"/beitraege/neuer-vorstand-gewaehlt",
|
||||
"/beitraege/neue-sporthalle-eroeffnet",
|
||||
"/termine",
|
||||
"/termine.ics",
|
||||
];
|
||||
|
||||
[Theory]
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<NavLink href="/vorstandsteam">Vorstandsteam</NavLink>
|
||||
<NavLink href="/foerderverein">Förderverein</NavLink>
|
||||
<NavLink href="/beitraege">Beiträge</NavLink>
|
||||
<NavLink href="/termine">Termine</NavLink>
|
||||
<NavLink href="/faq">FAQ</NavLink>
|
||||
<NavLink href="/downloads">Downloads</NavLink>
|
||||
<NavLink href="/kontakt">Kontakt</NavLink>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
@page "/termine"
|
||||
@using System.Globalization
|
||||
@using Elternbeirat.Web.Services
|
||||
@inject EventService EventService
|
||||
|
||||
<PageTitle>Termine</PageTitle>
|
||||
|
||||
<h1>Termine</h1>
|
||||
|
||||
<p class="intro">
|
||||
Alle Termine des Elternbeirats auf einen Blick. Sie können den Kalender auch
|
||||
<a href="/termine.ics">abonnieren</a> und in Ihrer Kalender-App automatisch
|
||||
aktuell halten.
|
||||
</p>
|
||||
|
||||
<h2>Kommende Termine</h2>
|
||||
|
||||
@if (EventService.Upcoming.Count == 0)
|
||||
{
|
||||
<p>Zurzeit sind keine Termine geplant.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="event-list">
|
||||
@foreach (var ev in EventService.Upcoming)
|
||||
{
|
||||
<li>
|
||||
<time datetime="@ev.Start.ToString("s")">@Format(ev)</time>
|
||||
<span class="event-title">@ev.Title</span>
|
||||
@if (!string.IsNullOrWhiteSpace(ev.Location))
|
||||
{
|
||||
<span class="event-location">@ev.Location</span>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(ev.Note))
|
||||
{
|
||||
<p class="event-note">@ev.Note</p>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@if (EventService.Past.Count > 0)
|
||||
{
|
||||
<h2>Vergangene Termine</h2>
|
||||
<ul class="event-list event-list-past">
|
||||
@foreach (var ev in EventService.Past)
|
||||
{
|
||||
<li>
|
||||
<time datetime="@ev.Start.ToString("s")">@Format(ev)</time>
|
||||
<span class="event-title">@ev.Title</span>
|
||||
@if (!string.IsNullOrWhiteSpace(ev.Location))
|
||||
{
|
||||
<span class="event-location">@ev.Location</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static readonly CultureInfo _german = CultureInfo.GetCultureInfo("de-DE");
|
||||
|
||||
// Formats the entry's date range for display: a single day, a date with a
|
||||
// time, or a span across days.
|
||||
private static string Format(Event ev)
|
||||
{
|
||||
var start = ev.HasTime
|
||||
? ev.Start.ToString("dddd, d. MMMM yyyy, HH:mm", _german) + " Uhr"
|
||||
: ev.Start.ToString("dddd, d. MMMM yyyy", _german);
|
||||
|
||||
if (ev.End is not { } end)
|
||||
{
|
||||
return start;
|
||||
}
|
||||
|
||||
// Same day: append just the end time. Different days: append the full
|
||||
// end date.
|
||||
if (end.Date == ev.Start.Date)
|
||||
{
|
||||
return ev.HasTime
|
||||
? start + "–" + end.ToString("HH:mm", _german) + " Uhr"
|
||||
: start;
|
||||
}
|
||||
|
||||
var endText = ev.HasTime
|
||||
? end.ToString("d. MMMM yyyy, HH:mm", _german) + " Uhr"
|
||||
: end.ToString("d. MMMM yyyy", _german);
|
||||
return start + " – " + endText;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# Calendar entries of the Elternbeirat.
|
||||
#
|
||||
# One entry per event. Required keys: title, start.
|
||||
# start/end:
|
||||
# date only -> "2026-03-15" (all-day)
|
||||
# date with time -> "2026-03-15 19:30"
|
||||
# location and note are optional.
|
||||
|
||||
- title: Elternbeiratssitzung
|
||||
start: "2026-10-08 19:30"
|
||||
location: Lehrerzimmer
|
||||
note: Themen bitte vorab per E-Mail einreichen.
|
||||
|
||||
- title: Herbstbasar
|
||||
start: "2026-11-14 10:00"
|
||||
end: "2026-11-14 16:00"
|
||||
location: Aula
|
||||
|
||||
- title: Weihnachtsferien
|
||||
start: "2026-12-21"
|
||||
end: "2027-01-06"
|
||||
|
||||
- title: Elternsprechtag
|
||||
start: "2026-09-05 15:00"
|
||||
end: "2026-09-05 18:00"
|
||||
location: Klassenräume
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
titel: Datenschutz
|
||||
title: Datenschutz
|
||||
---
|
||||
|
||||
# Datenschutzerklärung
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
titel: Downloads
|
||||
title: Downloads
|
||||
---
|
||||
|
||||
# Downloads
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
titel: FAQ Elternarbeit
|
||||
title: FAQ Elternarbeit
|
||||
---
|
||||
|
||||
# Elternarbeit
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
titel: FAQ Elterneuro
|
||||
title: FAQ Elterneuro
|
||||
---
|
||||
|
||||
# Elterneuro
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
titel: FAQ Mensa
|
||||
title: FAQ Mensa
|
||||
---
|
||||
|
||||
# Mensa
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
titel: FAQ Schließfach
|
||||
title: FAQ Schließfach
|
||||
---
|
||||
|
||||
# Schließfächer
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
titel: FAQ
|
||||
title: FAQ
|
||||
---
|
||||
|
||||
# Häufige Fragen
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
titel: Förderverein
|
||||
title: Förderverein
|
||||
---
|
||||
|
||||
# Förderverein „Freunde der IGMH"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
titel: Impressum
|
||||
title: Impressum
|
||||
---
|
||||
|
||||
# Impressum
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
titel: Kontakt
|
||||
title: Kontakt
|
||||
---
|
||||
|
||||
# Kontakt
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
titel: Vorstandsteam
|
||||
title: Vorstandsteam
|
||||
---
|
||||
|
||||
# Das Vorstandsteam
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
---
|
||||
titel: Neue Sporthalle feierlich eröffnet
|
||||
datum: 2025-10-05
|
||||
title: Neue Sporthalle feierlich eröffnet
|
||||
date: 2025-10-05
|
||||
---
|
||||
|
||||
# Neue Sporthalle feierlich eröffnet
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
---
|
||||
titel: Neuer Vorstand des Elternbeirats gewählt
|
||||
datum: 2025-02-26
|
||||
title: Neuer Vorstand des Elternbeirats gewählt
|
||||
date: 2025-02-26
|
||||
---
|
||||
|
||||
# Neuer Vorstand des Elternbeirats gewählt
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Markdig" Version="0.38.0" />
|
||||
<PackageReference Include="YamlDotNet" Version="16.2.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Content ships as files inside the image, not in a volume. Copy it to
|
||||
|
||||
@@ -10,6 +10,7 @@ builder.Services.AddRazorComponents();
|
||||
// Content is read once at startup and cached -> singletons.
|
||||
builder.Services.AddSingleton<PageService>();
|
||||
builder.Services.AddSingleton<PostService>();
|
||||
builder.Services.AddSingleton<EventService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -40,4 +41,9 @@ app.UseAntiforgery();
|
||||
app.MapStaticAssets();
|
||||
app.MapRazorComponents<App>();
|
||||
|
||||
// Subscribable calendar feed of all events. A minimal API endpoint rather than
|
||||
// a Razor page because it returns text/calendar, not HTML.
|
||||
app.MapGet("/termine.ics", (EventService events) =>
|
||||
Results.Text(IcsCalendar.Build(events.All), "text/calendar; charset=utf-8"));
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace Elternbeirat.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// A single calendar entry (parents' evening, meeting, deadline). Read from
|
||||
/// <c>Content/events.yml</c> at startup. All display text is German because it
|
||||
/// is visitor-facing content.
|
||||
/// </summary>
|
||||
public sealed class Event
|
||||
{
|
||||
/// <summary>Short title shown in the list and the calendar.</summary>
|
||||
public required string Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the entry starts. A date-only entry (no time) is treated as an
|
||||
/// all-day event; see <see cref="HasTime"/>.
|
||||
/// </summary>
|
||||
public required DateTime Start { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// True when <see cref="Start"/> carries a wall-clock time, false for an
|
||||
/// all-day entry. Drives both the list formatting and the ICS export.
|
||||
/// </summary>
|
||||
public required bool HasTime { get; init; }
|
||||
|
||||
/// <summary>Optional end, for multi-hour or multi-day entries.</summary>
|
||||
public DateTime? End { get; init; }
|
||||
|
||||
/// <summary>Optional location, e.g. "Aula" or "Mensa".</summary>
|
||||
public string? Location { get; init; }
|
||||
|
||||
/// <summary>Optional free-text note shown below the entry.</summary>
|
||||
public string? Note { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using System.Globalization;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace Elternbeirat.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the calendar entries from <c>Content/events.yml</c> 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 EventService
|
||||
{
|
||||
private readonly IReadOnlyList<Event> _events;
|
||||
|
||||
/// <summary>
|
||||
/// Reads and parses <c>Content/events.yml</c> below the application root.
|
||||
/// A missing file yields an empty list rather than an error.
|
||||
/// </summary>
|
||||
public EventService(IWebHostEnvironment environment)
|
||||
{
|
||||
var path = Path.Combine(environment.ContentRootPath, "Content", "events.yml");
|
||||
_events = File.Exists(path)
|
||||
? Parse(File.ReadAllText(path))
|
||||
: [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entries that start today or later, earliest first — the upcoming section.
|
||||
/// "Today" is compared by date, so an entry earlier today still counts as
|
||||
/// upcoming.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Event> Upcoming =>
|
||||
_events
|
||||
.Where(e => DateOnly.FromDateTime(e.Start) >= DateOnly.FromDateTime(DateTime.Today))
|
||||
.OrderBy(e => e.Start)
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Entries that already passed, most recent first — the past section shown
|
||||
/// below the upcoming ones.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Event> Past =>
|
||||
_events
|
||||
.Where(e => DateOnly.FromDateTime(e.Start) < DateOnly.FromDateTime(DateTime.Today))
|
||||
.OrderByDescending(e => e.Start)
|
||||
.ToList();
|
||||
|
||||
/// <summary>All entries in chronological order, for the ICS export.</summary>
|
||||
public IReadOnlyList<Event> All =>
|
||||
_events.OrderBy(e => e.Start).ToList();
|
||||
|
||||
private static List<Event> Parse(string yaml)
|
||||
{
|
||||
// Keys are lowercase single words (title, start, ...), which the camel
|
||||
// case convention maps onto the PascalCase properties.
|
||||
var deserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
.IgnoreUnmatchedProperties()
|
||||
.Build();
|
||||
|
||||
var raw = deserializer.Deserialize<List<EventEntry>?>(yaml) ?? [];
|
||||
return raw
|
||||
.Where(entry => entry.Title is not null && entry.Start is not null)
|
||||
.Select(ToEvent)
|
||||
.OfType<Event>()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static Event? ToEvent(EventEntry entry)
|
||||
{
|
||||
if (!TryParseWhen(entry.Start!, out var start, out var hasTime))
|
||||
{
|
||||
// A malformed date drops the entry rather than crashing startup.
|
||||
return null;
|
||||
}
|
||||
|
||||
DateTime? end = null;
|
||||
if (entry.End is not null && TryParseWhen(entry.End, out var parsedEnd, out _))
|
||||
{
|
||||
end = parsedEnd;
|
||||
}
|
||||
|
||||
return new Event
|
||||
{
|
||||
Title = entry.Title!,
|
||||
Start = start,
|
||||
HasTime = hasTime,
|
||||
End = end,
|
||||
Location = entry.Location,
|
||||
Note = entry.Note,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a value that is either a date (<c>2026-03-15</c>) or a date with a
|
||||
/// time (<c>2026-03-15 19:30</c>). Sets <paramref name="hasTime"/> so the
|
||||
/// caller knows whether to treat the entry as all-day.
|
||||
/// </summary>
|
||||
private static bool TryParseWhen(string value, out DateTime when, out bool hasTime)
|
||||
{
|
||||
var trimmed = value.Trim();
|
||||
|
||||
if (DateOnly.TryParseExact(trimmed, "yyyy-MM-dd",
|
||||
CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateOnly))
|
||||
{
|
||||
when = dateOnly.ToDateTime(TimeOnly.MinValue);
|
||||
hasTime = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
string[] formats = ["yyyy-MM-dd HH:mm", "yyyy-MM-dd'T'HH:mm"];
|
||||
if (DateTime.TryParseExact(trimmed, formats,
|
||||
CultureInfo.InvariantCulture, DateTimeStyles.None, out when))
|
||||
{
|
||||
hasTime = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
when = default;
|
||||
hasTime = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raw shape of one YAML entry, before validation. String-typed on purpose so
|
||||
/// we control date parsing and can distinguish date-only from date-and-time.
|
||||
/// </summary>
|
||||
private sealed class EventEntry
|
||||
{
|
||||
public string? Title { get; set; }
|
||||
public string? Start { get; set; }
|
||||
public string? End { get; set; }
|
||||
public string? Location { get; set; }
|
||||
public string? Note { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Elternbeirat.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Builds an iCalendar (RFC 5545) document from the events so visitors can
|
||||
/// subscribe to the calendar in their own app. Times are written as local
|
||||
/// wall-clock times without a time zone, which is what a school calendar needs:
|
||||
/// "19:30" should read as 19:30 everywhere.
|
||||
/// </summary>
|
||||
public static class IcsCalendar
|
||||
{
|
||||
private const string ProductId = "-//Elternbeirat IGMH//Termine//DE";
|
||||
|
||||
/// <summary>
|
||||
/// Serialises the given entries into a single VCALENDAR document.
|
||||
/// </summary>
|
||||
public static string Build(IReadOnlyList<Event> events)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
AppendLine(sb, "BEGIN:VCALENDAR");
|
||||
AppendLine(sb, "VERSION:2.0");
|
||||
AppendLine(sb, $"PRODID:{ProductId}");
|
||||
AppendLine(sb, "CALSCALE:GREGORIAN");
|
||||
AppendLine(sb, "METHOD:PUBLISH");
|
||||
AppendLine(sb, "X-WR-CALNAME:Elternbeirat IGMH");
|
||||
|
||||
foreach (var ev in events)
|
||||
{
|
||||
AppendEvent(sb, ev);
|
||||
}
|
||||
|
||||
AppendLine(sb, "END:VCALENDAR");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void AppendEvent(StringBuilder sb, Event ev)
|
||||
{
|
||||
AppendLine(sb, "BEGIN:VEVENT");
|
||||
AppendLine(sb, $"UID:{Uid(ev)}");
|
||||
// No live timestamp: a stable DTSTAMP keeps the feed byte-identical
|
||||
// between requests, so caches and clients do not see spurious changes.
|
||||
AppendLine(sb, "DTSTAMP:20000101T000000Z");
|
||||
|
||||
if (ev.HasTime)
|
||||
{
|
||||
AppendLine(sb, $"DTSTART:{Local(ev.Start)}");
|
||||
if (ev.End is { } end)
|
||||
{
|
||||
AppendLine(sb, $"DTEND:{Local(end)}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AppendLine(sb, $"DTSTART;VALUE=DATE:{Date(ev.Start)}");
|
||||
// For an all-day event DTEND is exclusive: add one day so a single
|
||||
// day shows as one day, and a range covers its last day.
|
||||
var end = ev.End ?? ev.Start;
|
||||
AppendLine(sb, $"DTEND;VALUE=DATE:{Date(end.AddDays(1))}");
|
||||
}
|
||||
|
||||
AppendLine(sb, $"SUMMARY:{Escape(ev.Title)}");
|
||||
if (!string.IsNullOrWhiteSpace(ev.Location))
|
||||
{
|
||||
AppendLine(sb, $"LOCATION:{Escape(ev.Location)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(ev.Note))
|
||||
{
|
||||
AppendLine(sb, $"DESCRIPTION:{Escape(ev.Note)}");
|
||||
}
|
||||
|
||||
AppendLine(sb, "END:VEVENT");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A UID that stays the same as long as the entry's title and start do, so a
|
||||
/// re-subscribe updates the event instead of creating a duplicate.
|
||||
/// </summary>
|
||||
private static string Uid(Event ev)
|
||||
{
|
||||
var seed = $"{ev.Title}|{ev.Start:O}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
|
||||
return $"{Convert.ToHexString(hash)[..16].ToLowerInvariant()}@elternbeirat-igmh";
|
||||
}
|
||||
|
||||
private static string Local(DateTime value) =>
|
||||
value.ToString("yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture);
|
||||
|
||||
private static string Date(DateTime value) =>
|
||||
value.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>Escapes the characters that are special in an ICS text value.</summary>
|
||||
private static string Escape(string value) => value
|
||||
.Replace("\\", "\\\\")
|
||||
.Replace(";", "\\;")
|
||||
.Replace(",", "\\,")
|
||||
.Replace("\r\n", "\\n")
|
||||
.Replace("\n", "\\n");
|
||||
|
||||
// ICS lines are terminated with CRLF regardless of platform.
|
||||
private static void AppendLine(StringBuilder sb, string line) =>
|
||||
sb.Append(line).Append("\r\n");
|
||||
}
|
||||
@@ -49,7 +49,7 @@ public sealed class PageService
|
||||
var source = File.ReadAllText(path);
|
||||
|
||||
var document = Markdown.Parse(source, pipeline);
|
||||
var title = FrontMatter.Read("titel", document, source) ?? slug;
|
||||
var title = FrontMatter.Read("title", document, source) ?? slug;
|
||||
var html = Markdown.ToHtml(source, pipeline);
|
||||
|
||||
return new Page { Slug = slug, Title = title, ContentHtml = html };
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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
|
||||
/// A news post, read from a Markdown file in <c>Content/posts/</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>
|
||||
|
||||
@@ -4,7 +4,7 @@ using Markdig;
|
||||
namespace Elternbeirat.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the news posts from <c>Content/beitraege/*.md</c>, renders them to HTML
|
||||
/// Reads the news posts from <c>Content/posts/*.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.
|
||||
@@ -15,7 +15,7 @@ public sealed class PostService
|
||||
private readonly IReadOnlyDictionary<string, Post> _postsBySlug;
|
||||
|
||||
/// <summary>
|
||||
/// Reads every Markdown post from the <c>Content/beitraege</c> directory
|
||||
/// Reads every Markdown post from the <c>Content/posts</c> directory
|
||||
/// below the application root and caches it.
|
||||
/// </summary>
|
||||
public PostService(IWebHostEnvironment environment)
|
||||
@@ -24,7 +24,7 @@ public sealed class PostService
|
||||
.UseYamlFrontMatter()
|
||||
.Build();
|
||||
|
||||
var directory = Path.Combine(environment.ContentRootPath, "Content", "beitraege");
|
||||
var directory = Path.Combine(environment.ContentRootPath, "Content", "posts");
|
||||
var posts = new List<Post>();
|
||||
|
||||
if (Directory.Exists(directory))
|
||||
@@ -64,8 +64,8 @@ public sealed class PostService
|
||||
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 title = FrontMatter.Read("title", document, source) ?? slug;
|
||||
var date = ParseDate(FrontMatter.Read("date", document, source));
|
||||
var html = Markdown.ToHtml(source, pipeline);
|
||||
|
||||
return new Post { Slug = slug, Title = title, Date = date, ContentHtml = html };
|
||||
|
||||
@@ -63,6 +63,50 @@ h1:focus {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Event list on /termine */
|
||||
.event-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.event-list li {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.event-list time {
|
||||
display: block;
|
||||
color: #777;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.event-location::before {
|
||||
content: " · ";
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.event-location {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.event-note {
|
||||
margin: 0.25rem 0 0;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.event-list-past {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.event-list-past .event-title {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.valid.modified:not([type=checkbox]) {
|
||||
outline: 1px solid #26b050;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# Editing content
|
||||
|
||||
How to add or change the visitor-facing content of the site. All content lives
|
||||
as files under `Elternbeirat.Web/Content/` — there is no database and no admin
|
||||
interface. Every change needs a **commit** and a **rebuild** of the image, since
|
||||
the content ships inside the image, not in a volume.
|
||||
|
||||
> **Language convention.** File and folder names, front-matter keys and YAML keys
|
||||
> are English (`posts/`, `title:`, `start:`). The text a visitor reads stays
|
||||
> German — that includes the *value* after a key (`title: Vorstandsteam`) and the
|
||||
> Markdown body. So you write English keys with German values.
|
||||
|
||||
The three content kinds:
|
||||
|
||||
| Kind | Where | New entry = |
|
||||
|---|---|---|
|
||||
| Page | `Content/pages/*.md` | one Markdown file |
|
||||
| Post (news) | `Content/posts/*.md` | one Markdown file |
|
||||
| Event (calendar) | `Content/events.yml` | one entry in the list |
|
||||
|
||||
None of these touch a `.razor` file.
|
||||
|
||||
---
|
||||
|
||||
## Pages
|
||||
|
||||
A page is a standalone Markdown file in `Content/pages/`. The file name (without
|
||||
`.md`) is the slug and therefore the URL: `vorstandsteam.md` is served at
|
||||
`/vorstandsteam`.
|
||||
|
||||
Front matter at the top sets the title; the body is the content:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Vorstandsteam
|
||||
---
|
||||
|
||||
# Vorstandsteam
|
||||
|
||||
Hier stellt sich das Team vor …
|
||||
```
|
||||
|
||||
- **Slugs have no umlauts.** Use `ueber-uns`, not `über-uns`; `foerderverein`,
|
||||
not `förderverein`. The visible heading in the body may of course use umlauts.
|
||||
- If you omit `title:`, the slug is used as the title — so always set it.
|
||||
- Add the new page to the navigation only if it should appear there: edit
|
||||
`Components/Layout/MainLayout.razor`. Pages that are only linked from other
|
||||
pages (like the FAQ sub-pages) do not need a nav entry.
|
||||
|
||||
---
|
||||
|
||||
## Posts (news)
|
||||
|
||||
A post is a Markdown file in `Content/posts/`, same idea as a page but with a
|
||||
date. Posts show up in the news list at `/beitraege`, newest first, and each has
|
||||
its own URL at `/beitraege/<slug>`.
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Neue Sporthalle feierlich eröffnet
|
||||
date: 2025-10-05
|
||||
---
|
||||
|
||||
# Neue Sporthalle feierlich eröffnet
|
||||
|
||||
Der Text des Beitrags …
|
||||
```
|
||||
|
||||
- `date:` is an ISO date, **`yyyy-MM-dd`**. It drives the sort order (newest
|
||||
first) and the displayed date. A missing or malformed date sorts the post last
|
||||
rather than breaking the build.
|
||||
- The three most recent posts are also teased on the home page automatically —
|
||||
nothing to do there.
|
||||
|
||||
---
|
||||
|
||||
## Events (calendar)
|
||||
|
||||
Events are **not** separate files. They all live in the single list
|
||||
`Content/events.yml`. Add an entry to the list:
|
||||
|
||||
```yaml
|
||||
- title: Elternbeiratssitzung
|
||||
start: "2026-10-08 19:30"
|
||||
location: Lehrerzimmer
|
||||
note: Themen bitte vorab per E-Mail einreichen.
|
||||
```
|
||||
|
||||
Keys:
|
||||
|
||||
| Key | Required | Meaning |
|
||||
|---|---|---|
|
||||
| `title` | yes | Short name shown in the list and calendar |
|
||||
| `start` | yes | When it starts — see date formats below |
|
||||
| `end` | no | End, for entries that span hours or days |
|
||||
| `location` | no | e.g. `Aula`, `Mensa` |
|
||||
| `note` | no | Free text shown below the entry |
|
||||
|
||||
**Date formats** for `start` and `end`:
|
||||
|
||||
- Date only → all-day event: `"2026-03-15"`
|
||||
- Date with time: `"2026-03-15 19:30"` (24-hour clock)
|
||||
|
||||
Always keep the value in quotes so YAML treats it as text.
|
||||
|
||||
The overview at `/termine` splits the list automatically: entries today or later
|
||||
appear under *Kommende Termine* (earliest first), past ones under *Vergangene
|
||||
Termine* (most recent first). You do not sort the file yourself — order in the
|
||||
YAML does not matter.
|
||||
|
||||
Visitors can subscribe to `/termine.ics` in their own calendar app; that feed is
|
||||
generated from the same file, so a new entry appears there too.
|
||||
|
||||
> A malformed `start` drops just that one entry instead of breaking the whole
|
||||
> page, so a typo in one event will not take the calendar down — but the entry
|
||||
> silently disappears. If an event does not show up, check its `start` value.
|
||||
|
||||
---
|
||||
|
||||
## Publishing a change
|
||||
|
||||
A content change is not live until the image is rebuilt and redeployed:
|
||||
|
||||
1. Edit or add the file under `Content/`.
|
||||
2. Check it locally with `dotnet run --project Elternbeirat.Web`. Content is read
|
||||
once at startup, so after editing a file **restart the process** to see the
|
||||
change (or use `dotnet watch` to restart on save automatically).
|
||||
3. Commit the change.
|
||||
4. Rebuild and redeploy the image — see [deployment](deployment.md).
|
||||
Reference in new issue
Block a user