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.

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