using System.Globalization; using Markdig; namespace Elternbeirat.Web.Services; /// /// Reads the news posts from Content/posts/*.md, 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. /// public sealed class PostService { private readonly IReadOnlyList _posts; private readonly IReadOnlyDictionary _postsBySlug; /// /// Reads every Markdown post from the Content/posts directory /// below the application root and caches it. /// public PostService(IWebHostEnvironment environment) { var pipeline = new MarkdownPipelineBuilder() .UseYamlFrontMatter() .Build(); var directory = Path.Combine(environment.ContentRootPath, "Content", "posts"); var posts = new List(); 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); } /// /// All posts, newest first. Used for the overview list. /// public IReadOnlyList All => _posts; /// /// Returns the post for the given slug, or null if there is none. /// The lookup is case-insensitive. /// 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 }; } /// /// Parses the datum: value as an ISO date (yyyy-MM-dd). Falls back to /// when the value is missing or malformed, /// so a single bad file does not crash startup — it just sorts last. /// private static DateOnly ParseDate(string? value) { return DateOnly.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) ? date : DateOnly.MinValue; } }