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

+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>`));
}
});