Skip to content

Commit eadbe94

Browse files
feat: create script to generate CLI reference docs (#1015)
1 parent 24203a1 commit eadbe94

9 files changed

Lines changed: 734 additions & 200 deletions

File tree

.changeset/witty-radios-admire.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

packages/cli/src/cli/cmd/logout.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010

1111
export default new Command()
1212
.command("logout")
13-
.description("Sign out from Lingo.dev API")
13+
.description("Log out from Lingo.dev API")
1414
.helpOption("-h, --help", "Show help")
1515
.action(async () => {
1616
try {

pnpm-lock.yaml

Lines changed: 366 additions & 199 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ packages:
55
- "./legacy/*"
66
- "./action"
77
- "./php/*"
8+
- "./scripts/*"

scripts/docs/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# scripts/docs
2+
3+
## Introduction
4+
5+
This directory contains scripts for generating documentation from the Lingo.dev source code.
6+
7+
## generate-cli-docs
8+
9+
This script generates reference documentation for **Lingo.dev CLI**.
10+
11+
### Usage
12+
13+
```bash
14+
pnpm --filter docs run generate-cli-docs [output_file_path]
15+
```
16+
17+
### How it works
18+
19+
1. Loads the CLI program from the `cli` package.
20+
2. Walks through all commands and subcommands.
21+
3. Generates a Markdown file with the complete command reference.
22+
23+
### Notes
24+
25+
- When running inside a GitHub Action, this script comments on the PR with the Markdown content.
26+
- When running outside of a GitHub action, the script writes the Markdown file to disk.

scripts/docs/package.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"name": "docs",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"generate-cli-docs": "tsx ./src/generate-cli-docs.ts"
7+
},
8+
"devDependencies": {
9+
"@octokit/rest": "^20.1.2",
10+
"@types/mdast": "^4.0.4",
11+
"@types/node": "^24.0.10",
12+
"commander": "^12.0.0",
13+
"remark-stringify": "^11.0.0",
14+
"tsx": "^4.7.1",
15+
"unified": "^11.0.4"
16+
}
17+
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
#!/usr/bin/env node
2+
3+
import type { Command } from "commander";
4+
import { existsSync } from "fs";
5+
import { mkdir, writeFile } from "fs/promises";
6+
import type { Root } from "mdast";
7+
import { resolve, dirname } from "path";
8+
import remarkStringify from "remark-stringify";
9+
import { unified } from "unified";
10+
import { pathToFileURL } from "url";
11+
import { createOrUpdateGitHubComment, getRepoRoot } from "./utils";
12+
13+
async function getProgram(repoRoot: string): Promise<Command> {
14+
const filePath = resolve(
15+
repoRoot,
16+
"packages",
17+
"cli",
18+
"src",
19+
"cli",
20+
"index.ts",
21+
);
22+
23+
if (!existsSync(filePath)) {
24+
throw new Error(`CLI source file not found at ${filePath}`);
25+
}
26+
27+
const cliModule = (await import(pathToFileURL(filePath).href)) as {
28+
default: Command;
29+
};
30+
31+
return cliModule.default;
32+
}
33+
34+
function buildMarkdown(program: Command): string {
35+
const mdast: Root = {
36+
type: "root",
37+
children: [
38+
{
39+
type: "paragraph",
40+
children: [
41+
{
42+
type: "text",
43+
value:
44+
"This page contains the complete list of commands available via ",
45+
},
46+
{
47+
type: "strong",
48+
children: [{ type: "text", value: "Lingo.dev CLI" }],
49+
},
50+
{
51+
type: "text",
52+
value: ". To access this documentation from the CLI itself, run ",
53+
},
54+
{
55+
type: "inlineCode",
56+
value: "npx lingo.dev@latest --help",
57+
},
58+
{
59+
type: "text",
60+
value: ".",
61+
},
62+
],
63+
},
64+
],
65+
};
66+
67+
const helper = program.createHelp();
68+
const visited = new Set<Command>();
69+
70+
type WalkOptions = {
71+
cmd: Command;
72+
parents: string[];
73+
};
74+
75+
function walk({ cmd, parents }: WalkOptions): void {
76+
if (visited.has(cmd)) {
77+
return;
78+
}
79+
80+
visited.add(cmd);
81+
82+
const commandPath = [...parents, cmd.name()].join(" ").trim();
83+
84+
// Heading for this command
85+
mdast.children.push({
86+
type: "heading",
87+
depth: 2,
88+
children: [{ type: "inlineCode", value: commandPath || cmd.name() }],
89+
});
90+
91+
// Code block containing the help output
92+
mdast.children.push({
93+
type: "code",
94+
lang: "bash",
95+
value: helper.formatHelp(cmd, helper).trimEnd(),
96+
});
97+
98+
cmd.commands.forEach((sub: Command) => {
99+
walk({ cmd: sub, parents: [...parents, cmd.name()] });
100+
});
101+
}
102+
103+
walk({ cmd: program, parents: [] });
104+
105+
return unified().use(remarkStringify).stringify(mdast);
106+
}
107+
108+
async function main(): Promise<void> {
109+
const repoRoot = getRepoRoot();
110+
const commentMarker = "<!-- generate-cli-docs -->";
111+
112+
console.log("🔄 Generating CLI docs...");
113+
const cli = await getProgram(repoRoot);
114+
const markdown = buildMarkdown(cli);
115+
116+
const isGitHubAction = Boolean(process.env.GITHUB_ACTIONS);
117+
118+
if (isGitHubAction) {
119+
console.log("💬 Commenting on GitHub PR...");
120+
121+
const mdast: Root = {
122+
type: "root",
123+
children: [
124+
{ type: "html", value: commentMarker },
125+
{
126+
type: "paragraph",
127+
children: [
128+
{
129+
type: "text",
130+
value:
131+
"Your PR affects Lingo.dev CLI and, as a result, may affect the auto-generated reference documentation that will be published to the documentation website. Please review the output below to ensure that the changes are correct.",
132+
},
133+
],
134+
},
135+
{ type: "html", value: "<details>" },
136+
{ type: "html", value: "<summary>Lingo.dev CLI Commands</summary>" },
137+
{ type: "code", lang: "markdown", value: markdown },
138+
{ type: "html", value: "</details>" },
139+
],
140+
};
141+
142+
const body = unified()
143+
.use([[remarkStringify, { fence: "~" }]])
144+
.stringify(mdast)
145+
.toString();
146+
147+
await createOrUpdateGitHubComment({
148+
commentMarker,
149+
body,
150+
});
151+
152+
return;
153+
}
154+
155+
const outputArg = process.argv[2];
156+
157+
if (!outputArg) {
158+
throw new Error(
159+
"Output file path is required. Usage: generate-cli-docs <output-path>",
160+
);
161+
}
162+
163+
const outputFilePath = resolve(process.cwd(), outputArg);
164+
165+
console.log(`💾 Saving to ${outputFilePath}...`);
166+
await mkdir(dirname(outputFilePath), { recursive: true });
167+
await writeFile(outputFilePath, markdown, "utf8");
168+
console.log(`✅ Saved to ${outputFilePath}`);
169+
}
170+
171+
main().catch((err) => {
172+
console.error(err);
173+
process.exit(1);
174+
});

scripts/docs/src/utils.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { existsSync } from "fs";
2+
import path from "path";
3+
import { fileURLToPath } from "url";
4+
import { readFileSync } from "fs";
5+
import { Octokit } from "@octokit/rest";
6+
7+
export function getRepoRoot(): string {
8+
const __filename = fileURLToPath(import.meta.url);
9+
const __dirname = path.dirname(__filename);
10+
let currentDir = __dirname;
11+
12+
while (currentDir !== path.parse(currentDir).root) {
13+
if (existsSync(path.join(currentDir, ".git"))) {
14+
return currentDir;
15+
}
16+
currentDir = path.dirname(currentDir);
17+
}
18+
19+
throw new Error("Could not find project root");
20+
}
21+
22+
export function getGitHubToken() {
23+
const token = process.env.GITHUB_TOKEN;
24+
25+
if (!token) {
26+
throw new Error("GITHUB_TOKEN environment variable is required.");
27+
}
28+
29+
return token;
30+
}
31+
32+
export function getGitHubRepo() {
33+
const repository = process.env.GITHUB_REPOSITORY;
34+
35+
if (!repository) {
36+
throw new Error("GITHUB_REPOSITORY environment variable is missing.");
37+
}
38+
39+
const [_, repo] = repository.split("/");
40+
41+
return repo;
42+
}
43+
44+
export function getGitHubOwner() {
45+
const repository = process.env.GITHUB_REPOSITORY;
46+
47+
if (!repository) {
48+
throw new Error("GITHUB_REPOSITORY environment variable is missing.");
49+
}
50+
51+
const [owner] = repository.split("/");
52+
53+
return owner;
54+
}
55+
56+
export function getGitHubPRNumber() {
57+
const prNumber = process.env.PR_NUMBER;
58+
59+
if (prNumber) {
60+
return Number(prNumber);
61+
}
62+
63+
const eventPath = process.env.GITHUB_EVENT_PATH;
64+
65+
if (eventPath && existsSync(eventPath)) {
66+
try {
67+
const eventData = JSON.parse(readFileSync(eventPath, "utf8"));
68+
return Number(eventData.pull_request?.number);
69+
} catch (err) {
70+
console.warn("Failed to parse GITHUB_EVENT_PATH JSON:", err);
71+
}
72+
}
73+
74+
throw new Error("Could not determine pull request number.");
75+
}
76+
77+
export type GitHubCommentOptions = {
78+
commentMarker: string;
79+
body: string;
80+
};
81+
82+
export async function createOrUpdateGitHubComment(
83+
options: GitHubCommentOptions,
84+
): Promise<void> {
85+
const token = getGitHubToken();
86+
const owner = getGitHubOwner();
87+
const repo = getGitHubRepo();
88+
const prNumber = getGitHubPRNumber();
89+
90+
const octokit = new Octokit({ auth: token });
91+
92+
const commentsResponse = await octokit.rest.issues.listComments({
93+
owner,
94+
repo,
95+
issue_number: prNumber,
96+
per_page: 100,
97+
});
98+
99+
const comments = commentsResponse.data;
100+
101+
const existing = comments.find((c) => {
102+
if (!c.body) {
103+
return false;
104+
}
105+
return c.body.startsWith(options.commentMarker);
106+
});
107+
108+
if (existing) {
109+
console.log(`Updating existing comment (id: ${existing.id}).`);
110+
await octokit.rest.issues.updateComment({
111+
owner,
112+
repo,
113+
comment_id: existing.id,
114+
body: options.body,
115+
});
116+
return;
117+
}
118+
119+
console.log("Creating new comment.");
120+
await octokit.rest.issues.createComment({
121+
owner,
122+
repo,
123+
issue_number: prNumber,
124+
body: options.body,
125+
});
126+
}

scripts/docs/tsconfig.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"compilerOptions": {
3+
"rootDir": "src",
4+
"outDir": "build",
5+
"declaration": true,
6+
"declarationMap": true,
7+
"sourceMap": true,
8+
"strict": true,
9+
"esModuleInterop": true,
10+
"resolveJsonModule": true,
11+
"allowSyntheticDefaultImports": true,
12+
"skipLibCheck": true,
13+
"moduleResolution": "Bundler",
14+
"module": "ESNext",
15+
"target": "ESNext",
16+
"allowUnreachableCode": true,
17+
"types": ["node"]
18+
},
19+
"include": ["src/**/*.ts", "src/**/*.tsx"],
20+
"exclude": ["src/**/*.spec.ts", "src/**/*.spec.tsx"]
21+
}

0 commit comments

Comments
 (0)