Files
openship/scripts/check-docs-cli.mjs
2026-09-14 03:47:37 +03:00

94 lines
3.2 KiB
JavaScript

import { readFileSync } from "node:fs";
import { relative } from "node:path";
import { docsDirectory, filesUnder, root } from "./docs-surface.mjs";
// Tokenize documented shell commands without invoking the shell or command actions.
function tokens(line) {
const result = [];
let token = "",
quote = "";
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === "\\" && quote !== "'" && i + 1 < line.length) {
token += line[++i];
continue;
}
if (quote) {
if (char === quote) quote = "";
else token += char;
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === "#" && !token) break;
if (/\s/.test(char)) {
if (token) result.push(token);
token = "";
} else token += char;
}
if (token) result.push(token);
return result;
}
function flags(entry) {
return new Map(
entry.options.flatMap((option) => {
const names =
option.syntax
.match(/(?:^|[, ])(--?[\w-]+)/g)
?.map((name) => name.trim().replace(/^,\s*/, "")) ?? [];
return names.map((name) => [
name,
{ value: /[<[]/.test(option.syntax), optional: option.syntax.includes("[") },
]);
}),
);
}
export function checkCliExamples(catalog) {
const entries = new Map(catalog.map((entry) => [entry.command, entry]));
const globalFlags = flags(entries.get("openship"));
const errors = [];
let count = 0;
for (const file of filesUnder(docsDirectory, ".mdx")) {
if (file.includes("/cli/reference/")) continue;
const source = readFileSync(file, "utf8");
for (const block of source.matchAll(/^```(?:bash|sh|shell)\b[^\n]*\n([\s\S]*?)^```/gm)) {
for (const line of block[1].replace(/\\\n\s*/g, " ").split("\n")) {
const command = line.trim().replace(/^npx\s+/, "");
if (!command.startsWith("openship ")) continue;
const parts = tokens(command).slice(1);
let entry = entries.get("openship");
count++;
for (let index = 0; index < parts.length; index++) {
const part = parts[index];
if (["--", "|", "||", "&&", ";", ">", ">>"].includes(part)) break;
if (part.startsWith("-")) {
const [name] = part.split("=", 1);
const option = flags(entry).get(name) ?? globalFlags.get(name);
if (!option)
errors.push(`${relative(root, file)}: ${entry.command} has no ${name} option`);
else if (
option.value &&
!part.includes("=") &&
(!option.optional || !parts[index + 1]?.startsWith("-"))
)
index++;
continue;
}
const child = entry.children.find((row) =>
row.syntax.split(" ")[0].split("|").includes(part),
);
if (child) entry = entries.get(entry.command + " " + child.syntax.split(/[ |]/)[0]);
else if (entry.children.length && !entry.arguments.length)
errors.push(`${relative(root, file)}: ${entry.command} has no ${part} subcommand`);
}
}
}
}
if (errors.length) throw new Error([...new Set(errors)].join("\n"));
return count;
}