| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485 |
- <?php
- declare(strict_types=1);
- /**
- * Builds the public client handbook: one document containing every piece of
- * documentation and every source file of client-package/.
- *
- * Everything is read from disk on each request, so the published page always
- * matches the repository - there is no build step and nothing to regenerate
- * after an edit.
- *
- * Two renderings share this assembly:
- * index.php HTML, rendered client-side with the vendored marked.js
- * llms.php text/plain Markdown, meant to be fed to an agent verbatim
- */
- 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 this page is public, so it is excluded here by
- * path as well instead of relying on it being absent.
- */
- const HANDBOOK_EXCLUDED = [
- "manage-client/config.php",
- ];
- /** Vendored third-party files: listed in the inventory, never reproduced. */
- const HANDBOOK_VENDORED = [
- "docs/assets/marked.min.js",
- ];
- function handbookRepoRoot(): string
- {
- return dirname(__DIR__, 2);
- }
- /**
- * 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
- {
- $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 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 $scheme . "://" . $host . $dir;
- }
- /** Name of this documentation directory, as it appears in the URL. */
- function handbookDirName(): string
- {
- return basename(dirname(__DIR__));
- }
- /**
- * Absolute URL of this documentation directory, without a trailing slash.
- *
- * Derived from the installation base rather than from the request path, so a
- * MANAGE_PUBLIC_URL that carries a subdirectory is not duplicated.
- */
- function handbookSelfUrl(): string
- {
- return handbookBaseUrl() . "/" . handbookDirName();
- }
- function handbookEscape(string $value): string
- {
- return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8");
- }
- /** Stable anchor id for a section, usable in both the nav and the document. */
- function handbookAnchor(string $prefix, string $key): string
- {
- $slug = strtolower($key);
- $slug = preg_replace('/[^a-z0-9]+/', "-", $slug) ?? $slug;
- return $prefix . "-" . trim($slug, "-");
- }
- /** 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",
- };
- }
- /**
- * Recursively lists every file under client-package/, relative to it.
- *
- * @return string[] sorted relative paths
- */
- function handbookScanPackage(): array
- {
- $root = realpath(HANDBOOK_PACKAGE_DIR);
- if ($root === false || !is_dir($root)) {
- return [];
- }
- $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;
- }
- /**
- * Splits the package into the documentation chapters and the source files.
- *
- * @return array{docs: array<string, string>, code: string[], vendored: string[]}
- * docs maps relative path -> chapter title, in reading order
- */
- function handbookInventory(): array
- {
- $docs = [];
- $code = [];
- $vendored = [];
- foreach (handbookScanPackage() as $relative) {
- if (in_array($relative, HANDBOOK_VENDORED, true)) {
- $vendored[] = $relative;
- continue;
- }
- if (str_ends_with($relative, ".md")) {
- $number = preg_match('#/(\d+)_#', $relative, $match) === 1 ? $match[1] . " · " : "";
- $docs[$relative] = $number . handbookChapterTitle($relative);
- continue;
- }
- $code[] = $relative;
- }
- // README first, then the numbered chapters in order.
- uksort($docs, static function (string $a, string $b): int {
- if ($a === "README.md") {
- return -1;
- }
- if ($b === "README.md") {
- return 1;
- }
- return strcmp($a, $b);
- });
- usort($code, static function (string $a, string $b): int {
- return [handbookCodeWeight($a), $a] <=> [handbookCodeWeight($b), $b];
- });
- return ["docs" => $docs, "code" => $code, "vendored" => $vendored];
- }
- /**
- * Reading order for the source part: the files that are copied into the host
- * project first, entry point before the modules it pulls in, examples and
- * tooling last. Anything unrecognised sorts to the end alphabetically, so a
- * newly added file still lands somewhere sensible.
- */
- function handbookCodeWeight(string $relative): int
- {
- 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,
- };
- }
- /**
- * 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 handbookChapterTitle(string $relative): string
- {
- if (preg_match('/^#\s+(.+)$/m', handbookReadDoc($relative), $match) === 1) {
- return trim($match[1]);
- }
- return ucwords(strtolower(str_replace("_", " ", basename($relative, ".md"))));
- }
- function handbookReadDoc(string $relative): string
- {
- $path = HANDBOOK_PACKAGE_DIR . "/" . $relative;
- return is_file($path) ? (string) file_get_contents($path) : "";
- }
- /** Drops the leading h1; the assembly emits it as the chapter heading instead. */
- function handbookStripTitle(string $markdown): string
- {
- return preg_replace('/^#\s+.+\R+/', "", ltrim($markdown), 1) ?? $markdown;
- }
- /** Reads an authored page from content/, with the placeholders filled in. */
- function handbookContent(string $name, array $replacements = []): string
- {
- $path = HANDBOOK_CONTENT_DIR . "/" . $name;
- $text = is_file($path) ? (string) file_get_contents($path) : "";
- foreach ($replacements as $key => $value) {
- $text = str_replace("{{" . $key . "}}", (string) $value, $text);
- }
- return rtrim($text) . "\n";
- }
- /**
- * Rewrites the relative links between the chapters so they point at the
- * anchors of this single page instead of at neighbouring .md files.
- */
- function handbookRewriteLinks(string $markdown, array $docs): string
- {
- $targets = [];
- foreach ($docs as $relative => $_title) {
- $targets[basename($relative)] = "#" . handbookAnchor("doc", $relative);
- }
- return preg_replace_callback(
- '/\]\(([^)\s]+\.md)(#[^)\s]*)?\)/',
- static function (array $match) use ($targets): string {
- $file = basename($match[1]);
- return isset($targets[$file]) ? "](" . $targets[$file] . ")" : $match[0];
- },
- $markdown,
- ) ?? $markdown;
- }
- /**
- * Demotes every heading of an embedded chapter by one level, so the assembled
- * document keeps a single h1 and the chapters sit below the part headings.
- */
- function handbookDemoteHeadings(string $markdown): string
- {
- $lines = explode("\n", $markdown);
- $inFence = false;
- foreach ($lines as $index => $line) {
- if (preg_match('/^\s*(```|~~~)/', $line) === 1) {
- $inFence = !$inFence;
- continue;
- }
- if (!$inFence && preg_match('/^(#{1,5})\s/', $line) === 1) {
- $lines[$index] = "#" . $line;
- }
- }
- return implode("\n", $lines);
- }
- /**
- * Assembles the complete handbook as one Markdown document.
- *
- * @param bool $withAnchors emit HTML anchor targets for the in-page navigation.
- * Off for the plain-text rendering, which has no nav.
- */
- function handbookBuildMarkdown(bool $withAnchors): string
- {
- $inventory = handbookInventory();
- $base = handbookBaseUrl();
- $self = handbookSelfUrl();
- $anchor = static function (string $id) use ($withAnchors): string {
- return $withAnchors ? '<a id="' . $id . '"></a>' . "\n\n" : "";
- };
- $out = [];
- $out[] = $anchor("part-intro") . handbookContent("00_UEBERBLICK.md", [
- "BASE_URL" => $base,
- "SELF_URL" => $self,
- "DOC_COUNT" => (string) count($inventory["docs"]),
- "CODE_COUNT" => (string) count($inventory["code"]),
- ]);
- // ---- Table of contents -------------------------------------------------
- $toc = ["## Inhalt", ""];
- $toc[] = "**Teil 1 – Dokumentation**";
- $toc[] = "";
- foreach ($inventory["docs"] as $relative => $title) {
- $link = $withAnchors ? "[" . $title . "](#" . handbookAnchor("doc", $relative) . ")" : $title;
- $toc[] = "- " . $link . " — `client-package/" . $relative . "`";
- }
- $toc[] = "";
- $toc[] = "**Teil 2 – HTTP-API**";
- $toc[] = "";
- $toc[] = $withAnchors ? "- [OpenAPI-Spezifikation](#part-api)" : "- OpenAPI-Spezifikation";
- $toc[] = "";
- $toc[] = "**Teil 3 – Quellcode**";
- $toc[] = "";
- foreach ($inventory["code"] as $relative) {
- $link = $withAnchors ? "[" . $relative . "](#" . handbookAnchor("code", $relative) . ")" : $relative;
- $toc[] = "- " . $link;
- }
- $out[] = implode("\n", $toc) . "\n";
- // ---- Part 1: documentation --------------------------------------------
- $out[] = $anchor("part-docs") . "# Teil 1 – Dokumentation\n";
- foreach ($inventory["docs"] as $relative => $title) {
- $body = handbookStripTitle(handbookReadDoc($relative));
- $body = handbookRewriteLinks($body, $inventory["docs"]);
- $body = handbookDemoteHeadings($body);
- $out[] = $anchor(handbookAnchor("doc", $relative))
- . "## " . $title . "\n\n"
- . "> Quelle: `client-package/" . $relative . "`\n\n"
- . rtrim($body) . "\n";
- }
- // ---- Part 2: the HTTP API ---------------------------------------------
- $spec = handbookOpenApiJson();
- $out[] = $anchor("part-api") . handbookContent("50_API.md", [
- "BASE_URL" => $base,
- "SELF_URL" => $self,
- ]) . "\n"
- . "## OpenAPI 3.1 (vollständig)\n\n"
- . "```json\n" . rtrim($spec) . "\n```\n";
- // ---- Part 3: the source ------------------------------------------------
- $out[] = $anchor("part-code") . "# Teil 3 – Quellcode\n\n"
- . "Alle " . count($inventory["code"]) . " Quelldateien des Client-Pakets, vollständig und\n"
- . "unverändert. Die Pfade sind relativ zu `client-package/`.\n\n"
- . "Die Reihenfolge folgt der Wichtigkeit für eine Einbindung: zuerst die Vorlage der\n"
- . "Konfiguration und `lib/client.php` als einziger Einstiegspunkt, dann Updater, Backup\n"
- . "und die übrigen Module, danach Kommandozeile und Oberfläche, zuletzt Beispiele und\n"
- . "Werkzeuge. Für eine Übernahme wird der Ordner `manage-client/` gebraucht; alles\n"
- . "darunter gehört dazu, `examples/`, `docs/` und `scripts/` nicht.\n";
- foreach ($inventory["code"] as $relative) {
- $path = HANDBOOK_PACKAGE_DIR . "/" . $relative;
- $body = is_file($path) ? (string) file_get_contents($path) : "";
- $fence = handbookFenceFor($body);
- $out[] = $anchor(handbookAnchor("code", $relative))
- . "## `" . $relative . "`\n\n"
- . $fence . handbookLanguage($relative) . "\n"
- . rtrim($body) . "\n" . $fence . "\n";
- }
- // ---- Inventory ---------------------------------------------------------
- $out[] = $anchor("part-inventory") . handbookInventoryTable($inventory);
- return implode("\n", $out);
- }
- /**
- * Picks a fence long enough to survive content that itself contains one.
- * lib/zip.php and the docs both 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));
- }
- function handbookInventoryTable(array $inventory): string
- {
- $rows = ["# Dateiübersicht", "", "| Datei | Größe | Rolle |", "|---|---:|---|"];
- foreach ($inventory["docs"] as $relative => $_title) {
- $rows[] = handbookInventoryRow($relative, "Dokumentation");
- }
- foreach ($inventory["code"] as $relative) {
- $rows[] = handbookInventoryRow($relative, "Quellcode");
- }
- foreach ($inventory["vendored"] as $relative) {
- $rows[] = handbookInventoryRow($relative, "Fremdbibliothek, hier nicht abgedruckt");
- }
- return implode("\n", $rows) . "\n";
- }
- function handbookInventoryRow(string $relative, string $role): string
- {
- $size = @filesize(HANDBOOK_PACKAGE_DIR . "/" . $relative);
- return "| `" . $relative . "` | " . ($size === false ? "–" : number_format((int) $size, 0, ",", ".") . " B")
- . " | " . $role . " |";
- }
- /** The OpenAPI document, with the live server URL substituted. */
- function handbookOpenApiJson(): string
- {
- $path = __DIR__ . "/../openapi.json";
- $raw = is_file($path) ? (string) file_get_contents($path) : "{}";
- $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,
- );
- }
|