| 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. |
| 18 | import { build } from "esbuild"; |
| 19 | import { createHash } from "node:crypto"; |
| 20 | import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; |
| 21 | import { tmpdir } from "node:os"; |
| 22 | import path from "node:path"; |
| 23 | import { fileURLToPath, pathToFileURL } from "node:url"; |
| 24 | |
| 25 | const here = path.dirname(fileURLToPath(import.meta.url)); |
| 26 | const app = path.resolve(here, ".."); |
| 27 | const docsDir = path.join(app, "docs"); |
| 28 | const cliSrc = path.resolve(app, "../cli/src"); |
| 29 | const cliInstall = path.resolve(app, "../cli/install"); |
| 30 | const editorPackager = path.resolve(app, "../vscode/scripts/package.mjs"); |
| 31 | const editorInstall = path.resolve(app, "../vscode/install"); |
| 32 | const jetbrainsReader = path.resolve(app, "../jetbrains/scripts/plugin.mjs"); |
| 33 | const siteDir = path.join(app, "site"); |
| 34 | const out = path.join(app, "src/docs/content.generated.ts"); |
| 35 | |
| 36 | /** Pages produced in code rather than from a Markdown file. */ |
| 37 | const RUNTIME_PAGES = new Set(["api", "reference/mcp-tools"]); |
| 38 | const BUILD_PAGES = new Set(["reference/cli"]); |
| 39 | |
| 40 | const problems = []; |
| 41 | |
| 42 | async 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 | |
| 52 | function 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). |
| 74 | const REQUIRE_SHIM = "import { createRequire as __diffineryCreateRequire } from 'node:module'; const require = __diffineryCreateRequire(import.meta.url);"; |
| 75 | |
| 76 | async 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 | |
| 110 | function 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. |
| 178 | function 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 | |
| 190 | const nav = JSON.parse(await readFile(path.join(docsDir, "nav.json"), "utf8")); |
| 191 | const navSlugs = nav.flatMap((s) => s.pages); |
| 192 | const dupes = navSlugs.filter((s, i) => navSlugs.indexOf(s) !== i); |
| 193 | if (dupes.length) problems.push(`docs/nav.json lists ${dupes.join(", ")} more than once`); |
| 194 | |
| 195 | const files = await walk(docsDir); |
| 196 | const pages = {}; |
| 197 | for (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). |
| 202 | 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`); |
| 203 | for (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). |
| 208 | const IMAGE_TYPES = { ".png": "image/png", ".webp": "image/webp", ".jpg": "image/jpeg" }; |
| 209 | const MAX_IMAGE = 400 * 1024; |
| 210 | const images = {}; |
| 211 | const imagesDir = path.join(docsDir, "images"); |
| 212 | for (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 | } |
| 223 | const usedImages = new Set(); |
| 224 | for (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 | } |
| 231 | for (const name of Object.keys(images)) if (!usedImages.has(name)) problems.push(`docs/images/${name} isn't used by any page`); |
| 232 | |
| 233 | const tmp = await mkdtemp(path.join(tmpdir(), "diffinery-docs-")); |
| 234 | let cli; |
| 235 | try { |
| 236 | cli = await bundleCli(tmp); |
| 237 | } finally { |
| 238 | await rm(tmp, { recursive: true, force: true }); |
| 239 | } |
| 240 | const editor = await (await import(pathToFileURL(editorPackager).href)).packageExtension(); |
| 241 | let jetbrains; |
| 242 | try { |
| 243 | jetbrains = await (await import(pathToFileURL(jetbrainsReader).href)).readPlugin(); |
| 244 | } catch (e) { |
| 245 | problems.push(e.message); |
| 246 | } |
| 247 | pages["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). |
| 258 | const siteNav = JSON.parse(await readFile(path.join(siteDir, "nav.json"), "utf8")); |
| 259 | const sitePaths = siteNav.flatMap((s) => s.pages); |
| 260 | const site = {}; |
| 261 | for (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 | } |
| 273 | for (const u of sitePaths) if (!site[u]) problems.push(`site/nav.json lists "${u}" but there's no page for it in site/`); |
| 274 | |
| 275 | if (problems.length) { |
| 276 | console.error(`docs: ${problems.length} problem(s):\n${problems.map((p) => ` - ${p}`).join("\n")}`); |
| 277 | process.exit(1); |
| 278 | } |
| 279 | |
| 280 | const ordered = Object.fromEntries(navSlugs.filter((s) => pages[s]).map((s) => [s, pages[s]])); |
| 281 | const src = `// Generated by scripts/build-docs.mjs from docs/, site/, apps/cli, apps/vscode and apps/jetbrains/dist. Do not edit. |
| 282 | /* eslint-disable */ |
| 283 | export interface DocPage { title: string; description: string; markdown: string } |
| 284 | export interface DocSection { title: string; pages: string[] } |
| 285 | export const DOCS_NAV: DocSection[] = ${JSON.stringify(nav)}; |
| 286 | export const DOCS_PAGES: Record<string, DocPage> = ${JSON.stringify(ordered)}; |
| 287 | /** docs/images/: content type and base64 bytes by file name. */ |
| 288 | export interface SitePage { title: string; description: string; markdown: string; updated: string; seoTitle: string | null } |
| 289 | export 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). */ |
| 291 | export const SITE_NAV: SiteSection[] = ${JSON.stringify(siteNav)}; |
| 292 | export const SITE_PAGES: Record<string, SitePage> = ${JSON.stringify(Object.fromEntries(sitePaths.filter((u) => site[u]).map((u) => [u, site[u]])))}; |
| 293 | export const DOCS_IMAGES: Record<string, { type: string; data: string }> = ${JSON.stringify(images)}; |
| 294 | export const CLI_VERSION = ${JSON.stringify(cli.VERSION)}; |
| 295 | export const CLI_BUNDLE = ${JSON.stringify(cli.bin)}; |
| 296 | export const EDITOR_EXTENSION_VERSION = ${JSON.stringify(editor.version)}; |
| 297 | export const EDITOR_EXTENSION_SHA256 = ${JSON.stringify(createHash("sha256").update(editor.vsix).digest("hex"))}; |
| 298 | export const EDITOR_EXTENSION_VSIX_B64 = ${JSON.stringify(editor.vsix.toString("base64"))}; |
| 299 | export const EDITOR_INSTALL_SH = ${JSON.stringify(await readFile(path.join(editorInstall, "install.sh"), "utf8"))}; |
| 300 | export const EDITOR_INSTALL_PS1 = ${JSON.stringify(await readFile(path.join(editorInstall, "install.ps1"), "utf8"))}; |
| 301 | export const JETBRAINS_PLUGIN_VERSION = ${JSON.stringify(jetbrains.version)}; |
| 302 | export const JETBRAINS_PLUGIN_SINCE_BUILD = ${JSON.stringify(jetbrains.sinceBuild)}; |
| 303 | export const JETBRAINS_PLUGIN_DESCRIPTION = ${JSON.stringify(jetbrains.description)}; |
| 304 | export const JETBRAINS_PLUGIN_SHA256 = ${JSON.stringify(createHash("sha256").update(jetbrains.zip).digest("hex"))}; |
| 305 | export const JETBRAINS_PLUGIN_ZIP_B64 = ${JSON.stringify(jetbrains.zip.toString("base64"))}; |
| 306 | export const CLI_INSTALL_SH = ${JSON.stringify(await readFile(path.join(cliInstall, "install.sh"), "utf8"))}; |
| 307 | export const CLI_INSTALL_PS1 = ${JSON.stringify(await readFile(path.join(cliInstall, "install.ps1"), "utf8"))}; |
| 308 | export const CLI_SHA256 = ${JSON.stringify(createHash("sha256").update(cli.bin).digest("hex"))}; |
| 309 | export const AGENT_SETUP_SH = ${JSON.stringify(await readFile(path.join(cliInstall, "agent-setup.sh"), "utf8"))}; |
| 310 | `; |
| 311 | await mkdir(path.dirname(out), { recursive: true }); |
| 312 | await writeFile(out, src); |
| 313 | console.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)`); |