handbook.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * Builds the public client handbook: one document containing every piece of
  5. * documentation and every source file of client-package/.
  6. *
  7. * Everything is read from disk on each request, so the published page always
  8. * matches the repository - there is no build step and nothing to regenerate
  9. * after an edit.
  10. *
  11. * Two renderings share this assembly:
  12. * index.php HTML, rendered client-side with the vendored marked.js
  13. * llms.php text/plain Markdown, meant to be fed to an agent verbatim
  14. */
  15. const HANDBOOK_PACKAGE_DIR = __DIR__ . "/../../client-package";
  16. const HANDBOOK_CONTENT_DIR = __DIR__ . "/../content";
  17. /**
  18. * Paths inside client-package/ that must never be published.
  19. *
  20. * config.php holds the instance token. It is git-ignored and stripped by
  21. * build-client-package.sh, but this page is public, so it is excluded here by
  22. * path as well instead of relying on it being absent.
  23. */
  24. const HANDBOOK_EXCLUDED = [
  25. "manage-client/config.php",
  26. ];
  27. /** Vendored third-party files: listed in the inventory, never reproduced. */
  28. const HANDBOOK_VENDORED = [
  29. "docs/assets/marked.min.js",
  30. ];
  31. function handbookRepoRoot(): string
  32. {
  33. return dirname(__DIR__, 2);
  34. }
  35. /**
  36. * Absolute base URL of this installation.
  37. *
  38. * Prefers the configured MANAGE_PUBLIC_URL so that copied links keep working;
  39. * falls back to the current request for installations served under a different
  40. * name (staging, a local `php -S`).
  41. */
  42. function handbookBaseUrl(): string
  43. {
  44. $configured = "";
  45. $config = handbookRepoRoot() . "/config.php";
  46. if (is_file($config)) {
  47. // Read as text: including the server config would pull in its side
  48. // effects, and a public page needs none of them.
  49. $source = (string) file_get_contents($config);
  50. if (preg_match('/define\(\s*"MANAGE_PUBLIC_URL"\s*,\s*"([^"]*)"/', $source, $match) === 1) {
  51. $configured = trim($match[1]);
  52. }
  53. }
  54. if ($configured !== "" && preg_match('#^https?://#i', $configured) === 1) {
  55. return rtrim($configured, "/");
  56. }
  57. $scheme = ($_SERVER["HTTPS"] ?? "") === "on"
  58. || ($_SERVER["HTTP_X_FORWARDED_PROTO"] ?? "") === "https" ? "https" : "http";
  59. $host = (string) ($_SERVER["HTTP_HOST"] ?? "localhost");
  60. // dirname(SCRIPT_NAME) ends with this directory's name; dropping that
  61. // suffix yields the mount point of the manage installation itself.
  62. $dir = rtrim(str_replace("\\", "/", dirname($_SERVER["SCRIPT_NAME"] ?? "")), "/");
  63. $own = "/" . handbookDirName();
  64. if (str_ends_with($dir, $own)) {
  65. $dir = substr($dir, 0, -strlen($own));
  66. }
  67. return $scheme . "://" . $host . $dir;
  68. }
  69. /** Name of this documentation directory, as it appears in the URL. */
  70. function handbookDirName(): string
  71. {
  72. return basename(dirname(__DIR__));
  73. }
  74. /**
  75. * Absolute URL of this documentation directory, without a trailing slash.
  76. *
  77. * Derived from the installation base rather than from the request path, so a
  78. * MANAGE_PUBLIC_URL that carries a subdirectory is not duplicated.
  79. */
  80. function handbookSelfUrl(): string
  81. {
  82. return handbookBaseUrl() . "/" . handbookDirName();
  83. }
  84. function handbookEscape(string $value): string
  85. {
  86. return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8");
  87. }
  88. /** Stable anchor id for a section, usable in both the nav and the document. */
  89. function handbookAnchor(string $prefix, string $key): string
  90. {
  91. $slug = strtolower($key);
  92. $slug = preg_replace('/[^a-z0-9]+/', "-", $slug) ?? $slug;
  93. return $prefix . "-" . trim($slug, "-");
  94. }
  95. /** Fence language for a source file, by extension. */
  96. function handbookLanguage(string $relative): string
  97. {
  98. $name = basename($relative);
  99. if ($name === ".htaccess") {
  100. return "apacheconf";
  101. }
  102. if (str_ends_with($name, ".cron")) {
  103. return "text";
  104. }
  105. return match (strtolower(pathinfo($relative, PATHINFO_EXTENSION))) {
  106. "php" => "php",
  107. "sh", "bash" => "bash",
  108. "css" => "css",
  109. "js" => "javascript",
  110. "json" => "json",
  111. "md" => "markdown",
  112. default => "text",
  113. };
  114. }
  115. /**
  116. * Recursively lists every file under client-package/, relative to it.
  117. *
  118. * @return string[] sorted relative paths
  119. */
  120. function handbookScanPackage(): array
  121. {
  122. $root = realpath(HANDBOOK_PACKAGE_DIR);
  123. if ($root === false || !is_dir($root)) {
  124. return [];
  125. }
  126. $iterator = new RecursiveIteratorIterator(
  127. new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
  128. RecursiveIteratorIterator::SELF_FIRST,
  129. );
  130. $files = [];
  131. foreach ($iterator as $item) {
  132. if (!$item->isFile()) {
  133. continue;
  134. }
  135. $relative = str_replace("\\", "/", substr($item->getPathname(), strlen($root) + 1));
  136. $name = basename($relative);
  137. // Local noise that is not part of the handed-out package.
  138. if ($name === ".DS_Store" || str_ends_with($name, ".log")) {
  139. continue;
  140. }
  141. if (in_array($relative, HANDBOOK_EXCLUDED, true)) {
  142. continue;
  143. }
  144. $files[] = $relative;
  145. }
  146. sort($files, SORT_STRING);
  147. return $files;
  148. }
  149. /**
  150. * Splits the package into the documentation chapters and the source files.
  151. *
  152. * @return array{docs: array<string, string>, code: string[], vendored: string[]}
  153. * docs maps relative path -> chapter title, in reading order
  154. */
  155. function handbookInventory(): array
  156. {
  157. $docs = [];
  158. $code = [];
  159. $vendored = [];
  160. foreach (handbookScanPackage() as $relative) {
  161. if (in_array($relative, HANDBOOK_VENDORED, true)) {
  162. $vendored[] = $relative;
  163. continue;
  164. }
  165. if (str_ends_with($relative, ".md")) {
  166. $number = preg_match('#/(\d+)_#', $relative, $match) === 1 ? $match[1] . " · " : "";
  167. $docs[$relative] = $number . handbookChapterTitle($relative);
  168. continue;
  169. }
  170. $code[] = $relative;
  171. }
  172. // README first, then the numbered chapters in order.
  173. uksort($docs, static function (string $a, string $b): int {
  174. if ($a === "README.md") {
  175. return -1;
  176. }
  177. if ($b === "README.md") {
  178. return 1;
  179. }
  180. return strcmp($a, $b);
  181. });
  182. usort($code, static function (string $a, string $b): int {
  183. return [handbookCodeWeight($a), $a] <=> [handbookCodeWeight($b), $b];
  184. });
  185. return ["docs" => $docs, "code" => $code, "vendored" => $vendored];
  186. }
  187. /**
  188. * Reading order for the source part: the files that are copied into the host
  189. * project first, entry point before the modules it pulls in, examples and
  190. * tooling last. Anything unrecognised sorts to the end alphabetically, so a
  191. * newly added file still lands somewhere sensible.
  192. */
  193. function handbookCodeWeight(string $relative): int
  194. {
  195. return match (true) {
  196. $relative === "manage-client/config.sample.php" => 10,
  197. $relative === "manage-client/lib/client.php" => 11,
  198. $relative === "manage-client/lib/updater.php" => 12,
  199. $relative === "manage-client/lib/backup.php" => 13,
  200. $relative === "manage-client/lib/remote.php" => 14,
  201. str_starts_with($relative, "manage-client/lib/") => 15,
  202. str_starts_with($relative, "manage-client/bin/") => 16,
  203. str_starts_with($relative, "manage-client/ui/") => 17,
  204. str_starts_with($relative, "manage-client/") => 18,
  205. str_starts_with($relative, "examples/") => 20,
  206. str_starts_with($relative, "scripts/") => 21,
  207. default => 30,
  208. };
  209. }
  210. /**
  211. * Title of a chapter: its own first-level heading, so the handbook shows what
  212. * the document calls itself rather than a name derived from the file.
  213. */
  214. function handbookChapterTitle(string $relative): string
  215. {
  216. if (preg_match('/^#\s+(.+)$/m', handbookReadDoc($relative), $match) === 1) {
  217. return trim($match[1]);
  218. }
  219. return ucwords(strtolower(str_replace("_", " ", basename($relative, ".md"))));
  220. }
  221. function handbookReadDoc(string $relative): string
  222. {
  223. $path = HANDBOOK_PACKAGE_DIR . "/" . $relative;
  224. return is_file($path) ? (string) file_get_contents($path) : "";
  225. }
  226. /** Drops the leading h1; the assembly emits it as the chapter heading instead. */
  227. function handbookStripTitle(string $markdown): string
  228. {
  229. return preg_replace('/^#\s+.+\R+/', "", ltrim($markdown), 1) ?? $markdown;
  230. }
  231. /** Reads an authored page from content/, with the placeholders filled in. */
  232. function handbookContent(string $name, array $replacements = []): string
  233. {
  234. $path = HANDBOOK_CONTENT_DIR . "/" . $name;
  235. $text = is_file($path) ? (string) file_get_contents($path) : "";
  236. foreach ($replacements as $key => $value) {
  237. $text = str_replace("{{" . $key . "}}", (string) $value, $text);
  238. }
  239. return rtrim($text) . "\n";
  240. }
  241. /**
  242. * Rewrites the relative links between the chapters so they point at the
  243. * anchors of this single page instead of at neighbouring .md files.
  244. */
  245. function handbookRewriteLinks(string $markdown, array $docs): string
  246. {
  247. $targets = [];
  248. foreach ($docs as $relative => $_title) {
  249. $targets[basename($relative)] = "#" . handbookAnchor("doc", $relative);
  250. }
  251. return preg_replace_callback(
  252. '/\]\(([^)\s]+\.md)(#[^)\s]*)?\)/',
  253. static function (array $match) use ($targets): string {
  254. $file = basename($match[1]);
  255. return isset($targets[$file]) ? "](" . $targets[$file] . ")" : $match[0];
  256. },
  257. $markdown,
  258. ) ?? $markdown;
  259. }
  260. /**
  261. * Demotes every heading of an embedded chapter by one level, so the assembled
  262. * document keeps a single h1 and the chapters sit below the part headings.
  263. */
  264. function handbookDemoteHeadings(string $markdown): string
  265. {
  266. $lines = explode("\n", $markdown);
  267. $inFence = false;
  268. foreach ($lines as $index => $line) {
  269. if (preg_match('/^\s*(```|~~~)/', $line) === 1) {
  270. $inFence = !$inFence;
  271. continue;
  272. }
  273. if (!$inFence && preg_match('/^(#{1,5})\s/', $line) === 1) {
  274. $lines[$index] = "#" . $line;
  275. }
  276. }
  277. return implode("\n", $lines);
  278. }
  279. /**
  280. * Assembles the complete handbook as one Markdown document.
  281. *
  282. * @param bool $withAnchors emit HTML anchor targets for the in-page navigation.
  283. * Off for the plain-text rendering, which has no nav.
  284. */
  285. function handbookBuildMarkdown(bool $withAnchors): string
  286. {
  287. $inventory = handbookInventory();
  288. $base = handbookBaseUrl();
  289. $self = handbookSelfUrl();
  290. $anchor = static function (string $id) use ($withAnchors): string {
  291. return $withAnchors ? '<a id="' . $id . '"></a>' . "\n\n" : "";
  292. };
  293. $out = [];
  294. $out[] = $anchor("part-intro") . handbookContent("00_UEBERBLICK.md", [
  295. "BASE_URL" => $base,
  296. "SELF_URL" => $self,
  297. "DOC_COUNT" => (string) count($inventory["docs"]),
  298. "CODE_COUNT" => (string) count($inventory["code"]),
  299. ]);
  300. // ---- Table of contents -------------------------------------------------
  301. $toc = ["## Inhalt", ""];
  302. $toc[] = "**Teil 1 – Dokumentation**";
  303. $toc[] = "";
  304. foreach ($inventory["docs"] as $relative => $title) {
  305. $link = $withAnchors ? "[" . $title . "](#" . handbookAnchor("doc", $relative) . ")" : $title;
  306. $toc[] = "- " . $link . " — `client-package/" . $relative . "`";
  307. }
  308. $toc[] = "";
  309. $toc[] = "**Teil 2 – HTTP-API**";
  310. $toc[] = "";
  311. $toc[] = $withAnchors ? "- [OpenAPI-Spezifikation](#part-api)" : "- OpenAPI-Spezifikation";
  312. $toc[] = "";
  313. $toc[] = "**Teil 3 – Quellcode**";
  314. $toc[] = "";
  315. foreach ($inventory["code"] as $relative) {
  316. $link = $withAnchors ? "[" . $relative . "](#" . handbookAnchor("code", $relative) . ")" : $relative;
  317. $toc[] = "- " . $link;
  318. }
  319. $out[] = implode("\n", $toc) . "\n";
  320. // ---- Part 1: documentation --------------------------------------------
  321. $out[] = $anchor("part-docs") . "# Teil 1 – Dokumentation\n";
  322. foreach ($inventory["docs"] as $relative => $title) {
  323. $body = handbookStripTitle(handbookReadDoc($relative));
  324. $body = handbookRewriteLinks($body, $inventory["docs"]);
  325. $body = handbookDemoteHeadings($body);
  326. $out[] = $anchor(handbookAnchor("doc", $relative))
  327. . "## " . $title . "\n\n"
  328. . "> Quelle: `client-package/" . $relative . "`\n\n"
  329. . rtrim($body) . "\n";
  330. }
  331. // ---- Part 2: the HTTP API ---------------------------------------------
  332. $spec = handbookOpenApiJson();
  333. $out[] = $anchor("part-api") . handbookContent("50_API.md", [
  334. "BASE_URL" => $base,
  335. "SELF_URL" => $self,
  336. ]) . "\n"
  337. . "## OpenAPI 3.1 (vollständig)\n\n"
  338. . "```json\n" . rtrim($spec) . "\n```\n";
  339. // ---- Part 3: the source ------------------------------------------------
  340. $out[] = $anchor("part-code") . "# Teil 3 – Quellcode\n\n"
  341. . "Alle " . count($inventory["code"]) . " Quelldateien des Client-Pakets, vollständig und\n"
  342. . "unverändert. Die Pfade sind relativ zu `client-package/`.\n\n"
  343. . "Die Reihenfolge folgt der Wichtigkeit für eine Einbindung: zuerst die Vorlage der\n"
  344. . "Konfiguration und `lib/client.php` als einziger Einstiegspunkt, dann Updater, Backup\n"
  345. . "und die übrigen Module, danach Kommandozeile und Oberfläche, zuletzt Beispiele und\n"
  346. . "Werkzeuge. Für eine Übernahme wird der Ordner `manage-client/` gebraucht; alles\n"
  347. . "darunter gehört dazu, `examples/`, `docs/` und `scripts/` nicht.\n";
  348. foreach ($inventory["code"] as $relative) {
  349. $path = HANDBOOK_PACKAGE_DIR . "/" . $relative;
  350. $body = is_file($path) ? (string) file_get_contents($path) : "";
  351. $fence = handbookFenceFor($body);
  352. $out[] = $anchor(handbookAnchor("code", $relative))
  353. . "## `" . $relative . "`\n\n"
  354. . $fence . handbookLanguage($relative) . "\n"
  355. . rtrim($body) . "\n" . $fence . "\n";
  356. }
  357. // ---- Inventory ---------------------------------------------------------
  358. $out[] = $anchor("part-inventory") . handbookInventoryTable($inventory);
  359. return implode("\n", $out);
  360. }
  361. /**
  362. * Picks a fence long enough to survive content that itself contains one.
  363. * lib/zip.php and the docs both embed triple backticks.
  364. */
  365. function handbookFenceFor(string $body): string
  366. {
  367. $longest = 0;
  368. if (preg_match_all('/^\s*(`{3,})/m', $body, $matches) > 0) {
  369. foreach ($matches[1] as $run) {
  370. $longest = max($longest, strlen($run));
  371. }
  372. }
  373. return str_repeat("`", max(3, $longest + 1));
  374. }
  375. function handbookInventoryTable(array $inventory): string
  376. {
  377. $rows = ["# Dateiübersicht", "", "| Datei | Größe | Rolle |", "|---|---:|---|"];
  378. foreach ($inventory["docs"] as $relative => $_title) {
  379. $rows[] = handbookInventoryRow($relative, "Dokumentation");
  380. }
  381. foreach ($inventory["code"] as $relative) {
  382. $rows[] = handbookInventoryRow($relative, "Quellcode");
  383. }
  384. foreach ($inventory["vendored"] as $relative) {
  385. $rows[] = handbookInventoryRow($relative, "Fremdbibliothek, hier nicht abgedruckt");
  386. }
  387. return implode("\n", $rows) . "\n";
  388. }
  389. function handbookInventoryRow(string $relative, string $role): string
  390. {
  391. $size = @filesize(HANDBOOK_PACKAGE_DIR . "/" . $relative);
  392. return "| `" . $relative . "` | " . ($size === false ? "–" : number_format((int) $size, 0, ",", ".") . " B")
  393. . " | " . $role . " |";
  394. }
  395. /** The OpenAPI document, with the live server URL substituted. */
  396. function handbookOpenApiJson(): string
  397. {
  398. $path = __DIR__ . "/../openapi.json";
  399. $raw = is_file($path) ? (string) file_get_contents($path) : "{}";
  400. $spec = json_decode($raw, true);
  401. if (!is_array($spec)) {
  402. return $raw;
  403. }
  404. $spec["servers"] = [[
  405. "url" => handbookBaseUrl() . "/api/v1",
  406. "description" => "Diese Manage-Installation",
  407. ]];
  408. return (string) json_encode(
  409. $spec,
  410. JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
  411. );
  412. }