4fc541acf9
- +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>
342 lines
11 KiB
TypeScript
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,
|
|
};
|
|
};
|