Serve pages and home from PocketBase, drop the file-based content layer
This commit is contained in:
1 parent
da87716d97
commit
4136af6417
15 files changed
+117
-285
No files matched your search
@@ -70,9 +70,10 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase)
|
|||||||
|
|
||||||
var pages = await client.GetPagesAsync();
|
var pages = await client.GetPagesAsync();
|
||||||
|
|
||||||
// Three pages seeded, one with public=false; the draft must be filtered out.
|
// The draft (public=false) must be filtered out, the public ones present.
|
||||||
pages.Select(p => p.Slug).ShouldBe(["home", "contact"]);
|
|
||||||
pages.ShouldNotContain(p => p.Slug == "draft");
|
pages.ShouldNotContain(p => p.Slug == "draft");
|
||||||
|
pages.ShouldContain(p => p.Slug == "home");
|
||||||
|
pages.ShouldContain(p => p.Slug == "contact");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
|||||||
await CreateCollectionAsync(http, "pages", new object[]
|
await CreateCollectionAsync(http, "pages", new object[]
|
||||||
{
|
{
|
||||||
new { name = "title", type = "text", required = true },
|
new { name = "title", type = "text", required = true },
|
||||||
new { name = "body", type = "editor" },
|
new { name = "body", type = "text" },
|
||||||
new { name = "location", type = "select", maxSelect = 1, values = new[] { "header", "footer" } },
|
new { name = "location", type = "select", maxSelect = 1, values = new[] { "header", "footer" } },
|
||||||
new { name = "order", type = "number" },
|
new { name = "order", type = "number" },
|
||||||
new { name = "slug", type = "text", required = true },
|
new { name = "slug", type = "text", required = true },
|
||||||
@@ -203,7 +203,7 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
new { name = "date", type = "date" },
|
new { name = "date", type = "date" },
|
||||||
new { name = "title", type = "text", required = true },
|
new { name = "title", type = "text", required = true },
|
||||||
new { name = "body", type = "editor" },
|
new { name = "body", type = "text" },
|
||||||
new { name = "slug", type = "text", required = true },
|
new { name = "slug", type = "text", required = true },
|
||||||
new { name = "public", type = "bool" },
|
new { name = "public", type = "bool" },
|
||||||
});
|
});
|
||||||
@@ -221,7 +221,7 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
|||||||
await CreateCollectionAsync(http, "faqs", new object[]
|
await CreateCollectionAsync(http, "faqs", new object[]
|
||||||
{
|
{
|
||||||
new { name = "question", type = "text", required = true },
|
new { name = "question", type = "text", required = true },
|
||||||
new { name = "answer", type = "editor" },
|
new { name = "answer", type = "text" },
|
||||||
new { name = "topic", type = "select", maxSelect = 1, values = new[] { "mensa", "schliessfach", "elterneuro", "elternarbeit" } },
|
new { name = "topic", type = "select", maxSelect = 1, values = new[] { "mensa", "schliessfach", "elterneuro", "elternarbeit" } },
|
||||||
new { name = "public", type = "bool" },
|
new { name = "public", type = "bool" },
|
||||||
});
|
});
|
||||||
@@ -242,17 +242,54 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
|||||||
|
|
||||||
private static async Task SeedRecordsAsync(HttpClient http)
|
private static async Task SeedRecordsAsync(HttpClient http)
|
||||||
{
|
{
|
||||||
// Pages: one header page, one footer page. Both public.
|
// Pages that the route smoke tests reach through the navigation, the
|
||||||
|
// footer or the FAQ hub. Header pages carry a location+order; the FAQ
|
||||||
|
// sub-pages are plain content pages without a menu slot. All public.
|
||||||
await CreateRecordAsync(http, "pages", new
|
await CreateRecordAsync(http, "pages", new
|
||||||
{
|
{
|
||||||
title = "Startseite", body = "# Willkommen", location = "header",
|
title = "Startseite", body = "# Willkommen", location = "header",
|
||||||
order = 1, slug = "home", embed = new[] { "posts", "events" }, @public = true,
|
order = 1, slug = "home", embed = new[] { "posts", "events" }, @public = true,
|
||||||
});
|
});
|
||||||
await CreateRecordAsync(http, "pages", new
|
await CreateRecordAsync(http, "pages", new
|
||||||
|
{
|
||||||
|
title = "Vorstandsteam", body = "# Vorstand", location = "header",
|
||||||
|
order = 2, slug = "board", embed = Array.Empty<string>(), @public = true,
|
||||||
|
});
|
||||||
|
await CreateRecordAsync(http, "pages", new
|
||||||
|
{
|
||||||
|
title = "Förderverein", body = "# Förderverein", location = "header",
|
||||||
|
order = 3, slug = "patrons", embed = Array.Empty<string>(), @public = true,
|
||||||
|
});
|
||||||
|
await CreateRecordAsync(http, "pages", new
|
||||||
|
{
|
||||||
|
title = "FAQ", body = "# Häufige Fragen", location = "header",
|
||||||
|
order = 4, slug = "faq", embed = Array.Empty<string>(), @public = true,
|
||||||
|
});
|
||||||
|
await CreateRecordAsync(http, "pages", new
|
||||||
|
{
|
||||||
|
title = "Downloads", body = "# Downloads", location = "header",
|
||||||
|
order = 5, slug = "downloads", embed = Array.Empty<string>(), @public = true,
|
||||||
|
});
|
||||||
|
await CreateRecordAsync(http, "pages", new
|
||||||
|
{
|
||||||
|
title = "FAQ Mensa", body = "# Mensa", location = "",
|
||||||
|
order = 0, slug = "faq-mensa", embed = Array.Empty<string>(), @public = true,
|
||||||
|
});
|
||||||
|
await CreateRecordAsync(http, "pages", new
|
||||||
{
|
{
|
||||||
title = "Kontakt", body = "Mail an uns", location = "footer",
|
title = "Kontakt", body = "Mail an uns", location = "footer",
|
||||||
order = 1, slug = "contact", embed = Array.Empty<string>(), @public = true,
|
order = 1, slug = "contact", embed = Array.Empty<string>(), @public = true,
|
||||||
});
|
});
|
||||||
|
await CreateRecordAsync(http, "pages", new
|
||||||
|
{
|
||||||
|
title = "Impressum", body = "# Impressum", location = "footer",
|
||||||
|
order = 2, slug = "imprint", embed = Array.Empty<string>(), @public = true,
|
||||||
|
});
|
||||||
|
await CreateRecordAsync(http, "pages", new
|
||||||
|
{
|
||||||
|
title = "Datenschutz", body = "# Datenschutz", location = "footer",
|
||||||
|
order = 3, slug = "privacy", embed = Array.Empty<string>(), @public = true,
|
||||||
|
});
|
||||||
// A draft page that must never appear (public=false).
|
// A draft page that must never appear (public=false).
|
||||||
await CreateRecordAsync(http, "pages", new
|
await CreateRecordAsync(http, "pages", new
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -46,17 +46,14 @@ public sealed class RouteSmokeTests : IDisposable
|
|||||||
public static TheoryData<string> KnownRoutes =>
|
public static TheoryData<string> KnownRoutes =>
|
||||||
[
|
[
|
||||||
"/",
|
"/",
|
||||||
"/vorstandsteam",
|
"/board",
|
||||||
"/foerderverein",
|
"/patrons",
|
||||||
"/faq",
|
"/faq",
|
||||||
"/faq-mensa",
|
"/faq-mensa",
|
||||||
"/faq-schliessfach",
|
|
||||||
"/faq-elterneuro",
|
|
||||||
"/faq-elternarbeit",
|
|
||||||
"/downloads",
|
"/downloads",
|
||||||
"/kontakt",
|
"/contact",
|
||||||
"/impressum",
|
"/imprint",
|
||||||
"/datenschutz",
|
"/privacy",
|
||||||
"/posts",
|
"/posts",
|
||||||
"/posts/new-board",
|
"/posts/new-board",
|
||||||
"/posts/new-hall",
|
"/posts/new-hall",
|
||||||
|
|||||||
@@ -6,13 +6,13 @@
|
|||||||
<a class="brand" href="/">Elternbeirat IGMH</a>
|
<a class="brand" href="/">Elternbeirat IGMH</a>
|
||||||
<nav class="main-nav">
|
<nav class="main-nav">
|
||||||
<NavLink href="/" Match="NavLinkMatch.All">Start</NavLink>
|
<NavLink href="/" Match="NavLinkMatch.All">Start</NavLink>
|
||||||
<NavLink href="/vorstandsteam">Vorstandsteam</NavLink>
|
<NavLink href="/board">Vorstandsteam</NavLink>
|
||||||
<NavLink href="/foerderverein">Förderverein</NavLink>
|
<NavLink href="/patrons">Förderverein</NavLink>
|
||||||
<NavLink href="/posts">Beiträge</NavLink>
|
<NavLink href="/posts">Beiträge</NavLink>
|
||||||
<NavLink href="/events">Termine</NavLink>
|
<NavLink href="/events">Termine</NavLink>
|
||||||
<NavLink href="/faq">FAQ</NavLink>
|
<NavLink href="/faq">FAQ</NavLink>
|
||||||
<NavLink href="/downloads">Downloads</NavLink>
|
<NavLink href="/downloads">Downloads</NavLink>
|
||||||
<NavLink href="/kontakt">Kontakt</NavLink>
|
<NavLink href="/contact">Kontakt</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -25,9 +25,9 @@
|
|||||||
<div class="footer-inner">
|
<div class="footer-inner">
|
||||||
<span>Elternbeirat der IGMH</span>
|
<span>Elternbeirat der IGMH</span>
|
||||||
<nav class="footer-nav">
|
<nav class="footer-nav">
|
||||||
<a href="/kontakt">Kontakt</a>
|
<a href="/contact">Kontakt</a>
|
||||||
<a href="/impressum">Impressum</a>
|
<a href="/imprint">Impressum</a>
|
||||||
<a href="/datenschutz">Datenschutz</a>
|
<a href="/privacy">Datenschutz</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
@inject PostService PostService
|
|
||||||
|
|
||||||
<PageTitle>Elternbeirat der IGMH</PageTitle>
|
<PageTitle>Elternbeirat der IGMH</PageTitle>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using Elternbeirat.Web.Services;
|
using Elternbeirat.Contracts;
|
||||||
|
using Elternbeirat.PocketBase;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Features.Home;
|
namespace Elternbeirat.Web.Features.Home;
|
||||||
|
|
||||||
@@ -7,11 +9,29 @@ public partial class Home
|
|||||||
{
|
{
|
||||||
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
||||||
|
|
||||||
|
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load posts for the home teaser from PocketBase.")]
|
||||||
|
private static partial void LogLoadFailed(ILogger logger, Exception exception);
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private PocketBaseClient PocketBase { get; set; } = default!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private ILogger<Home> Logger { get; set; } = default!;
|
||||||
|
|
||||||
private IReadOnlyList<Post> _latest = [];
|
private IReadOnlyList<Post> _latest = [];
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
// Show the three most recent posts as a teaser on the home page.
|
try
|
||||||
_latest = [.. PostService.All.Take(3)];
|
{
|
||||||
|
// Show the three most recent posts as a teaser on the home page.
|
||||||
|
var posts = await PocketBase.GetPostsAsync();
|
||||||
|
_latest = [.. posts.Take(3)];
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
// Source unreachable -> show no teaser instead of a 500.
|
||||||
|
LogLoadFailed(Logger, ex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
@page "/{Slug}"
|
@page "/{Slug}"
|
||||||
@inject PageService PageService
|
|
||||||
|
|
||||||
@if (_page is null)
|
@if (_page is null)
|
||||||
{
|
{
|
||||||
@@ -9,6 +8,6 @@ else
|
|||||||
{
|
{
|
||||||
<PageTitle>@_page.Title</PageTitle>
|
<PageTitle>@_page.Title</PageTitle>
|
||||||
<article>
|
<article>
|
||||||
@((MarkupString)_page.ContentHtml)
|
@Body
|
||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
using Elternbeirat.Web.Services;
|
using Elternbeirat.Contracts;
|
||||||
|
using Elternbeirat.PocketBase;
|
||||||
|
using Elternbeirat.Web.Shared;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
@@ -6,23 +8,49 @@ namespace Elternbeirat.Web.Features.Pages;
|
|||||||
|
|
||||||
public partial class ContentPage
|
public partial class ContentPage
|
||||||
{
|
{
|
||||||
|
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not load page '{slug}' from PocketBase.")]
|
||||||
|
private static partial void LogLoadFailed(ILogger logger, string slug, Exception exception);
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public string Slug { get; set; } = "";
|
public string Slug { get; set; } = "";
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private PocketBaseClient PocketBase { get; set; } = default!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private ILogger<ContentPage> Logger { get; set; } = default!;
|
||||||
|
|
||||||
|
[CascadingParameter]
|
||||||
|
private HttpContext? HttpContext { get; set; }
|
||||||
|
|
||||||
private Page? _page;
|
private Page? _page;
|
||||||
|
|
||||||
protected override void OnParametersSet()
|
// The page body rendered from Markdown to HTML, ready to emit as a
|
||||||
{
|
// MarkupString in the markup.
|
||||||
_page = PageService.Find(Slug);
|
private MarkupString Body => (MarkupString)Markdown.ToHtml(_page?.Body);
|
||||||
|
|
||||||
// Unknown slug -> 404, so UseStatusCodePagesWithReExecute serves the
|
protected override async Task OnParametersSetAsync()
|
||||||
// /not-found page instead of an empty 200 response.
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pages = await PocketBase.GetPagesAsync();
|
||||||
|
_page = pages.FirstOrDefault(
|
||||||
|
p => string.Equals(p.Slug, Slug, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
// Source unreachable -> treat as "not found" rather than a 500. The
|
||||||
|
// 404 branch below serves the friendly /not-found page.
|
||||||
|
LogLoadFailed(Logger, Slug, ex);
|
||||||
|
_page = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown slug (or unreachable source) -> 404, so
|
||||||
|
// UseStatusCodePagesWithReExecute serves the /not-found page instead of an
|
||||||
|
// empty 200 response.
|
||||||
if (_page is null && HttpContext is not null)
|
if (_page is null && HttpContext is not null)
|
||||||
{
|
{
|
||||||
HttpContext.Response.StatusCode = StatusCodes.Status404NotFound;
|
HttpContext.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[CascadingParameter]
|
|
||||||
private HttpContext? HttpContext { get; set; }
|
|
||||||
}
|
}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
using Markdig;
|
|
||||||
|
|
||||||
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 = FrontMatter.Read("title", document, source) ?? slug;
|
|
||||||
var html = Markdown.ToHtml(source, pipeline);
|
|
||||||
|
|
||||||
return new Page { Slug = slug, Title = title, ContentHtml = html };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
namespace Elternbeirat.Web.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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>
|
|
||||||
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; }
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
using Markdig;
|
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </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/posts</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", "posts");
|
|
||||||
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("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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ using Elternbeirat.Contracts;
|
|||||||
using Elternbeirat.PocketBase;
|
using Elternbeirat.PocketBase;
|
||||||
using Elternbeirat.Web.Components;
|
using Elternbeirat.Web.Components;
|
||||||
using Elternbeirat.Web.Features.Events;
|
using Elternbeirat.Web.Features.Events;
|
||||||
using Elternbeirat.Web.Services;
|
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -18,10 +17,6 @@ var pocketBaseUrl = builder.Configuration["PocketBase:BaseUrl"]
|
|||||||
builder.Services.AddHttpClient<PocketBaseClient>(client =>
|
builder.Services.AddHttpClient<PocketBaseClient>(client =>
|
||||||
client.BaseAddress = new Uri(pocketBaseUrl));
|
client.BaseAddress = new Uri(pocketBaseUrl));
|
||||||
|
|
||||||
// Content is read once at startup and cached -> singletons.
|
|
||||||
builder.Services.AddSingleton<PageService>();
|
|
||||||
builder.Services.AddSingleton<PostService>();
|
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// NPM terminates TLS and is the only way to reach the container (no port
|
// NPM terminates TLS and is the only way to reach the container (no port
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,5 +7,4 @@
|
|||||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@using Elternbeirat.Web
|
@using Elternbeirat.Web
|
||||||
@using Elternbeirat.Web.Services
|
|
||||||
@using Elternbeirat.Web.Shared
|
@using Elternbeirat.Web.Shared
|
||||||
Reference in new issue
Block a user