|
@@ -3,16 +3,17 @@
|
|
|
declare(strict_types=1);
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * Builds the public client handbook: one document containing every piece of
|
|
|
|
|
- * documentation and every source file of client-package/.
|
|
|
|
|
|
|
+ * Page index for the public client handbook.
|
|
|
*
|
|
*
|
|
|
- * 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.
|
|
|
|
|
|
|
+ * 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:
|
|
|
*
|
|
*
|
|
|
- * 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
|
|
|
|
|
|
|
+ * 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_PACKAGE_DIR = __DIR__ . "/../../client-package";
|
|
@@ -22,23 +23,33 @@ const HANDBOOK_CONTENT_DIR = __DIR__ . "/../content";
|
|
|
* Paths inside client-package/ that must never be published.
|
|
* Paths inside client-package/ that must never be published.
|
|
|
*
|
|
*
|
|
|
* config.php holds the instance token. It is git-ignored and stripped by
|
|
* 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
|
|
|
|
|
|
|
+ * build-client-package.sh, but these pages are public, so it is excluded by
|
|
|
* path as well instead of relying on it being absent.
|
|
* path as well instead of relying on it being absent.
|
|
|
*/
|
|
*/
|
|
|
const HANDBOOK_EXCLUDED = [
|
|
const HANDBOOK_EXCLUDED = [
|
|
|
"manage-client/config.php",
|
|
"manage-client/config.php",
|
|
|
];
|
|
];
|
|
|
|
|
|
|
|
-/** Vendored third-party files: listed in the inventory, never reproduced. */
|
|
|
|
|
|
|
+/** Vendored third-party files: named in the index, never served as a page. */
|
|
|
const HANDBOOK_VENDORED = [
|
|
const HANDBOOK_VENDORED = [
|
|
|
"docs/assets/marked.min.js",
|
|
"docs/assets/marked.min.js",
|
|
|
];
|
|
];
|
|
|
|
|
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+// Addresses
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
function handbookRepoRoot(): string
|
|
function handbookRepoRoot(): string
|
|
|
{
|
|
{
|
|
|
return dirname(__DIR__, 2);
|
|
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.
|
|
* Absolute base URL of this installation.
|
|
|
*
|
|
*
|
|
@@ -48,6 +59,11 @@ function handbookRepoRoot(): string
|
|
|
*/
|
|
*/
|
|
|
function handbookBaseUrl(): string
|
|
function handbookBaseUrl(): string
|
|
|
{
|
|
{
|
|
|
|
|
+ static $cached = null;
|
|
|
|
|
+ if ($cached !== null) {
|
|
|
|
|
+ return $cached;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
$configured = "";
|
|
$configured = "";
|
|
|
$config = handbookRepoRoot() . "/config.php";
|
|
$config = handbookRepoRoot() . "/config.php";
|
|
|
if (is_file($config)) {
|
|
if (is_file($config)) {
|
|
@@ -60,12 +76,13 @@ function handbookBaseUrl(): string
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if ($configured !== "" && preg_match('#^https?://#i', $configured) === 1) {
|
|
if ($configured !== "" && preg_match('#^https?://#i', $configured) === 1) {
|
|
|
- return rtrim($configured, "/");
|
|
|
|
|
|
|
+ return $cached = rtrim($configured, "/");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
$scheme = ($_SERVER["HTTPS"] ?? "") === "on"
|
|
$scheme = ($_SERVER["HTTPS"] ?? "") === "on"
|
|
|
|| ($_SERVER["HTTP_X_FORWARDED_PROTO"] ?? "") === "https" ? "https" : "http";
|
|
|| ($_SERVER["HTTP_X_FORWARDED_PROTO"] ?? "") === "https" ? "https" : "http";
|
|
|
$host = (string) ($_SERVER["HTTP_HOST"] ?? "localhost");
|
|
$host = (string) ($_SERVER["HTTP_HOST"] ?? "localhost");
|
|
|
|
|
+
|
|
|
// dirname(SCRIPT_NAME) ends with this directory's name; dropping that
|
|
// dirname(SCRIPT_NAME) ends with this directory's name; dropping that
|
|
|
// suffix yields the mount point of the manage installation itself.
|
|
// suffix yields the mount point of the manage installation itself.
|
|
|
$dir = rtrim(str_replace("\\", "/", dirname($_SERVER["SCRIPT_NAME"] ?? "")), "/");
|
|
$dir = rtrim(str_replace("\\", "/", dirname($_SERVER["SCRIPT_NAME"] ?? "")), "/");
|
|
@@ -74,158 +91,172 @@ function handbookBaseUrl(): string
|
|
|
$dir = substr($dir, 0, -strlen($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__));
|
|
|
|
|
|
|
+ return $cached = $scheme . "://" . $host . $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.
|
|
|
|
|
- */
|
|
|
|
|
|
|
+/** Absolute URL of this documentation directory, without a trailing slash. */
|
|
|
function handbookSelfUrl(): string
|
|
function handbookSelfUrl(): string
|
|
|
{
|
|
{
|
|
|
return handbookBaseUrl() . "/" . handbookDirName();
|
|
return handbookBaseUrl() . "/" . handbookDirName();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function handbookEscape(string $value): string
|
|
|
|
|
|
|
+/** Absolute address of a page in the human rendering. */
|
|
|
|
|
+function handbookPageUrl(array $page): string
|
|
|
{
|
|
{
|
|
|
- return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8");
|
|
|
|
|
|
|
+ return handbookSelfUrl() . "/index.php?" . handbookPageQuery($page);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/** Stable anchor id for a section, usable in both the nav and the document. */
|
|
|
|
|
-function handbookAnchor(string $prefix, string $key): string
|
|
|
|
|
|
|
+/** Absolute address of the same page as plain Markdown. */
|
|
|
|
|
+function handbookRawUrl(array $page): string
|
|
|
{
|
|
{
|
|
|
- $slug = strtolower($key);
|
|
|
|
|
- $slug = preg_replace('/[^a-z0-9]+/', "-", $slug) ?? $slug;
|
|
|
|
|
|
|
+ return handbookSelfUrl() . "/llms.php?" . handbookPageQuery($page);
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- return $prefix . "-" . trim($slug, "-");
|
|
|
|
|
|
|
+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"]));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/** Fence language for a source file, by extension. */
|
|
|
|
|
-function handbookLanguage(string $relative): string
|
|
|
|
|
|
|
+function handbookEscape(string $value): string
|
|
|
{
|
|
{
|
|
|
- $name = basename($relative);
|
|
|
|
|
- if ($name === ".htaccess") {
|
|
|
|
|
- return "apacheconf";
|
|
|
|
|
- }
|
|
|
|
|
- if (str_ends_with($name, ".cron")) {
|
|
|
|
|
- return "text";
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8");
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- return match (strtolower(pathinfo($relative, PATHINFO_EXTENSION))) {
|
|
|
|
|
- "php" => "php",
|
|
|
|
|
- "sh", "bash" => "bash",
|
|
|
|
|
- "css" => "css",
|
|
|
|
|
- "js" => "javascript",
|
|
|
|
|
- "json" => "json",
|
|
|
|
|
- "md" => "markdown",
|
|
|
|
|
- default => "text",
|
|
|
|
|
- };
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * 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
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
- * Recursively lists every file under client-package/, relative to it.
|
|
|
|
|
|
|
+ * Every page of the handbook, in reading order, keyed by "<kind>:<key>".
|
|
|
*
|
|
*
|
|
|
- * @return string[] sorted relative paths
|
|
|
|
|
|
|
+ * 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 handbookScanPackage(): array
|
|
|
|
|
|
|
+function handbookPages(): array
|
|
|
{
|
|
{
|
|
|
- $root = realpath(HANDBOOK_PACKAGE_DIR);
|
|
|
|
|
- if ($root === false || !is_dir($root)) {
|
|
|
|
|
- return [];
|
|
|
|
|
|
|
+ static $pages = null;
|
|
|
|
|
+ if ($pages !== null) {
|
|
|
|
|
+ return $pages;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- $iterator = new RecursiveIteratorIterator(
|
|
|
|
|
- new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
|
|
|
|
|
- RecursiveIteratorIterator::SELF_FIRST,
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ $pages = [];
|
|
|
|
|
|
|
|
- $files = [];
|
|
|
|
|
- foreach ($iterator as $item) {
|
|
|
|
|
- if (!$item->isFile()) {
|
|
|
|
|
|
|
+ // The two authored chapters frame the material that comes from the package.
|
|
|
|
|
+ foreach (["00_UEBERBLICK", "50_API"] as $key) {
|
|
|
|
|
+ $path = HANDBOOK_CONTENT_DIR . "/" . $key . ".md";
|
|
|
|
|
+ if (!is_file($path)) {
|
|
|
continue;
|
|
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;
|
|
|
|
|
|
|
+ $pages["doc:" . $key] = handbookMakeDocPage($key, $path, null);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- 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) {
|
|
foreach (handbookScanPackage() as $relative) {
|
|
|
if (in_array($relative, HANDBOOK_VENDORED, true)) {
|
|
if (in_array($relative, HANDBOOK_VENDORED, true)) {
|
|
|
- $vendored[] = $relative;
|
|
|
|
|
continue;
|
|
continue;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ $path = HANDBOOK_PACKAGE_DIR . "/" . $relative;
|
|
|
|
|
+
|
|
|
if (str_ends_with($relative, ".md")) {
|
|
if (str_ends_with($relative, ".md")) {
|
|
|
- $number = preg_match('#/(\d+)_#', $relative, $match) === 1 ? $match[1] . " · " : "";
|
|
|
|
|
- $docs[$relative] = $number . handbookChapterTitle($relative);
|
|
|
|
|
|
|
+ $key = handbookDocKey($relative);
|
|
|
|
|
+ $pages["doc:" . $key] = handbookMakeDocPage($key, $path, $relative);
|
|
|
continue;
|
|
continue;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- $code[] = $relative;
|
|
|
|
|
|
|
+ $pages["code:" . $relative] = [
|
|
|
|
|
+ "kind" => "code",
|
|
|
|
|
+ "key" => $relative,
|
|
|
|
|
+ "title" => $relative,
|
|
|
|
|
+ "nav" => $relative,
|
|
|
|
|
+ "summary" => handbookCodeSummary($path),
|
|
|
|
|
+ "path" => $path,
|
|
|
|
|
+ "source" => "client-package/" . $relative,
|
|
|
|
|
+ "bytes" => (int) @filesize($path),
|
|
|
|
|
+ ];
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 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);
|
|
|
|
|
|
|
+ uasort($pages, static function (array $a, array $b): int {
|
|
|
|
|
+ return [handbookWeight($a), $a["key"]] <=> [handbookWeight($b), $b["key"]];
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- usort($code, static function (string $a, string $b): int {
|
|
|
|
|
- return [handbookCodeWeight($a), $a] <=> [handbookCodeWeight($b), $b];
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ 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),
|
|
|
|
|
+ ];
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- return ["docs" => $docs, "code" => $code, "vendored" => $vendored];
|
|
|
|
|
|
|
+/** 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 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
|
|
|
|
|
|
|
+ * 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.
|
|
* newly added file still lands somewhere sensible.
|
|
|
*/
|
|
*/
|
|
|
-function handbookCodeWeight(string $relative): int
|
|
|
|
|
|
|
+function handbookWeight(array $page): int
|
|
|
{
|
|
{
|
|
|
|
|
+ if ($page["kind"] === "doc") {
|
|
|
|
|
+ return match (true) {
|
|
|
|
|
+ $page["key"] === "00_UEBERBLICK" => 0,
|
|
|
|
|
+ $page["key"] === "README" => 1,
|
|
|
|
|
+ $page["key"] === "50_API" => 3,
|
|
|
|
|
+ default => 2,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $relative = $page["key"];
|
|
|
|
|
+
|
|
|
return match (true) {
|
|
return match (true) {
|
|
|
$relative === "manage-client/config.sample.php" => 10,
|
|
$relative === "manage-client/config.sample.php" => 10,
|
|
|
$relative === "manage-client/lib/client.php" => 11,
|
|
$relative === "manage-client/lib/client.php" => 11,
|
|
@@ -242,188 +273,227 @@ function handbookCodeWeight(string $relative): int
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/**
|
|
|
|
|
- * 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
|
|
|
|
|
|
|
+/** Third-party files that are named in the index but not served as pages. */
|
|
|
|
|
+function handbookVendoredFiles(): array
|
|
|
{
|
|
{
|
|
|
- if (preg_match('/^#\s+(.+)$/m', handbookReadDoc($relative), $match) === 1) {
|
|
|
|
|
- return trim($match[1]);
|
|
|
|
|
|
|
+ $found = [];
|
|
|
|
|
+ foreach (handbookScanPackage() as $relative) {
|
|
|
|
|
+ if (in_array($relative, HANDBOOK_VENDORED, true)) {
|
|
|
|
|
+ $found[$relative] = (int) @filesize(HANDBOOK_PACKAGE_DIR . "/" . $relative);
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- return ucwords(strtolower(str_replace("_", " ", basename($relative, ".md"))));
|
|
|
|
|
|
|
+ return $found;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function handbookReadDoc(string $relative): string
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * Recursively lists every file under client-package/, relative to it.
|
|
|
|
|
+ *
|
|
|
|
|
+ * @return string[] sorted relative paths
|
|
|
|
|
+ */
|
|
|
|
|
+function handbookScanPackage(): array
|
|
|
{
|
|
{
|
|
|
- $path = HANDBOOK_PACKAGE_DIR . "/" . $relative;
|
|
|
|
|
|
|
+ static $files = null;
|
|
|
|
|
+ if ($files !== null) {
|
|
|
|
|
+ return $files;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- return is_file($path) ? (string) file_get_contents($path) : "";
|
|
|
|
|
-}
|
|
|
|
|
|
|
+ $root = realpath(HANDBOOK_PACKAGE_DIR);
|
|
|
|
|
+ if ($root === false || !is_dir($root)) {
|
|
|
|
|
+ return $files = [];
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
-/** 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;
|
|
|
|
|
-}
|
|
|
|
|
|
|
+ $iterator = new RecursiveIteratorIterator(
|
|
|
|
|
+ new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
|
|
|
|
|
+ RecursiveIteratorIterator::SELF_FIRST,
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
-/** 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) : "";
|
|
|
|
|
|
|
+ $files = [];
|
|
|
|
|
+ foreach ($iterator as $item) {
|
|
|
|
|
+ if (!$item->isFile()) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $relative = str_replace("\\", "/", substr($item->getPathname(), strlen($root) + 1));
|
|
|
|
|
+ $name = basename($relative);
|
|
|
|
|
|
|
|
- foreach ($replacements as $key => $value) {
|
|
|
|
|
- $text = str_replace("{{" . $key . "}}", (string) $value, $text);
|
|
|
|
|
|
|
+ // 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;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- return rtrim($text) . "\n";
|
|
|
|
|
|
|
+ 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
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
- * Rewrites the relative links between the chapters so they point at the
|
|
|
|
|
- * anchors of this single page instead of at neighbouring .md files.
|
|
|
|
|
|
|
+ * 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 handbookRewriteLinks(string $markdown, array $docs): string
|
|
|
|
|
|
|
+function handbookDocTitle(string $path, string $key): string
|
|
|
{
|
|
{
|
|
|
- $targets = [];
|
|
|
|
|
- foreach ($docs as $relative => $_title) {
|
|
|
|
|
- $targets[basename($relative)] = "#" . handbookAnchor("doc", $relative);
|
|
|
|
|
|
|
+ if (preg_match('/^#\s+(.+)$/m', handbookRead($path), $match) === 1) {
|
|
|
|
|
+ return trim($match[1]);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- 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;
|
|
|
|
|
|
|
+ return ucwords(strtolower(str_replace("_", " ", preg_replace('/^\d+_/', "", $key) ?? $key)));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 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.
|
|
|
|
|
|
|
+ * 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 handbookDemoteHeadings(string $markdown): string
|
|
|
|
|
|
|
+function handbookDocSummary(string $path): string
|
|
|
{
|
|
{
|
|
|
- $lines = explode("\n", $markdown);
|
|
|
|
|
- $inFence = false;
|
|
|
|
|
-
|
|
|
|
|
- foreach ($lines as $index => $line) {
|
|
|
|
|
- if (preg_match('/^\s*(```|~~~)/', $line) === 1) {
|
|
|
|
|
- $inFence = !$inFence;
|
|
|
|
|
|
|
+ $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;
|
|
continue;
|
|
|
}
|
|
}
|
|
|
- if (!$inFence && preg_match('/^(#{1,5})\s/', $line) === 1) {
|
|
|
|
|
- $lines[$index] = "#" . $line;
|
|
|
|
|
|
|
+
|
|
|
|
|
+ if ($line === "") {
|
|
|
|
|
+ break;
|
|
|
}
|
|
}
|
|
|
|
|
+ $paragraph .= " " . $line;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- return implode("\n", $lines);
|
|
|
|
|
|
|
+ return handbookFirstSentence($paragraph);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 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.
|
|
|
|
|
|
|
+ * One line describing a source file: its first comment line. Every file in the
|
|
|
|
|
+ * package opens with one, in its own comment syntax.
|
|
|
*/
|
|
*/
|
|
|
-function handbookBuildMarkdown(bool $withAnchors): string
|
|
|
|
|
|
|
+function handbookCodeSummary(string $path): string
|
|
|
{
|
|
{
|
|
|
- $inventory = handbookInventory();
|
|
|
|
|
- $base = handbookBaseUrl();
|
|
|
|
|
- $self = handbookSelfUrl();
|
|
|
|
|
|
|
+ $handle = @fopen($path, "rb");
|
|
|
|
|
+ if ($handle === false) {
|
|
|
|
|
+ return "";
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- $anchor = static function (string $id) use ($withAnchors): string {
|
|
|
|
|
- return $withAnchors ? '<a id="' . $id . '"></a>' . "\n\n" : "";
|
|
|
|
|
- };
|
|
|
|
|
|
|
+ $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;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- $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;
|
|
|
|
|
|
|
+ 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;
|
|
|
}
|
|
}
|
|
|
- $out[] = implode("\n", $toc) . "\n";
|
|
|
|
|
|
|
|
|
|
- // ---- Part 1: documentation --------------------------------------------
|
|
|
|
|
- $out[] = $anchor("part-docs") . "# Teil 1 – Dokumentation\n";
|
|
|
|
|
|
|
+ fclose($handle);
|
|
|
|
|
|
|
|
- foreach ($inventory["docs"] as $relative => $title) {
|
|
|
|
|
- $body = handbookStripTitle(handbookReadDoc($relative));
|
|
|
|
|
- $body = handbookRewriteLinks($body, $inventory["docs"]);
|
|
|
|
|
- $body = handbookDemoteHeadings($body);
|
|
|
|
|
|
|
+ return handbookFirstSentence($summary);
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- $out[] = $anchor(handbookAnchor("doc", $relative))
|
|
|
|
|
- . "## " . $title . "\n\n"
|
|
|
|
|
- . "> Quelle: `client-package/" . $relative . "`\n\n"
|
|
|
|
|
- . rtrim($body) . "\n";
|
|
|
|
|
|
|
+function handbookFirstSentence(string $text): string
|
|
|
|
|
+{
|
|
|
|
|
+ $text = trim(preg_replace('/\s+/', " ", $text) ?? $text);
|
|
|
|
|
+ if ($text === "") {
|
|
|
|
|
+ return "";
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // ---- 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);
|
|
|
|
|
|
|
+ // 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];
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- $out[] = $anchor(handbookAnchor("code", $relative))
|
|
|
|
|
- . "## `" . $relative . "`\n\n"
|
|
|
|
|
- . $fence . handbookLanguage($relative) . "\n"
|
|
|
|
|
- . rtrim($body) . "\n" . $fence . "\n";
|
|
|
|
|
|
|
+ if (mb_strlen($text) > 180) {
|
|
|
|
|
+ $text = mb_substr($text, 0, 177) . "…";
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // ---- Inventory ---------------------------------------------------------
|
|
|
|
|
- $out[] = $anchor("part-inventory") . handbookInventoryTable($inventory);
|
|
|
|
|
|
|
+ return rtrim($text, ".");
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- return implode("\n", $out);
|
|
|
|
|
|
|
+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.
|
|
* Picks a fence long enough to survive content that itself contains one.
|
|
|
- * lib/zip.php and the docs both embed triple backticks.
|
|
|
|
|
|
|
+ * lib/zip.php and several chapters embed triple backticks.
|
|
|
*/
|
|
*/
|
|
|
function handbookFenceFor(string $body): string
|
|
function handbookFenceFor(string $body): string
|
|
|
{
|
|
{
|
|
@@ -437,36 +507,149 @@ function handbookFenceFor(string $body): string
|
|
|
return str_repeat("`", max(3, $longest + 1));
|
|
return str_repeat("`", max(3, $longest + 1));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function handbookInventoryTable(array $inventory): string
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * 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
|
|
|
{
|
|
{
|
|
|
- $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");
|
|
|
|
|
|
|
+ $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);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- return implode("\n", $rows) . "\n";
|
|
|
|
|
|
|
+ $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";
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function handbookInventoryRow(string $relative, string $role): string
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * 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
|
|
|
{
|
|
{
|
|
|
- $size = @filesize(HANDBOOK_PACKAGE_DIR . "/" . $relative);
|
|
|
|
|
|
|
+ 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 "| `" . $relative . "` | " . ($size === false ? "–" : number_format((int) $size, 0, ",", ".") . " B")
|
|
|
|
|
- . " | " . $role . " |";
|
|
|
|
|
|
|
+ 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" => "Update- und Backup-Funktion in ein Projekt einbauen",
|
|
|
|
|
+ "note" => "Der übliche Fall. Das Paket wird übernommen, nicht nachgebaut.",
|
|
|
|
|
+ "pages" => [
|
|
|
|
|
+ ["doc", "00_UEBERBLICK"],
|
|
|
|
|
+ ["doc", "01_QUICKSTART"],
|
|
|
|
|
+ ["doc", "02_INTEGRATION"],
|
|
|
|
|
+ ["doc", "03_CONFIG_REFERENCE"],
|
|
|
|
|
+ ["code", "manage-client/config.sample.php"],
|
|
|
|
|
+ ["code", "manage-client/lib/client.php"],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ [
|
|
|
|
|
+ "title" => "Nur Backups einrichten",
|
|
|
|
|
+ "note" => "Ohne Updater. Quellen festlegen, Ziele wählen, Cron eintragen.",
|
|
|
|
|
+ "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" => "Nur den Updater einrichten",
|
|
|
|
|
+ "note" => "Geschützte Pfade, Paketbau, Migrationen und 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" => "Einen eigenen Client schreiben",
|
|
|
|
|
+ "note" => "Andere Sprache oder anderes Framework. Das Protokoll ist verbindlich, "
|
|
|
|
|
+ . "die PHP-Fassung ist die Referenz.",
|
|
|
|
|
+ "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" => "Einen Fehler im laufenden Betrieb suchen",
|
|
|
|
|
+ "note" => "Meldungen, Exit-Codes und ihre Ursachen.",
|
|
|
|
|
+ "pages" => [
|
|
|
|
|
+ ["doc", "09_TROUBLESHOOTING"],
|
|
|
|
|
+ ["doc", "03_CONFIG_REFERENCE"],
|
|
|
|
|
+ ["code", "manage-client/bin/manage-client.php"],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ];
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/** The OpenAPI document, with the live server URL substituted. */
|
|
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+// 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
|
|
function handbookOpenApiJson(): string
|
|
|
{
|
|
{
|
|
|
- $path = __DIR__ . "/../openapi.json";
|
|
|
|
|
- $raw = is_file($path) ? (string) file_get_contents($path) : "{}";
|
|
|
|
|
|
|
+ $raw = handbookRead(__DIR__ . "/../openapi.json");
|
|
|
|
|
|
|
|
$spec = json_decode($raw, true);
|
|
$spec = json_decode($raw, true);
|
|
|
if (!is_array($spec)) {
|
|
if (!is_array($spec)) {
|