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)
This commit is contained in:
2026-05-02 22:23:15 +02:00
parent d59cc0a732
commit 4623d7a1f7
16 changed files with 112 additions and 37 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.63.0",
"version": "1.64.0",
"private": true,
"type": "module",
"scripts": {
+27
View File
@@ -25,6 +25,32 @@ async function htmlLang({ event, resolve }: Parameters<Handle>[0]) {
});
}
/** 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 = {
@@ -162,6 +188,7 @@ export const handleError: HandleServerError = async ({ error, event, status, mes
export const handle: Handle = sequence(
timing,
htmlLang,
noindex,
auth.handle,
authorization
);
+10
View File
@@ -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.',
+10
View File
@@ -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.',
@@ -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>
<Seo
title={`${heading} · bocken.org`}
description={lede}
lang={lang}
/>
<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.gstatic.com" crossorigin="anonymous" />
<link
@@ -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>
<Seo
title={`${labels.heading} · bocken.org`}
description={labels.lede}
lang={lang}
/>
<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.gstatic.com" crossorigin="anonymous" />
<link
@@ -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">
@@ -267,6 +267,8 @@ h2{
<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}
@@ -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>