| 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. |
| 14 | import { build } from "esbuild"; |
| 15 | import { createHash } from "node:crypto"; |
| 16 | import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; |
| 17 | import { tmpdir } from "node:os"; |
| 18 | import path from "node:path"; |
| 19 | import { fileURLToPath, pathToFileURL } from "node:url"; |
| 20 | |
| 21 | const here = path.dirname(fileURLToPath(import.meta.url)); |
| 22 | const app = path.resolve(here, ".."); |
| 23 | const docsDir = path.join(app, "docs"); |
| 24 | const cliSrc = path.resolve(app, "../cli/src"); |
| 25 | const cliInstall = path.resolve(app, "../cli/install"); |
| 26 | const editorPackager = path.resolve(app, "../vscode/scripts/package.mjs"); |
| 27 | const editorInstall = path.resolve(app, "../vscode/install"); |
| 28 | const out = path.join(app, "src/docs/content.generated.ts"); |
| 29 | |
| 30 | /** Pages produced in code rather than from a Markdown file. */ |
| 31 | const RUNTIME_PAGES = new Set(["api", "reference/mcp-tools"]); |
| 32 | const BUILD_PAGES = new Set(["reference/cli"]); |
| 33 | |
| 34 | const problems = []; |
| 35 | |
| 36 | async 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 | |
| 46 | function 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). |
| 68 | const REQUIRE_SHIM = "import { createRequire as __diffineryCreateRequire } from 'node:module'; const require = __diffineryCreateRequire(import.meta.url);"; |
| 69 | |
| 70 | async 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 | |
| 104 | function 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. |
| 172 | function 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 | |
| 184 | const nav = JSON.parse(await readFile(path.join(docsDir, "nav.json"), "utf8")); |
| 185 | const navSlugs = nav.flatMap((s) => s.pages); |
| 186 | const dupes = navSlugs.filter((s, i) => navSlugs.indexOf(s) !== i); |
| 187 | if (dupes.length) problems.push(`docs/nav.json lists ${dupes.join(", ")} more than once`); |
| 188 | |
| 189 | const files = await walk(docsDir); |
| 190 | const pages = {}; |
| 191 | for (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). |
| 196 | for (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`); |
| 197 | for (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). |
| 202 | const IMAGE_TYPES = { ".png": "image/png", ".webp": "image/webp", ".jpg": "image/jpeg" }; |
| 203 | const MAX_IMAGE = 400 * 1024; |
| 204 | const images = {}; |
| 205 | const imagesDir = path.join(docsDir, "images"); |
| 206 | for (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 | } |
| 217 | const usedImages = new Set(); |
| 218 | for (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 | } |
| 225 | for (const name of Object.keys(images)) if (!usedImages.has(name)) problems.push(`docs/images/${name} isn't used by any page`); |
| 226 | |
| 227 | const tmp = await mkdtemp(path.join(tmpdir(), "diffinery-docs-")); |
| 228 | let cli; |
| 229 | try { |
| 230 | cli = await bundleCli(tmp); |
| 231 | } finally { |
| 232 | await rm(tmp, { recursive: true, force: true }); |
| 233 | } |
| 234 | const editor = await (await import(pathToFileURL(editorPackager).href)).packageExtension(); |
| 235 | pages["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 | |
| 241 | if (problems.length) { |
| 242 | console.error(`docs: ${problems.length} problem(s):\n${problems.map((p) => ` - ${p}`).join("\n")}`); |
| 243 | process.exit(1); |
| 244 | } |
| 245 | |
| 246 | const ordered = Object.fromEntries(navSlugs.filter((s) => pages[s]).map((s) => [s, pages[s]])); |
| 247 | const src = `// Generated by scripts/build-docs.mjs from docs/, apps/cli and apps/vscode. Do not edit. |
| 248 | /* eslint-disable */ |
| 249 | export interface DocPage { title: string; description: string; markdown: string } |
| 250 | export interface DocSection { title: string; pages: string[] } |
| 251 | export const DOCS_NAV: DocSection[] = ${JSON.stringify(nav)}; |
| 252 | export const DOCS_PAGES: Record<string, DocPage> = ${JSON.stringify(ordered)}; |
| 253 | /** docs/images/: content type and base64 bytes by file name. */ |
| 254 | export const DOCS_IMAGES: Record<string, { type: string; data: string }> = ${JSON.stringify(images)}; |
| 255 | export const CLI_VERSION = ${JSON.stringify(cli.VERSION)}; |
| 256 | export const CLI_BUNDLE = ${JSON.stringify(cli.bin)}; |
| 257 | export const EDITOR_EXTENSION_VERSION = ${JSON.stringify(editor.version)}; |
| 258 | export const EDITOR_EXTENSION_SHA256 = ${JSON.stringify(createHash("sha256").update(editor.vsix).digest("hex"))}; |
| 259 | export const EDITOR_EXTENSION_VSIX_B64 = ${JSON.stringify(editor.vsix.toString("base64"))}; |
| 260 | export const EDITOR_INSTALL_SH = ${JSON.stringify(await readFile(path.join(editorInstall, "install.sh"), "utf8"))}; |
| 261 | export const EDITOR_INSTALL_PS1 = ${JSON.stringify(await readFile(path.join(editorInstall, "install.ps1"), "utf8"))}; |
| 262 | export const CLI_INSTALL_SH = ${JSON.stringify(await readFile(path.join(cliInstall, "install.sh"), "utf8"))}; |
| 263 | export const CLI_INSTALL_PS1 = ${JSON.stringify(await readFile(path.join(cliInstall, "install.ps1"), "utf8"))}; |
| 264 | `; |
| 265 | await mkdir(path.dirname(out), { recursive: true }); |
| 266 | await writeFile(out, src); |
| 267 | console.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)`); |