perf(cms): batch layout bootstrap, sparse fieldsets, depth caps
Deploy / verify (push) Successful in 40s
Deploy / deploy (push) Successful in 1m16s

Adopt RustyCMS' new resolve semantics (_depth, _fields,
POST /_batch) where they pay off:

- +layout.server.ts: 4 sequential GETs (navHeader, navSocial,
  footer, page_config) collapsed into one batchFetch() call.
  Legacy "default" page_config slug stays as fallback Single-
  Batch only when the canonical slug is missing.
- New getPageStubs() / getPostStubs() use _fields= so the
  Slug→Label/Slug→URL maps don't pull body/rows. Typically
  10-50× smaller payload, eligible for the same TTL cache.
- loadPostsList() now requests posts with _depth=2 and a
  sparse fieldset that keeps card-level fields plus
  postImage.* and postTag.{name,icon,color,_slug}. Body
  Markdown stays out of the listing entirely.
- Page + post detail routes set _depth=3 as a cycle/blast-radius
  cap (page → block → image → img reaches in three levels).
- cms.ts: ContentGetOptions gains depth/fields, getPageBySlug /
  getPostBySlug / getPosts thread them through (cache key
  extended). New batchFetch() / batchData<T>() helpers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-04-30 22:00:10 +02:00
parent 35f7aa8f90
commit 3bc7273e71
5 changed files with 260 additions and 38 deletions
+94 -33
View File
@@ -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<NavigationEntry>(batch.results.navHeader);
bootstrapNavSocial = batchData<NavigationEntry>(batch.results.navSocial);
bootstrapFooter = batchData<FooterEntry>(batch.results.footer);
bootstrapConfig = batchData<PageConfigEntry>(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<PageConfigEntry>(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<string, string>();
const slugToPageHref = new Map<string, string>();
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'
+3
View File
@@ -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,
});
+2
View File
@@ -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,
});