mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -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
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
---
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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">·</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" />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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,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 */}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>`));
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user