mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
Publish /compare/ with three pages answering the choices the app asks users to make: M3U vs Xtream Codes vs Stalker portal, the built-in web players vs embedded MPV vs external MPV/VLC, and the desktop app vs the self-hosted browser version. Each opens with a one-paragraph verdict, carries a feature matrix whose cells are yes, no or a qualifier, and emits WebPage / FAQPage / BreadcrumbList JSON-LD — not SoftwareApplication, because guidance is not a product listing. These compare IPTVnator's own options against each other rather than naming other products, so every cell is checkable against this repository. Pages that name competitors remain phase 3's open half; the plan now records what has to be decided first. A registry in src/lib/comparisons.ts drives the hub, the switcher and the tests. The header gains a Compare entry and the features hub links across. tools/testing/website-compare-pages.test.mjs checks canonical URLs, the verdict block, the table, schema, cross-links and sitemap entries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
77 lines
3.5 KiB
JavaScript
77 lines
3.5 KiB
JavaScript
import { readFile } from 'node:fs/promises';
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
/**
|
|
* Structural checks for the comparison pages: canonical URLs, the
|
|
* WebPage + FAQPage + BreadcrumbList structured data, a short verdict before
|
|
* the detail, at least one comparison table, cross-links and sitemap entries.
|
|
*/
|
|
|
|
const distRoot = new URL('../../dist/apps/website/', import.meta.url);
|
|
const SITE = 'https://4gray.github.io/iptvnator';
|
|
const COMPARISONS = ['m3u-vs-xtream-vs-stalker', 'playback-engines', 'desktop-vs-browser'];
|
|
|
|
const readDist = (relativePath) => readFile(new URL(relativePath, distRoot), 'utf8');
|
|
|
|
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]));
|
|
}
|
|
|
|
for (const slug of COMPARISONS) {
|
|
test(`comparison page ${slug}: canonical, schema and verdict`, async () => {
|
|
const html = await readDist(`compare/${slug}/index.html`);
|
|
assert.match(html, new RegExp(`<link rel="canonical" href="${SITE}/compare/${slug}/"`));
|
|
assert.match(html, /<meta name="robots" content="index, follow/);
|
|
assert.match(html, /Short answer/, 'Every comparison must state its verdict up front.');
|
|
assert.match(html, /<table/, 'Every comparison must carry a comparison table.');
|
|
|
|
const schema = extractJsonLd(html);
|
|
const page = schema.find((entry) => entry['@type'] === 'WebPage');
|
|
assert.ok(page, 'Expected a WebPage entry.');
|
|
assert.equal(page.url, `${SITE}/compare/${slug}/`);
|
|
const faq = schema.find((entry) => entry['@type'] === 'FAQPage');
|
|
assert.ok(faq && faq.mainEntity.length >= 5, 'Expected a FAQPage with at least five questions.');
|
|
const crumbs = schema.find((entry) => entry['@type'] === 'BreadcrumbList');
|
|
assert.equal(crumbs.itemListElement.at(-1).item, `${SITE}/compare/${slug}/`);
|
|
assert.ok(
|
|
!schema.some((entry) => entry['@type'] === 'SoftwareApplication'),
|
|
'A comparison page is guidance, not a product listing.'
|
|
);
|
|
});
|
|
|
|
test(`comparison page ${slug}: links to the hub and the other comparisons`, async () => {
|
|
const html = await readDist(`compare/${slug}/index.html`);
|
|
assert.match(html, /href="\/iptvnator\/compare\/"/);
|
|
for (const other of COMPARISONS.filter((candidate) => candidate !== slug)) {
|
|
assert.match(html, new RegExp(`href="/iptvnator/compare/${other}/"`));
|
|
}
|
|
assert.match(html, /href="\/iptvnator\/blog\/[a-z0-9-]+\/"/, 'Expected a link into the guides.');
|
|
});
|
|
}
|
|
|
|
test('compare hub links to every comparison and to the feature pages', async () => {
|
|
const html = await readDist('compare/index.html');
|
|
assert.match(html, new RegExp(`<link rel="canonical" href="${SITE}/compare/"`));
|
|
for (const slug of COMPARISONS) {
|
|
assert.match(html, new RegExp(`href="/iptvnator/compare/${slug}/"`));
|
|
}
|
|
assert.match(html, /href="\/iptvnator\/features\/[a-z0-9-]+\/"/);
|
|
});
|
|
|
|
test('the header and the features hub link into the comparisons', async () => {
|
|
const home = await readDist('index.html');
|
|
assert.match(home, /href="\/iptvnator\/compare\/"/);
|
|
const features = await readDist('features/index.html');
|
|
assert.match(features, /href="\/iptvnator\/compare\/"/);
|
|
});
|
|
|
|
test('sitemap lists the comparison pages', async () => {
|
|
const sitemap = await readDist('sitemap-0.xml');
|
|
for (const path of ['compare/', ...COMPARISONS.map((slug) => `compare/${slug}/`)]) {
|
|
assert.match(sitemap, new RegExp(`<loc>${SITE}/${path}</loc>`));
|
|
}
|
|
});
|