Files
iptvnator/apps/website
4grayandClaude Fable 5.1 812ec69fd4 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>
2026-09-05 21:51:26 +02:00
..

IPTVnator Website

The website is an Astro static site deployed to GitHub Pages at https://4gray.github.io/iptvnator/.

Blog Comments

Blog posts render Giscus comments from apps/website/src/components/GiscusComments.astro. Giscus stores comments in GitHub Discussions for 4gray/iptvnator and maps each page to a discussion by pathname, including the GitHub Pages base path such as /iptvnator/blog/why-external-players-help/.

The embed is wired to the dedicated Blog comments discussion category:

  • Repository id: MDEwOlJlcG9zaXRvcnkyMTMxOTQ3Mzg=
  • Category id: DIC_kwDODLUX8s4C9eBJ
  • Mapping: pathname
  • Theme: transparent_dark

If the category is recreated, query the new category id:

gh api graphql \
  -f owner=4gray \
  -f name=iptvnator \
  -f query='query($owner:String!, $name:String!) { repository(owner:$owner, name:$name) { discussionCategories(first:25) { nodes { id name slug isAnswerable } } } }'

Then update data-category-id in GiscusComments.astro.

Moderation happens in GitHub Discussions. Maintainers can hide, delete, lock, or move discussions and comments from the repository Discussions UI.

Download Pages

/download/ plus /download/windows/, /download/macos/, /download/linux/ and /download/docker/ (the self-hosted browser version: quick start, variables, tags, FAQ, with docker/README.md as the reference behind it) are landing pages (apps/website/src/pages/download/). They exist for search visibility on "IPTVnator download" style queries and to spare users the 27-asset GitHub release page; each carries OS-specific install steps, requirements, an FAQ and SoftwareApplication / FAQPage / BreadcrumbList structured data. Shared pieces live in src/components/download/ and reuse the blog components (StepRail, Alert, FaqAccordion, CopyCommand).

Latest-release resolution

Direct asset links need the release version, so src/lib/downloads.ts resolves it at build time:

  1. GET https://api.github.com/repos/4gray/iptvnator/releases/latest (8 s timeout). The asset list from the published release is authoritative: options whose file is missing are dropped, sizes and the publish date come from the API. deploy-website.yml passes GITHUB_TOKEN to the build so the call is authenticated.
  2. Fallback: the root package.json version with the asset naming pattern from electron-builder.json. This is deterministic but cannot prove the files exist yet (a version bump lands on master before the release is published), so a warning is printed. Set WEBSITE_SKIP_RELEASE_FETCH=1 to force it for offline or reproducible builds.

Both paths produce the same page structure. Adding an artifact means adding a DownloadOption (matcher + fallback name) in downloads.ts; the pages and the hub pick it up. The homepage SoftwareApplication schema reads the same resolved version.

pnpm nx test website builds the site and runs tools/testing/website-download-pages.test.mjs, which checks titles, canonicals, direct asset links, JSON-LD, cross-links and sitemap entries without depending on a specific version.

Guides

Evergreen how-to posts live in the blog collection next to release notes (xtream-codes-setup-guide.mdx, stalker-portal-setup-guide.mdx and m3u-playlist-epg-setup-guide.mdx in apps/website/src/content/blog/). Two conventions set them apart:

  • faq frontmatter. An optional list of { q, a } entries. BlogPost.astro renders it as an accordion after the body and emits a FAQPage JSON-LD block next to the BlogPosting one, so the answers can surface as rich results.
  • Screenshots from the capture script. Guide frames are captured by pnpm release:screenshots --group guides into apps/website/public/blog/guides/screenshots/<slug>-<theme>.png; the shots are declared in tools/release/screenshots.manifest.json with "group": "guides" and never appear in a release run.

tools/testing/website-guides.test.mjs (part of pnpm nx test website) checks 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, stalker-portal-player, epg, remote-control) live in apps/website/src/pages/features/. They target " player" style searches, reuse the download-page sections, and each carries SoftwareApplication (with featureList) / FAQPage / BreadcrumbList structured data. The registry in src/lib/features.ts drives the hub, the per-page switcher, the homepage feature cards and tools/testing/website-feature-pages.test.mjs; adding a page means adding one registry entry and one .astro file. Screenshots come only from the mock-backed guide and release captures, never from the older homepage screenshots that show real channel names.

Comparison Pages

/compare/ plus one page per decision the app asks users to make (m3u-vs-xtream-vs-stalker, playback-engines, desktop-vs-browser) live in apps/website/src/pages/compare/. They compare IPTVnator's own options against each other, never other products, so every claim is checkable against this repository; the registry is src/lib/comparisons.ts.

Each page opens with a one-paragraph verdict (CompareHero), carries at least one ComparisonTable (cells are true, false or a qualifying string) and emits WebPage / FAQPage / BreadcrumbList JSON-LD from src/lib/comparison-schema.ts — deliberately not SoftwareApplication, since these pages are guidance rather than a product listing, and tools/testing/website-compare-pages.test.mjs asserts that.

Naming a competitor on these pages is a product decision, not a technical one. Phase 3 of .plans/2026-09-03-marketing-landing-pages.md covers that and is still open.