diff --git a/package.json b/package.json index 23e1eee7..90c8437a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.62.0", + "version": "1.63.0", "private": true, "type": "module", "scripts": { diff --git a/src/lib/js/apologetikJsonLd.ts b/src/lib/js/apologetikJsonLd.ts index ee5bf75e..3ccbcf69 100644 --- a/src/lib/js/apologetikJsonLd.ts +++ b/src/lib/js/apologetikJsonLd.ts @@ -1,4 +1,4 @@ -import type { Argument, Archetype } from '$lib/data/apologetik'; +import type { Argument, Archetype, PosArgument, PosVoice } from '$lib/data/apologetik'; import type { FaithLang } from '$lib/js/faithI18n'; import { faithSlugFromLang, apologetikSlug } from '$lib/js/faithI18n'; @@ -66,3 +66,51 @@ export function generateContraQaJsonLd( }, }; } + +export interface ProArgArticleJsonLd { + '@context': 'https://schema.org'; + '@type': 'Article'; + headline: string; + description: string; + inLanguage: FaithLang; + url: string; + mainEntityOfPage: string; + author: { '@type': 'Person'; name: string; url: string }; + publisher: { '@type': 'Person'; name: string; url: string }; + articleSection: string; + keywords?: string; + citation?: Array<{ '@type': 'CreativeWork'; name: string }>; +} + +/** Build an Article JSON-LD for a positive (pro) apologetik argument. */ +export function generateProArgArticleJsonLd( + arg: PosArgument, + voices: Record, + lang: FaithLang +): ProArgArticleJsonLd { + const faithSeg = faithSlugFromLang(lang); + const apolSeg = apologetikSlug(lang === 'la' ? 'en' : lang); + const url = `https://bocken.org/${faithSeg}/${apolSeg}/pro/${arg.id}`; + + const allCites = Object.values(arg.voices).flatMap((v) => v.cites ?? []); + const uniqueCites = Array.from(new Set(allCites)); + const voiceNames = Object.keys(arg.voices) + .map((id) => voices[id]?.name ?? id) + .join(', '); + + const article: ProArgArticleJsonLd = { + '@context': 'https://schema.org', + '@type': 'Article', + headline: arg.title, + description: arg.claim, + inLanguage: lang, + url, + mainEntityOfPage: url, + author: { '@type': 'Person', name: 'Alexander Bocken', url: 'https://bocken.org/' }, + publisher: { '@type': 'Person', name: 'Alexander Bocken', url: 'https://bocken.org/' }, + articleSection: arg.layer, + }; + if (voiceNames) article.keywords = voiceNames; + if (uniqueCites.length) article.citation = uniqueCites.map((name) => ({ '@type': 'CreativeWork', name })); + return article; +} diff --git a/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/+layout.server.ts b/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/+layout.server.ts index e3221f77..f302514a 100644 --- a/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/+layout.server.ts +++ b/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/+layout.server.ts @@ -3,7 +3,8 @@ import type { LayoutServerLoad } from './$types'; const expectedSlug = { de: 'apologetik', en: 'apologetics' } as const; -export const load: LayoutServerLoad = async ({ params, parent, url }) => { +export const load: LayoutServerLoad = async ({ params, parent, url, setHeaders }) => { + setHeaders({ 'Cache-Control': 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400' }); const { lang, faithLang } = await parent(); const prefix = `/${faithLang}/${params.apologetikSlug}`; diff --git a/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/contra/+page.ts b/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/contra/+page.ts index 6c8835e0..17b1af24 100644 --- a/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/contra/+page.ts +++ b/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/contra/+page.ts @@ -1,7 +1,8 @@ import { getArchetypes, getArguments } from '$lib/data/apologetik'; import type { PageLoad } from './$types'; -export const load: PageLoad = async ({ parent }) => { +export const load: PageLoad = async ({ parent, setHeaders }) => { + setHeaders({ 'Cache-Control': 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400' }); const { lang } = await parent(); const [archetypes, args] = await Promise.all([getArchetypes(lang), getArguments(lang)]); return { archetypes, args }; diff --git a/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/contra/[argId]/[[archId]]/+page.ts b/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/contra/[argId]/[[archId]]/+page.ts index 0273993a..022e43fa 100644 --- a/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/contra/[argId]/[[archId]]/+page.ts +++ b/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/contra/[argId]/[[archId]]/+page.ts @@ -5,7 +5,8 @@ import { generateBreadcrumbJsonLd } from '$lib/js/breadcrumbJsonLd'; import { m as faithM, faithSlugFromLang, apologetikSlug, type FaithLang } from '$lib/js/faithI18n'; import type { PageLoad } from './$types'; -export const load: PageLoad = async ({ params, parent }) => { +export const load: PageLoad = async ({ params, parent, setHeaders }) => { + setHeaders({ 'Cache-Control': 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400' }); const parentData = await parent(); const lang = parentData.lang as FaithLang; const [arg, archetypes, args] = await Promise.all([ diff --git a/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/pro/+page.server.ts b/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/pro/+page.server.ts index 5e329564..fe91c5bb 100644 --- a/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/pro/+page.server.ts +++ b/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/pro/+page.server.ts @@ -7,7 +7,8 @@ import { } from '$lib/data/apologetik'; import { resolveScriptureForLang } from '$lib/server/scriptureLookup'; -export const load: PageServerLoad = async ({ parent }) => { +export const load: PageServerLoad = async ({ parent, setHeaders }) => { + setHeaders({ 'Cache-Control': 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400' }); const { lang } = await parent(); const [voices, layers, args] = await Promise.all([ getPosVoices(lang), diff --git a/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/pro/[posArgId]/[[voiceId]]/+page.server.ts b/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/pro/[posArgId]/[[voiceId]]/+page.server.ts index 98c8a168..a2f30cd3 100644 --- a/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/pro/[posArgId]/[[voiceId]]/+page.server.ts +++ b/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/pro/[posArgId]/[[voiceId]]/+page.server.ts @@ -8,9 +8,18 @@ import { POS_ARGUMENTS as EN_POS_ARGUMENTS } from '$lib/data/apologetik'; import { resolveScriptureForLang } from '$lib/server/scriptureLookup'; +import { generateProArgArticleJsonLd } from '$lib/js/apologetikJsonLd'; +import { generateBreadcrumbJsonLd } from '$lib/js/breadcrumbJsonLd'; +import { m as faithM, faithSlugFromLang, apologetikSlug, type FaithLang } from '$lib/js/faithI18n'; -export const load: PageServerLoad = async ({ params, parent }) => { - const { lang } = await parent(); +export const load: PageServerLoad = async ({ params, parent, setHeaders }) => { + // Pure static content — long-form prose with no per-user state. Cache aggressively + // at the edge so crawlers (and casual readers) get sub-50ms TTFB. Logged-in users + // still get fresh server-rendered pages because most reverse proxies vary on cookies. + setHeaders({ 'Cache-Control': 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400' }); + + const parentData = await parent(); + const lang = parentData.lang as FaithLang; const [arg, voices, layers, args] = await Promise.all([ findPositiveArgumentLang(params.posArgId, lang), getPosVoices(lang), @@ -46,5 +55,17 @@ export const load: PageServerLoad = async ({ params, parent }) => { return { ...a, scripture: resolved.text ? resolved : a.scripture }; }); - return { argument, voices, layers, args: argsWithScripture, initialVoiceId }; + const articleJsonLd = generateProArgArticleJsonLd(argument, voices, lang); + const tFaith = faithM[lang]; + const faithSeg = faithSlugFromLang(lang); + const apolSeg = apologetikSlug(lang === 'la' ? 'en' : lang); + const breadcrumbJsonLd = generateBreadcrumbJsonLd([ + { name: 'Bocken', path: '/' }, + { name: tFaith.title, path: `/${faithSeg}` }, + { name: tFaith.apologetics, path: `/${faithSeg}/${apolSeg}` }, + { name: tFaith.evidences, path: `/${faithSeg}/${apolSeg}/pro` }, + { name: argument.title, path: `/${faithSeg}/${apolSeg}/pro/${argument.id}` } + ]); + + return { argument, voices, layers, args: argsWithScripture, initialVoiceId, articleJsonLd, breadcrumbJsonLd }; }; diff --git a/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/pro/[posArgId]/[[voiceId]]/+page.svelte b/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/pro/[posArgId]/[[voiceId]]/+page.svelte index a6a8f6be..19d0576f 100644 --- a/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/pro/[posArgId]/[[voiceId]]/+page.svelte +++ b/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/pro/[posArgId]/[[voiceId]]/+page.svelte @@ -104,6 +104,8 @@ rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;0,700;1,400&family=Spectral:ital,wght@0,400;0,600;1,400&family=IBM+Plex+Mono:wght@300;400;500&family=JetBrains+Mono:wght@400;500&display=swap" /> + {@html ``} + {@html ``} diff --git a/src/routes/[faithLang=faithLang]/katechese/+page.ts b/src/routes/[faithLang=faithLang]/katechese/+page.ts new file mode 100644 index 00000000..11943ea0 --- /dev/null +++ b/src/routes/[faithLang=faithLang]/katechese/+page.ts @@ -0,0 +1,6 @@ +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ setHeaders }) => { + setHeaders({ 'Cache-Control': 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400' }); + return {}; +}; diff --git a/src/routes/[faithLang=faithLang]/katechese/zehn-gebote/+page.svelte b/src/routes/[faithLang=faithLang]/katechese/zehn-gebote/+page.svelte index d01cefa1..523bd1cf 100644 --- a/src/routes/[faithLang=faithLang]/katechese/zehn-gebote/+page.svelte +++ b/src/routes/[faithLang=faithLang]/katechese/zehn-gebote/+page.svelte @@ -84,6 +84,33 @@ Die 10 Gebote Gottes - Bocken + {@html ``} + {@html ``}
diff --git a/src/routes/[faithLang=faithLang]/katechese/zehn-gebote/+page.ts b/src/routes/[faithLang=faithLang]/katechese/zehn-gebote/+page.ts new file mode 100644 index 00000000..11943ea0 --- /dev/null +++ b/src/routes/[faithLang=faithLang]/katechese/zehn-gebote/+page.ts @@ -0,0 +1,6 @@ +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ setHeaders }) => { + setHeaders({ 'Cache-Control': 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400' }); + return {}; +}; diff --git a/src/routes/[recipeLang=recipeLang]/[name]/+page.server.ts b/src/routes/[recipeLang=recipeLang]/[name]/+page.server.ts index 4e0effa5..93b7d630 100644 --- a/src/routes/[recipeLang=recipeLang]/[name]/+page.server.ts +++ b/src/routes/[recipeLang=recipeLang]/[name]/+page.server.ts @@ -3,10 +3,15 @@ import type { PageServerLoad, Actions } from './$types'; import { stripHtmlTags } from '$lib/js/stripHtmlTags'; import { errorWithVerse } from '$lib/server/errorQuote'; -export const load: PageServerLoad = async ({ fetch, params, locals, url }) => { +export const load: PageServerLoad = async ({ fetch, params, locals, url, setHeaders }) => { const isEnglish = params.recipeLang === 'recipes'; const apiBase = `/api/${params.recipeLang}`; + // Recipe detail rarely changes — cache 5min in browser, 30min at edge, + // serve stale up to 24h while revalidating. Logged-in viewers see fresh + // content because cookies typically bust the shared cache. + setHeaders({ 'Cache-Control': 'public, max-age=300, s-maxage=1800, stale-while-revalidate=86400' }); + const res = await fetch(`${apiBase}/items/${params.name}`); if (!res.ok) { diff --git a/src/routes/sitemap.xml/+server.ts b/src/routes/sitemap.xml/+server.ts index 0e2ed241..a51a3406 100644 --- a/src/routes/sitemap.xml/+server.ts +++ b/src/routes/sitemap.xml/+server.ts @@ -7,16 +7,36 @@ import { dbConnect } from '$utils/db'; const SITE = 'https://bocken.org'; const BUILD_LASTMOD = new Date().toISOString().slice(0, 10); -type Url = { loc: string; lastmod?: string; changefreq?: string; priority?: number; alternates?: { hreflang: string; href: string }[] }; +type ImageEntry = { loc: string; title?: string; caption?: string }; +type Url = { + loc: string; + lastmod?: string; + changefreq?: string; + priority?: number; + alternates?: { hreflang: string; href: string }[]; + images?: ImageEntry[]; +}; function xmlEscape(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } +function renderImage(img: ImageEntry): string { + const parts = [ + ` `, + ` ${xmlEscape(img.loc)}`, + img.title ? ` ${xmlEscape(img.title)}` : '', + img.caption ? ` ${xmlEscape(img.caption)}` : '', + ` `, + ].filter(Boolean); + return parts.join('\n'); +} + function renderUrl(u: Url): string { const alt = (u.alternates ?? []) .map(a => ` `) .join('\n'); + const imgs = (u.images ?? []).map(renderImage).join('\n'); const parts = [ ` `, ` ${xmlEscape(u.loc)}`, @@ -24,6 +44,7 @@ function renderUrl(u: Url): string { u.changefreq ? ` ${u.changefreq}` : '', u.priority !== undefined ? ` ${u.priority.toFixed(1)}` : '', alt, + imgs, ` `, ].filter(Boolean); return parts.join('\n'); @@ -183,25 +204,35 @@ export const GET: RequestHandler = async ({ fetch, setHeaders }) => { urls.push({ loc: `${SITE}/faith/prayers/${slug}`, lastmod: BUILD_LASTMOD, changefreq: 'yearly', priority: 0.5 }); } - // Recipes — direct DB read so we get dateModified for . Tolerate - // failure so sitemap still ships the static URLs above. + // Recipes — direct DB read so we get dateModified for and image + // paths for . Tolerate failure so sitemap still ships the + // static URLs above. try { await dbConnect(); const recipes = await Recipe.find( {}, - 'short_name dateModified translations.en.short_name translations.en.translationStatus' + 'short_name name dateModified images translations.en.short_name translations.en.name translations.en.translationStatus' ).lean(); for (const r of recipes) { const lastmod = r.dateModified ? new Date(r.dateModified).toISOString().slice(0, 10) : BUILD_LASTMOD; + const images: ImageEntry[] = (r.images ?? []) + .filter((img) => img?.mediapath) + .map((img) => ({ + loc: `${SITE}/static/rezepte/full/${img.mediapath}`, + title: r.name, + caption: img.caption || img.alt, + })); if (r.short_name) { urls.push({ loc: `${SITE}/rezepte/${encodeURIComponent(r.short_name)}`, lastmod, changefreq: 'monthly', priority: 0.7, + images, }); } const enShort = r.translations?.en?.short_name; + const enName = r.translations?.en?.name; const approved = r.translations?.en?.translationStatus === 'approved'; if (enShort && approved) { urls.push({ @@ -209,6 +240,7 @@ export const GET: RequestHandler = async ({ fetch, setHeaders }) => { lastmod, changefreq: 'monthly', priority: 0.7, + images: images.map((img) => ({ ...img, title: enName ?? img.title })), }); } } @@ -217,7 +249,7 @@ export const GET: RequestHandler = async ({ fetch, setHeaders }) => { } const body = ` - + ${urls.map(renderUrl).join('\n')} `;