feat: update site URLs and enhance image handling
- Changed site URLs in docker-compose and deployment configurations to remove "www" prefix for consistency. - Improved image handling by introducing warmPostCardImages function for pre-fetching images in posts and tags. - Enhanced PostCard component with responsive image sizes for better performance. - Updated layout to include robots meta tag and JSON-LD structured data for improved SEO. - Added loading state for images in TopBanner component to enhance user experience.
This commit is contained in:
@@ -9,9 +9,9 @@
|
|||||||
# REGISTRY_USER Gitea-Username
|
# REGISTRY_USER Gitea-Username
|
||||||
# REGISTRY_TOKEN Gitea Access Token (Scope: package)
|
# REGISTRY_TOKEN Gitea Access Token (Scope: package)
|
||||||
# PUBLIC_CMS_URL https://cms.pm86.de
|
# PUBLIC_CMS_URL https://cms.pm86.de
|
||||||
# PUBLIC_SITE_URL https://www.windwiderstand.de
|
# PUBLIC_SITE_URL https://windwiderstand.de
|
||||||
# PUBLIC_SITE_NAME Windwiderstand
|
# PUBLIC_SITE_NAME Windwiderstand
|
||||||
# ORIGIN https://www.windwiderstand.de
|
# ORIGIN https://windwiderstand.de
|
||||||
# REVALIDATE_TOKEN openssl rand -hex 32
|
# REVALIDATE_TOKEN openssl rand -hex 32
|
||||||
# PUBLIC_UMAMI_WEBSITE_ID (optional, sonst leer)
|
# PUBLIC_UMAMI_WEBSITE_ID (optional, sonst leer)
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Caddy site block + reload
|
- name: Caddy site block + reload
|
||||||
run: |
|
run: |
|
||||||
ssh vserver 'grep -q "^windwiderstand.de" /opt/caddy/extra-config/Caddyfile || printf "\nwindwiderstand.de, www.windwiderstand.de {\n reverse_proxy windwiderstand:3001\n}\n" >> /opt/caddy/extra-config/Caddyfile'
|
ssh vserver 'grep -q "^windwiderstand.de {" /opt/caddy/extra-config/Caddyfile || printf "\nwindwiderstand.de {\n reverse_proxy windwiderstand:3001\n}\n\nwww.windwiderstand.de {\n redir https://windwiderstand.de{uri} permanent\n}\n" >> /opt/caddy/extra-config/Caddyfile'
|
||||||
ssh vserver "docker exec caddy caddy reload --config /extra-config/Caddyfile"
|
ssh vserver "docker exec caddy caddy reload --config /extra-config/Caddyfile"
|
||||||
|
|
||||||
- name: Cleanup
|
- name: Cleanup
|
||||||
|
|||||||
+2
-2
@@ -5,9 +5,9 @@ services:
|
|||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
environment:
|
environment:
|
||||||
- PUBLIC_CMS_URL=${PUBLIC_CMS_URL:-http://rustycms:3000}
|
- PUBLIC_CMS_URL=${PUBLIC_CMS_URL:-http://rustycms:3000}
|
||||||
- PUBLIC_SITE_URL=${PUBLIC_SITE_URL:-https://www.windwiderstand.de}
|
- PUBLIC_SITE_URL=${PUBLIC_SITE_URL:-https://windwiderstand.de}
|
||||||
- PUBLIC_SITE_NAME=${PUBLIC_SITE_NAME:-Windwiderstand}
|
- PUBLIC_SITE_NAME=${PUBLIC_SITE_NAME:-Windwiderstand}
|
||||||
- ORIGIN=${ORIGIN:-https://www.windwiderstand.de}
|
- ORIGIN=${ORIGIN:-https://windwiderstand.de}
|
||||||
- IMAGE_CACHE_DIR=${IMAGE_CACHE_DIR:-/app/.cache/images}
|
- IMAGE_CACHE_DIR=${IMAGE_CACHE_DIR:-/app/.cache/images}
|
||||||
volumes:
|
volumes:
|
||||||
- image-cache:/app/.cache/images
|
- image-cache:/app/.cache/images
|
||||||
|
|||||||
+7
-3
@@ -1,11 +1,11 @@
|
|||||||
/* Windwiderstand Design System – Fonts */
|
/* Windwiderstand Design System – Fonts
|
||||||
|
Nur erlaubte Gewichte (02-typography.md: 300/400/500/600/700). 800/900 entfernt.
|
||||||
|
@fontsource setzt font-display: swap als Default — Text sichtbar während Font-Load. */
|
||||||
@import "@fontsource/inter/latin-300.css";
|
@import "@fontsource/inter/latin-300.css";
|
||||||
@import "@fontsource/inter/latin-400.css";
|
@import "@fontsource/inter/latin-400.css";
|
||||||
@import "@fontsource/inter/latin-500.css";
|
@import "@fontsource/inter/latin-500.css";
|
||||||
@import "@fontsource/inter/latin-600.css";
|
@import "@fontsource/inter/latin-600.css";
|
||||||
@import "@fontsource/inter/latin-700.css";
|
@import "@fontsource/inter/latin-700.css";
|
||||||
@import "@fontsource/inter/latin-800.css";
|
|
||||||
@import "@fontsource/inter/latin-900.css";
|
|
||||||
@import "@fontsource-variable/lora";
|
@import "@fontsource-variable/lora";
|
||||||
|
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@@ -334,6 +334,10 @@ main ol li {
|
|||||||
@apply mt-4 mb-3;
|
@apply mt-4 mb-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.markdown hr {
|
||||||
|
@apply border-stein-200 border-dotted mb-4;
|
||||||
|
}
|
||||||
|
|
||||||
.markdown li,
|
.markdown li,
|
||||||
.markdown li p {
|
.markdown li p {
|
||||||
@apply mt-0 mb-0;
|
@apply mt-0 mb-0;
|
||||||
|
|||||||
+20
-1
@@ -39,7 +39,15 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
event.locals.translations = {};
|
event.locals.translations = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await resolve(event);
|
const response = await resolve(event, {
|
||||||
|
preload: ({ type, path }) => {
|
||||||
|
if (type === 'js' || type === 'css') return true;
|
||||||
|
if (type === 'font') {
|
||||||
|
return /inter-latin-(400|700)-normal\.[a-f0-9]*\.?woff2$/i.test(path);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
event.request.method === 'GET' &&
|
event.request.method === 'GET' &&
|
||||||
@@ -49,5 +57,16 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
response.headers.set('cache-control', PAGE_CACHE_CONTROL);
|
response.headers.set('cache-control', PAGE_CACHE_CONTROL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.request.method === 'GET' &&
|
||||||
|
!response.headers.has('x-robots-tag') &&
|
||||||
|
(response.headers.get('content-type') ?? '').includes('text/html')
|
||||||
|
) {
|
||||||
|
response.headers.set(
|
||||||
|
'x-robots-tag',
|
||||||
|
'index, follow, max-image-preview:large, max-snippet:-1',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
width={400}
|
width={400}
|
||||||
aspect="3/2"
|
aspect="3/2"
|
||||||
alt={post.headline ?? ""}
|
alt={post.headline ?? ""}
|
||||||
|
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
|
||||||
class="w-full h-full object-cover"
|
class="w-full h-full object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -80,6 +80,19 @@
|
|||||||
const hasPageTitle = $derived((headline != null && headline !== "") || (subheadline != null && subheadline !== ""));
|
const hasPageTitle = $derived((headline != null && headline !== "") || (subheadline != null && subheadline !== ""));
|
||||||
const showBanner = $derived(hasImage || hasText || hasPageTitle);
|
const showBanner = $derived(hasImage || hasText || hasPageTitle);
|
||||||
const altText = $derived(banner?.headline ?? headline ?? "");
|
const altText = $derived(banner?.headline ?? headline ?? "");
|
||||||
|
|
||||||
|
let imgEl = $state<HTMLImageElement | null>(null);
|
||||||
|
let imgLoaded = $state(false);
|
||||||
|
|
||||||
|
const currentSrc = $derived(responsive?.fallback ?? resolvedImages[0] ?? "");
|
||||||
|
$effect(() => {
|
||||||
|
// reset on src change, then probe cache
|
||||||
|
void currentSrc;
|
||||||
|
imgLoaded = false;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (imgEl?.complete && imgEl.naturalWidth > 0) imgLoaded = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if showBanner}
|
{#if showBanner}
|
||||||
@@ -98,13 +111,17 @@
|
|||||||
<img
|
<img
|
||||||
src={responsive.fallback}
|
src={responsive.fallback}
|
||||||
alt={altText}
|
alt={altText}
|
||||||
class="w-full object-cover max-h-[400px] lg:max-h-[600px] max-w-[1920px] aspect-square sm:aspect-video lg:aspect-24/9 text-transparent"
|
class="w-full object-cover max-h-[400px] lg:max-h-[600px] max-w-[1920px] aspect-square sm:aspect-video lg:aspect-24/9 text-transparent transition-opacity duration-500"
|
||||||
|
class:opacity-0={!imgLoaded}
|
||||||
|
class:opacity-100={imgLoaded}
|
||||||
width={responsive.width}
|
width={responsive.width}
|
||||||
height={responsive.height}
|
height={responsive.height}
|
||||||
style={imgStyle}
|
style={imgStyle}
|
||||||
loading="eager"
|
loading="eager"
|
||||||
fetchpriority="high"
|
fetchpriority="high"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
bind:this={imgEl}
|
||||||
|
onload={() => (imgLoaded = true)}
|
||||||
/>
|
/>
|
||||||
</picture>
|
</picture>
|
||||||
{/key}
|
{/key}
|
||||||
@@ -113,12 +130,15 @@
|
|||||||
<img
|
<img
|
||||||
src={resolvedImages[0]}
|
src={resolvedImages[0]}
|
||||||
alt={altText}
|
alt={altText}
|
||||||
class="w-full object-cover max-h-[400px] lg:max-h-[600px] max-w-[1920px] aspect-square sm:aspect-video lg:aspect-24/9 text-transparent"
|
class="w-full object-cover max-h-[400px] lg:max-h-[600px] max-w-[1920px] aspect-square sm:aspect-video lg:aspect-24/9 text-transparent transition-opacity duration-500"
|
||||||
|
class:opacity-0={!imgLoaded}
|
||||||
|
class:opacity-100={imgLoaded}
|
||||||
width={1920}
|
width={1920}
|
||||||
height={600}
|
height={600}
|
||||||
style={imgStyle}
|
style={imgStyle}
|
||||||
loading="eager"
|
loading="eager"
|
||||||
fetchpriority="high"
|
fetchpriority="high"
|
||||||
|
onload={() => (imgLoaded = true)}
|
||||||
/>
|
/>
|
||||||
{/key}
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
+15
-3
@@ -7,10 +7,22 @@
|
|||||||
export const DEFAULT_SOCIAL_IMAGE_URL =
|
export const DEFAULT_SOCIAL_IMAGE_URL =
|
||||||
"https://images.ctfassets.net/xjxq6v7l1pfe/43yZHpF9OCnrBlEWHJSZMK/1ef20b265ea1e4e55317812ee5ae6b4c/simple-green-tree-illustrated-watercolor-with-gentle-shading-great-rural-scenery-farm-backdrops-naturethemed-designs-landsca.jpg";
|
"https://images.ctfassets.net/xjxq6v7l1pfe/43yZHpF9OCnrBlEWHJSZMK/1ef20b265ea1e4e55317812ee5ae6b4c/simple-green-tree-illustrated-watercolor-with-gentle-shading-great-rural-scenery-farm-backdrops-naturethemed-designs-landsca.jpg";
|
||||||
|
|
||||||
/** Parameter für og:image / twitter:image (über /cms-images + Disk-Cache). */
|
/** og:image-Dimensionen (Facebook/Twitter empfehlen 1200×630, 1.91:1). */
|
||||||
|
export const SOCIAL_IMAGE_DIMENSIONS = {
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Logo-Fallback für og:image: letterboxed (contain), damit Logo komplett sichtbar bleibt. */
|
||||||
export const SOCIAL_IMAGE_TRANSFORM = {
|
export const SOCIAL_IMAGE_TRANSFORM = {
|
||||||
width: 200,
|
...SOCIAL_IMAGE_DIMENSIONS,
|
||||||
height: 200,
|
fit: "contain" as const,
|
||||||
|
format: "webp" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Post-Bild für og:image: cover (Banner-Look), Focal wird separat übergeben. */
|
||||||
|
export const POST_SOCIAL_IMAGE_TRANSFORM = {
|
||||||
|
...SOCIAL_IMAGE_DIMENSIONS,
|
||||||
fit: "cover" as const,
|
fit: "cover" as const,
|
||||||
format: "webp" as const,
|
format: "webp" as const,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -255,3 +255,35 @@ export function warmResponsiveImage(
|
|||||||
void fetchFn(u).catch(() => {});
|
void fetchFn(u).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire-and-forget Warmup für PostCard-Bilder (Grid: 400px base, 2x density, webp+jpeg).
|
||||||
|
* Spiegelt die URLs, die <RustyImage width={400} aspect="3/2"/> zur Laufzeit anfragt.
|
||||||
|
*/
|
||||||
|
export function warmPostCardImages(
|
||||||
|
posts: Array<{ _rawImageUrl?: string; _imageFocal?: { x: number; y: number } | null }>,
|
||||||
|
fetchFn: typeof fetch,
|
||||||
|
limit = 6,
|
||||||
|
): void {
|
||||||
|
const widths = [400, 800];
|
||||||
|
const formats: Array<'webp' | 'jpeg'> = ['webp', 'jpeg'];
|
||||||
|
for (const post of posts.slice(0, limit)) {
|
||||||
|
const raw = post._rawImageUrl;
|
||||||
|
if (!raw) continue;
|
||||||
|
const focal = post._imageFocal ?? undefined;
|
||||||
|
for (const w of widths) {
|
||||||
|
for (const format of formats) {
|
||||||
|
const url = getCmsImageUrl(raw, {
|
||||||
|
width: w,
|
||||||
|
height: Math.round((w / 3) * 2),
|
||||||
|
fit: 'cover',
|
||||||
|
format,
|
||||||
|
quality: 75,
|
||||||
|
ar: '3/2',
|
||||||
|
...(focal ? { focalX: focal.x, focalY: focal.y } : {}),
|
||||||
|
});
|
||||||
|
void fetchFn(url).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
import Breadcrumbs from '$lib/ui/Breadcrumbs.svelte';
|
import Breadcrumbs from '$lib/ui/Breadcrumbs.svelte';
|
||||||
import type { LayoutData } from './$types';
|
import type { LayoutData } from './$types';
|
||||||
import { ensureTransformedImage } from '$lib/rusty-image';
|
import { ensureTransformedImage } from '$lib/rusty-image';
|
||||||
import { DEFAULT_SOCIAL_IMAGE_URL, SOCIAL_IMAGE_TRANSFORM } from '$lib/constants';
|
import { DEFAULT_SOCIAL_IMAGE_URL, SOCIAL_IMAGE_DIMENSIONS } from '$lib/constants';
|
||||||
|
|
||||||
let { data, children }: { data: LayoutData; children: import('svelte').Snippet } = $props();
|
let { data, children }: { data: LayoutData; children: import('svelte').Snippet } = $props();
|
||||||
|
|
||||||
@@ -19,6 +19,8 @@
|
|||||||
seoTitle?: string;
|
seoTitle?: string;
|
||||||
seoDescription?: string;
|
seoDescription?: string;
|
||||||
socialImage?: string;
|
socialImage?: string;
|
||||||
|
robots?: string;
|
||||||
|
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
|
||||||
breadcrumbItems?: { href?: string; label: string }[];
|
breadcrumbItems?: { href?: string; label: string }[];
|
||||||
topBanner?: {
|
topBanner?: {
|
||||||
banner: unknown;
|
banner: unknown;
|
||||||
@@ -59,17 +61,83 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const breadcrumbItems = $derived(pageData?.breadcrumbItems ?? []);
|
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 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(
|
const jsonLdScript = $derived(
|
||||||
`<script type="application/ld+json">${JSON.stringify({
|
`<script type="application/ld+json">${escapeLdJson(JSON.stringify({
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'WebSite',
|
'@type': 'WebSite',
|
||||||
name: data.siteName,
|
name: data.siteName,
|
||||||
url: baseUrl,
|
url: baseUrl,
|
||||||
inLanguage: 'de-DE',
|
inLanguage: 'de-DE',
|
||||||
})}<\/script>`,
|
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 formbricksAppUrl = String(import.meta.env.PUBLIC_FORMBRICKS_APP_URL ?? '').replace(/\/$/, '');
|
||||||
const formbricksEnvId = String(import.meta.env.PUBLIC_FORMBRICKS_ENVIRONMENT_ID ?? '').trim();
|
const formbricksEnvId = String(import.meta.env.PUBLIC_FORMBRICKS_ENVIRONMENT_ID ?? '').trim();
|
||||||
@@ -81,6 +149,18 @@
|
|||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<meta name="theme-color" content="#ffffff" />
|
<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="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="alternate" type="application/rss+xml" title="Beiträge" href="/posts/rss.xml" />
|
<link rel="alternate" type="application/rss+xml" title="Beiträge" href="/posts/rss.xml" />
|
||||||
<link rel="sitemap" href="/sitemap.xml" />
|
<link rel="sitemap" href="/sitemap.xml" />
|
||||||
@@ -115,8 +195,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if socialImageUrl}
|
{#if socialImageUrl}
|
||||||
<meta property="og:image" content={socialImageUrl} />
|
<meta property="og:image" content={socialImageUrl} />
|
||||||
<meta property="og:image:width" content={String(SOCIAL_IMAGE_TRANSFORM.width)} />
|
<meta property="og:image:width" content={String(SOCIAL_IMAGE_DIMENSIONS.width)} />
|
||||||
<meta property="og:image:height" content={String(SOCIAL_IMAGE_TRANSFORM.height)} />
|
<meta property="og:image:height" content={String(SOCIAL_IMAGE_DIMENSIONS.height)} />
|
||||||
{/if}
|
{/if}
|
||||||
<meta property="og:locale" content="de_DE" />
|
<meta property="og:locale" content="de_DE" />
|
||||||
<meta property="og:site_name" content={data.siteName} />
|
<meta property="og:site_name" content={data.siteName} />
|
||||||
@@ -130,8 +210,15 @@
|
|||||||
{#if socialImageUrl}
|
{#if socialImageUrl}
|
||||||
<meta name="twitter:image" content={socialImageUrl} />
|
<meta name="twitter:image" content={socialImageUrl} />
|
||||||
{/if}
|
{/if}
|
||||||
<!-- JSON-LD WebSite -->
|
<!-- JSON-LD WebSite + Organization -->
|
||||||
{@html jsonLdScript}
|
{@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}
|
{#if import.meta.env.PUBLIC_UMAMI_SCRIPT_URL && import.meta.env.PUBLIC_UMAMI_WEBSITE_ID}
|
||||||
<script
|
<script
|
||||||
defer
|
defer
|
||||||
@@ -152,6 +239,10 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<TranslationProvider translations={data.translations}>
|
<TranslationProvider translations={data.translations}>
|
||||||
|
<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-sm"
|
||||||
|
>Zum Inhalt springen</a>
|
||||||
<div class="flex min-h-screen flex-col text-zinc-900">
|
<div class="flex min-h-screen flex-col text-zinc-900">
|
||||||
<Header
|
<Header
|
||||||
links={data.headerLinks}
|
links={data.headerLinks}
|
||||||
@@ -173,7 +264,7 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<main class="flex-1 min-h-[60em]">
|
<main id="main-content" tabindex="-1" class="flex-1 min-h-[60em]">
|
||||||
<div class="container-custom pb-12">
|
<div class="container-custom pb-12">
|
||||||
{#if breadcrumbItems.length > 0}
|
{#if breadcrumbItems.length > 0}
|
||||||
<Breadcrumbs items={breadcrumbItems} />
|
<Breadcrumbs items={breadcrumbItems} />
|
||||||
|
|||||||
@@ -111,5 +111,6 @@ export const load: PageServerLoad = async ({ locals, fetch }) => {
|
|||||||
? (page.seoTitle ?? page.headline ?? page.linkName ?? siteName)
|
? (page.seoTitle ?? page.headline ?? page.linkName ?? siteName)
|
||||||
: siteName,
|
: siteName,
|
||||||
seoDescription: page ? (page.seoDescription ?? page.subheadline ?? '') : '',
|
seoDescription: page ? (page.seoDescription ?? page.subheadline ?? '') : '',
|
||||||
|
robots: (page as { seoMetaRobots?: string } | null)?.seoMetaRobots ?? undefined,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export const load: PageServerLoad = async ({ params, locals, fetch }) => {
|
|||||||
translations,
|
translations,
|
||||||
seoTitle: page.seoTitle ?? page.headline ?? page.linkName ?? slug,
|
seoTitle: page.seoTitle ?? page.headline ?? page.linkName ?? slug,
|
||||||
seoDescription: page.seoDescription ?? page.subheadline ?? '',
|
seoDescription: page.seoDescription ?? page.subheadline ?? '',
|
||||||
|
robots: (page as { seoMetaRobots?: string }).seoMetaRobots ?? undefined,
|
||||||
breadcrumbItems: [
|
breadcrumbItems: [
|
||||||
{ href: '/', label: 'Start' },
|
{ href: '/', label: 'Start' },
|
||||||
{ label: page.headline ?? page.linkName ?? page.slug ?? page._slug ?? slug },
|
{ label: page.headline ?? page.linkName ?? page.slug ?? page._slug ?? slug },
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
getPostImageField,
|
getPostImageField,
|
||||||
formatPostDate,
|
formatPostDate,
|
||||||
} from '$lib/blog-utils';
|
} from '$lib/blog-utils';
|
||||||
import { POST_RESOLVE } from '$lib/constants';
|
import { POST_RESOLVE, POST_SOCIAL_IMAGE_TRANSFORM, SITE_NAME } from '$lib/constants';
|
||||||
|
import { env } from '$env/dynamic/public';
|
||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
|
|
||||||
@@ -47,6 +48,21 @@ export const load: PageServerLoad = async ({ params, locals, url }) => {
|
|||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const siteBaseUrl = (env.PUBLIC_SITE_URL || '').replace(/\/$/, '');
|
||||||
|
const canonical = `${siteBaseUrl}/post/${slug}/`;
|
||||||
|
const postSocialImageRel = postImageField
|
||||||
|
? await ensureTransformedImage(postImageField.url, {
|
||||||
|
...POST_SOCIAL_IMAGE_TRANSFORM,
|
||||||
|
focalX: postImageField.focal?.x,
|
||||||
|
focalY: postImageField.focal?.y,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const postSocialImageAbs = postSocialImageRel
|
||||||
|
? postSocialImageRel.startsWith('/')
|
||||||
|
? `${siteBaseUrl}${postSocialImageRel}`
|
||||||
|
: postSocialImageRel
|
||||||
|
: null;
|
||||||
|
|
||||||
const postDate = formatPostDate(post.created);
|
const postDate = formatPostDate(post.created);
|
||||||
|
|
||||||
const postContent = (post as { content?: string }).content;
|
const postContent = (post as { content?: string }).content;
|
||||||
@@ -63,6 +79,14 @@ export const load: PageServerLoad = async ({ params, locals, url }) => {
|
|||||||
((post as { row2Content?: unknown[] }).row2Content?.length ?? 0) > 0 ||
|
((post as { row2Content?: unknown[] }).row2Content?.length ?? 0) > 0 ||
|
||||||
((post as { row3Content?: unknown[] }).row3Content?.length ?? 0) > 0;
|
((post as { row3Content?: unknown[] }).row3Content?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
const siteName =
|
||||||
|
(import.meta.env.PUBLIC_SITE_NAME as string | undefined) || SITE_NAME;
|
||||||
|
const postCreated = (post as { created?: string }).created ?? null;
|
||||||
|
const postUpdated = (post as { updated?: string }).updated ?? postCreated;
|
||||||
|
const postAuthor = (post as { author?: string }).author ?? null;
|
||||||
|
const postHeadline = post.headline ?? post.linkName ?? slug;
|
||||||
|
const postDescription = post.seoDescription ?? post.subheadline ?? post.excerpt ?? '';
|
||||||
|
|
||||||
const isEvent = !!(post as { isEvent?: boolean }).isEvent;
|
const isEvent = !!(post as { isEvent?: boolean }).isEvent;
|
||||||
const eventDateRaw = (post as { eventDate?: string }).eventDate ?? null;
|
const eventDateRaw = (post as { eventDate?: string }).eventDate ?? null;
|
||||||
const eventDateLabel =
|
const eventDateLabel =
|
||||||
@@ -104,6 +128,68 @@ export const load: PageServerLoad = async ({ params, locals, url }) => {
|
|||||||
.replace(/\{\{PAGE_TITLE\}\}/g, post.headline ?? post.linkName ?? slug);
|
.replace(/\{\{PAGE_TITLE\}\}/g, post.headline ?? post.linkName ?? slug);
|
||||||
const showCommentSection = (post as { showCommentSection?: boolean }).showCommentSection !== false;
|
const showCommentSection = (post as { showCommentSection?: boolean }).showCommentSection !== false;
|
||||||
|
|
||||||
|
const jsonLd: Record<string, unknown>[] = [];
|
||||||
|
const articleLd: Record<string, unknown> = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': isEvent ? 'Article' : 'BlogPosting',
|
||||||
|
headline: postHeadline,
|
||||||
|
mainEntityOfPage: { '@type': 'WebPage', '@id': canonical },
|
||||||
|
url: canonical,
|
||||||
|
inLanguage: 'de-DE',
|
||||||
|
...(postDescription ? { description: postDescription } : {}),
|
||||||
|
...(postCreated ? { datePublished: postCreated } : {}),
|
||||||
|
...(postUpdated ? { dateModified: postUpdated } : {}),
|
||||||
|
...(postSocialImageAbs ? { image: postSocialImageAbs } : {}),
|
||||||
|
author: {
|
||||||
|
'@type': postAuthor ? 'Person' : 'Organization',
|
||||||
|
name: postAuthor ?? siteName,
|
||||||
|
},
|
||||||
|
publisher: {
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: siteName,
|
||||||
|
...(siteBaseUrl ? { url: siteBaseUrl } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
jsonLd.push(articleLd);
|
||||||
|
|
||||||
|
if (isEvent && eventDateRaw) {
|
||||||
|
const eventLd: Record<string, unknown> = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Event',
|
||||||
|
name: postHeadline,
|
||||||
|
startDate: eventDateRaw,
|
||||||
|
eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
|
||||||
|
eventStatus: 'https://schema.org/EventScheduled',
|
||||||
|
url: canonical,
|
||||||
|
inLanguage: 'de-DE',
|
||||||
|
...(postDescription ? { description: postDescription } : {}),
|
||||||
|
...(postSocialImageAbs ? { image: postSocialImageAbs } : {}),
|
||||||
|
organizer: {
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: siteName,
|
||||||
|
...(siteBaseUrl ? { url: siteBaseUrl } : {}),
|
||||||
|
},
|
||||||
|
...(eventLocation?.text
|
||||||
|
? {
|
||||||
|
location: {
|
||||||
|
'@type': 'Place',
|
||||||
|
name: eventLocation.text,
|
||||||
|
...(hasCoords
|
||||||
|
? {
|
||||||
|
geo: {
|
||||||
|
'@type': 'GeoCoordinates',
|
||||||
|
latitude: eventLocation.lat,
|
||||||
|
longitude: eventLocation.lng,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
jsonLd.push(eventLd);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
post,
|
post,
|
||||||
postImageUrl,
|
postImageUrl,
|
||||||
@@ -120,9 +206,11 @@ export const load: PageServerLoad = async ({ params, locals, url }) => {
|
|||||||
mapLinkUrl,
|
mapLinkUrl,
|
||||||
commentSlot: showCommentSection ? commentSlot : '',
|
commentSlot: showCommentSection ? commentSlot : '',
|
||||||
translations,
|
translations,
|
||||||
seoTitle: post.seoTitle ?? post.headline ?? post.linkName ?? slug,
|
seoTitle: post.seoTitle ?? postHeadline,
|
||||||
seoDescription: post.seoDescription ?? post.subheadline ?? post.excerpt ?? '',
|
seoDescription: postDescription,
|
||||||
socialImage: postImageUrl ?? undefined,
|
socialImage: postSocialImageAbs ?? postImageUrl ?? undefined,
|
||||||
|
robots: (post as { seoMetaRobots?: string }).seoMetaRobots ?? undefined,
|
||||||
|
jsonLd,
|
||||||
breadcrumbItems: [
|
breadcrumbItems: [
|
||||||
{ href: '/', label: 'Start' },
|
{ href: '/', label: 'Start' },
|
||||||
{ href: '/posts/', label: 'Beiträge' },
|
{ href: '/posts/', label: 'Beiträge' },
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { loadPostsList } from '$lib/blog-utils';
|
import { loadPostsList } from '$lib/blog-utils';
|
||||||
|
import { warmPostCardImages } from '$lib/rusty-image.server';
|
||||||
import { t, T } from '$lib/translations';
|
import { t, T } from '$lib/translations';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals, fetch }) => {
|
||||||
const translations = locals.translations ?? {};
|
const translations = locals.translations ?? {};
|
||||||
const result = await loadPostsList({ page: 1 });
|
const result = await loadPostsList({ page: 1 });
|
||||||
|
|
||||||
|
warmPostCardImages(
|
||||||
|
result.posts as Array<{ _rawImageUrl?: string; _imageFocal?: { x: number; y: number } | null }>,
|
||||||
|
fetch,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
translations,
|
translations,
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { loadPostsList } from '$lib/blog-utils';
|
import { loadPostsList } from '$lib/blog-utils';
|
||||||
|
import { warmPostCardImages } from '$lib/rusty-image.server';
|
||||||
import { t, T } from '$lib/translations';
|
import { t, T } from '$lib/translations';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
export const load: PageServerLoad = async ({ params, locals, fetch }) => {
|
||||||
const translations = locals.translations ?? {};
|
const translations = locals.translations ?? {};
|
||||||
const page = Math.max(1, parseInt(params.page, 10) || 1);
|
const page = Math.max(1, parseInt(params.page, 10) || 1);
|
||||||
const result = await loadPostsList({ page });
|
const result = await loadPostsList({ page });
|
||||||
|
|
||||||
|
warmPostCardImages(
|
||||||
|
result.posts as Array<{ _rawImageUrl?: string; _imageFocal?: { x: number; y: number } | null }>,
|
||||||
|
fetch,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
translations,
|
translations,
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { loadPostsList } from '$lib/blog-utils';
|
import { loadPostsList } from '$lib/blog-utils';
|
||||||
|
import { warmPostCardImages } from '$lib/rusty-image.server';
|
||||||
import { t, T } from '$lib/translations';
|
import { t, T } from '$lib/translations';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
export const load: PageServerLoad = async ({ params, locals, fetch }) => {
|
||||||
const translations = locals.translations ?? {};
|
const translations = locals.translations ?? {};
|
||||||
const result = await loadPostsList({ tagSlug: params.tag, page: 1 });
|
const result = await loadPostsList({ tagSlug: params.tag, page: 1 });
|
||||||
|
|
||||||
|
warmPostCardImages(
|
||||||
|
result.posts as Array<{ _rawImageUrl?: string; _imageFocal?: { x: number; y: number } | null }>,
|
||||||
|
fetch,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
translations,
|
translations,
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { loadPostsList } from '$lib/blog-utils';
|
import { loadPostsList } from '$lib/blog-utils';
|
||||||
|
import { warmPostCardImages } from '$lib/rusty-image.server';
|
||||||
import { t, T } from '$lib/translations';
|
import { t, T } from '$lib/translations';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
export const load: PageServerLoad = async ({ params, locals, fetch }) => {
|
||||||
const translations = locals.translations ?? {};
|
const translations = locals.translations ?? {};
|
||||||
const page = Math.max(1, parseInt(params.page, 10) || 1);
|
const page = Math.max(1, parseInt(params.page, 10) || 1);
|
||||||
const result = await loadPostsList({ tagSlug: params.tag, page });
|
const result = await loadPostsList({ tagSlug: params.tag, page });
|
||||||
|
|
||||||
|
warmPostCardImages(
|
||||||
|
result.posts as Array<{ _rawImageUrl?: string; _imageFocal?: { x: number; y: number } | null }>,
|
||||||
|
fetch,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
translations,
|
translations,
|
||||||
|
|||||||
Reference in New Issue
Block a user