diff --git a/package.json b/package.json
index 0e60495b..23e1eee7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "homepage",
- "version": "1.61.0",
+ "version": "1.62.0",
"private": true,
"type": "module",
"scripts": {
diff --git a/src/app.html b/src/app.html
index 37e173bf..cff44f78 100644
--- a/src/app.html
+++ b/src/app.html
@@ -1,5 +1,5 @@
-
+
diff --git a/src/hooks.server.ts b/src/hooks.server.ts
index ab95832f..379eb80b 100644
--- a/src/hooks.server.ts
+++ b/src/hooks.server.ts
@@ -7,6 +7,24 @@ 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[0]) {
+ const lang = langFromPath(event.url.pathname);
+ return resolve(event, {
+ transformPageChunk: ({ html }) => html.replace('%lang%', lang),
+ });
+}
+
async function timing({ event, resolve }: Parameters[0]) {
const marks: Record = {};
event.locals.timing = {
@@ -143,6 +161,7 @@ export const handleError: HandleServerError = async ({ error, event, status, mes
export const handle: Handle = sequence(
timing,
+ htmlLang,
auth.handle,
authorization
);
diff --git a/src/lib/js/apologetikJsonLd.ts b/src/lib/js/apologetikJsonLd.ts
new file mode 100644
index 00000000..ee5bf75e
--- /dev/null
+++ b/src/lib/js/apologetikJsonLd.ts
@@ -0,0 +1,68 @@
+import type { Argument, Archetype } 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,
+ 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,
+ },
+ };
+}
diff --git a/src/lib/js/breadcrumbJsonLd.ts b/src/lib/js/breadcrumbJsonLd.ts
new file mode 100644
index 00000000..68ed7a9d
--- /dev/null
+++ b/src/lib/js/breadcrumbJsonLd.ts
@@ -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}`,
+ })),
+ };
+}
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 0062cab0..f425a089 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -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 @@
});
+
+ {@html ``}
+
+
{@render children()}
\ No newline at end of file
diff --git a/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/contra/[argId]/[[archId]]/+page.svelte b/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/contra/[argId]/[[archId]]/+page.svelte
index f7e523f3..3e4f8066 100644
--- a/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/contra/[argId]/[[archId]]/+page.svelte
+++ b/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/contra/[argId]/[[archId]]/+page.svelte
@@ -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 ``}
+ {@html ``}
diff --git a/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/contra/[argId]/[[archId]]/+page.ts b/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/contra/[argId]/[[archId]]/+page.ts
index c7430dfc..0273993a 100644
--- a/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/contra/[argId]/[[archId]]/+page.ts
+++ b/src/routes/[faithLang=faithLang]/[apologetikSlug=apologetikSlug]/contra/[argId]/[[archId]]/+page.ts
@@ -1,9 +1,13 @@
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();
+ 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 +21,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
};
};
diff --git a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/detail/[yyyy=calendarYear]/[mm=calendarMonth]/[dd=calendarDay]/+page.svelte b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/detail/[yyyy=calendarYear]/[mm=calendarMonth]/[dd=calendarDay]/+page.svelte
index 9e402055..838d42fd 100644
--- a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/detail/[yyyy=calendarYear]/[mm=calendarMonth]/[dd=calendarDay]/+page.svelte
+++ b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/detail/[yyyy=calendarYear]/[mm=calendarMonth]/[dd=calendarDay]/+page.svelte
@@ -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
+ });
{day.name} — {formatLongDate(iso, lang)}
+ {@html ``}
+ {@html ``}
diff --git a/src/routes/[faithLang=faithLang]/[prayers=prayersLang]/[prayer]/+page.svelte b/src/routes/[faithLang=faithLang]/[prayers=prayersLang]/[prayer]/+page.svelte
index c9b14c92..fdfecdb9 100644
--- a/src/routes/[faithLang=faithLang]/[prayers=prayersLang]/[prayer]/+page.svelte
+++ b/src/routes/[faithLang=faithLang]/[prayers=prayersLang]/[prayer]/+page.svelte
@@ -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 @@
{prayerName} - Bocken
+ {@html ``}