ef163fb336
Adds AnnouncementBanner component fed by a new top_banner CMS entry (site-announcement). Extended top_banner schema and TS types with optional link + linkLabel fields. Rendered inline-markdown text. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
359 lines
14 KiB
Svelte
359 lines
14 KiB
Svelte
<script lang="ts">
|
|
import '../app.css';
|
|
import '$lib/iconify-offline';
|
|
import { page } from '$app/stores';
|
|
import Header from '$lib/components/Header.svelte';
|
|
import HeaderOverlay from '$lib/components/HeaderOverlay.svelte';
|
|
import Footer from '$lib/components/Footer.svelte';
|
|
import TopBanner from '$lib/components/TopBanner.svelte';
|
|
import AnnouncementBanner from '$lib/components/AnnouncementBanner.svelte';
|
|
import TranslationProvider from '$lib/components/TranslationProvider.svelte';
|
|
import Breadcrumbs from '$lib/ui/Breadcrumbs.svelte';
|
|
import type { LayoutData } from './$types';
|
|
import { ensureTransformedImage } from '$lib/rusty-image';
|
|
import { DEFAULT_SOCIAL_IMAGE_URL, SOCIAL_IMAGE_DIMENSIONS } from '$lib/constants';
|
|
import { onMount } from 'svelte';
|
|
import { invalidateAll } from '$app/navigation';
|
|
|
|
let { data, children }: { data: LayoutData; children: import('svelte').Snippet } = $props();
|
|
|
|
/**
|
|
* Embed-Routen (z.B. /widget/...) liegen in der `(embed)`-Route-Group und
|
|
* sollen ohne Site-Chrome (Header, Footer, TopBanner, Breadcrumbs, Skip-Link,
|
|
* Preview-Banner) gerendert werden — auch SEO-Tags ausser noindex sind irrelevant,
|
|
* weil das Widget per `<iframe>` eingebettet wird.
|
|
*/
|
|
const isEmbed = $derived(($page.route.id ?? '').startsWith('/(embed)'));
|
|
|
|
// Live-preview reload bridge: when the page was loaded with
|
|
// `?preview_draft=...`, accept `rustycms:reload` postMessage events from the
|
|
// admin iframe parent and re-run the SvelteKit `load()` so the iframe
|
|
// reflects the latest in-memory draft. We re-run instead of `location.reload()`
|
|
// to keep scroll position and avoid the full document re-mount cost.
|
|
onMount(() => {
|
|
if (typeof window === 'undefined') return;
|
|
const isPreviewDraft = window.location.search.includes('preview_draft=');
|
|
if (!isPreviewDraft) return;
|
|
const allowed = (import.meta.env.PUBLIC_PREVIEW_PARENT_ORIGIN ?? '').toString().trim();
|
|
const handler = (event: MessageEvent) => {
|
|
if (allowed && event.origin !== allowed) return;
|
|
const data = event.data;
|
|
if (!data) return;
|
|
if (data === 'rustycms:reload' || (typeof data === 'object' && data.type === 'rustycms:reload')) {
|
|
void invalidateAll();
|
|
}
|
|
};
|
|
window.addEventListener('message', handler);
|
|
return () => window.removeEventListener('message', handler);
|
|
});
|
|
|
|
// Per-page SEO data from $page.data
|
|
const pageData = $derived($page.data as {
|
|
seoTitle?: string;
|
|
seoDescription?: string;
|
|
socialImage?: string;
|
|
robots?: string;
|
|
keywords?: string;
|
|
preview?: boolean;
|
|
previewDraft?: boolean;
|
|
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
|
|
breadcrumbItems?: { href?: string; label: string }[];
|
|
topBanner?: {
|
|
banner: unknown;
|
|
resolvedImages: string[];
|
|
headline?: string;
|
|
subheadline?: string;
|
|
responsive?: {
|
|
sources: { type: string; srcset: string }[];
|
|
fallback: string;
|
|
width: number;
|
|
height: number;
|
|
lqip?: string;
|
|
} | null;
|
|
focalCss?: string | null;
|
|
} | null;
|
|
});
|
|
|
|
const baseUrl = $derived(data.siteBaseUrl ?? '');
|
|
const canonical = $derived(`${baseUrl}${$page.url.pathname}`);
|
|
|
|
function applyTemplate(tpl: string | null | undefined, value: string): string {
|
|
if (!tpl) return value;
|
|
return tpl.replace(/\$1/g, value);
|
|
}
|
|
|
|
const rawSeoTitle = $derived(pageData?.seoTitle ?? data.siteName);
|
|
const rawSeoDescription = $derived(pageData?.seoDescription ?? '');
|
|
const seoTitle = $derived(applyTemplate(data.seoTitleTemplate, rawSeoTitle));
|
|
const seoDescription = $derived(applyTemplate(data.seoDescriptionTemplate, rawSeoDescription));
|
|
|
|
const socialImageUrl = $derived.by(() => {
|
|
const raw = pageData?.socialImage ?? data.logoSocialImage ?? data.logoUrl ?? DEFAULT_SOCIAL_IMAGE_URL;
|
|
if (!raw) return '';
|
|
if (raw.startsWith('//')) return `https:${raw}`;
|
|
if (raw.startsWith('http://') || raw.startsWith('https://')) return raw;
|
|
if (raw.startsWith('/')) return `${baseUrl}${raw}`;
|
|
return raw;
|
|
});
|
|
|
|
const breadcrumbItems = $derived(pageData?.breadcrumbItems ?? []);
|
|
|
|
const robotsContent = $derived.by(() => {
|
|
const base = (pageData?.robots ?? 'index, follow').trim();
|
|
if (/noindex/i.test(base)) return base;
|
|
return `${base}, max-image-preview:large, max-snippet:-1`;
|
|
});
|
|
const topBannerData = $derived(pageData?.topBanner ?? null);
|
|
|
|
const bannerPreload = $derived.by(() => {
|
|
const r = topBannerData?.responsive;
|
|
if (!r) return null;
|
|
const webp = r.sources.find((s) => s.type === 'image/webp');
|
|
const picked = webp ?? r.sources[0];
|
|
if (!picked) return { href: r.fallback, srcset: undefined as string | undefined, type: undefined as string | undefined };
|
|
return { href: r.fallback, srcset: picked.srcset, type: picked.type };
|
|
});
|
|
|
|
function escapeLdJson(s: string): string {
|
|
return s.replace(/<\/script/gi, '<\\/script');
|
|
}
|
|
const jsonLdScript = $derived(
|
|
`<script type="application/ld+json">${escapeLdJson(JSON.stringify({
|
|
'@context': 'https://schema.org',
|
|
'@type': 'WebSite',
|
|
name: data.siteName,
|
|
url: baseUrl,
|
|
inLanguage: 'de-DE',
|
|
publisher: { '@type': 'Organization', name: data.siteName, ...(baseUrl ? { '@id': `${baseUrl}#org` } : {}) },
|
|
}))}<\/script>`,
|
|
);
|
|
const organizationLdScript = $derived.by(() => {
|
|
const sameAs = (data.socialLinks ?? [])
|
|
.map((l) => l.href)
|
|
.filter((h) => /^https?:\/\//.test(h));
|
|
const org: Record<string, unknown> = {
|
|
'@context': 'https://schema.org',
|
|
'@type': 'Organization',
|
|
...(baseUrl ? { '@id': `${baseUrl}#org` } : {}),
|
|
name: data.siteName,
|
|
url: baseUrl,
|
|
...(data.logoSocialImage ? { logo: data.logoSocialImage } : {}),
|
|
...(sameAs.length ? { sameAs } : {}),
|
|
};
|
|
return `<script type="application/ld+json">${escapeLdJson(JSON.stringify(org))}<\/script>`;
|
|
});
|
|
const pageJsonLdScripts = $derived.by(() => {
|
|
const raw = pageData?.jsonLd;
|
|
if (!raw) return '';
|
|
const items = Array.isArray(raw) ? raw : [raw];
|
|
return items
|
|
.map(
|
|
(o) =>
|
|
`<script type="application/ld+json">${escapeLdJson(JSON.stringify(o))}<\/script>`,
|
|
)
|
|
.join('');
|
|
});
|
|
const breadcrumbJsonLdScript = $derived.by(() => {
|
|
const items = pageData?.breadcrumbItems ?? [];
|
|
if (items.length < 2) return '';
|
|
const ld = {
|
|
'@context': 'https://schema.org',
|
|
'@type': 'BreadcrumbList',
|
|
itemListElement: items.map((item, i) => ({
|
|
'@type': 'ListItem',
|
|
position: i + 1,
|
|
name: item.label,
|
|
...(item.href
|
|
? {
|
|
item: item.href.startsWith('http')
|
|
? item.href
|
|
: `${baseUrl}${item.href}`,
|
|
}
|
|
: {}),
|
|
})),
|
|
};
|
|
return `<script type="application/ld+json">${escapeLdJson(JSON.stringify(ld))}<\/script>`;
|
|
});
|
|
|
|
const formbricksAppUrl = String(import.meta.env.PUBLIC_FORMBRICKS_APP_URL ?? '').replace(/\/$/, '');
|
|
const formbricksEnvId = String(import.meta.env.PUBLIC_FORMBRICKS_ENVIRONMENT_ID ?? '').trim();
|
|
const formbricksScript =
|
|
formbricksAppUrl && formbricksEnvId
|
|
? `<script>(function(){var appUrl=${JSON.stringify(formbricksAppUrl)};var environmentId=${JSON.stringify(formbricksEnvId)};if(!appUrl||!environmentId)return;var t=document.createElement('script');t.type='text/javascript';t.async=true;t.src=appUrl+'/js/formbricks.umd.cjs';var e=document.getElementsByTagName('script')[0];if(e&&e.parentNode)e.parentNode.insertBefore(t,e);setTimeout(function(){if(window.formbricks&&typeof window.formbricks.setup==='function'){window.formbricks.setup({environmentId:environmentId,appUrl:appUrl});}},500);})();<\/script>`
|
|
: '';
|
|
</script>
|
|
|
|
<svelte:head>
|
|
{#if isEmbed}
|
|
<!-- Embed-Modus: minimaler Head, kein OG/JSON-LD/Analytics. Page setzt
|
|
eigenen <title> und robots-meta. -->
|
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
{:else}
|
|
<meta name="theme-color" content="#ffffff" />
|
|
<meta name="robots" content={robotsContent} />
|
|
{#if bannerPreload}
|
|
<link
|
|
rel="preload"
|
|
as="image"
|
|
href={bannerPreload.href}
|
|
imagesrcset={bannerPreload.srcset}
|
|
imagesizes="100vw"
|
|
type={bannerPreload.type}
|
|
fetchpriority="high"
|
|
/>
|
|
{/if}
|
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
<link rel="alternate" type="application/rss+xml" title="Beiträge" href="/posts/rss.xml" />
|
|
<link rel="sitemap" href="/sitemap.xml" />
|
|
<link rel="icon" href="/favicons/favicon.ico" sizes="any" />
|
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicons/favicon-16x16.png" />
|
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicons/favicon-32x32.png" />
|
|
<link rel="icon" type="image/png" sizes="96x96" href="/favicons/favicon-96x96.png" />
|
|
<link rel="icon" type="image/png" sizes="192x192" href="/favicons/android-icon-192x192.png" />
|
|
<link rel="apple-touch-icon" sizes="57x57" href="/favicons/apple-icon-57x57.png" />
|
|
<link rel="apple-touch-icon" sizes="60x60" href="/favicons/apple-icon-60x60.png" />
|
|
<link rel="apple-touch-icon" sizes="72x72" href="/favicons/apple-icon-72x72.png" />
|
|
<link rel="apple-touch-icon" sizes="76x76" href="/favicons/apple-icon-76x76.png" />
|
|
<link rel="apple-touch-icon" sizes="114x114" href="/favicons/apple-icon-114x114.png" />
|
|
<link rel="apple-touch-icon" sizes="120x120" href="/favicons/apple-icon-120x120.png" />
|
|
<link rel="apple-touch-icon" sizes="144x144" href="/favicons/apple-icon-144x144.png" />
|
|
<link rel="apple-touch-icon" sizes="152x152" href="/favicons/apple-icon-152x152.png" />
|
|
<link rel="apple-touch-icon" sizes="180x180" href="/favicons/apple-icon-180x180.png" />
|
|
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#2d7a45" />
|
|
<meta name="msapplication-TileColor" content="#ffffff" />
|
|
<meta name="msapplication-TileImage" content="/favicons/ms-icon-144x144.png" />
|
|
<title>{seoTitle}</title>
|
|
{#if seoDescription}
|
|
<meta name="description" content={seoDescription} />
|
|
{/if}
|
|
{#if pageData?.keywords}
|
|
<meta name="keywords" content={pageData.keywords} />
|
|
{/if}
|
|
<link rel="canonical" href={canonical} />
|
|
<!-- Open Graph -->
|
|
<meta property="og:type" content="website" />
|
|
<meta property="og:url" content={canonical} />
|
|
<meta property="og:title" content={seoTitle} />
|
|
{#if seoDescription}
|
|
<meta property="og:description" content={seoDescription} />
|
|
{/if}
|
|
{#if socialImageUrl}
|
|
<meta property="og:image" content={socialImageUrl} />
|
|
<meta property="og:image:width" content={String(SOCIAL_IMAGE_DIMENSIONS.width)} />
|
|
<meta property="og:image:height" content={String(SOCIAL_IMAGE_DIMENSIONS.height)} />
|
|
{/if}
|
|
<meta property="og:locale" content="de_DE" />
|
|
<meta property="og:site_name" content={data.siteName} />
|
|
<!-- Twitter -->
|
|
<meta name="twitter:card" content="summary_large_image" />
|
|
<meta name="twitter:url" content={canonical} />
|
|
<meta name="twitter:title" content={seoTitle} />
|
|
{#if seoDescription}
|
|
<meta name="twitter:description" content={seoDescription} />
|
|
{/if}
|
|
{#if socialImageUrl}
|
|
<meta name="twitter:image" content={socialImageUrl} />
|
|
{/if}
|
|
<!-- JSON-LD WebSite + Organization -->
|
|
{@html jsonLdScript}
|
|
{@html organizationLdScript}
|
|
{#if breadcrumbJsonLdScript}
|
|
{@html breadcrumbJsonLdScript}
|
|
{/if}
|
|
{#if pageJsonLdScripts}
|
|
{@html pageJsonLdScripts}
|
|
{/if}
|
|
{#if 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>
|
|
{/if}
|
|
{#if data.analyticsPm86SiteId}
|
|
<script
|
|
defer
|
|
src="https://analytics.pm86.de/api/script.js"
|
|
data-site-id={data.analyticsPm86SiteId}
|
|
></script>
|
|
{/if}
|
|
{#if formbricksScript}
|
|
{@html formbricksScript}
|
|
{/if}
|
|
{/if}
|
|
</svelte:head>
|
|
|
|
<TranslationProvider translations={data.translations}>
|
|
{#if isEmbed}
|
|
{@render children()}
|
|
{:else}
|
|
<a
|
|
href="#main-content"
|
|
class="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[100] focus:bg-white focus:px-3 focus:py-2 focus:shadow focus:outline focus:outline-2 focus:outline-wald-600 focus:rounded-xs"
|
|
>Zum Inhalt springen</a>
|
|
<div class="flex min-h-screen flex-col text-stein-900">
|
|
{#if data.announcementBanner}
|
|
<AnnouncementBanner banner={data.announcementBanner} />
|
|
{/if}
|
|
{#if pageData?.preview || pageData?.previewDraft}
|
|
<div
|
|
class="sticky top-0 z-[90] w-full bg-erde-500 text-black shadow-sm"
|
|
role="status"
|
|
aria-live="polite"
|
|
>
|
|
<div class="container-custom flex items-center justify-between gap-3 py-1.5 text-sm">
|
|
<span class="font-semibold">
|
|
{pageData?.previewDraft ? 'Live-Preview · ungespeicherter Draft' : 'Preview-Modus · Draft sichtbar (nicht indexiert)'}
|
|
</span>
|
|
<a
|
|
href={$page.url.pathname}
|
|
class="underline hover:no-underline"
|
|
rel="nofollow"
|
|
>
|
|
Preview verlassen
|
|
</a>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
<div class="print:hidden">
|
|
<Header
|
|
links={data.headerLinks}
|
|
socialLinks={data.socialLinks}
|
|
logoUrl={data.logoUrl}
|
|
logoSvgHtml={data.logoSvgHtml}
|
|
translations={data.translations}
|
|
/>
|
|
<HeaderOverlay />
|
|
</div>
|
|
|
|
{#if topBannerData}
|
|
<div class="print:hidden">
|
|
<TopBanner
|
|
banner={topBannerData.banner as import('$lib/cms').FullwidthBannerEntry | null}
|
|
resolvedImages={topBannerData.resolvedImages}
|
|
responsive={topBannerData.responsive ?? null}
|
|
focalCss={topBannerData.focalCss ?? null}
|
|
headline={topBannerData.headline}
|
|
subheadline={topBannerData.subheadline}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
|
|
<main id="main-content" tabindex="-1" class="flex-1 min-h-[60em] print:min-h-0">
|
|
<div class="container-custom pb-12 print:pb-0">
|
|
{#if breadcrumbItems.length > 0}
|
|
<div class="print:hidden">
|
|
<Breadcrumbs items={breadcrumbItems} />
|
|
</div>
|
|
{/if}
|
|
{@render children()}
|
|
</div>
|
|
</main>
|
|
|
|
<div class="print:hidden">
|
|
<Footer footer={data.footerData} translations={data.translations} />
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</TranslationProvider>
|