# CMF - API-Kurzreferenz fuer Maschinen-Agenten

Stand: CMF 1.17.1. Ausfuehrliche Fassung mit allen Beispielen: https://cmf.brosemedien.de/api-anleitung
(als Markdown: https://cmf.brosemedien.de/api-anleitung.md). Beide Fassungen beschreiben denselben Stand.
Diese Datei ist bewusst ohne Umlaute geschrieben (ae/oe/ue/ss), damit sie auch ohne Zeichensatz-Angabe lesbar bleibt.

Du arbeitest auf einem dateibasierten CMS (JSON-Dateien, keine Datenbank) ueber eine JSON-API.

    API-Basis:  https://deinewebsite.de/api.php     (Adresse deiner Installation einsetzen)
    Token:      Authorization: Bearer cms_DEIN_API_TOKEN_HIER
    alternativ: X-API-Token: cms_DEIN_API_TOKEN_HIER

SICHERHEIT: Den echten Token nie in eine oeffentlich erreichbare Kopie dieser Datei eintragen
(z. B. public/files/README_MASCHINEN.md). Der Token hat volle Schreib- und Loeschrechte.

---

## Arbeitsregeln

- Ausschliesslich ueber die API arbeiten.
- Immer zuerst lesen, dann aendern.
- Nur die 30 Aktionen und 9 Blocktypen unten verwenden. Keine JSON-Felder erfinden (sie werden verworfen).
- Block-IDs stabil halten; neue IDs eindeutig im ganzen Dokument (auch in Spalten).
- Pro Seite genau ein heading mit level 1.
- Bei Updates das vollstaendige Dokument senden - es ersetzt das alte.
- Medienpfade nur aus media_upload (data.src) uebernehmen, nie erfinden.
- Fehler (error, details) auswerten, korrigieren, erneut senden.
- Nach jedem Schreiben die Antwort pruefen: ok:true heisst nicht automatisch "geaendert" - data.content vergleichen.

## Arbeitsreihenfolge

1. GET ?a=site_bundle                      (site, styles, header, footer, custom_css)
2. GET ?a=pages  bzw.  GET ?a=blog_posts   (IDs, Slugs, Status, nav, Kategorien)
3. GET ?a=page&id=ID  bzw.  ?a=blog_post&id=ID   (Dokument vollstaendig laden)
4. Optional: POST ?a=media_upload -> data.src verwenden
5. data.content aendern (IDs behalten)
6. POST ?a=page_update&id=ID  bzw.  ?a=blog_update&id=ID  mit {"content": ...}
7. Antwort pruefen: data.content, data.page.slug, data.page.nav

Empfehlung: GET ?a=page liefert data.content - genau dieses Objekt als {"content": ...} zurueckschicken.
(Damit bleiben meta.image und meta.robots erhalten.)

---

## Anfrage und Antwort

- Aktion per URL-Parameter a, Kennungen als weitere Parameter (id, slug, part, path).
- Body: JSON (UTF-8). Ausnahme media_upload: multipart/form-data.
- Erfolg: { "ok": true, "data": { ... } }
- Fehler: { "ok": false, "error": "fehlercode", "details": [ ... ] }   (details nur bei manchen Fehlern)
- POST-Aufrufe laufen nacheinander (Schreibsperre). KEIN Versionsschutz: wer zwischen deinem GET und POST aendert,
  wird ueberschrieben -> direkt vor dem Schreiben frisch lesen.
- sitemap.xml, robots.txt, feed.xml, llms.txt, search-index.json entstehen nach jeder Aenderung an Seiten/Beitraegen automatisch neu.

## Authentifizierung

- Token noetig fuer ALLE Aktionen ausser search_index und version_check (auch fuer sitemap_generate und lesende Aufrufe).
- Token entsteht im Admin: Benutzer -> "Neuen Zugang anlegen" + Haekchen "API-Zugang anlegen"; wird einmal angezeigt, gespeichert nur als SHA-256.
- Keine Rollenwahl: ein Token darf alles. Erneuern = neuen Zugang anlegen, alten Benutzer loeschen.
- Keine CORS-Header (ausser version_check): nicht aus Browser-JavaScript fremder Domains aufrufen.
- 401 missing_token | invalid_token | token_disabled. Die Token-Pruefung kommt vor allem anderen (auch unbekannte Aktion -> 401).

---

## Endpunkte (30)

Seiten (7):
    GET  ?a=pages                     data.pages: Index aller Seiten (nach Slug, inkl. Entwuerfe)
    GET  ?a=page&id=ID | &slug=SLUG   data.page + data.content (id hat Vorrang; Entwuerfe inklusive)
    POST ?a=page_create               Body: title, slug, status, nav, content(Pflicht) -> 201
    POST ?a=page_update&id=ID         Body: content, title, slug, status, nav (alle optional; id auch im Body)
    POST ?a=page_delete&id=ID         -> data.deleted, data.page (endgueltig)
    GET  ?a=pages_export              data.pages [{index, content}], data.count
    POST ?a=pages_import              Body: {"mode": "skip"|"overwrite", "pages": [{index, content}]}

Blog (5):
    GET  ?a=blog_posts                data.slug (Blog-Praefix), data.categories, data.posts (nach order)
    GET  ?a=blog_post&id=ID | &slug=SLUG   data.post + data.content
    POST ?a=blog_create               Body: title, slug, status, image, description, category, content(Pflicht) -> 201
    POST ?a=blog_update&id=ID         Body wie blog_create (alles optional) + order
    POST ?a=blog_delete&id=ID         -> data.deleted, data.post

Website (5):
    GET  ?a=site                      data.site
    POST ?a=site_update               Body: VOLLSTAENDIGES site-Objekt (oder {"site": {...}})
    GET  ?a=site_bundle               data.site, styles, header, footer, custom_css
    GET  ?a=site_export               data.site, styles, header, footer, custom_css, pages (OHNE Blog, OHNE Medien)
    POST ?a=site_import               Body: beliebige dieser Teile -> data.imported, data.rejected

Header/Footer (2):
    GET  ?a=partial&part=header|footer        data.part, data.content
    POST ?a=partial_update&part=header|footer Body: {"content": {...}} (part auch im Body moeglich)

Theme (4):
    GET  ?a=styles                    data.styles
    POST ?a=styles_update             Body: Teilobjekt (oder {"styles": {...}})
    GET  ?a=custom_css                data.css
    POST ?a=custom_css_update         Body: {"css": "..."}

Medien (4):
    GET  ?a=media                     data.files [path, url, filename, ext, mime, size, modified, used, usage_count, deletable]
    POST ?a=media_upload              multipart/form-data, Feldname: file -> 201
    GET  ?a=media_usage[&path=/media/JJJJ/MM/DATEI.EXT]
    POST ?a=media_delete&path=/media/...   (oder Body {"path": "/media/..."})

System (3):
    GET  ?a=search_index              OEFFENTLICH. data.pages [slug, title, description, text] (nur veroeffentlicht)
    GET  ?a=sitemap_generate          Token noetig. -> data.generated. Nur nach site_update noetig.
    GET  ?a=version_check             OEFFENTLICH. data.version, date, changelog, download_url

Falsche Methode -> 405 method_not_allowed (Header Allow nennt die richtige).

---

## Ersetzt oder fuehrt zusammen

    page_update/blog_update MIT content   ERSETZT das ganze Dokument (meta + blocks).
                                          meta.image und meta.robots mitschicken, sonst weg.
                                          (Blog: fehlt meta.image, wird "image" uebernommen.
                                          Neues image OHNE content aendert ein vorhandenes meta.image nicht.)
    page_update/blog_update OHNE content  Inhalt bleibt, keine Schemapruefung.
    title, slug, status                   ersetzt, nur wenn mitgeschickt. title aendert NICHT meta.title.
                                          Neuer Slug = neue URL, keine Weiterleitung.
    nav (page_update)                     FUEHRT ZUSAMMEN: einzelne Schluessel reichen; "parent": null = Hauptpunkt.
    image/description/category/order      ersetzt, nur wenn mitgeschickt (blog_update).
    partial_update                        ERSETZT Header/Footer komplett; meta nur title + description.
    site_update                           ERSETZT site.json komplett -> erst GET ?a=site, aendern, alles zuruecksenden.
    styles_update                         FUEHRT ZUSAMMEN: Teilobjekt reicht.
    custom_css_update                     ERSETZT custom.css komplett; Body ohne css leert die Datei; PHP-Tags werden entfernt.
    site_import                           ersetzt jeden mitgeschickten Teil; Seiten mit gleicher ID ueberschrieben.
    pages_import                          skip: vorhandene IDs bleiben; overwrite: Index + Inhalt ersetzt. KEINE Schemapruefung.
    page_delete/blog_delete               endgueltig; Unterseiten -> parent null; Medien bleiben.

---

## Body-Formate

page_create:

    {
      "title": "Leistungen",
      "slug": "leistungen",
      "status": "draft",
      "nav": { "show": false },
      "content": {
        "meta": { "title": "Leistungen", "description": "Was wir anbieten, kurz und konkret." },
        "content": { "blocks": [
          { "id": "h1_leistungen", "type": "heading", "data": { "level": 1, "text": "Leistungen" } },
          { "id": "t_intro", "type": "text", "data": { "html": "<p>Kurzer Einstieg.</p>" } }
        ] }
      }
    }

- content Pflicht (sonst 422). title fehlt -> "Neue Seite". status: "published" oder "draft" (Standard).
- slug aus slug oder title (Kleinbuchstaben, ae/oe/ue/ss, sonst "-"); belegt -> "-2", "-3" ... -> SLUG AUS DER ANTWORT UEBERNEHMEN.
- nav.show Standard TRUE: veroeffentlichte Seite ohne nav erscheint im Menue. Ausserhalb des Menues: "nav": {"show": false}.
- nav.order Standard: Anzahl Seiten + 1. nav.label: null = Titel.
- nav.parent: ID ODER Slug der Elternseite; unbekannt/Selbstbezug/Schleife -> still null (data.page.nav.parent pruefen).

page_update (POST ?a=page_update&id=ID):

    { "content": { "meta": { "title": "...", "description": "...", "image": "/media/...", "robots": "noindex" },
                   "content": { "blocks": [ ... ] } } }

    Nur Index aendern:  { "status": "published", "nav": { "parent": "anleitungen", "order": 3 } }

- Ohne Huelle {"meta":..., "content":{...}} -> 422 (meta fehlt, content fehlt). Mit {"data": ...} -> ok:true, KEINE Aenderung.
- Leerer title -> "Ohne Titel". Fehler: 400 missing_id, 404 page_not_found, 422 invalid_page_schema.

blog_create:

    { "title": "Neue Funktion", "slug": "neue-funktion", "status": "published",
      "image": "/media/2026/09/titelbild-9f3c2a1b.jpg",
      "description": "Kurztext fuer Karte, Feed und llms.txt.",
      "category": "Technik",
      "content": { "meta": { "title": "Neue Funktion im Ueberblick", "description": "Meta-Beschreibung fuer Suchmaschinen." },
                   "content": { "blocks": [ ... ] } } }

- description = Kurztext (Karte, feed.xml, llms.txt); content.meta.description = Meta-Tag. GETRENNT setzen.
- image = Beitragsbild; wird meta.image, wenn das Dokument keines hat.
- category: exakt ein Wert aus data.categories von blog_posts (Gross-/Kleinschreibung). Nicht geprueft; abweichend -> fehlt in gefilterter Uebersicht.
- Neue Beitraege landen oben (order = kleinste vorhandene order - 1, hoechstens 0). Beitraege haben kein nav. Titel fehlt -> "Neuer Beitrag".
- URL eines Beitrags: /<blog-praefix>/<beitrags-slug>

blog_update: wie page_update, zusaetzlich image, description, category, order (int; kleiner = weiter oben), z. B. {"order": -10}.
Fehler: 400 missing_id, 404 post_not_found, 422 invalid_post_schema.

partial_update (POST ?a=partial_update&part=footer):

    { "content": { "meta": { "title": "Footer" },
                   "content": { "blocks": [ { "id": "t_footer", "type": "text", "data": { "html": "<p>...</p>" } } ] } } }

- meta.title Pflicht. "blocks": [] erlaubt (leert den Bereich). part ungueltig -> 404 partial_not_found. 422 invalid_partial_schema.

site_update (vollstaendig senden):

    { "name": "Meine Website", "lang": "de", "baseUrl": "https://deinewebsite.de",
      "og_image": "/media/2026/09/vorschau-1a2b3c4d.png", "title_suffix": "Meine Website",
      "csp": { "script_src": ["https://widget.example.com"], "connect_src": ["https://widget.example.com"] } }

- Pflicht name, lang. Unbekannte Felder verworfen. 422 invalid_site_schema.
- Erzeugt Sitemap/Feed/llms.txt NICHT neu -> danach GET ?a=sitemap_generate (z. B. bei neuer baseUrl).

styles_update (Teilobjekt):

    { "colors": { "primary": "#ea6b17", "link": "#ea6b17" }, "fonts": { "heading": "Raleway", "heading_weight": "bold" } }

custom_css_update:

    { "css": "/* KOMPLETTER bisheriger Inhalt aus GET ?a=custom_css */ .neue-klasse { ... }" }

media_upload:

    curl -H "Authorization: Bearer cms_DEIN_API_TOKEN_HIER" -F "file=@titelbild.jpg" "https://deinewebsite.de/api.php?a=media_upload"
    -> 201 { "ok": true, "data": { "src": "/media/2026/09/titelbild-9f3c2a1b.jpg", "url": "https://...", "filename": "titelbild-9f3c2a1b.jpg",
                                   "ext": "jpg", "mime": "image/jpeg", "size": 184223, "modified": "..." } }

- Erlaubt: jpg, jpeg, png, webp, gif, svg, pdf, mp4, mp3, wav. Ablage /media/JJJJ/MM/, Dateiname IMMER mit Zufallsanhang.
- SVG wird per XML-Parser bereinigt: unsichere Teile entfernt, bereinigte Datei gespeichert.
  422 invalid_svg nur bei unlesbarem SVG (kein XML, Wurzel nicht svg, fremder Namespace, interne DTD).
- Fehler: 400 missing_file | upload_failed (auch: Datei zu gross fuer den Server) | invalid_file; 415 unsupported_filetype | mime_mismatch (details.ext, details.mime); 500 media_dir_create_failed | move_failed.

media_usage:

    mit path:  data.path, data.used, data.usage_count, data.references [{ "source": "Seite: Leistungen", "location": "content.blocks.2.data.src" }]
    ohne path: data.summary {total, used, unused}, data.used_files [...], data.unused_files [...]

- path muss mit /media/ beginnen, sonst/fehlend -> 404 media_not_found.
- Durchsucht: Seiten, Header, Footer, Beitraege + Beitragsbild, Custom CSS, site.json (og_image, logo).

media_delete:

- Nur ungenutzte Dateien. 200 data.deleted. 400 missing_path, 404 media_not_found, 500 delete_failed.
- In Verwendung: 409 { "ok": false, "error": "media_in_use", "data": { "path": "...", "usage_count": 1, "references": [ ... ] } }
  (Fundstellen in data.references, NICHT in details)

site_import:

- Body mit beliebigen Schluesseln: site, styles, header, footer, custom_css, pages [{index, content}].
- Jeder Teil einzeln geprueft; Antwort IMMER ok:true -> data.imported [...] und data.rejected { "page:ID": [...] } pruefen.
- Seiten-ID nur a-z 0-9 - _ (max. 64 Zeichen), sonst still uebersprungen.
- Keine Pruefung auf doppelte Slugs: bei gleichem Slug ist nur die erste Seite erreichbar.

pages_import:

- {"mode": "skip"|"overwrite", "pages": [{"index": {...}, "content": {...}}]} -> data.imported, data.skipped.
- Fehlt pages -> 422, error "pages array fehlt". Keine Schemapruefung, Index-Eintrag ohne Pruefung uebernommen (nur Slug bereinigt) -> page_create/page_update bevorzugen.
- Ganze Website mit Blog + Medien klonen: ZIP-Export/-Import im Admin (Einstellungen), nicht per API. site.json ist NICHT im ZIP.

---

## Dokument-Schema (Seiten, Beitraege, Header, Footer)

    { "meta": { "title": "Pflicht", "description": "empfohlen", "image": "/media/...", "robots": "noindex" },
      "content": { "blocks": [ ... ] } }

- meta kennt nur: title (Pflicht, nicht leer), description, image (og:image), robots ("noindex" / "noindex, nofollow"; noindex -> nicht in Sitemap und llms.txt).
- blocks: Array; jeder Block { "id", "type", "data" }. id nicht leer und eindeutig im ganzen Dokument (inkl. Spalten).
- data ist immer ein Objekt, auch leer ({}).
- Genau ein heading level 1 pro Seite (nicht vom Schema geprueft, trotzdem Pflicht).
- "blocks": [] bei Seiten/Beitraegen -> Platzhalter (Ueberschrift mit Titel + "Inhalt hier.").
- Startseite = Slug "home".

nav (nur Seiten): show (bool, Standard true), order (int), label (string|null), parent (ID oder Slug|null; gespeichert als ID).

## Blocktypen (9)

    heading       { "id": "h1_x", "type": "heading", "data": { "level": 1, "text": "Titel" } }
                  level 1-6, text Pflicht (nicht leer). Reiner Text.
    text          { "id": "t_x", "type": "text", "data": { "html": "<p>...</p>" } }
                  html (String) Pflicht. Filter siehe unten.
    image         { "id": "img_x", "type": "image", "data": { "src": "/media/...", "alt": "", "caption": "", "loading": "lazy", "width": 1600, "height": 900 } }
                  src Pflicht (nicht leer); alt Pflicht als String (darf leer sein).
                  Optional caption, loading ("lazy" Standard | "eager"), width/height (ganze Zahl >= 1, sonst 422;
                  wirken nur wenn BEIDE gesetzt; werden nicht automatisch ermittelt).
    list          { "id": "l_x", "type": "list", "data": { "ordered": false, "items": ["A", "B"] } }
                  ordered (bool) + items (String-Array) Pflicht. Reiner Text.
    buttons       { "id": "b_x", "type": "buttons", "data": { "items": [ { "label": "Kontakt", "href": "/kontakt", "style": "primary" } ] } }
                  items nicht leer; je label + href Pflicht; style "primary" oder "". href nur relativ/#/http(s)/mailto/tel, sonst "#".
    columns       { "id": "c_x", "type": "columns", "data": { "columns": 2, "items": [ [ ...Bloecke... ], [ ...Bloecke... ] ] } }
                  columns 2-5; items = genau so viele Arrays.
    html          { "id": "html_x", "type": "html", "data": { "code": "<div class=\"table-wrap\">...</div>" } }
                  code (String) Pflicht. UNGEFILTERT. Fuer Tabellen, Karten, Raster, Einbettungen.
                  Externe Skripte brauchen site.json csp. Klassen aus Custom CSS statt Inline-Styles.
    blog_overview { "id": "blog_x", "type": "blog_overview", "data": { "category": "" } }
                  Karten veroeffentlichter Beitraege; category exakt, leer = alle.
    form          siehe Formular-Block.

## text-Block-Filter

- Erlaubt genau 17 Tags: a b strong i em u br p ul ol li code pre span small sup sub
- Alle anderen Tags (h2, img, table, div, blockquote ...) werden STILL entfernt, Textinhalt bleibt. Keine Fehlermeldung.
- Entfernt: on...-Attribute, formaction, style. href/src nur relativ, #anker, http:, https:, mailto:, tel: - sonst "#".
- Filter wirkt bei der Ausgabe; das gespeicherte JSON bleibt wie gesendet.
- HTML in heading.text, list.items, buttons.label erscheint als Text.

## Formular-Block

    { "id": "f_kontakt", "type": "form", "data": {
        "title": "Schreib uns", "intro": "Pflichtfelder sind markiert.", "submit_label": "Nachricht senden",
        "success_message": "Danke, deine Nachricht ist angekommen.", "store": true, "email_to": "info@example.com", "confirm": false,
        "fields": [
          { "name": "name", "type": "text", "label": "Name", "required": true },
          { "name": "email", "type": "email", "label": "E-Mail", "required": true },
          { "name": "anliegen", "type": "select", "label": "Anliegen", "required": true, "options": ["Frage", "Angebot", "Sonstiges"] },
          { "name": "nachricht", "type": "textarea", "label": "Nachricht", "required": true },
          { "name": "datenschutz", "type": "checkbox", "label": "Ich habe die Datenschutzerklaerung gelesen.", "required": true }
        ] } }

- fields Pflicht, nicht leer.
- field.name Pflicht, nur a-z 0-9 _, eindeutig im Formular. field.label Pflicht.
- field.type Pflicht: text | email | tel | textarea | select | checkbox | radio. required optional (bool).
- select/radio: options Pflicht (nicht leeres Array nicht leerer Strings).
- title (als h2), intro, submit_label (Standard "Absenden"), success_message optional.
- store: Standard an, nur false schaltet Speichern ab. Einsendungen im Admin unter "Einsendungen" (max. 2000 je Formular).
- email_to: leer oder gueltige Adresse (sonst 422) -> zusaetzlich Mail je Einsendung.
- confirm (bool, Standard false): Bestaetigungsmail an erstes email-Feld.
- Spam-Schutz automatisch (Honeypot + signierte Zeitfalle: fruehestens 2 s, max. 1 h). Formular postet auf die eigene URL.
- Laengen: Textfelder 200, textarea 5000 Zeichen. Drosseln: 10 Einsendungen je IP/Stunde, Betreiber-Mails 100/Stunde,
  Bestaetigungsmails 5 je IP und 60 gesamt/Stunde.
- Funktioniert auch in Beitraegen, Header und Footer. Einsendungen sind per API NICHT abrufbar.

## Validierung (Schema-Pruefung bei page_create/page_update/blog_*/partial_update/site_import)

- meta Objekt, meta.title nicht leer; content Objekt; content.blocks Array
- Jeder Block: id (nicht leer, eindeutig), type (einer der 9), data (Objekt)
- heading: level 1-6, text nicht leer | text: html String | image: src nicht leer, alt String, width/height >= 1
- list: ordered bool, items String-Array | buttons: items nicht leer, label + href | columns: 2-5, items passend
- html: code String | form: siehe oben
- Typische details: "meta.title fehlt", "doppelte id: t_intro", "columns c_start items passen nicht zur spaltenzahl",
  "image img_titel alt fehlt", "form f_kontakt feld 0 label fehlt"

---

## site.json

    name (Pflicht)      Website-Name (Titel-Anhang, strukturierte Daten, Feed)
    lang (Pflicht)      z. B. "de"
    baseUrl             absolute Adresse ohne "/" am Ende (Canonical, Sitemap, Feed, llms.txt, Medien-url)
    og_image            Standard-Vorschaubild
    logo                Logo-Pfad (Favicon-Link, strukturierte Daten)
    title_suffix        Titel-Anhang " | Wert"; leer = keiner; fehlt = name
    software_schema     bool, SoftwareApplication-JSON-LD
    download_url, software_category (Standard DeveloperApplication), software_version
    csp                 { script_src, connect_src, style_src, img_src, font_src, media_src, frame_src: [Quellen] }
                        erweitert die Content-Security-Policy; ungueltige Quellen werden verworfen.

Andere Felder werden verworfen. Im Admin gibt es keine Oberflaeche fuer site.json.

## Styles (24 Werte; gezeigt sind die Rueckfallwerte)

    {
      "container": "1100px", "pad": "16px", "gap": "16px",
      "radius": { "sm": "8px", "md": "14px", "lg": "22px" },
      "colors": { "bg": "#ffffff", "text": "#111111", "muted": "#666666", "border": "#dddddd",
                  "primary": "#0d6efd", "secondary": "#ff0000", "primary_text": "#ffffff", "link": "#0d6efd" },
      "type": { "body": "14px", "h1": "2.1rem", "h2": "1.6rem", "h3": "1.25rem", "h4": "1.1rem", "h5": "1.0rem" },
      "fonts": { "body": "", "body_weight": "regular", "heading": "", "heading_weight": "regular" }
    }

- fonts.body / fonts.heading: Inter, Lato, Merriweather, Montserrat, Nunito, OpenSans, PTSerif, PlayfairDisplay, Raleway, Roboto
  (ohne Leerzeichen; leer = Systemschrift; unbekannter Name laedt keine Schrift).
- Gewichte: light (300), regular (400), bold (700); PTSerif nur regular/bold. Anderer Wert -> 422 invalid_styles_schema.
- Unsichere CSS-Werte (;, {, url( ...) landen nicht in theme.css, dort gilt der Rueckfallwert.
- Eigene Klassen: per custom_css_update ins Custom CSS, in html-Bloecken per class="..." nutzen - keine Inline-Styles.

---

## HTTP-Codes

    200  Erfolg (ok:true heisst nicht automatisch "geaendert")
    201  angelegt: page_create, blog_create, media_upload
    400  invalid_json (Body kein JSON-Objekt) | missing_id | missing_path | missing_file | upload_failed | invalid_file
    401  missing_token | invalid_token | token_disabled
    404  page_not_found | post_not_found | partial_not_found | media_not_found | unknown_action
    405  method_not_allowed (Header Allow)
    409  media_in_use (data.references)
    415  unsupported_filetype | mime_mismatch (details.ext, details.mime)
    422  invalid_page_schema | invalid_post_schema | invalid_partial_schema | invalid_site_schema | invalid_styles_schema (details: Liste)
         invalid_svg (ohne details) | "pages array fehlt" (pages_import)
    500  media_dir_create_failed | move_failed | delete_failed; andere Schreibfehler ggf. ohne JSON-Antwort
    503  System-Update laeuft (Wartungsseite, Retry-After); nur version_check antwortet

## Was die API nicht kann

- Formular-Einsendungen lesen/exportieren/loeschen (nur Admin)
- Benutzer, Passwoerter, API-Tokens verwalten
- Blog-Einstellungen: Blog-Praefix, Kategorienliste (Kategorien nur zuweisen)
- System-Update, Backup, Rollback, ZIP-Export/-Import mit Medien und Blog
- Einzelne Bloecke gezielt aendern (immer ganzes Dokument)
- Weiterleitungen (geaenderter Slug -> alte URL 404)
- Medien umbenennen/ersetzen/verschieben, Bildmasse ermitteln
- Blog und Medien per site_export/site_import
- Entwuerfe anzeigen (API liefert sie, Website und .md nur Veroeffentlichtes)
- Rueckgaengig machen (kein Papierkorb, keine Versionsgeschichte)
- Aufrufe aus dem Browser fremder Domains (keine CORS-Header)

## Typische Fallen

1. page_update ohne {"content": ...} -> 422; mit {"data": ...} -> ok:true, nichts geaendert.
2. meta.image / meta.robots nicht mitgeschickt -> geloescht (Blog: meta.image faellt auf image zurueck).
3. page_create ohne "nav": {"show": false} -> veroeffentlichte Seite im Menue.
4. nav.parent vertippt -> still null. data.page.nav.parent pruefen.
5. Slug belegt -> "-2". Slug aus der Antwort verwenden.
6. site_update mit Teilobjekt -> 422 oder Verlust von og_image, csp usw.
7. custom_css_update mit Ausschnitt -> restliches CSS weg; {} leert die Datei.
8. HTML in heading.text / list.items / buttons.label -> erscheint als Text; Tabellen/Bilder im text-Block -> still entfernt.
9. width/height 0 oder "800px" -> 422; nur eines gesetzt -> wirkt nicht.
10. Gleiche ID in zwei Spalten -> 422 "doppelte id".
11. "blocks": [] -> Platzhalter statt leerer Seite.
12. Kategorie "technik" statt "Technik" -> Beitrag fehlt in gefilterter Uebersicht.
13. description (Karte/Feed/llms.txt) und content.meta.description (Meta-Tag) sind getrennte Felder.
14. pages_import speichert auch fehlerhafte Inhalte.
15. site_import: ok:true trotz abgelehnter Teile -> data.rejected pruefen.
16. Neue baseUrl -> nach site_update sitemap_generate aufrufen.
17. ok:true heisst nicht geaendert -> data.content der Antwort gegen das Gesendete pruefen.

Beim Erzeugen von Inhalten: semantisch arbeiten, sprechende Block-IDs, sinnvolle Alt-Texte, nur schema-gueltiges JSON.
