# REST-API-Referenz: alle 30 Aktionen erklärt

> Referenz aller 30 Aktionen der CMF-REST-API: Parameter, Beispiele, Antworten und Fehlercodes, dazu Blockschema, Formular-Block und typische Fallen.

Kanonische URL: https://cmf.brosemedien.de/api-anleitung

---

# API-Referenz

Alle 30 Aktionen der CMF-REST-API mit Parametern, Beispielen, Antworten und Fehlercodes – dazu das vollständige Blockschema und die Stellen, an denen beim Zurückschreiben Daten verloren gehen können. Stand: CMF 1.17.1.

Diese Website wird selbst über genau diese API gepflegt: Die News-Beiträge vom 15.09.2026 sind komplett per API angelegt.

- [Kurzfassung README_MASCHINEN.md](/files/README_MASCHINEN.md)
- [Diese Seite als Markdown](/api-anleitung.md)
- [llms.txt](/llms.txt)

**Inhalt**

- [Grundlagen](#grundlagen)
- [Authentifizierung](#authentifizierung)
- [Arbeitsweise](#arbeitsweise)
- [Ersetzt oder führt zusammen?](#semantik)
- [Alle 30 Aktionen](#aktionen)
- [Seiten](#seiten)
- [Blog](#blog)
- [Website, Header/Footer, Theme](#website)
- [Medien](#medien)
- [Export und Import](#export-import)
- [Öffentliche und System-Aktionen](#system)
- [Dokument: meta und nav](#dokument)
- [Die 9 Blocktypen](#bloecke)
- [Filter im text-Block](#text-block)
- [Formular-Block](#formular)
- [site.json-Felder](#site-json)
- [Styles-Schema](#styles-schema)
- [HTTP-Codes und Fehler](#fehlercodes)
- [Was die API nicht kann](#grenzen)
- [Typische Fallen](#fallen)

## Grundlagen

Die API steckt in jeder CMF-Installation unter `/api.php`. Die Aktion wählst du mit dem URL-Parameter `a`; Kennungen wie `id`, `slug`, `part` oder `path` hängen als weitere Parameter an. Anfragen und Antworten sind JSON in UTF-8, einzige Ausnahme ist der Datei-Upload per `multipart/form-data`.

```
API-Basis:  https://deinewebsite.de/api.php
Lesen:      GET  https://deinewebsite.de/api.php?a=page&slug=kontakt
Schreiben:  POST https://deinewebsite.de/api.php?a=page_update&id=a1b2c3d4e5f6
            Content-Type: application/json
            Authorization: Bearer cms_DEIN_API_TOKEN_HIER
```

Jede Antwort hat dieselbe Hülle:

```
{ "ok": true,  "data": { … } }
{ "ok": false, "error": "invalid_page_schema", "details": [ "heading h1_start text fehlt" ] }
```

- `details` gibt es nur bei einigen Fehlern, siehe [HTTP-Codes und Fehler](#fehlercodes).
- Schreibende Aufrufe (POST) werden nacheinander ausgeführt, damit sich zwei Speichervorgänge nicht gegenseitig die Datei zerstören. Einen Versionsschutz gibt es nicht: Ändert jemand die Seite zwischen deinem GET und deinem POST, überschreibt dein POST diese Änderung. Lies deshalb direkt vor dem Schreiben frisch.
- Nach jeder Änderung an Seiten oder Beiträgen entstehen `sitemap.xml`, `robots.txt`, `feed.xml`, `llms.txt` und `search-index.json` automatisch neu.
- Diese Referenz gibt es auch als [Markdown](/api-anleitung.md); die kompakte Fassung für Maschinen-Agenten liegt unter [/files/README_MASCHINEN.md](/files/README_MASCHINEN.md). Beide beschreiben denselben Stand.

## Authentifizierung

Alle Aktionen außer `search_index` und `version_check` brauchen einen API-Token – auch `sitemap_generate` und lesende Aufrufe wie `pages`. Du schickst ihn als Bearer-Token oder, falls dein Server den Authorization-Header nicht an PHP weitergibt, im Header `X-API-Token`:

```
Authorization: Bearer cms_DEIN_API_TOKEN_HIER
X-API-Token: cms_DEIN_API_TOKEN_HIER
```

- **Token anlegen:** im Admin unter „Benutzer“ → „Neuen Zugang anlegen“ mit dem Häkchen „API-Zugang anlegen“. Der Token wird genau einmal angezeigt und nur als SHA-256-Prüfsumme gespeichert.
- **Rechte:** Es gibt keine Rollenwahl. Ein Token darf alles, was die API kann – auch löschen und `site.json` ersetzen. Behandle ihn wie ein Admin-Passwort.
- **Token erneuern:** neuen Zugang anlegen, den alten Benutzer löschen.
- **Nicht in den Browser:** Die API sendet keine CORS-Header (einzige Ausnahme: `version_check`). Aufrufe gehören in ein Skript, auf einen Server oder in einen Agenten, nie in öffentlich ausgeliefertes JavaScript.

HTTP `error` Bedeutung
401 `missing_token` Kein Token im Header gefunden.
401 `invalid_token` Token unbekannt.
401 `token_disabled` Token ist in `config/users.json` mit `"enabled": false` gesperrt.

Die Token-Prüfung läuft vor allem anderen: Auch eine unbekannte Aktion antwortet ohne gültigen Token mit 401, nicht mit 404.

## Arbeitsweise: lesen, ändern, vollständig zurückschreiben

- `GET ?a=site_bundle` – Name, Sprache, Theme, Header, Footer und Custom CSS auf einen Blick.
- `GET ?a=pages` bzw. `GET ?a=blog_posts` – IDs, Slugs, Status, Navigation, Kategorien.
- `GET ?a=page&id=…` bzw. `GET ?a=blog_post&id=…` – das Dokument vollständig laden.
- Bilder und Dateien vorher mit `media_upload` hochladen und `data.src` übernehmen – Pfade nie selbst erfinden.
- `data.content` ändern: vorhandene Block-IDs behalten, neue IDs eindeutig vergeben.
- Mit `page_update` bzw. `blog_update` vollständig zurückschreiben.
- Antwort prüfen: `data.content`, `data.page.slug` und `data.page.nav` zeigen, was tatsächlich gespeichert wurde.

**Empfehlung:** GET ?a=page liefert data.content – genau dieses Objekt als {"content": …} zurückschicken.

```
GET  ?a=page&id=a1b2c3d4e5f6
→ { "ok": true, "data": { "page": { … }, "content": { "meta": { … }, "content": { "blocks": [ … ] } } } }

POST ?a=page_update&id=a1b2c3d4e5f6
{ "content": { "meta": { … }, "content": { "blocks": [ … ] } } }
```

Damit bleiben `meta.image` und `meta.robots` automatisch erhalten, und alle Block-IDs stimmen.

## Ersetzt oder führt zusammen?

Die wichtigste Tabelle dieser Seite: Wer ein Teilobjekt schickt, wo das CMF komplett ersetzt, löscht alles Übrige.

Aktion / Feld Verhalten Das heißt für dich
`page_update`, `blog_update` mit `content` **ersetzt** das ganze Dokument (meta und alle Blöcke) `meta.image` und `meta.robots` mitschicken, sonst sind sie weg. `meta` kennt nur title, description, image, robots – andere Felder werden verworfen.
`page_update`, `blog_update` ohne `content` Inhalt bleibt unverändert, keine Schemaprüfung Geeignet für reine Änderungen an Titel, Slug, Status, nav (Blog: image, description, category, order).
`title`, `slug`, `status` **ersetzt**, nur wenn mitgeschickt `title` ändert nicht `meta.title`. Ein neuer Slug ändert die URL – Weiterleitungen gibt es nicht.
`nav` bei `page_update` **führt zusammen** Einzelne Schlüssel reichen, z. B. `{"nav": {"order": 3}}`. `"parent": null` macht die Seite zum Hauptpunkt.
`image`, `description`, `category`, `order` bei `blog_update` **ersetzt**, nur wenn mitgeschickt Fehlt `meta.image` im mitgeschickten Inhalt, wird `image` übernommen. Hat das gespeicherte Dokument schon ein `meta.image`, ändert ein neues `image` ohne `content` daran nichts – dann `meta.image` mit ändern.
`partial_update` **ersetzt** Header bzw. Footer komplett `meta` behält nur title und description.
`site_update` **ersetzt** `site.json` komplett Erst `GET ?a=site`, ändern, vollständig zurücksenden. Fehlende Felder (og_image, csp …) sind danach weg.
`styles_update` **führt zusammen** Ein Teilobjekt reicht, z. B. nur `colors.primary`.
`custom_css_update` **ersetzt** `custom.css` komplett Erst `GET ?a=custom_css`. Ein Body ohne `css` leert die Datei.
`site_import` **ersetzt** jeden mitgeschickten Teil; Seiten mit gleicher ID werden überschrieben Nicht mitgeschickte Teile und übrige Seiten bleiben.
`pages_import` `mode: "skip"`: vorhandene IDs bleiben; `"overwrite"`: Index-Eintrag und Inhalt **ersetzt** Ohne Schemaprüfung – `page_create`/`page_update` bevorzugen.
`page_delete`, `blog_delete` löscht endgültig Unterseiten werden zu Hauptpunkten; hochgeladene Medien bleiben liegen.

## Alle 30 Aktionen

Alle Aufrufe gehen an `/api.php?a=AKTION`. Öffentlich (ohne Token) sind nur `search_index` und `version_check`.

Aktion Methode Parameter / Body Zweck
[`pages`](#pages) GET – Alle Seiten (Index, inkl. Entwürfe)
[`page`](#page) GET `id` oder `slug` Eine Seite mit Inhalt
[`page_create`](#page_create) POST JSON: title, slug, status, nav, content Seite anlegen (201)
[`page_update`](#page_update) POST `id`; JSON: content, title, slug, status, nav Seite ändern
[`page_delete`](#page_delete) POST `id` Seite löschen
[`pages_export`](#pages_export) GET – Alle Seiten mit Inhalt
[`pages_import`](#pages_import) POST JSON: mode, pages Seiten importieren (ohne Prüfung)
[`blog_posts`](#blog_posts) GET – Beiträge, Kategorien, Blog-Präfix
[`blog_post`](#blog_post) GET `id` oder `slug` Ein Beitrag mit Inhalt
[`blog_create`](#blog_create) POST JSON: title, slug, status, image, description, category, content Beitrag anlegen (201)
[`blog_update`](#blog_update) POST `id`; JSON wie blog_create, dazu order Beitrag ändern
[`blog_delete`](#blog_delete) POST `id` Beitrag löschen
[`site`](#site) GET – `site.json` lesen
[`site_update`](#site_update) POST JSON: vollständiges site-Objekt `site.json` ersetzen
[`site_bundle`](#site_bundle) GET – site, styles, header, footer, custom_css
[`site_export`](#site_export) GET – site, styles, header, footer, custom_css, pages
[`site_import`](#site_import) POST JSON: beliebige dieser Teile Teile einspielen
[`partial`](#partial) GET `part=header` oder `footer` Header/Footer lesen
[`partial_update`](#partial_update) POST `part`; JSON: content Header/Footer ersetzen
[`styles`](#styles) GET – Theme-Werte lesen
[`styles_update`](#styles_update) POST JSON: Teil-Styles Theme-Werte zusammenführen
[`custom_css`](#custom_css) GET – Custom CSS lesen
[`custom_css_update`](#custom_css_update) POST JSON: css Custom CSS ersetzen
[`media`](#media) GET – Alle Mediendateien mit Verwendung
[`media_upload`](#media_upload) POST multipart, Feld `file` Datei hochladen (201)
[`media_usage`](#media_usage) GET optional `path` Verwendung prüfen
[`media_delete`](#media_delete) POST `path` Ungenutzte Datei löschen
[`search_index`](#search_index) GET – (öffentlich) Suchindex
[`sitemap_generate`](#sitemap_generate) GET – (Token nötig) Sitemap & Co. neu erzeugen
[`version_check`](#version_check) GET – (öffentlich) Installierte Version

## Seiten

### pages – alle Seiten

`GET ?a=pages` liefert `data.pages`: alle Index-Einträge, nach Slug sortiert, **einschließlich Entwürfen**. Jeder Eintrag: `id`, `slug`, `title`, `status` (`published` | `draft`), `nav` (`show`, `order`, `label`, `parent`), `created`, `updated`. Die Startseite hat den Slug `home`.

### page – eine Seite lesen

`GET ?a=page&id=ID` oder `GET ?a=page&slug=SLUG` – ist `id` gesetzt, wird `slug` ignoriert. Antwort: `data.page` (Index-Eintrag) und `data.content` (das Dokument). Entwürfe werden ebenfalls geliefert. Unbekannt oder ohne beide Parameter: 404 `page_not_found`.

### page_create – Seite anlegen

Feld Pflicht Verhalten
`content` ja Vollständiges Dokument (siehe [Dokument](#dokument)). Fehlt es: 422 `invalid_page_schema`.
`title` nein Fehlt es, heißt die Seite „Neue Seite“.
`slug` nein Aus `slug` oder `title` erzeugt: Kleinbuchstaben, ä→ae, ö→oe, ü→ue, ß→ss, alles andere wird zu `-`. Ist er belegt, hängt das CMF `-2`, `-3` … an – **Slug aus der Antwort übernehmen**.
`status` nein `"published"` oder `"draft"` (Standard; jeder andere Wert gilt als draft).
`nav.show` nein **Standard `true`** – eine veröffentlichte Seite ohne `nav` erscheint im Menü. Für Seiten außerhalb des Menüs `"nav": {"show": false}` senden.
`nav.order` nein Ganze Zahl, Standard: Anzahl vorhandener Seiten + 1.
`nav.label` nein Menütext; `null` = Titel.
`nav.parent` nein ID **oder** Slug der Elternseite. Unbekannt, Selbstbezug oder Schleife → still `null`. `data.page.nav.parent` in der Antwort prüfen.

```
POST ?a=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>"
          }
        }
      ]
    }
  }
}
```

Antwort **201**:

```
{
  "ok": true,
  "data": {
    "page": {
      "id": "a1b2c3d4e5f6",
      "slug": "leistungen",
      "title": "Leistungen",
      "status": "draft",
      "nav": {
        "show": false,
        "order": 24,
        "label": null,
        "parent": null
      },
      "created": "2026-09-15T10:00:00+02:00",
      "updated": "2026-09-15T10:00:00+02:00"
    },
    "content": {
      "meta": {
        "title": "Leistungen",
        "description": "Was wir anbieten, kurz und konkret."
      },
      "content": {
        "blocks": [
          "…"
        ]
      }
    }
  }
}
```

### page_update – Seite ändern

`POST ?a=page_update&id=ID` (die `id` darf auch im Body stehen). Alle Felder sind optional: `content` ersetzt das Dokument komplett, `title`, `slug` und `status` werden einzeln ersetzt, `nav` wird zusammengeführt. Antwort 200 mit `data.page` und `data.content`.

```
POST ?a=page_update&id=a1b2c3d4e5f6
{
  "content": {
    "meta": {
      "title": "Leistungen",
      "description": "Was wir anbieten, kurz und konkret.",
      "image": "/media/2026/09/titelbild-9f3c2a1b.jpg",
      "robots": "noindex"
    },
    "content": {
      "blocks": [
        {
          "id": "h1_leistungen",
          "type": "heading",
          "data": {
            "level": 1,
            "text": "Leistungen"
          }
        },
        {
          "id": "t_intro",
          "type": "text",
          "data": {
            "html": "<p>Überarbeiteter Einstieg.</p>"
          }
        }
      ]
    }
  }
}
```

Nur veröffentlichen und einhängen, Inhalt unverändert lassen:

```
POST ?a=page_update&id=a1b2c3d4e5f6
{
  "status": "published",
  "nav": {
    "parent": "anleitungen",
    "order": 3
  }
}
```

Fehler: 400 `missing_id`, 404 `page_not_found`, 422 `invalid_page_schema` mit `details`. Ein leerer `title` wird zu „Ohne Titel“.

Ohne `content`-Hülle geht es schief: Ein Body `{"meta": …, "content": {"blocks": …}}` ergibt 422 (meta fehlt, content fehlt). Ein Body `{"data": …}` ergibt `ok: true`, ändert aber nichts.

### page_delete – Seite löschen

`POST ?a=page_delete&id=ID` (oder Body `{"id": "…"}`). Antwort: `data.deleted: true` und `data.page`. Unterseiten verlieren ihre Elternseite (`parent` wird `null`), Medien bleiben erhalten. Gelöschte Seiten lassen sich nicht wiederherstellen.

## Blog

### blog_posts – alle Beiträge

`GET ?a=blog_posts` liefert `data.slug` (Blog-Präfix, Beitrags-URL: `/PRÄFIX/beitrags-slug`), `data.categories` (Liste der im Admin angelegten Kategorien) und `data.posts`, sortiert nach `order` aufsteigend. Jeder Eintrag: `id`, `slug`, `title`, `status`, `image`, `description`, `category`, `order`, `created`, `updated`.

### blog_post – einen Beitrag lesen

`GET ?a=blog_post&id=ID` oder `&slug=SLUG`. Antwort: `data.post` und `data.content`. Unbekannt: 404 `post_not_found`.

### blog_create – Beitrag anlegen

Feld Bedeutung
`content` Pflicht, vollständiges Dokument wie bei Seiten.
`title`, `slug`, `status` Wie bei `page_create`; Standardtitel „Neuer Beitrag“, Slug eindeutig unter den Beiträgen.
`image` Beitragsbild für Karte und Vorschau; wird als `meta.image` übernommen, wenn das Dokument keines hat.
`description` Kurztext für Beitragskarte, `feed.xml` und `llms.txt`.
`content.meta.description` Meta-Beschreibung für Suchmaschinen – **getrennt** von `description` setzen.
`category` Genau ein Wert aus `data.categories` von `blog_posts`, **exakt** geschrieben (Groß-/Kleinschreibung). Die API prüft das nicht; ein abweichender Wert fällt aus gefilterten Übersichten.
`order` Nicht bei `blog_create`: Neue Beiträge landen automatisch oben (`order` = kleinster vorhandener Wert minus 1, höchstens 0).

```
POST ?a=blog_create
{
  "title": "Neue Funktion",
  "slug": "neue-funktion",
  "status": "published",
  "image": "/media/2026/09/titelbild-9f3c2a1b.jpg",
  "description": "Kurztext für die Beitragskarte, den Feed und die llms.txt.",
  "category": "Technik",
  "content": {
    "meta": {
      "title": "Neue Funktion im Überblick",
      "description": "Meta-Beschreibung für Suchmaschinen, getrennt vom Kurztext."
    },
    "content": {
      "blocks": [
        {
          "id": "h1_funktion",
          "type": "heading",
          "data": {
            "level": 1,
            "text": "Neue Funktion im Überblick"
          }
        },
        {
          "id": "t_funktion",
          "type": "text",
          "data": {
            "html": "<p>Worum es geht.</p>"
          }
        }
      ]
    }
  }
}
```

Antwort **201** mit `data.post` und `data.content`. Beiträge haben kein `nav`.

### blog_update – Beitrag ändern

`POST ?a=blog_update&id=ID`. Wie `page_update`, zusätzlich `image`, `description`, `category` und `order` (ganze Zahl; kleiner = weiter oben). Beispiel zum Umsortieren: `{"order": -10}`. Fehler: 400 `missing_id`, 404 `post_not_found`, 422 `invalid_post_schema`.

### blog_delete – Beitrag löschen

`POST ?a=blog_delete&id=ID`. Antwort: `data.deleted: true` und `data.post`. Endgültig.

## Website, Header/Footer, Theme

### site – Website-Einstellungen lesen

`GET ?a=site` liefert `data.site` (Felder siehe [site.json](#site-json)). Im Admin gibt es für `site.json` keine Oberfläche – die API ist der Weg.

### site_update – Website-Einstellungen ersetzen

`POST ?a=site_update` mit dem **vollständigen** site-Objekt (direkt oder als `{"site": …}`). Pflicht: `name` und `lang`. Unbekannte Felder werden verworfen, fehlende sind danach weg. Fehler: 422 `invalid_site_schema` mit `details`.

```
POST ?a=site_update
{
  "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"
    ]
  }
}
```

`site_update` erzeugt Sitemap, Feed und `llms.txt` **nicht** neu. Nach einer Änderung von `baseUrl` oder `name` deshalb `sitemap_generate` aufrufen.

### site_bundle – alles Globale auf einmal

`GET ?a=site_bundle` liefert `data.site`, `data.styles`, `data.header`, `data.footer` und `data.custom_css`. Der beste erste Aufruf.

### partial – Header oder Footer lesen

`GET ?a=partial&part=header` bzw. `part=footer`. Antwort: `data.part` und `data.content` (gleiches Dokumentformat wie Seiten). Anderer Wert: 404 `partial_not_found`.

### partial_update – Header oder Footer ersetzen

`POST ?a=partial_update&part=footer` (`part` darf auch im Body stehen). Schema wie bei Seiten, `meta.title` ist Pflicht. Das Dokument wird komplett ersetzt; `meta` behält nur title und description. Ein leeres `blocks`-Array ist hier erlaubt und leert den Bereich. Fehler: 422 `invalid_partial_schema`.

```
POST ?a=partial_update&part=footer
{
  "content": {
    "meta": {
      "title": "Footer"
    },
    "content": {
      "blocks": [
        {
          "id": "t_footer",
          "type": "text",
          "data": {
            "html": "<p>© 2026 Beispiel · <a href=\"/impressum\">Impressum</a></p>"
          }
        }
      ]
    }
  }
}
```

### styles – Theme-Werte lesen

`GET ?a=styles` liefert `data.styles` mit allen 24 Werten (siehe [Styles-Schema](#styles-schema)).

### styles_update – Theme-Werte zusammenführen

`POST ?a=styles_update` mit einem Teilobjekt (direkt oder als `{"styles": …}`). Nicht genannte Werte bleiben. Die Datei `theme.css` wird sofort neu geschrieben. Fehler: 422 `invalid_styles_schema`, z. B. bei einem Gewicht außer light, regular, bold.

```
POST ?a=styles_update
{
  "colors": {
    "primary": "#ea6b17",
    "link": "#ea6b17"
  },
  "fonts": {
    "heading": "Raleway",
    "heading_weight": "bold"
  }
}
```

### custom_css – Custom CSS lesen

`GET ?a=custom_css` liefert `data.css` – den kompletten Inhalt von `public/assets/css/custom.css`.

### custom_css_update – Custom CSS ersetzen

`POST ?a=custom_css_update` mit `{"css": "…"}`. Der Inhalt **ersetzt die Datei komplett**; PHP-Tags werden entfernt. Fehlt `css`, ist die Datei danach leer. Also: lesen, ergänzen, alles zurückschicken.

```
POST ?a=custom_css_update
{
  "css": "/* bisheriger Inhalt aus GET ?a=custom_css … */\n.hinweis { border-left: 4px solid var(--color-primary); }"
}
```

## Medien

### media – alle Dateien

`GET ?a=media` liefert `data.files`, neueste zuerst. Jeder Eintrag: `path` (z. B. `/media/2026/09/titelbild-9f3c2a1b.jpg`), `url`, `filename`, `ext`, `mime`, `size`, `modified`, `used`, `usage_count`, `deletable`.

### media_upload – Datei hochladen

`POST ?a=media_upload` als `multipart/form-data` mit dem Feld **`file`**. Erlaubt: jpg, jpeg, png, webp, gif, svg, pdf, mp4, mp3, wav. Die Datei landet in `/media/JJJJ/MM/`, der Dateiname wird bereinigt und bekommt **immer** einen Zufallsanhang. SVGs werden per XML-Parser bereinigt: Unsichere Teile entfernt das CMF und speichert die bereinigte Datei. Abgelehnt (`422 invalid_svg`) wird nur, was kein lesbares SVG ist.

```
curl -H "Authorization: Bearer cms_DEIN_API_TOKEN_HIER" \
     -F "file=@titelbild.jpg" \
     "https://deinewebsite.de/api.php?a=media_upload"
```

Antwort **201** – `data.src` ist der Pfad für Blöcke:

```
{
  "ok": true,
  "data": {
    "src": "/media/2026/09/titelbild-9f3c2a1b.jpg",
    "url": "https://deinewebsite.de/media/2026/09/titelbild-9f3c2a1b.jpg",
    "filename": "titelbild-9f3c2a1b.jpg",
    "ext": "jpg",
    "mime": "image/jpeg",
    "size": 184223,
    "modified": "2026-09-15T10:12:00+02:00"
  }
}
```

Fehler: 400 `missing_file`, `upload_failed` (auch wenn die Datei die Upload-Grenze des Servers überschreitet), `invalid_file`; 415 `unsupported_filetype`, `mime_mismatch` (mit `details.ext` und `details.mime`); 422 `invalid_svg`; 500 `media_dir_create_failed`, `move_failed`.

### media_usage – Verwendung prüfen

**Mit `path`:** `GET ?a=media_usage&path=/media/2026/09/titelbild-9f3c2a1b.jpg` liefert `used`, `usage_count` und `references`. Der Pfad muss mit `/media/` beginnen, sonst oder bei fehlender Datei: 404 `media_not_found`.

```
{
  "ok": true,
  "data": {
    "path": "/media/2026/09/titelbild-9f3c2a1b.jpg",
    "used": true,
    "usage_count": 1,
    "references": [
      {
        "source": "Seite: Leistungen",
        "location": "content.blocks.2.data.src"
      }
    ]
  }
}
```

**Ohne `path`:** Übersicht mit `data.summary` (`total`, `used`, `unused`), `data.used_files` und `data.unused_files`. Durchsucht werden Seiten, Header, Footer, Beiträge samt Beitragsbild, Custom CSS sowie `og_image` und `logo` aus `site.json`.

### media_delete – Datei löschen

`POST ?a=media_delete&path=/media/…` oder Body `{"path": "/media/…"}`. Gelöscht werden nur **ungenutzte** Dateien; Antwort `data.deleted: true`. Fehler: 400 `missing_path`, 404 `media_not_found`, 500 `delete_failed`. Ist die Datei eingebunden, kommt 409 – die Fundstellen stehen in `data.references`, nicht in `details`:

```
{
  "ok": false,
  "error": "media_in_use",
  "data": {
    "path": "/media/2026/09/titelbild-9f3c2a1b.jpg",
    "usage_count": 1,
    "references": [
      {
        "source": "Seite: Leistungen",
        "location": "content.blocks.2.data.src"
      }
    ]
  }
}
```

## Export und Import

### site_export – Website exportieren

`GET ?a=site_export` liefert `data.site`, `data.styles`, `data.header`, `data.footer`, `data.custom_css` und `data.pages` (Liste aus `{index, content}`). **Blog und Medien sind nicht enthalten.**

### site_import – Teile einspielen

`POST ?a=site_import` mit beliebigen dieser Schlüssel. Jeder Teil wird einzeln geprüft: `site` (name, lang), `styles`, `header`/`footer` und jede Seite gegen das Schema; `custom_css` ersetzt die Datei. Seiten mit vorhandener ID werden überschrieben, neue angelegt. Seiten-IDs müssen `a-z`, `0-9`, `-`, `_` sein (höchstens 64 Zeichen), sonst wird der Eintrag still übersprungen. Doppelte Slugs prüft `site_import` nicht: Zwei Seiten mit gleichem Slug werden beide gespeichert, erreichbar ist nur die erste.

Die Antwort ist **immer** `ok: true` – abgelehnte Teile stehen in `data.rejected`:

```
{
  "ok": true,
  "data": {
    "imported": [
      "styles",
      "pages (2)"
    ],
    "rejected": {
      "page:a1b2c3d4e5f6": [
        "meta.title fehlt"
      ]
    }
  }
}
```

### pages_export – alle Seiten exportieren

`GET ?a=pages_export` liefert `data.pages` (Liste aus `{index, content}`) und `data.count`.

### pages_import – Seiten importieren

`POST ?a=pages_import` mit `mode` (`"skip"` = Standard, vorhandene IDs bleiben; `"overwrite"` = ersetzen) und `pages`. Neue Seiten mit belegtem Slug bekommen `-2` usw. Antwort: `data.imported` und `data.skipped`. Fehlt `pages`: 422 mit `error` „pages array fehlt“.

```
POST ?a=pages_import
{
  "mode": "skip",
  "pages": [
    {
      "index": {
        "id": "a1b2c3d4e5f6",
        "slug": "leistungen",
        "title": "Leistungen",
        "status": "draft",
        "nav": {
          "show": false,
          "order": 5,
          "label": null,
          "parent": null
        }
      },
      "content": {
        "meta": {
          "title": "Leistungen",
          "description": "Was wir anbieten, kurz und konkret.",
          "image": "/media/2026/09/titelbild-9f3c2a1b.jpg",
          "robots": "noindex"
        },
        "content": {
          "blocks": [
            {
              "id": "h1_leistungen",
              "type": "heading",
              "data": {
                "level": 1,
                "text": "Leistungen"
              }
            },
            {
              "id": "t_intro",
              "type": "text",
              "data": {
                "html": "<p>Überarbeiteter Einstieg.</p>"
              }
            }
          ]
        }
      }
    }
  ]
}
```

`pages_import` prüft die Inhalte **nicht** gegen das Schema und übernimmt den Index-Eintrag ohne Prüfung (nur der Slug wird bereinigt). Fehlerhafte Seiten werden gespeichert und rendern kaputt. Für einzelne Seiten `page_create` bzw. `page_update` nehmen.

Eine ganze Website mit Blog, Medien und Theme klonst du über den ZIP-Export und -Import im Admin unter „Einstellungen“ – dabei bleiben alle Pfade gleich. Die `config/site.json` steckt nicht im ZIP: Name, Adresse und weitere Einstellungen trägst du auf der Zielinstallation selbst ein.

## Öffentliche und System-Aktionen

### search_index – Suchindex (öffentlich)

`GET ?a=search_index` ohne Token. `data.pages` enthält veröffentlichte Seiten und Beiträge mit `slug` (Beiträge mit Blog-Präfix), `title`, `description` und `text`. Dieselben Daten liegen als statische Datei unter `/search-index.json`.

### sitemap_generate – abgeleitete Dateien neu erzeugen

`GET ?a=sitemap_generate` **mit Token**. Schreibt `sitemap.xml`, `robots.txt`, `feed.xml`, `llms.txt` und `search-index.json` neu; Antwort `data.generated: true`. Nötig ist das nur nach `site_update` (z. B. neue `baseUrl`) – nach Änderungen an Seiten und Beiträgen passiert es automatisch.

### version_check – Version (öffentlich)

`GET ?a=version_check` ohne Token, mit `Access-Control-Allow-Origin: *`. Antwort: `data.version`, `data.date`, `data.changelog`, `data.download_url`. Antwortet auch während eines laufenden System-Updates.

## Dokument: meta und nav

Seiten, Beiträge, Header und Footer haben denselben Aufbau:

```
{
  "meta": {
    "title": "Pflicht, nicht leer",
    "description": "empfohlen",
    "image": "/media/2026/09/vorschau-1a2b3c4d.jpg",
    "robots": "noindex"
  },
  "content": {
    "blocks": [
      "…"
    ]
  }
}
```

meta-Feld Bedeutung
`title` Pflicht. Wird zum `<title>`, mit dem Anhang aus `site.json` (`title_suffix`, sonst `name`).
`description` Meta-Beschreibung; bei Seiten auch Kurztext in `llms.txt` und Suche.
`image` Vorschaubild (og:image). Fehlt es, nimmt das CMF das erste Bild der Seite, dann `og_image` aus `site.json`.
`robots` Z. B. `"noindex"` oder `"noindex, nofollow"`. Enthält der Wert noindex, fehlt die Seite in Sitemap und `llms.txt`.

Andere `meta`-Felder werden verworfen.

### Regeln für content.blocks

- `blocks` ist ein Array. Jeder Block hat `id`, `type` und `data`.
- `id` ist nicht leer und **eindeutig im ganzen Dokument** – auch über alle Spalten eines `columns`-Blocks hinweg. Vorhandene IDs beim Ändern behalten.
- `type` ist einer der [9 Blocktypen](#bloecke); `data` ist immer ein Objekt, auch wenn es leer ist (`{}`).
- Genau ein `heading` mit `level: 1` pro Seite. Das Schema prüft das nicht – es ist trotzdem Pflicht für saubere Seiten.
- Ein leeres Array `"blocks": []` wird bei Seiten und Beiträgen durch einen Platzhalter ersetzt (Überschrift mit dem Titel und „Inhalt hier.“).

### Das nav-Objekt (nur Seiten)

Schlüssel Typ Bedeutung
`show` bool Im Menü anzeigen. Standard `true`.
`order` int Reihenfolge, kleiner = weiter vorn.
`label` string | null Menütext; `null` = Seitentitel.
`parent` string | null ID oder Slug der Elternseite (wird als ID gespeichert). Ungültig → `null`.

## Die 9 Blocktypen

Typ Pflicht in `data` Optional Hinweise
`heading` `level` (1–6), `text` (nicht leer) – Reiner Text, HTML erscheint als Text.
`text` `html` (String) – Nur erlaubte Tags, siehe [Filter](#text-block).
`image` `src` (nicht leer), `alt` (String, darf leer sein) `caption`, `loading` (`"lazy"` Standard oder `"eager"`), `width`, `height` `width`/`height` als ganze Zahl ≥ 1, sonst 422. Sie wirken nur, wenn **beide** gesetzt sind, und werden nicht automatisch ermittelt.
`list` `ordered` (bool), `items` (Array aus Strings) – Einträge sind reiner Text.
`buttons` `items` (nicht leer), je `label` und `href` `style`: `"primary"` oder `""` `href` nur relativ, `#anker`, http(s), mailto, tel – sonst wird `#` daraus.
`columns` `columns` (2–5), `items`: genau so viele Arrays aus Blöcken – Blöcke in Spalten brauchen ebenfalls eindeutige IDs.
`html` `code` (String) – Wird **ungefiltert** ausgegeben. Für Tabellen, Karten, Raster, Einbettungen. Externe Skripte brauchen einen Eintrag in `site.json` → `csp`.
`blog_overview` – (`data` als `{}`) `category` Karten der veröffentlichten Beiträge; `category` filtert exakt, leer = alle.
`form` `fields` (nicht leer) siehe [Formular-Block](#formular) Postet auf die eigene Seiten-URL.

Eine Seite mit acht Blocktypen, die genau so gegen das Schema besteht:

```
{
  "meta": {
    "title": "Beispielseite",
    "description": "Kurzbeschreibung für Suchmaschinen.",
    "image": "/media/2026/09/titelbild-9f3c2a1b.jpg"
  },
  "content": {
    "blocks": [
      {
        "id": "h1_beispiel",
        "type": "heading",
        "data": {
          "level": 1,
          "text": "Beispielseite"
        }
      },
      {
        "id": "t_einleitung",
        "type": "text",
        "data": {
          "html": "<p>Absatz mit <strong>Hervorhebung</strong> und <a href=\"/kontakt\">Link</a>.</p>"
        }
      },
      {
        "id": "img_titel",
        "type": "image",
        "data": {
          "src": "/media/2026/09/titelbild-9f3c2a1b.jpg",
          "alt": "Werkstatt von innen",
          "caption": "Unsere Werkstatt",
          "loading": "eager",
          "width": 1600,
          "height": 900
        }
      },
      {
        "id": "l_vorteile",
        "type": "list",
        "data": {
          "ordered": false,
          "items": [
            "Schnell geladen",
            "Ohne Datenbank"
          ]
        }
      },
      {
        "id": "b_aktion",
        "type": "buttons",
        "data": {
          "items": [
            {
              "label": "Kontakt",
              "href": "/kontakt",
              "style": "primary"
            },
            {
              "label": "Mehr erfahren",
              "href": "/warum-cmf",
              "style": ""
            }
          ]
        }
      },
      {
        "id": "c_zwei",
        "type": "columns",
        "data": {
          "columns": 2,
          "items": [
            [
              {
                "id": "h2_links",
                "type": "heading",
                "data": {
                  "level": 2,
                  "text": "Linke Spalte"
                }
              }
            ],
            [
              {
                "id": "t_rechts",
                "type": "text",
                "data": {
                  "html": "<p>Rechte Spalte.</p>"
                }
              }
            ]
          ]
        }
      },
      {
        "id": "html_tabelle",
        "type": "html",
        "data": {
          "code": "<div class=\"table-wrap\"><table><tr><td>A</td><td>B</td></tr></table></div>"
        }
      },
      {
        "id": "blog_news",
        "type": "blog_overview",
        "data": {
          "category": ""
        }
      }
    ]
  }
}
```

## Filter im text-Block

Der text-Block erlaubt genau diese 17 Tags:

```
a b strong i em u br p ul ol li code pre span small sup sub
```

- Alle anderen Tags – etwa `h2`, `img`, `table`, `div`, `blockquote` – werden **still entfernt**; ihr Textinhalt bleibt stehen. Es gibt keine Fehlermeldung.
- Die Attribute `on…` (Event-Handler), `formaction` und `style` werden entfernt.
- `href` und `src` sind nur relativ, als `#anker`, `http:`, `https:`, `mailto:` oder `tel:` erlaubt; alles andere wird zu `#`.
- Der Filter wirkt bei der Ausgabe. Im gespeicherten JSON steht weiterhin, was du geschickt hast – ein `GET` zeigt also nicht, was auf der Seite ankommt.
- Für Zwischenüberschriften einen `heading`-Block nehmen, für Tabellen, Karten und Raster den `html`-Block.

## 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 Datenschutzerklärung gelesen.",
        "required": true
      }
    ]
  }
}
```

Feld in `data` Regel
`fields` Pflicht, nicht leeres Array.
`title` Optional; erscheint als Überschrift (h2) über dem Formular.
`intro` Optional; Einleitungstext.
`submit_label` Optional; Standard „Absenden“.
`success_message` Optional; Text nach dem Absenden.
`store` Standard an. Nur `false` schaltet das Speichern ab. Gespeicherte Einsendungen stehen im Admin unter „Einsendungen“ (höchstens 2000 je Formular, die ältesten fallen heraus).
`email_to` Optional; leer oder eine gültige E-Mail-Adresse, sonst 422. Schickt jede Einsendung zusätzlich per Mail.
`confirm` Optional, bool, Standard `false`. Bestätigungsmail mit den Angaben an die Adresse aus dem ersten `email`-Feld.

Feld in `fields[]` Regel
`name` Pflicht; nur `a-z`, `0-9`, `_`; eindeutig im Formular.
`label` Pflicht.
`type` Pflicht: `text`, `email`, `tel`, `textarea`, `select`, `checkbox` oder `radio`.
`required` Optional, bool. Eine Pflicht-Checkbox muss angehakt werden.
`options` Pflicht bei `select` und `radio`: nicht leeres Array aus nicht leeren Strings.

- Spam-Schutz ohne Cookie: verstecktes Honeypot-Feld und signierte Zeitfalle (Absenden frühestens nach 2 Sekunden, Formular bis zu einer Stunde gültig).
- Längen: Textfelder bis 200 Zeichen, `textarea` bis 5000.
- Drosseln: 10 Einsendungen je IP pro Stunde; Mails an `email_to` höchstens 100 pro Stunde; Bestätigungsmails höchstens 5 je IP und 60 insgesamt pro Stunde.
- Formulare funktionieren auch in Beiträgen, im Header und im Footer.
- Einsendungen sind über die API nicht abrufbar.

## site.json-Felder

Feld Typ Bedeutung
`name` string, Pflicht Name der Website (Titel-Anhang, strukturierte Daten, Feed).
`lang` string, Pflicht Sprache, z. B. `de`.
`baseUrl` string Absolute Adresse ohne `/` am Ende. Grundlage für Canonical, Sitemap, Feed, `llms.txt` und `url` bei Medien.
`og_image` string Standard-Vorschaubild.
`logo` string Logo-Pfad (Favicon-Link und strukturierte Daten).
`title_suffix` string Titel-Anhang „ | Wert“. Leer = kein Anhang; fehlt das Feld, wird `name` angehängt.
`software_schema` bool Gibt zusätzlich SoftwareApplication-Daten (JSON-LD) aus.
`download_url`, `software_category`, `software_version` string Angaben dafür; `software_category` Standard `DeveloperApplication`.
`csp` Objekt Erweitert die Content-Security-Policy. Schlüssel: `script_src`, `connect_src`, `style_src`, `img_src`, `font_src`, `media_src`, `frame_src`, je ein Array aus Quellen wie `https://widget.example.com`. Ungültige Quellen werden verworfen.

Andere Felder verwirft `site_update`. Denk daran: `site_update` ersetzt die Datei komplett.

## Styles-Schema

24 Werte in vier Gruppen. Das Beispiel zeigt die Rückfallwerte, die gelten, wenn ein Wert nirgends gesetzt ist:

```
{
  "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"
  }
}
```

Gruppe Schlüssel Werte
Maße `container`, `pad`, `gap` CSS-Längen als String, z. B. `"1100px"`
`radius` `sm`, `md`, `lg` CSS-Längen
`colors` `bg`, `text`, `muted`, `border`, `primary`, `secondary`, `primary_text`, `link` Farben, z. B. `"#ea6b17"`
`type` `body`, `h1` bis `h5` Schriftgrößen, z. B. `"1rem"`
`fonts` `body`, `heading` Schriftfamilie: `Inter`, `Lato`, `Merriweather`, `Montserrat`, `Nunito`, `OpenSans`, `PTSerif`, `PlayfairDisplay`, `Raleway`, `Roboto` – oder leer für die Systemschrift
`fonts` `body_weight`, `heading_weight` `light` (300), `regular` (400), `bold` (700); PT Serif gibt es nur regular und bold

- Schriftnamen ohne Leerzeichen, genau wie oben. Ein unbekannter Name wird gespeichert, lädt aber keine Schriftdatei.
- Unsichere CSS-Werte (z. B. mit `;`, `{` oder `url(`) landen nicht in `theme.css`; dort gilt dann der Rückfallwert.
- Eigene Klassen gehören in das Custom CSS (`custom_css_update`) und werden in `html`-Blöcken per `class` genutzt – keine Inline-Styles.

## HTTP-Codes und Fehler

HTTP `error` Wann
200 – Erfolg. `ok: true` heißt nicht automatisch „Inhalt geändert“ – `data.content` der Antwort prüfen.
201 – Angelegt: `page_create`, `blog_create`, `media_upload`.
400 `invalid_json` Body ist kein JSON-Objekt (bei allen Aktionen, die JSON erwarten).
400 `missing_id` `page_update`, `page_delete`, `blog_update`, `blog_delete` ohne `id`.
400 `missing_path` `media_delete` ohne `path`.
400 `missing_file`, `upload_failed`, `invalid_file` `media_upload`: kein Feld `file`, Upload abgebrochen oder zu groß, Datei ohne Namen oder Endung.
401 `missing_token`, `invalid_token`, `token_disabled` Token fehlt, ist unbekannt oder gesperrt.
404 `page_not_found`, `post_not_found` ID oder Slug unbekannt.
404 `partial_not_found` `part` ist nicht `header` oder `footer`.
404 `media_not_found` Datei fehlt oder Pfad beginnt nicht mit `/media/`.
404 `unknown_action` Aktion gibt es nicht.
405 `method_not_allowed` Falsche Methode; der Header `Allow` nennt die richtige.
409 `media_in_use` Datei ist eingebunden; Fundstellen in `data.references`.
415 `unsupported_filetype`, `mime_mismatch` Endung nicht erlaubt bzw. Inhalt passt nicht zur Endung (`details.ext`, `details.mime`).
422 `invalid_page_schema`, `invalid_post_schema`, `invalid_partial_schema` Dokument verletzt das Schema; `details` ist eine Liste.
422 `invalid_site_schema`, `invalid_styles_schema` site bzw. styles ungültig; `details` ist eine Liste.
422 `invalid_svg` SVG ungültig oder nicht sicher bereinigbar (ohne `details`).
422 „pages array fehlt“ `pages_import` ohne `pages`.
500 `media_dir_create_failed`, `move_failed`, `delete_failed` Schreibrechte in `public/media/` prüfen. Andere Schreibfehler können ohne JSON-Antwort enden.
503 – System-Update läuft (Wartungsseite mit `Retry-After`). Nur `version_check` antwortet.

Typische `details`-Einträge: `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 name ungültig (nur a-z 0-9 _)`.

## Was die API nicht kann

- Einsendungen des Formular-Blocks lesen, exportieren oder löschen – das geht nur im Admin.
- Benutzer, Passwörter und API-Tokens verwalten.
- Blog-Einstellungen ändern: Blog-Präfix und Kategorienliste. Kategorien lassen sich Beiträgen nur zuweisen.
- System-Update, Backup, Rollback und den ZIP-Export/-Import mit Medien und Blog ausführen.
- Einzelne Blöcke gezielt ändern: Geschrieben wird immer das ganze Dokument.
- Weiterleitungen anlegen: Ein geänderter Slug macht die alte URL zu einer 404.
- Medien umbenennen, ersetzen oder verschieben und Bildmaße ermitteln – `width`/`height` setzt du selbst.
- Blog und Medien über `site_export`/`site_import` übertragen.
- Entwürfe anzeigen: `page` und `blog_post` liefern sie, Website und `.md`-Ausgabe zeigen nur Veröffentlichtes.
- Rückgängig machen: Es gibt keinen Papierkorb und keine Versionsgeschichte.
- Aufrufe aus dem Browser einer fremden Domain annehmen (keine CORS-Header).

## Typische Fallen

- **Hülle vergessen:** `page_update` ohne `{"content": …}` → 422; mit `{"data": …}` → `ok: true`, aber nichts geändert.
- **meta unvollständig:** `meta.image` und `meta.robots` nicht mitgeschickt → gelöscht (bei Beiträgen fällt `meta.image` auf `image` zurück).
- **Menü:** `page_create` ohne `"nav": {"show": false}` → eine veröffentlichte Seite erscheint im Menü.
- **Elternseite vertippt:** `nav.parent` unbekannt → still `null`. `data.page.nav.parent` prüfen.
- **Slug belegt:** Das CMF hängt `-2` an. Den Slug aus der Antwort verwenden, nicht den gesendeten.
- **site_update mit Teilobjekt:** 422 (name/lang fehlen) oder Verlust aller nicht mitgeschickten Felder wie `og_image` und `csp`.
- **custom_css_update mit Ausschnitt:** Der Rest des CSS ist weg; `{}` leert die Datei.
- **HTML an der falschen Stelle:** In `heading.text`, `list.items` und `buttons.label` erscheint HTML als Text; Tabellen oder Bilder im text-Block werden still entfernt.
- **Bildmaße:** `width`/`height` als 0 oder als Text wie `"800px"` → 422; nur eines gesetzt → wirkt nicht.
- **IDs:** Dieselbe ID in zwei Spalten → 422 `doppelte id`.
- **Leere Seite:** `"blocks": []` ergibt einen Platzhalter, keine leere Seite.
- **Kategorie:** `"technik"` statt `"Technik"` → der Beitrag fehlt in der gefilterten Übersicht.
- **Zwei Beschreibungen:** `description` (Karte, Feed, llms.txt) und `content.meta.description` (Meta-Tag) sind getrennte Felder.
- **pages_import:** speichert auch fehlerhafte Inhalte.
- **site_import:** `ok: true` trotz abgelehnter Teile – `data.rejected` prüfen.
- **Neue baseUrl:** nach `site_update` `sitemap_generate` aufrufen.
- **ok heißt nicht geändert:** immer `data.content` der Antwort gegen das Gesendete prüfen.

- [Zu den Anleitungen](/anleitungen)
- [Seiten per Maschine erstellen](/maschinen-anleitung)
