Harden the content contracts and enforce the slug rule
This commit is contained in:
1 parent
d06af2891a
commit
0f7e968c16
13 files changed
+157
-81
No files matched your search
@@ -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
|
||||
@@ -14,8 +14,13 @@ public record Event
|
||||
/// <summary>
|
||||
/// Gets the PocketBase record id.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see langword="required"/>: 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.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// <see langword="required"/>: an event without a start has no place on the
|
||||
/// calendar, and the field is required in PocketBase.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("start")]
|
||||
[JsonConverter(typeof(LocalDateTimeConverter))]
|
||||
public DateTime Start { get; init; }
|
||||
public required DateTime Start { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional end of the event as Europe/Berlin wall-clock time.
|
||||
@@ -42,8 +49,12 @@ public record Event
|
||||
/// <summary>
|
||||
/// Gets the event name, e.g. <c>"Elternbeiratssitzung"</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see langword="required"/>: an event without a name is incomplete, and the
|
||||
/// field is required in PocketBase.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; init; } = "";
|
||||
public required string Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional location, e.g. <c>"Aula"</c>.
|
||||
@@ -52,7 +63,7 @@ public record Event
|
||||
/// The location, or <see cref="string.Empty"/> if none is set.
|
||||
/// </value>
|
||||
[JsonPropertyName("location")]
|
||||
public string Location { get; init; } = "";
|
||||
public string Location { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional note, e.g. <c>"Anmeldung erforderlich"</c>.
|
||||
@@ -61,13 +72,7 @@ public record Event
|
||||
/// The note, or <see cref="string.Empty"/> if none is set.
|
||||
/// </value>
|
||||
[JsonPropertyName("note")]
|
||||
public string Note { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the event is visible to visitors of the site.
|
||||
/// </summary>
|
||||
[JsonPropertyName("public")]
|
||||
public bool Public { get; init; }
|
||||
public string Note { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the event has a wall-clock time, as opposed
|
||||
|
||||
@@ -13,30 +13,41 @@ public record Faq
|
||||
/// <summary>
|
||||
/// Gets the PocketBase record id.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see langword="required"/>: 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.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the question as a parent would phrase it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see langword="required"/>: a FAQ entry without a question is incomplete, and
|
||||
/// the field is required in PocketBase.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("question")]
|
||||
public string Question { get; init; } = "";
|
||||
public required string Question { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the answer as Markdown.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see langword="required"/>: a FAQ entry exists to answer its question, so an
|
||||
/// entry without an answer is incomplete. The field is required in PocketBase.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("answer")]
|
||||
public string Answer { get; init; } = "";
|
||||
public required string Answer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the topic the question is grouped under, e.g. <c>"mensa"</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see langword="required"/>: the FAQ page groups entries by topic, so an entry
|
||||
/// without one has no place. The field is required in PocketBase.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("topic")]
|
||||
public string Topic { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the question is visible to visitors of the site.
|
||||
/// </summary>
|
||||
[JsonPropertyName("public")]
|
||||
public bool Public { get; init; }
|
||||
public required string Topic { get; init; }
|
||||
}
|
||||
@@ -22,23 +22,30 @@ public record Page
|
||||
/// <summary>
|
||||
/// Gets the PocketBase record id.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see langword="required"/>: 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.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the heading shown to visitors.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Free text; may contain umlauts and spaces. For the URL, see <see cref="Slug"/>.
|
||||
/// <see langword="required"/>: a page without a heading is incomplete, and the
|
||||
/// field is required in PocketBase.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; init; } = "";
|
||||
public required string Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the page body as Markdown.
|
||||
/// </summary>
|
||||
[JsonPropertyName("body")]
|
||||
public string Body { get; init; } = "";
|
||||
public string Body { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the navigation menu the page appears in.
|
||||
@@ -46,8 +53,12 @@ public record Page
|
||||
/// <value>
|
||||
/// Either <c>"header"</c> or <c>"footer"</c>.
|
||||
/// </value>
|
||||
/// <remarks>
|
||||
/// <see langword="required"/>: the navigation is built from this, and the field
|
||||
/// is required in PocketBase, so every page belongs to one of the two menus.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("location")]
|
||||
public string Location { get; init; } = "";
|
||||
public required string Location { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the sort order within the navigation menu given by <see cref="Location"/>.
|
||||
@@ -62,10 +73,18 @@ public record Page
|
||||
/// Gets the URL slug of the page, e.g. <c>"board"</c> for <c>/board</c>.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// 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 (<c>^[a-z0-9]+(-[a-z0-9]+)*$</c>), so every stored slug is already
|
||||
/// canonical and the app can use it verbatim in comparisons and generated URLs.
|
||||
/// </value>
|
||||
/// <remarks>
|
||||
/// <see langword="required"/>: 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.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("slug")]
|
||||
public string Slug { get; init; } = "";
|
||||
public required string Slug { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the dynamic blocks rendered below the <see cref="Body"/>.
|
||||
@@ -78,22 +97,20 @@ public record Page
|
||||
public IReadOnlyList<string> Embed { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the page is visible to visitors of the site.
|
||||
/// </summary>
|
||||
[JsonPropertyName("public")]
|
||||
public bool Public { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tests whether a slug is the home page's, ignoring case. This is the single
|
||||
/// definition of that comparison, so every caller treats <c>home</c> and
|
||||
/// <c>Home</c> 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An ordinal (case-sensitive) comparison is enough because slugs are always
|
||||
/// lowercase: PocketBase enforces that with a field pattern (see
|
||||
/// <see cref="Slug"/>), and this is only ever called with a stored slug.
|
||||
/// </remarks>
|
||||
/// <param name="slug">
|
||||
/// The slug to test.
|
||||
/// The slug to test, e.g. from <see cref="Slug"/>.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> if <paramref name="slug"/> is the home page's slug.
|
||||
/// </returns>
|
||||
public static bool IsHomeSlug(string slug) =>
|
||||
string.Equals(slug, HomeSlug, StringComparison.OrdinalIgnoreCase);
|
||||
string.Equals(slug, HomeSlug, StringComparison.Ordinal);
|
||||
}
|
||||
@@ -13,8 +13,13 @@ public record Post
|
||||
/// <summary>
|
||||
/// Gets the PocketBase record id.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see langword="required"/>: 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.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the publication date as a calendar day.
|
||||
@@ -34,28 +39,32 @@ public record Post
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Free text; may contain umlauts and spaces. For the URL, see <see cref="Slug"/>.
|
||||
/// <see langword="required"/>: a post without a heading is incomplete, and the
|
||||
/// field is required in PocketBase.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; init; } = "";
|
||||
public required string Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the post body as Markdown.
|
||||
/// </summary>
|
||||
[JsonPropertyName("body")]
|
||||
public string Body { get; init; } = "";
|
||||
public string Body { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the URL slug of the post, e.g. <c>"herbstbasar"</c> for <c>/posts/herbstbasar</c>.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// 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 (<c>^[a-z0-9]+(-[a-z0-9]+)*$</c>), so every stored slug is already
|
||||
/// canonical and the app can use it verbatim in comparisons and generated URLs.
|
||||
/// </value>
|
||||
/// <remarks>
|
||||
/// <see langword="required"/>: 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.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("slug")]
|
||||
public string Slug { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the post is visible to visitors of the site.
|
||||
/// </summary>
|
||||
[JsonPropertyName("public")]
|
||||
public bool Public { get; init; }
|
||||
public required string Slug { get; init; }
|
||||
}
|
||||
@@ -24,15 +24,15 @@ namespace Elternbeirat.PocketBase;
|
||||
/// <see cref="HttpClient.BaseAddress"/> set to the PocketBase base URL.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="http">
|
||||
/// <param name="httpClient">
|
||||
/// The HTTP client; its <see cref="HttpClient.BaseAddress"/> must point to PocketBase.
|
||||
/// </param>
|
||||
public sealed class PocketBaseClient(HttpClient http)
|
||||
public sealed class PocketBaseClient(HttpClient httpClient)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets all public pages, ordered by <see cref="Page.Order"/>.
|
||||
/// </summary>
|
||||
/// <param name="ct">
|
||||
/// <param name="token">
|
||||
/// A token to cancel the request.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
@@ -45,10 +45,10 @@ public sealed class PocketBaseClient(HttpClient http)
|
||||
/// The response could not be deserialized.
|
||||
/// </exception>
|
||||
/// <exception cref="OperationCanceledException">
|
||||
/// <paramref name="ct"/> was canceled or the request timed out.
|
||||
/// <paramref name="token"/> was canceled or the request timed out.
|
||||
/// </exception>
|
||||
public Task<IReadOnlyList<Page>> GetPagesAsync(CancellationToken ct = default)
|
||||
=> GetRecordsAsync<Page>("pages", "order", ct);
|
||||
public Task<IReadOnlyList<Page>> GetPagesAsync(CancellationToken token = default)
|
||||
=> GetRecordsAsync<Page>("pages", "order", token);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all public posts, ordered by <see cref="Post.Date"/>, newest first.
|
||||
@@ -58,8 +58,8 @@ public sealed class PocketBaseClient(HttpClient http)
|
||||
/// The public posts, or an empty list if there are none.
|
||||
/// </returns>
|
||||
/// <inheritdoc cref="GetPagesAsync" path="/exception"/>
|
||||
public Task<IReadOnlyList<Post>> GetPostsAsync(CancellationToken ct = default)
|
||||
=> GetRecordsAsync<Post>("posts", "-date", ct);
|
||||
public Task<IReadOnlyList<Post>> GetPostsAsync(CancellationToken token = default)
|
||||
=> GetRecordsAsync<Post>("posts", "-date", token);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all public events, ordered by <see cref="Event.Start"/>, earliest first.
|
||||
@@ -69,8 +69,8 @@ public sealed class PocketBaseClient(HttpClient http)
|
||||
/// The public events, or an empty list if there are none.
|
||||
/// </returns>
|
||||
/// <inheritdoc cref="GetPagesAsync" path="/exception"/>
|
||||
public Task<IReadOnlyList<Event>> GetEventsAsync(CancellationToken ct = default)
|
||||
=> GetRecordsAsync<Event>("events", "start", ct);
|
||||
public Task<IReadOnlyList<Event>> GetEventsAsync(CancellationToken token = default)
|
||||
=> GetRecordsAsync<Event>("events", "start", token);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all public FAQ entries, ordered by <see cref="Faq.Topic"/>.
|
||||
@@ -80,8 +80,8 @@ public sealed class PocketBaseClient(HttpClient http)
|
||||
/// The public FAQ entries, or an empty list if there are none.
|
||||
/// </returns>
|
||||
/// <inheritdoc cref="GetPagesAsync" path="/exception"/>
|
||||
public Task<IReadOnlyList<Faq>> GetFaqsAsync(CancellationToken ct = default)
|
||||
=> GetRecordsAsync<Faq>("faqs", "topic", ct);
|
||||
public Task<IReadOnlyList<Faq>> GetFaqsAsync(CancellationToken token = default)
|
||||
=> GetRecordsAsync<Faq>("faqs", "topic", token);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all public records of a collection in a single request.
|
||||
@@ -95,7 +95,7 @@ public sealed class PocketBaseClient(HttpClient http)
|
||||
/// <param name="sort">
|
||||
/// The PocketBase sort expression; a leading <c>-</c> sorts descending.
|
||||
/// </param>
|
||||
/// <param name="ct">
|
||||
/// <param name="token">
|
||||
/// A token to cancel the request.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
@@ -104,7 +104,7 @@ public sealed class PocketBaseClient(HttpClient http)
|
||||
private async Task<IReadOnlyList<T>> GetRecordsAsync<T>(
|
||||
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<RecordList<T>>(url, ct);
|
||||
var result = await httpClient.GetFromJsonAsync<RecordList<T>>(url, token);
|
||||
return result?.Items ?? [];
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,10 @@ namespace Elternbeirat.Web.Tests;
|
||||
/// </summary>
|
||||
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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<ContentPage> Logger { get; set; } = default!;
|
||||
private ILogger<ContentPage> Logger { get; set; } = null!;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
@@ -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!;
|
||||
|
||||
+26
-3
@@ -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*).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
Reference in new issue
Block a user