| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668 |
- <?php
- declare(strict_types=1);
- /**
- * Page index for the public client handbook.
- *
- * The handbook is one page per document and one page per source file, the same
- * split the repository already has - not a single long page. Two renderings
- * share this index:
- *
- * index.php HTML for people, rendered with the vendored marked.js
- * llms.php the same pages as plain Markdown, plus llms.txt as their index
- *
- * Everything is read from disk on each request, so the published pages always
- * match the repository; there is no build step and nothing to regenerate.
- */
- const HANDBOOK_PACKAGE_DIR = __DIR__ . "/../../client-package";
- const HANDBOOK_CONTENT_DIR = __DIR__ . "/../content";
- /**
- * Paths inside client-package/ that must never be published.
- *
- * config.php holds the instance token. It is git-ignored and stripped by
- * build-client-package.sh, but these pages are public, so it is excluded by
- * path as well instead of relying on it being absent.
- */
- const HANDBOOK_EXCLUDED = [
- "manage-client/config.php",
- ];
- /** Vendored third-party files: named in the index, never served as a page. */
- const HANDBOOK_VENDORED = [
- "docs/assets/marked.min.js",
- ];
- // ---------------------------------------------------------------------------
- // Addresses
- // ---------------------------------------------------------------------------
- function handbookRepoRoot(): string
- {
- return dirname(__DIR__, 2);
- }
- /** Name of this documentation directory, as it appears in the URL. */
- function handbookDirName(): string
- {
- return basename(dirname(__DIR__));
- }
- /**
- * Absolute base URL of this installation.
- *
- * Prefers the configured MANAGE_PUBLIC_URL so that copied links keep working;
- * falls back to the current request for installations served under a different
- * name (staging, a local `php -S`).
- */
- function handbookBaseUrl(): string
- {
- static $cached = null;
- if ($cached !== null) {
- return $cached;
- }
- $configured = "";
- $config = handbookRepoRoot() . "/config.php";
- if (is_file($config)) {
- // Read as text: including the server config would pull in its side
- // effects, and a public page needs none of them.
- $source = (string) file_get_contents($config);
- if (preg_match('/define\(\s*"MANAGE_PUBLIC_URL"\s*,\s*"([^"]*)"/', $source, $match) === 1) {
- $configured = trim($match[1]);
- }
- }
- if ($configured !== "" && preg_match('#^https?://#i', $configured) === 1) {
- return $cached = rtrim($configured, "/");
- }
- $scheme = ($_SERVER["HTTPS"] ?? "") === "on"
- || ($_SERVER["HTTP_X_FORWARDED_PROTO"] ?? "") === "https" ? "https" : "http";
- $host = (string) ($_SERVER["HTTP_HOST"] ?? "localhost");
- // dirname(SCRIPT_NAME) ends with this directory's name; dropping that
- // suffix yields the mount point of the manage installation itself.
- $dir = rtrim(str_replace("\\", "/", dirname($_SERVER["SCRIPT_NAME"] ?? "")), "/");
- $own = "/" . handbookDirName();
- if (str_ends_with($dir, $own)) {
- $dir = substr($dir, 0, -strlen($own));
- }
- return $cached = $scheme . "://" . $host . $dir;
- }
- /** Absolute URL of this documentation directory, without a trailing slash. */
- function handbookSelfUrl(): string
- {
- return handbookBaseUrl() . "/" . handbookDirName();
- }
- /** Absolute address of a page in the human rendering. */
- function handbookPageUrl(array $page): string
- {
- return handbookSelfUrl() . "/index.php?" . handbookPageQuery($page);
- }
- /** Absolute address of the same page as plain Markdown. */
- function handbookRawUrl(array $page): string
- {
- return handbookSelfUrl() . "/llms.php?" . handbookPageQuery($page);
- }
- function handbookPageQuery(array $page): string
- {
- // Slashes are legal in a query string and a source path is easier to read
- // - and to type into a terminal - when they are left alone.
- return $page["kind"] . "=" . str_replace("%2F", "/", rawurlencode($page["key"]));
- }
- function handbookEscape(string $value): string
- {
- return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8");
- }
- /**
- * A summary for the HTML index. The summaries are lifted from Markdown and
- * from source comments, so they may carry `code spans`; those become <code>
- * after escaping, which has already neutralised any markup in the text.
- */
- function handbookSummaryHtml(string $summary): string
- {
- return preg_replace(
- '/`([^`]+)`/',
- '<code>$1</code>',
- handbookEscape($summary),
- ) ?? handbookEscape($summary);
- }
- // ---------------------------------------------------------------------------
- // The page index
- // ---------------------------------------------------------------------------
- /**
- * Every page of the handbook, in reading order, keyed by "<kind>:<key>".
- *
- * 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<string, 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<string, 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 === "<?php" || str_starts_with($line, "#!")
- || str_starts_with($line, "declare(")) {
- continue;
- }
- }
- if (preg_match('#^(//|\#|/\*\*?|\*)\s*(.*)$#', $line, $match) !== 1) {
- break;
- }
- $text = trim($match[2]);
- if ($text === "" || $text === "*/") {
- // The blank line that ends the opening paragraph of the header.
- if ($summary !== "") {
- break;
- }
- continue;
- }
- // The header wraps over several lines; join them before cutting.
- $summary = $summary === "" ? $text : $summary . " " . $text;
- }
- fclose($handle);
- return handbookFirstSentence($summary);
- }
- function handbookFirstSentence(string $text): string
- {
- $text = trim(preg_replace('/\s+/', " ", $text) ?? $text);
- if ($text === "") {
- return "";
- }
- // Cut after the first sentence, but not on an abbreviation or a version.
- if (preg_match('/^(.{20,}?[.!?])(\s|$)/u', $text, $match) === 1) {
- $text = $match[1];
- }
- if (mb_strlen($text) > 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,
- );
- }
|