Bladeren bron

reworking client webdocs

Medowar 1 maand geleden
bovenliggende
commit
92de6a4111

+ 23 - 9
README.md

@@ -82,21 +82,35 @@ Im Browser lesbar über `docs/index.php` beziehungsweise `client-package/docs/in
 
 ### Öffentliches Client-Handbuch
 
-`client-docs/` veröffentlicht den gesamten Inhalt von `client-package/` als **eine**
-Seite: alle Kapitel, den vollständigen Quellcode und die Schnittstelle als OpenAPI.
-Anders als `admin/` und `api/v1/` ist dieser Ordner ohne Anmeldung erreichbar – er ist
-dafür da, weitergegeben zu werden, damit ein Projekt eingebunden werden kann, ohne
-vorher ein ZIP zu verschicken.
+`client-docs/` veröffentlicht den Inhalt von `client-package/` über HTTP: jedes Kapitel
+und jede Quelldatei als eigene Seite, dazu die Schnittstelle als OpenAPI. Anders als
+`admin/` und `api/v1/` ist dieser Ordner ohne Anmeldung erreichbar – er ist dafür da,
+weitergegeben zu werden, damit ein Projekt eingebunden werden kann, ohne vorher ein ZIP
+zu verschicken.
 
 | Adresse | Inhalt |
 |---|---|
-| `client-docs/` | das Handbuch, zum Lesen |
-| `client-docs/llms.txt` | dasselbe als reiner Text – diese Adresse bekommt ein LLM |
+| `client-docs/` | Übersicht, von dort ein Kapitel oder eine Quelldatei |
+| `client-docs/index.php?doc=01_QUICKSTART` | ein Kapitel |
+| `client-docs/index.php?code=manage-client/lib/updater.php` | eine Quelldatei |
 | `client-docs/api.php` | Protokoll v1 in Swagger UI |
 | `client-docs/openapi.php` | das OpenAPI-Dokument allein, für Codegeneratoren |
 
-Der Inhalt wird bei jedem Aufruf aus `client-package/` gelesen; es gibt nichts zu
-bauen und nichts nachzuziehen. `manage-client/config.php` ist von der Veröffentlichung
+Für Programme gibt es dieselben Seiten als reines Markdown unter `llms.php`, mit
+`llms.php?doc=…` und `llms.php?code=…` daneben. `client-docs/llms.php` ist das
+Verzeichnis: es nennt jede Seite mit einem Satz Beschreibung und ihrer Größe und führt
+für die üblichen Aufgaben – Client einbauen, nur Backups, nur Updater, eigenen Client
+schreiben, Fehlersuche – die kurze Liste der Seiten auf, die dafür reicht. Das ist der
+Zweck der Aufteilung: nur laden, was gebraucht wird. Diese Adresse bekommt ein LLM.
+Apache beantwortet zusätzlich `llms.txt`; kanonisch ist `llms.php`, weil das ohne
+`mod_rewrite` auskommt.
+
+Die Seiten für Menschen rendern ihr Markdown im Browser und tragen im Quelltext einen
+Kommentar und ein unsichtbares Element, die auf die Markdown-Fassung derselben Seite
+verweisen – ein Programm, das versehentlich dort landet, findet den Weg.
+
+Der Inhalt wird bei jedem Aufruf aus `client-package/` gelesen; es gibt nichts zu bauen
+und nichts nachzuziehen. `manage-client/config.php` ist von der Veröffentlichung
 ausgenommen, damit ein lokal angelegtes Token nicht öffentlich wird.
 
 marked und Swagger UI liegen unter `client-docs/assets/` im Repository. Es wird kein

+ 3 - 2
client-docs/.htaccess

@@ -4,8 +4,9 @@ Options -Indexes
 <IfModule mod_rewrite.c>
     RewriteEngine On
 
-    # The address handed to agents. A .txt extension states the content type
-    # before the request is made; the response comes from llms.php.
+    # Convenience alias for the index that agents read. llms.php is the
+    # canonical address and everything links to that, so this rule is optional:
+    # a server without mod_rewrite loses nothing but the shorter name.
     RewriteRule ^llms\.txt$ llms.php [L]
 </IfModule>
 

+ 22 - 3
client-docs/api.php

@@ -13,6 +13,7 @@ declare(strict_types=1);
 require_once __DIR__ . "/inc/handbook.php";
 
 $base = handbookBaseUrl();
+$self = handbookSelfUrl();
 ?>
 <!DOCTYPE html>
 <html lang="de">
@@ -22,15 +23,33 @@ $base = handbookBaseUrl();
     <title>API-Referenz – Manage Client</title>
     <link rel="stylesheet" href="assets/docs.css">
     <link rel="stylesheet" href="assets/swagger-ui.css">
+    <link rel="alternate" type="application/json" href="openapi.php"
+          title="Dieselbe Beschreibung als OpenAPI-Dokument">
 </head>
 <body>
+<!--
+    Hinweis für LLMs und andere Programme / note for LLMs and other programs:
+
+    Diese Seite ist Swagger UI und baut sich erst im Browser auf; im HTML steht
+    nichts Verwertbares. Dieselbe Beschreibung als OpenAPI 3.1:
+
+        <?php echo $self . "/openapi.php\n"; ?>
+
+    Verzeichnis aller Seiten der Dokumentation, mit Kurzbeschreibung und Größe:
+
+        <?php echo $self . "/llms.php\n"; ?>
+-->
+<p class="llm-only">
+    Für LLMs: die Schnittstelle als OpenAPI-Dokument unter <?php echo handbookEscape($self); ?>/openapi.php –
+    Verzeichnis aller Seiten unter <?php echo handbookEscape($self); ?>/llms.php
+</p>
 <header class="docs-header">
     <div class="docs-header-inner">
-        <a class="docs-brand" href="index.php">Manage Client – Handbuch</a>
+        <a class="docs-brand" href="index.php">Manage Client</a>
         <nav class="docs-header-links">
-            <a href="index.php">Handbuch</a>
-            <a href="llms.txt">Reiner Text</a>
+            <a href="index.php">Dokumentation</a>
             <a href="openapi.php">OpenAPI</a>
+            <a href="llms.php">Für LLMs</a>
         </nav>
     </div>
 </header>

+ 84 - 54
client-docs/assets/docs.css

@@ -1,7 +1,7 @@
 /*
  * Public client handbook. Deliberately close to client-package/docs/assets/docs.css
- * so both viewers look like the same product; the additions here are the ones a
- * single long page needs: a grouped sidebar and anchor offsets.
+ * so all three viewers look like the same product; the additions here are the
+ * index lists, the source note and the hint that only machines read.
  */
 
 :root {
@@ -29,13 +29,22 @@ body {
     color: var(--docs-text);
 }
 
+/*
+ * The pointer to the Markdown rendering. It is in the markup, not in a comment
+ * alone, so that a program extracting text from this page finds it; a reader
+ * never sees it.
+ */
+.llm-only {
+    display: none;
+}
+
 .docs-header {
     background: var(--docs-accent);
     color: #fff;
 }
 
 .docs-header-inner {
-    max-width: 78rem;
+    max-width: 72rem;
     margin: 0 auto;
     padding: 0.75rem 1.25rem;
     display: flex;
@@ -67,11 +76,11 @@ body {
 }
 
 .docs-layout {
-    max-width: 78rem;
+    max-width: 72rem;
     margin: 0 auto;
     padding: 1.25rem;
     display: grid;
-    grid-template-columns: minmax(13rem, 18rem) 1fr;
+    grid-template-columns: minmax(13rem, 17rem) 1fr;
     gap: 1.5rem;
     align-items: start;
 }
@@ -99,7 +108,7 @@ body {
 }
 
 .docs-nav-title {
-    margin: 1rem 0 0.4rem;
+    margin: 1.1rem 0 0.4rem;
     font-size: 0.7rem;
     text-transform: uppercase;
     letter-spacing: 0.06em;
@@ -116,13 +125,17 @@ body {
     list-style: none;
 }
 
+.docs-nav li + li {
+    margin-top: 0.15rem;
+}
+
 .docs-nav a {
     display: block;
-    padding: 0.25rem 0.45rem;
+    padding: 0.3rem 0.45rem;
     border-radius: 0.25rem;
     color: var(--docs-accent);
     text-decoration: none;
-    font-size: 0.87rem;
+    font-size: 0.88rem;
     overflow-wrap: anywhere;
 }
 
@@ -130,6 +143,11 @@ body {
     background: var(--docs-code-bg);
 }
 
+.docs-nav a[aria-current="page"] {
+    background: var(--docs-accent);
+    color: #fff;
+}
+
 .docs-nav-code a {
     font-family: ui-monospace, "Cascadia Code", "Source Code Pro", monospace;
     font-size: 0.78rem;
@@ -143,11 +161,61 @@ body {
     min-width: 0;
 }
 
-/* The nav jumps to anchors that sit right above a heading. */
-.markdown-body a[id] {
-    display: block;
-    position: relative;
-    top: -0.75rem;
+.docs-main > h1:first-child {
+    margin-top: 0;
+}
+
+.docs-main h2 {
+    margin-top: 2rem;
+    padding-bottom: 0.2em;
+    border-bottom: 1px solid var(--docs-border);
+}
+
+/* Index: page name plus the one line that says what is on it. */
+.docs-index-list {
+    margin: 0.75rem 0 0;
+}
+
+.docs-index-list dt {
+    margin-top: 0.9rem;
+    display: flex;
+    align-items: baseline;
+    gap: 0.6rem;
+}
+
+.docs-index-list dt a {
+    color: var(--docs-accent);
+    font-weight: 600;
+}
+
+.docs-index-list dd {
+    margin: 0.1rem 0 0;
+    color: var(--docs-muted);
+    font-size: 0.92rem;
+}
+
+.docs-index-code dt a {
+    font-family: ui-monospace, "Cascadia Code", "Source Code Pro", monospace;
+    font-size: 0.88rem;
+    font-weight: 500;
+}
+
+.docs-size {
+    color: var(--docs-muted);
+    font-size: 0.78rem;
+    white-space: nowrap;
+}
+
+.docs-source-note {
+    margin: 0 0 1.5rem;
+    padding-bottom: 0.75rem;
+    border-bottom: 1px solid var(--docs-border);
+    color: var(--docs-muted);
+    font-size: 0.85rem;
+}
+
+.docs-source-note a {
+    color: var(--docs-accent);
 }
 
 .markdown-body h1,
@@ -157,12 +225,10 @@ body {
     line-height: 1.25;
     margin-top: 1.6em;
     margin-bottom: 0.5em;
-    scroll-margin-top: 1rem;
 }
 
-.markdown-body h1 {
-    padding-bottom: 0.3em;
-    border-bottom: 2px solid var(--docs-border);
+.markdown-body h1:first-child {
+    margin-top: 0;
 }
 
 .markdown-body h2 {
@@ -170,10 +236,6 @@ body {
     border-bottom: 1px solid var(--docs-border);
 }
 
-.markdown-body h1:first-child {
-    margin-top: 0;
-}
-
 .markdown-body p,
 .markdown-body ul,
 .markdown-body ol,
@@ -240,22 +302,6 @@ body {
     margin: 2em 0;
 }
 
-/*
- * The Markdown source is in the page as plain text so that the page is readable
- * without JavaScript and so that tools which extract text from HTML get the
- * whole document. With JavaScript on, marked replaces it.
- */
-.handbook-source {
-    background: none;
-    padding: 0;
-    white-space: pre-wrap;
-    font-size: 0.85rem;
-}
-
-html.has-js .handbook-source {
-    display: none;
-}
-
 .docs-note {
     background: var(--docs-code-bg);
     border-left: 4px solid var(--docs-accent);
@@ -268,21 +314,9 @@ html.has-js .handbook-source {
     margin: 0.25rem 0;
 }
 
-.docs-footer {
-    max-width: 78rem;
-    margin: 0 auto;
-    padding: 0 1.25rem 2rem;
-    color: var(--docs-muted);
-    font-size: 0.85rem;
-}
-
-.docs-footer a {
-    color: var(--docs-accent);
-}
-
 /* Swagger UI page: same frame, the widget brings its own styling. */
 .swagger-frame {
-    max-width: 78rem;
+    max-width: 72rem;
     margin: 0 auto;
     padding: 1.25rem;
 }
@@ -290,7 +324,3 @@ html.has-js .handbook-source {
 .swagger-frame .swagger-ui .topbar {
     display: none;
 }
-
-.swagger-frame > .docs-note {
-    margin-bottom: 0;
-}

+ 50 - 74
client-docs/content/00_UEBERBLICK.md

@@ -1,70 +1,17 @@
-# Manage Client – Handbuch
-
-Update- und Backup-Funktionalität für eigenständige PHP-Projekte: Dokumentation und
-vollständiger Quellcode auf einer Seite.
-
-Diese Seite ist die veröffentlichte Fassung des Ordners `client-package/` aus dem
-Manage-Repository. Sie enthält **{{DOC_COUNT}} Dokumente** und **{{CODE_COUNT}} Quelldateien**,
-jeweils vollständig und unverändert, dazu die OpenAPI-Spezifikation der
-Server-Schnittstelle. Alles wird bei jedem Aufruf frisch aus dem Repository gelesen;
-was hier steht, ist der aktuelle Stand.
-
-| | |
-|---|---|
-| Diese Seite | <{{SELF_URL}}/> |
-| Reiner Text für Agenten | <{{SELF_URL}}/llms.txt> |
-| API-Referenz (Swagger UI) | <{{SELF_URL}}/api.php> |
-| OpenAPI-Dokument | <{{SELF_URL}}/openapi.php> |
-| Manage-Server | <{{BASE_URL}}/> |
-
-## Hinweis für LLM-Agenten
-
-Wenn Sie ein Agent sind und den Auftrag haben, Update- und Backup-Funktionalität in ein
-Projekt einzubauen: Diese Seite ist die vollständige Vorlage. Sie brauchen keine weitere
-Quelle.
-
-**Empfohlener Weg – das Paket übernehmen, nicht nachbauen.** Der Client ist bewusst so
-geschnitten, dass er als Ordner in ein fremdes Projekt kopiert wird. Er hat keine
-Abhängigkeiten, keinen Build-Schritt und keine Annahmen über das Zielprojekt außer PHP.
-Nachbauen kostet Zeit und verliert Details, die hier bereits gelöst sind – Prüfsummen,
-geschützte Pfade, Sperrdateien, Wiederanlauf nach Teilfehlern.
-
-Vorgehen:
-
-1. Legen Sie `manage-client/` im Zielprojekt an und übernehmen Sie alle Dateien aus
-   Teil 3 dieses Dokuments, die mit `manage-client/` beginnen – Pfade und Inhalte
-   unverändert.
-2. Kopieren Sie `manage-client/config.sample.php` nach `manage-client/config.php` und
-   setzen Sie `MANAGE_SERVER_URL`, `MANAGE_INSTANCE`, `MANAGE_TOKEN` sowie
-   `MANAGE_BACKUP_SOURCES`. Die Werte für die ersten drei kommen aus dem Manage-Server;
-   fragen Sie danach, statt sie zu erfinden.
-3. Prüfen Sie `MANAGE_APP_ROOT`, `MANAGE_VERSION_FILE` und `MANAGE_VERSION_CONSTANT`
-   gegen den tatsächlichen Aufbau des Zielprojekts. Das sind die einzigen Stellen, an
-   denen der Client etwas über das Projekt wissen muss.
-4. Ergänzen Sie `MANAGE_UPDATE_PROTECTED_PATHS` um alles, was ein Update niemals
-   überschreiben darf, und `MANAGE_UPDATE_SANITY_PATHS` um eine Datei, die in jedem
-   gültigen Release vorkommt.
-5. Binden Sie einen der drei Einstiegspunkte ein: Kommandozeile für Cron, `ui/panel.php`
-   für den Adminbereich, oder die Funktions-API für eigene Seiten. Siehe das Kapitel
-   *Integration*.
-6. Prüfen Sie mit `php manage-client/bin/manage-client.php status`.
-
-**Wenn Sie stattdessen neu implementieren müssen** – andere Sprache, anderes Framework –
-ist das Kapitel *Protokoll* zusammen mit Teil 2 die verbindliche Beschreibung der
-Schnittstelle. Halten Sie sich an drei Punkte, sonst ist die Implementierung unsicher:
-SHA-256 und Größe jedes Pakets gegen das Manifest prüfen und bei Abweichung abbrechen;
-beim Entpacken jeden Eintrag gegen Pfadausbruch prüfen; geschützte Pfade beim Ausrollen
-auslassen.
-
-**Was Sie nicht tun sollten:** die Konfigurationsdatei mit einem erfundenen Token
-ausliefern, das Backup vor dem Update automatisch einbauen (das ist bewusst eine
-sichtbare Zeile im Projekt), oder eine Wiederherstellungsfunktion versprechen – es gibt
-keine, siehe unten.
+# Überblick
+
+Der Manage-Client bringt zwei Dinge in ein eigenständiges PHP-Projekt: eine Möglichkeit,
+neue Versionen einzuspielen, und regelmäßige Sicherungen der Betriebsdaten.
+
+Dies ist die veröffentlichte Fassung des Ordners `client-package/` aus dem
+Manage-Repository: {{DOC_COUNT}} Kapitel und {{CODE_COUNT}} Quelldateien, jede auf einer
+eigenen Seite, dazu die Schnittstelle zum Server als OpenAPI. Alles wird bei jedem Aufruf
+frisch aus dem Repository gelesen; was hier steht, ist der aktuelle Stand.
 
 ## Was der Client tut
 
-Drei Vorgänge, alle über dieselben Funktionen, egal ob sie aus der Kommandozeile, aus
-der mitgelieferten Oberfläche oder direkt aus dem Projekt ausgelöst werden.
+Drei Vorgänge, alle über dieselben Funktionen, egal ob sie aus der Kommandozeile, aus der
+mitgelieferten Oberfläche oder direkt aus dem Projekt ausgelöst werden.
 
 **Update.** `manageUpdateCheck()` holt das Manifest vom Server und vergleicht die
 Versionen. `manageUpdateApply()` lädt das Paket, prüft Größe und SHA-256, entpackt es in
@@ -85,6 +32,43 @@ Archiv nicht ungültig.
 offene Migrationen und den Zeitpunkt des letzten Backups. Die Antwort enthält nebenbei
 die Update-Information.
 
+## Einbinden statt nachbauen
+
+Der Client ist so geschnitten, dass er als Ordner in ein fremdes Projekt kopiert wird. Er
+hat keine Abhängigkeiten, keinen Build-Schritt und keine Annahmen über das Zielprojekt
+außer PHP. Nachbauen kostet Zeit und verliert Details, die hier bereits gelöst sind –
+Prüfsummen, geschützte Pfade, Sperrdateien, Wiederanlauf nach Teilfehlern.
+
+1. `manage-client/` aus dem Quellcode-Teil in das Zielprojekt übernehmen, Pfade und
+   Inhalte unverändert.
+2. `manage-client/config.sample.php` nach `manage-client/config.php` kopieren und
+   `MANAGE_SERVER_URL`, `MANAGE_INSTANCE`, `MANAGE_TOKEN` sowie `MANAGE_BACKUP_SOURCES`
+   setzen. Die ersten drei Werte kommen aus dem Manage-Server.
+3. `MANAGE_APP_ROOT`, `MANAGE_VERSION_FILE` und `MANAGE_VERSION_CONSTANT` gegen den
+   tatsächlichen Aufbau des Zielprojekts prüfen. Das sind die einzigen Stellen, an denen
+   der Client etwas über das Projekt wissen muss.
+4. `MANAGE_UPDATE_PROTECTED_PATHS` um alles ergänzen, was ein Update niemals überschreiben
+   darf, und `MANAGE_UPDATE_SANITY_PATHS` um eine Datei, die in jedem gültigen Release
+   vorkommt.
+5. Einen der drei Einstiegspunkte einbinden: Kommandozeile für Cron, `ui/panel.php` für
+   den Adminbereich, oder die Funktions-API für eigene Seiten. Siehe
+   [02_INTEGRATION.md](02_INTEGRATION.md).
+6. Mit `php manage-client/bin/manage-client.php status` prüfen.
+
+Ausführlich in [01_QUICKSTART.md](01_QUICKSTART.md).
+
+## Einen eigenen Client schreiben
+
+Wer in einer anderen Sprache implementieren muss, findet in
+[08_PROTOCOL.md](08_PROTOCOL.md) und im OpenAPI-Dokument die verbindliche Beschreibung
+der Schnittstelle. Drei Punkte entscheiden darüber, ob die Implementierung sicher ist:
+
+- SHA-256 **und** Größe jedes Pakets gegen das Manifest prüfen und bei Abweichung
+  abbrechen, bevor irgendetwas entpackt wird.
+- Beim Entpacken jeden Eintrag gegen Pfadausbruch prüfen (`..`, absolute Pfade).
+- Beim Ausrollen die geschützten Pfade auslassen, sonst überschreibt das erste Update die
+  Konfiguration und die Betriebsdaten.
+
 ## Grenzen
 
 Diese Punkte fehlen bewusst. Wer sie erwartet, baut auf einer falschen Annahme auf.
@@ -96,19 +80,11 @@ Diese Punkte fehlen bewusst. Wer sie erwartet, baut auf einer falschen Annahme a
 - **Keine Signatur der Pakete.** Prüfsumme und Paket kommen vom selben Server; die
   Absicherung ist TLS plus Token.
 - **Keine Wartungsseite.** Das Ausrollen überschreibt Dateien im laufenden Betrieb.
+- **Kein Backup vor dem Update.** Das ist bewusst eine sichtbare Zeile im Projekt und
+  nicht eingebaut, siehe [04_FUNCTION_API.md](04_FUNCTION_API.md).
 
 ## Voraussetzungen
 
 PHP 8.0 oder neuer, die Erweiterung `zip` für Updates, Schreibrechte auf dem
 Datenverzeichnis des Projekts. Kein Composer, kein Build-Schritt, keine externen
 Bibliotheken.
-
-## Aufbau dieses Dokuments
-
-**Teil 1** ist die Dokumentation des Pakets in Lesereihenfolge, beginnend mit dem
-README und dem Quickstart. **Teil 2** beschreibt die HTTP-Schnittstelle zum
-Manage-Server und enthält die vollständige OpenAPI-Spezifikation. **Teil 3** ist der
-gesamte Quellcode. Am Ende steht eine Dateiübersicht.
-
-Querverweise zwischen den Kapiteln zeigen innerhalb dieser Seite auf den jeweiligen
-Abschnitt.

+ 24 - 13
client-docs/content/50_API.md

@@ -1,14 +1,19 @@
-# Teil 2 – HTTP-API
+# HTTP-API
 
 Die Schnittstelle zwischen Client und Manage-Server, Protokoll v1. Wer den
-mitgelieferten Client übernimmt, braucht diesen Teil nicht – er ist für eigene Clients,
-für Debugging und für die Fehlersuche mit `curl` gedacht. Die ausführliche Beschreibung
-mit Beispielaufrufen steht im Kapitel *Protokoll* in Teil 1.
+mitgelieferten Client übernimmt, braucht dieses Kapitel nicht – es ist für eigene
+Clients, für Debugging und für die Fehlersuche mit `curl` gedacht.
 
 Basis-URL dieser Installation: `{{BASE_URL}}/api/v1`
 
-Zum Ausprobieren im Browser, mit Swagger UI: <{{SELF_URL}}/api.php>.
-Das Dokument allein, für Codegeneratoren: <{{SELF_URL}}/openapi.php>.
+## Die vier Endpunkte
+
+| Methode | Pfad | Zweck |
+|---|---|---|
+| `GET` | `/manifest.php` | Welches Release soll installiert werden |
+| `GET` | `/package.php?version=vX.Y.Z` | Das Release-ZIP |
+| `POST` | `/backup.php` | Backup-Archiv hochladen (`multipart/form-data`) |
+| `POST` | `/heartbeat.php` | Status melden, Update-Information erhalten |
 
 Jede Anfrage trägt zwei Header:
 
@@ -17,11 +22,17 @@ X-Manage-Instance: meinprojekt-prod
 X-Manage-Token:    e4032c4dc51e9100…
 ```
 
-Vier Endpunkte:
+Es gibt keine Sitzung, kein Cookie und kein gemeinsames Passwort. Der Server speichert
+nur den SHA-256-Hash des Tokens und vergleicht in konstanter Zeit.
 
-| Methode | Pfad | Zweck |
-|---|---|---|
-| `GET` | `/manifest.php` | Welches Release soll installiert werden |
-| `GET` | `/package.php?version=vX.Y.Z` | Das Release-ZIP |
-| `POST` | `/backup.php` | Backup-Archiv hochladen (`multipart/form-data`) |
-| `POST` | `/heartbeat.php` | Status melden, Update-Information erhalten |
+## Wo was steht
+
+- **Beschreibung mit Beispielaufrufen**, Fehlerfällen und den Regeln, an die sich ein
+  eigener Client halten muss: [08_PROTOCOL.md](08_PROTOCOL.md).
+- **Maschinenlesbar**, als OpenAPI 3.1: <{{SELF_URL}}/openapi.php>. Enthält Schemata für
+  alle Antworten, die Fehlercodes und die Muster für Version, Dateiname und Prüfsumme.
+- **Zum Nachschlagen und Ausprobieren** im Browser:
+  [Swagger UI]({{SELF_URL}}/api.php). „Try it out“ spricht diese Installation an und
+  braucht eine gültige Instanz samt Token.
+- **Als Referenzimplementierung**: `manage-client/lib/client.php` baut die Anfragen,
+  `lib/updater.php` und `lib/backup.php` benutzen sie.

+ 26 - 0
client-docs/content/llms-intro.md

@@ -0,0 +1,26 @@
+# Manage Client – Verzeichnis
+
+Update- und Backup-Funktionalität für eigenständige PHP-Projekte: ein PHP-Client, der
+Releases von einem zentralen Server holt, prüft und einspielt, Backups der Betriebsdaten
+erstellt und hochlädt und den Zustand der Installation meldet. Keine Abhängigkeiten, kein
+Composer, kein Build-Schritt.
+
+Dies ist das Verzeichnis für Programme. Jede Seite ist reines Markdown und einzeln
+abrufbar. Insgesamt {{PAGE_COUNT}} Seiten, zusammen {{TOTAL_SIZE}} – **laden Sie nicht
+alles.** Der Abschnitt *Wofür welche Seiten* nennt für die üblichen Aufgaben die kurze
+Liste, die dafür reicht; die Größenangabe hinter jeder Seite hilft beim Abschätzen.
+
+Adressen:
+
+- Verzeichnis (diese Seite): {{SELF_URL}}/llms.php (auf Apache auch {{SELF_URL}}/llms.txt)
+- Eine Seite: {{SELF_URL}}/llms.php?doc=KEY beziehungsweise {{SELF_URL}}/llms.php?code=PFAD
+- Dieselben Inhalte für Menschen: {{SELF_URL}}/index.php
+- Der Manage-Server, den der Client anspricht: {{BASE_URL}}
+
+Die Quellcode-Seiten geben den jeweiligen Dateiinhalt vollständig und unverändert wieder,
+in einem Codeblock unter dem Pfad, unter dem die Datei im Projekt liegen muss. Sie sind
+zum Übernehmen gedacht, nicht als Vorlage zum Umschreiben.
+
+Nicht enthalten: `manage-client/config.php`. Diese Datei trägt das Zugangstoken einer
+konkreten Instanz und wird nicht veröffentlicht. Die Vorlage dafür ist
+`manage-client/config.sample.php`.

+ 448 - 265
client-docs/inc/handbook.php

@@ -3,16 +3,17 @@
 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";
@@ -22,23 +23,33 @@ 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
+ * 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: listed in the inventory, never reproduced. */
+/** 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.
  *
@@ -48,6 +59,11 @@ function handbookRepoRoot(): string
  */
 function handbookBaseUrl(): string
 {
+    static $cached = null;
+    if ($cached !== null) {
+        return $cached;
+    }
+
     $configured = "";
     $config = handbookRepoRoot() . "/config.php";
     if (is_file($config)) {
@@ -60,12 +76,13 @@ function handbookBaseUrl(): string
     }
 
     if ($configured !== "" && preg_match('#^https?://#i', $configured) === 1) {
-        return rtrim($configured, "/");
+        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"] ?? "")), "/");
@@ -74,158 +91,172 @@ function handbookBaseUrl(): string
         $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
 {
     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;
         }
-
-        $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) {
         if (in_array($relative, HANDBOOK_VENDORED, true)) {
-            $vendored[] = $relative;
             continue;
         }
 
+        $path = HANDBOOK_PACKAGE_DIR . "/" . $relative;
+
         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;
         }
 
-        $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.
  */
-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) {
         $relative === "manage-client/config.sample.php" => 10,
         $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;
         }
-        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.
- * lib/zip.php and the docs both embed triple backticks.
+ * lib/zip.php and several chapters embed triple backticks.
  */
 function handbookFenceFor(string $body): string
 {
@@ -437,36 +507,149 @@ function handbookFenceFor(string $body): string
     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
 {
-    $path = __DIR__ . "/../openapi.json";
-    $raw = is_file($path) ? (string) file_get_contents($path) : "{}";
+    $raw = handbookRead(__DIR__ . "/../openapi.json");
 
     $spec = json_decode($raw, true);
     if (!is_array($spec)) {

+ 161 - 71
client-docs/index.php

@@ -3,121 +3,211 @@
 declare(strict_types=1);
 
 /**
- * Public client handbook: every document and every source file of
- * client-package/ on one page.
+ * Public client handbook (Markdown -> HTML via the vendored marked.js).
  *
- * Rendering happens in the browser with the vendored marked.js, exactly like
- * the two internal viewers. The Markdown source sits in the page as plain text
- * rather than in a script tag, so the page also works without JavaScript and so
- * that a tool extracting text from the HTML receives the whole handbook.
+ * Same shape as the two viewers inside the repository: an index plus one page
+ * per document, reached as index.php?doc=KEY. In addition every source file of
+ * the client package has its own page, index.php?code=PATH.
  *
- * A client that does not ask for HTML (curl, most agent fetchers) gets the
- * plain-text rendering instead; see llms.php.
+ * Self-contained: no build step and no external requests. The pages are read
+ * from client-package/ on each request, so they always match the repository.
+ *
+ * Agents do not read this rendering - the head of every page points at the
+ * Markdown twin under llms.php; see the hint below.
  */
 
 require_once __DIR__ . "/inc/handbook.php";
 
-$accept = (string) ($_SERVER["HTTP_ACCEPT"] ?? "");
-$wantsText = isset($_GET["format"]) && $_GET["format"] === "md";
+$pages = handbookPages();
+$docs = handbookPagesOfKind("doc");
+$code = handbookPagesOfKind("code");
+
+$requestedDoc = isset($_GET["doc"]) ? (string) $_GET["doc"] : "";
+$requestedCode = isset($_GET["code"]) ? (string) $_GET["code"] : "";
+
+$page = null;
+$markdown = null;
+$pageTitle = "Übersicht";
+$notFound = false;
 
-// No Accept header at all, or one that never mentions HTML: not a browser.
-if (!$wantsText && !str_contains(strtolower($accept), "text/html")) {
-    $wantsText = true;
+if ($requestedDoc !== "" || $requestedCode !== "") {
+    $page = $requestedDoc !== ""
+        ? handbookPage("doc", $requestedDoc)
+        : handbookPage("code", $requestedCode);
+
+    if ($page === null) {
+        http_response_code(404);
+        $pageTitle = "Nicht gefunden";
+        $notFound = true;
+    } else {
+        $markdown = handbookPageMarkdown($page);
+        $pageTitle = $page["title"];
+    }
 }
 
-if ($wantsText) {
-    require __DIR__ . "/llms.php";
-    return;
+// Works both when client-docs/ is a subdirectory and when it is served as the
+// web root (php -S localhost:8080 -t client-docs), where dirname() yields "/".
+$baseHref = rtrim(str_replace("\\", "/", dirname($_SERVER["SCRIPT_NAME"] ?? "")), "/") . "/";
+if ($baseHref === "") {
+    $baseHref = "/";
 }
 
-$inventory = handbookInventory();
-$markdown = handbookBuildMarkdown(true);
-$self = handbookSelfUrl();
+$indexUrl = handbookSelfUrl() . "/llms.php";
+$rawUrl = $page !== null ? handbookRawUrl($page) : $indexUrl;
 ?>
 <!DOCTYPE html>
 <html lang="de">
 <head>
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
-    <title>Manage Client – Handbuch</title>
-    <meta name="description" content="Update- und Backup-Client für PHP-Projekte: vollständige Dokumentation, Quellcode und HTTP-Schnittstelle auf einer Seite.">
-    <link rel="stylesheet" href="assets/docs.css">
-    <link rel="alternate" type="text/markdown" href="llms.txt" title="Handbuch als reiner Text">
-    <script>document.documentElement.className += ' has-js';</script>
+    <title><?php echo handbookEscape($pageTitle); ?> – Manage Client</title>
+    <link rel="stylesheet" href="<?php echo handbookEscape($baseHref); ?>assets/docs.css">
+    <link rel="alternate" type="text/markdown" href="<?php echo handbookEscape($rawUrl); ?>"
+          title="Diese Seite als Markdown">
 </head>
 <body>
+<!--
+    Hinweis für LLMs und andere Programme / note for LLMs and other programs:
+
+    Diese Seite rendert ihren Inhalt erst im Browser. Der Text steht hier daher
+    nicht im HTML. Dieselbe Seite als reines Markdown, ohne Auszeichnung:
+
+        <?php echo $rawUrl . "\n"; ?>
+
+    Verzeichnis aller Seiten, mit Kurzbeschreibung und Größe, um gezielt nur das
+    Nötige zu laden:
+
+        <?php echo $indexUrl . "\n"; ?>
+-->
+<p class="llm-only">
+    Für LLMs: diese Seite als Markdown unter <?php echo handbookEscape($rawUrl); ?> –
+    Verzeichnis aller Seiten unter <?php echo handbookEscape($indexUrl); ?>
+</p>
 <header class="docs-header">
     <div class="docs-header-inner">
-        <a class="docs-brand" href="index.php">Manage Client – Handbuch</a>
+        <a class="docs-brand" href="<?php echo handbookEscape($baseHref); ?>index.php">Manage Client</a>
         <nav class="docs-header-links">
-            <a href="llms.txt">Reiner Text</a>
-            <a href="api.php">API-Referenz</a>
-            <a href="openapi.php">OpenAPI</a>
+            <a href="<?php echo handbookEscape($baseHref); ?>api.php">API-Referenz</a>
+            <a href="<?php echo handbookEscape($baseHref); ?>llms.php">Für LLMs</a>
         </nav>
     </div>
 </header>
 <div class="docs-layout">
-    <nav class="docs-nav" aria-label="Inhalt">
-        <p class="docs-nav-title">Einstieg</p>
-        <ul>
-            <li><a href="#part-intro">Überblick</a></li>
-            <li><a href="#part-api">HTTP-API</a></li>
-            <li><a href="#part-inventory">Dateiübersicht</a></li>
-        </ul>
-
+    <nav class="docs-nav" aria-label="Dokumentation">
         <p class="docs-nav-title">Dokumentation</p>
         <ul>
-            <?php foreach ($inventory["docs"] as $relative => $title): ?>
+            <?php foreach ($docs as $entry): ?>
                 <li>
-                    <a href="#<?php echo handbookEscape(handbookAnchor("doc", $relative)); ?>">
-                        <?php echo handbookEscape($title); ?>
+                    <a href="<?php echo handbookEscape($baseHref); ?>index.php?<?php echo handbookEscape(handbookPageQuery($entry)); ?>"
+                       <?php echo handbookIsSamePage($page, $entry) ? 'aria-current="page"' : ""; ?>>
+                        <?php echo handbookEscape($entry["nav"]); ?>
                     </a>
                 </li>
             <?php endforeach; ?>
         </ul>
 
+        <p class="docs-nav-title">Schnittstelle</p>
+        <ul>
+            <li><a href="<?php echo handbookEscape($baseHref); ?>api.php">Swagger UI</a></li>
+            <li><a href="<?php echo handbookEscape($baseHref); ?>openapi.php">OpenAPI-Dokument</a></li>
+        </ul>
+
         <p class="docs-nav-title">Quellcode</p>
         <ul class="docs-nav-code">
-            <?php foreach ($inventory["code"] as $relative): ?>
+            <?php foreach ($code as $entry): ?>
                 <li>
-                    <a href="#<?php echo handbookEscape(handbookAnchor("code", $relative)); ?>">
-                        <?php echo handbookEscape($relative); ?>
+                    <a href="<?php echo handbookEscape($baseHref); ?>index.php?<?php echo handbookEscape(handbookPageQuery($entry)); ?>"
+                       <?php echo handbookIsSamePage($page, $entry) ? 'aria-current="page"' : ""; ?>>
+                        <?php echo handbookEscape($entry["nav"]); ?>
                     </a>
                 </li>
             <?php endforeach; ?>
         </ul>
     </nav>
     <main class="docs-main">
-        <div class="docs-note">
-            <p><strong>Sie sind ein LLM oder ein Skript?</strong> Dieselbe Seite als reiner
-                Text, ohne Auszeichnung: <a href="llms.txt"><?php echo handbookEscape($self); ?>/llms.txt</a></p>
-            <p>Aufrufe ohne <code>Accept: text/html</code> erhalten diese Fassung automatisch.</p>
-        </div>
-        <article id="handbook" class="markdown-body">
-            <pre class="handbook-source" id="handbook-source"><?php echo handbookEscape($markdown); ?></pre>
-        </article>
+        <?php if ($page === null && !$notFound): ?>
+            <h1>Manage Client</h1>
+            <p>Update- und Backup-Funktionalität für eigenständige PHP-Projekte: die
+                vollständige Dokumentation des Client-Pakets, sein gesamter Quellcode und
+                die Schnittstelle zum Manage-Server.</p>
+            <p>Jedes Kapitel und jede Quelldatei hat eine eigene Seite; die Liste steht links
+                und noch einmal hier darunter, jeweils mit einem Satz dazu, was darauf steht.</p>
+            <p>Für Programme gibt es dieselben Seiten als reines Markdown:
+                <a href="<?php echo handbookEscape($baseHref); ?>llms.php">llms.php</a> ist das
+                Verzeichnis dafür und nennt zusätzlich, welche Seiten für welche Aufgabe
+                gebraucht werden.</p>
+
+            <h2>Dokumentation</h2>
+            <p>In empfohlener Lesereihenfolge.</p>
+            <dl class="docs-index-list">
+                <?php foreach ($docs as $entry): ?>
+                    <dt>
+                        <a href="<?php echo handbookEscape($baseHref); ?>index.php?<?php echo handbookEscape(handbookPageQuery($entry)); ?>">
+                            <?php echo handbookEscape($entry["nav"]); ?>
+                        </a>
+                    </dt>
+                    <dd><?php echo handbookSummaryHtml($entry["summary"]); ?></dd>
+                <?php endforeach; ?>
+            </dl>
+
+            <h2>Schnittstelle</h2>
+            <dl class="docs-index-list">
+                <dt><a href="<?php echo handbookEscape($baseHref); ?>api.php">API-Referenz</a></dt>
+                <dd>Protokoll v1 in Swagger UI, zum Nachschlagen und Ausprobieren</dd>
+                <dt><a href="<?php echo handbookEscape($baseHref); ?>openapi.php">OpenAPI-Dokument</a></dt>
+                <dd>Dieselbe Beschreibung als OpenAPI 3.1, für Codegeneratoren</dd>
+            </dl>
+
+            <h2>Quellcode</h2>
+            <p>Das vollständige Client-Paket. Für eine Einbindung wird der Ordner
+                <code>manage-client/</code> gebraucht; <code>examples/</code>,
+                <code>docs/</code> und <code>scripts/</code> gehören nicht dazu.</p>
+            <dl class="docs-index-list docs-index-code">
+                <?php foreach ($code as $entry): ?>
+                    <dt>
+                        <a href="<?php echo handbookEscape($baseHref); ?>index.php?<?php echo handbookEscape(handbookPageQuery($entry)); ?>">
+                            <?php echo handbookEscape($entry["nav"]); ?>
+                        </a>
+                        <span class="docs-size"><?php echo handbookEscape(handbookFormatBytes($entry["bytes"])); ?></span>
+                    </dt>
+                    <dd><?php echo handbookSummaryHtml($entry["summary"]); ?></dd>
+                <?php endforeach; ?>
+            </dl>
+        <?php elseif ($notFound): ?>
+            <h1>Nicht gefunden</h1>
+            <p>Die angeforderte Seite gibt es nicht.</p>
+            <p><a href="<?php echo handbookEscape($baseHref); ?>index.php">Zur Übersicht</a></p>
+        <?php else: ?>
+            <p class="docs-source-note">
+                Quelle: <code><?php echo handbookEscape($page["source"]); ?></code>
+                · <?php echo handbookEscape(handbookFormatBytes($page["bytes"])); ?>
+                · <a href="<?php echo handbookEscape($rawUrl); ?>">als Markdown</a>
+            </p>
+            <article id="doc-content" class="markdown-body"></article>
+            <script type="application/json" id="doc-source"><?php
+                echo json_encode(
+                    $markdown,
+                    JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE,
+                );
+            ?></script>
+        <?php endif; ?>
     </main>
 </div>
-<footer class="docs-footer">
-    <p>Erzeugt aus <code>client-package/</code> des Manage-Repositories, bei jedem Aufruf neu.
-        Gerendert mit <a href="https://marked.js.org/">marked</a>, API-Referenz mit
-        <a href="https://swagger.io/tools/swagger-ui/">Swagger UI</a>; beide liegen mit im
-        Repository, es werden keine fremden Server kontaktiert.</p>
-</footer>
-<script src="assets/marked.min.js"></script>
-<script>
-    (function () {
-        var source = document.getElementById('handbook-source');
-        var target = document.getElementById('handbook');
-        if (!source || !target || typeof marked === 'undefined') {
-            // Without marked the plain-text fallback stays visible.
-            document.documentElement.className =
-                document.documentElement.className.replace(' has-js', '');
-            return;
-        }
-
-        target.innerHTML = marked.parse(source.textContent || '', { gfm: true, breaks: false });
-    })();
-</script>
+<?php if ($markdown !== null): ?>
+    <script src="<?php echo handbookEscape($baseHref); ?>assets/marked.min.js"></script>
+    <script>
+        (function () {
+            var source = document.getElementById('doc-source');
+            var target = document.getElementById('doc-content');
+            if (!source || !target || typeof marked === 'undefined') {
+                return;
+            }
+            target.innerHTML = marked.parse(JSON.parse(source.textContent || '""'), {
+                gfm: true,
+                breaks: false
+            });
+        })();
+    </script>
+<?php endif; ?>
 </body>
 </html>

+ 140 - 11
client-docs/llms.php

@@ -3,21 +3,150 @@
 declare(strict_types=1);
 
 /**
- * The handbook as one plain-text Markdown document.
+ * The handbook as plain Markdown, one page per request.
  *
- * This is the address to hand to an agent: no markup, no navigation, no
- * JavaScript - documentation, source code and the OpenAPI document in one
- * response. Reachable as llms.txt through the rewrite in .htaccess.
+ *   llms.php                  index: every page with a summary and its size
+ *   llms.php?doc=KEY          one chapter, as written
+ *   llms.php?code=PATH        one source file
+ *
+ * Apache also answers llms.txt for the index, see .htaccess. That alias is a
+ * convenience, not the canonical address: it needs mod_rewrite, and this file
+ * has to work without it.
+ *
+ * This is the rendering meant for agents. It exists separately from index.php
+ * so that a fetch costs only the page that was asked for: the index says what
+ * each page contains and how large it is, and names the pages a given job
+ * needs, so nothing has to be pulled in on the chance it might be relevant.
  */
 
 require_once __DIR__ . "/inc/handbook.php";
 
-$markdown = handbookBuildMarkdown(false);
+$requestedDoc = isset($_GET["doc"]) ? (string) $_GET["doc"] : "";
+$requestedCode = isset($_GET["code"]) ? (string) $_GET["code"] : "";
+
+if ($requestedDoc === "" && $requestedCode === "") {
+    handbookSendText(handbookRenderIndex());
+}
+
+$page = $requestedDoc !== ""
+    ? handbookPage("doc", $requestedDoc)
+    : handbookPage("code", $requestedCode);
+
+if ($page === null) {
+    http_response_code(404);
+    handbookSendText(
+        "# Nicht gefunden\n\n"
+        . "Diese Seite gibt es nicht. Das Verzeichnis aller Seiten steht unter\n"
+        . handbookSelfUrl() . "/llms.php\n",
+    );
+}
+
+// Chapters link each other by file name; inside this rendering the neighbour
+// is another llms.php page, so a following request stays in plain Markdown.
+handbookSendText(
+    rtrim(handbookPageMarkdown($page, "llms.php")) . "\n\n"
+    . "---\n\n"
+    . "Verzeichnis aller Seiten: " . handbookSelfUrl() . "/llms.php\n"
+    . "Diese Seite für Menschen: " . handbookPageUrl($page) . "\n",
+);
+
+/** Emits a Markdown document and ends the request. */
+function handbookSendText(string $text): void
+{
+    header("Content-Type: text/plain; charset=utf-8");
+    header("Content-Length: " . (string) strlen($text));
+    header("X-Content-Type-Options: nosniff");
+    // Cheap to rebuild, but an agent walking several pages should not pay for
+    // a revalidation on each one.
+    header("Cache-Control: public, max-age=300");
+
+    echo $text;
+    exit;
+}
+
+/**
+ * The index: what exists, how big it is, and which pages a given job needs.
+ */
+function handbookRenderIndex(): string
+{
+    $self = handbookSelfUrl();
+    $docs = handbookPagesOfKind("doc");
+    $code = handbookPagesOfKind("code");
+
+    $total = 0;
+    foreach (handbookPages() as $page) {
+        $total += $page["bytes"];
+    }
+
+    $out = [];
+    $out[] = strtr(handbookRead(HANDBOOK_CONTENT_DIR . "/llms-intro.md"), [
+        "{{SELF_URL}}" => $self,
+        "{{BASE_URL}}" => handbookBaseUrl(),
+        "{{PAGE_COUNT}}" => (string) count(handbookPages()),
+        "{{TOTAL_SIZE}}" => handbookFormatBytes($total),
+    ]);
+
+    // ---- Recipes: the shortlist per job ------------------------------------
+    $out[] = "## Wofür welche Seiten\n";
+    foreach (handbookRecipes() as $recipe) {
+        $lines = ["### " . $recipe["title"], "", $recipe["note"], ""];
+
+        foreach ($recipe["pages"] as [$kind, $key]) {
+            $page = handbookPage($kind, $key);
+            if ($page === null) {
+                continue;
+            }
+            $lines[] = "- " . handbookRawUrl($page) . " – " . $page["title"];
+        }
+
+        $out[] = implode("\n", $lines) . "\n";
+    }
+
+    // ---- Every page --------------------------------------------------------
+    $out[] = handbookRenderList(
+        "## Dokumentation",
+        "Kapitel in empfohlener Lesereihenfolge.",
+        $docs,
+    );
+
+    $out[] = "## Schnittstelle\n\n"
+        . "- " . $self . "/openapi.php – OpenAPI 3.1 der vier Endpunkte des Manage-Servers, "
+        . "als JSON. Für einen eigenen Client zusammen mit dem Kapitel *Protokoll v1* lesen.\n"
+        . "- " . $self . "/api.php – dieselbe Beschreibung in Swagger UI. Eine Seite für "
+        . "Menschen, für ein Programm ohne Nutzen.\n";
+
+    $out[] = handbookRenderList(
+        "## Quellcode",
+        "Das vollständige Client-Paket, jede Datei einzeln abrufbar. Für eine Einbindung "
+        . "wird der Ordner `manage-client/` gebraucht; `examples/`, `docs/` und `scripts/` "
+        . "gehören nicht dazu.",
+        $code,
+    );
+
+    $vendored = handbookVendoredFiles();
+    if ($vendored !== []) {
+        $lines = ["## Nicht abgedruckt", "", "Fremdbibliotheken, die zum Paket gehören, aber "
+            . "hier nicht als Seite stehen:", ""];
+        foreach ($vendored as $relative => $bytes) {
+            $lines[] = "- `client-package/" . $relative . "` (" . handbookFormatBytes($bytes) . ")";
+        }
+        $out[] = implode("\n", $lines) . "\n";
+    }
+
+    return implode("\n", $out);
+}
+
+/** @param array<string, array> $pages */
+function handbookRenderList(string $heading, string $intro, array $pages): string
+{
+    $lines = [$heading, "", $intro, ""];
 
-header("Content-Type: text/plain; charset=utf-8");
-header("Content-Length: " . (string) strlen($markdown));
-header("X-Content-Type-Options: nosniff");
-// Cheap to rebuild, but a fetching agent should not re-download it per file.
-header("Cache-Control: public, max-age=300");
+    foreach ($pages as $page) {
+        $lines[] = "- " . handbookRawUrl($page)
+            . " (" . handbookFormatBytes($page["bytes"]) . ")"
+            . " – **" . $page["title"] . "**"
+            . ($page["summary"] !== "" ? ". " . $page["summary"] : "");
+    }
 
-echo $markdown;
+    return implode("\n", $lines) . "\n";
+}