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:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.62.0",
|
||||
"version": "1.63.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
+2
-1
@@ -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([
|
||||
|
||||
+2
-1
@@ -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),
|
||||
|
||||
+24
-3
@@ -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 };
|
||||
};
|
||||
|
||||
+2
@@ -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) {
|
||||
|
||||
@@ -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, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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>
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user