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",
|
||||
"version": "1.60.0",
|
||||
"version": "1.61.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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',
|
||||
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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: /
|
||||
|
||||
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