Files
windwiderstand.de/src/layouts/Layout.astro
T

508 lines
17 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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<string, string> = Astro.locals.translations ?? {};
/** JSON für script type="application/json": </ escapen, damit das Tag nicht geschlossen wird. */
function safeJsonScriptContent(obj: Record<string, string>): 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<string, string>();
/** Für Pages: Nav-Key (_slug oder slug) → kanonischer Slug für URL (ohne führenden Slash). */
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);
}
}
/** Für Posts: Nav-Key → kanonische URL /post/{slug} (slug = URL). */
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 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<ReturnType<typeof getFooterBySlug>> = 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<ReturnType<typeof getPageConfigBySlug>> | 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();
---
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<meta name="theme-color" content="#2d7a45" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.svg" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#2d7a45" />
<meta name="msapplication-TileColor" content="#2d7a45" />
<meta name="generator" content={Astro.generator} />
<title>{seoTitle}</title>
{seoDescription && <meta name="description" content={seoDescription} />}
<link rel="canonical" href={canonical} />
<link rel="sitemap" href="/sitemap-index.xml" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:url" content={canonical} />
<meta property="og:title" content={seoTitle} />
{
seoDescription && (
<meta property="og:description" content={seoDescription} />
)
}
{socialImage && <meta property="og:image" content={socialImage} />}
{
socialImage && (
<>
<meta
property="og:image:width"
content={String(SOCIAL_IMAGE_TRANSFORM.width)}
/>
<meta
property="og:image:height"
content={String(SOCIAL_IMAGE_TRANSFORM.height)}
/>
</>
)
}
<meta property="og:locale" content="de_DE" />
<meta property="og:site_name" content={siteName} />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content={canonical} />
<meta name="twitter:title" content={seoTitle} />
{
seoDescription && (
<meta name="twitter:description" content={seoDescription} />
)
}
{socialImage && <meta name="twitter:image" content={socialImage} />}
<!-- JSON-LD WebSite -->
<script type="application/ld+json" set:html={JSON.stringify({
"@context": "https://schema.org",
"@type": "WebSite",
"name": siteName,
"url": baseUrl,
"inLanguage": "de-DE",
})}></script>
<link href="/pagefind/pagefind-ui.css" rel="stylesheet" />
{
import.meta.env.PUBLIC_UMAMI_SCRIPT_URL &&
import.meta.env.PUBLIC_UMAMI_WEBSITE_ID && (
<script
defer
src={import.meta.env.PUBLIC_UMAMI_SCRIPT_URL}
data-website-id={import.meta.env.PUBLIC_UMAMI_WEBSITE_ID}
/>
)
}
<script
src="https://analytics.pm86.de/api/script.js"
data-site-id="ad2858cf2db1"
defer
/>
</head>
<body class="flex min-h-screen flex-col text-zinc-900">
{
import.meta.env.DEV && cmsFromCache && (
<div
class="sticky top-0 z-50 bg-amber-500 text-amber-950 text-center py-1.5 px-2 text-sm font-medium"
role="status"
aria-live="polite"
>
CMS nicht erreichbar Daten werden aus dem Cache (.cache/cms) geladen.
</div>
)
}
<script id="astro-translations" type="application/json" set:html={safeJsonScriptContent(translations)}></script>
<script define:vars={{ translations }}>
try { window.__TRANSLATIONS__ = typeof translations !== "undefined" && translations != null ? translations : {}; } catch (e) {}
</script>
<TranslationProvider translations={translations}>
<Header
links={headerLinks}
socialLinks={socialLinks}
logoUrl={logoUrl}
translations={translations}
client:load
/>
<HeaderOverlay client:load />
{
showBannerInLayout &&
(topBanner != null || topBannerResolvedImages.length > 0) && (
<TopBanner
banner={topBanner}
resolvedImages={topBannerResolvedImages}
headline={headline}
subheadline={subheadline}
/>
)
}
<main class="flex-1 min-h-[60em]">
<div class="container-custom pb-12">
{resolvedBreadcrumbItems.length > 0 && (
<Breadcrumbs items={resolvedBreadcrumbItems} client:load />
)}
{
showTitleInContent &&
(titleHeadlineHtml || titleSubheadlineHtml) && (
<div class="mb-6 pt-10 pageTitle">
{titleHeadlineHtml && (
<h1 class="pageTitle" set:html={titleHeadlineHtml} />
)}
{titleSubheadlineHtml && (
<h2 class="pageTitle" set:html={titleSubheadlineHtml} />
)}
</div>
)
}
<slot />
</div>
</main>
<Footer footer={footerData} translations={translations} />
</TranslationProvider>
<script src="/pagefind/pagefind-ui.js" is:inline></script>
</body>
</html>