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 ``}