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:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.60.0",
|
"version": "1.61.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -181,6 +181,11 @@ export const de = {
|
|||||||
instructions_label: 'Zubereitung',
|
instructions_label: 'Zubereitung',
|
||||||
at_temp: 'bei',
|
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
|
// CreateStepList baking
|
||||||
not_set: 'Nicht gesetzt',
|
not_set: 'Nicht gesetzt',
|
||||||
duration: 'Dauer',
|
duration: 'Dauer',
|
||||||
|
|||||||
@@ -181,6 +181,11 @@ export const en = {
|
|||||||
instructions_label: 'Instructions',
|
instructions_label: 'Instructions',
|
||||||
at_temp: 'at',
|
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
|
// CreateStepList baking
|
||||||
not_set: 'Not set',
|
not_set: 'Not set',
|
||||||
duration: 'Duration',
|
duration: 'Duration',
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ function parseTimeToISO8601(timeString: string | undefined): string | undefined
|
|||||||
}
|
}
|
||||||
|
|
||||||
import type { RecipeModelType, NutritionMapping } from '$types/types';
|
import type { RecipeModelType, NutritionMapping } from '$types/types';
|
||||||
|
import { m, type RecipesLang } from '$lib/js/recipesI18n';
|
||||||
|
|
||||||
interface HowToStep {
|
interface HowToStep {
|
||||||
"@type": "HowToStep";
|
"@type": "HowToStep";
|
||||||
@@ -62,7 +63,8 @@ type ReferencedNutrition = {
|
|||||||
baseMultiplier: number;
|
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 = {
|
const jsonLd: RecipeJsonLd = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Recipe",
|
"@type": "Recipe",
|
||||||
@@ -126,7 +128,7 @@ export function generateRecipeJsonLd(data: RecipeModelType, referencedNutrition?
|
|||||||
for (let i = 0; i < instructionGroup.steps.length; i++) {
|
for (let i = 0; i < instructionGroup.steps.length; i++) {
|
||||||
jsonLd.recipeInstructions.push({
|
jsonLd.recipeInstructions.push({
|
||||||
"@type": "HowToStep",
|
"@type": "HowToStep",
|
||||||
"name": `Schritt ${i + 1}`,
|
"name": `${t.jsonld_step} ${i + 1}`,
|
||||||
"text": instructionGroup.steps[i]
|
"text": instructionGroup.steps[i]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -137,16 +139,16 @@ export function generateRecipeJsonLd(data: RecipeModelType, referencedNutrition?
|
|||||||
// Add baking instructions if available
|
// Add baking instructions if available
|
||||||
if (data.baking?.temperature || data.baking?.length) {
|
if (data.baking?.temperature || data.baking?.length) {
|
||||||
const bakingText = [
|
const bakingText = [
|
||||||
data.baking.temperature ? `bei ${data.baking.temperature}` : '',
|
data.baking.temperature ? `${t.at_temp} ${data.baking.temperature}` : '',
|
||||||
data.baking.length ? `für ${data.baking.length}` : '',
|
data.baking.length ? `${t.jsonld_for_duration} ${data.baking.length}` : '',
|
||||||
data.baking.mode || ''
|
data.baking.mode || ''
|
||||||
].filter(Boolean).join(' ');
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
if (bakingText) {
|
if (bakingText) {
|
||||||
jsonLd.recipeInstructions.push({
|
jsonLd.recipeInstructions.push({
|
||||||
"@type": "HowToStep",
|
"@type": "HowToStep",
|
||||||
"name": "Backen",
|
"name": t.jsonld_bake,
|
||||||
"text": `Backen ${bakingText}`
|
"text": `${t.jsonld_bake} ${bakingText}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import LinksGrid from "$lib/components/LinksGrid.svelte";
|
import LinksGrid from "$lib/components/LinksGrid.svelte";
|
||||||
|
import Seo from '$lib/components/Seo.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -110,14 +111,17 @@ section h2{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<svelte:head>
|
<Seo
|
||||||
<title>Bocken</title>
|
title={isEnglish ? "Alexander Bocken — personal site" : "Alexander Bocken — Persönliche Website"}
|
||||||
<meta name="description" content="Die persönliche Website von Alexander Bocken" />
|
description={isEnglish
|
||||||
<meta property="og:image" content="https://bocken.org/static/favicon.png" />
|
? "Self-hosted recipe collection, Catholic faith resources, apologetics, prayers, and personal projects by Alexander Bocken."
|
||||||
<meta property="og:image:secure_url" content="https://bocken.org/favicon.png" />
|
: "Selbstgehostete Rezeptsammlung, katholische Glaubensinhalte, Apologetik, Gebete und persönliche Projekte von Alexander Bocken."}
|
||||||
<meta property="og:image:type" content="image/png" />
|
canonical="https://bocken.org/"
|
||||||
<meta property="og:image:alt" content="Das Familienwappen simplifiziert" />
|
ogImage="https://bocken.org/static/user/full/alexander.webp"
|
||||||
</svelte:head>
|
ogImageAlt={isEnglish ? "Smiling Alexander Bocken" : "Lächelnder Alexander Bocken"}
|
||||||
|
siteName="Bocken"
|
||||||
|
lang={isEnglish ? 'en' : 'de'}
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- SVG Definitions -->
|
<!-- SVG Definitions -->
|
||||||
<svg style="display: none;">
|
<svg style="display: none;">
|
||||||
|
|||||||
@@ -10,6 +10,43 @@ import { m, prayersSlug as prayersSlugFor, rosarySlug, calendarSlug, apologetikS
|
|||||||
/** @typedef {import('$lib/js/faithI18n').FaithLang} FaithLang */
|
/** @typedef {import('$lib/js/faithI18n').FaithLang} FaithLang */
|
||||||
let { data, children } = $props();
|
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 lang = $derived(/** @type {FaithLang} */ (data.lang));
|
||||||
const t = $derived(m[lang]);
|
const t = $derived(m[lang]);
|
||||||
const eastertide = isEastertide();
|
const eastertide = isEastertide();
|
||||||
@@ -42,6 +79,11 @@ const prayersActive = $derived(isActive(prayersHref) && !isActive(angelusHref));
|
|||||||
</script>
|
</script>
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="preload" href={asset('/fonts/crosses.woff2')} as="font" type="font/woff2" crossorigin="anonymous">
|
<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>
|
</svelte:head>
|
||||||
<Header>
|
<Header>
|
||||||
{#snippet links()}
|
{#snippet links()}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import LinksGrid from '$lib/components/LinksGrid.svelte';
|
import LinksGrid from '$lib/components/LinksGrid.svelte';
|
||||||
|
import Seo from '$lib/components/Seo.svelte';
|
||||||
import { isEastertide } from '$lib/js/easter.svelte';
|
import { isEastertide } from '$lib/js/easter.svelte';
|
||||||
import { m, prayersSlug as prayersSlugFor, rosarySlug, calendarSlug, apologetikSlug, faithSlugFromLang } from '$lib/js/faithI18n';
|
import { m, prayersSlug as prayersSlugFor, rosarySlug, calendarSlug, apologetikSlug, faithSlugFromLang } from '$lib/js/faithI18n';
|
||||||
/** @typedef {import('$lib/js/faithI18n').FaithLang} FaithLang */
|
/** @typedef {import('$lib/js/faithI18n').FaithLang} FaithLang */
|
||||||
@@ -20,10 +21,11 @@
|
|||||||
const eastertide = isEastertide();
|
const eastertide = isEastertide();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<Seo
|
||||||
<title>{t.title} - Bocken</title>
|
title={`${t.title} — Bocken`}
|
||||||
<meta name="description" content={t.description} />
|
description={t.description}
|
||||||
</svelte:head>
|
lang={lang}
|
||||||
|
/>
|
||||||
<style>
|
<style>
|
||||||
h1{
|
h1{
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import Shield from '@lucide/svelte/icons/shield';
|
import Shield from '@lucide/svelte/icons/shield';
|
||||||
import Flame from '@lucide/svelte/icons/flame';
|
import Flame from '@lucide/svelte/icons/flame';
|
||||||
|
import Seo from '$lib/components/Seo.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
const faithLang = $derived(data?.faithLang ?? 'faith');
|
const faithLang = $derived(data?.faithLang ?? 'faith');
|
||||||
@@ -44,10 +45,11 @@
|
|||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<Seo
|
||||||
<title>{t.title} · bocken.org</title>
|
title={`${t.title} · bocken.org`}
|
||||||
<meta name="description" content={t.lede} />
|
description={t.lede}
|
||||||
</svelte:head>
|
lang={isGerman ? 'de' : 'en'}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="apologetik-landing">
|
<div class="apologetik-landing">
|
||||||
<section class="page-head">
|
<section class="page-head">
|
||||||
|
|||||||
@@ -79,8 +79,39 @@ function isActive(path) {
|
|||||||
// For other paths, check if current path starts with the link path
|
// For other paths, check if current path starts with the link path
|
||||||
return currentPath.startsWith(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>
|
</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>
|
<Header>
|
||||||
{#snippet links()}
|
{#snippet links()}
|
||||||
<ul class=site_header>
|
<ul class=site_header>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import AddButton from '$lib/components/AddButton.svelte';
|
import AddButton from '$lib/components/AddButton.svelte';
|
||||||
|
import Seo from '$lib/components/Seo.svelte';
|
||||||
import CompactCard from '$lib/components/recipes/CompactCard.svelte';
|
import CompactCard from '$lib/components/recipes/CompactCard.svelte';
|
||||||
import OfflineSyncBanner from '$lib/components/OfflineSyncBanner.svelte';
|
import OfflineSyncBanner from '$lib/components/OfflineSyncBanner.svelte';
|
||||||
import Search from '$lib/components/recipes/Search.svelte';
|
import Search from '$lib/components/recipes/Search.svelte';
|
||||||
@@ -350,16 +351,17 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</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>
|
<svelte:head>
|
||||||
<title>{labels.metaTitle}</title>
|
|
||||||
<meta name="description" content="{labels.metaDescription}" />
|
|
||||||
{#if heroRecipe}
|
{#if heroRecipe}
|
||||||
<link rel="preload" as="image" href="https://bocken.org/static/rezepte/full/{heroImg}" fetchpriority="high" />
|
<link rel="preload" as="image" href="https://bocken.org/static/rezepte/full/{heroImg}" fetchpriority="high" />
|
||||||
{/if}
|
{/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>
|
</svelte:head>
|
||||||
|
|
||||||
{#if heroRecipe}
|
{#if heroRecipe}
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export const load: PageLoad = async ({ fetch, params, url, data }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate JSON-LD server-side
|
// 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 German page: check if English translation exists
|
||||||
// For English page: germanShortName is already in item (from API)
|
// 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 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
|
// Set appropriate headers for JSON-LD
|
||||||
setHeaders({
|
setHeaders({
|
||||||
|
|||||||
@@ -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, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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
@@ -2,4 +2,11 @@ User-agent: GPTBot
|
|||||||
Disallow: /
|
Disallow: /
|
||||||
|
|
||||||
User-agent: *
|
User-agent: *
|
||||||
Disallow: /static/
|
Disallow: /api/
|
||||||
|
Disallow: /login
|
||||||
|
Disallow: /logout
|
||||||
|
Disallow: /tasks
|
||||||
|
Disallow: /settings
|
||||||
|
Disallow: /register
|
||||||
|
|
||||||
|
Sitemap: https://bocken.org/sitemap.xml
|
||||||
|
|||||||
Reference in New Issue
Block a user