feat(website): turn blog tags into topic hubs

Tags were decorative: a plain span on every card and post header, with no
page, filter or search behind them, and a vocabulary of 32 slugs across 16
posts where 22 slugs appeared once. They now form a closed vocabulary of ten
topics (src/lib/blog-tags.ts) that the content collection schema enforces, so
an unknown slug fails the build instead of minting a new tag.

Every used tag gets a hub at /blog/tag/<tag>/ with CollectionPage and
BreadcrumbList structured data; the blog index and the hubs show a "Topics"
rail with post counts; and every chip links to its hub. The cards become
<article> elements with the title link stretched over the card, because chip
links cannot nest inside a card that is one big <a>.

Posts are retagged to the new vocabulary. A structural test covers the rail,
each hub, the chip targets, the sitemap and the absence of nested anchors.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-05 21:51:26 +02:00
1 parent b55a3cc322
commit 812ec69fd4
31 files changed
+439 -80

No files matched your search

@@ -1,10 +1,13 @@
---
import BlogTagChip from './BlogTagChip.astro';
import type { BlogTag } from '../lib/blog-tags';
interface Props {
title: string;
description: string;
pubDate: Date;
slug: string;
tags?: string[];
tags?: BlogTag[];
heroImage?: string;
}
@@ -17,10 +20,8 @@ const formattedDate = pubDate.toLocaleDateString('en-US', {
});
---
<a
href={`/iptvnator/blog/${slug}/`}
class="card-panel group block overflow-hidden"
>
{/* Same stretched-link pattern as BlogPostCard: the tag chips are links of their own. */}
<article class="card-panel group relative block overflow-hidden">
<div class="grid items-center gap-8 md:grid-cols-2">
<!-- Thumbnail -->
<div class="overflow-hidden rounded-lg -mx-6 -mt-6 md:-my-6 md:-ml-6 md:mr-0">
@@ -55,15 +56,18 @@ const formattedDate = pubDate.toLocaleDateString('en-US', {
{tags.length > 0 && (
<div class="mt-2 flex flex-wrap gap-1.5">
{tags.map((tag) => (
<span class="rounded-md border border-surface-800 bg-surface-900/60 px-2 py-0.5 text-xs text-surface-300">
{tag}
</span>
<BlogTagChip tag={tag} />
))}
</div>
)}
<h3 class="mt-4 font-display text-2xl font-bold tracking-tight text-surface-50 transition-colors duration-200 group-hover:text-accent-400 sm:text-3xl">
{title}
<a
href={`/iptvnator/blog/${slug}/`}
class="card-link after:absolute after:inset-0 after:rounded-xl after:content-[''] focus-visible:outline-none focus-visible:after:ring-2 focus-visible:after:ring-accent-500"
>
{title}
</a>
</h3>
<p class="mt-3 text-sm leading-relaxed text-surface-400">
@@ -84,4 +88,4 @@ const formattedDate = pubDate.toLocaleDateString('en-US', {
</span>
</div>
</div>
</a>
</article>