* after escaping, which has already neutralised any markup in the text. */ function handbookSummaryHtml(string $summary): string { return preg_replace( '/`([^`]+)`/', '$1', handbookEscape($summary), ) ?? handbookEscape($summary); } // --------------------------------------------------------------------------- // The page index // --------------------------------------------------------------------------- /** * Every page of the handbook, in reading order, keyed by ":". * * A page is an array with: kind (doc|code), key, title, nav, summary, path, * source (repository path, null for the two authored chapters), bytes. * * @return array */ function handbookPages(): array { static $pages = null; if ($pages !== null) { return $pages; } $pages = []; // The two authored chapters frame the material that comes from the package. foreach (["00_OVERVIEW", "50_API"] as $key) { $path = HANDBOOK_CONTENT_DIR . "/" . $key . ".md"; if (!is_file($path)) { continue; } $pages["doc:" . $key] = handbookMakeDocPage($key, $path, null); } foreach (handbookScanPackage() as $relative) { if (in_array($relative, HANDBOOK_VENDORED, true)) { continue; } $path = HANDBOOK_PACKAGE_DIR . "/" . $relative; if (str_ends_with($relative, ".md")) { $key = handbookDocKey($relative); $pages["doc:" . $key] = handbookMakeDocPage($key, $path, $relative); continue; } $pages["code:" . $relative] = [ "kind" => "code", "key" => $relative, "title" => $relative, "nav" => $relative, "summary" => handbookCodeSummary($path), "path" => $path, "source" => "client-package/" . $relative, "bytes" => (int) @filesize($path), ]; } uasort($pages, static function (array $a, array $b): int { return [handbookWeight($a), $a["key"]] <=> [handbookWeight($b), $b["key"]]; }); return $pages; } function handbookMakeDocPage(string $key, string $path, ?string $relative): array { $title = handbookDocTitle($path, $key); $number = preg_match('/^(\d+)_/', $key, $match) === 1 ? $match[1] . " · " : ""; return [ "kind" => "doc", "key" => $key, "title" => $title, "nav" => $number . $title, "summary" => handbookDocSummary($path), "path" => $path, "source" => $relative === null ? handbookDirName() . "/content/" . $key . ".md" : "client-package/" . $relative, "bytes" => (int) @filesize($path), ]; } /** Whether two page records describe the same page. */ function handbookIsSamePage(?array $a, array $b): bool { return $a !== null && $a["kind"] === $b["kind"] && $a["key"] === $b["key"]; } /** Look up one page, or null when the key is unknown. */ function handbookPage(string $kind, string $key): ?array { return handbookPages()[$kind . ":" . $key] ?? null; } /** @return array only the pages of one kind */ function handbookPagesOfKind(string $kind): array { return array_filter(handbookPages(), static fn(array $p): bool => $p["kind"] === $kind); } /** * Reading order. Chapters before source, the overview first and the API * chapter after the numbered ones; within the source the files that get copied * into the host project come first, entry point before the modules it pulls in, * examples and tooling last. Anything unrecognised sorts to the end, so a * newly added file still lands somewhere sensible. */ function handbookWeight(array $page): int { if ($page["kind"] === "doc") { return match (true) { $page["key"] === "00_OVERVIEW" => 0, $page["key"] === "README" => 1, $page["key"] === "50_API" => 3, default => 2, }; } $relative = $page["key"]; return match (true) { $relative === "manage-client/config.sample.php" => 10, $relative === "manage-client/lib/client.php" => 11, $relative === "manage-client/lib/updater.php" => 12, $relative === "manage-client/lib/backup.php" => 13, $relative === "manage-client/lib/remote.php" => 14, str_starts_with($relative, "manage-client/lib/") => 15, str_starts_with($relative, "manage-client/bin/") => 16, str_starts_with($relative, "manage-client/ui/") => 17, str_starts_with($relative, "manage-client/") => 18, str_starts_with($relative, "examples/") => 20, str_starts_with($relative, "scripts/") => 21, default => 30, }; } /** Third-party files that are named in the index but not served as pages. */ function handbookVendoredFiles(): array { $found = []; foreach (handbookScanPackage() as $relative) { if (in_array($relative, HANDBOOK_VENDORED, true)) { $found[$relative] = (int) @filesize(HANDBOOK_PACKAGE_DIR . "/" . $relative); } } return $found; } /** * Recursively lists every file under client-package/, relative to it. * * @return string[] sorted relative paths */ function handbookScanPackage(): array { static $files = null; if ($files !== null) { return $files; } $root = realpath(HANDBOOK_PACKAGE_DIR); if ($root === false || !is_dir($root)) { return $files = []; } $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST, ); $files = []; foreach ($iterator as $item) { if (!$item->isFile()) { continue; } $relative = str_replace("\\", "/", substr($item->getPathname(), strlen($root) + 1)); $name = basename($relative); // Local noise that is not part of the handed-out package. if ($name === ".DS_Store" || str_ends_with($name, ".log")) { continue; } if (in_array($relative, HANDBOOK_EXCLUDED, true)) { continue; } $files[] = $relative; } sort($files, SORT_STRING); return $files; } /** Page key for a Markdown file: the file name without prefix path. */ function handbookDocKey(string $relative): string { return basename($relative, ".md"); } // --------------------------------------------------------------------------- // Titles and summaries // --------------------------------------------------------------------------- /** * Title of a chapter: its own first-level heading, so the handbook shows what * the document calls itself rather than a name derived from the file. */ function handbookDocTitle(string $path, string $key): string { if (preg_match('/^#\s+(.+)$/m', handbookRead($path), $match) === 1) { return trim($match[1]); } return ucwords(strtolower(str_replace("_", " ", preg_replace('/^\d+_/', "", $key) ?? $key))); } /** * One line describing a chapter: its first prose paragraph, cut to the first * sentence. Written by hand in every document, so nothing has to be maintained * here in parallel. */ function handbookDocSummary(string $path): string { $paragraph = ""; foreach (explode("\n", handbookRead($path)) as $line) { $line = trim($line); if ($paragraph === "") { // Skip headings, quotes, lists, tables and fences before the prose. if ($line === "" || preg_match('/^([#>|\-*+]|\d+\.|```)/', $line) === 1) { continue; } $paragraph = $line; continue; } if ($line === "") { break; } $paragraph .= " " . $line; } return handbookFirstSentence($paragraph); } /** * One line describing a source file: its first comment line. Every file in the * package opens with one, in its own comment syntax. */ function handbookCodeSummary(string $path): string { $handle = @fopen($path, "rb"); if ($handle === false) { return ""; } $summary = ""; $lines = 0; while (($line = fgets($handle)) !== false && $lines < 30) { $lines++; $line = trim($line); // Skip the preamble a file may carry before its first comment. if ($summary === "") { if ($line === "" || $line === " 180) { $text = mb_substr($text, 0, 177) . "…"; } return rtrim($text, "."); } function handbookRead(string $path): string { return is_file($path) ? (string) file_get_contents($path) : ""; } function handbookFormatBytes(int $bytes): string { if ($bytes < 1024) { return $bytes . " B"; } return number_format($bytes / 1024, 1, ",", ".") . " KB"; } // --------------------------------------------------------------------------- // Page bodies // --------------------------------------------------------------------------- /** Fence language for a source file, by extension. */ function handbookLanguage(string $relative): string { $name = basename($relative); if ($name === ".htaccess") { return "apacheconf"; } if (str_ends_with($name, ".cron")) { return "text"; } return match (strtolower(pathinfo($relative, PATHINFO_EXTENSION))) { "php" => "php", "sh", "bash" => "bash", "css" => "css", "js" => "javascript", "json" => "json", "md" => "markdown", default => "text", }; } /** * Picks a fence long enough to survive content that itself contains one. * lib/zip.php and several chapters embed triple backticks. */ function handbookFenceFor(string $body): string { $longest = 0; if (preg_match_all('/^\s*(`{3,})/m', $body, $matches) > 0) { foreach ($matches[1] as $run) { $longest = max($longest, strlen($run)); } } return str_repeat("`", max(3, $longest + 1)); } /** * The Markdown body of a page. * * A chapter is served as written. A source file is wrapped in a fenced block * under a heading naming its path, so both kinds of page are Markdown and can * go through the same renderer. */ function handbookPageMarkdown(array $page, string $script = "index.php"): string { $body = handbookRead($page["path"]); if ($page["kind"] === "doc") { $body = strtr($body, [ "{{BASE_URL}}" => handbookBaseUrl(), "{{SELF_URL}}" => handbookSelfUrl(), "{{SCRIPT}}" => $script, "{{DOC_COUNT}}" => (string) count(handbookPagesOfKind("doc")), "{{CODE_COUNT}}" => (string) count(handbookPagesOfKind("code")), ]); return handbookRewriteLinks($body, $script); } $fence = handbookFenceFor($body); return "# " . $page["key"] . "\n\n" . "> " . ($page["summary"] !== "" ? $page["summary"] . ". " : "") . "Quelle: `" . $page["source"] . "`, " . handbookFormatBytes($page["bytes"]) . "\n\n" . $fence . handbookLanguage($page["key"]) . "\n" . rtrim($body) . "\n" . $fence . "\n"; } /** * Rewrites the links between chapters so they point at the neighbouring page * of whichever rendering the reader is in. * * The documents link each other as `02_INTEGRATION.md`, which is right inside * the package and inside the ZIP; here the same target is a query parameter. */ function handbookRewriteLinks(string $markdown, string $script = "index.php"): string { return preg_replace_callback( '/\]\(([^)\s]+\.md)(#[^)\s]*)?\)/', static function (array $match) use ($script): string { $key = basename($match[1], ".md"); if (handbookPage("doc", $key) === null) { return $match[0]; } return "](" . $script . "?doc=" . rawurlencode($key) . ($match[2] ?? "") . ")"; }, $markdown, ) ?? $markdown; } // --------------------------------------------------------------------------- // Task recipes // --------------------------------------------------------------------------- /** * Named jobs with the pages they need, for the index that agents read. * * The point of splitting the handbook is that an agent fetches a handful of * pages instead of everything; without a shortlist per job it would have to * fetch everything to find out which pages matter. Keys that no longer exist * are dropped when the list is rendered, so a renamed document degrades to a * shorter recipe rather than to a dead link. */ function handbookRecipes(): array { return [ [ "title" => "Adding update and backup functionality to a project", "note" => "The usual case. The package gets adopted, not rebuilt.", "pages" => [ ["doc", "00_OVERVIEW"], ["doc", "01_QUICKSTART"], ["doc", "02_INTEGRATION"], ["doc", "03_CONFIG_REFERENCE"], ["code", "manage-client/config.sample.php"], ["code", "manage-client/lib/client.php"], ], ], [ "title" => "Setting up backups only", "note" => "Without the updater. Define sources, choose targets, add cron.", "pages" => [ ["doc", "05_BACKUP_SOURCES"], ["doc", "03_CONFIG_REFERENCE"], ["code", "manage-client/lib/backup.php"], ["code", "manage-client/lib/remote.php"], ["code", "examples/cron/manage-client.cron"], ], ], [ "title" => "Setting up the updater only", "note" => "Protected paths, packaging, migrations and the post-update hook.", "pages" => [ ["doc", "06_UPDATE_PACKAGING"], ["doc", "07_POST_UPDATE_HOOKS"], ["code", "manage-client/lib/updater.php"], ["code", "manage-client/lib/hooks.php"], ["code", "scripts/create-release-zip.sh"], ], ], [ "title" => "Writing your own client", "note" => "A different language or framework. The protocol is binding, " . "the PHP version is the reference.", "pages" => [ ["doc", "08_PROTOCOL"], ["doc", "50_API"], ["doc", "10_SECURITY"], ["code", "manage-client/lib/client.php"], ["code", "manage-client/lib/updater.php"], ["code", "manage-client/lib/backup.php"], ], ], [ "title" => "Debugging a running installation", "note" => "Messages, exit codes and their causes.", "pages" => [ ["doc", "09_TROUBLESHOOTING"], ["doc", "03_CONFIG_REFERENCE"], ["code", "manage-client/bin/manage-client.php"], ], ], ]; } // --------------------------------------------------------------------------- // The OpenAPI document // --------------------------------------------------------------------------- /** * The OpenAPI document for api/v1, with the live server URL substituted. * * openapi.json next to this directory is the source of truth; only the server * entry is filled in, so the file stays valid on its own. */ function handbookOpenApiJson(): string { $raw = handbookRead(__DIR__ . "/../openapi.json"); $spec = json_decode($raw, true); if (!is_array($spec)) { return $raw; } $spec["servers"] = [[ "url" => handbookBaseUrl() . "/api/v1", "description" => "Diese Manage-Installation", ]]; return (string) json_encode( $spec, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE, ); }