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
+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}`
});
}
}