4 Commits

Author SHA1 Message Date
Alexander 4623d7a1f7 feat(seo): noindex hook, recipe self-canonical, list-page metadata
CI / update (push) Successful in 37s
Add X-Robots-Tag noindex,nofollow handler in hooks.server.ts for /api,
/login, /logout, /register, /settings, /tasks, /fitness, /cospend,
/expenses, and the recipe admin/edit/add/search/favorites/to-try paths.
Header-based so the rule lives in one place and covers JSON responses.

Recipe detail pages now emit a self-canonical pointing at the bare slug —
the layout helper deliberately skipped detail pages, leaving query-param
variants (?multiplier=2, ?utm=…) as duplicate URLs in Google's index.

Per-page Seo on list pages so each ranks for its category-level query:
- Apologetik contra/pro indices now use localized heading + lede instead
  of hardcoded English descriptions
- Calendar month view title includes month + rite ("April 2026 ·
  Liturgical Calendar (Vetus Ordo) — Bocken")
- Recipe /category, /tag, /icon, /season hub + detail pages get
  descriptions via new *_meta_description and *_meta_prefix i18n keys
  (added in both DE and EN locales)
2026-05-02 22:23:15 +02:00
Alexander d59cc0a732 feat(seo): image sitemap, Article schemas on apologetik pro + katechese, edge caching
Sitemap now declares the image:image namespace and emits an entry per recipe
photo (loc, title from recipe name, caption from alt text) — Google Image
Search can discover all recipe images directly instead of relying on crawl.

Pro arg pages get Article JSON-LD (headline, claim as description, layer as
articleSection, voice names as keywords, deduped voice cites as citations)
plus BreadcrumbList. Katechese/zehn-gebote gets inline Article + Breadcrumb
with a Thing reference to "Dekalog" and CreativeWork citation of P. Martin
Ramm FSSP's Glaubenskurs.

Static-content load functions now set Cache-Control:
public,max-age=300,s-maxage=3600,stale-while-revalidate=86400 — applied to
apologetik layout, contra index/arg, pro index/arg, and the new katechese
+page.ts files. Recipe detail uses s-maxage=1800. Picked HTTP caching over
SvelteKit prerender to avoid baking session=null into the navbar of routes
shared with the auth-aware faithLang layout.
2026-05-02 22:05:50 +02:00
Alexander ecbd24d7a4 feat(seo): per-route html lang, QAPage/Breadcrumb/Event/WebSite schemas, sitemap lastmod
Set <html lang> from URL prefix via handle hook (was hardcoded "en" despite
mostly German content). Add Person + WebSite + SearchAction graph to root
layout — enables Google sitelinks search box and clusters identity across
git.bocken.org and github.com/AlexBocken via sameAs.

Build apologetikJsonLd.ts: contra args now emit QAPage with one suggestedAnswer
per voiced archetype, citations as CreativeWork. Build breadcrumbJsonLd.ts and
wire BreadcrumbList into recipe detail, contra args, prayer detail, and
calendar day. Calendar day also emits Event schema.

Sitemap now reads recipes directly from MongoDB to populate <lastmod> from
dateModified; static URLs use server-startup ISO date. English recipe URLs
only emitted when translation status is approved.
2026-05-02 21:48:05 +02:00
Alexander 7e33ea833e 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.
2026-05-02 21:32:06 +02:00
45 changed files with 917 additions and 81 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.60.0", "version": "1.64.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="%lang%">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" /> <link rel="icon" href="%sveltekit.assets%/favicon.svg" />
+46
View File
@@ -7,6 +7,50 @@ import { dbConnect } from "./utils/db"
import { errorWithVerse, getRandomVerse } from "$lib/server/errorQuote" import { errorWithVerse, getRandomVerse } from "$lib/server/errorQuote"
import { warmLiturgicalCache } from "$lib/server/liturgicalCalendar" import { warmLiturgicalCache } from "$lib/server/liturgicalCalendar"
/** Map URL path to BCP 47 lang tag. Mirrors the [recipeLang] / [faithLang]
* param matchers — keep in sync if new locale slugs are added.
* @returns 'de' | 'en' | 'la'
*/
function langFromPath(pathname: string): 'de' | 'en' | 'la' {
const first = pathname.split('/').filter(Boolean)[0] ?? '';
if (first === 'recipes' || first === 'faith') return 'en';
if (first === 'fides') return 'la';
return 'de';
}
async function htmlLang({ event, resolve }: Parameters<Handle>[0]) {
const lang = langFromPath(event.url.pathname);
return resolve(event, {
transformPageChunk: ({ html }) => html.replace('%lang%', lang),
});
}
/** Routes that must never appear in search-engine indexes. Search-results pages
* are thin/duplicate content; admin/edit/auth-walled pages have no public value
* and shouldn't burn crawl budget. Sets X-Robots-Tag rather than per-page meta
* so the rule lives in one place and also covers JSON/API responses.
*/
const NOINDEX_PATTERNS: RegExp[] = [
/^\/api(\/|$)/,
/^\/(rezepte|recipes)\/(search|admin|administration|add|edit|favorites|to-try)(\/|$)/,
/^\/login$/,
/^\/logout$/,
/^\/register(\/|$)/,
/^\/settings(\/|$)/,
/^\/tasks(\/|$)/,
/^\/fitness(\/|$)/,
/^\/cospend(\/|$)/,
/^\/expenses(\/|$)/,
];
async function noindex({ event, resolve }: Parameters<Handle>[0]) {
const response = await resolve(event);
if (NOINDEX_PATTERNS.some((p) => p.test(event.url.pathname))) {
response.headers.set('X-Robots-Tag', 'noindex, nofollow');
}
return response;
}
async function timing({ event, resolve }: Parameters<Handle>[0]) { async function timing({ event, resolve }: Parameters<Handle>[0]) {
const marks: Record<string, number> = {}; const marks: Record<string, number> = {};
event.locals.timing = { event.locals.timing = {
@@ -143,6 +187,8 @@ export const handleError: HandleServerError = async ({ error, event, status, mes
export const handle: Handle = sequence( export const handle: Handle = sequence(
timing, timing,
htmlLang,
noindex,
auth.handle, auth.handle,
authorization authorization
); );
+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>
+15
View File
@@ -138,6 +138,16 @@ export const de = {
icons_title: 'Icons', icons_title: 'Icons',
tips_title: 'Tipps & Tricks', tips_title: 'Tipps & Tricks',
favorites_meta_description: 'Meine favorisierten Rezepte aus der Bockenschen Küche.', favorites_meta_description: 'Meine favorisierten Rezepte aus der Bockenschen Küche.',
// Browse-page meta descriptions (for SEO discovery via category-level queries)
categories_meta_description: 'Rezepte nach Kategorie durchstöbern — Hauptspeisen, Vorspeisen, Desserts, Brot, Suppen und mehr aus der Bockenschen Sammlung.',
keywords_meta_description: 'Rezepte nach Stichwort filtern — von Hefeteig bis zu Italien, von Käse bis zu Kürbis.',
in_season_meta_description: 'Saisonal passende Rezepte — was gerade Saison hat, vom Frühling bis zum Winter.',
icons_meta_description: 'Rezepte nach Symbol durchstöbern — eine visuelle Übersicht der Bockenschen Sammlung.',
category_meta_prefix: 'Rezepte in der Kategorie',
tag_meta_prefix: 'Rezepte mit dem Stichwort',
icon_meta_prefix: 'Rezepte mit dem Symbol',
season_meta_prefix: 'Rezepte für',
empty_favorites_1: 'Du hast noch keine Rezepte als Favoriten gespeichert.', empty_favorites_1: 'Du hast noch keine Rezepte als Favoriten gespeichert.',
empty_favorites_2: 'Besuche ein Rezept und klicke auf das Herz-Symbol, um es zu deinen Favoriten hinzuzufügen.', empty_favorites_2: 'Besuche ein Rezept und klicke auf das Herz-Symbol, um es zu deinen Favoriten hinzuzufügen.',
@@ -181,6 +191,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',
+15
View File
@@ -138,6 +138,16 @@ export const en = {
icons_title: 'Icons', icons_title: 'Icons',
tips_title: 'Tips & Tricks', tips_title: 'Tips & Tricks',
favorites_meta_description: "My favorite recipes from Bocken's kitchen.", favorites_meta_description: "My favorite recipes from Bocken's kitchen.",
// Browse-page meta descriptions (for SEO discovery via category-level queries)
categories_meta_description: "Browse recipes by category — mains, starters, desserts, breads, soups and more from Bocken's collection.",
keywords_meta_description: 'Filter recipes by keyword — from yeast doughs to Italian, from cheese to pumpkin.',
in_season_meta_description: 'Seasonal recipes for what is in season right now, from spring to winter.',
icons_meta_description: "Browse recipes by symbol — a visual overview of Bocken's collection.",
category_meta_prefix: 'Recipes in category',
tag_meta_prefix: 'Recipes tagged',
icon_meta_prefix: 'Recipes with symbol',
season_meta_prefix: 'Recipes for',
empty_favorites_1: "You haven't saved any recipes as favorites yet.", empty_favorites_1: "You haven't saved any recipes as favorites yet.",
empty_favorites_2: 'Visit a recipe and click the heart icon to add it to your favorites.', empty_favorites_2: 'Visit a recipe and click the heart icon to add it to your favorites.',
@@ -181,6 +191,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',
+116
View File
@@ -0,0 +1,116 @@
import type { Argument, Archetype, PosArgument, PosVoice } from '$lib/data/apologetik';
import type { FaithLang } from '$lib/js/faithI18n';
import { faithSlugFromLang, apologetikSlug } from '$lib/js/faithI18n';
const SITE = 'https://bocken.org';
type CreativeWorkRef = { '@type': 'CreativeWork'; name: string };
type Answer = {
'@type': 'Answer';
text: string;
author: { '@type': 'Person'; name: string };
citation?: CreativeWorkRef[];
url: string;
};
export interface ContraQaJsonLd {
'@context': 'https://schema.org';
'@type': 'QAPage';
mainEntity: {
'@type': 'Question';
name: string;
text: string;
inLanguage: FaithLang;
answerCount: number;
suggestedAnswer: Answer[];
};
}
/** Build a QAPage JSON-LD for a contra argument: one Question, many voiced Answers. */
export function generateContraQaJsonLd(
arg: Argument,
archetypes: Record<string, Archetype>,
lang: FaithLang
): ContraQaJsonLd {
const faithSeg = faithSlugFromLang(lang);
const apolSeg = apologetikSlug(lang === 'la' ? 'en' : lang);
const baseUrl = `${SITE}/${faithSeg}/${apolSeg}/contra/${arg.id}`;
const archIds = Object.keys(arg.counters);
const answers: Answer[] = archIds.map((archId) => {
const counter = arg.counters[archId];
const archetype = archetypes[archId];
const text = [counter.lede, ...(counter.body ?? [])].filter(Boolean).join('\n\n');
const ans: Answer = {
'@type': 'Answer',
text,
author: { '@type': 'Person', name: archetype?.name ?? archId },
url: `${baseUrl}/${archId}`,
};
if (counter.cites?.length) {
ans.citation = counter.cites.map((name) => ({ '@type': 'CreativeWork', name }));
}
return ans;
});
return {
'@context': 'https://schema.org',
'@type': 'QAPage',
mainEntity: {
'@type': 'Question',
name: arg.title,
text: arg.steel,
inLanguage: lang,
answerCount: answers.length,
suggestedAnswer: answers,
},
};
}
export interface ProArgArticleJsonLd {
'@context': 'https://schema.org';
'@type': 'Article';
headline: string;
description: string;
inLanguage: FaithLang;
url: string;
mainEntityOfPage: string;
author: { '@type': 'Person'; name: string; url: string };
publisher: { '@type': 'Person'; name: string; url: string };
articleSection: string;
keywords?: string;
citation?: Array<{ '@type': 'CreativeWork'; name: string }>;
}
/** Build an Article JSON-LD for a positive (pro) apologetik argument. */
export function generateProArgArticleJsonLd(
arg: PosArgument,
voices: Record<string, PosVoice>,
lang: FaithLang
): ProArgArticleJsonLd {
const faithSeg = faithSlugFromLang(lang);
const apolSeg = apologetikSlug(lang === 'la' ? 'en' : lang);
const url = `https://bocken.org/${faithSeg}/${apolSeg}/pro/${arg.id}`;
const allCites = Object.values(arg.voices).flatMap((v) => v.cites ?? []);
const uniqueCites = Array.from(new Set(allCites));
const voiceNames = Object.keys(arg.voices)
.map((id) => voices[id]?.name ?? id)
.join(', ');
const article: ProArgArticleJsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: arg.title,
description: arg.claim,
inLanguage: lang,
url,
mainEntityOfPage: url,
author: { '@type': 'Person', name: 'Alexander Bocken', url: 'https://bocken.org/' },
publisher: { '@type': 'Person', name: 'Alexander Bocken', url: 'https://bocken.org/' },
articleSection: arg.layer,
};
if (voiceNames) article.keywords = voiceNames;
if (uniqueCites.length) article.citation = uniqueCites.map((name) => ({ '@type': 'CreativeWork', name }));
return article;
}
+30
View File
@@ -0,0 +1,30 @@
const SITE = 'https://bocken.org';
export type Crumb = { name: string; path: string };
export interface BreadcrumbListJsonLd {
'@context': 'https://schema.org';
'@type': 'BreadcrumbList';
itemListElement: Array<{
'@type': 'ListItem';
position: number;
name: string;
item: string;
}>;
}
/** Build a BreadcrumbList. Pass crumbs in order from root → current page.
* Last crumb's `item` is omitted per Google guidance (current page).
* Paths are relative ("/rezepte"); SITE is prepended. */
export function generateBreadcrumbJsonLd(crumbs: Crumb[]): BreadcrumbListJsonLd {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: crumbs.map((c, i) => ({
'@type': 'ListItem',
position: i + 1,
name: c.name,
item: `${SITE}${c.path}`,
})),
};
}
+9 -7
View File
@@ -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}`
}); });
} }
} }
+12 -8
View File
@@ -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;">
+31
View File
@@ -6,6 +6,33 @@
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
let { children } = $props(); let { children } = $props();
const websiteJsonLd = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'WebSite',
'@id': 'https://bocken.org/#website',
url: 'https://bocken.org/',
name: 'Bocken',
inLanguage: ['de', 'en', 'la'],
publisher: { '@id': 'https://bocken.org/#person' },
potentialAction: {
'@type': 'SearchAction',
target: { '@type': 'EntryPoint', urlTemplate: 'https://bocken.org/rezepte/search?q={search_term_string}' },
'query-input': 'required name=search_term_string'
}
},
{
'@type': 'Person',
'@id': 'https://bocken.org/#person',
name: 'Alexander Bocken',
url: 'https://bocken.org/',
image: 'https://bocken.org/static/user/full/alexander.webp',
sameAs: ['https://git.bocken.org', 'https://github.com/AlexBocken']
}
]
};
/** Refresh server data on resume — Tauri WebView and backgrounded browser tabs /** Refresh server data on resume — Tauri WebView and backgrounded browser tabs
* don't re-run SvelteKit load() otherwise. Throttled: at most once per 5 min. */ * don't re-run SvelteKit load() otherwise. Throttled: at most once per 5 min. */
const REFRESH_MIN_GAP_MS = 5 * 60 * 1000; const REFRESH_MIN_GAP_MS = 5 * 60 * 1000;
@@ -43,6 +70,10 @@
}); });
</script> </script>
<svelte:head>
{@html `<script type="application/ld+json">${JSON.stringify(websiteJsonLd)}</script>`}
</svelte:head>
{@render children()} {@render children()}
<Toast /> <Toast />
<ConfirmDialog /> <ConfirmDialog />
@@ -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;
@@ -3,7 +3,8 @@ import type { LayoutServerLoad } from './$types';
const expectedSlug = { de: 'apologetik', en: 'apologetics' } as const; const expectedSlug = { de: 'apologetik', en: 'apologetics' } as const;
export const load: LayoutServerLoad = async ({ params, parent, url }) => { export const load: LayoutServerLoad = async ({ params, parent, url, setHeaders }) => {
setHeaders({ 'Cache-Control': 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400' });
const { lang, faithLang } = await parent(); const { lang, faithLang } = await parent();
const prefix = `/${faithLang}/${params.apologetikSlug}`; const prefix = `/${faithLang}/${params.apologetikSlug}`;
@@ -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">
@@ -5,6 +5,7 @@
import ApologetikToc from '$lib/components/faith/ApologetikToc.svelte'; import ApologetikToc from '$lib/components/faith/ApologetikToc.svelte';
import { m, type FaithLang } from '$lib/js/faithI18n'; import { m, type FaithLang } from '$lib/js/faithI18n';
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');
@@ -98,12 +99,12 @@
const archetypes = $derived(Object.values(ARCHETYPES)); const archetypes = $derived(Object.values(ARCHETYPES));
</script> </script>
<Seo
title={`${heading} · bocken.org`}
description={lede}
lang={lang}
/>
<svelte:head> <svelte:head>
<title>{heading} · bocken.org</title>
<meta
name="description"
content="Common objections to Christianity, each answered in several historical voices: Aquinas, Pascal, Augustine, Francis, Lewis, Chesterton, the Logician, the Mystic, the Scientist, the Pastor."
/>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link <link
@@ -1,7 +1,8 @@
import { getArchetypes, getArguments } from '$lib/data/apologetik'; import { getArchetypes, getArguments } from '$lib/data/apologetik';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const load: PageLoad = async ({ parent }) => { export const load: PageLoad = async ({ parent, setHeaders }) => {
setHeaders({ 'Cache-Control': 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400' });
const { lang } = await parent(); const { lang } = await parent();
const [archetypes, args] = await Promise.all([getArchetypes(lang), getArguments(lang)]); const [archetypes, args] = await Promise.all([getArchetypes(lang), getArguments(lang)]);
return { archetypes, args }; return { archetypes, args };
@@ -96,6 +96,8 @@
rel="stylesheet" rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;0,700;1,400&family=Spectral:ital,wght@0,400;0,600;1,400&family=IBM+Plex+Mono:wght@300;400;500&family=JetBrains+Mono:wght@400;500&display=swap" href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;0,700;1,400&family=Spectral:ital,wght@0,400;0,600;1,400&family=IBM+Plex+Mono:wght@300;400;500&family=JetBrains+Mono:wght@400;500&display=swap"
/> />
{@html `<script type="application/ld+json">${JSON.stringify(data.qaJsonLd)}</script>`}
{@html `<script type="application/ld+json">${JSON.stringify(data.breadcrumbJsonLd)}</script>`}
</svelte:head> </svelte:head>
<ApologetikToc title={tocLabel} items={tocItems} activeId={arg.id} /> <ApologetikToc title={tocLabel} items={tocItems} activeId={arg.id} />
@@ -1,9 +1,14 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { ALEX_PICKS, findArgumentLang, getArchetypes, getArguments } from '$lib/data/apologetik'; import { ALEX_PICKS, findArgumentLang, getArchetypes, getArguments } from '$lib/data/apologetik';
import { generateContraQaJsonLd } from '$lib/js/apologetikJsonLd';
import { generateBreadcrumbJsonLd } from '$lib/js/breadcrumbJsonLd';
import { m as faithM, faithSlugFromLang, apologetikSlug, type FaithLang } from '$lib/js/faithI18n';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params, parent }) => { export const load: PageLoad = async ({ params, parent, setHeaders }) => {
const { lang } = await parent(); setHeaders({ 'Cache-Control': 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400' });
const parentData = await parent();
const lang = parentData.lang as FaithLang;
const [arg, archetypes, args] = await Promise.all([ const [arg, archetypes, args] = await Promise.all([
findArgumentLang(params.argId, lang), findArgumentLang(params.argId, lang),
getArchetypes(lang), getArchetypes(lang),
@@ -17,11 +22,24 @@ export const load: PageLoad = async ({ params, parent }) => {
error(404, 'Voice not found'); error(404, 'Voice not found');
} }
const initialArchId = params.archId ?? null; const initialArchId = params.archId ?? null;
const qaJsonLd = generateContraQaJsonLd(arg, archetypes, lang);
const tFaith = faithM[lang];
const faithSeg = faithSlugFromLang(lang);
const apolSeg = apologetikSlug(lang === 'la' ? 'en' : lang);
const breadcrumbJsonLd = generateBreadcrumbJsonLd([
{ name: 'Bocken', path: '/' },
{ name: tFaith.title, path: `/${faithSeg}` },
{ name: tFaith.apologetics, path: `/${faithSeg}/${apolSeg}` },
{ name: tFaith.objections, path: `/${faithSeg}/${apolSeg}/contra` },
{ name: arg.title, path: `/${faithSeg}/${apolSeg}/contra/${arg.id}` }
]);
return { return {
argument: arg, argument: arg,
archetypes, archetypes,
args, args,
alexPicks: ALEX_PICKS[params.argId] ?? [], alexPicks: ALEX_PICKS[params.argId] ?? [],
initialArchId initialArchId,
qaJsonLd,
breadcrumbJsonLd
}; };
}; };
@@ -7,7 +7,8 @@ import {
} from '$lib/data/apologetik'; } from '$lib/data/apologetik';
import { resolveScriptureForLang } from '$lib/server/scriptureLookup'; import { resolveScriptureForLang } from '$lib/server/scriptureLookup';
export const load: PageServerLoad = async ({ parent }) => { export const load: PageServerLoad = async ({ parent, setHeaders }) => {
setHeaders({ 'Cache-Control': 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400' });
const { lang } = await parent(); const { lang } = await parent();
const [voices, layers, args] = await Promise.all([ const [voices, layers, args] = await Promise.all([
getPosVoices(lang), getPosVoices(lang),
@@ -6,6 +6,7 @@
import ApologetikToc from '$lib/components/faith/ApologetikToc.svelte'; import ApologetikToc from '$lib/components/faith/ApologetikToc.svelte';
import { m, type FaithLang } from '$lib/js/faithI18n'; import { m, type FaithLang } from '$lib/js/faithI18n';
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');
@@ -137,12 +138,12 @@
); );
</script> </script>
<Seo
title={`${labels.heading} · bocken.org`}
description={labels.lede}
lang={lang}
/>
<svelte:head> <svelte:head>
<title>{labels.heading} · bocken.org</title>
<meta
name="description"
content="A positive case for Christianity in twelve threads, organized in three layers: the supernatural is real, there is one God, Christianity is that revelation. Voices: Habermas, Polkinghorne, Newman, Hart, Lewis, Wright, Hahn, Plantinga, Eliade, the Perennialist."
/>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link <link
@@ -8,9 +8,18 @@ import {
POS_ARGUMENTS as EN_POS_ARGUMENTS POS_ARGUMENTS as EN_POS_ARGUMENTS
} from '$lib/data/apologetik'; } from '$lib/data/apologetik';
import { resolveScriptureForLang } from '$lib/server/scriptureLookup'; import { resolveScriptureForLang } from '$lib/server/scriptureLookup';
import { generateProArgArticleJsonLd } from '$lib/js/apologetikJsonLd';
import { generateBreadcrumbJsonLd } from '$lib/js/breadcrumbJsonLd';
import { m as faithM, faithSlugFromLang, apologetikSlug, type FaithLang } from '$lib/js/faithI18n';
export const load: PageServerLoad = async ({ params, parent }) => { export const load: PageServerLoad = async ({ params, parent, setHeaders }) => {
const { lang } = await parent(); // Pure static content — long-form prose with no per-user state. Cache aggressively
// at the edge so crawlers (and casual readers) get sub-50ms TTFB. Logged-in users
// still get fresh server-rendered pages because most reverse proxies vary on cookies.
setHeaders({ 'Cache-Control': 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400' });
const parentData = await parent();
const lang = parentData.lang as FaithLang;
const [arg, voices, layers, args] = await Promise.all([ const [arg, voices, layers, args] = await Promise.all([
findPositiveArgumentLang(params.posArgId, lang), findPositiveArgumentLang(params.posArgId, lang),
getPosVoices(lang), getPosVoices(lang),
@@ -46,5 +55,17 @@ export const load: PageServerLoad = async ({ params, parent }) => {
return { ...a, scripture: resolved.text ? resolved : a.scripture }; return { ...a, scripture: resolved.text ? resolved : a.scripture };
}); });
return { argument, voices, layers, args: argsWithScripture, initialVoiceId }; const articleJsonLd = generateProArgArticleJsonLd(argument, voices, lang);
const tFaith = faithM[lang];
const faithSeg = faithSlugFromLang(lang);
const apolSeg = apologetikSlug(lang === 'la' ? 'en' : lang);
const breadcrumbJsonLd = generateBreadcrumbJsonLd([
{ name: 'Bocken', path: '/' },
{ name: tFaith.title, path: `/${faithSeg}` },
{ name: tFaith.apologetics, path: `/${faithSeg}/${apolSeg}` },
{ name: tFaith.evidences, path: `/${faithSeg}/${apolSeg}/pro` },
{ name: argument.title, path: `/${faithSeg}/${apolSeg}/pro/${argument.id}` }
]);
return { argument, voices, layers, args: argsWithScripture, initialVoiceId, articleJsonLd, breadcrumbJsonLd };
}; };
@@ -104,6 +104,8 @@
rel="stylesheet" rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;0,700;1,400&family=Spectral:ital,wght@0,400;0,600;1,400&family=IBM+Plex+Mono:wght@300;400;500&family=JetBrains+Mono:wght@400;500&display=swap" href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;0,700;1,400&family=Spectral:ital,wght@0,400;0,600;1,400&family=IBM+Plex+Mono:wght@300;400;500&family=JetBrains+Mono:wght@400;500&display=swap"
/> />
{@html `<script type="application/ld+json">${JSON.stringify(data.articleJsonLd)}</script>`}
{@html `<script type="application/ld+json">${JSON.stringify(data.breadcrumbJsonLd)}</script>`}
</svelte:head> </svelte:head>
<ApologetikToc title={tocLabel} items={tocItems} activeId={arg.id} /> <ApologetikToc title={tocLabel} items={tocItems} activeId={arg.id} />
@@ -163,8 +163,8 @@
</script> </script>
<svelte:head> <svelte:head>
<title>{pageTitle} — Bocken</title> <title>{monthTitle} · {pageTitle} ({riteSubtitle}) — Bocken</title>
<meta name="description" content={pageTitle} /> <meta name="description" content={`${pageTitle} ${monthTitle}${riteSubtitle}.`} />
</svelte:head> </svelte:head>
<main class="cal-wrap"> <main class="cal-wrap">
@@ -5,6 +5,8 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { formatLongDate, getMonthName, properLabel, type CalendarLang, m, m1962 } from '../../../../../calendarI18n'; import { formatLongDate, getMonthName, properLabel, type CalendarLang, m, m1962 } from '../../../../../calendarI18n';
import HeroCard from '../../../../../HeroCard.svelte'; import HeroCard from '../../../../../HeroCard.svelte';
import { generateBreadcrumbJsonLd } from '$lib/js/breadcrumbJsonLd';
import { m as faithM, faithSlugFromLang, calendarSlug } from '$lib/js/faithI18n';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
@@ -78,11 +80,41 @@
const nextHref = $derived(shiftDay(1)); const nextHref = $derived(shiftDay(1));
const monthTitle = $derived(`${getMonthName(month, lang)} ${year}`); const monthTitle = $derived(`${getMonthName(month, lang)} ${year}`);
const breadcrumbJsonLd = $derived.by(() => {
// faith calendar i18n only has 'de' | 'en' | 'la'; CalendarLang has the same keys.
const fLang = lang as 'de' | 'en' | 'la';
const tFaith = faithM[fLang];
const faithSeg = faithSlugFromLang(fLang);
const calSeg = calendarSlug(fLang);
return generateBreadcrumbJsonLd([
{ name: 'Bocken', path: '/' },
{ name: tFaith.title, path: `/${faithSeg}` },
{ name: tFaith.calendar, path: `/${faithSeg}/${calSeg}` },
{ name: monthTitle, path: backHref.split('?')[0] },
{ name: `${day.name} ${formatLongDate(iso, lang)}`, path: page.url.pathname }
]);
});
const eventJsonLd = $derived({
'@context': 'https://schema.org',
'@type': 'Event',
name: day.name,
startDate: iso,
endDate: iso,
eventAttendanceMode: 'https://schema.org/MixedEventAttendanceMode',
eventStatus: 'https://schema.org/EventScheduled',
location: { '@type': 'VirtualLocation', url: `https://bocken.org${page.url.pathname}` },
organizer: { '@type': 'Person', name: 'Alexander Bocken' },
description: day.name
});
</script> </script>
<svelte:head> <svelte:head>
<title>{day.name}{formatLongDate(iso, lang)}</title> <title>{day.name}{formatLongDate(iso, lang)}</title>
<meta name="description" content={day.name} /> <meta name="description" content={day.name} />
{@html `<script type="application/ld+json">${JSON.stringify(breadcrumbJsonLd)}</script>`}
{@html `<script type="application/ld+json">${JSON.stringify(eventJsonLd)}</script>`}
</svelte:head> </svelte:head>
<main class="detail-wrap"> <main class="detail-wrap">
@@ -24,7 +24,8 @@
import ReginaCaeli from "$lib/components/faith/prayers/ReginaCaeli.svelte"; import ReginaCaeli from "$lib/components/faith/prayers/ReginaCaeli.svelte";
import StickyImage from "$lib/components/faith/StickyImage.svelte"; import StickyImage from "$lib/components/faith/StickyImage.svelte";
import AngelusStreakCounter from "$lib/components/faith/AngelusStreakCounter.svelte"; import AngelusStreakCounter from "$lib/components/faith/AngelusStreakCounter.svelte";
import { m } from '$lib/js/faithI18n'; import { m, faithSlugFromLang, prayersSlug } from '$lib/js/faithI18n';
import { generateBreadcrumbJsonLd } from '$lib/js/breadcrumbJsonLd';
/** @typedef {import('$lib/js/faithI18n').FaithLang} FaithLang */ /** @typedef {import('$lib/js/faithI18n').FaithLang} FaithLang */
let { data } = $props(); let { data } = $props();
@@ -99,6 +100,13 @@
// Toggle href for no-JS fallback (navigates to opposite latin state) // Toggle href for no-JS fallback (navigates to opposite latin state)
const latinToggleHref = $derived(data.initialLatin ? '?latin=0' : '?'); const latinToggleHref = $derived(data.initialLatin ? '?latin=0' : '?');
const breadcrumbJsonLd = $derived(generateBreadcrumbJsonLd([
{ name: 'Bocken', path: '/' },
{ name: t.title, path: `/${faithSlugFromLang(lang)}` },
{ name: t.prayers, path: `/${faithSlugFromLang(lang)}/${prayersSlug(lang)}` },
{ name: prayerName, path: `/${faithSlugFromLang(lang)}/${prayersSlug(lang)}/${data.prayer}` }
]));
onMount(() => { onMount(() => {
// Clean up URL params after hydration (state is now in component state) // Clean up URL params after hydration (state is now in component state)
if (window.location.search) { if (window.location.search) {
@@ -109,6 +117,7 @@
<svelte:head> <svelte:head>
<title>{prayerName} - Bocken</title> <title>{prayerName} - Bocken</title>
{@html `<script type="application/ld+json">${JSON.stringify(breadcrumbJsonLd)}</script>`}
</svelte:head> </svelte:head>
<style> <style>
@@ -0,0 +1,6 @@
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ setHeaders }) => {
setHeaders({ 'Cache-Control': 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400' });
return {};
};
@@ -84,6 +84,33 @@
<svelte:head> <svelte:head>
<title>Die 10 Gebote Gottes - Bocken</title> <title>Die 10 Gebote Gottes - Bocken</title>
<meta name="description" content="Die Zehn Gebote Gottes - Katechese nach P. Martin Ramm FSSP" /> <meta name="description" content="Die Zehn Gebote Gottes - Katechese nach P. Martin Ramm FSSP" />
{@html `<script type="application/ld+json">${JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Article',
headline: 'Die Zehn Gebote Gottes',
description: 'Katechese zu den Zehn Geboten Gottes — Aufbereitung des Glaubenskurses (3. Hauptteil) von P. Martin Ramm FSSP.',
inLanguage: 'de',
url: 'https://bocken.org/glaube/katechese/zehn-gebote',
mainEntityOfPage: 'https://bocken.org/glaube/katechese/zehn-gebote',
isAccessibleForFree: true,
articleSection: 'Katechese',
about: { '@type': 'Thing', name: 'Dekalog' },
author: { '@type': 'Person', name: 'Alexander Bocken', url: 'https://bocken.org/' },
publisher: { '@type': 'Person', name: 'Alexander Bocken', url: 'https://bocken.org/' },
citation: [
{ '@type': 'CreativeWork', name: 'Glaubenskurs, 3. Hauptteil', author: { '@type': 'Person', name: 'P. Martin Ramm FSSP' } }
]
})}</script>`}
{@html `<script type="application/ld+json">${JSON.stringify({
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Bocken', item: 'https://bocken.org/' },
{ '@type': 'ListItem', position: 2, name: 'Glaube', item: 'https://bocken.org/glaube' },
{ '@type': 'ListItem', position: 3, name: 'Katechese', item: 'https://bocken.org/glaube/katechese' },
{ '@type': 'ListItem', position: 4, name: 'Die Zehn Gebote Gottes', item: 'https://bocken.org/glaube/katechese/zehn-gebote' }
]
})}</script>`}
</svelte:head> </svelte:head>
<div class="page-wrapper"> <div class="page-wrapper">
@@ -0,0 +1,6 @@
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ setHeaders }) => {
setHeaders({ 'Cache-Control': 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400' });
return {};
};
@@ -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}
@@ -3,10 +3,15 @@ import type { PageServerLoad, Actions } from './$types';
import { stripHtmlTags } from '$lib/js/stripHtmlTags'; import { stripHtmlTags } from '$lib/js/stripHtmlTags';
import { errorWithVerse } from '$lib/server/errorQuote'; import { errorWithVerse } from '$lib/server/errorQuote';
export const load: PageServerLoad = async ({ fetch, params, locals, url }) => { export const load: PageServerLoad = async ({ fetch, params, locals, url, setHeaders }) => {
const isEnglish = params.recipeLang === 'recipes'; const isEnglish = params.recipeLang === 'recipes';
const apiBase = `/api/${params.recipeLang}`; const apiBase = `/api/${params.recipeLang}`;
// Recipe detail rarely changes — cache 5min in browser, 30min at edge,
// serve stale up to 24h while revalidating. Logged-in viewers see fresh
// content because cookies typically bust the shared cache.
setHeaders({ 'Cache-Control': 'public, max-age=300, s-maxage=1800, stale-while-revalidate=86400' });
const res = await fetch(`${apiBase}/items/${params.name}`); const res = await fetch(`${apiBase}/items/${params.name}`);
if (!res.ok) { if (!res.ok) {
@@ -266,6 +266,9 @@ h2{
<meta property="og:image:type" content="image/webp" /> <meta property="og:image:type" content="image/webp" />
<meta property="og:image:alt" content="{data.strippedName}" /> <meta property="og:image:alt" content="{data.strippedName}" />
{@html `<script type="application/ld+json">${JSON.stringify(data.recipeJsonLd)}</script>`} {@html `<script type="application/ld+json">${JSON.stringify(data.recipeJsonLd)}</script>`}
{@html `<script type="application/ld+json">${JSON.stringify(data.breadcrumbJsonLd)}</script>`}
<!-- Self-canonical: collapse query-param variants (?multiplier=2, ?utm=...) to the bare slug -->
<link rel="canonical" href="https://bocken.org/{data.recipeLang}/{data.short_name}" />
<!-- SEO: hreflang tags --> <!-- SEO: hreflang tags -->
<link rel="alternate" hreflang="de" href="https://bocken.org/rezepte/{data.germanShortName}" /> <link rel="alternate" hreflang="de" href="https://bocken.org/rezepte/{data.germanShortName}" />
{#if isEnglish || data.hasEnglishTranslation} {#if isEnglish || data.hasEnglishTranslation}
@@ -1,5 +1,7 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { generateRecipeJsonLd } from '$lib/js/recipeJsonLd'; import { generateRecipeJsonLd } from '$lib/js/recipeJsonLd';
import { generateBreadcrumbJsonLd } from '$lib/js/breadcrumbJsonLd';
import { m as recipesM } from '$lib/js/recipesI18n';
import { isOffline, canUseOfflineData } from '$lib/offline/helpers'; import { isOffline, canUseOfflineData } from '$lib/offline/helpers';
import { getFullRecipe, isOfflineDataAvailable } from '$lib/offline/db'; import { getFullRecipe, isOfflineDataAvailable } from '$lib/offline/db';
import { stripHtmlTags } from '$lib/js/stripHtmlTags'; import { stripHtmlTags } from '$lib/js/stripHtmlTags';
@@ -173,7 +175,14 @@ 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');
const tRecipes = recipesM[isEnglish ? 'en' : 'de'];
const breadcrumbJsonLd = generateBreadcrumbJsonLd([
{ name: 'Bocken', path: '/' },
{ name: tRecipes.all_recipes, path: `/${params.recipeLang}` },
...(item.category ? [{ name: item.category, path: `/${params.recipeLang}/category/${item.category}` }] : []),
{ name: stripHtmlTags(item.name || ''), path: `/${params.recipeLang}/${item.short_name}` },
]);
// 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)
@@ -194,6 +203,7 @@ export const load: PageLoad = async ({ fetch, params, url, data }) => {
isFavorite, isFavorite,
multiplier, multiplier,
recipeJsonLd, recipeJsonLd,
breadcrumbJsonLd,
hasEnglishTranslation, hasEnglishTranslation,
englishShortName, englishShortName,
germanShortName, germanShortName,
@@ -4,6 +4,7 @@
let { data } = $props<{ data: PageData }>(); let { data } = $props<{ data: PageData }>();
import TagCloud from '$lib/components/TagCloud.svelte'; import TagCloud from '$lib/components/TagCloud.svelte';
import TagBall from '$lib/components/TagBall.svelte'; import TagBall from '$lib/components/TagBall.svelte';
import Seo from '$lib/components/Seo.svelte';
import { m, type RecipesLang } from '$lib/js/recipesI18n'; import { m, type RecipesLang } from '$lib/js/recipesI18n';
const lang = $derived(data.lang as RecipesLang); const lang = $derived(data.lang as RecipesLang);
@@ -14,9 +15,11 @@
}); });
</script> </script>
<svelte:head> <Seo
<title>{labels.title} - {labels.siteTitle}</title> title={`${labels.title}${labels.siteTitle}`}
</svelte:head> description={t.categories_meta_description}
lang={lang}
/>
<h1 class="sr-only">{labels.title}</h1> <h1 class="sr-only">{labels.title}</h1>
<section> <section>
<TagCloud> <TagCloud>
@@ -4,6 +4,7 @@
import type { BriefRecipeType } from '$types/types'; import type { BriefRecipeType } from '$types/types';
import Search from '$lib/components/recipes/Search.svelte'; import Search from '$lib/components/recipes/Search.svelte';
import CompactCard from '$lib/components/recipes/CompactCard.svelte'; import CompactCard from '$lib/components/recipes/CompactCard.svelte';
import Seo from '$lib/components/Seo.svelte';
import { rand_array } from '$lib/js/randomize'; import { rand_array } from '$lib/js/randomize';
type RecipeItem = BriefRecipeType & { isFavorite: boolean }; type RecipeItem = BriefRecipeType & { isFavorite: boolean };
@@ -38,9 +39,11 @@
} }
</style> </style>
<svelte:head> <Seo
<title>{data.category} - {siteTitle}</title> title={`${data.category}${siteTitle}`}
</svelte:head> description={`${t.category_meta_prefix} ${data.category}.`}
lang={lang}
/>
<h1>{label} <q>{data.category}</q>:</h1> <h1>{label} <q>{data.category}</q>:</h1>
<Search category={data.category} lang={data.lang} recipes={data.allRecipes} isLoggedIn={!!data.session?.user} onSearchResults={handleSearchResults}></Search> <Search category={data.category} lang={data.lang} recipes={data.allRecipes} isLoggedIn={!!data.session?.user} onSearchResults={handleSearchResults}></Search>
@@ -2,6 +2,7 @@
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import type { PageData } from './$types'; import type { PageData } from './$types';
let { data } = $props<{ data: PageData }>(); let { data } = $props<{ data: PageData }>();
import Seo from '$lib/components/Seo.svelte';
import { m, type RecipesLang } from '$lib/js/recipesI18n'; import { m, type RecipesLang } from '$lib/js/recipesI18n';
const lang = $derived(data.lang as RecipesLang); const lang = $derived(data.lang as RecipesLang);
@@ -13,9 +14,11 @@
}); });
</script> </script>
<svelte:head> <Seo
<title>{labels.title} - {labels.siteTitle}</title> title={`${labels.title}${labels.siteTitle}`}
</svelte:head> description={t.icons_meta_description}
lang={lang}
/>
<style> <style>
a{ a{
font-family: "Noto Color Emoji", "Noto Color Emoji Subset", emoji, sans-serif; font-family: "Noto Color Emoji", "Noto Color Emoji Subset", emoji, sans-serif;
@@ -3,6 +3,7 @@
import type { PageData } from './$types'; import type { PageData } from './$types';
import IconLayout from '$lib/components/recipes/IconLayout.svelte'; import IconLayout from '$lib/components/recipes/IconLayout.svelte';
import CompactCard from '$lib/components/recipes/CompactCard.svelte'; import CompactCard from '$lib/components/recipes/CompactCard.svelte';
import Seo from '$lib/components/Seo.svelte';
let { data } = $props<{ data: PageData }>(); let { data } = $props<{ data: PageData }>();
import { rand_array } from '$lib/js/randomize'; import { rand_array } from '$lib/js/randomize';
@@ -30,9 +31,11 @@
}); });
</script> </script>
<svelte:head> <Seo
<title>{data.icon} - {siteTitle}</title> title={`${data.icon}${siteTitle}`}
</svelte:head> description={`${t.icon_meta_prefix} ${data.icon}.`}
lang={lang}
/>
<IconLayout icons={data.icons} active_icon={data.icon} routePrefix={resolve('/[recipeLang=recipeLang]', { recipeLang: data.recipeLang })} lang={data.lang} recipes={data.season} isLoggedIn={!!data.session?.user} onSearchResults={handleSearchResults}> <IconLayout icons={data.icons} active_icon={data.icon} routePrefix={resolve('/[recipeLang=recipeLang]', { recipeLang: data.recipeLang })} lang={data.lang} recipes={data.season} isLoggedIn={!!data.session?.user} onSearchResults={handleSearchResults}>
{#snippet recipesSlot()} {#snippet recipesSlot()}
@@ -3,6 +3,7 @@
import type { PageData } from './$types'; import type { PageData } from './$types';
import SeasonLayout from '$lib/components/recipes/SeasonLayout.svelte' import SeasonLayout from '$lib/components/recipes/SeasonLayout.svelte'
import CompactCard from '$lib/components/recipes/CompactCard.svelte'; import CompactCard from '$lib/components/recipes/CompactCard.svelte';
import Seo from '$lib/components/Seo.svelte';
let { data } = $props<{ data: PageData }>(); let { data } = $props<{ data: PageData }>();
let current_month = new Date().getMonth() + 1 let current_month = new Date().getMonth() + 1
import { rand_array } from '$lib/js/randomize'; import { rand_array } from '$lib/js/randomize';
@@ -38,9 +39,11 @@
}); });
</script> </script>
<svelte:head> <Seo
<title>{labels.title} - {labels.siteTitle}</title> title={`${labels.title}${labels.siteTitle}`}
</svelte:head> description={t.in_season_meta_description}
lang={lang}
/>
<SeasonLayout active_index={current_month-1} {months} routePrefix={resolve('/[recipeLang=recipeLang]', { recipeLang: data.recipeLang })} lang={data.lang} recipes={data.season} isLoggedIn={!!data.session?.user} onSearchResults={handleSearchResults}> <SeasonLayout active_index={current_month-1} {months} routePrefix={resolve('/[recipeLang=recipeLang]', { recipeLang: data.recipeLang })} lang={data.lang} recipes={data.season} isLoggedIn={!!data.session?.user} onSearchResults={handleSearchResults}>
{#snippet recipesSlot()} {#snippet recipesSlot()}
@@ -3,6 +3,7 @@
import type { PageData } from './$types'; import type { PageData } from './$types';
import SeasonLayout from '$lib/components/recipes/SeasonLayout.svelte'; import SeasonLayout from '$lib/components/recipes/SeasonLayout.svelte';
import CompactCard from '$lib/components/recipes/CompactCard.svelte'; import CompactCard from '$lib/components/recipes/CompactCard.svelte';
import Seo from '$lib/components/Seo.svelte';
let { data } = $props<{ data: PageData }>(); let { data } = $props<{ data: PageData }>();
import { m, type RecipesLang } from '$lib/js/recipesI18n'; import { m, type RecipesLang } from '$lib/js/recipesI18n';
@@ -36,9 +37,11 @@
}); });
</script> </script>
<svelte:head> <Seo
<title>{currentMonth} - {siteTitle}</title> title={`${currentMonth}${siteTitle}`}
</svelte:head> description={`${t.season_meta_prefix} ${currentMonth}.`}
lang={lang}
/>
<SeasonLayout active_index={data.month -1} {months} routePrefix={resolve('/[recipeLang=recipeLang]', { recipeLang: data.recipeLang })} lang={data.lang} recipes={data.season} onSearchResults={handleSearchResults}> <SeasonLayout active_index={data.month -1} {months} routePrefix={resolve('/[recipeLang=recipeLang]', { recipeLang: data.recipeLang })} lang={data.lang} recipes={data.season} onSearchResults={handleSearchResults}>
{#snippet recipesSlot()} {#snippet recipesSlot()}
@@ -4,6 +4,7 @@
let { data } = $props<{ data: PageData }>(); let { data } = $props<{ data: PageData }>();
import TagCloud from '$lib/components/TagCloud.svelte'; import TagCloud from '$lib/components/TagCloud.svelte';
import TagBall from '$lib/components/TagBall.svelte'; import TagBall from '$lib/components/TagBall.svelte';
import Seo from '$lib/components/Seo.svelte';
import { m, type RecipesLang } from '$lib/js/recipesI18n'; import { m, type RecipesLang } from '$lib/js/recipesI18n';
const lang = $derived(data.lang as RecipesLang); const lang = $derived(data.lang as RecipesLang);
@@ -22,9 +23,11 @@
); );
</script> </script>
<svelte:head> <Seo
<title>{labels.title} - {labels.siteTitle}</title> title={`${labels.title}${labels.siteTitle}`}
</svelte:head> description={t.keywords_meta_description}
lang={lang}
/>
<style> <style>
.search-wrap { .search-wrap {
max-width: 400px; max-width: 400px;
@@ -4,6 +4,7 @@
import type { BriefRecipeType } from '$types/types'; import type { BriefRecipeType } from '$types/types';
import CompactCard from '$lib/components/recipes/CompactCard.svelte'; import CompactCard from '$lib/components/recipes/CompactCard.svelte';
import Search from '$lib/components/recipes/Search.svelte'; import Search from '$lib/components/recipes/Search.svelte';
import Seo from '$lib/components/Seo.svelte';
import { rand_array } from '$lib/js/randomize'; import { rand_array } from '$lib/js/randomize';
type RecipeItem = BriefRecipeType & { isFavorite: boolean }; type RecipeItem = BriefRecipeType & { isFavorite: boolean };
@@ -38,9 +39,11 @@
} }
</style> </style>
<svelte:head> <Seo
<title>{data.tag} - {siteTitle}</title> title={`${data.tag}${siteTitle}`}
</svelte:head> description={`${t.tag_meta_prefix} ${data.tag}.`}
lang={lang}
/>
<h1>{label} <q>{data.tag}</q>:</h1> <h1>{label} <q>{data.tag}</q>:</h1>
<Search tag={data.tag} lang={data.lang} recipes={data.allRecipes} isLoggedIn={!!data.session?.user} onSearchResults={handleSearchResults}></Search> <Search tag={data.tag} lang={data.lang} recipes={data.allRecipes} isLoggedIn={!!data.session?.user} onSearchResults={handleSearchResults}></Search>
@@ -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({
+263
View File
@@ -0,0 +1,263 @@
import type { RequestHandler } from './$types';
import { ARGUMENTS, POS_ARGUMENTS } from '$lib/data/apologetik';
import { validPrayerSlugs } from '$lib/data/prayerSlugs';
import { Recipe } from '$models/Recipe';
import { dbConnect } from '$utils/db';
const SITE = 'https://bocken.org';
const BUILD_LASTMOD = new Date().toISOString().slice(0, 10);
type ImageEntry = { loc: string; title?: string; caption?: string };
type Url = {
loc: string;
lastmod?: string;
changefreq?: string;
priority?: number;
alternates?: { hreflang: string; href: string }[];
images?: ImageEntry[];
};
function xmlEscape(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
}
function renderImage(img: ImageEntry): string {
const parts = [
` <image:image>`,
` <image:loc>${xmlEscape(img.loc)}</image:loc>`,
img.title ? ` <image:title>${xmlEscape(img.title)}</image:title>` : '',
img.caption ? ` <image:caption>${xmlEscape(img.caption)}</image:caption>` : '',
` </image:image>`,
].filter(Boolean);
return parts.join('\n');
}
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 imgs = (u.images ?? []).map(renderImage).join('\n');
const parts = [
` <url>`,
` <loc>${xmlEscape(u.loc)}</loc>`,
u.lastmod ? ` <lastmod>${u.lastmod}</lastmod>` : '',
u.changefreq ? ` <changefreq>${u.changefreq}</changefreq>` : '',
u.priority !== undefined ? ` <priority>${u.priority.toFixed(1)}</priority>` : '',
alt,
imgs,
` </url>`,
].filter(Boolean);
return parts.join('\n');
}
export const GET: RequestHandler = async ({ fetch, setHeaders }) => {
const urls: Url[] = [];
// Home
urls.push({ loc: `${SITE}/`, lastmod: BUILD_LASTMOD, 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}`,
lastmod: BUILD_LASTMOD,
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}`,
lastmod: BUILD_LASTMOD,
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}`,
lastmod: BUILD_LASTMOD,
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}`,
lastmod: BUILD_LASTMOD,
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}`,
lastmod: BUILD_LASTMOD,
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}`,
lastmod: BUILD_LASTMOD,
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}`,
lastmod: BUILD_LASTMOD,
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}`,
lastmod: BUILD_LASTMOD,
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}`,
lastmod: BUILD_LASTMOD,
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}`,
lastmod: BUILD_LASTMOD,
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}`, lastmod: BUILD_LASTMOD, changefreq: 'yearly', priority: 0.5 });
urls.push({ loc: `${SITE}/faith/prayers/${slug}`, lastmod: BUILD_LASTMOD, changefreq: 'yearly', priority: 0.5 });
}
// Recipes — direct DB read so we get dateModified for <lastmod> and image
// paths for <image:image>. Tolerate failure so sitemap still ships the
// static URLs above.
try {
await dbConnect();
const recipes = await Recipe.find(
{},
'short_name name dateModified images translations.en.short_name translations.en.name translations.en.translationStatus'
).lean();
for (const r of recipes) {
const lastmod = r.dateModified ? new Date(r.dateModified).toISOString().slice(0, 10) : BUILD_LASTMOD;
const images: ImageEntry[] = (r.images ?? [])
.filter((img) => img?.mediapath)
.map((img) => ({
loc: `${SITE}/static/rezepte/full/${img.mediapath}`,
title: r.name,
caption: img.caption || img.alt,
}));
if (r.short_name) {
urls.push({
loc: `${SITE}/rezepte/${encodeURIComponent(r.short_name)}`,
lastmod,
changefreq: 'monthly',
priority: 0.7,
images,
});
}
const enShort = r.translations?.en?.short_name;
const enName = r.translations?.en?.name;
const approved = r.translations?.en?.translationStatus === 'approved';
if (enShort && approved) {
urls.push({
loc: `${SITE}/recipes/${encodeURIComponent(enShort)}`,
lastmod,
changefreq: 'monthly',
priority: 0.7,
images: images.map((img) => ({ ...img, title: enName ?? img.title })),
});
}
}
} catch (e) {
console.error('[sitemap] recipe DB query 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" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
${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: / 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