|
|
@@ -0,0 +1,485 @@
|
|
|
+<?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,
|
|
|
+ );
|
|
|
+}
|