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 -1
View File
@@ -330,7 +330,7 @@ This is an Nx monorepo with the following structure:
- **apps/electron-backend-e2e** - Playwright E2E tests against the Electron app
- **apps/stalker-mock-server** - Mock Stalker/Ministra portal for dev and E2E
- **apps/xtream-mock-server** - Mock Xtream Codes API for dev and E2E
- **apps/website** - Astro + Tailwind landing page, blog (guides carry `faq:` frontmatter → FAQPage JSON-LD), per-OS download landing pages (`/download/`, `/download/{windows,macos,linux}/`) plus the Docker page (`/download/docker/`) feature landing pages (`/features/`, registry in `src/lib/features.ts`) and comparison pages (`/compare/`, registry in `src/lib/comparisons.ts`, comparing IPTVnator's own options rather than other products); direct asset links are resolved at build time from the GitHub Releases API with a `package.json` fallback (`src/lib/downloads.ts`, see `apps/website/README.md`)
- **apps/website** - Astro + Tailwind landing page, blog (guides carry `faq:` frontmatter → FAQPage JSON-LD; tags are a closed vocabulary in `src/lib/blog-tags.ts` enforced by the collection schema, each with a `/blog/tag/<tag>/` hub), per-OS download landing pages (`/download/`, `/download/{windows,macos,linux}/`) plus the Docker page (`/download/docker/`) feature landing pages (`/features/`, registry in `src/lib/features.ts`) and comparison pages (`/compare/`, registry in `src/lib/comparisons.ts`, comparing IPTVnator's own options rather than other products); direct asset links are resolved at build time from the GitHub Releases API with a `package.json` fallback (`src/lib/downloads.ts`, see `apps/website/README.md`)
- **libs/** - Shared libraries:
- **epg/data-access** - EPG services, runtime bridge, program normalization
- **m3u-state** - NgRx state management for M3U playlists
+20
View File
@@ -84,6 +84,26 @@ Two conventions set them apart:
each guide for the FAQPage schema, a link to the download hub and the presence
of every referenced screenshot in the build output.
## Blog Tags
Blog tags are a closed vocabulary in `apps/website/src/lib/blog-tags.ts`
(`release`, `guide`, `troubleshooting`, `playback`, `m3u`, `xtream-codes`,
`stalker-portal`, `epg`, `macos`, `security`). The content collection schema
only accepts those slugs, so a typo in a post's `tags:` fails the build. Every
tag with at least one published post gets a hub page at `/blog/tag/<tag>/`
(`src/pages/blog/tag/[tag].astro`, `CollectionPage` + `BreadcrumbList` JSON-LD),
the blog index and the hubs show a "Topics" rail with post counts
(`BlogTagRail.astro`), and every chip on a card or a post header links to its
hub (`BlogTagChip.astro`). The cards are `<article>` elements with the title
link stretched over the whole card, because a card that is one big `<a>` cannot
hold chip links.
Add a tag only when it will hold more than one post for good: each tag is an
indexable page, and a hub with a single post is a thin page. Add the slug to the
registry with a label and a one-sentence description; nothing else needs to
change. `tools/testing/website-blog-tags.test.mjs` checks the rail, every hub,
the chip targets, the sitemap and the absence of nested anchors.
## Feature Pages
`/features/` plus one page per feature (`m3u-player`, `xtream-codes-player`,
+1 -1
View File
@@ -18,7 +18,7 @@
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"command": "node --test tools/testing/website-giscus-comments.test.mjs tools/testing/website-download-pages.test.mjs tools/testing/website-guides.test.mjs tools/testing/website-feature-pages.test.mjs tools/testing/website-compare-pages.test.mjs"
"command": "node --test tools/testing/website-giscus-comments.test.mjs tools/testing/website-download-pages.test.mjs tools/testing/website-guides.test.mjs tools/testing/website-feature-pages.test.mjs tools/testing/website-compare-pages.test.mjs tools/testing/website-blog-tags.test.mjs"
}
},
"serve": {
+3
View File
@@ -12,6 +12,9 @@
- Download for macOS: https://4gray.github.io/iptvnator/download/macos/
- Download for Linux: https://4gray.github.io/iptvnator/download/linux/
- Self-host the browser version with Docker: https://4gray.github.io/iptvnator/download/docker/
- Blog topic, all guides: https://4gray.github.io/iptvnator/blog/tag/guide/
- Blog topic, release notes: https://4gray.github.io/iptvnator/blog/tag/release/
- Blog topic, troubleshooting: https://4gray.github.io/iptvnator/blog/tag/troubleshooting/
- Guide, Xtream Codes setup: https://4gray.github.io/iptvnator/blog/xtream-codes-setup-guide/
- Guide, Stalker/Ministra portal setup: https://4gray.github.io/iptvnator/blog/stalker-portal-setup-guide/
- Guide, M3U playlist and EPG setup: https://4gray.github.io/iptvnator/blog/m3u-playlist-epg-setup-guide/
+20 -14
View File
@@ -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>
+3 -1
View File
@@ -1,5 +1,6 @@
import { glob } from 'astro/loaders';
import { defineCollection, z } from 'astro:content';
import { BLOG_TAG_SLUGS } from './lib/blog-tags';
const blog = defineCollection({
loader: glob({ pattern: '**/[^_]*.{md,mdx}', base: './src/content/blog' }),
@@ -11,7 +12,8 @@ const blog = defineCollection({
author: z.string().default('4gray'),
featured: z.boolean().default(false),
heroImage: z.string().optional(),
tags: z.array(z.string()).default([]),
/** Closed vocabulary: each slug is a hub page at `/blog/tag/<slug>/` (see `lib/blog-tags.ts`). */
tags: z.array(z.enum(BLOG_TAG_SLUGS)).default([]),
draft: z.boolean().default(false),
/** Rendered as an accordion after the post and emitted as FAQPage JSON-LD. */
faq: z.array(z.object({ q: z.string(), a: z.string() })).optional(),
@@ -6,9 +6,7 @@ author: 4gray
heroImage: /iptvnator/blog/beware-unofficial-iptvnator-websites.png
featured: true
tags:
- security
- safety
- official
- security
draft: false
---
@@ -3,10 +3,7 @@ title: Blog Components Demo (MDX)
description: Reusable MDX components for IPTVnator blog posts including alerts, CTA buttons, and terminal command blocks.
pubDate: 2026-02-15
author: 4gray
tags:
- blog
- mdx
- docs
tags: []
draft: true
---
@@ -6,10 +6,8 @@ pubDate: 2026-05-11
author: 4gray
heroImage: /iptvnator/blog/v0-21/embedded-mpv.webp
tags:
- macos
- mpv
- playback
- experimental
- macos
draft: false
---
+2 -2
View File
@@ -5,8 +5,8 @@ pubDate: 2026-02-14
author: 4gray
heroImage: /iptvnator/blog/epg.png
tags:
- tutorial
- epg
- guide
- epg
draft: false
---
@@ -5,11 +5,9 @@ pubDate: 2026-09-04
author: 4gray
heroImage: /iptvnator/blog/guides/screenshots/guide-m3u-live-groups-dark.png
tags:
- guide
- m3u
- epg
- setup
- tutorial
- guide
- m3u
- epg
draft: false
faq:
- q: What is the difference between an M3U and an M3U8 playlist?
@@ -5,9 +5,8 @@ pubDate: 2026-02-15
author: 4gray
heroImage: /iptvnator/blog/macos-app-damaged.jpg
tags:
- macos
- troubleshooting
- installation
- troubleshooting
- macos
draft: false
---
@@ -5,10 +5,8 @@ pubDate: 2026-09-03
author: 4gray
heroImage: /iptvnator/blog/guides/screenshots/guide-stalker-live-dark.png
tags:
- guide
- stalker-portal
- setup
- tutorial
- guide
- stalker-portal
draft: false
faq:
- q: What is the difference between a Stalker portal and an Xtream Codes account?
@@ -6,9 +6,7 @@ pubDate: 2026-01-14
author: 4gray
heroImage: /iptvnator/blog/iptvnator-v18.jpeg
tags:
- release
- release-notes
- v0.18
- release
draft: false
---
@@ -7,8 +7,6 @@ author: 4gray
heroImage: /iptvnator/blog/v0-19-xtream-category-grid.png
tags:
- release
- release-notes
- v0.19
draft: false
---
@@ -7,8 +7,6 @@ author: 4gray
heroImage: /iptvnator/blog/v0-20/wide-changelog.jpg
tags:
- release
- release-notes
- v0.20
draft: false
---
@@ -7,8 +7,6 @@ author: 4gray
heroImage: /iptvnator/blog/v0-21/wide-changelog.jpg
tags:
- release
- release-notes
- v0.21
draft: false
---
@@ -7,8 +7,6 @@ author: 4gray
heroImage: /iptvnator/blog/v0-22/wide-changelog.jpg
tags:
- release
- release-notes
- v0.22
draft: false
---
@@ -7,8 +7,6 @@ author: 4gray
heroImage: /iptvnator/blog/v0-23/hero.jpg
tags:
- release
- release-notes
- v0.23
draft: false
---
+1 -2
View File
@@ -4,8 +4,7 @@ description: We are excited to launch the official IPTVnator website and develop
pubDate: 2026-02-14
author: 4gray
heroImage: /iptvnator/blog/player.png
tags:
- announcement
tags: []
draft: false
---
@@ -7,11 +7,7 @@ author: 4gray
heroImage: /iptvnator/screenshots/external-player-support-mpv.webp
tags:
- playback
- external-players
- diagnostics
- mpv
- vlc
- codecs
- troubleshooting
draft: false
---
@@ -5,10 +5,8 @@ pubDate: 2026-09-03
author: 4gray
heroImage: /iptvnator/blog/guides/screenshots/guide-xtream-live-dark.png
tags:
- guide
- xtream-codes
- setup
- tutorial
- guide
- xtream-codes
draft: false
faq:
- q: Is an Xtream Codes login the same thing as an M3U link?
+4 -4
View File
@@ -1,5 +1,7 @@
---
import BaseLayout from './BaseLayout.astro';
import BlogTagChip from '../components/BlogTagChip.astro';
import type { BlogTag } from '../lib/blog-tags';
import GiscusComments from '../components/GiscusComments.astro';
import FaqAccordion from '../components/blog/FaqAccordion.astro';
@@ -14,7 +16,7 @@ interface Props {
pubDate: Date;
updatedDate?: Date;
author: string;
tags?: string[];
tags?: BlogTag[];
heroImage?: string;
/** Optional FAQ from the post frontmatter: rendered after the body and emitted as FAQPage schema. */
faq?: FaqEntry[];
@@ -119,9 +121,7 @@ const jsonLd = faqSchema ? [blogPostSchema, faqSchema] : blogPostSchema;
<>
<span class="text-surface-700">&middot;</span>
{tags.map((tag) => (
<span class="rounded-md border border-surface-800 bg-surface-900/60 px-2.5 py-0.5 text-xs font-medium text-surface-300">
{tag}
</span>
<BlogTagChip tag={tag} class="px-2.5 font-medium" />
))}
</>
)
+72
View File
@@ -0,0 +1,72 @@
import { siteHref } from './site';
/**
* Closed blog tag vocabulary. Every tag is a hub page at `/blog/tag/<tag>/`,
* so the list stays short on purpose: a tag that would hold one post for a
* long time is a thin page, not a topic. The content collection schema only
* accepts these slugs, so a typo in a post's frontmatter fails the build
* instead of silently creating a new tag.
*/
export interface BlogTagMeta {
/** Human label used in chips, headings and titles. */
label: string;
/** One-sentence description for the tag page's intro and meta description. */
description: string;
}
export const BLOG_TAGS = {
release: {
label: 'Release notes',
description: 'What changed in every IPTVnator release: new features, fixes and known issues.',
},
guide: {
label: 'Guides',
description: 'Step-by-step setup guides for playlists, portals and the program guide.',
},
troubleshooting: {
label: 'Troubleshooting',
description: 'Fixes for streams that refuse to play, installs that fail and other common problems.',
},
playback: {
label: 'Playback',
description: 'The built-in web players, MPV and VLC, codecs, and what makes a stream play or fail.',
},
m3u: {
label: 'M3U playlists',
description: 'Loading, refreshing and organizing M3U and M3U8 playlists.',
},
'xtream-codes': {
label: 'Xtream Codes',
description: 'Connecting Xtream Codes API providers and using their live, movie and series catalogs.',
},
'stalker-portal': {
label: 'Stalker portals',
description: 'Connecting Stalker and Ministra portals with a MAC address.',
},
epg: {
label: 'EPG',
description: 'XMLTV program guides, channel mapping and the live timeline.',
},
macos: {
label: 'macOS',
description: 'Installing and running IPTVnator on Apple Silicon and Intel Macs.',
},
security: {
label: 'Security',
description: 'Unofficial websites, fake apps and how to get the real IPTVnator.',
},
} as const satisfies Record<string, BlogTagMeta>;
export type BlogTag = keyof typeof BLOG_TAGS;
/** Tag slugs as a non-empty tuple, the shape `z.enum()` in the content schema needs. */
export const BLOG_TAG_SLUGS = Object.keys(BLOG_TAGS) as [BlogTag, ...BlogTag[]];
export function blogTagLabel(tag: BlogTag): string {
return BLOG_TAGS[tag].label;
}
/** Site-relative href of a tag hub page. */
export function blogTagHref(tag: BlogTag): string {
return siteHref(`/blog/tag/${tag}/`);
}
+3
View File
@@ -3,6 +3,7 @@ import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import FeaturedBlogPost from '../../components/FeaturedBlogPost.astro';
import BlogPostCard from '../../components/BlogPostCard.astro';
import BlogTagRail from '../../components/BlogTagRail.astro';
const includeDrafts = import.meta.env.DEV || import.meta.env.PUBLIC_INCLUDE_DRAFTS === 'true';
@@ -34,6 +35,8 @@ const remainingPosts = featuredPost ? posts.filter((post) => post.id !== feature
</p>
</div>
<BlogTagRail posts={posts} class="mt-8" />
{posts.length > 0 ? (
<>
{/* Featured post */}
+109
View File
@@ -0,0 +1,109 @@
---
import { getCollection } from 'astro:content';
import BaseLayout from '../../../layouts/BaseLayout.astro';
import BlogPostCard from '../../../components/BlogPostCard.astro';
import BlogTagRail from '../../../components/BlogTagRail.astro';
import { BLOG_TAGS, BLOG_TAG_SLUGS, type BlogTag } from '../../../lib/blog-tags';
import { absoluteUrl } from '../../../lib/site';
/**
* Tag hub: every published post carrying one tag, newest first. Only tags
* with at least one published post get a page, so a chip never points at an
* empty hub (a chip exists only on a post that carries the tag).
*/
export async function getStaticPaths() {
const includeDrafts = import.meta.env.DEV || import.meta.env.PUBLIC_INCLUDE_DRAFTS === 'true';
const posts = (await getCollection('blog'))
.filter((post) => includeDrafts || !post.data.draft)
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
return BLOG_TAG_SLUGS.map((tag) => ({
params: { tag },
props: { tag, posts: posts.filter((post) => post.data.tags.includes(tag)), allPosts: posts },
})).filter(({ props }) => props.posts.length > 0);
}
interface Props {
tag: BlogTag;
posts: Awaited<ReturnType<typeof getCollection<'blog'>>>;
allPosts: Awaited<ReturnType<typeof getCollection<'blog'>>>;
}
const { tag, posts, allPosts } = Astro.props;
const meta = BLOG_TAGS[tag];
const pageUrl = absoluteUrl(`/blog/tag/${tag}/`);
const countLabel = posts.length === 1 ? '1 post' : `${posts.length} posts`;
const jsonLd = [
{
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: `${meta.label} – IPTVnator Blog`,
description: meta.description,
url: pageUrl,
isPartOf: { '@type': 'Blog', name: 'IPTVnator Blog', url: absoluteUrl('/blog/') },
hasPart: posts.map((post) => ({
'@type': 'BlogPosting',
headline: post.data.title,
url: absoluteUrl(`/blog/${post.id}/`),
datePublished: post.data.pubDate.toISOString(),
})),
},
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'IPTVnator', item: absoluteUrl('/') },
{ '@type': 'ListItem', position: 2, name: 'Blog', item: absoluteUrl('/blog/') },
{ '@type': 'ListItem', position: 3, name: meta.label, item: pageUrl },
],
},
];
---
<BaseLayout
title={`${meta.label} | IPTVnator Blog`}
description={meta.description}
keywords={[`IPTVnator ${meta.label.toLowerCase()}`, meta.label, 'IPTVnator blog']}
jsonLd={jsonLd}
>
<section class="pt-36 pb-24">
<div class="mx-auto max-w-6xl px-6">
<a
href="/iptvnator/blog/"
class="inline-flex items-center gap-1.5 font-mono text-xs uppercase tracking-[0.1em] text-surface-500 transition-colors hover:text-accent-400"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Back to blog
</a>
<div class="mt-10 max-w-2xl">
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">
Blog topic
</span>
<h1 class="mt-4 font-display text-3xl font-bold tracking-tight text-surface-50 sm:text-4xl">
{meta.label}
</h1>
<p class="mt-4 text-lg text-surface-400">{meta.description}</p>
<p class="mt-3 font-mono text-xs text-surface-500">{countLabel}</p>
</div>
<BlogTagRail posts={allPosts} current={tag} class="mt-8" />
<div class="mt-10 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<BlogPostCard
title={post.data.title}
description={post.data.description}
pubDate={post.data.pubDate}
slug={post.id}
tags={post.data.tags}
heroImage={post.data.heroImage}
/>
))}
</div>
</div>
</section>
</BaseLayout>
+2 -1
View File
@@ -63,7 +63,8 @@
@apply rounded-xl border border-surface-800 bg-surface-900/40 p-6 transition-colors duration-300;
}
a.card-panel:hover,
.card-panel:has(> a:hover) {
.card-panel:has(> a:hover),
.card-panel:has(.card-link:hover) {
@apply border-surface-600 bg-surface-900/70;
}
+100
View File
@@ -0,0 +1,100 @@
import { readFile, access } from 'node:fs/promises';
import { test } from 'node:test';
import assert from 'node:assert/strict';
/**
* Structural checks for the blog tag hubs: the topic rail on the blog index,
* one `/blog/tag/<tag>/` page per used tag with CollectionPage + BreadcrumbList
* data, tag chips that resolve to existing hubs, sitemap entries, and no
* nested anchors in the cards (the card is an <article> with a stretched title
* link so the chips can be links of their own).
*/
const distRoot = new URL('../../dist/apps/website/', import.meta.url);
const SITE = 'https://4gray.github.io/iptvnator';
const TAG_HREF = /href="\/iptvnator\/blog\/tag\/([a-z0-9-]+)\/"/g;
const readDist = (relativePath) => readFile(new URL(relativePath, distRoot), 'utf8');
const distExists = (relativePath) =>
access(new URL(relativePath, distRoot)).then(() => true, () => false);
function extractJsonLd(html) {
const blocks = [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)];
assert.ok(blocks.length > 0, 'Expected at least one JSON-LD script block.');
return blocks.flatMap((match) => JSON.parse(match[1]));
}
/** Anchors must never nest: a stretched card link plus chip links inside one <a> would be invalid HTML. */
function assertNoNestedAnchors(html, label) {
let depth = 0;
for (const token of html.matchAll(/<a[\s>]|<\/a>/g)) {
depth += token[0] === '</a>' ? -1 : 1;
assert.ok(depth <= 1, `${label}: nested <a> found near offset ${token.index}.`);
}
assert.equal(depth, 0, `${label}: unbalanced anchors.`);
}
const tagsFromRail = (html) => [...new Set([...html.matchAll(TAG_HREF)].map((match) => match[1]))];
test('blog index carries a topic rail whose tags all have hub pages', async () => {
const html = await readDist('blog/index.html');
assert.match(html, /aria-label="Blog topics"/);
const tags = tagsFromRail(html);
assert.ok(tags.length >= 5, `Expected at least five topics, found ${tags.length}.`);
for (const tag of tags) {
assert.ok(await distExists(`blog/tag/${tag}/index.html`), `Missing hub page for tag "${tag}".`);
}
assertNoNestedAnchors(html, 'blog index');
});
test('every tag hub: canonical, CollectionPage schema, breadcrumb, cards and the rail', async () => {
const tags = tagsFromRail(await readDist('blog/index.html'));
for (const tag of tags) {
const html = await readDist(`blog/tag/${tag}/index.html`);
assert.match(html, new RegExp(`<link rel="canonical" href="${SITE}/blog/tag/${tag}/"`));
assert.match(html, /<meta name="robots" content="index, follow/);
assert.match(html, /Back to blog/);
assert.match(html, new RegExp(`aria-current="page"[^>]*href="/iptvnator/blog/tag/${tag}/"|href="/iptvnator/blog/tag/${tag}/"[^>]*aria-current="page"`));
const cards = html.match(/<article class="card-panel/g) ?? [];
assert.ok(cards.length >= 1, `Tag "${tag}" renders no post cards.`);
const schema = extractJsonLd(html);
const page = schema.find((entry) => entry['@type'] === 'CollectionPage');
assert.ok(page, `Tag "${tag}": expected a CollectionPage entry.`);
assert.equal(page.url, `${SITE}/blog/tag/${tag}/`);
assert.equal(page.hasPart.length, cards.length, `Tag "${tag}": schema and cards disagree on the post count.`);
const crumbs = schema.find((entry) => entry['@type'] === 'BreadcrumbList');
assert.equal(crumbs.itemListElement.at(-1).item, `${SITE}/blog/tag/${tag}/`);
assertNoNestedAnchors(html, `tag ${tag}`);
}
});
test('post pages link their tag chips to existing hubs', async () => {
const blogIndex = await readDist('blog/index.html');
const slugs = [...new Set([...blogIndex.matchAll(/href="\/iptvnator\/blog\/([a-z0-9-]+)\/"/g)].map((match) => match[1]))]
.filter((slug) => slug !== 'tag');
assert.ok(slugs.length >= 10, `Expected the blog index to link at least ten posts, found ${slugs.length}.`);
let chipCount = 0;
for (const slug of slugs) {
const html = await readDist(`blog/${slug}/index.html`);
for (const tag of tagsFromRail(html)) {
chipCount += 1;
assert.ok(await distExists(`blog/tag/${tag}/index.html`), `Post "${slug}" links to a missing tag hub "${tag}".`);
}
}
assert.ok(chipCount > 0, 'Expected at least one post page to carry tag chips.');
});
test('homepage blog cards have no nested anchors', async () => {
const html = await readDist('index.html');
assert.match(html, /<article class="card-panel/);
assertNoNestedAnchors(html, 'homepage');
});
test('sitemap lists the tag hubs', async () => {
const sitemap = await readDist('sitemap-0.xml');
for (const tag of tagsFromRail(await readDist('blog/index.html'))) {
assert.match(sitemap, new RegExp(`<loc>${SITE}/blog/tag/${tag}/</loc>`));
}
});