diff --git a/.editorconfig b/.editorconfig index 74d6a2f..cf29b5f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -54,3 +54,9 @@ dotnet_diagnostic.CA1812.severity = none # layout and is harmless (Blazor routing is not namespace-based). Renaming would # break the folder convention for no benefit. dotnet_diagnostic.CA1724.severity = none + +# CA1308 wants ToUpperInvariant over ToLowerInvariant. That advice is for +# normalizing before a comparison, where a few characters do not round-trip. Here +# lowercasing is the goal itself (URL slugs, which must be lowercase), not a step +# before a comparison -- comparisons use StringComparison.OrdinalIgnoreCase instead. +dotnet_diagnostic.CA1308.severity = none diff --git a/Elternbeirat.Contracts/Event.cs b/Elternbeirat.Contracts/Event.cs index aef9878..7b5d894 100644 --- a/Elternbeirat.Contracts/Event.cs +++ b/Elternbeirat.Contracts/Event.cs @@ -14,8 +14,13 @@ public record Event /// /// Gets the PocketBase record id. /// + /// + /// : every stored record has an id. Modeling it as + /// required states which fields a record must carry, independent of the store, + /// so a future data source has to supply them too. + /// [JsonPropertyName("id")] - public string Id { get; init; } = ""; + public required string Id { get; init; } /// /// Gets the start of the event as Europe/Berlin wall-clock time. @@ -24,10 +29,12 @@ public record Event /// PocketBase stores the value in UTC; it is converted to Berlin local time on /// deserialization, with daylight saving applied. An all-day event uses 00:00 /// as the time. + /// : an event without a start has no place on the + /// calendar, and the field is required in PocketBase. /// [JsonPropertyName("start")] [JsonConverter(typeof(LocalDateTimeConverter))] - public DateTime Start { get; init; } + public required DateTime Start { get; init; } /// /// Gets the optional end of the event as Europe/Berlin wall-clock time. @@ -42,8 +49,12 @@ public record Event /// /// Gets the event name, e.g. "Elternbeiratssitzung". /// + /// + /// : an event without a name is incomplete, and the + /// field is required in PocketBase. + /// [JsonPropertyName("title")] - public string Title { get; init; } = ""; + public required string Title { get; init; } /// /// Gets the optional location, e.g. "Aula". @@ -52,7 +63,7 @@ public record Event /// The location, or if none is set. /// [JsonPropertyName("location")] - public string Location { get; init; } = ""; + public string Location { get; init; } = string.Empty; /// /// Gets the optional note, e.g. "Anmeldung erforderlich". @@ -61,13 +72,7 @@ public record Event /// The note, or if none is set. /// [JsonPropertyName("note")] - public string Note { get; init; } = ""; - - /// - /// Gets a value indicating whether the event is visible to visitors of the site. - /// - [JsonPropertyName("public")] - public bool Public { get; init; } + public string Note { get; init; } = string.Empty; /// /// Gets a value indicating whether the event has a wall-clock time, as opposed diff --git a/Elternbeirat.Contracts/Faq.cs b/Elternbeirat.Contracts/Faq.cs index bc8dd38..f95f388 100644 --- a/Elternbeirat.Contracts/Faq.cs +++ b/Elternbeirat.Contracts/Faq.cs @@ -13,30 +13,41 @@ public record Faq /// /// Gets the PocketBase record id. /// + /// + /// : every stored record has an id. Modeling it as + /// required states which fields a record must carry, independent of the store, + /// so a future data source has to supply them too. + /// [JsonPropertyName("id")] - public string Id { get; init; } = ""; + public required string Id { get; init; } /// /// Gets the question as a parent would phrase it. /// + /// + /// : a FAQ entry without a question is incomplete, and + /// the field is required in PocketBase. + /// [JsonPropertyName("question")] - public string Question { get; init; } = ""; + public required string Question { get; init; } /// /// Gets the answer as Markdown. /// + /// + /// : a FAQ entry exists to answer its question, so an + /// entry without an answer is incomplete. The field is required in PocketBase. + /// [JsonPropertyName("answer")] - public string Answer { get; init; } = ""; + public required string Answer { get; init; } /// /// Gets the topic the question is grouped under, e.g. "mensa". /// + /// + /// : the FAQ page groups entries by topic, so an entry + /// without one has no place. The field is required in PocketBase. + /// [JsonPropertyName("topic")] - public string Topic { get; init; } = ""; - - /// - /// Gets a value indicating whether the question is visible to visitors of the site. - /// - [JsonPropertyName("public")] - public bool Public { get; init; } + public required string Topic { get; init; } } diff --git a/Elternbeirat.Contracts/Page.cs b/Elternbeirat.Contracts/Page.cs index 7204d72..ee17931 100644 --- a/Elternbeirat.Contracts/Page.cs +++ b/Elternbeirat.Contracts/Page.cs @@ -22,23 +22,30 @@ public record Page /// /// Gets the PocketBase record id. /// + /// + /// : every stored record has an id. Modeling it as + /// required states which fields a record must carry, independent of the store, + /// so a future data source has to supply them too. + /// [JsonPropertyName("id")] - public string Id { get; init; } = ""; + public required string Id { get; init; } /// /// Gets the heading shown to visitors. /// /// /// Free text; may contain umlauts and spaces. For the URL, see . + /// : a page without a heading is incomplete, and the + /// field is required in PocketBase. /// [JsonPropertyName("title")] - public string Title { get; init; } = ""; + public required string Title { get; init; } /// /// Gets the page body as Markdown. /// [JsonPropertyName("body")] - public string Body { get; init; } = ""; + public string Body { get; init; } = string.Empty; /// /// Gets the navigation menu the page appears in. @@ -46,8 +53,12 @@ public record Page /// /// Either "header" or "footer". /// + /// + /// : the navigation is built from this, and the field + /// is required in PocketBase, so every page belongs to one of the two menus. + /// [JsonPropertyName("location")] - public string Location { get; init; } = ""; + public required string Location { get; init; } /// /// Gets the sort order within the navigation menu given by . @@ -62,10 +73,18 @@ public record Page /// Gets the URL slug of the page, e.g. "board" for /board. /// /// - /// A lowercase path segment without umlauts. + /// A URL path segment: lowercase letters, digits and single hyphens as + /// separators, no umlauts. PocketBase enforces this on save via a field + /// pattern (^[a-z0-9]+(-[a-z0-9]+)*$), so every stored slug is already + /// canonical and the app can use it verbatim in comparisons and generated URLs. /// + /// + /// : the slug is how a page is addressed, so a record + /// without one is broken. It is a required field in PocketBase, so deserializing + /// one that lacks it should fail loudly rather than yield a page with no URL. + /// [JsonPropertyName("slug")] - public string Slug { get; init; } = ""; + public required string Slug { get; init; } /// /// Gets the dynamic blocks rendered below the . @@ -78,22 +97,20 @@ public record Page public IReadOnlyList Embed { get; init; } = []; /// - /// Gets a value indicating whether the page is visible to visitors of the site. - /// - [JsonPropertyName("public")] - public bool Public { get; init; } - - /// - /// Tests whether a slug is the home page's, ignoring case. This is the single - /// definition of that comparison, so every caller treats home and - /// Home alike. + /// Tests whether a slug is the home page's. This is the single definition of + /// that comparison, so every caller identifies the home page the same way. /// + /// + /// An ordinal (case-sensitive) comparison is enough because slugs are always + /// lowercase: PocketBase enforces that with a field pattern (see + /// ), and this is only ever called with a stored slug. + /// /// - /// The slug to test. + /// The slug to test, e.g. from . /// /// /// if is the home page's slug. /// public static bool IsHomeSlug(string slug) => - string.Equals(slug, HomeSlug, StringComparison.OrdinalIgnoreCase); + string.Equals(slug, HomeSlug, StringComparison.Ordinal); } diff --git a/Elternbeirat.Contracts/Post.cs b/Elternbeirat.Contracts/Post.cs index 51933e7..0622c12 100644 --- a/Elternbeirat.Contracts/Post.cs +++ b/Elternbeirat.Contracts/Post.cs @@ -13,8 +13,13 @@ public record Post /// /// Gets the PocketBase record id. /// + /// + /// : every stored record has an id. Modeling it as + /// required states which fields a record must carry, independent of the store, + /// so a future data source has to supply them too. + /// [JsonPropertyName("id")] - public string Id { get; init; } = ""; + public required string Id { get; init; } /// /// Gets the publication date as a calendar day. @@ -34,28 +39,32 @@ public record Post /// /// /// Free text; may contain umlauts and spaces. For the URL, see . + /// : a post without a heading is incomplete, and the + /// field is required in PocketBase. /// [JsonPropertyName("title")] - public string Title { get; init; } = ""; + public required string Title { get; init; } /// /// Gets the post body as Markdown. /// [JsonPropertyName("body")] - public string Body { get; init; } = ""; + public string Body { get; init; } = string.Empty; /// /// Gets the URL slug of the post, e.g. "herbstbasar" for /posts/herbstbasar. /// /// - /// A lowercase path segment without umlauts. + /// A URL path segment: lowercase letters, digits and single hyphens as + /// separators, no umlauts. PocketBase enforces this on save via a field + /// pattern (^[a-z0-9]+(-[a-z0-9]+)*$), so every stored slug is already + /// canonical and the app can use it verbatim in comparisons and generated URLs. /// + /// + /// : the slug is how a post is addressed, so a record + /// without one is broken. It is a required field in PocketBase, so deserializing + /// one that lacks it should fail loudly rather than yield a post with no URL. + /// [JsonPropertyName("slug")] - public string Slug { get; init; } = ""; - - /// - /// Gets a value indicating whether the post is visible to visitors of the site. - /// - [JsonPropertyName("public")] - public bool Public { get; init; } + public required string Slug { get; init; } } diff --git a/Elternbeirat.PocketBase/PocketBaseClient.cs b/Elternbeirat.PocketBase/PocketBaseClient.cs index 5e68cfa..5438431 100644 --- a/Elternbeirat.PocketBase/PocketBaseClient.cs +++ b/Elternbeirat.PocketBase/PocketBaseClient.cs @@ -24,15 +24,15 @@ namespace Elternbeirat.PocketBase; /// set to the PocketBase base URL. /// /// -/// +/// /// The HTTP client; its must point to PocketBase. /// -public sealed class PocketBaseClient(HttpClient http) +public sealed class PocketBaseClient(HttpClient httpClient) { /// /// Gets all public pages, ordered by . /// - /// + /// /// A token to cancel the request. /// /// @@ -45,10 +45,10 @@ public sealed class PocketBaseClient(HttpClient http) /// The response could not be deserialized. /// /// - /// was canceled or the request timed out. + /// was canceled or the request timed out. /// - public Task> GetPagesAsync(CancellationToken ct = default) - => GetRecordsAsync("pages", "order", ct); + public Task> GetPagesAsync(CancellationToken token = default) + => GetRecordsAsync("pages", "order", token); /// /// Gets all public posts, ordered by , newest first. @@ -58,8 +58,8 @@ public sealed class PocketBaseClient(HttpClient http) /// The public posts, or an empty list if there are none. /// /// - public Task> GetPostsAsync(CancellationToken ct = default) - => GetRecordsAsync("posts", "-date", ct); + public Task> GetPostsAsync(CancellationToken token = default) + => GetRecordsAsync("posts", "-date", token); /// /// Gets all public events, ordered by , earliest first. @@ -69,8 +69,8 @@ public sealed class PocketBaseClient(HttpClient http) /// The public events, or an empty list if there are none. /// /// - public Task> GetEventsAsync(CancellationToken ct = default) - => GetRecordsAsync("events", "start", ct); + public Task> GetEventsAsync(CancellationToken token = default) + => GetRecordsAsync("events", "start", token); /// /// Gets all public FAQ entries, ordered by . @@ -80,8 +80,8 @@ public sealed class PocketBaseClient(HttpClient http) /// The public FAQ entries, or an empty list if there are none. /// /// - public Task> GetFaqsAsync(CancellationToken ct = default) - => GetRecordsAsync("faqs", "topic", ct); + public Task> GetFaqsAsync(CancellationToken token = default) + => GetRecordsAsync("faqs", "topic", token); /// /// Gets all public records of a collection in a single request. @@ -95,7 +95,7 @@ public sealed class PocketBaseClient(HttpClient http) /// /// The PocketBase sort expression; a leading - sorts descending. /// - /// + /// /// A token to cancel the request. /// /// @@ -104,7 +104,7 @@ public sealed class PocketBaseClient(HttpClient http) private async Task> GetRecordsAsync( string collection, string sort, - CancellationToken ct) + CancellationToken token) { // filter=public=true keeps drafts out; perPage is large enough to fetch // every record in a single page given the small content volume. @@ -112,7 +112,7 @@ public sealed class PocketBaseClient(HttpClient http) + $"?perPage=500&filter={Uri.EscapeDataString("public=true")}" + $"&sort={Uri.EscapeDataString(sort)}"; - var result = await http.GetFromJsonAsync>(url, ct); + var result = await httpClient.GetFromJsonAsync>(url, token); return result?.Items ?? []; } } diff --git a/Elternbeirat.Web.Tests/NavBuilderTests.cs b/Elternbeirat.Web.Tests/NavBuilderTests.cs index c8cdedc..f12c2e1 100644 --- a/Elternbeirat.Web.Tests/NavBuilderTests.cs +++ b/Elternbeirat.Web.Tests/NavBuilderTests.cs @@ -8,8 +8,10 @@ namespace Elternbeirat.Web.Tests; /// public class NavBuilderTests { + // Id is required on the record but irrelevant to navigation, so a fixed + // placeholder keeps these cases focused on slug/location/order/title. private static Page Page(string slug, string location, double order, string title) => - new() { Slug = slug, Location = location, Order = order, Title = title }; + new() { Id = "test", Slug = slug, Location = location, Order = order, Title = title }; [Fact] public void Header_keeps_only_header_pages_ordered_by_order() diff --git a/Elternbeirat.Web.Tests/PocketBaseClientTests.cs b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs index 26b2ecc..c8bc2ba 100644 --- a/Elternbeirat.Web.Tests/PocketBaseClientTests.cs +++ b/Elternbeirat.Web.Tests/PocketBaseClientTests.cs @@ -19,7 +19,6 @@ public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase) // Two seeded events, earliest start first. events.Select(e => e.Title).ShouldBe(["Elternbeiratssitzung", "Herbstbasar"]); - events.ShouldAllBe(e => e.Public); } [Fact] diff --git a/Elternbeirat.Web.Tests/PocketBaseFixture.cs b/Elternbeirat.Web.Tests/PocketBaseFixture.cs index 7dfc87f..3f4bc47 100644 --- a/Elternbeirat.Web.Tests/PocketBaseFixture.cs +++ b/Elternbeirat.Web.Tests/PocketBaseFixture.cs @@ -335,6 +335,6 @@ public sealed class PocketBaseFixture : IAsyncLifetime private sealed record AuthResponse { [System.Text.Json.Serialization.JsonPropertyName("token")] - public string Token { get; init; } = ""; + public string Token { get; init; } = string.Empty; } } diff --git a/Elternbeirat.Web/Features/Pages/ContentPage.razor.cs b/Elternbeirat.Web/Features/Pages/ContentPage.razor.cs index ed809fc..8c187ce 100644 --- a/Elternbeirat.Web/Features/Pages/ContentPage.razor.cs +++ b/Elternbeirat.Web/Features/Pages/ContentPage.razor.cs @@ -2,23 +2,27 @@ using Elternbeirat.Contracts; using Elternbeirat.PocketBase; using Elternbeirat.Web.Shared; using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Http; namespace Elternbeirat.Web.Features.Pages; 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); + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Could not load page '{slug}' from PocketBase.")] + private static partial void LogLoadFailed( + ILogger logger, + string slug, + Exception exception); [Parameter] - public string Slug { get; set; } = ""; + public string Slug { get; set; } = string.Empty; [Inject] - private PocketBaseClient PocketBase { get; set; } = default!; + private PocketBaseClient PocketBase { get; set; } = null!; [Inject] - private ILogger Logger { get; set; } = default!; + private ILogger Logger { get; set; } = null!; [CascadingParameter] private HttpContext? HttpContext { get; set; } diff --git a/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs b/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs index 39e1df1..b3a68d6 100644 --- a/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs +++ b/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs @@ -15,7 +15,7 @@ public partial class PostDetail private static partial void LogLoadFailed(ILogger logger, string slug, Exception exception); [Parameter] - public string Slug { get; set; } = ""; + public string Slug { get; set; } = string.Empty; [Inject] private PocketBaseClient PocketBase { get; set; } = default!; diff --git a/docs/redaktion.md b/docs/redaktion.md index f317199..4dc88be 100644 --- a/docs/redaktion.md +++ b/docs/redaktion.md @@ -87,8 +87,9 @@ sofort live, ohne Code und ohne Rebuild: 5. Optional `location` (`header`/`footer`) **und** `order` setzen, damit die Seite ins Menü kommt; optional `embed` für eingebettete Blöcke (siehe unten). -- **Slug bleibt englisch und ohne Umlaute.** `imprint`, `privacy`, `contact`, - `board`, `patrons`. Der `title` darf deutsch mit Umlauten sein (`Förderverein`). +- **Slug bleibt englisch und ohne Umlaute** — `imprint`, `privacy`, `contact`, + `board`, `patrons`; erzwungen per Pattern (siehe *Slug-Regel wird erzwungen* + unten). Der `title` darf deutsch mit Umlauten sein (`Förderverein`). - **Reservierte Slugs.** `posts`, `events`, `faqs` und `/` (die Startseite `home`) gehören den festen Listen-/Sonderansichten. Eine `pages`-Seite mit einem dieser Slugs wird von der jeweiligen Ansicht verdeckt und **nicht** angezeigt; in der @@ -105,6 +106,26 @@ sofort live, ohne Code und ohne Rebuild: > Diese Slugs nicht ändern — sonst laufen die rechtlich verlinkten Adressen ins > Leere (404). +### Slug-Regel wird erzwungen + +Ein Slug muss ein sauberes URL-Segment sein: **Kleinbuchstaben, Ziffern und +einzelne Bindestriche als Trenner, keine Umlaute** (`ueber-uns`, nicht `Über uns`). +Das ist keine Bitte, sondern eine Feldregel: PocketBase weist einen Slug ab, der +sie verletzt, und zeigt beim Speichern einen Fehler. Gibst du `Overview` ein, +kommst du erst weiter, wenn du `overview` daraus machst. Die Website verlässt sich +darauf — sie repariert einen Slug nicht mehr nachträglich. + +Die Regel steckt als **Pattern** am Feld `slug`. So ist sie gesetzt (einmalig, +pro Collection mit einem Slug — `pages` **und** `posts`): + +1. Collection öffnen → *Edit collection* (oben rechts) → Feld `slug`. +2. Am Feld auf das **Zahnrad** (Feldoptionen) → **Pattern**. +3. Als Pattern eintragen: `^[a-z0-9]+(-[a-z0-9]+)*$` +4. **Save** (Feld) und **Save** (Collection). + +Zum Prüfen: `board`, `ueber-uns`, `faq-lunch` werden angenommen; `Overview`, +`über-uns`, `my page`, `-x` und `a--b` werden abgewiesen. + --- ## Beiträge (`posts`) @@ -116,7 +137,9 @@ der Startseite als Vorschau angeteasert. - `date` steuert Sortierung und angezeigtes Datum. Anders als bei Terminen zählt hier nur der **Tag** — eine Uhrzeit wird nie angezeigt, und die UTC-Verschiebung aus dem Termine-Hinweis spielt keine Rolle. Trage einfach das Datum ein. -- `slug` ist englisch, klein, ohne Umlaute (z. B. `new-sports-hall-opened`). +- `slug` ist englisch, klein, ohne Umlaute (z. B. `new-sports-hall-opened`) — + dieselbe erzwungene Feldregel wie bei Seiten (siehe *Seiten → Slug-Regel wird + erzwungen*). --- diff --git a/pb/pb_migrations/collections_schema.json b/pb/pb_migrations/collections_schema.json index 37c7848..302f8da 100644 --- a/pb/pb_migrations/collections_schema.json +++ b/pb/pb_migrations/collections_schema.json @@ -74,7 +74,7 @@ "max": 0, "min": 0, "name": "slug", - "pattern": "", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", "presentable": false, "primaryKey": false, "required": true, @@ -298,7 +298,7 @@ "max": 0, "min": 0, "name": "slug", - "pattern": "", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", "presentable": false, "primaryKey": false, "required": true, @@ -388,7 +388,7 @@ "pattern": "", "presentable": false, "primaryKey": false, - "required": false, + "required": true, "system": false, "type": "text" }, @@ -399,7 +399,7 @@ "maxSelect": 1, "name": "topic", "presentable": false, - "required": false, + "required": true, "system": false, "type": "select", "values": [