diff --git a/CLAUDE.md b/CLAUDE.md index eea50c021..6ee950d9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//` 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 diff --git a/apps/website/README.md b/apps/website/README.md index 5cebf2b4b..179730d51 100644 --- a/apps/website/README.md +++ b/apps/website/README.md @@ -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//` +(`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 `
` elements with the title +link stretched over the whole card, because a card that is one big `` 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`, diff --git a/apps/website/project.json b/apps/website/project.json index 1bc8356f0..ba1b4c4fd 100644 --- a/apps/website/project.json +++ b/apps/website/project.json @@ -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": { diff --git a/apps/website/public/llms.txt b/apps/website/public/llms.txt index 313e3c765..0ece21b06 100644 --- a/apps/website/public/llms.txt +++ b/apps/website/public/llms.txt @@ -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/ diff --git a/apps/website/src/components/BlogPostCard.astro b/apps/website/src/components/BlogPostCard.astro index eed6fb29a..07e39330b 100644 --- a/apps/website/src/components/BlogPostCard.astro +++ b/apps/website/src/components/BlogPostCard.astro @@ -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', { }); --- - +{/* + The tag chips are links, so the card cannot be one big . 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. +*/} + diff --git a/apps/website/src/components/BlogTagChip.astro b/apps/website/src/components/BlogTagChip.astro new file mode 100644 index 000000000..e13764b7c --- /dev/null +++ b/apps/website/src/components/BlogTagChip.astro @@ -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; +--- + + + {blogTagLabel(tag)} + diff --git a/apps/website/src/components/BlogTagRail.astro b/apps/website/src/components/BlogTagRail.astro new file mode 100644 index 000000000..b32e90763 --- /dev/null +++ b/apps/website/src/components/BlogTagRail.astro @@ -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 && ( + + ) +} diff --git a/apps/website/src/components/FeaturedBlogPost.astro b/apps/website/src/components/FeaturedBlogPost.astro index 2a7a8589e..a1c1c7836 100644 --- a/apps/website/src/components/FeaturedBlogPost.astro +++ b/apps/website/src/components/FeaturedBlogPost.astro @@ -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', { }); --- - +{/* Same stretched-link pattern as BlogPostCard: the tag chips are links of their own. */} + diff --git a/apps/website/src/content.config.ts b/apps/website/src/content.config.ts index e568909cf..e78698658 100644 --- a/apps/website/src/content.config.ts +++ b/apps/website/src/content.config.ts @@ -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//` (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(), diff --git a/apps/website/src/content/blog/beware-unofficial-iptvnator-websites.mdx b/apps/website/src/content/blog/beware-unofficial-iptvnator-websites.mdx index 7bbd3429b..109b498be 100644 --- a/apps/website/src/content/blog/beware-unofficial-iptvnator-websites.mdx +++ b/apps/website/src/content/blog/beware-unofficial-iptvnator-websites.mdx @@ -6,9 +6,7 @@ author: 4gray heroImage: /iptvnator/blog/beware-unofficial-iptvnator-websites.png featured: true tags: -- security -- safety -- official + - security draft: false --- diff --git a/apps/website/src/content/blog/blog-components-demo.mdx b/apps/website/src/content/blog/blog-components-demo.mdx index 148c6a1e5..85ac09032 100644 --- a/apps/website/src/content/blog/blog-components-demo.mdx +++ b/apps/website/src/content/blog/blog-components-demo.mdx @@ -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 --- diff --git a/apps/website/src/content/blog/embedded-mpv-macos-experiment.mdx b/apps/website/src/content/blog/embedded-mpv-macos-experiment.mdx index 86dec1797..80f780388 100644 --- a/apps/website/src/content/blog/embedded-mpv-macos-experiment.mdx +++ b/apps/website/src/content/blog/embedded-mpv-macos-experiment.mdx @@ -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 --- diff --git a/apps/website/src/content/blog/epg-guide.mdx b/apps/website/src/content/blog/epg-guide.mdx index abfa216c3..5e0627cae 100644 --- a/apps/website/src/content/blog/epg-guide.mdx +++ b/apps/website/src/content/blog/epg-guide.mdx @@ -5,8 +5,8 @@ pubDate: 2026-02-14 author: 4gray heroImage: /iptvnator/blog/epg.png tags: -- tutorial -- epg + - guide + - epg draft: false --- diff --git a/apps/website/src/content/blog/m3u-playlist-epg-setup-guide.mdx b/apps/website/src/content/blog/m3u-playlist-epg-setup-guide.mdx index 634fc7a02..14e0c9829 100644 --- a/apps/website/src/content/blog/m3u-playlist-epg-setup-guide.mdx +++ b/apps/website/src/content/blog/m3u-playlist-epg-setup-guide.mdx @@ -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? diff --git a/apps/website/src/content/blog/macos-arm-app-damaged-fix.mdx b/apps/website/src/content/blog/macos-arm-app-damaged-fix.mdx index e4f77e75d..7c66add59 100644 --- a/apps/website/src/content/blog/macos-arm-app-damaged-fix.mdx +++ b/apps/website/src/content/blog/macos-arm-app-damaged-fix.mdx @@ -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 --- diff --git a/apps/website/src/content/blog/stalker-portal-setup-guide.mdx b/apps/website/src/content/blog/stalker-portal-setup-guide.mdx index d6f32f68c..ebd74b90c 100644 --- a/apps/website/src/content/blog/stalker-portal-setup-guide.mdx +++ b/apps/website/src/content/blog/stalker-portal-setup-guide.mdx @@ -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? diff --git a/apps/website/src/content/blog/v0-18-release-notes.mdx b/apps/website/src/content/blog/v0-18-release-notes.mdx index 9c70bcf0c..1183147ea 100644 --- a/apps/website/src/content/blog/v0-18-release-notes.mdx +++ b/apps/website/src/content/blog/v0-18-release-notes.mdx @@ -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 --- diff --git a/apps/website/src/content/blog/v0-19-release-notes.mdx b/apps/website/src/content/blog/v0-19-release-notes.mdx index 6a7276b6e..7ea9c82a6 100644 --- a/apps/website/src/content/blog/v0-19-release-notes.mdx +++ b/apps/website/src/content/blog/v0-19-release-notes.mdx @@ -7,8 +7,6 @@ author: 4gray heroImage: /iptvnator/blog/v0-19-xtream-category-grid.png tags: - release - - release-notes - - v0.19 draft: false --- diff --git a/apps/website/src/content/blog/v0-20-release-notes.mdx b/apps/website/src/content/blog/v0-20-release-notes.mdx index c6716160e..91c51b4bd 100644 --- a/apps/website/src/content/blog/v0-20-release-notes.mdx +++ b/apps/website/src/content/blog/v0-20-release-notes.mdx @@ -7,8 +7,6 @@ author: 4gray heroImage: /iptvnator/blog/v0-20/wide-changelog.jpg tags: - release - - release-notes - - v0.20 draft: false --- diff --git a/apps/website/src/content/blog/v0-21-release-notes.mdx b/apps/website/src/content/blog/v0-21-release-notes.mdx index 319bb2222..ecf634c14 100644 --- a/apps/website/src/content/blog/v0-21-release-notes.mdx +++ b/apps/website/src/content/blog/v0-21-release-notes.mdx @@ -7,8 +7,6 @@ author: 4gray heroImage: /iptvnator/blog/v0-21/wide-changelog.jpg tags: - release - - release-notes - - v0.21 draft: false --- diff --git a/apps/website/src/content/blog/v0-22-release-notes.mdx b/apps/website/src/content/blog/v0-22-release-notes.mdx index a0668321b..3eb2b025b 100644 --- a/apps/website/src/content/blog/v0-22-release-notes.mdx +++ b/apps/website/src/content/blog/v0-22-release-notes.mdx @@ -7,8 +7,6 @@ author: 4gray heroImage: /iptvnator/blog/v0-22/wide-changelog.jpg tags: - release - - release-notes - - v0.22 draft: false --- diff --git a/apps/website/src/content/blog/v0-23-release-notes.mdx b/apps/website/src/content/blog/v0-23-release-notes.mdx index db5faa599..55e01fa85 100644 --- a/apps/website/src/content/blog/v0-23-release-notes.mdx +++ b/apps/website/src/content/blog/v0-23-release-notes.mdx @@ -7,8 +7,6 @@ author: 4gray heroImage: /iptvnator/blog/v0-23/hero.jpg tags: - release - - release-notes - - v0.23 draft: false --- diff --git a/apps/website/src/content/blog/welcome.mdx b/apps/website/src/content/blog/welcome.mdx index fb0a66b36..eaafe28b6 100644 --- a/apps/website/src/content/blog/welcome.mdx +++ b/apps/website/src/content/blog/welcome.mdx @@ -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 --- diff --git a/apps/website/src/content/blog/why-external-players-help.mdx b/apps/website/src/content/blog/why-external-players-help.mdx index ca30134ec..0c25d9ca9 100644 --- a/apps/website/src/content/blog/why-external-players-help.mdx +++ b/apps/website/src/content/blog/why-external-players-help.mdx @@ -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 --- diff --git a/apps/website/src/content/blog/xtream-codes-setup-guide.mdx b/apps/website/src/content/blog/xtream-codes-setup-guide.mdx index d66da1b72..5016956c6 100644 --- a/apps/website/src/content/blog/xtream-codes-setup-guide.mdx +++ b/apps/website/src/content/blog/xtream-codes-setup-guide.mdx @@ -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? diff --git a/apps/website/src/layouts/BlogPost.astro b/apps/website/src/layouts/BlogPost.astro index f25274717..a2c704752 100644 --- a/apps/website/src/layouts/BlogPost.astro +++ b/apps/website/src/layouts/BlogPost.astro @@ -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; <> · {tags.map((tag) => ( - - {tag} - + ))} ) diff --git a/apps/website/src/lib/blog-tags.ts b/apps/website/src/lib/blog-tags.ts new file mode 100644 index 000000000..5ef1d42b0 --- /dev/null +++ b/apps/website/src/lib/blog-tags.ts @@ -0,0 +1,72 @@ +import { siteHref } from './site'; + +/** + * Closed blog tag vocabulary. Every tag is a hub page at `/blog/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; + +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}/`); +} diff --git a/apps/website/src/pages/blog/index.astro b/apps/website/src/pages/blog/index.astro index ff14c6d6d..262bcd62d 100644 --- a/apps/website/src/pages/blog/index.astro +++ b/apps/website/src/pages/blog/index.astro @@ -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

+ + {posts.length > 0 ? ( <> {/* Featured post */} diff --git a/apps/website/src/pages/blog/tag/[tag].astro b/apps/website/src/pages/blog/tag/[tag].astro new file mode 100644 index 000000000..645acd8fe --- /dev/null +++ b/apps/website/src/pages/blog/tag/[tag].astro @@ -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>>; + allPosts: Awaited>>; +} + +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 }, + ], + }, +]; +--- + + +
+
+ + + + + Back to blog + + +
+ + Blog topic + +

+ {meta.label} +

+

{meta.description}

+

{countLabel}

+
+ + + +
+ {posts.map((post) => ( + + ))} +
+
+
+
diff --git a/apps/website/src/styles/global.css b/apps/website/src/styles/global.css index 8ee9b7df3..c8844aa60 100644 --- a/apps/website/src/styles/global.css +++ b/apps/website/src/styles/global.css @@ -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; } diff --git a/tools/testing/website-blog-tags.test.mjs b/tools/testing/website-blog-tags.test.mjs new file mode 100644 index 000000000..2e0204a84 --- /dev/null +++ b/tools/testing/website-blog-tags.test.mjs @@ -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//` 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
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(/