diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 7e6a6ff..6a60b7f 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -9,9 +9,9 @@ # REGISTRY_USER Gitea-Username # REGISTRY_TOKEN Gitea Access Token (Scope: package) # PUBLIC_CMS_URL https://cms.pm86.de -# PUBLIC_SITE_URL https://www.windwiderstand.de +# PUBLIC_SITE_URL https://windwiderstand.de # PUBLIC_SITE_NAME Windwiderstand -# ORIGIN https://www.windwiderstand.de +# ORIGIN https://windwiderstand.de # REVALIDATE_TOKEN openssl rand -hex 32 # PUBLIC_UMAMI_WEBSITE_ID (optional, sonst leer) @@ -95,7 +95,7 @@ jobs: - name: Caddy site block + reload 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" - name: Cleanup diff --git a/docker-compose.yml b/docker-compose.yml index 16c0ecc..a2f82fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,9 +5,9 @@ services: - "3001:3001" environment: - 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} - - ORIGIN=${ORIGIN:-https://www.windwiderstand.de} + - ORIGIN=${ORIGIN:-https://windwiderstand.de} - IMAGE_CACHE_DIR=${IMAGE_CACHE_DIR:-/app/.cache/images} volumes: - image-cache:/app/.cache/images diff --git a/src/app.css b/src/app.css index 1868162..36167a1 100644 --- a/src/app.css +++ b/src/app.css @@ -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-400.css"; @import "@fontsource/inter/latin-500.css"; @import "@fontsource/inter/latin-600.css"; @import "@fontsource/inter/latin-700.css"; -@import "@fontsource/inter/latin-800.css"; -@import "@fontsource/inter/latin-900.css"; @import "@fontsource-variable/lora"; @import "tailwindcss"; @@ -334,6 +334,10 @@ main ol li { @apply mt-4 mb-3; } +.markdown hr { + @apply border-stein-200 border-dotted mb-4; +} + .markdown li, .markdown li p { @apply mt-0 mb-0; diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 3530e45..e440ecf 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -39,7 +39,15 @@ export const handle: Handle = async ({ event, resolve }) => { 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 ( event.request.method === 'GET' && @@ -49,5 +57,16 @@ export const handle: Handle = async ({ event, resolve }) => { 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; }; diff --git a/src/lib/components/PostCard.svelte b/src/lib/components/PostCard.svelte index 8bd6e05..be3666d 100644 --- a/src/lib/components/PostCard.svelte +++ b/src/lib/components/PostCard.svelte @@ -47,6 +47,7 @@ width={400} aspect="3/2" alt={post.headline ?? ""} + sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" class="w-full h-full object-cover" loading="lazy" /> diff --git a/src/lib/components/TopBanner.svelte b/src/lib/components/TopBanner.svelte index cba9cff..0304c2d 100644 --- a/src/lib/components/TopBanner.svelte +++ b/src/lib/components/TopBanner.svelte @@ -80,6 +80,19 @@ const hasPageTitle = $derived((headline != null && headline !== "") || (subheadline != null && subheadline !== "")); const showBanner = $derived(hasImage || hasText || hasPageTitle); const altText = $derived(banner?.headline ?? headline ?? ""); + + let imgEl = $state(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; + }); + }); {#if showBanner} @@ -98,13 +111,17 @@ {altText} (imgLoaded = true)} /> {/key} @@ -113,12 +130,15 @@ {altText} (imgLoaded = true)} /> {/key} {/if} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 15915ff..4a4c8b3 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -7,10 +7,22 @@ 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"; -/** 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 = { - width: 200, - height: 200, + ...SOCIAL_IMAGE_DIMENSIONS, + 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, format: "webp" as const, }; diff --git a/src/lib/rusty-image.server.ts b/src/lib/rusty-image.server.ts index 700295a..43c44e4 100644 --- a/src/lib/rusty-image.server.ts +++ b/src/lib/rusty-image.server.ts @@ -255,3 +255,35 @@ export function warmResponsiveImage( void fetchFn(u).catch(() => {}); } } + +/** + * Fire-and-forget Warmup für PostCard-Bilder (Grid: 400px base, 2x density, webp+jpeg). + * Spiegelt die URLs, die 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(() => {}); + } + } + } +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 7b6ab56..0d43571 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -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 | Record[]; 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( - `