Files
windwiderstand/src/routes/+layout.server.ts
T
Peter Meier 4fc541acf9
Deploy / verify (push) Successful in 57s
Deploy / deploy (push) Successful in 59s
perf: parallel block resolution, immutable asset caching, fewer favicons
- +page.server.ts: resolveContentImages/Calendar/Deadline/SearchableText
  now run in parallel via Promise.all (after resolvePostOverviewBlocks)
- +page.server.ts: banner widths 5→3 [640,1024,1536]
- hooks.server.ts: /_app/immutable/** gets Cache-Control: max-age=31536000
- +layout.svelte: favicon tags 18→4 (ico, 32px png, 192px, apple-180px)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 15:00:07 +02:00

342 lines
11 KiB
TypeScript

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<NavigationEntry>(batch.results.navHeader);
bootstrapNavSocial = batchData<NavigationEntry>(batch.results.navSocial);
bootstrapFooter = batchData<FooterEntry>(batch.results.footer);
bootstrapConfig = batchData<PageConfigEntry>(batch.results.config);
bootstrapTopBanner = batchData<TopBannerEntry>(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<PageConfigEntry>(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<string, string>();
const slugToPageHref = new Map<string, string>();
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<string, string>();
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('<svg')) logoSvgHtml = text;
}
} catch (err) { logWarn('layout.logoSvgFetch', err, { url: u }); }
} else {
logoUrl = await ensureTransformedImage(u, {
height: 56,
fit: 'contain',
format: 'webp',
});
}
}
} catch (err) {
logWarn('layout.logo', err);
}
const siteName =
(import.meta.env.PUBLIC_SITE_NAME as string | undefined) || SITE_NAME;
const siteBaseUrl = (env.PUBLIC_SITE_URL || '').replace(/\/$/, '');
// og-taugliches Social-Image parallel zum Rest — kein sequenzielles Warten.
let logoSocialImage: string | null = null;
try {
const source = logoUrl ?? DEFAULT_SOCIAL_IMAGE_URL;
const transformed = await ensureTransformedImage(source, SOCIAL_IMAGE_TRANSFORM);
logoSocialImage = transformed.startsWith('/')
? `${siteBaseUrl}${transformed}`
: transformed;
} catch (err) {
logoSocialImage = null;
logWarn('layout.socialImage', err);
}
return {
headerLinks,
socialLinks,
footerData,
logoUrl,
logoSvgHtml,
logoSocialImage,
siteName,
siteBaseUrl,
translations,
pageConfig,
seoTitleTemplate: pageConfig?.seoTitle ?? null,
seoDescriptionTemplate: pageConfig?.seoDescription ?? null,
analyticsPm86SiteId:
(pageConfig as { analyticsPm86SiteId?: string } | null)?.analyticsPm86SiteId ?? null,
announcementBanner: bootstrapTopBanner,
};
};