--- import "../styles/global.css"; import "../lib/iconify-offline"; import Header from "../components/Header.svelte"; import HeaderOverlay from "../components/HeaderOverlay.svelte"; import Footer from "../components/Footer.svelte"; import TopBanner from "../components/TopBanner.svelte"; import Breadcrumbs from "../lib/ui/Breadcrumbs.svelte"; import { getNavigationByKey, NavigationKeys, getPages, getPosts, getFooterBySlug, getFullwidthBannerBySlug, getPageConfigBySlug, } from "../lib/cms"; import type { NavLinkEntry, FullwidthBannerEntry } from "../lib/cms"; import { resolveImageUrls, ensureTransformedImage } from "../lib/rusty-image"; import { DEFAULT_SOCIAL_IMAGE_URL, ROW_RESOLVE, SITE_NAME, SOCIAL_IMAGE_TRANSFORM, } from "../lib/constants"; import TranslationProvider from "../components/TranslationProvider.svelte"; import { t, T } from "../lib/translations"; import { getCmsFromCache } from "../lib/cms-cache-state"; import type { TranslationKey } from "../lib/translations"; import { marked } from "marked"; marked.setOptions({ gfm: true }); /** Aufgelöster Banner (Objekt mit image[]) oder Slug (string). */ type TopBannerInput = FullwidthBannerEntry | string | null | undefined; function isResolvedBanner(v: TopBannerInput): v is FullwidthBannerEntry { return typeof v === "object" && v !== null && "name" in v; } interface Props { title?: string; description?: string; /** Headline der aktuellen Page (wird im TopBanner angezeigt, falls gepflegt). */ headline?: string; /** Subheadline der aktuellen Page (Markdown). */ subheadline?: string; /** Fullwidth-Banner: aufgelöst (Objekt mit image[]) bei _resolve, sonst Slug. Nur wenn gesetzt → TopBanner anzeigen. */ topFullwidthBanner?: TopBannerInput; /** true = Layout zeigt TopBanner (Titel dort). false = kein TopBanner, Seite zeigt Titel im Content. */ showBannerInLayout?: boolean; /** Optional: absoluter oder relativer URL für og:image / twitter:image (z. B. Post-Bild). */ image?: string | null; /** Optional: Breadcrumb-Items. Letzter Eintrag ohne href = aktuelle Seite. translationKey überschreibt label (übersetzt). */ breadcrumbItems?: { href?: string; label?: string; translationKey?: TranslationKey }[]; } const { title = "RustyAstro", description, headline, subheadline, topFullwidthBanner, showBannerInLayout = false, image: imageProp = null, breadcrumbItems = undefined, } = Astro.props; // Übersetzungen aus Middleware (Astro.locals.translations) – für TranslationProvider und Breadcrumbs const translations: Record = Astro.locals.translations ?? {}; /** JSON für script type="application/json": ): string { return JSON.stringify(obj).replace(/<\//g, "\\u003c/"); } const resolvedBreadcrumbItems = breadcrumbItems?.map((item) => ({ href: item.href, label: item.translationKey != null ? t(translations, item.translationKey) : (item.label ?? ""), })) ?? []; // Header: Navigation laden und Links bauen interface NavLink { href: string; label: string; } let headerLinks: NavLink[] = []; /** 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 ?? ""; } try { const nav = await getNavigationByKey(NavigationKeys.header, { locale: "de", resolve: "links", }); const rawLinks = nav?.links ?? []; const [pages, posts] = await Promise.all([getPages(), getPosts()]); const slugToLabel = new Map(); /** Für Pages: Nav-Key (_slug oder slug) → kanonischer Slug für URL (ohne führenden Slash). */ 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); } } /** Für Posts: Nav-Key → kanonische URL /post/{slug} (slug = URL). */ 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 href = postHref ?? (hrefSlug === "home" ? "/" : `/${encodeURIComponent(hrefSlug)}`); headerLinks.push({ href, label }); } } catch { // CMS nicht erreichbar } // Social-Media-Links aus navigation-social-media (icon = iconify string) interface SocialLink { href: string; icon: string; label?: string; } let socialLinks: SocialLink[] = []; try { const socialNav = 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 { /* Social-Links optional */ } // Footer laden (mit Row-Resolve) let footerData: Awaited> = null; try { footerData = await getFooterBySlug("footer-main", { locale: "de", resolve: ROW_RESOLVE, }); } catch { // CMS nicht erreichbar } // Logo und SEO-Templates aus page_config (wie windwiderstand). let logoUrl: string | null = null; let pageConfig: Awaited> | null = null; try { // Zuerst page-config-default (häufiger Slug), dann default – vermeidet doppelten 404 pageConfig = (await getPageConfigBySlug("page-config-default", { locale: "de", resolve: "logo", })) ?? (await getPageConfigBySlug("default", { locale: "de", resolve: "logo" })); const rawLogo = pageConfig?.logo; const logoFileUrl = typeof rawLogo === "string" && rawLogo.startsWith("http") ? rawLogo : (( rawLogo as | { src?: string; _slug?: string; file?: { url?: string } } | undefined )?.src ?? (rawLogo as { _slug?: string; file?: { url?: string } } | undefined) ?._slug ?? (rawLogo as { file?: { url?: string } } | undefined)?.file?.url); if (logoFileUrl) { const u = logoFileUrl.startsWith("//") ? `https:${logoFileUrl}` : logoFileUrl; logoUrl = await ensureTransformedImage(u, { height: 56, fit: "contain", format: "webp", }); } } catch { /* Logo optional */ } // SEO Title/Description: Page/Post-Wert + page_config-Template ($1 = Platzhalter, wie windwiderstand) let seoTitle = title ?? "RustyAstro"; let seoDescription = description ?? ""; if (pageConfig?.seoTitle) seoTitle = pageConfig.seoTitle.replace(/\$1/g, seoTitle); if (pageConfig?.seoDescription) seoDescription = pageConfig.seoDescription.replace(/\$1/g, seoDescription); // SEO: canonical und Social-Image (nach logoUrl) // Lokale Entwicklung: echte Origin (localhost:4321); sonst PUBLIC_SITE_URL / site aus Config const siteFromEnv = (import.meta.env.SITE || "").replace(/\/$/, ""); const isLocalhost = Astro.url.origin.startsWith("http://localhost") || Astro.url.origin.startsWith("http://127.0.0.1"); const baseUrl = isLocalhost ? Astro.url.origin : siteFromEnv || Astro.url.origin; const pathname = Astro.url.pathname || "/"; const canonical = `${baseUrl}${pathname}`; // Social-Image immer über RustyImage (externe URLs transformieren; lokale Pfade unverändert nutzen). // Fallback: Wenn kein Bild/Logo und Transform fehlschlägt → DEFAULT_SOCIAL_IMAGE_URL (absolut), damit WhatsApp/og immer ein Vorschaubild hat. const socialImageSource = imageProp ?? logoUrl ?? null; const needsTransform = !socialImageSource || socialImageSource.startsWith("http://") || socialImageSource.startsWith("https://"); const urlToTransform = socialImageSource ?? DEFAULT_SOCIAL_IMAGE_URL; let socialImageResolved: string | null = null; if (needsTransform) { try { const transformed = await ensureTransformedImage( urlToTransform, SOCIAL_IMAGE_TRANSFORM, ); socialImageResolved = transformed.startsWith("/") ? `${baseUrl}${transformed}` : transformed; } catch { socialImageResolved = null; } } else { // Bereits lokaler Pfad (z. B. von Post), nur baseUrl davor socialImageResolved = `${baseUrl}${urlToTransform.startsWith("/") ? "" : "/"}${urlToTransform}`; } const socialImage = socialImageResolved ?? (urlToTransform.startsWith("http") ? urlToTransform : ""); const siteName = (import.meta.env.PUBLIC_SITE_NAME as string | undefined) || SITE_NAME; // Top-Banner nur laden, wenn die Seite einen Banner anzeigen soll (showBannerInLayout + topFullwidthBanner) let topBanner: FullwidthBannerEntry | null = null; let topBannerResolvedImages: string[] = []; if ( showBannerInLayout && topFullwidthBanner != null && (typeof topFullwidthBanner === "string" ? topFullwidthBanner !== "" : true) ) { try { if (isResolvedBanner(topFullwidthBanner)) { topBanner = topFullwidthBanner; const bannerImages = Array.isArray(topBanner.image) ? topBanner.image : topBanner.image != null ? [topBanner.image] : []; if (bannerImages.length > 0) { topBannerResolvedImages = await resolveImageUrls(bannerImages, { width: 2000, height: 750, fit: "cover", format: "webp", }); } } else if ( typeof topFullwidthBanner === "string" && topFullwidthBanner !== "" ) { topBanner = await getFullwidthBannerBySlug(topFullwidthBanner, { locale: "de", }); const bannerImages = Array.isArray(topBanner?.image) ? topBanner.image : topBanner?.image != null ? [topBanner.image] : []; if (bannerImages.length > 0) { topBannerResolvedImages = await resolveImageUrls(bannerImages, { width: 2000, height: 750, fit: "cover", format: "webp", }); } } } catch { // CMS nicht erreichbar oder kein Banner } } // Titelblock (Headline/Subheadline als Markdown), wenn kein Banner angezeigt wird const showTitleInContent = !showBannerInLayout && ((headline != null && headline !== "") || (subheadline != null && subheadline !== "")); const titleHeadlineHtml = showTitleInContent && headline?.trim() ? (marked.parseInline(headline) as string) : ""; const titleSubheadlineHtml = showTitleInContent && subheadline?.trim() ? (marked.parseInline(subheadline) as string) : ""; const cmsFromCache = getCmsFromCache(); --- {seoTitle} {seoDescription && } { seoDescription && ( ) } {socialImage && } { socialImage && ( <> ) } { seoDescription && ( ) } {socialImage && } { import.meta.env.PUBLIC_UMAMI_SCRIPT_URL && import.meta.env.PUBLIC_UMAMI_WEBSITE_ID && (
{ showBannerInLayout && (topBanner != null || topBannerResolvedImages.length > 0) && ( ) }
{resolvedBreadcrumbItems.length > 0 && ( )} { showTitleInContent && (titleHeadlineHtml || titleSubheadlineHtml) && (
{titleHeadlineHtml && (

)} {titleSubheadlineHtml && (

)}

) }