mirror of
https://github.com/oblien/openship.git
synced 2026-10-21 06:19:40 +08:00
157 lines
6.6 KiB
JavaScript
157 lines
6.6 KiB
JavaScript
/** Validate navigation, links, MDX, and the published SDK/CLI documentation surface. */
|
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
import { dirname, join, relative } from "node:path";
|
|
import { createRequire } from "node:module";
|
|
import { pathToFileURL } from "node:url";
|
|
import { docsDirectory, filesUnder, root, cliSurface } from "./docs-surface.mjs";
|
|
import { updateApiReference } from "./generate-api-reference.mjs";
|
|
import { renderCliReference } from "./generate-cli-reference.mjs";
|
|
import { updateMcpReference } from "./generate-mcp-reference.mjs";
|
|
import { checkDocExamples } from "./check-docs-examples.mjs";
|
|
import { checkCliExamples } from "./check-docs-cli.mjs";
|
|
|
|
// Resolve the actual website's parser and heading plugin. No separate parser,
|
|
// global CLI, Git executable, or search utility is needed on CI runners.
|
|
const webRequire = createRequire(join(root, "apps/web/package.json"));
|
|
const mdxRequire = createRequire(webRequire.resolve("fumadocs-mdx/package.json"));
|
|
const coreRequire = createRequire(webRequire.resolve("fumadocs-core/package.json"));
|
|
const { createProcessor } = await import(pathToFileURL(mdxRequire.resolve("@mdx-js/mdx")).href);
|
|
const { default: remarkGfm } = await import(pathToFileURL(coreRequire.resolve("remark-gfm")).href);
|
|
const { remarkHeading } = await import(
|
|
pathToFileURL(webRequire.resolve("fumadocs-core/mdx-plugins/remark-heading")).href
|
|
);
|
|
const failures = [];
|
|
const pages = new Map();
|
|
const walk = (node, visit) => {
|
|
visit(node);
|
|
for (const child of node.children ?? []) walk(child, visit);
|
|
};
|
|
const routeFor = (file) =>
|
|
"/docs" +
|
|
(
|
|
"/" +
|
|
relative(docsDirectory, file)
|
|
.replaceAll("\\", "/")
|
|
.replace(/\.mdx$/, "")
|
|
.replace(/(?:^|\/)index$/, "")
|
|
).replace(/\/$/, "");
|
|
|
|
for (const file of filesUnder(docsDirectory, ".mdx")) {
|
|
const raw = readFileSync(file, "utf8");
|
|
const frontmatter = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
if (
|
|
!frontmatter ||
|
|
!/^title: \S/m.test(frontmatter[1]) ||
|
|
!/^description: \S/m.test(frontmatter[1])
|
|
)
|
|
failures.push(`${relative(root, file)}: add a title and description`);
|
|
const content = raw.slice(frontmatter?.[0].length ?? 0);
|
|
const route = routeFor(file);
|
|
if (pages.has(route)) failures.push(`Duplicate documentation URL: ${route}`);
|
|
const page = { file, ids: new Set(), links: [] };
|
|
pages.set(route, page);
|
|
try {
|
|
let document;
|
|
const processor = createProcessor({
|
|
remarkPlugins: [
|
|
remarkGfm,
|
|
[remarkHeading, { generateToc: false }],
|
|
() => (tree) => {
|
|
document = tree;
|
|
},
|
|
],
|
|
});
|
|
await processor.process(content);
|
|
walk(document, (node) => {
|
|
if (node.type === "heading" && node.data?.hProperties?.id)
|
|
page.ids.add(node.data.hProperties.id);
|
|
if (node.type === "link" || node.type === "definition") page.links.push(node.url);
|
|
for (const attribute of node.attributes ?? []) {
|
|
if (attribute.name === "href" && typeof attribute.value === "string")
|
|
page.links.push(attribute.value);
|
|
if (attribute.name === "id" && typeof attribute.value === "string")
|
|
page.ids.add(attribute.value);
|
|
}
|
|
});
|
|
} catch (error) {
|
|
failures.push(`${relative(root, file)}: ${error.message}`);
|
|
}
|
|
if (/Screenshot pending|REST reference pending/.test(content))
|
|
failures.push(`${relative(root, file)}: remove unfinished documentation placeholders`);
|
|
}
|
|
|
|
for (const [route, page] of pages) {
|
|
for (const link of page.links) {
|
|
if (
|
|
!link.startsWith("/docs") &&
|
|
!link.startsWith("#") &&
|
|
!link.startsWith("https://openship.io/docs")
|
|
)
|
|
continue;
|
|
const url = new URL(link, "https://openship.io" + route);
|
|
const targetRoute = url.pathname.replace(/\.md$/, "").replace(/\/$/, "");
|
|
const target = pages.get(targetRoute);
|
|
if (!target) failures.push(`${relative(root, page.file)}: missing page ${link}`);
|
|
else if (url.hash && !target.ids.has(decodeURIComponent(url.hash.slice(1))))
|
|
failures.push(`${relative(root, page.file)}: missing heading ${link}`);
|
|
}
|
|
}
|
|
|
|
for (const metaFile of filesUnder(docsDirectory, "meta.json")) {
|
|
const meta = JSON.parse(readFileSync(metaFile, "utf8"));
|
|
if (!Array.isArray(meta.pages)) continue;
|
|
const directory = dirname(metaFile);
|
|
const explicit = new Set();
|
|
for (const entry of meta.pages) {
|
|
if (entry.startsWith("---") || entry.startsWith("[") || entry.startsWith("!")) continue;
|
|
if (entry === "...") continue;
|
|
const candidate = join(directory, entry);
|
|
if (
|
|
!existsSync(candidate + ".mdx") &&
|
|
!(
|
|
existsSync(candidate) &&
|
|
readdirSync(candidate).some((name) => name.endsWith(".mdx") || name === "meta.json")
|
|
)
|
|
)
|
|
failures.push(`${relative(root, metaFile)}: missing navigation entry ${entry}`);
|
|
if (explicit.has(entry))
|
|
failures.push(`${relative(root, metaFile)}: repeated navigation entry ${entry}`);
|
|
explicit.add(entry);
|
|
}
|
|
if (!meta.pages.includes("...")) {
|
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
const name =
|
|
entry.isFile() && entry.name.endsWith(".mdx")
|
|
? entry.name.slice(0, -4)
|
|
: entry.isDirectory() && filesUnder(join(directory, entry.name), ".mdx").length
|
|
? entry.name
|
|
: undefined;
|
|
if (name && !explicit.has(name) && !meta.pages.includes("!" + name))
|
|
failures.push(`${relative(root, metaFile)}: page/folder missing from navigation: ${name}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (failures.length) throw new Error([...new Set(failures)].join("\n"));
|
|
console.log(`${pages.size} documentation pages compile; navigation and internal links resolve.`);
|
|
const mcp = updateMcpReference();
|
|
console.log(`${mcp.tools.length} MCP tools match runtime descriptions; every HTTP route is classified.`);
|
|
const api = updateApiReference();
|
|
console.log(
|
|
`${api.methodCount} SDK methods and ${api.routeCount} HTTP routes have one shared reference.`,
|
|
);
|
|
const cli = await cliSurface();
|
|
const reference = renderCliReference(cli);
|
|
const cliDirectory = join(docsDirectory, "cli/reference");
|
|
for (const [file, expected] of reference) {
|
|
if (readFileSync(join(cliDirectory, file), "utf8") !== expected)
|
|
throw new Error(`Stale CLI reference: ${file}. Run bun run docs:reference.`);
|
|
}
|
|
for (const file of filesUnder(cliDirectory, ".mdx")) {
|
|
if (!reference.has(relative(cliDirectory, file).replaceAll("\\", "/")))
|
|
throw new Error(`Obsolete CLI reference: ${relative(root, file)}`);
|
|
}
|
|
console.log(`${cli.length} CLI command paths match the built public CLI.`);
|
|
console.log(`${checkCliExamples(cli)} CLI examples use current commands and options.`);
|
|
console.log(`${checkDocExamples()} SDK examples type-check against the public package.`);
|