508 lines
17 KiB
Plaintext
508 lines
17 KiB
Plaintext
---
|
||
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>
|