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) =>
@@ -117,6 +117,9 @@
"clock-alert-outline": {
"body": "<path fill=\"currentColor\" d=\"M11 7v6l5.2 3.1l.8-1.2l-4.5-2.7V7zm9 5v6h2v-6zm0 8v2h2v-2zm-2 0c-1.7 1.3-3.7 2-6 2c-5.5 0-10-4.5-10-10S6.5 2 12 2c4.8 0 8.9 3.4 9.8 8h-2.1c-.9-3.4-4-6-7.7-6c-4.4 0-8 3.6-8 8s3.6 8 8 8c2.4 0 4.5-1.1 6-2.7z\"/>"
},
"clock-remove-outline": {
"body": "<path fill=\"currentColor\" d=\"M14.47 15.08L11 13V7h1.5v5.25l3.08 1.83c-.41.28-.79.62-1.11 1m-1.39 4.84c-.36.05-.71.08-1.08.08c-4.42 0-8-3.58-8-8s3.58-8 8-8s8 3.58 8 8c0 .37-.03.72-.08 1.08c.69.1 1.33.32 1.92.64c.1-.56.16-1.13.16-1.72c0-5.5-4.5-10-10-10S2 6.5 2 12s4.47 10 10 10c.59 0 1.16-.06 1.72-.16c-.32-.59-.54-1.23-.64-1.92m8.04-4.46L19 17.59l-2.12-2.12l-1.41 1.41L17.59 19l-2.12 2.12l1.41 1.42L19 20.41l2.12 2.13l1.42-1.42L20.41 19l2.13-2.12z\"/>"
},
"content-save-outline": {
"body": "<path fill=\"currentColor\" d=\"M17 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14c1.1 0 2-.9 2-2V7zm2 16H5V5h11.17L19 7.83zm-7-7c-1.66 0-3 1.34-3 3s1.34 3 3 3s3-1.34 3-3s-1.34-3-3-3M6 6h9v4H6z\"/>"
},
+18 -5
View File
@@ -38,6 +38,14 @@ export const load: PageServerLoad = async ({ params, locals, fetch, url, setHead
'cache-control': 'private, no-store, max-age=0',
'x-robots-tag': 'noindex, nofollow',
});
} else {
// Public Pages: Caddy/CDN + Browser cachen 60s, stale-while-revalidate
// erlaubt 10min Sofort-Lieferung während im Hintergrund neu geholt wird.
// CMS-Mutationen lösen Webhook → Frontend-Content-Cache-Purge aus, der
// Edge-Cache läuft per TTL aus (kurze Inkonsistenz akzeptabel).
setHeaders({
'cache-control': 'public, s-maxage=60, stale-while-revalidate=600',
});
}
const page = await getPageBySlug(slug, {
@@ -58,15 +66,20 @@ export const load: PageServerLoad = async ({ params, locals, fetch, url, setHead
// Tree — vor den Block-Resolvern erledigen, die danach gegen mutierte
// Blocks arbeiten.
await resolveContentImages(page);
// PostOverview liefert tagsMap, den SearchableText braucht. Calendar +
// DeadlineBanner sind unabhängig → parallel mit SearchableText laufen.
const tagsMap = await resolvePostOverviewBlocks(page);
await Promise.all([
resolveSearchableTextBlocks(page, tagsMap),
// PostOverview→SearchableText hat Daten-Abhängigkeit (tagsMap). Calendar,
// DeadlineBanner und StellingnahmeGenerator sind unabhängig — sofort
// parallel starten, nicht erst nach PostOverview warten.
const postOverviewPromise = resolvePostOverviewBlocks(page);
const independentResolvers = Promise.all([
resolveCalendarBlocks(page),
resolveDeadlineBannerBlocks(page),
resolveStellingnahmeGeneratorBlocks(page),
]);
const tagsMap = await postOverviewPromise;
await Promise.all([
resolveSearchableTextBlocks(page, tagsMap),
independentResolvers,
]);
sanitizeBlocks(page);
// Top-Bild: bevorzugt neues inline-Feld `topImage`, sonst Legacy via