mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
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:
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 { blogTagLabel, type BlogTag } from '../lib/blog-tags';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
pubDate: Date;
|
||||
slug: string;
|
||||
tags?: string[];
|
||||
tags?: BlogTag[];
|
||||
heroImage?: string;
|
||||
}
|
||||
|
||||
@@ -24,10 +27,12 @@ const formattedDate = pubDate.toLocaleDateString('en-US', {
|
||||
});
|
||||
---
|
||||
|
||||
<a
|
||||
href={`/iptvnator/blog/${slug}/`}
|
||||
class="card-panel group block"
|
||||
>
|
||||
{/*
|
||||
The tag chips are links, so the card cannot be one big <a>. The title link is
|
||||
stretched over the whole card with a ::after overlay instead; chips sit above
|
||||
it (z-10) and keep their own targets.
|
||||
*/}
|
||||
<article class="card-panel group relative block">
|
||||
{heroImage && (
|
||||
<div class="overflow-hidden rounded-t-lg -mx-6 -mt-6 mb-5">
|
||||
<img
|
||||
@@ -45,14 +50,12 @@ const formattedDate = pubDate.toLocaleDateString('en-US', {
|
||||
tags.length > 0 && (
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
{visibleTags.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} />
|
||||
))}
|
||||
{hiddenTags.length > 0 && (
|
||||
<span
|
||||
class="rounded-md border border-surface-800 bg-surface-900/60 px-2 py-0.5 font-mono text-xs text-surface-500"
|
||||
title={hiddenTags.join(', ')}
|
||||
title={hiddenTags.map(blogTagLabel).join(', ')}
|
||||
>
|
||||
+{hiddenTags.length}
|
||||
</span>
|
||||
@@ -60,10 +63,13 @@ const formattedDate = pubDate.toLocaleDateString('en-US', {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<h3
|
||||
class="mt-3 font-display text-lg font-semibold text-surface-100 transition-colors duration-200 group-hover:text-accent-400"
|
||||
>
|
||||
{title}
|
||||
<h3 class="mt-3 font-display text-lg font-semibold text-surface-100 transition-colors duration-200 group-hover:text-accent-400">
|
||||
<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-2 text-sm leading-relaxed text-surface-500">
|
||||
{description}
|
||||
@@ -80,4 +86,4 @@ const formattedDate = pubDate.toLocaleDateString('en-US', {
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
</span>
|
||||
</a>
|
||||
</article>
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
import { blogTagHref, blogTagLabel, type BlogTag } from '../lib/blog-tags';
|
||||
|
||||
/**
|
||||
* A blog tag rendered as a link to its hub page. Cards that wrap their whole
|
||||
* surface in a stretched title link stack these above it with `relative z-10`,
|
||||
* so a chip click opens the tag, not the post.
|
||||
*/
|
||||
interface Props {
|
||||
tag: BlogTag;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { tag, class: className = '' } = Astro.props;
|
||||
---
|
||||
|
||||
<a
|
||||
href={blogTagHref(tag)}
|
||||
class:list={[
|
||||
'relative z-10 rounded-md border border-surface-800 bg-surface-900/60 px-2 py-0.5 text-xs text-surface-300 transition-colors hover:border-accent-500/60 hover:text-accent-400',
|
||||
className,
|
||||
]}
|
||||
>
|
||||
{blogTagLabel(tag)}
|
||||
</a>
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { BLOG_TAG_SLUGS, blogTagHref, blogTagLabel, type BlogTag } from '../lib/blog-tags';
|
||||
|
||||
/**
|
||||
* "Browse by topic" strip: every tag that has at least one published post,
|
||||
* with its post count, linking to the tag hub. `current` marks the tag whose
|
||||
* page is open.
|
||||
*/
|
||||
interface Props {
|
||||
posts: CollectionEntry<'blog'>[];
|
||||
current?: BlogTag;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { posts, current, class: className = '' } = Astro.props;
|
||||
|
||||
const topics = BLOG_TAG_SLUGS.map((tag) => ({
|
||||
tag,
|
||||
count: posts.filter((post) => post.data.tags.includes(tag)).length,
|
||||
})).filter((topic) => topic.count > 0);
|
||||
---
|
||||
|
||||
{
|
||||
topics.length > 0 && (
|
||||
<nav aria-label="Blog topics" class:list={['flex flex-wrap items-center gap-2', className]}>
|
||||
<span class="mr-1 font-mono text-[10px] uppercase tracking-[0.15em] text-surface-500">Topics</span>
|
||||
{topics.map(({ tag, count }) => (
|
||||
<a
|
||||
href={blogTagHref(tag)}
|
||||
aria-current={tag === current ? 'page' : undefined}
|
||||
class:list={[
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs transition-colors',
|
||||
tag === current
|
||||
? 'border-accent-500/60 bg-accent-500/10 text-accent-400'
|
||||
: 'border-surface-800 bg-surface-900/60 text-surface-300 hover:border-accent-500/60 hover:text-accent-400',
|
||||
]}
|
||||
>
|
||||
{blogTagLabel(tag)}
|
||||
<span class="font-mono text-[10px] text-surface-500">{count}</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user