gogcli/scripts/check-docs-coverage.mjs
2026-05-05 07:17:38 +01:00

138 lines
4.1 KiB
JavaScript

#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const bin = process.env.GOG_BIN || path.join(root, "bin", "gog");
const docsDir = path.join(root, "docs");
const commandsDir = path.join(docsDir, "commands");
const requiredFeatureDocs = [
"install.md",
"auth-clients.md",
"safety-profiles.md",
"raw-api.md",
"raw-audit.md",
"gmail-workflows.md",
"watch.md",
"email-tracking.md",
"drive-audits.md",
"contacts-dedupe.md",
"contacts-json-update.md",
"docs-editing.md",
"sheets-tables.md",
"sheets-formatting.md",
"slides-markdown.md",
"slides-template-replacement.md",
"backup.md",
"dates.md",
];
const schema = JSON.parse(execFileSync(bin, ["schema", "--json"], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }));
const commands = Array.from(walk(schema.command || {}));
const seenSlugs = new Set();
const missingCommandPages = [];
for (const command of commands) {
const base = commandSlug(command);
let slug = base;
let suffix = 2;
while (seenSlugs.has(slug)) {
slug = `${base}-${suffix}`;
suffix += 1;
}
seenSlugs.add(slug);
const page = path.join(commandsDir, `${slug}.md`);
if (!fs.existsSync(page)) {
missingCommandPages.push(path.relative(root, page));
}
}
const docsReadme = fs.readFileSync(path.join(docsDir, "README.md"), "utf8");
const missingFeaturePages = [];
const unlinkedFeaturePages = [];
const brokenLinks = checkMarkdownLinks(docsDir);
for (const rel of requiredFeatureDocs) {
const page = path.join(docsDir, rel);
if (!fs.existsSync(page)) {
missingFeaturePages.push(`docs/${rel}`);
continue;
}
if (!docsReadme.includes(`(${rel})`)) {
unlinkedFeaturePages.push(`docs/${rel}`);
}
}
if (missingCommandPages.length || missingFeaturePages.length || unlinkedFeaturePages.length || brokenLinks.length) {
for (const name of missingCommandPages) console.error(`missing command doc: ${name}`);
for (const name of missingFeaturePages) console.error(`missing feature doc: ${name}`);
for (const name of unlinkedFeaturePages) console.error(`feature doc not linked from docs/README.md: ${name}`);
for (const item of brokenLinks) console.error(`broken docs link: ${item}`);
process.exit(1);
}
console.log(`docs coverage ok: ${commands.length} command pages, ${requiredFeatureDocs.length} feature pages`);
function* walk(command) {
yield command;
for (const child of command.subcommands || []) {
yield* walk(child);
}
}
function canonicalTokens(commandPath) {
return (commandPath || "")
.split(/\s+/)
.filter((part) => part && !(part.startsWith("(") && part.endsWith(")")));
}
function canonicalPath(command) {
return canonicalTokens(command.path || command.name || "").join(" ");
}
function commandSlug(command) {
const slug = canonicalPath(command)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return slug || "gog";
}
function checkMarkdownLinks(dir) {
const broken = [];
for (const file of allMarkdown(dir)) {
const markdown = fs.readFileSync(file, "utf8");
const linkPattern = /!?\[[^\]]*\]\(([^)]+)\)/g;
let match;
while ((match = linkPattern.exec(markdown)) !== null) {
const rawTarget = match[1].trim().replace(/^<|>$/g, "");
if (!rawTarget || rawTarget.startsWith("#")) continue;
if (/^[a-z][a-z0-9+.-]*:/i.test(rawTarget)) continue;
const targetWithoutTitle = rawTarget.split(/\s+["'][^"']*["']\s*$/)[0];
const targetPath = targetWithoutTitle.split("#")[0];
if (!targetPath) continue;
if (/^(url|path|file)$/i.test(targetPath)) continue;
const resolved = path.resolve(path.dirname(file), targetPath);
if (!fs.existsSync(resolved)) {
broken.push(`${path.relative(root, file)} -> ${targetPath}`);
}
}
}
return broken;
}
function allMarkdown(dir) {
return fs
.readdirSync(dir, { withFileTypes: true })
.flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return allMarkdown(full);
return entry.name.endsWith(".md") ? [full] : [];
});
}