* after escaping, which has already neutralised any markup in the text.
*/
function handbookSummaryHtml(string $summary): string
{
return preg_replace(
'/`([^`]+)`/',
'$1',
handbookEscape($summary),
) ?? handbookEscape($summary);
}
// ---------------------------------------------------------------------------
// The page index
// ---------------------------------------------------------------------------
/**
* Every page of the handbook, in reading order, keyed by ":".
*
* 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
*/
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 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 === " 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,
);
}