docs: improve Fumadocs search and AI integration
This commit is contained in:
1 parent
6704ef2c13
commit
16ba3c6977
33 files changed
+1354
-280
No files matched your search
@@ -1,7 +1,7 @@
|
||||
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";
|
||||
import { printErrors, readFiles, validateFiles, type ScanResult, type UrlMeta } from "next-validate-link";
|
||||
|
||||
const outputDirectory = path.resolve("out");
|
||||
|
||||
@@ -11,7 +11,7 @@ const toContentUrl = (file: string) => {
|
||||
if (normalized.startsWith("docs/")) {
|
||||
const slug = normalized
|
||||
.slice("docs/".length)
|
||||
.replace(/\/(index)?\.mdx?$/, "")
|
||||
.replace(/(?:^|\/)index\.mdx?$/, "")
|
||||
.replace(/\.mdx?$/, "");
|
||||
return `/docs${slug ? `/${slug}` : ""}/`;
|
||||
}
|
||||
@@ -25,8 +25,8 @@ const toContentUrl = (file: string) => {
|
||||
|
||||
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 }[] = [];
|
||||
const urls = new Map<string, UrlMeta>();
|
||||
const renderedPages: { url: string; links: string[] }[] = [];
|
||||
|
||||
for (const entry of output) {
|
||||
if (!entry.isFile()) continue;
|
||||
@@ -37,9 +37,20 @@ for (const entry of output) {
|
||||
continue;
|
||||
}
|
||||
const url = `/${relative.replace(/\/?index\.html$/, "")}`.replace(/\/$/, "") || "/";
|
||||
urls.set(url, {});
|
||||
urls.set(`${url}/`, {});
|
||||
renderedPages.push({ file, url });
|
||||
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: [] };
|
||||
@@ -47,38 +58,42 @@ 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),
|
||||
whitelist: (url) => /^(?:https?:|mailto:|tel:)/.test(url),
|
||||
});
|
||||
|
||||
printErrors(
|
||||
results.filter((result) => result.errors.length > 0),
|
||||
true,
|
||||
);
|
||||
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) {
|
||||
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}`);
|
||||
});
|
||||
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) {
|
||||
|
||||
Reference in new issue
Block a user