feat(seo): sitemap, OG/canonical/hreflang, JSON-LD i18n

Add sitemap.xml route enumerating recipes, apologetik args, prayers, and
faith hubs. Drop /static/ from robots.txt — was blocking JSON-LD recipe
images from Google. Add reusable Seo component (OG/Twitter/canonical) and
wire into homepage, faith hub, recipes hub, and apologetik index.

Faith and recipe layouts now emit canonical + hreflang automatically by
swapping known lang slugs; deeper paths whose inner segments aren't safely
translatable (recipe [name], prayer [prayer], apologetik [argId]) are
skipped at the layout and may opt-in per page.

Recipe JSON-LD HowToStep names and baking instructions now resolve via
the recipes i18n table (jsonld_step / jsonld_bake / jsonld_for_duration +
existing at_temp) instead of being hardcoded German — English /recipes/
pages were emitting "Schritt N" in their schema.
This commit is contained in:
2026-05-02 21:32:06 +02:00
parent b10634f831
commit 7e33ea833e
15 changed files with 399 additions and 33 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.60.0",
"version": "1.61.0",
"private": true,
"type": "module",
"scripts": {
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
type Alternate = { hreflang: string; href: string };
interface Props {
title: string;
description?: string;
canonical?: string;
ogImage?: string;
ogImageAlt?: string;
ogType?: 'website' | 'article';
siteName?: string;
lang?: 'de' | 'en' | 'la';
alternates?: Alternate[];
twitterCard?: 'summary' | 'summary_large_image';
}
const {
title,
description,
canonical,
ogImage,
ogImageAlt,
ogType = 'website',
siteName = 'Bocken',
lang,
alternates = [],
twitterCard = 'summary_large_image',
}: Props = $props();
const localeMap = { de: 'de_DE', en: 'en_US', la: 'la' } as const;
</script>
<svelte:head>
<title>{title}</title>
{#if description}<meta name="description" content={description} />{/if}
{#if canonical}<link rel="canonical" href={canonical} />{/if}
<meta property="og:title" content={title} />
{#if description}<meta property="og:description" content={description} />{/if}
<meta property="og:type" content={ogType} />
<meta property="og:site_name" content={siteName} />
{#if canonical}<meta property="og:url" content={canonical} />{/if}
{#if lang}<meta property="og:locale" content={localeMap[lang]} />{/if}
{#if ogImage}
<meta property="og:image" content={ogImage} />
{#if ogImageAlt}<meta property="og:image:alt" content={ogImageAlt} />{/if}
{/if}
<meta name="twitter:card" content={twitterCard} />
<meta name="twitter:title" content={title} />
{#if description}<meta name="twitter:description" content={description} />{/if}
{#if ogImage}<meta name="twitter:image" content={ogImage} />{/if}
{#each alternates as a (a.hreflang)}
<link rel="alternate" hreflang={a.hreflang} href={a.href} />
{/each}
</svelte:head>
+5
View File
@@ -181,6 +181,11 @@ export const de = {
instructions_label: 'Zubereitung',
at_temp: 'bei',
// JSON-LD recipe schema labels (HowToStep names, baking instruction text)
jsonld_step: 'Schritt',
jsonld_bake: 'Backen',
jsonld_for_duration: 'für',
// CreateStepList baking
not_set: 'Nicht gesetzt',
duration: 'Dauer',
+5
View File
@@ -181,6 +181,11 @@ export const en = {
instructions_label: 'Instructions',
at_temp: 'at',
// JSON-LD recipe schema labels (HowToStep names, baking instruction text)
jsonld_step: 'Step',
jsonld_bake: 'Bake',
jsonld_for_duration: 'for',
// CreateStepList baking
not_set: 'Not set',
duration: 'Duration',
+9 -7
View File
@@ -27,6 +27,7 @@ function parseTimeToISO8601(timeString: string | undefined): string | undefined
}
import type { RecipeModelType, NutritionMapping } from '$types/types';
import { m, type RecipesLang } from '$lib/js/recipesI18n';
interface HowToStep {
"@type": "HowToStep";
@@ -62,7 +63,8 @@ type ReferencedNutrition = {
baseMultiplier: number;
};
export function generateRecipeJsonLd(data: RecipeModelType, referencedNutrition?: ReferencedNutrition[]) {
export function generateRecipeJsonLd(data: RecipeModelType, referencedNutrition?: ReferencedNutrition[], lang: RecipesLang = 'de') {
const t = m[lang];
const jsonLd: RecipeJsonLd = {
"@context": "https://schema.org",
"@type": "Recipe",
@@ -126,7 +128,7 @@ export function generateRecipeJsonLd(data: RecipeModelType, referencedNutrition?
for (let i = 0; i < instructionGroup.steps.length; i++) {
jsonLd.recipeInstructions.push({
"@type": "HowToStep",
"name": `Schritt ${i + 1}`,
"name": `${t.jsonld_step} ${i + 1}`,
"text": instructionGroup.steps[i]
});
}
@@ -137,16 +139,16 @@ export function generateRecipeJsonLd(data: RecipeModelType, referencedNutrition?
// Add baking instructions if available
if (data.baking?.temperature || data.baking?.length) {
const bakingText = [
data.baking.temperature ? `bei ${data.baking.temperature}` : '',
data.baking.length ? `für ${data.baking.length}` : '',
data.baking.temperature ? `${t.at_temp} ${data.baking.temperature}` : '',
data.baking.length ? `${t.jsonld_for_duration} ${data.baking.length}` : '',
data.baking.mode || ''
].filter(Boolean).join(' ');
if (bakingText) {
jsonLd.recipeInstructions.push({
"@type": "HowToStep",
"name": "Backen",
"text": `Backen ${bakingText}`
"name": t.jsonld_bake,
"text": `${t.jsonld_bake} ${bakingText}`
});
}
}
+12 -8
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { resolve } from '$app/paths';
import LinksGrid from "$lib/components/LinksGrid.svelte";
import Seo from '$lib/components/Seo.svelte';
import { onMount } from 'svelte';
let { data } = $props();
@@ -110,14 +111,17 @@ section h2{
}
}
</style>
<svelte:head>
<title>Bocken</title>
<meta name="description" content="Die persönliche Website von Alexander Bocken" />
<meta property="og:image" content="https://bocken.org/static/favicon.png" />
<meta property="og:image:secure_url" content="https://bocken.org/favicon.png" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:alt" content="Das Familienwappen simplifiziert" />
</svelte:head>
<Seo
title={isEnglish ? "Alexander Bocken — personal site" : "Alexander Bocken — Persönliche Website"}
description={isEnglish
? "Self-hosted recipe collection, Catholic faith resources, apologetics, prayers, and personal projects by Alexander Bocken."
: "Selbstgehostete Rezeptsammlung, katholische Glaubensinhalte, Apologetik, Gebete und persönliche Projekte von Alexander Bocken."}
canonical="https://bocken.org/"
ogImage="https://bocken.org/static/user/full/alexander.webp"
ogImageAlt={isEnglish ? "Smiling Alexander Bocken" : "Lächelnder Alexander Bocken"}
siteName="Bocken"
lang={isEnglish ? 'en' : 'de'}
/>
<!-- SVG Definitions -->
<svg style="display: none;">
@@ -10,6 +10,43 @@ import { m, prayersSlug as prayersSlugFor, rosarySlug, calendarSlug, apologetikS
/** @typedef {import('$lib/js/faithI18n').FaithLang} FaithLang */
let { data, children } = $props();
const SITE = 'https://bocken.org';
/** Build the alternate URL for `targetLang`, swapping the lang segment + known
* container slugs. Returns null when the route has no equivalent (e.g. katechese
* doesn't exist outside DE; apologetik has no Latin variant). For paths deeper
* than two segments we skip — inner slugs (prayer names, arg ids) aren't
* translated by this generic mapper. Per-page Seo can opt in instead.
* @param {string} currentPath
* @param {FaithLang} targetLang
* @returns {string | null}
*/
function alternatePath(currentPath, targetLang) {
const segs = currentPath.split('/').filter(Boolean);
if (segs.length === 0) return null;
if (segs.length > 2) return null;
segs[0] = faithSlugFromLang(targetLang);
if (segs.length === 2) {
const sec = segs[1];
if (['prayers', 'gebete', 'orationes'].includes(sec)) segs[1] = prayersSlugFor(targetLang);
else if (['rosary', 'rosenkranz', 'rosarium'].includes(sec)) segs[1] = rosarySlug(targetLang);
else if (['calendar', 'kalender', 'calendarium'].includes(sec)) segs[1] = calendarSlug(targetLang);
else if (['apologetik', 'apologetics'].includes(sec)) {
if (targetLang === 'la') return null;
segs[1] = apologetikSlug(targetLang);
} else if (sec === 'katechese') {
if (targetLang !== 'de') return null;
}
// 'angelus' and unknown second segments pass through unchanged.
}
return '/' + segs.join('/');
}
const altDe = $derived(alternatePath(page.url.pathname, 'de'));
const altEn = $derived(alternatePath(page.url.pathname, 'en'));
const altLa = $derived(alternatePath(page.url.pathname, 'la'));
const canonicalPath = $derived(alternatePath(page.url.pathname, /** @type {FaithLang} */ (data.lang)) ?? page.url.pathname);
const lang = $derived(/** @type {FaithLang} */ (data.lang));
const t = $derived(m[lang]);
const eastertide = isEastertide();
@@ -42,6 +79,11 @@ const prayersActive = $derived(isActive(prayersHref) && !isActive(angelusHref));
</script>
<svelte:head>
<link rel="preload" href={asset('/fonts/crosses.woff2')} as="font" type="font/woff2" crossorigin="anonymous">
<link rel="canonical" href={`${SITE}${canonicalPath}`} />
{#if altDe}<link rel="alternate" hreflang="de" href={`${SITE}${altDe}`} />{/if}
{#if altEn}<link rel="alternate" hreflang="en" href={`${SITE}${altEn}`} />{/if}
{#if altLa}<link rel="alternate" hreflang="la" href={`${SITE}${altLa}`} />{/if}
{#if altEn}<link rel="alternate" hreflang="x-default" href={`${SITE}${altEn}`} />{/if}
</svelte:head>
<Header>
{#snippet links()}
@@ -1,6 +1,7 @@
<script>
import { resolve } from '$app/paths';
import LinksGrid from '$lib/components/LinksGrid.svelte';
import Seo from '$lib/components/Seo.svelte';
import { isEastertide } from '$lib/js/easter.svelte';
import { m, prayersSlug as prayersSlugFor, rosarySlug, calendarSlug, apologetikSlug, faithSlugFromLang } from '$lib/js/faithI18n';
/** @typedef {import('$lib/js/faithI18n').FaithLang} FaithLang */
@@ -20,10 +21,11 @@
const eastertide = isEastertide();
</script>
<svelte:head>
<title>{t.title} - Bocken</title>
<meta name="description" content={t.description} />
</svelte:head>
<Seo
title={`${t.title} — Bocken`}
description={t.description}
lang={lang}
/>
<style>
h1{
text-align: center;
@@ -2,6 +2,7 @@
import { resolve } from '$app/paths';
import Shield from '@lucide/svelte/icons/shield';
import Flame from '@lucide/svelte/icons/flame';
import Seo from '$lib/components/Seo.svelte';
let { data } = $props();
const faithLang = $derived(data?.faithLang ?? 'faith');
@@ -44,10 +45,11 @@
);
</script>
<svelte:head>
<title>{t.title} · bocken.org</title>
<meta name="description" content={t.lede} />
</svelte:head>
<Seo
title={`${t.title} · bocken.org`}
description={t.lede}
lang={isGerman ? 'de' : 'en'}
/>
<div class="apologetik-landing">
<section class="page-head">
@@ -79,8 +79,39 @@ function isActive(path) {
// For other paths, check if current path starts with the link path
return currentPath.startsWith(path);
}
const SITE = 'https://bocken.org';
// Language-neutral nav slugs — same path works in both langs.
const NEUTRAL_SLUGS = new Set(['category', 'season', 'tag', 'icon', 'favorites', 'search', 'to-try', 'tips-and-tricks', 'add', 'edit', 'admin', 'administration', 'offline-shell']);
/** Build de/en alternates by swapping the lang segment. Returns null when the
* shape isn't safely translatable (recipe detail [name] differs across langs).
* Recipe detail pages already emit their own hreflang via per-page Seo.
* @param {string} currentPath
* @param {'de' | 'en'} targetLang
* @returns {string | null}
*/
function recipeAltPath(currentPath, targetLang) {
const segs = currentPath.split('/').filter(Boolean);
if (segs.length === 0) return null;
if (segs.length >= 2 && !NEUTRAL_SLUGS.has(segs[1])) return null;
segs[0] = targetLang === 'en' ? 'recipes' : 'rezepte';
return '/' + segs.join('/');
}
const altRecipeDe = $derived(recipeAltPath(page.url.pathname, 'de'));
const altRecipeEn = $derived(recipeAltPath(page.url.pathname, 'en'));
const recipeCanonicalPath = $derived(recipeAltPath(page.url.pathname, /** @type {'de' | 'en'} */ (data.lang)));
</script>
<svelte:head>
{#if recipeCanonicalPath}<link rel="canonical" href={`${SITE}${recipeCanonicalPath}`} />{/if}
{#if altRecipeDe}<link rel="alternate" hreflang="de" href={`${SITE}${altRecipeDe}`} />{/if}
{#if altRecipeEn}<link rel="alternate" hreflang="en" href={`${SITE}${altRecipeEn}`} />{/if}
{#if altRecipeDe}<link rel="alternate" hreflang="x-default" href={`${SITE}${altRecipeDe}`} />{/if}
</svelte:head>
<Header>
{#snippet links()}
<ul class=site_header>
@@ -2,6 +2,7 @@
import { resolve } from '$app/paths';
import type { PageData } from './$types';
import AddButton from '$lib/components/AddButton.svelte';
import Seo from '$lib/components/Seo.svelte';
import CompactCard from '$lib/components/recipes/CompactCard.svelte';
import OfflineSyncBanner from '$lib/components/OfflineSyncBanner.svelte';
import Search from '$lib/components/recipes/Search.svelte';
@@ -350,16 +351,17 @@
}
</style>
<Seo
title={labels.metaTitle}
description={labels.metaDescription}
ogImage="https://bocken.org/static/rezepte/thumb/ragu_aus_rindsrippen.webp"
ogImageAlt={labels.metaAlt}
lang={isEnglish ? 'en' : 'de'}
/>
<svelte:head>
<title>{labels.metaTitle}</title>
<meta name="description" content="{labels.metaDescription}" />
{#if heroRecipe}
<link rel="preload" as="image" href="https://bocken.org/static/rezepte/full/{heroImg}" fetchpriority="high" />
{/if}
<meta property="og:image" content="https://bocken.org/static/rezepte/thumb/ragu_aus_rindsrippen.webp" />
<meta property="og:image:secure_url" content="https://bocken.org/static/rezepte/thumb/ragu_aus_rindsrippen.webp" />
<meta property="og:image:type" content="image/webp" />
<meta property="og:image:alt" content="{labels.metaAlt}" />
</svelte:head>
{#if heroRecipe}
@@ -173,7 +173,7 @@ export const load: PageLoad = async ({ fetch, params, url, data }) => {
}
// Generate JSON-LD server-side
const recipeJsonLd = generateRecipeJsonLd(item);
const recipeJsonLd = generateRecipeJsonLd(item, undefined, isEnglish ? 'en' : 'de');
// For German page: check if English translation exists
// For English page: germanShortName is already in item (from API)
@@ -18,7 +18,8 @@ export const GET: RequestHandler = async ({ params, setHeaders }) => {
}
const referencedNutrition = await resolveReferencedNutrition(recipe.ingredients || [], recipe.nutritionMappings);
const jsonLd = generateRecipeJsonLd(recipe, referencedNutrition);
const lang = params.recipeLang === 'recipes' ? 'en' : 'de';
const jsonLd = generateRecipeJsonLd(recipe, referencedNutrition, lang);
// Set appropriate headers for JSON-LD
setHeaders({
+206
View File
@@ -0,0 +1,206 @@
import type { RequestHandler } from './$types';
import { ARGUMENTS, POS_ARGUMENTS } from '$lib/data/apologetik';
import { validPrayerSlugs } from '$lib/data/prayerSlugs';
const SITE = 'https://bocken.org';
type Url = { loc: string; changefreq?: string; priority?: number; alternates?: { hreflang: string; href: string }[] };
function xmlEscape(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
}
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 parts = [
` <url>`,
` <loc>${xmlEscape(u.loc)}</loc>`,
u.changefreq ? ` <changefreq>${u.changefreq}</changefreq>` : '',
u.priority !== undefined ? ` <priority>${u.priority.toFixed(1)}</priority>` : '',
alt,
` </url>`,
].filter(Boolean);
return parts.join('\n');
}
export const GET: RequestHandler = async ({ fetch, setHeaders }) => {
const urls: Url[] = [];
// Home
urls.push({ loc: `${SITE}/`, changefreq: 'monthly', priority: 1.0 });
// Faith hubs (de/en) — Latin route exists but content sparse, skip.
for (const [de, en] of [
['/glaube', '/faith'],
['/glaube/katechese', '/faith/katechese'],
['/glaube/katechese/zehn-gebote', '/faith/katechese/zehn-gebote'],
['/glaube/angelus', '/faith/angelus'],
]) {
urls.push({
loc: `${SITE}${de}`,
changefreq: 'monthly',
priority: 0.7,
alternates: [
{ hreflang: 'de', href: `${SITE}${de}` },
{ hreflang: 'en', href: `${SITE}${en}` },
{ hreflang: 'x-default', href: `${SITE}${de}` },
],
});
urls.push({
loc: `${SITE}${en}`,
changefreq: 'monthly',
priority: 0.7,
alternates: [
{ hreflang: 'de', href: `${SITE}${de}` },
{ hreflang: 'en', href: `${SITE}${en}` },
{ hreflang: 'x-default', href: `${SITE}${de}` },
],
});
}
// Recipe hubs
for (const [de, en] of [
['/rezepte', '/recipes'],
['/rezepte/tips-and-tricks', '/recipes/tips-and-tricks'],
]) {
urls.push({
loc: `${SITE}${de}`,
changefreq: 'weekly',
priority: 0.8,
alternates: [
{ hreflang: 'de', href: `${SITE}${de}` },
{ hreflang: 'en', href: `${SITE}${en}` },
{ hreflang: 'x-default', href: `${SITE}${de}` },
],
});
urls.push({
loc: `${SITE}${en}`,
changefreq: 'weekly',
priority: 0.8,
alternates: [
{ hreflang: 'de', href: `${SITE}${de}` },
{ hreflang: 'en', href: `${SITE}${en}` },
{ hreflang: 'x-default', href: `${SITE}${de}` },
],
});
}
// Apologetik index
for (const [de, en] of [['/glaube/apologetik', '/faith/apologetics']]) {
urls.push({
loc: `${SITE}${de}`,
changefreq: 'monthly',
priority: 0.7,
alternates: [
{ hreflang: 'de', href: `${SITE}${de}` },
{ hreflang: 'en', href: `${SITE}${en}` },
{ hreflang: 'x-default', href: `${SITE}${en}` },
],
});
urls.push({
loc: `${SITE}${en}`,
changefreq: 'monthly',
priority: 0.7,
alternates: [
{ hreflang: 'de', href: `${SITE}${de}` },
{ hreflang: 'en', href: `${SITE}${en}` },
{ hreflang: 'x-default', href: `${SITE}${en}` },
],
});
}
// Apologetik contra arguments
for (const arg of ARGUMENTS) {
const dePath = `/glaube/apologetik/contra/${arg.id}`;
const enPath = `/faith/apologetics/contra/${arg.id}`;
urls.push({
loc: `${SITE}${dePath}`,
changefreq: 'yearly',
priority: 0.6,
alternates: [
{ hreflang: 'de', href: `${SITE}${dePath}` },
{ hreflang: 'en', href: `${SITE}${enPath}` },
{ hreflang: 'x-default', href: `${SITE}${enPath}` },
],
});
urls.push({
loc: `${SITE}${enPath}`,
changefreq: 'yearly',
priority: 0.6,
alternates: [
{ hreflang: 'de', href: `${SITE}${dePath}` },
{ hreflang: 'en', href: `${SITE}${enPath}` },
{ hreflang: 'x-default', href: `${SITE}${enPath}` },
],
});
}
// Apologetik pro arguments
for (const arg of POS_ARGUMENTS) {
const dePath = `/glaube/apologetik/pro/${arg.id}`;
const enPath = `/faith/apologetics/pro/${arg.id}`;
urls.push({
loc: `${SITE}${dePath}`,
changefreq: 'yearly',
priority: 0.6,
alternates: [
{ hreflang: 'de', href: `${SITE}${dePath}` },
{ hreflang: 'en', href: `${SITE}${enPath}` },
{ hreflang: 'x-default', href: `${SITE}${enPath}` },
],
});
urls.push({
loc: `${SITE}${enPath}`,
changefreq: 'yearly',
priority: 0.6,
alternates: [
{ hreflang: 'de', href: `${SITE}${dePath}` },
{ hreflang: 'en', href: `${SITE}${enPath}` },
{ hreflang: 'x-default', href: `${SITE}${enPath}` },
],
});
}
// Prayers — slugs include both de+en variants in one set; emit each as its own URL.
for (const slug of validPrayerSlugs) {
urls.push({ loc: `${SITE}/glaube/gebete/${slug}`, changefreq: 'yearly', priority: 0.5 });
urls.push({ loc: `${SITE}/faith/prayers/${slug}`, changefreq: 'yearly', priority: 0.5 });
}
// Recipes — fetch from internal API; tolerate failure so sitemap still ships static URLs.
try {
const [deRes, enRes] = await Promise.all([
fetch('/api/rezepte/items/all_brief'),
fetch('/api/recipes/items/all_brief'),
]);
if (deRes.ok) {
const items: Array<{ short_name?: string }> = await deRes.json();
for (const r of items) {
if (r.short_name) urls.push({ loc: `${SITE}/rezepte/${encodeURIComponent(r.short_name)}`, changefreq: 'monthly', priority: 0.7 });
}
}
if (enRes.ok) {
const items: Array<{ short_name?: string }> = await enRes.json();
for (const r of items) {
if (r.short_name) urls.push({ loc: `${SITE}/recipes/${encodeURIComponent(r.short_name)}`, changefreq: 'monthly', priority: 0.7 });
}
}
} catch (e) {
console.error('[sitemap] recipe fetch failed:', e);
}
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">
${urls.map(renderUrl).join('\n')}
</urlset>
`;
setHeaders({
'Content-Type': 'application/xml; charset=utf-8',
'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400',
});
return new Response(body);
};
+8 -1
View File
@@ -2,4 +2,11 @@ User-agent: GPTBot
Disallow: /
User-agent: *
Disallow: /static/
Disallow: /api/
Disallow: /login
Disallow: /logout
Disallow: /tasks
Disallow: /settings
Disallow: /register
Sitemap: https://bocken.org/sitemap.xml