feat(seo): noindex hook, recipe self-canonical, list-page metadata
CI / update (push) Successful in 37s
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:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.63.0",
|
||||
"version": "1.64.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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.',
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
|
||||
+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>
|
||||
|
||||
<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
|
||||
|
||||
+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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user