Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

267 lines · 13.3 KBRaw History
1// Builds the public documentation into src/docs/content.generated.ts:
2// - every Markdown page under docs/ (with its frontmatter), in the order of docs/nav.json
3// - the CLI reference, generated from the CLI's own command registry
4// - the CLI itself as one file, served at /-/cli/diffinery.mjs, and its install scripts
5// (apps/cli/install), served at /install and /install.ps1
6// - the editor extension for VS Code and Cursor (apps/vscode), packed as a .vsix, served at
7// /-/vscode/diffinery.vsix, and its install scripts (apps/vscode/install), served at /install-editor
8// and /install-editor.ps1
9// The REST API and MCP tool references are generated at runtime from the live
10// router and tool list (src/docs/reference.ts), so none of the references can drift.
11//
12// Run by `pnpm build` and `pnpm dev`. Fails on a page missing from the nav (or the
13// other way round) and on missing or overlong frontmatter.
14import { build } from "esbuild";
15import { createHash } from "node:crypto";
16import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
17import { tmpdir } from "node:os";
18import path from "node:path";
19import { fileURLToPath, pathToFileURL } from "node:url";
20
21const here = path.dirname(fileURLToPath(import.meta.url));
22const app = path.resolve(here, "..");
23const docsDir = path.join(app, "docs");
24const cliSrc = path.resolve(app, "../cli/src");
25const cliInstall = path.resolve(app, "../cli/install");
26const editorPackager = path.resolve(app, "../vscode/scripts/package.mjs");
27const editorInstall = path.resolve(app, "../vscode/install");
28const out = path.join(app, "src/docs/content.generated.ts");
29
30/** Pages produced in code rather than from a Markdown file. */
31const RUNTIME_PAGES = new Set(["api", "reference/mcp-tools"]);
32const BUILD_PAGES = new Set(["reference/cli"]);
33
34const problems = [];
35
36async function walk(dir) {
37 const files = [];
38 for (const e of await readdir(dir, { withFileTypes: true })) {
39 const p = path.join(dir, e.name);
40 if (e.isDirectory()) files.push(...(await walk(p)));
41 else if (e.name.endsWith(".md")) files.push(p);
42 }
43 return files;
44}
45
46function parsePage(slug, text) {
47 const m = /^---\n([\s\S]*?)\n---\n?/.exec(text.replace(/\r\n?/g, "\n"));
48 if (!m) {
49 problems.push(`${slug}.md: missing frontmatter (--- title / description ---)`);
50 return { title: slug, description: "", markdown: text };
51 }
52 const meta = {};
53 for (const line of m[1].split("\n")) {
54 const kv = /^(\w+):\s*(.*)$/.exec(line);
55 if (kv) meta[kv[1]] = kv[2].trim().replace(/^(["'])(.*)\1$/, "$2");
56 }
57 if (!meta.title) problems.push(`${slug}.md: frontmatter has no title`);
58 if (!meta.description) problems.push(`${slug}.md: frontmatter has no description`);
59 else if (meta.description.length > 200) problems.push(`${slug}.md: description is ${meta.description.length} characters (keep it under 200)`);
60 const markdown = text.replace(/\r\n?/g, "\n").slice(m[0].length).trim() + "\n";
61 if (/^# /m.test(markdown.replace(/```[\s\S]*?```/g, ""))) problems.push(`${slug}.md: don't use an H1 (the title is the H1)`);
62 return { title: meta.title ?? slug, description: meta.description ?? "", markdown };
63}
64
65// ---------------------------------------------------------------- the CLI
66
67// Lets bundled CommonJS dependencies call require() (same banner as apps/cli's own build).
68const REQUIRE_SHIM = "import { createRequire as __diffineryCreateRequire } from 'node:module'; const require = __diffineryCreateRequire(import.meta.url);";
69
70async function bundleCli(tmp) {
71 // The registry, for the reference page.
72 const regFile = path.join(tmp, "registry.mjs");
73 await build({
74 stdin: {
75 contents: `export { registry } from "./main.ts"; export { optionsFor } from "./cli.ts"; export { VERSION } from "./version.ts";`,
76 resolveDir: cliSrc,
77 loader: "ts",
78 },
79 bundle: true,
80 platform: "node",
81 target: "node22",
82 format: "esm",
83 outfile: regFile,
84 banner: { js: REQUIRE_SHIM },
85 logLevel: "error",
86 });
87 // The CLI itself, minified, for /-/cli/diffinery.mjs.
88 const binFile = path.join(tmp, "diffinery.mjs");
89 await build({
90 entryPoints: [path.join(cliSrc, "bin.ts")],
91 bundle: true,
92 minify: true,
93 platform: "node",
94 target: "node22",
95 format: "esm",
96 outfile: binFile,
97 banner: { js: `#!/usr/bin/env node\n${REQUIRE_SHIM}` },
98 logLevel: "error",
99 });
100 const mod = await import(pathToFileURL(regFile).href);
101 return { ...mod, bin: await readFile(binFile, "utf8") };
102}
103
104function cliReference({ registry, optionsFor, VERSION }) {
105 const all = [...registry.groups.flatMap((g) => g.commands), ...registry.commands];
106 const groups = [...registry.groups].sort((a, b) => a.name.localeCompare(b.name));
107 const singles = [...registry.commands].sort((a, b) => a.name.localeCompare(b.name));
108 const lines = [
109 `This page lists every command of the \`diffinery\` command-line client (version ${VERSION}) with all of its options. It is generated from the CLI's own command definitions, so it always matches \`diffinery <command> --help\`.`,
110 "",
111 "## Install",
112 "",
113 "```sh",
114 "curl -fsSL https://diffinery.com/install | bash # Linux and macOS",
115 "diffinery login # sign in through your browser",
116 "```",
117 "",
118 "On Windows, in PowerShell: `powershell -c \"irm https://diffinery.com/install.ps1 | iex\"`. The CLI runs on Node.js 22 or later; the script installs one for it when yours is older or missing. `diffinery upgrade` updates it. See [Install and sign in to the CLI](/docs/accounts/cli-sign-in).",
119 "",
120 "## Configuration",
121 "",
122 "| Setting | Where | Notes |",
123 "| --- | --- | --- |",
124 "| Server URL | `DIFFINERY_URL`, else `url` in the config file, else `https://diffinery.com` | Set this for your own Diffinery instance. |",
125 "| Token | `DIFFINERY_TOKEN`, else `token` in the config file | `diffinery login` signs in through the browser and stores the CLI's token in the config file. |",
126 "| Config file | `~/.config/diffinery/config.json` (`$XDG_CONFIG_HOME/diffinery/config.json` when set) | Written by `diffinery login`. |",
127 "",
128 "Commands that work on a repository take `-R OWNER/NAME`. Without it, the CLI uses the `origin` remote of the git repository you are in, when that remote points at the configured server.",
129 "",
130 "Most read commands take `--json` to print the raw API response, for scripts and agents.",
131 "",
132 "## Exit codes",
133 "",
134 "| Code | Meaning |",
135 "| --- | --- |",
136 "| `0` | Success. |",
137 "| `1` | The command failed (an API error, a failed check, a failed run when waiting). |",
138 "| `2` | Usage error: unknown command, bad option or missing argument. Help is printed to stderr. |",
139 "",
140 "## Commands",
141 "",
142 "| Command | Summary |",
143 "| --- | --- |",
144 ...[...groups.map((g) => [g.name, g.summary]), ...singles.map((c) => [c.name, c.summary])].sort((a, b) => a[0].localeCompare(b[0])).map(([n, s]) => `| [\`${n}\`](#${slug(`diffinery ${n}`)}) | ${s} |`),
145 "",
146 ];
147 const cmdSection = (cmd, level) => {
148 const opts = optionsFor(cmd);
149 const usage = `diffinery ${cmd.name}${cmd.args ? ` ${cmd.args}` : ""}${Object.keys(opts).length > 1 ? " [options]" : ""}`;
150 lines.push(`${"#".repeat(level)} diffinery ${cmd.name}`, "", cmd.summary.replace(/\.?$/, "."), "", "```text", usage, "```", "");
151 if (cmd.description) lines.push(cmd.description.trim(), "");
152 const rows = Object.entries(opts).filter(([k]) => k !== "help");
153 if (rows.length) {
154 lines.push("| Option | Description |", "| --- | --- |");
155 for (const [k, o] of rows) {
156 const flag = `${o.short ? `-${o.short}, ` : ""}--${k}${o.type === "string" ? ` ${o.arg ?? "VALUE"}` : ""}`;
157 lines.push(`| \`${flag}\` | ${o.desc.replace(/\|/g, "\\|")}${o.multiple ? " Repeatable." : ""} |`);
158 }
159 lines.push("");
160 }
161 };
162 for (const g of groups) {
163 lines.push(`### diffinery ${g.name}`, "", g.summary.replace(/\.?$/, "."), "");
164 for (const c of [...g.commands].sort((a, b) => a.name.localeCompare(b.name))) cmdSection(c, 4);
165 }
166 for (const c of singles) cmdSection(c, 3);
167 if (all.length < 10) problems.push("the CLI registry looks empty");
168 return lines.join("\n");
169}
170
171// Same rules as slugify() in src/web/markdown.ts.
172function slug(text) {
173 return text
174 .toLowerCase()
175 .replace(/<[^>]*>/g, "")
176 .replace(/[^\p{L}\p{N}\s_-]/gu, "")
177 .trim()
178 .replace(/\s+/g, "-")
179 .slice(0, 80);
180}
181
182// ---------------------------------------------------------------- main
183
184const nav = JSON.parse(await readFile(path.join(docsDir, "nav.json"), "utf8"));
185const navSlugs = nav.flatMap((s) => s.pages);
186const dupes = navSlugs.filter((s, i) => navSlugs.indexOf(s) !== i);
187if (dupes.length) problems.push(`docs/nav.json lists ${dupes.join(", ")} more than once`);
188
189const files = await walk(docsDir);
190const pages = {};
191for (const f of files) {
192 const s = path.relative(docsDir, f).replace(/\\/g, "/").replace(/\.md$/, "");
193 pages[s] = parsePage(s, await readFile(f, "utf8"));
194}
195// DOCS_DRAFT=1 tolerates pages that aren't written yet (local work in progress only).
196for (const s of navSlugs) if (!pages[s] && !RUNTIME_PAGES.has(s) && !BUILD_PAGES.has(s) && !process.env.DOCS_DRAFT) problems.push(`docs/nav.json lists "${s}" but docs/${s}.md doesn't exist`);
197for (const s of Object.keys(pages)) if (!navSlugs.includes(s)) problems.push(`docs/${s}.md isn't listed in docs/nav.json`);
198
199// Screenshots and other pictures in docs/images/, served at /docs/images/<file>. Every one must be used by
200// a page, and every /docs/images/ reference must exist. Raster formats only (an SVG is a document that can
201// run script on this origin).
202const IMAGE_TYPES = { ".png": "image/png", ".webp": "image/webp", ".jpg": "image/jpeg" };
203const MAX_IMAGE = 400 * 1024;
204const images = {};
205const imagesDir = path.join(docsDir, "images");
206for (const e of await readdir(imagesDir, { withFileTypes: true }).catch(() => [])) {
207 if (!e.isFile()) continue;
208 const type = IMAGE_TYPES[path.extname(e.name).toLowerCase()];
209 if (!type || !/^[a-z0-9][a-z0-9.-]*$/.test(e.name)) {
210 problems.push(`docs/images/${e.name}: use lowercase names ending in .png, .webp or .jpg`);
211 continue;
212 }
213 const data = await readFile(path.join(imagesDir, e.name));
214 if (data.length > MAX_IMAGE) problems.push(`docs/images/${e.name} is ${Math.round(data.length / 1024)} KiB (keep it under ${MAX_IMAGE / 1024} KiB)`);
215 images[e.name] = { type, data: data.toString("base64") };
216}
217const usedImages = new Set();
218for (const [s, p] of Object.entries(pages)) {
219 for (const m of p.markdown.matchAll(/!\[([^\]]*)\]\(\/docs\/images\/([^)\s]+)/g)) {
220 usedImages.add(m[2]);
221 if (!images[m[2]]) problems.push(`docs/${s}.md shows /docs/images/${m[2]}, which doesn't exist`);
222 if (!m[1].trim()) problems.push(`docs/${s}.md shows /docs/images/${m[2]} without alt text (agents and screen readers read it)`);
223 }
224}
225for (const name of Object.keys(images)) if (!usedImages.has(name)) problems.push(`docs/images/${name} isn't used by any page`);
226
227const tmp = await mkdtemp(path.join(tmpdir(), "diffinery-docs-"));
228let cli;
229try {
230 cli = await bundleCli(tmp);
231} finally {
232 await rm(tmp, { recursive: true, force: true });
233}
234const editor = await (await import(pathToFileURL(editorPackager).href)).packageExtension();
235pages["reference/cli"] = {
236 title: "CLI reference",
237 description: "Every command and option of the diffinery command-line client, generated from its source.",
238 markdown: cliReference(cli) + "\n",
239};
240
241if (problems.length) {
242 console.error(`docs: ${problems.length} problem(s):\n${problems.map((p) => ` - ${p}`).join("\n")}`);
243 process.exit(1);
244}
245
246const ordered = Object.fromEntries(navSlugs.filter((s) => pages[s]).map((s) => [s, pages[s]]));
247const src = `// Generated by scripts/build-docs.mjs from docs/, apps/cli and apps/vscode. Do not edit.
248/* eslint-disable */
249export interface DocPage { title: string; description: string; markdown: string }
250export interface DocSection { title: string; pages: string[] }
251export const DOCS_NAV: DocSection[] = ${JSON.stringify(nav)};
252export const DOCS_PAGES: Record<string, DocPage> = ${JSON.stringify(ordered)};
253/** docs/images/: content type and base64 bytes by file name. */
254export const DOCS_IMAGES: Record<string, { type: string; data: string }> = ${JSON.stringify(images)};
255export const CLI_VERSION = ${JSON.stringify(cli.VERSION)};
256export const CLI_BUNDLE = ${JSON.stringify(cli.bin)};
257export const EDITOR_EXTENSION_VERSION = ${JSON.stringify(editor.version)};
258export const EDITOR_EXTENSION_SHA256 = ${JSON.stringify(createHash("sha256").update(editor.vsix).digest("hex"))};
259export const EDITOR_EXTENSION_VSIX_B64 = ${JSON.stringify(editor.vsix.toString("base64"))};
260export const EDITOR_INSTALL_SH = ${JSON.stringify(await readFile(path.join(editorInstall, "install.sh"), "utf8"))};
261export const EDITOR_INSTALL_PS1 = ${JSON.stringify(await readFile(path.join(editorInstall, "install.ps1"), "utf8"))};
262export const CLI_INSTALL_SH = ${JSON.stringify(await readFile(path.join(cliInstall, "install.sh"), "utf8"))};
263export const CLI_INSTALL_PS1 = ${JSON.stringify(await readFile(path.join(cliInstall, "install.ps1"), "utf8"))};
264`;
265await mkdir(path.dirname(out), { recursive: true });
266await writeFile(out, src);
267console.log(`docs: ${Object.keys(ordered).length} pages (+${RUNTIME_PAGES.size} generated at runtime), ${Object.keys(images).length} images, CLI ${cli.VERSION} (${Math.round(cli.bin.length / 1024)} KiB), editor extension ${editor.version} (${Math.round(editor.vsix.length / 1024)} KiB)`);