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