Files
homarr/apps/docs/scripts/verify-seo.ts
T
+12 fcd7c979df feat!: release v2 (#6545)
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
2026-10-02 21:00:14 +02:00

106 lines
5.1 KiB
TypeScript

import assert from "node:assert/strict";
import { access, readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { load } from "cheerio";
const outputDirectory = path.resolve("out");
const origin = new URL(process.env.HOMARR_WEBSITE_URL ?? "https://homarr.dev").origin;
const sitemap = load(await readFile(path.join(outputDirectory, "sitemap.xml"), "utf8"), { xml: true });
const locations = sitemap("url > loc")
.toArray()
.map((element) => sitemap(element).text());
const indexed = new Set(locations);
assert.equal(indexed.size, locations.length, "Duplicate sitemap URLs");
const robots = await readFile(path.join(outputDirectory, "robots.txt"), "utf8");
assert(robots.includes(`Sitemap: ${origin}/sitemap.xml`), "Robots must advertise this site's sitemap");
assert(!/^Disallow:\s*\/\s*$/m.test(robots), "Robots must not block the entire public site");
const files = (await readdir(outputDirectory, { recursive: true })).filter((file) => /(?:^|\/)index\.html$/.test(file));
const canonicalPages = new Set<string>();
let excluded = 0;
for (const file of files) {
const pathname = `/${file.replace(/index\.html$/, "")}`;
const $ = load(await readFile(path.join(outputDirectory, file), "utf8"));
const noindex = /\bnoindex\b/.test($("meta[name=robots]").attr("content") ?? "");
const isExcluded =
/^\/(?:404|_not-found|blog|search|workshop\/admin)\/$/.test(pathname) || pathname.startsWith("/docs/category/");
assert.equal(noindex, isExcluded, `${pathname}: unexpected robots indexing policy`);
if (isExcluded) {
assert(!indexed.has(`${origin}${pathname}`), `${pathname}: noindex page in sitemap`);
if (pathname === "/blog/" || pathname.startsWith("/docs/category/")) {
const canonical = $("link[rel=canonical]").attr("href");
assert(canonical && indexed.has(canonical), `${pathname}: redirect destination must be indexed`);
const destination = new URL(canonical);
assert.equal(destination.origin, origin, `${pathname}: redirect must stay on this site`);
assert.notEqual(destination.pathname, pathname, `${pathname}: redirect must not point to itself`);
assert(
$("a[href]")
.toArray()
.some((link) => {
const href = $(link).attr("href");
return href !== undefined && new URL(href, origin).href === canonical;
}),
`${pathname}: missing no-JavaScript redirect link`,
);
await access(path.join(outputDirectory, destination.pathname, "index.html"));
}
excluded++;
continue;
}
const title = $("title").text();
assert.equal($("h1").length, 1, `${pathname}: expected one document heading`);
const description = $("meta[name=description]").attr("content");
assert(title.trim() && description?.trim(), `${pathname}: missing title or description`);
assert.equal($("link[rel=canonical]").length, 1, `${pathname}: expected one canonical URL`);
const canonical = $("link[rel=canonical]").attr("href");
assert.equal(canonical, `${origin}${pathname}`, `${pathname}: canonical must match exported route`);
assert(indexed.has(canonical), `${pathname}: missing from sitemap`);
canonicalPages.add(canonical);
const isArticle = pathname.startsWith("/blog/") && pathname !== "/blog/";
const socialImage = $("meta[property='og:image']").attr("content");
const twitterCard = socialImage === `${origin}/img/logo.png` ? "summary" : "summary_large_image";
for (const [selector, expected] of [
['meta[property="og:title"]', title],
['meta[property="og:description"]', description],
['meta[property="og:url"]', canonical],
['meta[name="twitter:title"]', title],
['meta[name="twitter:description"]', description],
['meta[name="twitter:card"]', twitterCard],
]) {
assert.equal($(selector).attr("content"), expected, `${pathname}: incorrect ${selector}`);
}
assert.equal(
$("meta[property='og:type']").attr("content"),
isArticle ? "article" : "website",
`${pathname}: OG type`,
);
if (isArticle) {
const published = $("meta[property='article:published_time']").attr("content");
assert(published && !Number.isNaN(Date.parse(published)), `${pathname}: missing article publication date`);
assert.equal(
published.slice(0, 10),
$("time[datetime]").first().attr("datetime"),
`${pathname}: incorrect publication date`,
);
}
for (const selector of ['meta[property="og:image"]', 'meta[name="twitter:image"]']) {
const href = $(selector).attr("content");
assert(href, `${pathname}: missing ${selector}`);
const image = new URL(href);
assert.equal(image.origin, origin, `${pathname}: social image must use this site's origin`);
await access(path.join(outputDirectory, decodeURIComponent(image.pathname)));
}
}
for (const url of indexed)
assert(canonicalPages.has(url), `${url}: sitemap does not resolve to an indexable exported page`);
for (const file of ["api/search", "llms.txt", "llms-full.txt", "workshop-runtime-config.js"]) {
await access(path.join(outputDirectory, file));
}
console.log(
`SEO verified: ${canonicalPages.size} canonical pages, matching social metadata and sitemap; ${excluded} noindex pages.`,
);