+12








fcd7c979df
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>
106 lines
5.1 KiB
TypeScript
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.`,
|
|
);
|