API-Referenz
Inhalt
- Grundlagen
- Authentifizierung
- Arbeitsweise
- Ersetzt oder führt zusammen?
- Alle 30 Aktionen
- Seiten
- Blog
- Website, Header/Footer, Theme
- Medien
- Export und Import
- Öffentliche und System-Aktionen
- Dokument: meta und nav
- Die 9 Blocktypen
- Filter im text-Block
- Formular-Block
- site.json-Felder
- Styles-Schema
- HTTP-Codes und Fehler
- Was die API nicht kann
- Typische 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_HIERJede Antwort hat dieselbe Hülle:
{ "ok": true, "data": { … } }
{ "ok": false, "error": "invalid_page_schema", "details": [ "heading h1_start text fehlt" ] }detailsgibt es nur bei einigen Fehlern, siehe HTTP-Codes und Fehler.- 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.txtundsearch-index.jsonautomatisch neu. - Diese Referenz gibt es auch als Markdown; die kompakte Fassung für Maschinen-Agenten liegt unter /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.jsonersetzen. 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=pagesbzw.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_uploadhochladen unddata.srcübernehmen – Pfade nie selbst erfinden. data.contentändern: vorhandene Block-IDs behalten, neue IDs eindeutig vergeben.- Mit
page_updatebzw.blog_updatevollständig zurückschreiben. - Antwort prüfen:
data.content,data.page.slugunddata.page.navzeigen, 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). 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). 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_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
blocksist ein Array. Jeder Block hatid,typeunddata.idist nicht leer und eindeutig im ganzen Dokument – auch über alle Spalten einescolumns-Blocks hinweg. Vorhandene IDs beim Ändern behalten.typeist einer der 9 Blocktypen;dataist immer ein Objekt, auch wenn es leer ist ({}).- Genau ein
headingmitlevel: 1pro 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. |
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 | 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),formactionundstylewerden entfernt. hrefundsrcsind nur relativ, als#anker,http:,https:,mailto:odertel:erlaubt; alles andere wird zu#.- Der Filter wirkt bei der Ausgabe. Im gespeicherten JSON steht weiterhin, was du geschickt hast – ein
GETzeigt also nicht, was auf der Seite ankommt. - Für Zwischenüberschriften einen
heading-Block nehmen, für Tabellen, Karten und Raster denhtml-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,
textareabis 5000. - Drosseln: 10 Einsendungen je IP pro Stunde; Mails an
email_tohö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
;,{oderurl() landen nicht intheme.css; dort gilt dann der Rückfallwert. - Eigene Klassen gehören in das Custom CSS (
custom_css_update) und werden inhtml-Blöcken perclassgenutzt – 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/heightsetzt du selbst. - Blog und Medien über
site_export/site_importübertragen. - Entwürfe anzeigen:
pageundblog_postliefern 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_updateohne{"content": …}→ 422; mit{"data": …}→ok: true, aber nichts geändert. - meta unvollständig:
meta.imageundmeta.robotsnicht mitgeschickt → gelöscht (bei Beiträgen fälltmeta.imageaufimagezurück). - Menü:
page_createohne"nav": {"show": false}→ eine veröffentlichte Seite erscheint im Menü. - Elternseite vertippt:
nav.parentunbekannt → stillnull.data.page.nav.parentprüfen. - Slug belegt: Das CMF hängt
-2an. 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_imageundcsp. - custom_css_update mit Ausschnitt: Der Rest des CSS ist weg;
{}leert die Datei. - HTML an der falschen Stelle: In
heading.text,list.itemsundbuttons.labelerscheint HTML als Text; Tabellen oder Bilder im text-Block werden still entfernt. - Bildmaße:
width/heightals 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) undcontent.meta.description(Meta-Tag) sind getrennte Felder. - pages_import: speichert auch fehlerhafte Inhalte.
- site_import:
ok: truetrotz abgelehnter Teile –data.rejectedprüfen. - Neue baseUrl: nach
site_updatesitemap_generateaufrufen. - ok heißt nicht geändert: immer
data.contentder Antwort gegen das Gesendete prüfen.