feat(posts): search, upcoming events, RSS feed
Deploy to Firebase Hosting / deploy (push) Has been cancelled

- Client-side search on /posts/ (full archive, ~2-char threshold)
- Upcoming-events block at top of /posts/ from isEvent+eventDate posts
- RSS feed at /posts/rss.xml via @astrojs/rss
- Layout head: rss autodiscovery link
- RSS subscribe link below the listing
- Style tweaks on QuoteBlock and PostOverviewBlock
This commit is contained in:
Peter Meier
2026-04-14 15:43:34 +02:00
parent 4bfdf540fa
commit e09bc4de2a
8 changed files with 220 additions and 5 deletions
+1
View File
@@ -17,6 +17,7 @@
"firebase-deploy:nocache": "yarn build && firebase deploy --only hosting"
},
"dependencies": {
"@astrojs/rss": "^4.0.18",
"@astrojs/sitemap": "^3.7.2",
"@astrojs/svelte": "^8.0.4",
"@fontsource-variable/lora": "^5.2.8",
+118
View File
@@ -14,6 +14,20 @@
color?: string;
}
type SearchIndexEntry = {
slug: string;
headline: string;
excerpt: string;
subheadline: string;
tags: string[];
image: string | null;
created: string | null;
isEvent: boolean;
eventDate: string | null;
};
type EventPost = PostEntry & { eventDate?: string; isEvent?: boolean };
let {
posts = [],
tags = [],
@@ -23,6 +37,8 @@
totalPosts = 0,
basePath = "/posts",
translations = {},
upcomingEvents = [],
searchIndex = null,
}: {
posts: PostEntry[];
tags: TagEntry[];
@@ -32,8 +48,37 @@
totalPosts?: number;
basePath?: string;
translations?: Translations | null;
upcomingEvents?: EventPost[];
searchIndex?: SearchIndexEntry[] | null;
} = $props();
let query = $state("");
const normalizedQuery = $derived(query.trim().toLowerCase());
const isSearching = $derived(normalizedQuery.length >= 2);
const searchResults = $derived.by<SearchIndexEntry[]>(() => {
if (!isSearching || !searchIndex) return [];
const q = normalizedQuery;
return searchIndex
.filter((p) => {
const hay = `${p.headline} ${p.excerpt} ${p.subheadline} ${p.tags.join(" ")}`.toLowerCase();
return hay.includes(q);
})
.slice(0, 40);
});
function searchResultHref(slug: string): string {
const norm = (slug ?? "").replace(/^\//, "").trim();
return norm ? `/post/${encodeURIComponent(norm)}` : "/post";
}
function formatEventDate(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" });
}
/** Immer Übersetzungen von der Astro-Seite nutzen (SSR + Insel), damit Keys wie blog_tag_all ankommen. */
function t(key: string, replacements?: Record<string, string | number>) {
return tStatic(translations ?? null, key, replacements);
@@ -48,6 +93,74 @@
</script>
<div class="blog-overview">
{#if searchIndex}
<div class="mb-4">
<label class="relative block">
<span class="sr-only">Suche</span>
<input
type="search"
bind:value={query}
placeholder="Beiträge durchsuchen…"
class="w-full rounded-sm border border-zinc-300 bg-white px-3 py-2 pr-10 text-sm focus:border-zinc-500 focus:outline-none"
/>
{#if query}
<button
type="button"
onclick={() => (query = "")}
class="absolute top-1/2 right-2 -translate-y-1/2 text-zinc-500 hover:text-zinc-900"
aria-label="Suche leeren"
>×</button>
{/if}
</label>
</div>
{/if}
{#if !isSearching && upcomingEvents.length > 0}
<section class="mb-6 rounded-sm border border-zinc-200 bg-zinc-50 p-4">
<h2 class="mb-3 text-sm font-semibold tracking-wide text-zinc-700 uppercase">Nächste Termine</h2>
<ul class="space-y-2">
{#each upcomingEvents as ev}
<li>
<a href={postHref(ev)} class="group flex items-baseline gap-3 no-underline hover:underline">
<time class="shrink-0 text-xs font-medium text-zinc-600 tabular-nums">
{formatEventDate(ev.eventDate)}
</time>
<span class="text-sm text-zinc-900 group-hover:text-zinc-950">{ev.headline}</span>
</a>
</li>
{/each}
</ul>
</section>
{/if}
{#if isSearching}
<div class="mb-3 text-xs text-zinc-600">
{searchResults.length} Treffer für „{query}"
</div>
{#if searchResults.length > 0}
<div class="py-2 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each searchResults as hit}
<a href={searchResultHref(hit.slug)} class="flex flex-col overflow-hidden border border-gray-200 bg-white text-slate-950 no-underline transition-all">
{#if hit.image}
<div class="aspect-3/2 w-full overflow-hidden bg-gray-100">
<img src={hit.image} alt={hit.headline} class="h-full w-full object-cover" loading="lazy" />
</div>
{/if}
<div class="flex flex-1 flex-col p-4">
<h3 class="mb-1 text-base font-semibold">{hit.headline}</h3>
{#if hit.excerpt}
<p class="text-sm text-zinc-700 line-clamp-3">{hit.excerpt}</p>
{/if}
</div>
</a>
{/each}
</div>
{:else}
<div class="rounded-sm border border-zinc-200 bg-white p-6 text-center text-sm text-zinc-600">
Keine Treffer. Versuche einen anderen Suchbegriff.
</div>
{/if}
{:else}
{#if tags.length > 0}
<div class="mb-2">
<div class="flex flex-wrap gap-2" role="navigation" aria-label={t(T.blog_filter_aria)}>
@@ -117,4 +230,9 @@
</div>
</div>
</div>
<div class="mt-4 text-xs text-zinc-600">
<a href="/posts/rss.xml" class="hover:underline">RSS-Feed abonnieren</a>
</div>
{/if}
</div>
@@ -59,8 +59,8 @@
{/if}
{#if posts.length > 0}
<div class="mt-3 text-sm">
<a href="/posts/" class="inline-flex items-center gap-1 text-zinc-700 hover:text-zinc-900 hover:underline">
<div class="mt-3 text-xs px-2">
<a href="/posts/" class="inline-flex items-center gap-1 text-zinc-700 hover:text-zinc-900 hover:underline !no-underline">
{t(T.post_overview_all)}
<span aria-hidden="true"></span>
</a>
+3 -3
View File
@@ -9,10 +9,10 @@
</script>
<div class={layoutClasses} data-block-type="quote" data-block-slug={block._slug}>
<blockquote class="border-l-4 border-wald-700 pl-4 {variantClass}">
<p class="text-xl font-semibold text-wald-700">{block.quote ?? ""}</p>
<blockquote class="border-l-2 border-wald-400 pl-4 {variantClass}">
<p class="text-lg font-extralight text-wald-600">{block.quote ?? ""}</p>
{#if block.author}
<cite class="mt-1 block not-italic text-sm text-wald-300">{block.author}</cite>
<cite class="block font-sans! text-xs text-wald-400"> » {block.author}</cite>
{/if}
</blockquote>
</div>
+1
View File
@@ -460,6 +460,7 @@ const cmsFromCache = getCmsFromCache();
<meta name="viewport" content="width=device-width" />
<meta name="theme-color" content="#ffffff" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="alternate" type="application/rss+xml" title="Beiträge" href="/posts/rss.xml" />
<link rel="icon" href="/favicons/favicon.ico" sizes="any" />
<link
rel="icon"
+27
View File
@@ -49,6 +49,30 @@ const filtered = filterPostsByTag(posts, null);
const totalPages = getTotalPages(filtered.length, perPage);
const pagePosts = paginate(filtered, 1, perPage);
type PostWithEvent = (typeof posts)[number] & {
isEvent?: boolean;
eventDate?: string;
_resolvedImageUrl?: string;
};
const nowMs = Date.now();
const upcomingEvents = (posts as PostWithEvent[])
.filter((p) => p.isEvent && p.eventDate && new Date(p.eventDate).getTime() >= nowMs)
.sort((a, b) => new Date(a.eventDate ?? 0).getTime() - new Date(b.eventDate ?? 0).getTime())
.slice(0, 4);
// Lightweight index for client-side search over the full archive.
const searchIndex = posts.map((p) => ({
slug: p.slug ?? p._slug ?? "",
headline: p.headline ?? "",
excerpt: p.excerpt ?? "",
subheadline: p.subheadline ?? "",
tags: (p.postTag ?? []).map((t) => (typeof t === "string" ? t : (t as { name?: string; _slug?: string }).name ?? (t as { _slug?: string })._slug ?? "")).filter(Boolean),
image: (p as PostWithEvent)._resolvedImageUrl ?? null,
created: p.created ?? null,
isEvent: Boolean((p as PostWithEvent).isEvent),
eventDate: (p as PostWithEvent).eventDate ?? null,
}));
const translations = Astro.locals.translations ?? {};
---
@@ -79,6 +103,9 @@ const translations = Astro.locals.translations ?? {};
totalPosts={filtered.length}
basePath="/posts"
translations={translations}
upcomingEvents={upcomingEvents}
searchIndex={searchIndex}
client:load
/>
)}
</div>
+33
View File
@@ -0,0 +1,33 @@
import rss from "@astrojs/rss";
import type { APIContext } from "astro";
import { getPosts } from "../../lib/cms";
import { sortPostsByDate, filterHiddenPosts, postHref } from "../../lib/blog-utils";
export const prerender = true;
export async function GET(context: APIContext) {
const site = context.site ?? new URL("https://windwiderstand.de");
let posts: Awaited<ReturnType<typeof getPosts>> = [];
try {
posts = filterHiddenPosts(sortPostsByDate(await getPosts()));
} catch {
posts = [];
}
return rss({
title: "Bürgerinitiative Vachdorf Beiträge",
description: "Aktuelles zur Windkraft, Gesundheit und Region.",
site,
items: posts.map((p) => {
const createdRaw = (p as { _created?: string; created?: string }).created ?? (p as { _created?: string })._created;
const pubDate = createdRaw ? new Date(createdRaw) : undefined;
return {
title: p.headline ?? p.slug ?? "",
description: p.excerpt ?? p.subheadline ?? "",
link: postHref(p),
pubDate: pubDate && !Number.isNaN(pubDate.getTime()) ? pubDate : undefined,
};
}),
customData: `<language>de-de</language>`,
});
}
+35
View File
@@ -66,6 +66,15 @@
dependencies:
prismjs "^1.30.0"
"@astrojs/rss@^4.0.18":
version "4.0.18"
resolved "https://registry.yarnpkg.com/@astrojs/rss/-/rss-4.0.18.tgz#9ce80003be5668545418d7e6ad53fe00e7a94d16"
integrity sha512-wc5DwKlbTEdgVAWnHy8krFTeQ42t1v/DJqeq5HtulYK3FYHE4krtRGjoyhS3eXXgfdV6Raoz2RU3wrMTFAitRg==
dependencies:
fast-xml-parser "^5.5.7"
piccolore "^0.1.3"
zod "^4.3.6"
"@astrojs/sitemap@^3.7.2":
version "3.7.2"
resolved "https://registry.yarnpkg.com/@astrojs/sitemap/-/sitemap-3.7.2.tgz#6647a3f42f75435970abf72d456e1e9d8360dcdc"
@@ -2484,6 +2493,22 @@ fast-uri@^3.0.1:
resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.0.tgz#66eecff6c764c0df9b762e62ca7edcfb53b4edfa"
integrity sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==
fast-xml-builder@^1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz#0c407a1d9d5996336c0cd76f7ff785cac6413017"
integrity sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==
dependencies:
path-expression-matcher "^1.1.3"
fast-xml-parser@^5.5.7:
version "5.5.12"
resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.5.12.tgz#6e50e5a5bbb03c1dc72a9268a9bfe677b1b623b2"
integrity sha512-nUR0q8PPfoA/svPM43Gup7vLOZWppaNrYgGmrVqrAVJa7cOH4hMG6FX9M4mQ8dZA1/ObGZHzES7Ed88hxEBSJg==
dependencies:
fast-xml-builder "^1.1.4"
path-expression-matcher "^1.5.0"
strnum "^2.2.3"
faye-websocket@0.11.4:
version "0.11.4"
resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.4.tgz#7f0d9275cfdd86a1c963dc8b65fcc451edcbb1da"
@@ -3657,6 +3682,11 @@ parse5@^7.0.0, parse5@^7.3.0:
dependencies:
entities "^6.0.0"
path-expression-matcher@^1.1.3, path-expression-matcher@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz#3b98545dc88ffebb593e2d8458d0929da9275f4a"
integrity sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==
pathe@^2.0.1, pathe@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716"
@@ -4177,6 +4207,11 @@ strip-indent@^3.0.0:
dependencies:
min-indent "^1.0.0"
strnum@^2.2.3:
version "2.2.3"
resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.2.3.tgz#0119fce02749a11bb126a4d686ac5dbdf6e57586"
integrity sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==
supports-color@^10.2.2:
version "10.2.2"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-10.2.2.tgz#466c2978cc5cd0052d542a0b576461c2b802ebb4"