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>
This commit is contained in:
3286 files changed
+284386
-139176
No files matched your search
@@ -1,14 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
import fs from 'fs/promises';
|
||||
import { z } from "zod";
|
||||
import fs from "fs/promises";
|
||||
|
||||
const sources = {
|
||||
crowdin: [
|
||||
{ projectId: 534422, tokenName: 'HOMARR_CROWDIN_TOKEN' },
|
||||
{ projectId: 742587, tokenName: 'HOMARR_LABS_CROWDIN_TOKEN' },
|
||||
{ projectId: 534422, tokenName: "HOMARR_CROWDIN_TOKEN" },
|
||||
{ projectId: 742587, tokenName: "HOMARR_LABS_CROWDIN_TOKEN" },
|
||||
],
|
||||
github: [
|
||||
{ repository: 'homarr', slug: 'ajnart' },
|
||||
{ repository: 'homarr', slug: 'homarr-labs' },
|
||||
{ repository: "homarr", slug: "ajnart" },
|
||||
{ repository: "homarr", slug: "homarr-labs" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -23,11 +23,11 @@ const env = schema.parse(process.env);
|
||||
const fetchGithubContributors = async (slug, repository) => {
|
||||
const url = `https://api.github.com/repos/${slug}/${repository}/contributors?per_page=999`;
|
||||
const options = {
|
||||
method: 'GET',
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.GITHUB_TOKEN}`,
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -39,7 +39,7 @@ const fetchGithubContributors = async (slug, repository) => {
|
||||
login: z.string(),
|
||||
avatar_url: z.string().url(),
|
||||
contributions: z.number(),
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
return dataSchema.parse(data);
|
||||
@@ -48,9 +48,9 @@ const fetchGithubContributors = async (slug, repository) => {
|
||||
const fetchCrowdinMembers = async (projectId, tokenName) => {
|
||||
const url = `https://crowdin.com/api/v2/projects/${projectId}/members`;
|
||||
const options = {
|
||||
method: 'GET',
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${env[tokenName]}`,
|
||||
},
|
||||
};
|
||||
@@ -65,7 +65,7 @@ const fetchCrowdinMembers = async (projectId, tokenName) => {
|
||||
username: z.string(),
|
||||
avatarUrl: z.string().url(),
|
||||
}),
|
||||
})
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -88,16 +88,11 @@ const distinctGithubContributors = githubContributors
|
||||
.filter(distinctBy((contributor) => contributor.login))
|
||||
.sort((a, b) => b.contributions - a.contributions)
|
||||
.map(({ contributions, ...props }) => props)
|
||||
.filter((contributor) => !contributor.login.includes('[bot]'));
|
||||
await fs.writeFile('./static/data/contributions.json', JSON.stringify(distinctGithubContributors));
|
||||
.filter((contributor) => !contributor.login.includes("[bot]"));
|
||||
await fs.writeFile("./public/data/contributions.json", JSON.stringify(distinctGithubContributors));
|
||||
|
||||
for (const { projectId, tokenName } of sources.crowdin) {
|
||||
crowdinContributors.push(...(await fetchCrowdinMembers(projectId, tokenName)));
|
||||
}
|
||||
const distinctCrowdinContributors = crowdinContributors.filter(
|
||||
distinctBy((contributor) => contributor.username)
|
||||
);
|
||||
await fs.writeFile(
|
||||
'./static/data/translation-contributions.json',
|
||||
JSON.stringify(distinctCrowdinContributors)
|
||||
);
|
||||
const distinctCrowdinContributors = crowdinContributors.filter(distinctBy((contributor) => contributor.username));
|
||||
await fs.writeFile("./public/data/translation-contributions.json", JSON.stringify(distinctCrowdinContributors));
|
||||
@@ -0,0 +1,42 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { widgetDocSlugs } from "@homarr/definitions/docs/widget-doc-slugs";
|
||||
import { getIntegrationDocumentationSlug, integrationKinds } from "@homarr/definitions/integration";
|
||||
|
||||
const docsDir = path.resolve(import.meta.dirname, "../docs");
|
||||
const documentationEntries = [
|
||||
{
|
||||
label: "Integration",
|
||||
folder: "integrations",
|
||||
entries: integrationKinds.map((kind) => [kind, getIntegrationDocumentationSlug(kind)] as const),
|
||||
},
|
||||
{ label: "Widget", folder: "widgets", entries: Object.entries(widgetDocSlugs) },
|
||||
] as const;
|
||||
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const { label, folder, entries } of documentationEntries) {
|
||||
for (const [kind, slug] of entries) {
|
||||
if (!slug) continue;
|
||||
const entryDirectory = path.join(docsDir, folder, slug);
|
||||
if (!fs.existsSync(path.join(entryDirectory, "index.mdx"))) {
|
||||
missing.push(`${label} "${kind}" -> docs/${folder}/${slug}/index.mdx`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadataModules = ["index.ts", "index.tsx"];
|
||||
const hasMetadataModule = metadataModules.some((file) => fs.existsSync(path.join(entryDirectory, file)));
|
||||
if (!hasMetadataModule) {
|
||||
missing.push(`${label} "${kind}" -> docs/${folder}/${slug}/index.ts or index.tsx`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Missing documentation for ${missing.length} feature(s):\n${missing.map((item) => ` - ${item}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Integration and widget documentation coverage passed");
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "homarr-docs-links-"));
|
||||
const validator = fileURLToPath(new URL("./validate-links.ts", import.meta.url));
|
||||
|
||||
try {
|
||||
await mkdir(path.join(directory, "docs"));
|
||||
await mkdir(path.join(directory, "out/docs/child"), { recursive: true });
|
||||
await writeFile(path.join(directory, "out/docs/child/index.html"), '<h1 id="child">Child</h1>');
|
||||
await writeFile(path.join(directory, "out/asset.svg"), "<svg />");
|
||||
|
||||
const cases = [
|
||||
{ name: "valid anchors, relative routes, assets and external URLs", href: "#section", expected: 0 },
|
||||
{ name: "same-page generated anchor", href: "#missing", expected: 1 },
|
||||
{ name: "cross-page anchor", href: "/docs/child/#missing", expected: 1 },
|
||||
{ name: "rendered route", href: "/missing", expected: 1 },
|
||||
{ name: "same-origin absolute route", href: "https://homarr.dev/missing", expected: 1 },
|
||||
{ name: "rendered asset", href: "/missing.svg", expected: 1 },
|
||||
{ name: "source-only link", href: "#section", source: "[Missing](/missing)", expected: 1 },
|
||||
{ name: "source-only anchor", href: "#section", source: "[Missing](/docs/#missing)", expected: 1 },
|
||||
];
|
||||
|
||||
for (const test of cases) {
|
||||
await writeFile(path.join(directory, "docs/index.mdx"), test.source ?? "[Valid](/docs/#section)");
|
||||
await writeFile(
|
||||
path.join(directory, "out/docs/index.html"),
|
||||
`<main><h1 id="section">Section</h1><a href="${test.href}">Test</a>
|
||||
<a href="child/#child">Child</a><a href="/asset.svg">Asset</a>
|
||||
<a href="https://example.com/missing">External</a><a href="#top">Top</a></main>`,
|
||||
);
|
||||
const result = spawnSync(process.execPath, ["--import", import.meta.resolve("tsx"), validator], {
|
||||
cwd: directory,
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(result.status, test.expected, `${test.name}\n${result.stdout}\n${result.stderr}`);
|
||||
}
|
||||
console.log(`${cases.length} link validation checks passed`);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { load } from "cheerio";
|
||||
import { printErrors, readFiles, validateFiles, type ScanResult, type UrlMeta } from "next-validate-link";
|
||||
|
||||
const outputDirectory = path.resolve("out");
|
||||
|
||||
const toContentUrl = (file: string) => {
|
||||
const normalized = file.replaceAll(path.sep, "/");
|
||||
|
||||
if (normalized.startsWith("docs/")) {
|
||||
const slug = normalized
|
||||
.slice("docs/".length)
|
||||
.replace(/(?:^|\/)index\.mdx?$/, "")
|
||||
.replace(/\.mdx?$/, "");
|
||||
return `/docs${slug ? `/${slug}` : ""}/`;
|
||||
}
|
||||
|
||||
if (normalized.includes("06-22-documentation")) return "/blog/documentation-migration/";
|
||||
if (normalized.includes("08-28-translation")) return "/blog/translations/";
|
||||
|
||||
const match = /^blog\/(\d{4})\/(\d{2})-(\d{2})-(.+)\/index\.mdx?$/.exec(normalized);
|
||||
return match ? `/blog/${match[1]}/${match[2]}/${match[3]}/${match[4]}/` : undefined;
|
||||
};
|
||||
|
||||
const files = await readFiles(["docs/**/*.{md,mdx}", "blog/**/*.{md,mdx}"], { pathToUrl: toContentUrl });
|
||||
const output = await readdir(outputDirectory, { recursive: true, withFileTypes: true });
|
||||
const urls = new Map<string, UrlMeta>();
|
||||
const renderedPages: { url: string; links: string[] }[] = [];
|
||||
|
||||
for (const entry of output) {
|
||||
if (!entry.isFile()) continue;
|
||||
const file = path.join(entry.parentPath, entry.name);
|
||||
const relative = path.relative(outputDirectory, file).replaceAll(path.sep, "/");
|
||||
if (entry.name !== "index.html") {
|
||||
urls.set(`/${relative}`, {});
|
||||
continue;
|
||||
}
|
||||
const url = `/${relative.replace(/\/?index\.html$/, "")}`.replace(/\/$/, "") || "/";
|
||||
const $ = load(await readFile(file, "utf8"));
|
||||
const hashes = $("[id], a[name]")
|
||||
.toArray()
|
||||
.flatMap((element) => [$(element).attr("id"), $(element).attr("name")].filter((value) => value !== undefined));
|
||||
const meta = { hashes };
|
||||
urls.set(url, meta);
|
||||
urls.set(url === "/" ? url : `${url}/`, meta);
|
||||
renderedPages.push({
|
||||
url,
|
||||
links: $("a[href]")
|
||||
.toArray()
|
||||
.map((element) => $(element).attr("href"))
|
||||
.filter((href) => href !== undefined),
|
||||
});
|
||||
}
|
||||
|
||||
const scanned: ScanResult = { urls, fallbackUrls: [] };
|
||||
const results = await validateFiles(files, {
|
||||
scanned,
|
||||
checkExternal: false,
|
||||
checkRelativePaths: "exists",
|
||||
markdown: {
|
||||
components: {
|
||||
Link: { attributes: ["href", "to"] },
|
||||
},
|
||||
},
|
||||
whitelist: (url) => /^(?:https?:|mailto:|tel:)/.test(url),
|
||||
});
|
||||
|
||||
const sourceErrors = results.filter((result) => result.errors.length > 0);
|
||||
printErrors(sourceErrors);
|
||||
if (sourceErrors.length > 0) process.exitCode = 1;
|
||||
|
||||
const renderedLinkErrors = new Set<string>();
|
||||
|
||||
for (const page of renderedPages) {
|
||||
for (const href of page.links) {
|
||||
if (!href) continue;
|
||||
try {
|
||||
// The export serves directories with trailing slashes, including relative links.
|
||||
const target = new URL(href, `https://homarr.dev${page.url.replace(/\/$/, "")}/`);
|
||||
if (target.origin !== "https://homarr.dev") continue;
|
||||
const pathname = decodeURI(target.pathname);
|
||||
const meta = urls.get(pathname) ?? urls.get(pathname.replace(/\/$/, "")) ?? urls.get(`${pathname}/`);
|
||||
if (!meta) {
|
||||
renderedLinkErrors.add(`${page.url}: ${href} resolves to missing route ${pathname}`);
|
||||
continue;
|
||||
}
|
||||
// Text fragments do not refer to element IDs; validate any preceding anchor.
|
||||
const hash = decodeURIComponent(target.hash.slice(1).split(":~:text=")[0]);
|
||||
if (hash && hash !== "top" && meta.hashes && !meta.hashes.includes(hash)) {
|
||||
renderedLinkErrors.add(`${page.url}: ${href} resolves to missing anchor #${hash} on ${pathname}`);
|
||||
}
|
||||
} catch {
|
||||
renderedLinkErrors.add(`${page.url}: invalid URL ${href}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (renderedLinkErrors.size > 0) {
|
||||
console.error(`\n${renderedLinkErrors.size} broken rendered link(s):`);
|
||||
for (const error of renderedLinkErrors) console.error(`- ${error}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("0 broken rendered links");
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
const APP_ID = 'N69WSPZTID';
|
||||
const API_KEY = 'b2b00f4ed8ca3dc87b5d211c55121416';
|
||||
const DOCSEARCH_INDEX = 'Docusaurus';
|
||||
const MARKDOWN_INDEX = 'markdown';
|
||||
const MIN_DOCSEARCH_HITS = 100;
|
||||
const MIN_MARKDOWN_HITS = 10;
|
||||
|
||||
const DOCSEARCH_FACETS = [
|
||||
'docusaurus_tag',
|
||||
'language',
|
||||
'lang',
|
||||
'version',
|
||||
'type',
|
||||
];
|
||||
|
||||
const SEARCH_CASES = [
|
||||
{ term: 'docker', minHits: 1 },
|
||||
{ term: 'coolify', minHits: 1 },
|
||||
{ term: 'jellyseerr', minHits: 1 },
|
||||
{ term: 'keyboard shortcuts', minHits: 1 },
|
||||
{ term: 'version 0.12', minHits: 1 },
|
||||
];
|
||||
|
||||
const STALE_URL_PATTERN = /\/docs\/(next|\d+(?:\.\d+)+)\//;
|
||||
|
||||
async function algoliaQuery(index, body) {
|
||||
const response = await fetch(
|
||||
`https://${APP_ID}-dsn.algolia.net/1/indexes/${index}/query`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Algolia-Application-Id': APP_ID,
|
||||
'X-Algolia-API-Key': API_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Algolia query on "${index}" failed: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function reportLine(ok, message) {
|
||||
const prefix = ok ? 'OK' : 'FAIL';
|
||||
console.log(`${prefix}: ${message}`);
|
||||
return ok;
|
||||
}
|
||||
|
||||
async function resolveDocsearchIndex() {
|
||||
const candidates = [DOCSEARCH_INDEX, `${DOCSEARCH_INDEX}.tmp`];
|
||||
|
||||
for (const index of candidates) {
|
||||
const result = await algoliaQuery(index, { query: '', hitsPerPage: 0 });
|
||||
const hitCount = result.nbHits ?? 0;
|
||||
if (hitCount >= MIN_DOCSEARCH_HITS) {
|
||||
return { index, hitCount };
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = await algoliaQuery(DOCSEARCH_INDEX, { query: '', hitsPerPage: 0 });
|
||||
return { index: DOCSEARCH_INDEX, hitCount: fallback.nbHits ?? 0 };
|
||||
}
|
||||
|
||||
async function resolveMarkdownIndex() {
|
||||
const candidates = [MARKDOWN_INDEX, `${MARKDOWN_INDEX}.tmp`];
|
||||
|
||||
for (const index of candidates) {
|
||||
const result = await algoliaQuery(index, { query: '', hitsPerPage: 0 });
|
||||
const hitCount = result.nbHits ?? 0;
|
||||
if (hitCount >= MIN_MARKDOWN_HITS) {
|
||||
return { index, hitCount };
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = await algoliaQuery(MARKDOWN_INDEX, { query: '', hitsPerPage: 0 });
|
||||
return { index: MARKDOWN_INDEX, hitCount: fallback.nbHits ?? 0 };
|
||||
}
|
||||
|
||||
async function verifyDocsearchIndex(index, hitCount) {
|
||||
let ok = reportLine(
|
||||
hitCount >= MIN_DOCSEARCH_HITS,
|
||||
`index "${index}" has ${hitCount} records (need >= ${MIN_DOCSEARCH_HITS})`,
|
||||
);
|
||||
|
||||
const sample = await algoliaQuery(index, {
|
||||
query: 'docker',
|
||||
hitsPerPage: 1,
|
||||
attributesToRetrieve: DOCSEARCH_FACETS,
|
||||
});
|
||||
|
||||
const record = sample.hits?.[0] ?? {};
|
||||
const recordFields = ['docusaurus_tag', 'language', 'lang', 'version', 'type'];
|
||||
for (const field of recordFields) {
|
||||
const present = record[field] !== undefined && record[field] !== null;
|
||||
ok = reportLine(present, `records include "${field}"`) && ok;
|
||||
}
|
||||
|
||||
for (const searchCase of SEARCH_CASES) {
|
||||
const result = await algoliaQuery(index, {
|
||||
query: searchCase.term,
|
||||
hitsPerPage: 5,
|
||||
attributesToRetrieve: ['url'],
|
||||
});
|
||||
|
||||
const caseHits = result.nbHits ?? 0;
|
||||
ok =
|
||||
reportLine(
|
||||
caseHits >= searchCase.minHits,
|
||||
`"${searchCase.term}" returns ${caseHits} hits (need >= ${searchCase.minHits})`,
|
||||
) && ok;
|
||||
|
||||
const staleUrls = (result.hits ?? [])
|
||||
.map((hit) => hit.url)
|
||||
.filter((url) => STALE_URL_PATTERN.test(url));
|
||||
|
||||
ok =
|
||||
reportLine(
|
||||
staleUrls.length === 0,
|
||||
`"${searchCase.term}" has no stale versioned URLs (${staleUrls.length} stale)`,
|
||||
) && ok;
|
||||
}
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
async function verifyMarkdownIndex(index, hitCount) {
|
||||
let ok = reportLine(
|
||||
hitCount >= MIN_MARKDOWN_HITS,
|
||||
`markdown index "${index}" has ${hitCount} records (need >= ${MIN_MARKDOWN_HITS})`,
|
||||
);
|
||||
|
||||
const coolify = await algoliaQuery(index, {
|
||||
query: 'coolify',
|
||||
hitsPerPage: 3,
|
||||
attributesToRetrieve: ['url', 'title', 'heading', 'text'],
|
||||
});
|
||||
|
||||
ok =
|
||||
reportLine(
|
||||
(coolify.nbHits ?? 0) >= 1,
|
||||
`markdown index "${index}" returns coolify (${coolify.nbHits ?? 0} hits)`,
|
||||
) && ok;
|
||||
|
||||
const sample = coolify.hits?.[0];
|
||||
ok =
|
||||
reportLine(
|
||||
Boolean(sample?.text?.length),
|
||||
`markdown records include chunked text content`,
|
||||
) && ok;
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Verifying Algolia DocSearch + markdown indices...\n');
|
||||
|
||||
const docsearch = await resolveDocsearchIndex();
|
||||
console.log(`Using DocSearch index: ${docsearch.index}\n`);
|
||||
const docsearchOk = await verifyDocsearchIndex(
|
||||
docsearch.index,
|
||||
docsearch.hitCount,
|
||||
);
|
||||
|
||||
console.log('');
|
||||
const markdown = await resolveMarkdownIndex();
|
||||
console.log(`Using markdown index: ${markdown.index}\n`);
|
||||
const markdownOk = await verifyMarkdownIndex(
|
||||
markdown.index,
|
||||
markdown.hitCount,
|
||||
);
|
||||
|
||||
const allOk = docsearchOk && markdownOk;
|
||||
console.log('');
|
||||
console.log(
|
||||
allOk
|
||||
? 'All checks passed.'
|
||||
: 'Some checks failed — ensure the crawler config in docsearch.config.js is applied and a full recrawl has finished.',
|
||||
);
|
||||
process.exit(allOk ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { load } from "cheerio";
|
||||
import { staticClient } from "fumadocs-core/search/client/orama-static";
|
||||
|
||||
const outputDirectory = path.resolve("out");
|
||||
const index = await readFile(path.join(outputDirectory, "api/search"), "utf8");
|
||||
// Exercise the same client and exported database as the browser, without a server.
|
||||
const from = `data:application/json;base64,${Buffer.from(index).toString("base64")}`;
|
||||
const client = staticClient({ from });
|
||||
const cases = [
|
||||
{ query: "Sonarr", type: "page", url: "/docs/integrations/sonarr" },
|
||||
{ query: "Docker Compose", type: "heading", url: "/docs/getting-started/installation/docker#docker-compose" },
|
||||
{ query: "SECRET_ENCRYPTION_KEY", type: "text", url: "/docs/advanced/environment-variables" },
|
||||
] as const;
|
||||
const verifiedUrls = new Set<string>();
|
||||
|
||||
for (const test of cases) {
|
||||
const results = await client.search(test.query);
|
||||
assert(
|
||||
results.some(
|
||||
(result) =>
|
||||
result.type === test.type &&
|
||||
result.url.split("#")[0] === test.url.split("#")[0] &&
|
||||
(!test.url.includes("#") || result.url === test.url),
|
||||
),
|
||||
`${test.query}: missing ${test.type} result for ${test.url}`,
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
assert(/^\/(?:docs|api-reference)(?:\/|#|$)/.test(result.url), `Unexpected search destination: ${result.url}`);
|
||||
if (verifiedUrls.has(result.url)) continue;
|
||||
const url = new URL(result.url, "https://homarr.dev");
|
||||
const html = await readFile(path.join(outputDirectory, decodeURIComponent(url.pathname), "index.html"), "utf8");
|
||||
if (url.hash) {
|
||||
const id = decodeURIComponent(url.hash.slice(1));
|
||||
assert(
|
||||
load(html)("[id]")
|
||||
.toArray()
|
||||
.some((element) => element.attribs.id === id),
|
||||
`Missing search anchor: ${result.url}`,
|
||||
);
|
||||
}
|
||||
verifiedUrls.add(result.url);
|
||||
}
|
||||
console.log(`${test.query}: ${results.length} results; expected ${test.type} found`);
|
||||
}
|
||||
|
||||
assert.deepEqual(await client.search("homarrsearchnomatchqzxv987654321"), [], "Unknown words should return no results");
|
||||
const credentialResults = await staticClient({ from, search: { limit: 1000 } }).search("API Key");
|
||||
assert(
|
||||
credentialResults.some((result) => result.type === "text" && result.url.startsWith("/docs/integrations/sonarr#")),
|
||||
"Sonarr credential metadata must be searchable",
|
||||
);
|
||||
|
||||
for (const [slug, expected] of [
|
||||
["integrations/sonarr", ["API Key", "Management", "Calendar"]],
|
||||
["widgets/calendar", ["Radarr release type", "Default:", "Show unmonitored"]],
|
||||
] as const) {
|
||||
const markdown = await readFile(path.join(outputDirectory, "llms.mdx/docs", slug, "content.md"), "utf8");
|
||||
for (const text of expected) assert(markdown.includes(text), `${slug}: missing resolved metadata ${text}`);
|
||||
assert(!/<(?:Integration|Widget|Adding)[A-Z][^>]*[/>]/.test(markdown), `${slug}: unresolved metadata component`);
|
||||
}
|
||||
|
||||
const openApi = JSON.parse(await readFile(path.join(outputDirectory, "api/open-api-schema.json"), "utf8"));
|
||||
const apiClient = staticClient({ from, search: { limit: 1000 } });
|
||||
let operationCount = 0;
|
||||
for (const [apiPath, pathItem] of Object.entries(openApi.paths)) {
|
||||
for (const [method, operation] of Object.entries(pathItem as Record<string, { operationId?: string }>)) {
|
||||
const operationId = operation.operationId;
|
||||
if (!operationId) continue;
|
||||
const results = await apiClient.search(operationId);
|
||||
const result = results.find((entry) => entry.url.startsWith("/api-reference/") && entry.url.includes(operationId));
|
||||
assert(result, `${method} ${apiPath}: operation must be searchable by ID`);
|
||||
const destination = new URL(result.url, "https://homarr.dev");
|
||||
assert(!destination.hash, `${result.url}: generated API search text must not invent anchors`);
|
||||
await access(path.join(outputDirectory, destination.pathname, "index.html"));
|
||||
const markdown = await readFile(path.join(outputDirectory, "llms.mdx", destination.pathname, "content.md"), "utf8");
|
||||
assert(
|
||||
markdown.includes(`${method.toUpperCase()} ${apiPath}`),
|
||||
`${result.url}: Markdown must describe its request`,
|
||||
);
|
||||
assert(markdown.includes(operationId), `${result.url}: Markdown must identify its operation`);
|
||||
verifiedUrls.add(result.url);
|
||||
operationCount++;
|
||||
}
|
||||
}
|
||||
assert(operationCount > 0, "API operation coverage must not be empty");
|
||||
console.log(`API discovery verified: ${operationCount} operations in search and Markdown.`);
|
||||
|
||||
const discovery = await readFile(path.join(outputDirectory, "llms.txt"), "utf8");
|
||||
const markdownLinks = [...discovery.matchAll(/\]\(([^)]*\/llms\.mdx\/[^)]+)\)/g)];
|
||||
assert(markdownLinks.length > 0, "LLM discovery must link to exported Markdown");
|
||||
await Promise.all(
|
||||
markdownLinks.map(async ([, href]) => {
|
||||
const url = new URL(href, "https://homarr.dev");
|
||||
await access(path.join(outputDirectory, decodeURIComponent(url.pathname)));
|
||||
}),
|
||||
);
|
||||
console.log(`LLM discovery verified: ${markdownLinks.length} Markdown exports.`);
|
||||
console.log(`Search verified: page titles, headings, body content, and ${verifiedUrls.size} destinations.`);
|
||||
@@ -0,0 +1,105 @@
|
||||
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.`,
|
||||
);
|
||||
Reference in new issue
Block a user