diff --git a/src/lib/blog-utils.ts b/src/lib/blog-utils.ts index 5e0dbc9..de5142e 100644 --- a/src/lib/blog-utils.ts +++ b/src/lib/blog-utils.ts @@ -255,8 +255,31 @@ export async function loadPostsList(opts: { let cmsError: string | null = null; try { + // Sparse fieldset für die Listen-Ansicht: Body/Rows brauchen wir hier + // nicht, nur Karten-Felder + image + tag-Metadaten. _depth=2 reicht + // (post → postImage → img-Sidecar; post → postTag → name/icon/color). [posts, tags] = await Promise.all([ - getPosts({ resolve: ["postImage"] }), + getPosts({ + resolve: ["postImage", "postTag"], + depth: 2, + fields: [ + "_slug", + "slug", + "headline", + "linkName", + "subheadline", + "excerpt", + "created", + "isEvent", + "eventDate", + "hideFromListing", + "postImage.*", + "postTag.name", + "postTag.icon", + "postTag.color", + "postTag._slug", + ], + }), getTags(), ]); posts = filterHiddenPosts(sortPostsByDate(posts)); diff --git a/src/lib/cms.ts b/src/lib/cms.ts index 36e5ede..881661d 100644 --- a/src/lib/cms.ts +++ b/src/lib/cms.ts @@ -229,10 +229,74 @@ export type ContentGetOptions = { locale?: string; /** Felder, deren Referenzen aufgelöst werden (z. B. ["row1Content", "row2Content", "row3Content"]). */ resolve?: string[]; + /** Cap für Resolve-Rekursionstiefe (0..=10). Ohne Wert: legacy unbegrenzt. */ + depth?: number; + /** Sparse-Fieldset, z. B. ["title", "author.name"]. Punktnotation triggert implicit _resolve. */ + fields?: string[]; /** Signierter Preview-Token (HMAC), entsperrt Drafts für genau diesen Eintrag. Bypass Cache. */ preview?: string; }; +// --------------------------------------------------------------------------- +// Batch endpoint (POST /api/content/_batch) +// --------------------------------------------------------------------------- + +/** Eine Sub-Anfrage im Batch. `id` wird im Response als Schlüssel zurückgegeben. */ +export type BatchRequest = { + id: string; + collection: string; + slug: string; + environment?: string; + locale?: string; + resolve?: string; + depth?: number; + fields?: string[]; + preview?: string; + raw_assets?: boolean; +}; + +/** Pro-Sub-Result: Erfolg (`status: 200, data`) oder Fehler (`status: 4xx/5xx, error`). */ +export type BatchResult = + | { status: 200; data: T } + | { status: number; error: string }; + +/** Antwort von POST /api/content/_batch. */ +export type BatchResponse = { results: Record }; + +/** + * Schickt mehrere Lookups in einem HTTP-Roundtrip an den CMS-Batch-Endpoint. + * + * - Outer-Status ist immer 200 wenn der Envelope ok ist; pro Sub-Request + * findet sich der eigentliche Status in `results..status`. + * - Cap pro Call wird vom Server erzwungen (`RUSTYCMS_BATCH_MAX_REQUESTS`, + * default 50). Über dem Cap → 400 mit Detail-Message. + * - Kein TTL-Cache: Batch-Calls sind POST und tragen einen serialisierten + * Body, der sich pro Call unterscheidet. Wer Cache will, soll die + * Single-GET-Funktionen nehmen. + */ +export async function batchFetch(requests: BatchRequest[]): Promise { + if (requests.length === 0) return { results: {} }; + const base = getBaseUrl(); + const res = await fetch(`${base}/api/content/_batch`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ requests }), + }); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + throw new Error( + `RustyCMS batch failed: ${res.status} ${res.statusText}. ${detail}`, + ); + } + return (await res.json()) as BatchResponse; +} + +/** Type-narrowing-Helper: liefert `data`, wenn der Sub-Request 200 lieferte, sonst `null`. */ +export function batchData(result: BatchResult | undefined): T | null { + if (!result) return null; + return result.status === 200 ? (result as { data: T }).data : null; +} + /** Führenden + abschließenden Slash entfernen (CMS "slug": "/about"; Route-Param mit trailing slash). */ function normalizePageSlug(s: string | undefined): string { return (s ?? "").replace(/^\/+|\/+$/g, "").trim(); @@ -246,7 +310,9 @@ export async function getPageBySlug( options?: ContentGetOptions, ): Promise { const resolveKey = options?.resolve?.join(",") ?? ""; - const key = `page:slug:${slug}:${options?.locale ?? ""}:${resolveKey}`; + const fieldsKey = options?.fields?.join(",") ?? ""; + const depthKey = options?.depth ?? ""; + const key = `page:slug:${slug}:${options?.locale ?? ""}:${resolveKey}:${depthKey}:${fieldsKey}`; const fetchFn = async () => { const base = getBaseUrl(); const trySlug = async (s: string): Promise => { @@ -254,6 +320,10 @@ export async function getPageBySlug( if (options?.locale) u.searchParams.set("_locale", options.locale); if (options?.resolve?.length) u.searchParams.set("_resolve", options.resolve.join(",")); + if (typeof options?.depth === "number") + u.searchParams.set("_depth", String(options.depth)); + if (options?.fields?.length) + u.searchParams.set("_fields", options.fields.join(",")); if (options?.preview) u.searchParams.set("preview", options.preview); return fetch(u.toString()); }; @@ -291,6 +361,55 @@ export async function getPageSlugs(): Promise { return items.map((p) => normalizePageSlug(p.slug ?? p._slug)).filter(Boolean); } +/** Magere Page-Stub-Daten für Navigation/Slug-Maps — nur Label-Felder, kein Body. */ +export type PageStub = Pick< + PageEntry, + "_slug" | "slug" | "linkName" | "name" | "headline" +>; + +/** + * Page-Liste auf nur die Felder reduziert, die für Slug→Label/Slug→URL-Maps + * gebraucht werden. Spart Body/Rows — typisch Faktor 10-50× kleiner. + */ +export async function getPageStubs(options?: { locale?: string }): Promise { + const locale = options?.locale ?? ""; + const key = `page:stubs:${locale}`; + return cached("page", key, async () => { + const base = getBaseUrl(); + const url = new URL(`${base}/api/content/page`); + url.searchParams.set("_fields", "_slug,slug,linkName,name,headline"); + url.searchParams.set("_per_page", "1000"); + if (locale) url.searchParams.set("_locale", locale); + const res = await fetch(url.toString()); + if (!res.ok) throw new Error(`RustyCMS list page (stubs) failed: ${res.status}`); + const data = (await res.json()) as { items?: PageStub[] }; + return data.items ?? []; + }); +} + +/** Magere Post-Stubs für Navigation/Slug-Maps — kein Body, keine Rows. */ +export type PostStub = Pick< + PostEntry, + "_slug" | "slug" | "linkName" | "headline" +>; + +/** Post-Liste reduziert auf Slug+Label-Felder. */ +export async function getPostStubs(options?: { locale?: string }): Promise { + const locale = options?.locale ?? ""; + const key = `post:stubs:${locale}`; + return cached("post", key, async () => { + const base = getBaseUrl(); + const url = new URL(`${base}/api/content/post`); + url.searchParams.set("_fields", "_slug,slug,linkName,headline"); + url.searchParams.set("_per_page", "1000"); + if (locale) url.searchParams.set("_locale", locale); + const res = await fetch(url.toString()); + if (!res.ok) throw new Error(`RustyCMS list post (stubs) failed: ${res.status}`); + const data = (await res.json()) as { items?: PostStub[] }; + return data.items ?? []; + }); +} + /** Navigation (Single Source of Truth: RustyCMS OpenAPI). */ export type NavigationEntry = components["schemas"]["navigation"]; @@ -439,7 +558,9 @@ export async function getPostBySlug( options?: ContentGetOptions, ): Promise { const resolveKey = options?.resolve?.join(",") ?? ""; - const key = `post:slug:${slug}:${options?.locale ?? ""}:${resolveKey}`; + const fieldsKey = options?.fields?.join(",") ?? ""; + const depthKey = options?.depth ?? ""; + const key = `post:slug:${slug}:${options?.locale ?? ""}:${resolveKey}:${depthKey}:${fieldsKey}`; const fetchFn = async () => { const base = getBaseUrl(); const trySlug = async (s: string): Promise => { @@ -447,6 +568,10 @@ export async function getPostBySlug( if (options?.locale) u.searchParams.set("_locale", options.locale); if (options?.resolve?.length) u.searchParams.set("_resolve", options.resolve.join(",")); + if (typeof options?.depth === "number") + u.searchParams.set("_depth", String(options.depth)); + if (options?.fields?.length) + u.searchParams.set("_fields", options.fields.join(",")); if (options?.preview) u.searchParams.set("preview", options.preview); return fetch(u.toString()); }; @@ -484,12 +609,16 @@ export async function getPostSlugs(): Promise { return items.map((p) => normalizePostSlug(p.slug ?? p._slug)).filter(Boolean); } -/** Optionen für getPosts (Query-Parameter _sort, _order, _resolve). */ +/** Optionen für getPosts (Query-Parameter _sort, _order, _resolve, _depth, _fields). */ export interface GetPostsOptions { _sort?: string; _order?: "asc" | "desc"; /** Referenzen auflösen, z. B. "all" oder ["postImage", "postTag"] */ resolve?: string | string[]; + /** Cap für Resolve-Rekursionstiefe (0..=10). */ + depth?: number; + /** Sparse-Fieldset, z. B. ["headline", "excerpt", "postImage.*", "postTag.name"]. */ + fields?: string[]; } /** Alle Post-Einträge (GET /api/content/post). Gecacht pro Request. */ @@ -500,7 +629,9 @@ export async function getPosts( const resolveVal = Array.isArray(opts.resolve) ? opts.resolve.join(",") : (opts.resolve ?? ""); - const key = `post:list:${opts._sort ?? ""}:${opts._order ?? ""}:${resolveVal}`; + const fieldsVal = opts.fields?.join(",") ?? ""; + const depthVal = opts.depth ?? ""; + const key = `post:list:${opts._sort ?? ""}:${opts._order ?? ""}:${resolveVal}:${depthVal}:${fieldsVal}`; return cached("post", key, async () => { const base = getBaseUrl(); const url = new URL(`${base}/api/content/post`); @@ -508,6 +639,8 @@ export async function getPosts( if (opts._sort) url.searchParams.set("_sort", opts._sort); if (opts._order) url.searchParams.set("_order", opts._order); if (resolveVal) url.searchParams.set("_resolve", resolveVal); + if (typeof opts.depth === "number") url.searchParams.set("_depth", String(opts.depth)); + if (fieldsVal) url.searchParams.set("_fields", fieldsVal); const res = await fetch(url.toString()); if (!res.ok) throw new Error("RustyCMS list post failed"); const data = (await res.json()) as { items?: PostEntry[] }; diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index b2e16e6..e6da1dd 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -3,14 +3,19 @@ import type { LayoutServerLoad } from './$types'; export const trailingSlash = 'always'; import { + batchFetch, + batchData, getNavigationByKey, NavigationKeys, - getPages, - getPosts, - getFooterBySlug, - getPageConfigBySlug, + getPageStubs, + getPostStubs, +} from '$lib/cms'; +import type { + NavLinkEntry, + NavigationEntry, + FooterEntry, + PageConfigEntry, } from '$lib/cms'; -import type { NavLinkEntry } from '$lib/cms'; import { ensureTransformedImage } from '$lib/rusty-image'; import { DEFAULT_SOCIAL_IMAGE_URL, @@ -45,15 +50,82 @@ function getSlugFromEntry(entry: NavLinkEntry): string { export const load: LayoutServerLoad = async ({ locals }) => { const translations = locals.translations ?? {}; - // Header: Navigation laden + // Layout-Bootstrap: Navigation (header + social), Footer, page_config in + // einem einzigen HTTP-Roundtrip (POST /api/content/_batch). Reduziert von + // 4 sequentiellen Calls auf 1. Page-/Post-Stubs gehen separat (sparse + // _fields), weil sie viel größere Listen sind und einen eigenen TTL-Cache + // verdienen. + let bootstrapNavHeader: NavigationEntry | null = null; + let bootstrapNavSocial: NavigationEntry | null = null; + let bootstrapFooter: FooterEntry | null = null; + let bootstrapConfig: PageConfigEntry | null = null; + try { + const batch = await batchFetch([ + { + id: 'navHeader', + collection: 'navigation', + slug: NavigationKeys.header, + locale: 'de', + resolve: 'links', + }, + { + id: 'navSocial', + collection: 'navigation', + slug: NavigationKeys.socialMedia, + locale: 'de', + resolve: 'all', + }, + { + id: 'footer', + collection: 'footer', + slug: 'footer-main', + locale: 'de', + resolve: 'all', + }, + { + id: 'config', + collection: 'page_config', + slug: 'page-config-default', + locale: 'de', + resolve: 'logo', + }, + ]); + bootstrapNavHeader = batchData(batch.results.navHeader); + bootstrapNavSocial = batchData(batch.results.navSocial); + bootstrapFooter = batchData(batch.results.footer); + bootstrapConfig = batchData(batch.results.config); + } catch { + /* CMS nicht erreichbar — alle Felder bleiben null, Page rendert ohne. */ + } + + // Fallback für config: Slug "default" probieren (legacy), wenn die kanonische + // Variante fehlt. Selten genug für einen weiteren Single-Call. + if (!bootstrapConfig) { + try { + const fallback = await batchFetch([ + { + id: 'config', + collection: 'page_config', + slug: 'default', + locale: 'de', + resolve: 'logo', + }, + ]); + bootstrapConfig = batchData(fallback.results.config); + } catch { + /* optional */ + } + } + + // Header: Navigation aufbereiten (slug→label/href-Maps aus Page-/Post-Stubs) let headerLinks: NavLink[] = []; try { - const nav = await getNavigationByKey(NavigationKeys.header, { - locale: 'de', - resolve: 'links', - }); + const nav = bootstrapNavHeader; const rawLinks = nav?.links ?? []; - const [pages, posts] = await Promise.all([getPages(), getPosts()]); + const [pages, posts] = await Promise.all([ + getPageStubs({ locale: 'de' }), + getPostStubs({ locale: 'de' }), + ]); const slugToLabel = new Map(); const slugToPageHref = new Map(); for (const p of pages) { @@ -123,13 +195,16 @@ export const load: LayoutServerLoad = async ({ locals }) => { // CMS nicht erreichbar } - // Social-Media-Links + // Social-Media-Links (aus Batch übernommen — Fallback falls Batch nichts + // gegeben hat: alter Single-GET mit eigenem TTL-Cache). let socialLinks: SocialLink[] = []; try { - const socialNav = await getNavigationByKey(NavigationKeys.socialMedia, { - locale: 'de', - resolve: 'all', - }); + const socialNav = + bootstrapNavSocial ?? + (await getNavigationByKey(NavigationKeys.socialMedia, { + locale: 'de', + resolve: 'all', + })); const rawSocialLinks = socialNav?.links ?? []; for (const entry of rawSocialLinks) { const asObj = @@ -149,28 +224,14 @@ export const load: LayoutServerLoad = async ({ locals }) => { /* Social-Links optional */ } - // Footer laden (mit Row-Resolve) - let footerData = null; - try { - footerData = await getFooterBySlug('footer-main', { - locale: 'de', - resolve: ['all'], - }); - } catch { - // CMS nicht erreichbar - } + // Footer + page_config kommen direkt aus dem Batch (siehe oben). + const footerData = bootstrapFooter; // Logo und SEO-Templates aus page_config let logoUrl: string | null = null; let logoSvgHtml: string | null = null; - let pageConfig = null; + const pageConfig = bootstrapConfig; try { - pageConfig = - (await getPageConfigBySlug('page-config-default', { - locale: 'de', - resolve: 'logo', - })) ?? - (await getPageConfigBySlug('default', { locale: 'de', resolve: 'logo' })); const rawLogo = pageConfig?.logo; const rawSrc = typeof rawLogo === 'string' diff --git a/src/routes/[...slug]/+page.server.ts b/src/routes/[...slug]/+page.server.ts index 4e58769..9593a92 100644 --- a/src/routes/[...slug]/+page.server.ts +++ b/src/routes/[...slug]/+page.server.ts @@ -38,6 +38,9 @@ export const load: PageServerLoad = async ({ params, locals, fetch, url, setHead const page = await getPageBySlug(slug, { locale: 'de', resolve: PAGE_RESOLVE, + // Hard-cap gegen Cycle-Surprises (page → image → img-Asset reicht in 3 + // Levels). Kein Body-Stripping hier — Page-Detail braucht alle Felder. + depth: 3, preview, }); diff --git a/src/routes/post/[slug]/+page.server.ts b/src/routes/post/[slug]/+page.server.ts index 7452ce8..e39cb33 100644 --- a/src/routes/post/[slug]/+page.server.ts +++ b/src/routes/post/[slug]/+page.server.ts @@ -35,6 +35,8 @@ export const load: PageServerLoad = async ({ params, locals, url, setHeaders }) const post = await getPostBySlug(slug, { locale: 'de', resolve: POST_RESOLVE, + // Hard-cap: post → block → image → img reicht in 3 Levels. + depth: 3, preview, });