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:
@@ -10,7 +10,7 @@
|
||||
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_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();
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
seoTitle?: string;
|
||||
seoDescription?: string;
|
||||
socialImage?: string;
|
||||
robots?: string;
|
||||
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
|
||||
breadcrumbItems?: { href?: string; label: string }[];
|
||||
topBanner?: {
|
||||
banner: unknown;
|
||||
@@ -59,17 +61,83 @@
|
||||
});
|
||||
|
||||
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">${JSON.stringify({
|
||||
`<script type="application/ld+json">${escapeLdJson(JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: data.siteName,
|
||||
url: baseUrl,
|
||||
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 formbricksEnvId = String(import.meta.env.PUBLIC_FORMBRICKS_ENVIRONMENT_ID ?? '').trim();
|
||||
@@ -81,6 +149,18 @@
|
||||
|
||||
<svelte:head>
|
||||
<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" />
|
||||
@@ -115,8 +195,8 @@
|
||||
{/if}
|
||||
{#if socialImageUrl}
|
||||
<meta property="og:image" content={socialImageUrl} />
|
||||
<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: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} />
|
||||
@@ -130,8 +210,15 @@
|
||||
{#if socialImageUrl}
|
||||
<meta name="twitter:image" content={socialImageUrl} />
|
||||
{/if}
|
||||
<!-- JSON-LD WebSite -->
|
||||
<!-- 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
|
||||
@@ -152,6 +239,10 @@
|
||||
</svelte:head>
|
||||
|
||||
<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">
|
||||
<Header
|
||||
links={data.headerLinks}
|
||||
@@ -173,7 +264,7 @@
|
||||
/>
|
||||
{/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">
|
||||
{#if breadcrumbItems.length > 0}
|
||||
<Breadcrumbs items={breadcrumbItems} />
|
||||
|
||||
@@ -111,5 +111,6 @@ export const load: PageServerLoad = async ({ locals, fetch }) => {
|
||||
? (page.seoTitle ?? page.headline ?? page.linkName ?? siteName)
|
||||
: siteName,
|
||||
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,
|
||||
seoTitle: page.seoTitle ?? page.headline ?? page.linkName ?? slug,
|
||||
seoDescription: page.seoDescription ?? page.subheadline ?? '',
|
||||
robots: (page as { seoMetaRobots?: string }).seoMetaRobots ?? undefined,
|
||||
breadcrumbItems: [
|
||||
{ href: '/', label: 'Start' },
|
||||
{ label: page.headline ?? page.linkName ?? page.slug ?? page._slug ?? slug },
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
getPostImageField,
|
||||
formatPostDate,
|
||||
} 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 { marked } from 'marked';
|
||||
|
||||
@@ -47,6 +48,21 @@ export const load: PageServerLoad = async ({ params, locals, url }) => {
|
||||
})
|
||||
: 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 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 { 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 eventDateRaw = (post as { eventDate?: string }).eventDate ?? null;
|
||||
const eventDateLabel =
|
||||
@@ -104,6 +128,68 @@ export const load: PageServerLoad = async ({ params, locals, url }) => {
|
||||
.replace(/\{\{PAGE_TITLE\}\}/g, post.headline ?? post.linkName ?? slug);
|
||||
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 {
|
||||
post,
|
||||
postImageUrl,
|
||||
@@ -120,9 +206,11 @@ export const load: PageServerLoad = async ({ params, locals, url }) => {
|
||||
mapLinkUrl,
|
||||
commentSlot: showCommentSection ? commentSlot : '',
|
||||
translations,
|
||||
seoTitle: post.seoTitle ?? post.headline ?? post.linkName ?? slug,
|
||||
seoDescription: post.seoDescription ?? post.subheadline ?? post.excerpt ?? '',
|
||||
socialImage: postImageUrl ?? undefined,
|
||||
seoTitle: post.seoTitle ?? postHeadline,
|
||||
seoDescription: postDescription,
|
||||
socialImage: postSocialImageAbs ?? postImageUrl ?? undefined,
|
||||
robots: (post as { seoMetaRobots?: string }).seoMetaRobots ?? undefined,
|
||||
jsonLd,
|
||||
breadcrumbItems: [
|
||||
{ href: '/', label: 'Start' },
|
||||
{ href: '/posts/', label: 'Beiträge' },
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { loadPostsList } from '$lib/blog-utils';
|
||||
import { warmPostCardImages } from '$lib/rusty-image.server';
|
||||
import { t, T } from '$lib/translations';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
export const load: PageServerLoad = async ({ locals, fetch }) => {
|
||||
const translations = locals.translations ?? {};
|
||||
const result = await loadPostsList({ page: 1 });
|
||||
|
||||
warmPostCardImages(
|
||||
result.posts as Array<{ _rawImageUrl?: string; _imageFocal?: { x: number; y: number } | null }>,
|
||||
fetch,
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
translations,
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { loadPostsList } from '$lib/blog-utils';
|
||||
import { warmPostCardImages } from '$lib/rusty-image.server';
|
||||
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 page = Math.max(1, parseInt(params.page, 10) || 1);
|
||||
const result = await loadPostsList({ page });
|
||||
|
||||
warmPostCardImages(
|
||||
result.posts as Array<{ _rawImageUrl?: string; _imageFocal?: { x: number; y: number } | null }>,
|
||||
fetch,
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
translations,
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { loadPostsList } from '$lib/blog-utils';
|
||||
import { warmPostCardImages } from '$lib/rusty-image.server';
|
||||
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 result = await loadPostsList({ tagSlug: params.tag, page: 1 });
|
||||
|
||||
warmPostCardImages(
|
||||
result.posts as Array<{ _rawImageUrl?: string; _imageFocal?: { x: number; y: number } | null }>,
|
||||
fetch,
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
translations,
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { loadPostsList } from '$lib/blog-utils';
|
||||
import { warmPostCardImages } from '$lib/rusty-image.server';
|
||||
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 page = Math.max(1, parseInt(params.page, 10) || 1);
|
||||
const result = await loadPostsList({ tagSlug: params.tag, page });
|
||||
|
||||
warmPostCardImages(
|
||||
result.posts as Array<{ _rawImageUrl?: string; _imageFocal?: { x: number; y: number } | null }>,
|
||||
fetch,
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
translations,
|
||||
|
||||
Reference in New Issue
Block a user