feat(seo): image sitemap, Article schemas on apologetik pro + katechese, edge caching

Sitemap now declares the image:image namespace and emits an entry per recipe
photo (loc, title from recipe name, caption from alt text) — Google Image
Search can discover all recipe images directly instead of relying on crawl.

Pro arg pages get Article JSON-LD (headline, claim as description, layer as
articleSection, voice names as keywords, deduped voice cites as citations)
plus BreadcrumbList. Katechese/zehn-gebote gets inline Article + Breadcrumb
with a Thing reference to "Dekalog" and CreativeWork citation of P. Martin
Ramm FSSP's Glaubenskurs.

Static-content load functions now set Cache-Control:
public,max-age=300,s-maxage=3600,stale-while-revalidate=86400 — applied to
apologetik layout, contra index/arg, pro index/arg, and the new katechese
+page.ts files. Recipe detail uses s-maxage=1800. Picked HTTP caching over
SvelteKit prerender to avoid baking session=null into the navbar of routes
shared with the auth-aware faithLang layout.
This commit is contained in:
2026-05-02 22:05:50 +02:00
parent ecbd24d7a4
commit d59cc0a732
13 changed files with 166 additions and 15 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.62.0",
"version": "1.63.0",
"private": true,
"type": "module",
"scripts": {
+49 -1
View File
@@ -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<string, PosVoice>,
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;
}
@@ -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}`;
@@ -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 };
@@ -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([
@@ -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),
@@ -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 };
};
@@ -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 `<script type="application/ld+json">${JSON.stringify(data.articleJsonLd)}</script>`}
{@html `<script type="application/ld+json">${JSON.stringify(data.breadcrumbJsonLd)}</script>`}
</svelte:head>
<ApologetikToc title={tocLabel} items={tocItems} activeId={arg.id} />
@@ -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 {};
};
@@ -84,6 +84,33 @@
<svelte:head>
<title>Die 10 Gebote Gottes - Bocken</title>
<meta name="description" content="Die Zehn Gebote Gottes - Katechese nach P. Martin Ramm FSSP" />
{@html `<script type="application/ld+json">${JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Article',
headline: 'Die Zehn Gebote Gottes',
description: 'Katechese zu den Zehn Geboten Gottes — Aufbereitung des Glaubenskurses (3. Hauptteil) von P. Martin Ramm FSSP.',
inLanguage: 'de',
url: 'https://bocken.org/glaube/katechese/zehn-gebote',
mainEntityOfPage: 'https://bocken.org/glaube/katechese/zehn-gebote',
isAccessibleForFree: true,
articleSection: 'Katechese',
about: { '@type': 'Thing', name: 'Dekalog' },
author: { '@type': 'Person', name: 'Alexander Bocken', url: 'https://bocken.org/' },
publisher: { '@type': 'Person', name: 'Alexander Bocken', url: 'https://bocken.org/' },
citation: [
{ '@type': 'CreativeWork', name: 'Glaubenskurs, 3. Hauptteil', author: { '@type': 'Person', name: 'P. Martin Ramm FSSP' } }
]
})}</script>`}
{@html `<script type="application/ld+json">${JSON.stringify({
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Bocken', item: 'https://bocken.org/' },
{ '@type': 'ListItem', position: 2, name: 'Glaube', item: 'https://bocken.org/glaube' },
{ '@type': 'ListItem', position: 3, name: 'Katechese', item: 'https://bocken.org/glaube/katechese' },
{ '@type': 'ListItem', position: 4, name: 'Die Zehn Gebote Gottes', item: 'https://bocken.org/glaube/katechese/zehn-gebote' }
]
})}</script>`}
</svelte:head>
<div class="page-wrapper">
@@ -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 {};
};
@@ -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) {
+37 -5
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
}
function renderImage(img: ImageEntry): string {
const parts = [
` <image:image>`,
` <image:loc>${xmlEscape(img.loc)}</image:loc>`,
img.title ? ` <image:title>${xmlEscape(img.title)}</image:title>` : '',
img.caption ? ` <image:caption>${xmlEscape(img.caption)}</image:caption>` : '',
` </image:image>`,
].filter(Boolean);
return parts.join('\n');
}
function renderUrl(u: Url): string {
const alt = (u.alternates ?? [])
.map(a => ` <xhtml:link rel="alternate" hreflang="${a.hreflang}" href="${xmlEscape(a.href)}" />`)
.join('\n');
const imgs = (u.images ?? []).map(renderImage).join('\n');
const parts = [
` <url>`,
` <loc>${xmlEscape(u.loc)}</loc>`,
@@ -24,6 +44,7 @@ function renderUrl(u: Url): string {
u.changefreq ? ` <changefreq>${u.changefreq}</changefreq>` : '',
u.priority !== undefined ? ` <priority>${u.priority.toFixed(1)}</priority>` : '',
alt,
imgs,
` </url>`,
].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 <lastmod>. Tolerate
// failure so sitemap still ships the static URLs above.
// Recipes — direct DB read so we get dateModified for <lastmod> and image
// paths for <image:image>. 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 = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
${urls.map(renderUrl).join('\n')}
</urlset>
`;