diff --git a/src/lib/cms.ts b/src/lib/cms.ts index f5304bb..5bb46d3 100644 --- a/src/lib/cms.ts +++ b/src/lib/cms.ts @@ -62,20 +62,22 @@ let openApiCache: OpenApiSpec | null = null; * Override via PUBLIC_CMS_CACHE_TTL_MS_ falls nötig. */ const CACHE_TTL: Record = { - 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 = { value: T; expires: number }; @@ -167,12 +169,17 @@ export async function getPages(): Promise { // 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 80–95 % + // 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 { + 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) => diff --git a/src/lib/iconify-mdi-subset.generated.json b/src/lib/iconify-mdi-subset.generated.json index ad7c855..b6cd184 100644 --- a/src/lib/iconify-mdi-subset.generated.json +++ b/src/lib/iconify-mdi-subset.generated.json @@ -117,6 +117,9 @@ "clock-alert-outline": { "body": "" }, + "clock-remove-outline": { + "body": "" + }, "content-save-outline": { "body": "" }, diff --git a/src/routes/[...slug]/+page.server.ts b/src/routes/[...slug]/+page.server.ts index ce6c22b..e3a7613 100644 --- a/src/routes/[...slug]/+page.server.ts +++ b/src/routes/[...slug]/+page.server.ts @@ -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