import type { LayoutServerLoad } from './$types'; export const trailingSlash = 'always'; import { batchFetch, batchData, getNavigationByKey, NavigationKeys, getPageStubs, getPostStubs, } from '$lib/cms'; import type { NavLinkEntry, NavigationEntry, FooterEntry, PageConfigEntry, TopBannerEntry, } from '$lib/cms'; import { ensureTransformedImage } from '$lib/rusty-image'; import { DEFAULT_SOCIAL_IMAGE_URL, ROW_RESOLVE, SITE_NAME, SOCIAL_IMAGE_TRANSFORM, } from '$lib/constants'; import { env } from '$env/dynamic/public'; import { logWarn } from '$lib/log.server'; interface NavLink { href: string; label: string; } interface SocialLink { href: string; icon: string; label?: string; } /** Führenden Slash ignorieren (CMS liefert z. B. "slug": "/about"). */ function normalizeSlug(s: string | undefined): string { return (s ?? '').replace(/^\//, '').trim(); } function getSlugFromEntry(entry: NavLinkEntry): string { if (typeof entry === 'string') return entry; const e = entry as { _slug?: string; slug?: string }; return e.slug ?? e._slug ?? ''; } export const load: LayoutServerLoad = async ({ locals, route }) => { const translations = locals.translations ?? {}; // Embed-Routen ((embed)/...) brauchen kein Site-Chrome. Bootstrap-Batch // wird übersprungen — spart 1 CMS-Roundtrip + Logo-Transform pro Request. // Layout-Komponente rendert über `isEmbed` ohne Header/Footer/Banner. if ((route.id ?? '').startsWith('/(embed)')) { const siteName = (import.meta.env.PUBLIC_SITE_NAME as string | undefined) || SITE_NAME; const siteBaseUrl = (env.PUBLIC_SITE_URL || '').replace(/\/$/, ''); return { headerLinks: [] as NavLink[], socialLinks: [] as SocialLink[], footerData: null as FooterEntry | null, logoUrl: null as string | null, logoSvgHtml: null as string | null, logoSocialImage: null as string | null, siteName, siteBaseUrl, translations, pageConfig: null as PageConfigEntry | null, seoTitleTemplate: null, seoDescriptionTemplate: null, analyticsPm86SiteId: null, }; } // 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; let bootstrapTopBanner: TopBannerEntry | 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', }, { id: 'topBanner', collection: 'top_banner', slug: 'site-announcement', }, ]); bootstrapNavHeader = batchData(batch.results.navHeader); bootstrapNavSocial = batchData(batch.results.navSocial); bootstrapFooter = batchData(batch.results.footer); bootstrapConfig = batchData(batch.results.config); bootstrapTopBanner = batchData(batch.results.topBanner); } catch (err) { /* CMS nicht erreichbar — alle Felder bleiben null, Page rendert ohne. */ logWarn('layout.bootstrap', err); } // 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 (err) { logWarn('layout.config-fallback', err); } } // Header: Navigation aufbereiten (slug→label/href-Maps aus Page-/Post-Stubs) let headerLinks: NavLink[] = []; try { const nav = bootstrapNavHeader; const rawLinks = nav?.links ?? []; 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) { const label = p.linkName ?? p.name ?? p.headline ?? p.slug ?? p._slug ?? ''; const canonicalSlug = normalizeSlug(p.slug ?? p._slug) || 'home'; const keySlug = normalizeSlug(p.slug); const keyUnderscore = normalizeSlug(p._slug); if (keySlug) { slugToLabel.set(keySlug, label); slugToPageHref.set(keySlug, canonicalSlug); } if (keyUnderscore && keyUnderscore !== keySlug) { slugToLabel.set(keyUnderscore, label); slugToPageHref.set(keyUnderscore, canonicalSlug); } } const slugToPostHref = new Map(); for (const p of posts) { const label = p.linkName ?? p.headline ?? p.slug ?? p._slug ?? ''; const urlSlug = normalizeSlug(p.slug ?? p._slug); const canonical = urlSlug ? `/post/${encodeURIComponent(urlSlug)}/` : ''; const keySlug = normalizeSlug(p.slug); const keyUnderscore = normalizeSlug(p._slug); if (keySlug) { slugToLabel.set(keySlug, label); slugToPostHref.set(keySlug, canonical); } if (keyUnderscore && keyUnderscore !== keySlug) { slugToLabel.set(keyUnderscore, label); slugToPostHref.set(keyUnderscore, canonical); } } for (const entry of rawLinks) { const asObj = typeof entry === 'object' && entry !== null ? (entry as { url?: string; linkName?: string; name?: string }) : null; if (asObj?.url) { const href = asObj.url.startsWith('/') || asObj.url.startsWith('http') ? asObj.url : `/${asObj.url.replace(/^\//, '')}`; const label = asObj.linkName ?? asObj.name ?? asObj.url ?? ''; headerLinks.push({ href, label }); continue; } const raw = getSlugFromEntry(entry); const slug = normalizeSlug(raw) || 'home'; const label = slugToLabel.get(slug) ?? slug; const postHref = slugToPostHref.get(slug); const pageSlug = slugToPageHref.get(slug) ?? slug; const hrefSlug = normalizeSlug(pageSlug) || 'home'; const pageHref = hrefSlug === 'home' ? '/' : '/' + hrefSlug .split('/') .filter(Boolean) .map((seg) => encodeURIComponent(seg)) .join('/') + '/'; const href = postHref ?? pageHref; headerLinks.push({ href, label }); } } catch (err) { logWarn('layout.headerLinks', err); } // 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 = bootstrapNavSocial ?? (await getNavigationByKey(NavigationKeys.socialMedia, { locale: 'de', resolve: 'all', })); const rawSocialLinks = socialNav?.links ?? []; for (const entry of rawSocialLinks) { const asObj = typeof entry === 'object' && entry !== null ? (entry as { url?: string; icon?: string; linkName?: string; name?: string }) : null; if (!asObj?.url) continue; const href = asObj.url.startsWith('/') || asObj.url.startsWith('http') ? asObj.url : `/${asObj.url.replace(/^\//, '')}`; const icon = asObj.icon ?? 'mdi:link'; const label = asObj.linkName ?? asObj.name ?? ''; socialLinks.push({ href, icon, label }); } } catch (err) { logWarn('layout.socialLinks', err); } // 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; const pageConfig = bootstrapConfig; try { const rawLogo = pageConfig?.logo; const rawSrc = typeof rawLogo === 'string' ? rawLogo : (rawLogo as { src?: string } | undefined)?.src ?? null; if (rawSrc) { const cmsBase = (env.PUBLIC_CMS_URL || 'http://localhost:3000').replace(/\/$/, ''); // Absolute URL aufbauen const u = rawSrc.startsWith('//') ? `https:${rawSrc}` : rawSrc.startsWith('/') ? `${cmsBase}${rawSrc}` : rawSrc.startsWith('http') ? rawSrc : `${cmsBase}/api/assets/${rawSrc}`; const isSvg = u.split('?')[0].toLowerCase().endsWith('.svg'); if (isSvg) { // SVGs können nicht transformiert werden — direkt verwenden und inline einbetten logoUrl = u; try { const res = await fetch(u); if (res.ok) { const text = (await res.text()).trim(); if (text.includes('