From 7e33ea833ef0a25ce6494f03f6238b45e76e685e Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Sat, 2 May 2026 21:32:06 +0200 Subject: [PATCH] feat(seo): sitemap, OG/canonical/hreflang, JSON-LD i18n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- package.json | 2 +- src/lib/components/Seo.svelte | 57 +++++ src/lib/i18n/recipes/de.ts | 5 + src/lib/i18n/recipes/en.ts | 5 + src/lib/js/recipeJsonLd.ts | 16 +- src/routes/(main)/+page.svelte | 20 +- .../[faithLang=faithLang]/+layout.svelte | 42 ++++ src/routes/[faithLang=faithLang]/+page.svelte | 10 +- .../+page.svelte | 10 +- .../[recipeLang=recipeLang]/+layout.svelte | 31 +++ .../[recipeLang=recipeLang]/+page.svelte | 14 +- .../[recipeLang=recipeLang]/[name]/+page.ts | 2 +- .../json-ld/[name]/+server.ts | 3 +- src/routes/sitemap.xml/+server.ts | 206 ++++++++++++++++++ static/robots.txt | 9 +- 15 files changed, 399 insertions(+), 33 deletions(-) create mode 100644 src/lib/components/Seo.svelte create mode 100644 src/routes/sitemap.xml/+server.ts diff --git a/package.json b/package.json index 31e322c0..0e60495b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.60.0", + "version": "1.61.0", "private": true, "type": "module", "scripts": { diff --git a/src/lib/components/Seo.svelte b/src/lib/components/Seo.svelte new file mode 100644 index 00000000..e5f2e16f --- /dev/null +++ b/src/lib/components/Seo.svelte @@ -0,0 +1,57 @@ + + + + {title} + {#if description}{/if} + {#if canonical}{/if} + + + {#if description}{/if} + + + {#if canonical}{/if} + {#if lang}{/if} + {#if ogImage} + + {#if ogImageAlt}{/if} + {/if} + + + + {#if description}{/if} + {#if ogImage}{/if} + + {#each alternates as a (a.hreflang)} + + {/each} + diff --git a/src/lib/i18n/recipes/de.ts b/src/lib/i18n/recipes/de.ts index defec6bf..69ded570 100644 --- a/src/lib/i18n/recipes/de.ts +++ b/src/lib/i18n/recipes/de.ts @@ -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', diff --git a/src/lib/i18n/recipes/en.ts b/src/lib/i18n/recipes/en.ts index 7d90ff12..14a7e307 100644 --- a/src/lib/i18n/recipes/en.ts +++ b/src/lib/i18n/recipes/en.ts @@ -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', diff --git a/src/lib/js/recipeJsonLd.ts b/src/lib/js/recipeJsonLd.ts index 04ebe2c6..43832dfa 100644 --- a/src/lib/js/recipeJsonLd.ts +++ b/src/lib/js/recipeJsonLd.ts @@ -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}` }); } } diff --git a/src/routes/(main)/+page.svelte b/src/routes/(main)/+page.svelte index 6b0241c4..db3fe101 100644 --- a/src/routes/(main)/+page.svelte +++ b/src/routes/(main)/+page.svelte @@ -1,6 +1,7 @@ + + {#if altDe}{/if} + {#if altEn}{/if} + {#if altLa}{/if} + {#if altEn}{/if}
{#snippet links()} diff --git a/src/routes/[faithLang=faithLang]/+page.svelte b/src/routes/[faithLang=faithLang]/+page.svelte index b3c72e64..b1cbdee9 100644 --- a/src/routes/[faithLang=faithLang]/+page.svelte +++ b/src/routes/[faithLang=faithLang]/+page.svelte @@ -1,6 +1,7 @@ - - {t.title} - Bocken - - + + - {labels.metaTitle} - {#if heroRecipe} {/if} - - - - {#if heroRecipe} diff --git a/src/routes/[recipeLang=recipeLang]/[name]/+page.ts b/src/routes/[recipeLang=recipeLang]/[name]/+page.ts index a5c23e94..a9c096b2 100644 --- a/src/routes/[recipeLang=recipeLang]/[name]/+page.ts +++ b/src/routes/[recipeLang=recipeLang]/[name]/+page.ts @@ -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) diff --git a/src/routes/api/[recipeLang=recipeLang]/json-ld/[name]/+server.ts b/src/routes/api/[recipeLang=recipeLang]/json-ld/[name]/+server.ts index 3d82edd0..0e2c699a 100644 --- a/src/routes/api/[recipeLang=recipeLang]/json-ld/[name]/+server.ts +++ b/src/routes/api/[recipeLang=recipeLang]/json-ld/[name]/+server.ts @@ -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({ diff --git a/src/routes/sitemap.xml/+server.ts b/src/routes/sitemap.xml/+server.ts new file mode 100644 index 00000000..29508bd8 --- /dev/null +++ b/src/routes/sitemap.xml/+server.ts @@ -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, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); +} + +function renderUrl(u: Url): string { + const alt = (u.alternates ?? []) + .map(a => ` `) + .join('\n'); + const parts = [ + ` `, + ` ${xmlEscape(u.loc)}`, + u.changefreq ? ` ${u.changefreq}` : '', + u.priority !== undefined ? ` ${u.priority.toFixed(1)}` : '', + alt, + ` `, + ].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 = ` + +${urls.map(renderUrl).join('\n')} + +`; + + 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); +}; diff --git a/static/robots.txt b/static/robots.txt index 3319f208..805a334b 100644 --- a/static/robots.txt +++ b/static/robots.txt @@ -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