perf(cms-client+ssr): O(1)-Slug-Lookup, Sparse-Pages, höhere TTLs, Cache-Control, Parallel-Resolver
Deploy / verify (push) Successful in 58s
Deploy / deploy (push) Successful in 1m12s

- getPageBySlug: neuer Filter-Lookup-Pfad (?slug=X&_fields=_slug) als
  primärer Fallback — O(1) statt komplette Page-Liste laden. Vollständige
  Liste nur noch als Last-Resort-Safety-Net. Skaliert egal wieviele Pages.
- getPages: Sparse-Projektion via _fields (8 Felder statt Voll-Resolve-
  Tree) → ~90 % kleinere Payload pro Call.
- CACHE_TTL hoch (Webhooks purgen eh): page/page_config/navigation/footer/
  fullwidth_banner/translation/openapi → 1h; tag/text_fragment → 30min;
  post → 10min. Spart Cold-Loads + CMS-Roundtrips.
- [...slug]/+page.server.ts: Cache-Control public, s-maxage=60,
  stale-while-revalidate=600 für öffentliche Pages (Caddy/Browser cachen).
  Preview-Branch bleibt private/no-store.
- Resolver parallelisiert: Calendar/Deadline/StellingnahmeGenerator
  starten sofort parallel zu PostOverview, nicht erst danach.
- Icon-Subset um clock-remove-outline ergänzt (Frist-abgelaufen-Banner).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-05-30 00:09:26 +02:00
parent f05ee6b9d7
commit c58f418c1a
3 changed files with 87 additions and 20 deletions
+66 -15
View File
@@ -62,20 +62,22 @@ let openApiCache: OpenApiSpec | null = null;
* Override via PUBLIC_CMS_CACHE_TTL_MS_<COLLECTION> falls nötig.
*/
const CACHE_TTL: Record<string, number> = {
page: 5 * 60_000,
page_config: 10 * 60_000,
navigation: 10 * 60_000,
footer: 10 * 60_000,
post: 60_000,
tag: 5 * 60_000,
text_fragment: 5 * 60_000,
fullwidth_banner: 5 * 60_000,
calendar: 60_000,
calendar_item: 60_000,
translation: 5 * 60_000,
translation_bundle: 5 * 60_000,
openapi: 10 * 60_000,
default: 60_000,
// Webhooks purgen bei Mutation → höhere TTL ist unkritisch und spart
// Cold-Loads + CMS-Roundtrips.
page: 60 * 60_000, // 1h (war 5min)
page_config: 60 * 60_000, // 1h
navigation: 60 * 60_000, // 1h
footer: 60 * 60_000, // 1h
post: 10 * 60_000, // 10min (war 1min) — Posts ändern öfter
tag: 30 * 60_000, // 30min
text_fragment: 30 * 60_000, // 30min
fullwidth_banner: 60 * 60_000, // 1h
calendar: 5 * 60_000, // 5min — wenig Mutationen, aber zeitsensitiv
calendar_item: 5 * 60_000, // 5min
translation: 60 * 60_000, // 1h
translation_bundle: 60 * 60_000, // 1h
openapi: 60 * 60_000, // 1h
default: 5 * 60_000,
};
type CacheEntry<T> = { value: T; expires: number };
@@ -167,12 +169,17 @@ export async function getPages(): Promise<PageEntry[]> {
// Ohne vollständige Liste scheitert der Slug→_slug-Fallback in
// getPageBySlug für Seiten jenseits der ersten Page → 404.
const PER_PAGE = 200;
// Sparse-Projektion: nur die Felder, die Caller (sitemap, search-index,
// root-page, getPageBySlug-Fallback) tatsächlich verwenden. Spart 8095 %
// Payload-Größe und vermeidet komplette Resolve-Trees.
const FIELDS =
"_slug,_updated,_created,slug,linkName,name,headline,subheadline";
const all: PageEntry[] = [];
let page = 1;
let totalPages = 1;
do {
const res = await cmsFetch(
`${base}/api/content/page?_per_page=${PER_PAGE}&_page=${page}`,
`${base}/api/content/page?_per_page=${PER_PAGE}&_page=${page}&_fields=${FIELDS}`,
);
if (!res.ok) {
throw new Error(
@@ -341,6 +348,38 @@ function normalizePageSlug(s: string | undefined): string {
return (s ?? "").replace(/^\/+|\/+$/g, "").trim();
}
/**
* Schlanker Lookup: gibt zu einem URL-Slug ("/regionalplan/w36" oder
* "regionalplan/w36") den internen `_slug` zurück, indem das CMS gezielt nach
* dem `slug`-Feld gefiltert wird. Nur `_slug` wird projiziert → kleine Payload
* unabhängig von Collection-Größe. Probiert beide Slug-Schreibweisen.
*/
async function findPageSlugByField(
normSlug: string,
base: string,
): Promise<string | null> {
const candidates = [normSlug, "/" + normSlug];
for (const candidate of candidates) {
const u = new URL(`${base}/api/content/page`);
u.searchParams.set("slug", candidate);
u.searchParams.set("_fields", "_slug");
u.searchParams.set("_per_page", "2");
let r: Response;
try {
r = await cmsFetch(u.toString());
} catch {
continue;
}
if (!r.ok) continue;
const data = (await r.json().catch(() => null)) as
| { items?: Array<{ _slug?: string }> }
| null;
const hit = data?.items?.[0]?._slug;
if (hit) return hit;
}
return null;
}
/**
* Ein Page-Eintrag nach Slug (GET /api/content/page/:slug).
*/
@@ -373,6 +412,18 @@ export async function getPageBySlug(
res = await trySlug("/" + normSlug);
}
if (res.status === 404) {
// O(1)-Lookup via slug-Feld-Filter (statt komplette Page-Liste zu holen).
// Skaliert egal wie viele Pages existieren. Beide Slug-Schreibweisen
// probieren — Filter macht exakten String-Vergleich.
const matchSlug = await findPageSlugByField(normSlug, base);
if (matchSlug) {
res = await trySlug(matchSlug);
}
}
if (res.status === 404) {
// Last-resort: vollständige Liste durchsuchen (Safety-Net für Edge-Cases
// — z.B. Slug-Formen die der Exakt-Filter verfehlt aber post-Normalisierung
// matchen würden). Sollte praktisch nie greifen.
const items = await getPages();
const match = items.find(
(p) =>