convex
Version:
Client for the Convex Cloud
394 lines (360 loc) • 14.7 kB
text/typescript
import type { CommandUnknownOpts } from "@commander-js/extra-typings";
export type GeneratedDocs = Record<string, string>;
type CommandInfo = {
command: CommandUnknownOpts;
// Full path of command names from the root, e.g. ["convex", "env", "set"].
path: string[];
};
/**
* Generate Markdown reference docs for a Commander main command.
*
* Returns a map of file paths (relative, e.g. `env.mdx`) to Markdown
* contents. One file is generated for each visible main command (a direct
* child of the root); the root itself gets no page, so "Command Reference"
* is a sidebar section with no landing page. Descendant subcommands are
* rendered as sections inside their main command's file, with their heading
* level reflecting their depth: a direct subcommand (`env default`) is an
* `## h2`, a sub-subcommand (`env default set`) an `### h3`, and so on.
*
* Each page carries a `description` frontmatter equal to the command's
* summary; the `/cli` page reads these back via Docusaurus metadata to render
* the list of commands dynamically.
*/
export function generateDocs(root: CommandUnknownOpts): GeneratedDocs {
const docs: GeneratedDocs = {};
const all = collectCommands(root, [root.name()]);
let mainCommandPosition = 0;
for (const entry of all) {
// Skip the root command (no landing page) and any descendant beyond a
// main command (those are rendered inside their main command's file).
if (entry.path.length !== 2) continue;
const filePath = `${entry.path[1]}.mdx`;
const sidebarPosition = ++mainCommandPosition;
const summary =
entry.command.summary() || entry.command.description() || "";
// Collapse to a single line so it's a valid YAML frontmatter scalar, and
// quote it safely (summaries contain `[BETA]`, `:`, etc.).
const description = JSON.stringify(summary.replace(/\s+/g, " ").trim());
const lines: string[] = [];
const sidebarLabel = `npx ${entry.path.join(" ")}`;
lines.push("---");
lines.push(`sidebar_position: ${sidebarPosition}`);
// Set an explicit `title` so the Docusaurus <title> (and browser tab) reads
// the full command, e.g. `npx convex dev | …`. Without it Docusaurus
// derives the title from the doc id (the file name), which would be just
// `dev`.
lines.push(`title: "${sidebarLabel}"`);
lines.push(`sidebar_label: "${sidebarLabel}"`);
lines.push(`description: ${description}`);
lines.push("---");
lines.push("");
lines.push(
"{/* @generated from the command definitions, do not edit manually (run `just regenerate-cli-docs` to regenerate) */}",
);
lines.push("");
lines.push(renderCommand(entry, all, { headingLevel: 1 }));
const descendants = all.filter(
(e) =>
e.path.length > entry.path.length &&
entry.path.every((p, i) => e.path[i] === p),
);
for (const d of descendants) {
// Render the heading hierarchically by depth below the root: a direct
// subcommand (`env default`, path length 3) is an h2, a sub-subcommand
// (`env default set`, path length 4) is an h3, and so on. This nests
// sub-subcommands under their parent group instead of flattening every
// descendant to the same h2 level.
lines.push(
renderCommand(d, all, {
headingLevel: d.path.length - 1,
includeSubcommandsList: false,
}),
);
}
docs[filePath] = lines.join("\n");
}
return docs;
}
function collectCommands(
command: CommandUnknownOpts,
path: string[],
): CommandInfo[] {
const result: CommandInfo[] = [{ command, path }];
for (const sub of command.commands) {
// Skip hidden commands and Commander's built-in help command.
if ((sub as any)._hidden) continue;
if (sub.name() === "help") continue;
result.push(...collectCommands(sub, [...path, sub.name()]));
}
return result;
}
function displayName(path: string[]): string {
return `npx ${path.join(" ")}`;
}
// Build the usage suffix (everything after the command name) for the Usage
// code block. We derive the argument portion from the command's registered
// arguments rather than trusting a manual `.usage()` override, so the Usage
// line always agrees with the Arguments section below. For example `env set`
// overrides `.usage()` to show `<name> <value>` (pretending the args are
// required) for nicer `--help` output, even though it registers them as
// optional `[name] [value]`; reproducing the override verbatim would make the
// generated page contradict itself. Commands with no registered arguments keep
// their `.usage()` string verbatim (e.g. the root's `<command> [options]`).
function commandUsage(command: CommandUnknownOpts): string {
const args = (command as any).registeredArguments ?? [];
if (args.length === 0) {
return command.usage();
}
// Mirror Commander's default usage order: options, then a subcommand
// placeholder, then the positional arguments. `[options]` is always present
// because the built-in `--help` option is always registered.
const parts = ["[options]"];
if (command.commands.length > 0) {
parts.push("[command]");
}
for (const arg of args) {
const name = `${arg._name}${arg.variadic ? "..." : ""}`;
parts.push(arg.required ? `<${name}>` : `[${name}]`);
}
return parts.join(" ");
}
// Anchor id for a descendant command rendered as an h2 section inside its
// main command's file. Built from the path segments below the main command,
// joined with `-`, so sub-subcommands stay unique. For example, both
// `deployment create` and `deployment token create` would collide on `#create`
// if we used only the leaf name; instead they become `#create` and
// `#token-create`.
function anchorSlug(path: string[]): string {
return path.slice(2).join("-");
}
// Link target for a subcommand listed inside a command's "Subcommands"
// section. From the root index, main commands live in sibling files. From a
// main command page, descendants are rendered as h2 sections in the same
// file, anchored by their path below the main command.
function subcommandLink(parentPath: string[], subPath: string[]): string {
if (parentPath.length === 1) {
return `./${subPath[1]}`;
}
return `#${anchorSlug(subPath)}`;
}
function renderCommand(
entry: CommandInfo,
all: CommandInfo[],
options: { headingLevel: number; includeSubcommandsList?: boolean },
): string {
const { headingLevel, includeSubcommandsList = true } = options;
const h1 = "#".repeat(headingLevel);
const h2 = "#".repeat(headingLevel + 1);
const { command, path } = entry;
const display = displayName(path);
const description = renderCopyableCommands(
escapeMdx(
formatExampleLines(
replaceBullets(command.description() || command.summary() || ""),
),
),
);
const lines: string[] = [];
// For nested subcommand sections, pin the heading id to the path below the
// main command so anchors like `#set` and `#token-create` stay unique even
// when leaf names collide across different parents.
const headingSuffix = headingLevel > 1 ? ` \\{#${anchorSlug(path)}}` : "";
lines.push(`${h1} \`${display}\`${headingSuffix}`);
lines.push("");
if (description) {
lines.push(description);
lines.push("");
}
const usage = commandUsage(command);
lines.push(`${h2} Usage`);
lines.push("");
lines.push("```sh");
lines.push(`${display} ${usage}`.trim());
lines.push("```");
lines.push("");
const aliases = command.aliases();
if (aliases.length > 0) {
lines.push(`${h2} Aliases`);
lines.push("");
for (const alias of aliases) {
lines.push(`- \`${alias}\``);
}
lines.push("");
}
const args = (command as any).registeredArguments ?? [];
if (args.length > 0) {
lines.push(`${h2} Arguments`);
lines.push("");
lines.push("<dl>");
for (const arg of args) {
const name = arg.required ? `<${arg._name}>` : `[${arg._name}]`;
const desc = escapeMdx(replaceBullets(arg.description || ""));
lines.push(`<dt>\`${name}\`</dt>`);
lines.push(`<dd>`);
lines.push("");
lines.push(desc);
lines.push("");
lines.push(`</dd>`);
}
lines.push("</dl>");
lines.push("");
}
const opts = command.options.filter((o: any) => !o.hidden);
if (opts.length > 0) {
lines.push(`${h2} Options`);
lines.push("");
lines.push("<dl>");
for (const opt of opts) {
const flags = (opt as any).flags as string;
const desc = escapeMdx(replaceBullets(opt.description || ""));
lines.push(`<dt>\`${flags}\`</dt>`);
lines.push(`<dd>`);
lines.push("");
lines.push(desc);
lines.push("");
lines.push(`</dd>`);
}
lines.push("</dl>");
lines.push("");
}
if (includeSubcommandsList) {
const subEntries = all.filter(
(e) =>
e.path.length === path.length + 1 &&
path.every((p, i) => e.path[i] === p),
);
if (subEntries.length > 0) {
lines.push(`${h2} Subcommands`);
lines.push("");
for (const sub of subEntries) {
const subDisplay = displayName(sub.path);
const target = subcommandLink(path, sub.path);
const subDesc = indentContinuation(
escapeMdx(sub.command.summary() || sub.command.description() || ""),
);
lines.push(`- [\`${subDisplay}\`](${target}) — ${subDesc}`.trimEnd());
}
lines.push("");
}
}
return lines.join("\n");
}
// Escape characters that MDX would otherwise parse as JSX. Backslash-escaping
// `<` and `{` keeps placeholder text like `<team_slug>` rendering as literal
// text instead of being interpreted as a tag or expression.
//
// MDX only parses `<` and `{` specially in prose — inside an inline code span
// (backticks) both are already literal, and a backslash there renders verbatim
// (e.g. `` `\<nameOrToken>` `` shows the backslash). So leave code spans
// untouched and escape only the surrounding prose.
function escapeMdx(text: string): string {
return text
.split(/(`[^`]*`)/)
.map((segment, i) =>
// Odd indices are the captured code spans; even indices are prose.
i % 2 === 1 ? segment : segment.replace(/[<{]/g, (c) => `\\${c}`),
)
.join("");
}
// Turn inline code spans that are full `npx convex ...` commands into a
// CodeWithCopyButton component so readers can copy them with one click. The
// component is registered globally in the docs site's MDXComponents, so no
// import is needed in the generated page. This runs after escapeMdx, which
// leaves code spans verbatim, so the command text inside the backticks is
// unescaped and drops straight into the `text` attribute.
//
// We pass the command as a JSX expression containing a JSON-encoded string
// (`text={"..."}`) rather than a plain attribute (`text="..."`) because
// commands routinely contain double quotes (e.g. JSON arguments like
// `'{"body": "hello"}'`), which would otherwise terminate the attribute value
// and produce invalid MDX. JSON.stringify escapes quotes and backslashes for
// us.
function renderCopyableCommands(text: string): string {
return text
.split("\n")
.map((line) =>
/^- /.test(line)
? line.replace(
/`(npx convex [^`]*)`/g,
(_, command) =>
`<CodeWithCopyButton text={${JSON.stringify(command)}} />`,
)
: line,
)
.join("\n");
}
// Replace "•" bullet points with markdown "-" bullet points. Every line that is
// (maybe leading spaces +) "• " becomes the same spaces + "- ". This runs before
// formatExampleLines and renderCopyableCommands so that `npx convex ...` commands
// in bulleted help text are recognized as list items and get a copy button.
export function replaceBullets(text: string): string {
return text.replace(/^( *)• /gm, "$1- ");
}
// Convert indented example lines (e.g., from CLI help text) into markdown lists
// so they render with proper line breaks instead of collapsing into one line.
// Consecutive indented lines are gathered into a block; any line indented by at
// least two spaces belongs to the block, regardless of how deeply it nests.
//
// How much the block is dedented depends on what precedes it:
// - When the block follows a list item, its indentation encodes real nesting
// (a sub-list under that item), so it is preserved verbatim. This keeps a
// 3-level list like the deploy command's preview-key description intact
// instead of flattening the 2-space sub-item to a top-level bullet.
// - When the block follows prose (or starts the text), the indentation is
// just help-text formatting, so the block is dedented by its smallest
// indent to the left margin. Otherwise an indented list wouldn't interrupt
// the preceding paragraph and would collapse into it.
function formatExampleLines(text: string): string {
const lines = text.split("\n");
const result: string[] = [];
let blockLines: string[] = [];
const flushBlock = () => {
if (blockLines.length === 0) {
return;
}
const prev = result[result.length - 1];
const followsListItem = prev !== undefined && isListItem(prev);
const dedent = followsListItem
? 0
: Math.min(...blockLines.map(indentWidth));
for (const blockLine of blockLines) {
result.push(toListItem(blockLine.substring(dedent)));
}
blockLines = [];
};
for (const line of lines) {
// Any line indented by at least two spaces (and not blank) is part of an
// example block, regardless of nesting depth.
if (/^ {2,}\S/.test(line)) {
blockLines.push(line);
} else {
flushBlock();
result.push(line);
}
}
flushBlock();
return result.join("\n");
}
function indentWidth(line: string): number {
return line.length - line.trimStart().length;
}
function isListItem(line: string): boolean {
const content = line.trimStart();
return content.startsWith("- ") || /^\d+\.\s/.test(content);
}
// Render an example line as a markdown list item, preserving any leading indent
// so nested items stay nested. Lines that are already list items — either a
// bullet (`- `) or an ordered-list item (`1. `) — are kept verbatim so they
// render as their intended list type. Prefixing an ordered-list item with `- `
// would nest it inside a bullet, which renders as roman numerals instead of the
// numbered steps.
function toListItem(line: string): string {
if (isListItem(line)) {
return line;
}
const indent = line.slice(0, indentWidth(line));
return `${indent}- ${line.trimStart()}`;
}
function indentContinuation(text: string): string {
return text.replace(/\n/g, "\n ");
}