docs: migrate documentation to Fumadocs
This commit is contained in:
1 parent
6cca757493
commit
6704ef2c13
254 files changed
+6035
-11002
No files matched your search
@@ -89,10 +89,10 @@ const distinctGithubContributors = githubContributors
|
||||
.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));
|
||||
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));
|
||||
await fs.writeFile("./public/data/translation-contributions.json", JSON.stringify(distinctCrowdinContributors));
|
||||
@@ -0,0 +1,34 @@
|
||||
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;
|
||||
if (!fs.existsSync(path.join(docsDir, folder, slug, "index.mdx"))) {
|
||||
missing.push(`${label} "${kind}" -> docs/${folder}/${slug}/index.mdx`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,90 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { load } from "cheerio";
|
||||
import { printErrors, readFiles, validateFiles, type ScanResult } 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, object>();
|
||||
const renderedPages: { file: string; url: 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(/\/$/, "") || "/";
|
||||
urls.set(url, {});
|
||||
urls.set(`${url}/`, {});
|
||||
renderedPages.push({ file, url });
|
||||
}
|
||||
|
||||
const scanned: ScanResult = { urls, fallbackUrls: [] };
|
||||
const results = await validateFiles(files, {
|
||||
scanned,
|
||||
checkExternal: false,
|
||||
checkRelativePaths: "exists",
|
||||
ignoreFragment: true,
|
||||
markdown: {
|
||||
components: {
|
||||
Link: { attributes: ["href", "to"] },
|
||||
},
|
||||
},
|
||||
whitelist: (url) =>
|
||||
/^(?:https?:|mailto:|tel:|#)/.test(url) ||
|
||||
/^\/(?:api\/|custom-widgets\/|data\/|img\/|workshop(?:\/|$)|llms(?:-|\.|\/)|robots\.txt|sitemap\.xml)/.test(url),
|
||||
});
|
||||
|
||||
printErrors(
|
||||
results.filter((result) => result.errors.length > 0),
|
||||
true,
|
||||
);
|
||||
|
||||
const renderedLinkErrors = new Set<string>();
|
||||
|
||||
for (const page of renderedPages) {
|
||||
const $ = load(await readFile(page.file, "utf8"));
|
||||
|
||||
$("main a[href]").each((_, element) => {
|
||||
const href = $(element).attr("href");
|
||||
if (!href || /^(?:https?:|mailto:|tel:|#)/.test(href)) return;
|
||||
|
||||
const target = new URL(href, `https://homarr.dev${page.url}`).pathname;
|
||||
if (/^\/(?:_next\/|api\/|custom-widgets\/|data\/|img\/|videos\/)/.test(target)) return;
|
||||
if (/\.(?:avif|gif|jpe?g|json|md|mp4|png|svg|webm|webp)$/i.test(target)) return;
|
||||
if (urls.has(target) || urls.has(target.replace(/\/$/, "")) || urls.has(`${target}/`)) return;
|
||||
|
||||
renderedLinkErrors.add(`${page.url}: ${href} resolves to missing route ${target}`);
|
||||
});
|
||||
}
|
||||
|
||||
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,166 +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);
|
||||
});
|
||||
Reference in new issue
Block a user