Compare commits
4 Commits
b10634f831
...
4623d7a1f7
| Author | SHA1 | Date | |
|---|---|---|---|
|
4623d7a1f7
|
|||
|
d59cc0a732
|
|||
|
ecbd24d7a4
|
|||
|
7e33ea833e
|
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.60.0",
|
||||
"version": "1.64.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="%lang%">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
|
||||
@@ -7,6 +7,50 @@ import { dbConnect } from "./utils/db"
|
||||
import { errorWithVerse, getRandomVerse } from "$lib/server/errorQuote"
|
||||
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]) {
|
||||
const marks: Record<string, number> = {};
|
||||
event.locals.timing = {
|
||||
@@ -143,6 +187,8 @@ export const handleError: HandleServerError = async ({ error, event, status, mes
|
||||
|
||||
export const handle: Handle = sequence(
|
||||
timing,
|
||||
htmlLang,
|
||||
noindex,
|
||||
auth.handle,
|
||||
authorization
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
@@ -138,6 +138,16 @@ export const de = {
|
||||
icons_title: 'Icons',
|
||||
tips_title: 'Tipps & Tricks',
|
||||
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_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',
|
||||
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',
|
||||
|
||||
@@ -138,6 +138,16 @@ export const en = {
|
||||
icons_title: 'Icons',
|
||||
tips_title: 'Tips & Tricks',
|
||||
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_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',
|
||||
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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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;">
|
||||
|
||||
@@ -6,6 +6,33 @@
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
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
|
||||
* don't re-run SvelteKit load() otherwise. Throttled: at most once per 5 min. */
|
||||
const REFRESH_MIN_GAP_MS = 5 * 60 * 1000;
|
||||
@@ -43,6 +70,10 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{@html `<script type="application/ld+json">${JSON.stringify(websiteJsonLd)}</script>`}
|
||||
</svelte:head>
|
||||
|
||||
{@render children()}
|
||||
<Toast />
|
||||
<ConfirmDialog />
|
||||
@@ -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;
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { LayoutServerLoad } from './$types';
|
||||
|
||||
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 prefix = `/${faithLang}/${params.apologetikSlug}`;
|
||||
|
||||
@@ -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">
|
||||
|
||||
+6
-5
@@ -5,6 +5,7 @@
|
||||
import ApologetikToc from '$lib/components/faith/ApologetikToc.svelte';
|
||||
|
||||
import { m, type FaithLang } from '$lib/js/faithI18n';
|
||||
import Seo from '$lib/components/Seo.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
const faithLang = $derived(data?.faithLang ?? 'faith');
|
||||
@@ -98,12 +99,12 @@
|
||||
const archetypes = $derived(Object.values(ARCHETYPES));
|
||||
</script>
|
||||
|
||||
<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."
|
||||
<Seo
|
||||
title={`${heading} · bocken.org`}
|
||||
description={lede}
|
||||
lang={lang}
|
||||
/>
|
||||
<svelte:head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||
<link
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { getArchetypes, getArguments } from '$lib/data/apologetik';
|
||||
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 [archetypes, args] = await Promise.all([getArchetypes(lang), getArguments(lang)]);
|
||||
return { archetypes, args };
|
||||
|
||||
+2
@@ -96,6 +96,8 @@
|
||||
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"
|
||||
/>
|
||||
{@html `<script type="application/ld+json">${JSON.stringify(data.qaJsonLd)}</script>`}
|
||||
{@html `<script type="application/ld+json">${JSON.stringify(data.breadcrumbJsonLd)}</script>`}
|
||||
</svelte:head>
|
||||
|
||||
<ApologetikToc title={tocLabel} items={tocItems} activeId={arg.id} />
|
||||
|
||||
+21
-3
@@ -1,9 +1,14 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
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';
|
||||
|
||||
export const load: PageLoad = async ({ params, parent }) => {
|
||||
const { lang } = await parent();
|
||||
export const load: PageLoad = async ({ params, parent, setHeaders }) => {
|
||||
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([
|
||||
findArgumentLang(params.argId, lang),
|
||||
getArchetypes(lang),
|
||||
@@ -17,11 +22,24 @@ export const load: PageLoad = async ({ params, parent }) => {
|
||||
error(404, 'Voice not found');
|
||||
}
|
||||
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 {
|
||||
argument: arg,
|
||||
archetypes,
|
||||
args,
|
||||
alexPicks: ALEX_PICKS[params.argId] ?? [],
|
||||
initialArchId
|
||||
initialArchId,
|
||||
qaJsonLd,
|
||||
breadcrumbJsonLd
|
||||
};
|
||||
};
|
||||
|
||||
+2
-1
@@ -7,7 +7,8 @@ import {
|
||||
} from '$lib/data/apologetik';
|
||||
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 [voices, layers, args] = await Promise.all([
|
||||
getPosVoices(lang),
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import ApologetikToc from '$lib/components/faith/ApologetikToc.svelte';
|
||||
|
||||
import { m, type FaithLang } from '$lib/js/faithI18n';
|
||||
import Seo from '$lib/components/Seo.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
const faithLang = $derived(data?.faithLang ?? 'faith');
|
||||
@@ -137,12 +138,12 @@
|
||||
);
|
||||
</script>
|
||||
|
||||
<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."
|
||||
<Seo
|
||||
title={`${labels.heading} · bocken.org`}
|
||||
description={labels.lede}
|
||||
lang={lang}
|
||||
/>
|
||||
<svelte:head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||
<link
|
||||
|
||||
+24
-3
@@ -8,9 +8,18 @@ import {
|
||||
POS_ARGUMENTS as EN_POS_ARGUMENTS
|
||||
} from '$lib/data/apologetik';
|
||||
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 }) => {
|
||||
const { lang } = await parent();
|
||||
export const load: PageServerLoad = async ({ params, parent, setHeaders }) => {
|
||||
// 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([
|
||||
findPositiveArgumentLang(params.posArgId, lang),
|
||||
getPosVoices(lang),
|
||||
@@ -46,5 +55,17 @@ export const load: PageServerLoad = async ({ params, parent }) => {
|
||||
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 };
|
||||
};
|
||||
|
||||
+2
@@ -104,6 +104,8 @@
|
||||
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"
|
||||
/>
|
||||
{@html `<script type="application/ld+json">${JSON.stringify(data.articleJsonLd)}</script>`}
|
||||
{@html `<script type="application/ld+json">${JSON.stringify(data.breadcrumbJsonLd)}</script>`}
|
||||
</svelte:head>
|
||||
|
||||
<ApologetikToc title={tocLabel} items={tocItems} activeId={arg.id} />
|
||||
|
||||
+2
-2
@@ -163,8 +163,8 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{pageTitle} — Bocken</title>
|
||||
<meta name="description" content={pageTitle} />
|
||||
<title>{monthTitle} · {pageTitle} ({riteSubtitle}) — Bocken</title>
|
||||
<meta name="description" content={`${pageTitle} ${monthTitle} — ${riteSubtitle}.`} />
|
||||
</svelte:head>
|
||||
|
||||
<main class="cal-wrap">
|
||||
|
||||
+32
@@ -5,6 +5,8 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { formatLongDate, getMonthName, properLabel, type CalendarLang, m, m1962 } from '../../../../../calendarI18n';
|
||||
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();
|
||||
|
||||
@@ -78,11 +80,41 @@
|
||||
const nextHref = $derived(shiftDay(1));
|
||||
|
||||
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>
|
||||
|
||||
<svelte:head>
|
||||
<title>{day.name} — {formatLongDate(iso, lang)}</title>
|
||||
<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>
|
||||
|
||||
<main class="detail-wrap">
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
import ReginaCaeli from "$lib/components/faith/prayers/ReginaCaeli.svelte";
|
||||
import StickyImage from "$lib/components/faith/StickyImage.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 */
|
||||
|
||||
let { data } = $props();
|
||||
@@ -99,6 +100,13 @@
|
||||
// Toggle href for no-JS fallback (navigates to opposite latin state)
|
||||
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(() => {
|
||||
// Clean up URL params after hydration (state is now in component state)
|
||||
if (window.location.search) {
|
||||
@@ -109,6 +117,7 @@
|
||||
|
||||
<svelte:head>
|
||||
<title>{prayerName} - Bocken</title>
|
||||
{@html `<script type="application/ld+json">${JSON.stringify(breadcrumbJsonLd)}</script>`}
|
||||
</svelte:head>
|
||||
|
||||
<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>
|
||||
<title>Die 10 Gebote Gottes - Bocken</title>
|
||||
<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>
|
||||
|
||||
<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
|
||||
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}
|
||||
|
||||
@@ -3,10 +3,15 @@ import type { PageServerLoad, Actions } from './$types';
|
||||
import { stripHtmlTags } from '$lib/js/stripHtmlTags';
|
||||
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 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}`);
|
||||
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -266,6 +266,9 @@ h2{
|
||||
<meta property="og:image:type" content="image/webp" />
|
||||
<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.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 -->
|
||||
<link rel="alternate" hreflang="de" href="https://bocken.org/rezepte/{data.germanShortName}" />
|
||||
{#if isEnglish || data.hasEnglishTranslation}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { browser } from '$app/environment';
|
||||
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 { getFullRecipe, isOfflineDataAvailable } from '$lib/offline/db';
|
||||
import { stripHtmlTags } from '$lib/js/stripHtmlTags';
|
||||
@@ -173,7 +175,14 @@ 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');
|
||||
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 English page: germanShortName is already in item (from API)
|
||||
@@ -194,6 +203,7 @@ export const load: PageLoad = async ({ fetch, params, url, data }) => {
|
||||
isFavorite,
|
||||
multiplier,
|
||||
recipeJsonLd,
|
||||
breadcrumbJsonLd,
|
||||
hasEnglishTranslation,
|
||||
englishShortName,
|
||||
germanShortName,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
let { data } = $props<{ data: PageData }>();
|
||||
import TagCloud from '$lib/components/TagCloud.svelte';
|
||||
import TagBall from '$lib/components/TagBall.svelte';
|
||||
import Seo from '$lib/components/Seo.svelte';
|
||||
|
||||
import { m, type RecipesLang } from '$lib/js/recipesI18n';
|
||||
const lang = $derived(data.lang as RecipesLang);
|
||||
@@ -14,9 +15,11 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{labels.title} - {labels.siteTitle}</title>
|
||||
</svelte:head>
|
||||
<Seo
|
||||
title={`${labels.title} — ${labels.siteTitle}`}
|
||||
description={t.categories_meta_description}
|
||||
lang={lang}
|
||||
/>
|
||||
<h1 class="sr-only">{labels.title}</h1>
|
||||
<section>
|
||||
<TagCloud>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import type { BriefRecipeType } from '$types/types';
|
||||
import Search from '$lib/components/recipes/Search.svelte';
|
||||
import CompactCard from '$lib/components/recipes/CompactCard.svelte';
|
||||
import Seo from '$lib/components/Seo.svelte';
|
||||
import { rand_array } from '$lib/js/randomize';
|
||||
|
||||
type RecipeItem = BriefRecipeType & { isFavorite: boolean };
|
||||
@@ -38,9 +39,11 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.category} - {siteTitle}</title>
|
||||
</svelte:head>
|
||||
<Seo
|
||||
title={`${data.category} — ${siteTitle}`}
|
||||
description={`${t.category_meta_prefix} ${data.category}.`}
|
||||
lang={lang}
|
||||
/>
|
||||
|
||||
<h1>{label} <q>{data.category}</q>:</h1>
|
||||
<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 type { PageData } from './$types';
|
||||
let { data } = $props<{ data: PageData }>();
|
||||
import Seo from '$lib/components/Seo.svelte';
|
||||
|
||||
import { m, type RecipesLang } from '$lib/js/recipesI18n';
|
||||
const lang = $derived(data.lang as RecipesLang);
|
||||
@@ -13,9 +14,11 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{labels.title} - {labels.siteTitle}</title>
|
||||
</svelte:head>
|
||||
<Seo
|
||||
title={`${labels.title} — ${labels.siteTitle}`}
|
||||
description={t.icons_meta_description}
|
||||
lang={lang}
|
||||
/>
|
||||
<style>
|
||||
a{
|
||||
font-family: "Noto Color Emoji", "Noto Color Emoji Subset", emoji, sans-serif;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { PageData } from './$types';
|
||||
import IconLayout from '$lib/components/recipes/IconLayout.svelte';
|
||||
import CompactCard from '$lib/components/recipes/CompactCard.svelte';
|
||||
import Seo from '$lib/components/Seo.svelte';
|
||||
let { data } = $props<{ data: PageData }>();
|
||||
import { rand_array } from '$lib/js/randomize';
|
||||
|
||||
@@ -30,9 +31,11 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.icon} - {siteTitle}</title>
|
||||
</svelte:head>
|
||||
<Seo
|
||||
title={`${data.icon} — ${siteTitle}`}
|
||||
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}>
|
||||
{#snippet recipesSlot()}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { PageData } from './$types';
|
||||
import SeasonLayout from '$lib/components/recipes/SeasonLayout.svelte'
|
||||
import CompactCard from '$lib/components/recipes/CompactCard.svelte';
|
||||
import Seo from '$lib/components/Seo.svelte';
|
||||
let { data } = $props<{ data: PageData }>();
|
||||
let current_month = new Date().getMonth() + 1
|
||||
import { rand_array } from '$lib/js/randomize';
|
||||
@@ -38,9 +39,11 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{labels.title} - {labels.siteTitle}</title>
|
||||
</svelte:head>
|
||||
<Seo
|
||||
title={`${labels.title} — ${labels.siteTitle}`}
|
||||
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}>
|
||||
{#snippet recipesSlot()}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { PageData } from './$types';
|
||||
import SeasonLayout from '$lib/components/recipes/SeasonLayout.svelte';
|
||||
import CompactCard from '$lib/components/recipes/CompactCard.svelte';
|
||||
import Seo from '$lib/components/Seo.svelte';
|
||||
let { data } = $props<{ data: PageData }>();
|
||||
|
||||
import { m, type RecipesLang } from '$lib/js/recipesI18n';
|
||||
@@ -36,9 +37,11 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{currentMonth} - {siteTitle}</title>
|
||||
</svelte:head>
|
||||
<Seo
|
||||
title={`${currentMonth} — ${siteTitle}`}
|
||||
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}>
|
||||
{#snippet recipesSlot()}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
let { data } = $props<{ data: PageData }>();
|
||||
import TagCloud from '$lib/components/TagCloud.svelte';
|
||||
import TagBall from '$lib/components/TagBall.svelte';
|
||||
import Seo from '$lib/components/Seo.svelte';
|
||||
|
||||
import { m, type RecipesLang } from '$lib/js/recipesI18n';
|
||||
const lang = $derived(data.lang as RecipesLang);
|
||||
@@ -22,9 +23,11 @@
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{labels.title} - {labels.siteTitle}</title>
|
||||
</svelte:head>
|
||||
<Seo
|
||||
title={`${labels.title} — ${labels.siteTitle}`}
|
||||
description={t.keywords_meta_description}
|
||||
lang={lang}
|
||||
/>
|
||||
<style>
|
||||
.search-wrap {
|
||||
max-width: 400px;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import type { BriefRecipeType } from '$types/types';
|
||||
import CompactCard from '$lib/components/recipes/CompactCard.svelte';
|
||||
import Search from '$lib/components/recipes/Search.svelte';
|
||||
import Seo from '$lib/components/Seo.svelte';
|
||||
import { rand_array } from '$lib/js/randomize';
|
||||
|
||||
type RecipeItem = BriefRecipeType & { isFavorite: boolean };
|
||||
@@ -38,9 +39,11 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.tag} - {siteTitle}</title>
|
||||
</svelte:head>
|
||||
<Seo
|
||||
title={`${data.tag} — ${siteTitle}`}
|
||||
description={`${t.tag_meta_prefix} ${data.tag}.`}
|
||||
lang={lang}
|
||||
/>
|
||||
|
||||
<h1>{label} <q>{data.tag}</q>:</h1>
|
||||
<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 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,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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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
@@ -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