handbook.php 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * Page index for the public client handbook.
  5. *
  6. * The handbook is one page per document and one page per source file, the same
  7. * split the repository already has - not a single long page. Two renderings
  8. * share this index:
  9. *
  10. * index.php HTML for people, rendered with the vendored marked.js
  11. * llms.php the same pages as plain Markdown, plus llms.txt as their index
  12. *
  13. * Everything is read from disk on each request, so the published pages always
  14. * match the repository; there is no build step and nothing to regenerate.
  15. */
  16. const HANDBOOK_PACKAGE_DIR = __DIR__ . "/../../client-package";
  17. const HANDBOOK_CONTENT_DIR = __DIR__ . "/../content";
  18. /**
  19. * Paths inside client-package/ that must never be published.
  20. *
  21. * config.php holds the instance token. It is git-ignored and stripped by
  22. * build-client-package.sh, but these pages are public, so it is excluded by
  23. * path as well instead of relying on it being absent.
  24. */
  25. const HANDBOOK_EXCLUDED = [
  26. "manage-client/config.php",
  27. ];
  28. /** Vendored third-party files: named in the index, never served as a page. */
  29. const HANDBOOK_VENDORED = [
  30. "docs/assets/marked.min.js",
  31. ];
  32. // ---------------------------------------------------------------------------
  33. // Addresses
  34. // ---------------------------------------------------------------------------
  35. function handbookRepoRoot(): string
  36. {
  37. return dirname(__DIR__, 2);
  38. }
  39. /** Name of this documentation directory, as it appears in the URL. */
  40. function handbookDirName(): string
  41. {
  42. return basename(dirname(__DIR__));
  43. }
  44. /**
  45. * Absolute base URL of this installation.
  46. *
  47. * Prefers the configured MANAGE_PUBLIC_URL so that copied links keep working;
  48. * falls back to the current request for installations served under a different
  49. * name (staging, a local `php -S`).
  50. */
  51. function handbookBaseUrl(): string
  52. {
  53. static $cached = null;
  54. if ($cached !== null) {
  55. return $cached;
  56. }
  57. $configured = "";
  58. $config = handbookRepoRoot() . "/config.php";
  59. if (is_file($config)) {
  60. // Read as text: including the server config would pull in its side
  61. // effects, and a public page needs none of them.
  62. $source = (string) file_get_contents($config);
  63. if (preg_match('/define\(\s*"MANAGE_PUBLIC_URL"\s*,\s*"([^"]*)"/', $source, $match) === 1) {
  64. $configured = trim($match[1]);
  65. }
  66. }
  67. if ($configured !== "" && preg_match('#^https?://#i', $configured) === 1) {
  68. return $cached = rtrim($configured, "/");
  69. }
  70. $scheme = ($_SERVER["HTTPS"] ?? "") === "on"
  71. || ($_SERVER["HTTP_X_FORWARDED_PROTO"] ?? "") === "https" ? "https" : "http";
  72. $host = (string) ($_SERVER["HTTP_HOST"] ?? "localhost");
  73. // dirname(SCRIPT_NAME) ends with this directory's name; dropping that
  74. // suffix yields the mount point of the manage installation itself.
  75. $dir = rtrim(str_replace("\\", "/", dirname($_SERVER["SCRIPT_NAME"] ?? "")), "/");
  76. $own = "/" . handbookDirName();
  77. if (str_ends_with($dir, $own)) {
  78. $dir = substr($dir, 0, -strlen($own));
  79. }
  80. return $cached = $scheme . "://" . $host . $dir;
  81. }
  82. /** Absolute URL of this documentation directory, without a trailing slash. */
  83. function handbookSelfUrl(): string
  84. {
  85. return handbookBaseUrl() . "/" . handbookDirName();
  86. }
  87. /** Absolute address of a page in the human rendering. */
  88. function handbookPageUrl(array $page): string
  89. {
  90. return handbookSelfUrl() . "/index.php?" . handbookPageQuery($page);
  91. }
  92. /** Absolute address of the same page as plain Markdown. */
  93. function handbookRawUrl(array $page): string
  94. {
  95. return handbookSelfUrl() . "/llms.php?" . handbookPageQuery($page);
  96. }
  97. function handbookPageQuery(array $page): string
  98. {
  99. // Slashes are legal in a query string and a source path is easier to read
  100. // - and to type into a terminal - when they are left alone.
  101. return $page["kind"] . "=" . str_replace("%2F", "/", rawurlencode($page["key"]));
  102. }
  103. function handbookEscape(string $value): string
  104. {
  105. return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8");
  106. }
  107. /**
  108. * A summary for the HTML index. The summaries are lifted from Markdown and
  109. * from source comments, so they may carry `code spans`; those become <code>
  110. * after escaping, which has already neutralised any markup in the text.
  111. */
  112. function handbookSummaryHtml(string $summary): string
  113. {
  114. return preg_replace(
  115. '/`([^`]+)`/',
  116. '<code>$1</code>',
  117. handbookEscape($summary),
  118. ) ?? handbookEscape($summary);
  119. }
  120. // ---------------------------------------------------------------------------
  121. // The page index
  122. // ---------------------------------------------------------------------------
  123. /**
  124. * Every page of the handbook, in reading order, keyed by "<kind>:<key>".
  125. *
  126. * A page is an array with: kind (doc|code), key, title, nav, summary, path,
  127. * source (repository path, null for the two authored chapters), bytes.
  128. *
  129. * @return array<string, array>
  130. */
  131. function handbookPages(): array
  132. {
  133. static $pages = null;
  134. if ($pages !== null) {
  135. return $pages;
  136. }
  137. $pages = [];
  138. // The two authored chapters frame the material that comes from the package.
  139. foreach (["00_OVERVIEW", "50_API"] as $key) {
  140. $path = HANDBOOK_CONTENT_DIR . "/" . $key . ".md";
  141. if (!is_file($path)) {
  142. continue;
  143. }
  144. $pages["doc:" . $key] = handbookMakeDocPage($key, $path, null);
  145. }
  146. foreach (handbookScanPackage() as $relative) {
  147. if (in_array($relative, HANDBOOK_VENDORED, true)) {
  148. continue;
  149. }
  150. $path = HANDBOOK_PACKAGE_DIR . "/" . $relative;
  151. if (str_ends_with($relative, ".md")) {
  152. $key = handbookDocKey($relative);
  153. $pages["doc:" . $key] = handbookMakeDocPage($key, $path, $relative);
  154. continue;
  155. }
  156. $pages["code:" . $relative] = [
  157. "kind" => "code",
  158. "key" => $relative,
  159. "title" => $relative,
  160. "nav" => $relative,
  161. "summary" => handbookCodeSummary($path),
  162. "path" => $path,
  163. "source" => "client-package/" . $relative,
  164. "bytes" => (int) @filesize($path),
  165. ];
  166. }
  167. uasort($pages, static function (array $a, array $b): int {
  168. return [handbookWeight($a), $a["key"]] <=> [handbookWeight($b), $b["key"]];
  169. });
  170. return $pages;
  171. }
  172. function handbookMakeDocPage(string $key, string $path, ?string $relative): array
  173. {
  174. $title = handbookDocTitle($path, $key);
  175. $number = preg_match('/^(\d+)_/', $key, $match) === 1 ? $match[1] . " · " : "";
  176. return [
  177. "kind" => "doc",
  178. "key" => $key,
  179. "title" => $title,
  180. "nav" => $number . $title,
  181. "summary" => handbookDocSummary($path),
  182. "path" => $path,
  183. "source" => $relative === null
  184. ? handbookDirName() . "/content/" . $key . ".md"
  185. : "client-package/" . $relative,
  186. "bytes" => (int) @filesize($path),
  187. ];
  188. }
  189. /** Whether two page records describe the same page. */
  190. function handbookIsSamePage(?array $a, array $b): bool
  191. {
  192. return $a !== null && $a["kind"] === $b["kind"] && $a["key"] === $b["key"];
  193. }
  194. /** Look up one page, or null when the key is unknown. */
  195. function handbookPage(string $kind, string $key): ?array
  196. {
  197. return handbookPages()[$kind . ":" . $key] ?? null;
  198. }
  199. /** @return array<string, array> only the pages of one kind */
  200. function handbookPagesOfKind(string $kind): array
  201. {
  202. return array_filter(handbookPages(), static fn(array $p): bool => $p["kind"] === $kind);
  203. }
  204. /**
  205. * Reading order. Chapters before source, the overview first and the API
  206. * chapter after the numbered ones; within the source the files that get copied
  207. * into the host project come first, entry point before the modules it pulls in,
  208. * examples and tooling last. Anything unrecognised sorts to the end, so a
  209. * newly added file still lands somewhere sensible.
  210. */
  211. function handbookWeight(array $page): int
  212. {
  213. if ($page["kind"] === "doc") {
  214. return match (true) {
  215. $page["key"] === "00_OVERVIEW" => 0,
  216. $page["key"] === "README" => 1,
  217. $page["key"] === "50_API" => 3,
  218. default => 2,
  219. };
  220. }
  221. $relative = $page["key"];
  222. return match (true) {
  223. $relative === "manage-client/config.sample.php" => 10,
  224. $relative === "manage-client/lib/client.php" => 11,
  225. $relative === "manage-client/lib/updater.php" => 12,
  226. $relative === "manage-client/lib/backup.php" => 13,
  227. $relative === "manage-client/lib/remote.php" => 14,
  228. str_starts_with($relative, "manage-client/lib/") => 15,
  229. str_starts_with($relative, "manage-client/bin/") => 16,
  230. str_starts_with($relative, "manage-client/ui/") => 17,
  231. str_starts_with($relative, "manage-client/") => 18,
  232. str_starts_with($relative, "examples/") => 20,
  233. str_starts_with($relative, "scripts/") => 21,
  234. default => 30,
  235. };
  236. }
  237. /** Third-party files that are named in the index but not served as pages. */
  238. function handbookVendoredFiles(): array
  239. {
  240. $found = [];
  241. foreach (handbookScanPackage() as $relative) {
  242. if (in_array($relative, HANDBOOK_VENDORED, true)) {
  243. $found[$relative] = (int) @filesize(HANDBOOK_PACKAGE_DIR . "/" . $relative);
  244. }
  245. }
  246. return $found;
  247. }
  248. /**
  249. * Recursively lists every file under client-package/, relative to it.
  250. *
  251. * @return string[] sorted relative paths
  252. */
  253. function handbookScanPackage(): array
  254. {
  255. static $files = null;
  256. if ($files !== null) {
  257. return $files;
  258. }
  259. $root = realpath(HANDBOOK_PACKAGE_DIR);
  260. if ($root === false || !is_dir($root)) {
  261. return $files = [];
  262. }
  263. $iterator = new RecursiveIteratorIterator(
  264. new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
  265. RecursiveIteratorIterator::SELF_FIRST,
  266. );
  267. $files = [];
  268. foreach ($iterator as $item) {
  269. if (!$item->isFile()) {
  270. continue;
  271. }
  272. $relative = str_replace("\\", "/", substr($item->getPathname(), strlen($root) + 1));
  273. $name = basename($relative);
  274. // Local noise that is not part of the handed-out package.
  275. if ($name === ".DS_Store" || str_ends_with($name, ".log")) {
  276. continue;
  277. }
  278. if (in_array($relative, HANDBOOK_EXCLUDED, true)) {
  279. continue;
  280. }
  281. $files[] = $relative;
  282. }
  283. sort($files, SORT_STRING);
  284. return $files;
  285. }
  286. /** Page key for a Markdown file: the file name without prefix path. */
  287. function handbookDocKey(string $relative): string
  288. {
  289. return basename($relative, ".md");
  290. }
  291. // ---------------------------------------------------------------------------
  292. // Titles and summaries
  293. // ---------------------------------------------------------------------------
  294. /**
  295. * Title of a chapter: its own first-level heading, so the handbook shows what
  296. * the document calls itself rather than a name derived from the file.
  297. */
  298. function handbookDocTitle(string $path, string $key): string
  299. {
  300. if (preg_match('/^#\s+(.+)$/m', handbookRead($path), $match) === 1) {
  301. return trim($match[1]);
  302. }
  303. return ucwords(strtolower(str_replace("_", " ", preg_replace('/^\d+_/', "", $key) ?? $key)));
  304. }
  305. /**
  306. * One line describing a chapter: its first prose paragraph, cut to the first
  307. * sentence. Written by hand in every document, so nothing has to be maintained
  308. * here in parallel.
  309. */
  310. function handbookDocSummary(string $path): string
  311. {
  312. $paragraph = "";
  313. foreach (explode("\n", handbookRead($path)) as $line) {
  314. $line = trim($line);
  315. if ($paragraph === "") {
  316. // Skip headings, quotes, lists, tables and fences before the prose.
  317. if ($line === "" || preg_match('/^([#>|\-*+]|\d+\.|```)/', $line) === 1) {
  318. continue;
  319. }
  320. $paragraph = $line;
  321. continue;
  322. }
  323. if ($line === "") {
  324. break;
  325. }
  326. $paragraph .= " " . $line;
  327. }
  328. return handbookFirstSentence($paragraph);
  329. }
  330. /**
  331. * One line describing a source file: its first comment line. Every file in the
  332. * package opens with one, in its own comment syntax.
  333. */
  334. function handbookCodeSummary(string $path): string
  335. {
  336. $handle = @fopen($path, "rb");
  337. if ($handle === false) {
  338. return "";
  339. }
  340. $summary = "";
  341. $lines = 0;
  342. while (($line = fgets($handle)) !== false && $lines < 30) {
  343. $lines++;
  344. $line = trim($line);
  345. // Skip the preamble a file may carry before its first comment.
  346. if ($summary === "") {
  347. if ($line === "" || $line === "<?php" || str_starts_with($line, "#!")
  348. || str_starts_with($line, "declare(")) {
  349. continue;
  350. }
  351. }
  352. if (preg_match('#^(//|\#|/\*\*?|\*)\s*(.*)$#', $line, $match) !== 1) {
  353. break;
  354. }
  355. $text = trim($match[2]);
  356. if ($text === "" || $text === "*/") {
  357. // The blank line that ends the opening paragraph of the header.
  358. if ($summary !== "") {
  359. break;
  360. }
  361. continue;
  362. }
  363. // The header wraps over several lines; join them before cutting.
  364. $summary = $summary === "" ? $text : $summary . " " . $text;
  365. }
  366. fclose($handle);
  367. return handbookFirstSentence($summary);
  368. }
  369. function handbookFirstSentence(string $text): string
  370. {
  371. $text = trim(preg_replace('/\s+/', " ", $text) ?? $text);
  372. if ($text === "") {
  373. return "";
  374. }
  375. // Cut after the first sentence, but not on an abbreviation or a version.
  376. if (preg_match('/^(.{20,}?[.!?])(\s|$)/u', $text, $match) === 1) {
  377. $text = $match[1];
  378. }
  379. if (mb_strlen($text) > 180) {
  380. $text = mb_substr($text, 0, 177) . "…";
  381. }
  382. return rtrim($text, ".");
  383. }
  384. function handbookRead(string $path): string
  385. {
  386. return is_file($path) ? (string) file_get_contents($path) : "";
  387. }
  388. function handbookFormatBytes(int $bytes): string
  389. {
  390. if ($bytes < 1024) {
  391. return $bytes . " B";
  392. }
  393. return number_format($bytes / 1024, 1, ",", ".") . " KB";
  394. }
  395. // ---------------------------------------------------------------------------
  396. // Page bodies
  397. // ---------------------------------------------------------------------------
  398. /** Fence language for a source file, by extension. */
  399. function handbookLanguage(string $relative): string
  400. {
  401. $name = basename($relative);
  402. if ($name === ".htaccess") {
  403. return "apacheconf";
  404. }
  405. if (str_ends_with($name, ".cron")) {
  406. return "text";
  407. }
  408. return match (strtolower(pathinfo($relative, PATHINFO_EXTENSION))) {
  409. "php" => "php",
  410. "sh", "bash" => "bash",
  411. "css" => "css",
  412. "js" => "javascript",
  413. "json" => "json",
  414. "md" => "markdown",
  415. default => "text",
  416. };
  417. }
  418. /**
  419. * Picks a fence long enough to survive content that itself contains one.
  420. * lib/zip.php and several chapters embed triple backticks.
  421. */
  422. function handbookFenceFor(string $body): string
  423. {
  424. $longest = 0;
  425. if (preg_match_all('/^\s*(`{3,})/m', $body, $matches) > 0) {
  426. foreach ($matches[1] as $run) {
  427. $longest = max($longest, strlen($run));
  428. }
  429. }
  430. return str_repeat("`", max(3, $longest + 1));
  431. }
  432. /**
  433. * The Markdown body of a page.
  434. *
  435. * A chapter is served as written. A source file is wrapped in a fenced block
  436. * under a heading naming its path, so both kinds of page are Markdown and can
  437. * go through the same renderer.
  438. */
  439. function handbookPageMarkdown(array $page, string $script = "index.php"): string
  440. {
  441. $body = handbookRead($page["path"]);
  442. if ($page["kind"] === "doc") {
  443. $body = strtr($body, [
  444. "{{BASE_URL}}" => handbookBaseUrl(),
  445. "{{SELF_URL}}" => handbookSelfUrl(),
  446. "{{SCRIPT}}" => $script,
  447. "{{DOC_COUNT}}" => (string) count(handbookPagesOfKind("doc")),
  448. "{{CODE_COUNT}}" => (string) count(handbookPagesOfKind("code")),
  449. ]);
  450. return handbookRewriteLinks($body, $script);
  451. }
  452. $fence = handbookFenceFor($body);
  453. return "# " . $page["key"] . "\n\n"
  454. . "> " . ($page["summary"] !== "" ? $page["summary"] . ". " : "")
  455. . "Quelle: `" . $page["source"] . "`, " . handbookFormatBytes($page["bytes"]) . "\n\n"
  456. . $fence . handbookLanguage($page["key"]) . "\n"
  457. . rtrim($body) . "\n" . $fence . "\n";
  458. }
  459. /**
  460. * Rewrites the links between chapters so they point at the neighbouring page
  461. * of whichever rendering the reader is in.
  462. *
  463. * The documents link each other as `02_INTEGRATION.md`, which is right inside
  464. * the package and inside the ZIP; here the same target is a query parameter.
  465. */
  466. function handbookRewriteLinks(string $markdown, string $script = "index.php"): string
  467. {
  468. return preg_replace_callback(
  469. '/\]\(([^)\s]+\.md)(#[^)\s]*)?\)/',
  470. static function (array $match) use ($script): string {
  471. $key = basename($match[1], ".md");
  472. if (handbookPage("doc", $key) === null) {
  473. return $match[0];
  474. }
  475. return "](" . $script . "?doc=" . rawurlencode($key) . ($match[2] ?? "") . ")";
  476. },
  477. $markdown,
  478. ) ?? $markdown;
  479. }
  480. // ---------------------------------------------------------------------------
  481. // Task recipes
  482. // ---------------------------------------------------------------------------
  483. /**
  484. * Named jobs with the pages they need, for the index that agents read.
  485. *
  486. * The point of splitting the handbook is that an agent fetches a handful of
  487. * pages instead of everything; without a shortlist per job it would have to
  488. * fetch everything to find out which pages matter. Keys that no longer exist
  489. * are dropped when the list is rendered, so a renamed document degrades to a
  490. * shorter recipe rather than to a dead link.
  491. */
  492. function handbookRecipes(): array
  493. {
  494. return [
  495. [
  496. "title" => "Adding update and backup functionality to a project",
  497. "note" => "The usual case. The package gets adopted, not rebuilt.",
  498. "pages" => [
  499. ["doc", "00_OVERVIEW"],
  500. ["doc", "01_QUICKSTART"],
  501. ["doc", "02_INTEGRATION"],
  502. ["doc", "03_CONFIG_REFERENCE"],
  503. ["code", "manage-client/config.sample.php"],
  504. ["code", "manage-client/lib/client.php"],
  505. ],
  506. ],
  507. [
  508. "title" => "Setting up backups only",
  509. "note" => "Without the updater. Define sources, choose targets, add cron.",
  510. "pages" => [
  511. ["doc", "05_BACKUP_SOURCES"],
  512. ["doc", "03_CONFIG_REFERENCE"],
  513. ["code", "manage-client/lib/backup.php"],
  514. ["code", "manage-client/lib/remote.php"],
  515. ["code", "examples/cron/manage-client.cron"],
  516. ],
  517. ],
  518. [
  519. "title" => "Setting up the updater only",
  520. "note" => "Protected paths, packaging, migrations and the post-update hook.",
  521. "pages" => [
  522. ["doc", "06_UPDATE_PACKAGING"],
  523. ["doc", "07_POST_UPDATE_HOOKS"],
  524. ["code", "manage-client/lib/updater.php"],
  525. ["code", "manage-client/lib/hooks.php"],
  526. ["code", "scripts/create-release-zip.sh"],
  527. ],
  528. ],
  529. [
  530. "title" => "Writing your own client",
  531. "note" => "A different language or framework. The protocol is binding, "
  532. . "the PHP version is the reference.",
  533. "pages" => [
  534. ["doc", "08_PROTOCOL"],
  535. ["doc", "50_API"],
  536. ["doc", "10_SECURITY"],
  537. ["code", "manage-client/lib/client.php"],
  538. ["code", "manage-client/lib/updater.php"],
  539. ["code", "manage-client/lib/backup.php"],
  540. ],
  541. ],
  542. [
  543. "title" => "Debugging a running installation",
  544. "note" => "Messages, exit codes and their causes.",
  545. "pages" => [
  546. ["doc", "09_TROUBLESHOOTING"],
  547. ["doc", "03_CONFIG_REFERENCE"],
  548. ["code", "manage-client/bin/manage-client.php"],
  549. ],
  550. ],
  551. ];
  552. }
  553. // ---------------------------------------------------------------------------
  554. // The OpenAPI document
  555. // ---------------------------------------------------------------------------
  556. /**
  557. * The OpenAPI document for api/v1, with the live server URL substituted.
  558. *
  559. * openapi.json next to this directory is the source of truth; only the server
  560. * entry is filled in, so the file stays valid on its own.
  561. */
  562. function handbookOpenApiJson(): string
  563. {
  564. $raw = handbookRead(__DIR__ . "/../openapi.json");
  565. $spec = json_decode($raw, true);
  566. if (!is_array($spec)) {
  567. return $raw;
  568. }
  569. $spec["servers"] = [[
  570. "url" => handbookBaseUrl() . "/api/v1",
  571. "description" => "Diese Manage-Installation",
  572. ]];
  573. return (string) json_encode(
  574. $spec,
  575. JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
  576. );
  577. }