docs: Divi guides shortform/placeholders, hints name the write tools; test: Divi write end-to-end; version 0.3.0

- Hints (PostPresenter::DIVI_NOTE, outline, catalog) and server instructions (tree_rev rule)
  name the Divi write tools; guides divi-shortform and divi-placeholders, additions to
  divi-structure, divi-attrs, overview (tool groups, 'nur lesen'), workflows, content-types.
- D7 wording and the S7 exception in readme, guides and spec 1.3/5.2; readme 0.3.0.
- ToolNamingTest: every referenced guide topic exists; overview lists exactly the topics.
- tests/Integration/divi-write-smoke.sh: all write tools via OAuth, editor without
  unfiltered_html with benign probes (S6), Divi's own save filters on the new fixture
  synthetic-divi-filters.html (S7); admin-render.php accepts the new wrap class.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 17:13:59 +02:00
co-authored by Claude Opus 5.5
parent b044cacc9d
commit 10c98cd3c6
24 changed files with 811 additions and 26 deletions
+8 -6
View File
@@ -23,14 +23,16 @@ Werkzeugnamen: `lit-…` sind direkt aufrufbare Tools; `lit/…` sind Abilities,
Trefferliste. Geliefert werden nur Einträge, die der verbundene Nutzer selbst bearbeiten darf (fremde
Entwürfe/private Inhalte ohne die passende Berechtigung erscheinen nicht).
- `lit-content-get`: alle Daten eines Eintrags inkl. Feldern, Begriffen, Beitragsbild und `rev`.
Bei Divi-Seiten fehlt `content`; den Aufbau liest `lit-divi-outline` (siehe `divi-structure`), geändert
wird er vorerst im Divi-Builder (Titel, Auszug, Felder und Begriffe gehen weiter über
`lit-content-update`).
Bei Divi-Seiten fehlt `content`; den Aufbau liest `lit-divi-outline`, geändert wird er mit den
Divi-Werkzeugen (siehe `divi-structure`); Titel, Auszug, Felder und Begriffe gehen weiter über
`lit-content-update`.
## Anlegen und Ändern
- `lit-content-create` legt immer einen **Entwurf** an. Felder werden gegen das Schema geprüft; ein Fehler
bricht den ganzen Aufruf ab (`fields.<schlüssel>` im Fehler nennt die Ursache).
bricht den ganzen Aufruf ab (`fields.<schlüssel>` im Fehler nennt die Ursache). Divi-Seiten gleich mit
Inhalt: `divi` = Liste von Abschnitten in Kurzform (siehe `divi-shortform`) statt `content`; die Antwort
enthält dann die `tree_rev` für weitere Divi-Änderungen.
- `lit-content-update` ändert nur die übergebenen Werte, der Status bleibt. `rev` aus `lit-content-get` ist
**Pflicht** – hat jemand parallel gespeichert (Inhalt, Titel, Auszug, Status oder irgendetwas anderes am
Eintrag), kommt `rev_mismatch`: neu lesen, Änderung auf dem aktuellen Stand wiederholen.
@@ -59,8 +61,8 @@ Werkzeugnamen: `lit-…` sind direkt aufrufbare Tools; `lit/…` sind Abilities,
## Sicher überarbeiten
1. `lit/post-duplicate` → Entwurfskopie; die Antwort enthält die `rev` der Kopie für `lit-content-update`.
2. Kopie mit `lit-content-update` (Divi-Inhalte vorerst im Divi-Builder) bearbeiten und dem Nutzer die
Vorschau zeigen; `lit-page-render-summary` fasst den gerenderten Inhalt zusammen.
2. Kopie mit `lit-content-update` bzw. den Divi-Werkzeugen bearbeiten und dem Nutzer die Vorschau zeigen;
`lit-page-render-summary` fasst den gerenderten Inhalt zusammen.
3. `lit/post-replace-with-copy` übernimmt die Kopie ins Original (ID, URL, Status und Menüeinträge bleiben).
Voraussetzungen: die Kopie muss noch Entwurf oder ausstehend sein, `rev` des Originals aus
`lit-content-get` ist Pflicht (sie deckt Titel, Inhalt, Auszug und Status ab – Felder/Meta, die direkt am
+25
View File
@@ -18,6 +18,31 @@ Der Wert eines Pfads ist eine Zuordnung **Breakpoint → Zustand → Wert**:
Breakpoints: `desktop`, `tablet`, `phone` (je nach Website auch `tabletWide`, `phoneWide`, `widescreen`,
`ultraWide`); Zustände: `value`, `hover`, `sticky`.
## Setzen
In `attrs` (bei `lit-divi-insert` je Knoten, bei `lit-divi-update` je Änderung) steht Pfad → Wert je Breakpoint.
Kurz: `{"desktop": "#1a1a1a", "phone": "…"}` bzw. bei Objektwerten `{"desktop": {"size": "28px"}}`; Hover und
Sticky: `{"hover": {…}}` bzw. `{"desktop": {"value": {…}, "hover": {…}}}`. Nur die genannten Breakpoints
werden ersetzt, der Rest bleibt; `null` entfernt einen Pfad, Breakpoint oder Zustand:
```json
{ "id": "s0.r0.c1.m0",
"attrs": { "title.decoration.font.font": { "desktop": { "color": "{{color:heading}}" },
"phone": { "size": "28px" } },
"module.decoration.spacing": null } }
```
Ein Objektwert ersetzt den bisherigen Wert dieses Breakpoints ganz (Unterschlüssel werden nicht
zusammengeführt): z. B. bei `module.decoration.sizing` einer Spalte `flexType` immer mitschicken (siehe
`divi-shortform`).
Unbekannte Pfade → Fehler `invalid_attr_path` mit Vorschlägen unter `fields`. Texte werden bereinigt
(HTML nur, wo Divi Rich-Text erwartet; Links ohne `javascript:`). `$variable`-Verweise werden geparst,
geprüft und vom Plugin neu erzeugt (siehe `divi-placeholders`). Eigenes HTML, CSS und HTML-Attribute
(`…decoration.attributes`, `…advanced.html`, `…css…`) nur mit dem Recht `unfiltered_html`, sonst „nur im
Divi-Builder“. Unberührte Teile der Seite bleiben byte-identisch; Ausnahme: Divi selbst normalisiert Seiten
mit eigenen HTML-/Attribut-Feldern beim Speichern (Warnung in der Antwort, neue `tree_rev` gilt).
## Pfade finden
- `lit-divi-catalog` ohne `module` listet alle Module dieser Website; mit `module` (z. B. `"heading"`) alle
+47
View File
@@ -0,0 +1,47 @@
# Platzhalter für Divi-Inhalte
Platzhalter stehen **allein** im Wert (nicht mitten im Text) und werden beim Speichern in Divis eigene
Verweise übersetzt. So bleibt die Seite mit dem Designsystem verbunden: ändert sich die Primärfarbe, ändert
sich auch der Button.
| Platzhalter | Bedeutung |
|---|---|
| `{{color:primary}}`, `secondary`, `heading`, `body`, `link` | die fünf Theme-Farben |
| `{{color:<Name>}}` | eigene globale Farbe (Name wie in `lit-divi-design-system`) |
| `{{var:<Name>}}` | globale Variable (Zahl, Text, Bild, Link) |
| `{{font:heading}}`, `{{font:body}}` | Theme-Schrift für Überschriften bzw. Fließtext |
| `{{post:title}}`, `excerpt`, `date`, `modified`, `author`, `url`, `featured_image`, `categories`, `tags` | dynamischer Inhalt des angezeigten Beitrags |
| `{{field:<meta_key>}}` | dynamischer Inhalt aus einem Feld (Pods/Meta), z. B. `{{field:telefon}}` |
| `"preset": "<Name>"` | Modul-Preset (kein Platzhalter, sondern eigenes Feld) |
Beispiele:
```json
{ "type": "button", "text": "Anrufen", "url": "{{field:telefon_link}}",
"attrs": { "button.decoration.background": { "desktop": { "color": "{{color:primary}}" },
"hover": { "color": "{{color:Nachtblau}}" } } } }
```
```json
{ "type": "heading", "text": "{{post:title}}", "level": "h1" }
```
Unbekannte Namen lehnt das Werkzeug ab und nennt die vorhandenen; ist ein Name doppelt vergeben, statt des
Namens die ID aus `lit-divi-design-system` angeben (z. B. `{{color:gcid-…}}`). Felder, deren Schlüssel mit `_`
beginnt (geschützte Meta-Daten), sind nicht erlaubt.
In Link-Feldern (`url`, `button_url` …) nimmt das Werkzeug jeden dynamischen Inhalt an, auch `{{post:title}}`;
sinnvoll sind dort nur Werte, die eine Adresse ergeben (`{{post:url}}`, ein Feld mit einer URL, eine
Link-Variable).
## `$variable`-Werte
In `lit-divi-node-get` erscheinen die übersetzten Verweise als `$variable({…})$`. Diese Werte unverändert
lassen oder wieder durch einen Platzhalter ersetzen. `$variable`-Verweise werden geparst, geprüft und vom
Plugin neu erzeugt: erlaubt sind nur ganze Werte mit bekannten Farben, Variablen, Schriften und
dynamischen Quellen; Zusatztexte davor/danach (`before`/`after`) werden bereinigt; alles andere (z. B. ein
Verweis mitten im Text, eigenes HTML-Ausgeben von Feldern) lehnt das Werkzeug mit `validation_failed` ab.
Unberührte Teile der Seite bleiben dabei byte-identisch. Ausnahme: Divi selbst normalisiert Seiten mit
eigenen HTML-/Attribut-Feldern beim Speichern; die Antwort warnt dann, dass der Inhalt beim Speichern
verändert wurde, und die neue `tree_rev` gilt.
+112
View File
@@ -0,0 +1,112 @@
# Divi-Kurzform
Neue Divi-Inhalte beschreibt man als JSON-Knoten. `lit-divi-insert` (in `nodes`) und `lit-content-create` (in
`divi`) nehmen dieselbe Form. Jeder Knoten hat einen `type`; dazu Kurzform-Felder, optional `label`
(Admin-Label), `preset` (Name eines Modul-Presets aus `lit-divi-design-system`) und `attrs` (Vollform, siehe
`divi-attrs`). Kurz- und Vollform lassen sich kombinieren, aber nicht für denselben Pfad (dann Fehler
`validation_failed` mit dem Pfad).
## Aufbau
```json
{ "type": "section", "label": "Hero", "background_color": "{{color:primary}}",
"rows": [
{ "columns": "1_3,2_3",
"modules": [
[ { "type": "image", "image_id": 33 } ],
[ { "type": "heading", "text": "Willkommen", "level": "h1" },
{ "type": "text", "content": "<p>Seit 1987 in Köln.</p>" },
{ "type": "button", "text": "Termin buchen", "url": "/kontakt", "preset": "CTA primär" } ]
] }
] }
```
- **Abschnitt** (`section`): `rows` (Zeilen) oder – für eine einspaltige Zeile – direkt `modules`;
`section_type: "fullwidth"` für Vollbreite-Abschnitte (dann nur `fullwidth-…`-Module in `modules`);
`background_color`. Spezialabschnitte (specialty) nur im Divi-Builder bauen.
- **Zeile** (`row`): `columns` = Breiten als Brüche, die zusammen 1 ergeben (`"4_4"`, `"1_2,1_2"`,
`"1_3,2_3"`, `"1_4,1_4,1_2"`, auch Fünftel); `modules` = je Spalte eine Liste (bei einer Spalte reicht eine
einfache Liste).
- **Module** mit Kind-Modulen (Akkordeon, Tabs, Slider, Kontaktformular, Karte, Social-Media-Follow,
Icon-Liste) bekommen ihre Einträge in `items`; der Eintragstyp ergibt sich von selbst.
- `label` fehlt → Admin-Label „<Abschnitt> – <Modul>“, z. B. „Hero – Button“. Abschnitten deshalb ein Label
geben.
- `heading` ohne `level` wird `h2` (eine Seite hat meist schon eine `h1` im Titel).
## Kurzform-Felder
| Modul | Felder |
|---|---|
| `text` | `content` (HTML) |
| `heading` | `text`, `level` (`h1`–`h6`) |
| `button` | `text`, `url`, `new_window` (true/false) |
| `image` | `image_id` (Mediathek, setzt URL/ID/Alt), `src`, `alt`, `title`, `url` |
| `blurb` | `title`, `url`, `content`, `image_id`, `src`, `alt`, `level` |
| `cta` | `title`, `content`, `button_text`, `button_url` |
| `icon` | `url`, `title` |
| `divider` | `show`, `color` |
| `testimonial` | `author`, `job_title`, `company`, `company_url`, `content`, `portrait_id`, `portrait_url` |
| `team-member` | `name`, `position`, `content`, `image_id`, `image_url` |
| `accordion` → `accordion-item` | Eintrag: `title`, `content`, `open` |
| `tabs` → `tab` | Eintrag: `title`, `content` |
| `toggle` | `title`, `content`, `open` |
| `gallery` | `image_ids` (Liste von Anhang-IDs) |
| `slider` → `slide` | Eintrag: `title`, `content`, `button_text`, `button_url`, `image_id`, `src`, `alt` |
| `contact-form` → `contact-field` | Formular: `title`, `email`, `success_message`, `submit_text`; Feld: `title`, `id`, `type` (`input`, `email`, `text`, `checkbox`, `radio`, `select`), `required` |
| `map` → `map-pin` | Karte: `address`, `lat`, `lng`, `zoom`; Pin: `title`, `content`, `address`, `lat`, `lng` |
| `video` | `src`, `thumbnail` |
| `number-counter` | `number`, `title`, `percent` |
| `social-media-follow` → `social-media-follow-network` | Eintrag: `network` (z. B. `facebook`), `url`, `label` |
| `icon-list` → `icon-list-item` | Eintrag: `text` |
| `code` | `content` – nur, wenn kein natives Modul passt (Warnung) |
Alle anderen Module (siehe `lit-divi-catalog`) gehen über `attrs`. Die Kurzform-Felder eines Moduls samt Pfad
zeigt `lit-divi-catalog` mit `module`.
Zwei Felder heißen wie Knoten-Schlüssel und lassen sich deshalb beim Einfügen **nicht** in Kurzform setzen:
`type` des Kontaktformular-Felds (`type` ist dort der Modultyp) und `label` des Social-Media-Netzwerks (`label`
ist dort das Admin-Label). Beim Einfügen über `attrs` (`fieldItem.advanced.type` bzw.
`socialNetwork.innerContent` mit `label`) setzen, danach auch mit `lit-divi-update` über `set`
(`{"set": {"type": "email"}}`).
## Einfügen, ändern, verschieben, löschen
- `lit-divi-insert`: `nodes` + Position – `parent` (Pfad-ID, z. B. Spalte `s0.r0.c1`; optional `index`),
`before` oder `after`; ohne Position ans Seitenende. Oben auf der Seite sind nur Abschnitte erlaubt, in
Abschnitten Zeilen, in Spalten Module.
- `lit-divi-update`: `changes` = `[{"id": "s0.r0.c1.m2", "set": {"text": "Jetzt buchen"}, "preset": "CTA
primär", "label": "Hero – Button"}]` – mehrere Knoten in einem Aufruf; `null` entfernt einen Wert. Denselben
Pfad nicht zugleich über `set` und `attrs` ändern (dann zwei Änderungen senden).
- `lit-divi-move`: `id` + Position (auch in andere Zeilen oder Abschnitte).
- `lit-divi-delete`: `ids`, erst nach Rückfrage mit `confirm: true`.
- `lit/divi-duplicate-node`: Kopie direkt hinter dem Original.
- Jede Antwort enthält die neue `tree_rev` (für die nächste Änderung), `changed` mit den neuen Pfad-IDs,
den betroffenen Abschnitt als `outline`, `warnings` (Qualität, siehe unten) und `revision_id`.
Fehler: `validation_failed` bzw. `invalid_attr_path` mit `fields` (Eingabepfad → Problem, z. B.
`nodes[0].rows[0].columns`); dann wurde nichts gespeichert.
## Spaltenbreiten ändern
Die Breite einer Spalte steht in `module.decoration.sizing` (Desktop: `flexType`, z. B. `8_24` = 1/3). Wer
diesen Desktop-Wert über `attrs` ersetzt, ersetzt das ganze Objekt: `flexType` immer mitschicken, sonst fällt
die Breite weg. In einer Zeile mit festen Breiten alle Spalten zusammen ändern (Summe 1). Die Angabe
`flexColumnStructure` der Zeile (Spaltenvorlage im Divi-Builder) passt das Werkzeug nicht an; nach einer
Breitenänderung zeigt der Builder dort evtl. noch die alte Vorlage; gespeichert sind die neuen Breiten (bei
Bedarf die Vorlage im Builder neu wählen).
## Sicherheit und Speichern
Alle Werte werden vor dem Speichern bereinigt: Texte ohne HTML, Rich-Text ohne `unfiltered_html` nur mit dem
HTML, das WordPress in Beiträgen erlaubt, Links ohne `javascript:`.
`$variable`-Verweise werden geparst, geprüft und vom Plugin neu erzeugt. Eigenes HTML, CSS und HTML-Attribute eines Moduls (`…decoration.attributes`, `…advanced.html`,
`…css…`) ändert nur, wer das Recht `unfiltered_html` hat – sonst „nur im Divi-Builder“. Unberührte Teile der
Seite bleiben byte-identisch; Ausnahme: Divi selbst normalisiert Seiten mit eigenen HTML-/Attribut-Feldern
beim Speichern (dann Warnung, dass der Inhalt beim Speichern verändert wurde – mit der neuen `tree_rev`
weiterarbeiten).
## Warnungen
Neue oder geänderte Knoten werden geprüft: feste Farben/Schriften, obwohl eine passende globale existiert;
Bilder ohne Alt-Text; leere Module; Sprünge in der Überschriftenhierarchie; Code-Modul. Warnungen dem Nutzer
nennen und möglichst gleich beheben.
+16 -1
View File
@@ -22,9 +22,24 @@ Spalten (`column`), Spalten Module (`text`, `heading`, `button`, …). Manche Mo
des letzten Lesevorgangs.
- `rev` ist davon getrennt: der Stand des ganzen Eintrags (Titel, Auszug, Status …) für `lit-content-update`.
## Ändern
Neue Inhalte und Änderungen: siehe `divi-shortform` (Kurzform, `lit-divi-insert`, `lit-divi-update`,
`lit-divi-move`, `lit-divi-delete`), `divi-attrs` (Vollform) und `divi-placeholders`. Ablauf:
1. `lit-divi-outline` lesen (liefert `tree_rev`), `lit-divi-design-system` für Farben, Variablen, Presets.
2. Änderung mit dieser `tree_rev` senden. Die Antwort enthält die neue `tree_rev` und die neuen Pfad-IDs –
damit direkt weiterarbeiten, ohne erneut zu lesen.
3. `rev_mismatch`: jemand hat die Seite inzwischen geändert (z. B. im Divi-Builder) → neu lesen.
4. Zum Schluss `lit-page-render-summary` prüfen und dem Nutzer die Vorschau (`preview_url`) bzw. den
Divi-Builder (`edit_url`) nennen. Der Status bleibt unverändert; veröffentlichte Seiten werden live
geändert (vorher entsteht eine Revision, `revision_id`).
## Was nicht bearbeitet wird
- Knoten mit „nur lesbar“ (globale Module `g…`, fremde Blöcke `x…`) ändern die Divi-Werkzeuge nicht.
- Knoten mit „nur lesbar“ (globale Module `g…`, fremde Blöcke `x…`) lassen sich nur als Ganzes verschieben,
duplizieren oder löschen; ihr Inhalt wird im Divi-Builder bearbeitet (globale Module folgen später).
`lit/divi-replace-text` ändert sie nicht, sondern nennt sie in `skipped_nodes`.
- Divi-4-Inhalt (Shortcodes `[et_pb_…]`) wird nicht gelesen oder geändert: Seite einmal im Divi-Builder
öffnen und speichern, Divi 5 wandelt sie dabei um.
- Ist das Markup beschädigt, lehnen die Werkzeuge ab (`unsupported`); die Seite im Divi-Builder öffnen und
+9 -4
View File
@@ -23,8 +23,12 @@ Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-conte
`lit-divi-outline`, `lit-divi-node-get`, `lit-divi-catalog`, `lit-divi-design-system`, `lit-divi-insert`,
`lit-divi-update`, `lit-divi-move`, `lit-divi-delete`.
Welche Tools direkt sichtbar sind, lässt sich unter Einstellungen → LIT MCP → Tools ändern (außer bei
`lit-guide`, das immer direkt bleibt).
Die Tools sind nach Integration gruppiert (Grundlagen, WordPress, Medien, Divi). Jede Gruppe hat unter
Einstellungen → LIT MCP → Tools eine eigene Einstellung (direkt/indirekt/deaktiviert), die ihre Tools übernehmen
(„wie Gruppe“). Eine Einstellung am einzelnen Tool geht vor; die Gruppe zeigt dann „gemischt“. „nur lesen“
schaltet alle schreibenden Tools der Gruppe ab – auch solche mit eigener Einstellung. `lit-guide` bleibt immer
direkt; `lit-media-preview`/`lit-media-download` bleiben bei Gruppe „indirekt“ direkt. Ab mehr als 25 direkten
Tools warnt die Seite (Kontextkosten in claude.ai). Änderungen gelten ab der nächsten Verbindung.
Über `mcp-adapter-discover-abilities` / `mcp-adapter-execute-ability` (Ability-Namen `lit/…`; in Guides und
Hinweisen immer so geschrieben):
@@ -37,12 +41,13 @@ Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`), Medien (`lit/me
Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist.
Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows`, `media`, `divi-structure`,
`divi-attrs` oder `divi-design-system`.
`divi-shortform`, `divi-attrs`, `divi-placeholders` oder `divi-design-system`.
## Rückmeldungen
- Erfolg: `ok`, `summary`, `changed`, `warnings`, `rev`, `revision_id` (Revision mit dem Stand vorher, nur bei
Tools, die Inhalt überschreiben, sonst `null`), `log_id`, URLs.
Tools, die Inhalt überschreiben, sonst `null`), `log_id`, URLs. Divi-Werkzeuge zusätzlich `tree_rev` (Stand
des Seiteninhalts, Pflicht für die nächste Divi-Änderung) und `outline` (betroffener Abschnitt).
- Fehler: JSON mit `code` (z. B. `validation_failed`, `rev_mismatch`, `write_failed`, `partial_write`),
`message`, `hint` (und ggf. `fields`). Dem `hint` folgen.
- `confirm_required`: Nutzer fragen, dann mit `confirm: true` wiederholen.
+19
View File
@@ -11,6 +11,25 @@
5. Menü: `lit/menu-create` (optional mit `location` aus `lit/menu-list`), Einträge mit `lit/menu-set-items`.
6. Veröffentlichen erst, wenn der Nutzer es ausdrücklich möchte.
## Divi-Seite bauen
1. `lit-divi-design-system` lesen (Farben, Schriften, Presets) und `lit-divi-catalog` für die Module.
2. Seite als Entwurf mit Inhalt anlegen: `lit-content-create` mit `divi` (Abschnitte in Kurzform, siehe
`divi-shortform`) – oder eine leere Seite anlegen und Abschnitte mit `lit-divi-insert` einfügen.
3. Weitere Abschnitte/Module mit `lit-divi-insert`, Anpassungen mit `lit-divi-update`; immer die `tree_rev`
der letzten Antwort mitgeben.
4. `lit-page-render-summary` prüfen, `warnings` beheben, dem Nutzer `preview_url` und `edit_url` (Divi-Builder)
nennen. Veröffentlichen nur auf Wunsch (`lit-post-publish`).
## Text auf vielen Seiten ersetzen
`lit/divi-replace-text` zuerst mit `dry_run: true` (Standard): zeigt Treffer je Seite mit `tree_rev`. Nach
Rückfrage mit `dry_run: false`, `targets` (post_id + tree_rev aus dem Probelauf) und `confirm: true`
ausführen; jede Seite bekommt eine eigene Revision, `results` meldet das Ergebnis je Seite. Ersetzt wird nur
sichtbarer Text (keine Links, Stile, Code oder dynamischen Inhalte); in reinen Textfeldern (Titel,
Button-Texte) werden Leerzeichen am Anfang und Ende der Ersetzung entfernt. Globale Module werden nicht
geändert, sondern in `skipped_nodes` genannt – dem Nutzer sagen, dass er sie in der Divi-Bibliothek ändern muss.
## Menüs pflegen
- `lit/menu-get` liefert den Baum und `menu_rev` (Stand des Menüs). `lit/menu-set-items` verlangt diese
@@ -0,0 +1,112 @@
# Etappe 2b – Manuelle Abnahme (Divi schreiben)
Vorbereitet von Task 10 (Hinweise, Guides, Version 0.3.0, Ende-zu-Ende-Test). Automatisch geprüft sind
bereits:
- alle Unit-Tests (`composer test`) und Lint (`composer lint`);
- die fünf Integrationsskripte `divi-write-smoke.sh`, `divi-read-smoke.sh`, `mcp-smoke.sh`,
`wp-basis-smoke.sh` und `media-admin-smoke.sh` (alle in `tests/Integration/`).
`divi-write-smoke.sh` prüft über den OAuth-Endpunkt:
- Einfügen, Ändern, Verschieben, Löschen und Duplizieren;
- Ersetzen mit Probelauf;
- `lit-content-create` mit Divi-Inhalt;
- den ersten Abschnitt auf einer leeren Seite;
- einen Redakteur ohne `unfiltered_html`: unberührte Bytes bleiben gleich, und es wird weder Skript,
Event-Handler, `javascript:` noch ein eingeschleustes Attribut gespeichert oder gerendert;
- Divis eigene Speicherfilter (Seite mit eigenen HTML-Attributen).
Details stehen im Report von Task 10 (`.superpowers/sdd/2026-09-26-etappe-2b-divi-schreiben/task-10-report.md`).
Die folgenden Punkte lassen sich nicht automatisch prüfen, weil sie den echten Divi Visual Builder und den
claude.ai-Connector brauchen. Jan-Niclas prüft sie selbst (Spec §1.3 Nr. 2, §13 „Bearbeitbarkeit im
Visual Builder“). Abweichungen als kurze Notiz zurückmelden; sie werden Folgeaufgaben (z. B. anders
gespeicherte Spaltenbreiten, D8).
## 1. Visual Builder: von Claude gebaute Seite
- [ ] Dieser Auftrag in claude.ai:
„Lege eine Entwurfsseite ‚Abnahme Divi‘ an mit Hero (Überschrift, Text, Button mit Preset), einer
Zeile 1/3 + 2/3 mit Bild und Blurb, einem Akkordeon und einem Kontaktformular.“
Erwartet:
- Claude liest vorher `lit-divi-design-system` und `lit-divi-catalog`;
- dann `lit-content-create` mit `divi` (oder eine leere Seite plus `lit-divi-insert`);
- die Antwort nennt `preview_url` und `edit_url`.
- [ ] Die Seite im Divi Visual Builder öffnen (`edit_url`) und prüfen:
- [ ] Alle Module erscheinen als **native Module**, nicht als Code-Modul.
- [ ] Die Admin-Labels folgen dem Muster „<Abschnitt> – <Modul>“, z. B. „Hero – Button“.
- [ ] Jedes Modul lässt sich anklicken, ändern und speichern, ohne Fehlermeldung des Builders.
- [ ] Die Spaltenbreiten stimmen (1/3 + 2/3), auch in der Spalteneinstellung des Builders.
- [ ] Farben aus `{{color:…}}` sind als **globale Farbe** verknüpft: der Farbwähler zeigt die globale
Farbe, nicht einen festen Hex-Wert.
- [ ] Das Button-Preset ist im Builder als Preset ausgewählt.
- [ ] Akkordeon-Einträge und Kontaktformular-Felder sind einzeln bearbeitbar.
- [ ] Eine zweite Seite nur mit `lit-divi-insert` aufbauen lassen: leere Seite anlegen, dann
„Füge einen Abschnitt ‚Start‘ mit Überschrift und Text ein“. Im Builder öffnen: der Abschnitt
erscheint normal und ist bearbeitbar (Ruling S9: Hülle `divi/placeholder` wie beim Builder).
## 2. Visual Builder: von Claude geänderte Seite
- [ ] Eine bestehende, im Builder gebaute Seite kopieren (`lit/post-duplicate`) und an der Kopie per
claude.ai einen Button-Text ändern, ein Modul verschieben und einen Abschnitt duplizieren lassen.
- [ ] Die Kopie im Builder öffnen und prüfen:
- [ ] Alle unveränderten Module sehen aus wie vorher (Einstellungen, Presets, globale Farben).
- [ ] Die geänderten Module sind bearbeitbar, und die Änderung ist sichtbar.
- [ ] Die Kopie eines Abschnitts heißt „<Label> (Kopie)“.
## 3. Zurück zu Claude nach einer Builder-Änderung
- [ ] Die Seite aus Schritt 1 im Builder ändern und speichern.
- [ ] Danach per claude.ai eine weitere Änderung verlangen. Erwartet:
- [ ] `rev_mismatch` erscheint nur, wenn Claude eine alte `tree_rev` benutzt.
- [ ] Nach erneutem Lesen (`lit-divi-outline`) gelingt die Änderung.
- [ ] Die im Builder gemachten Einstellungen bleiben erhalten.
## 4. Rückgängig
- [ ] Einen Abschnitt löschen lassen. Claude fragt nach (`confirm_required`) und löscht erst nach der
Bestätigung.
- [ ] Den Abschnitt über `lit/revision-restore` mit der gemeldeten `revision_id` zurückholen und im Builder
prüfen, dass er wieder vollständig da ist.
## 5. Text auf mehreren Seiten ersetzen
- [ ] „Ersetze auf allen Seiten ‚Termin‘ durch ‚Beratungstermin‘“. Erwartet:
- [ ] Claude zeigt erst den Probelauf (Treffer je Seite) und fragt nach.
- [ ] Erst danach ersetzt es.
- [ ] Im Protokoll-Tab (Einstellungen → LIT MCP → Protokoll) steht je geänderter Seite ein Eintrag mit
eigener Revision.
- [ ] Liegt ein Treffer in einem **globalen Modul**, ändert Claude ihn nicht, sondern nennt ihn
(`skipped_nodes`, „in der Divi-Bibliothek bearbeiten“).
## 6. Redakteur ohne `unfiltered_html`
- [ ] Mit einem Redakteurs-Konto verbinden; unter Einstellungen → LIT MCP → Zugriff muss die Rolle
freigegeben sein.
- [ ] Auf einer Seite eines Administrators einen Text ändern lassen.
- [ ] Die Seite im Builder öffnen. Erwartet:
- [ ] Eingebettete Inhalte des Administrators (z. B. iframe, eigenes HTML im Code-Modul) sind
unverändert.
- [ ] Die Änderung des Redakteurs ist da.
- [ ] Den Redakteur eigenes HTML/CSS/Attribute eines Moduls setzen lassen, z. B. „füge dem Button das
HTML-Attribut data-x hinzu“. Erwartet: Ablehnung mit „nur im Divi-Builder änderbar“.
## 7. Tool-Gruppen und „nur lesen“
- [ ] Unter Einstellungen → LIT MCP → Tools die Gruppe Divi auf „nur lesen“ stellen.
- [ ] Neu verbinden. Die Divi-Schreibwerkzeuge (`lit-divi-insert` usw.) fehlen; die Lesewerkzeuge sind
noch da.
- [ ] Die Einstellung zurücksetzen.
## 8. Builder-Fixtures aus 2a (falls noch offen; Ruling S20)
- [ ] Seiten mit **globalem Modul**, Preset, Variablen und Dynamic Content exportieren:
`tests/Integration/divi-export-page.php` (Anleitung in `2026-09-26-etappe-2a-abnahme.md` §1).
`composer test` bleibt grün.
- [ ] Auf einer Kopie einer solchen Seite `lit-divi-update` an einem normalen Modul ausführen. Das
globale Modul bleibt byte-identisch: Export vorher und nachher vergleichen.
- [ ] Hat die Seite Module mit eigenen HTML-Attributen oder einem eigenen HTML-Element (Erweitert →
Attribute/HTML), meldet die Antwort evtl. „Inhalt beim Speichern verändert“. Das ist dokumentiert
(Ruling S7: Divi normalisiert solche Seiten selbst). Prüfen, dass die Seite im Builder danach
einwandfrei ist.
@@ -18,7 +18,7 @@ Ein WordPress-Plugin, das auf jeder Kundenseite einen MCP-Server bereitstellt, m
1. Ein claude.ai-Connector verbindet sich per OAuth mit einer Kundenseite; nur freigegebene Rollen können Tools nutzen.
2. Von Claude erzeugte oder geänderte Divi-Inhalte sind im Visual Builder vollständig bearbeitbar (manuell verifiziert pro Etappe).
3. Unangetastete Inhalte bleiben byte-identisch erhalten.
3. Unangetastete Inhalte bleiben byte-identisch erhalten. Dokumentierte Ausnahme (Ruling S7): Divi selbst normalisiert Seiten mit eigenen HTML-/Attribut-Feldern beim Speichern (`AttributeSecurity`/`HtmlSecurity` auf `wp_insert_post_data`, laufen für jeden Benutzer und werden nie entfernt); die Antwort meldet das als Warnung „Inhalt beim Speichern verändert“ (`CHANGED_ON_SAVE`), die Seite bleibt gültig (E2E `divi-write-smoke.sh` Abschnitt 8).
4. Eine neue Website (Seitenstruktur, Menü, Einstellungen, Designsystem aus einem CD) lässt sich im Dialog aufsetzen.
5. Jede Änderung ist nachvollziehbar (Protokoll) und umkehrbar (Revision bzw. Snapshot).
@@ -171,7 +171,7 @@ Seiten, Theme-Builder-Layouts (`et_header_layout`, `et_body_layout`, `et_footer_
- Parst `post_content` in einen Baum (auf Basis von `parse_blocks`, mit Sonderbehandlung für `divi/global-layout` und `globalModule`, die `parse_blocks` laut Divi-Code nicht korrekt verarbeitet).
- Serialisiert den Baum zurück; unveränderte Knoten werden aus ihrem Original-Markup übernommen.
- **Invariante:** Parse → Serialize ohne Änderung ergibt byte-identischen Inhalt. Fremde/unbekannte Blöcke bleiben unberührt.
- **Invariante:** Parse → Serialize ohne Änderung ergibt byte-identischen Inhalt. Fremde/unbekannte Blöcke bleiben unberührt. Ausnahme beim Speichern (nicht im BlockTree): Divi selbst normalisiert Seiten mit eigenen HTML-/Attribut-Feldern beim Speichern (`module.decoration.attributes`, `module.advanced.html` mit `htmlBefore`/`htmlAfter`/`elementType`) – Divi parst und serialisiert dann die ganze Seite neu; gemeldet als `CHANGED_ON_SAVE` (Ruling S7).
- Reines PHP ohne WordPress-Abhängigkeit (vollständig unit-testbar).
- Attributwerte mit `$variable(...)$` werden beim Serialisieren wie von Divi erwartet escaped (`"` → `"`).
- Umsetzung (D3): eigener Tokenizer mit dem Delimiter-Muster von `WP_Block_Parser` statt `parse_blocks()` – ohne WordPress testbar, jedes Byte zwischen den Delimitern bleibt erhalten. Knoten mit `globalModule`, `divi/global-layout` oder nicht dekodierbaren Attributen sind undurchsichtig: sie werden nicht betreten, nur als Ganzes verschoben, dupliziert oder gelöscht (Bearbeitung ab Etappe 3). Unbalanciertes Markup wird abgelehnt, nie repariert.
+35 -1
View File
@@ -4,7 +4,7 @@ Tags: mcp, claude, divi, pods, ai
Requires at least: 6.9
Tested up to: 7.0
Requires PHP: 8.1
Stable tag: 0.2.0
Stable tag: 0.3.0
License: GPL-2.0-or-later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
@@ -39,6 +39,17 @@ Einstellungen: Einstellungen → LIT MCP (Verbindung, Zugriff, Tools, Protokoll,
* Eigene Daten: Optionen lit_mcp_allowed_roles, lit_mcp_exposure, lit_mcp_limits, lit_mcp_db_version,
Tabelle {prefix}lit_mcp_log (nur Verweise, keine Inhalte), täglicher Cron-Job lit_mcp_log_cleanup,
Transient lit_mcp_endpoint_check.
* Divi-Änderungen über LIT MCP speichern den Seiteninhalt für Benutzer ohne die Berechtigung
unfiltered_html ohne die kses-Filterung von WordPress, weil diese sonst jeden unveränderten Divi-Block neu
schreiben würde. Alle Werte, die LIT MCP dabei schreibt, werden vorher bereinigt (wp_kses_post,
sanitize_text_field, esc_url_raw); $variable-Verweise werden geparst, geprüft und vom Plugin neu erzeugt.
Inhalte des Code-Moduls bleiben nur mit unfiltered_html ungefiltert; eigenes HTML, CSS und HTML-Attribute
von Modulen schreibt LIT MCP nur für Benutzer mit unfiltered_html. Divis eigene Speicherfilter bleiben
aktiv.
* Unberührte Teile einer Divi-Seite bleiben beim Speichern byte-identisch. Ausnahme: Divi selbst normalisiert
Seiten mit eigenen HTML-/Attribut-Feldern beim Speichern (die Antwort warnt dann, dass der Inhalt beim
Speichern verändert wurde).
* Nach jeder Divi-Änderung wird der statische CSS-Cache von Divi für die Seite geleert.
* Uploads über LIT MCP: nur Bilder (jpg, png, gif, webp, avif), PDF, MP4, MP3; Größenlimit einstellbar
(Standard 5 MB, höchstens 32 MB; Base64-Upload und Download höchstens 10 MB); Import per URL nur von öffentlich erreichbaren Adressen (interne/lokale Adressen werden
abgelehnt).
@@ -57,6 +68,8 @@ Es gibt keine allgemeine "Rückgängig"-Funktion; was sich wie zurücksetzen lä
* Inhalte (Titel, Text, Auszug): über WordPress-Revisionen (Werkzeuge lit/revisions-list,
lit/revision-restore). Felder und Begriffe sind in Revisionen nicht enthalten.
* Divi-Seiten: jede Divi-Änderung legt vorher eine Revision an (revision_id in der Antwort); zurück über
lit/revision-restore. Beim seitenübergreifenden Ersetzen erhält jede geänderte Seite eine eigene Revision.
* Original durch eine Kopie ersetzen (lit/post-replace-with-copy): vorher legt das Werkzeug eine Revision
und eine vollständige Sicherung des bisherigen Originals im Papierkorb an (backup_id in der Antwort) –
von dort in WordPress wiederherstellbar.
@@ -93,8 +106,29 @@ Protokoll gelten pro Website. Super-Admins haben (wie jede Capability in WordPre
unabhängig von den freigegebenen Rollen. Der Standard-MCP-Server von mcp-adapter wird ebenfalls website-weit
abgeschaltet.
== Frequently Asked Questions ==
= Kann ich Tools abschalten oder nur lesend freigeben? =
Ja, unter Einstellungen → LIT MCP → Tools einzeln oder je Gruppe; „nur lesen“ schaltet alle schreibenden
Tools einer Gruppe auf einmal ab.
= Bleiben mit Claude bearbeitete Divi-Seiten im Visual Builder bearbeitbar? =
Ja. LIT MCP erzeugt native Divi-5-Module mit Admin-Labels und verknüpft Farben, Variablen und Presets mit dem
Designsystem. Nach einer Änderung im Builder liest Claude die Seite neu, bevor es weiter ändert.
== Changelog ==
= 0.3.0 =
* Divi-Seiten: Seitenbaum mit Pfad-IDs und tree_rev, Knoten in Voll- und Kurzform, Modulkatalog,
Designsystem (Farben, Variablen, Schriften, Presets), gerenderte Zusammenfassung; Abschnitte, Zeilen und
Module einfügen, ändern, verschieben, duplizieren und löschen (Kurzform, Vollform, Platzhalter wie
{{color:primary}}), Text seitenübergreifend ersetzen mit Probelauf, Qualitätswarnungen, neue Seiten
direkt mit Divi-Inhalt.
* Tools nach Gruppen einstellbar (direkt/indirekt/deaktiviert, „nur lesen“ je Gruppe); überarbeitete
Einstellungsseite.
= 0.2.0 =
* WordPress-Basis: Schema-Erkennung inkl. Pods, Inhalte lesen/anlegen/ändern, Status, Kopie und
Austausch, Revisionen, Menüs, WordPress-Einstellungen, Mediathek (Suche, Vorschau, Metadaten,
+6 -2
View File
@@ -53,8 +53,12 @@ final class InstructionsBuilder
}
$divi = str_contains(', ' . $active . ',', ', divi,')
? ['- Divi-Seiten: vor Änderungen lit-divi-outline und lit-divi-design-system lesen; Aufbau: lit-guide '
. 'topic "divi-structure".']
? [
'- Divi-Seiten: vor Änderungen lit-divi-outline und lit-divi-design-system lesen; Aufbau: lit-guide '
. 'topic "divi-structure".',
'- Divi-Änderungen verlangen die tree_rev aus lit-divi-outline bzw. der letzten Divi-Antwort '
. '(nicht rev).',
]
: [];
return implode("\n", array_merge($lines, [
@@ -177,13 +177,14 @@ final class DiviOutlineAbility implements Ability
$hints = [];
if ($rows === []) {
$hints[] = $page->isDiviLayout()
? 'Die Seite hat noch keinen Divi-Inhalt.'
? 'Die Seite hat noch keinen Divi-Inhalt: mit lit-divi-insert (ohne parent) den ersten Abschnitt '
. 'einfügen.'
: 'Der Inhalt ist kein Divi-Layout (Block-Editor oder klassisch); lit-content-get zeigt ihn.';
}
if (in_array(false, array_column($rows, 'editable'), true)) {
$hints[] = 'Knoten mit "nur lesbar" (globale Module, fremde Blöcke) ändern die Divi-Werkzeuge nicht; '
. 'ihren Inhalt nur im Divi-Builder bearbeiten.';
$hints[] = 'Knoten mit "nur lesbar" (globale Module, fremde Blöcke) lassen sich nur als Ganzes '
. 'verschieben, duplizieren oder löschen; ihren Inhalt im Divi-Builder bearbeiten.';
}
return $hints;
@@ -119,7 +119,7 @@ final class DiviCatalogAbility implements Ability
public function execute(array $input): mixed
{
$catalog = $this->modules->catalog();
$notes = [self::CODE_NOTE, 'Details zur Vollform: lit-guide mit topic "divi-attrs".'];
$notes = [self::CODE_NOTE, 'Details und Beispiele: lit-guide mit topic "divi-shortform" bzw. "divi-attrs".'];
if (! is_string($input['module'] ?? null) || $input['module'] === '') {
$modules = [];
@@ -25,8 +25,9 @@ use WP_Post;
class PostPresenter
{
/** How Divi content is read and changed with the Divi tools. */
public const DIVI_NOTE = 'Den Aufbau mit lit/divi-outline (direkt: lit-divi-outline) lesen, einzelne Knoten mit '
. 'lit/divi-node-get (direkt: lit-divi-node-get); ändern vorerst im Divi-Builder.';
public const DIVI_NOTE = 'Den Aufbau mit lit/divi-outline (direkt: lit-divi-outline) lesen und mit '
. 'lit/divi-insert, lit/divi-update, lit/divi-move bzw. lit/divi-delete (direkt: lit-divi-insert, '
. 'lit-divi-update, lit-divi-move, lit-divi-delete) ändern.';
/** Divi content on a site where the Divi 5 builder is not loaded (no Divi tools registered). */
public const DIVI_INACTIVE_NOTE = 'Der Divi-5-Builder ist auf dieser Website nicht aktiv, daher gibt es keine '
+1 -1
View File
@@ -40,7 +40,7 @@ use LIT\MCP\Server\VisibilityFilter;
*/
final class Plugin
{
public const VERSION = '0.2.0';
public const VERSION = '0.3.0';
private static string $pluginFile = '';
private static ?SettingsPage $settingsPage = null;
@@ -0,0 +1 @@
<!-- wp:divi/placeholder --><!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Eigene Felder"}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"Eigene Felder – Zeile"}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/text {"module":{"meta":{"adminLabel":{"desktop":{"value":"Eigene Felder – Artikel"}}},"advanced":{"html":{"elementType":{"desktop":{"value":"article"}}}},"decoration":{"attributes":{"desktop":{"value":{"attributes":[{"id":"lit-attr-1","name":"data-track","value":"hero","targetElement":""}]}}}}},"content":{"innerContent":{"desktop":{"value":"<p>Text mit eigenem Element und Attribut.</p>"}}},"builderVersion":"5.2.1"} --><!-- /wp:divi/text --><!-- wp:divi/heading {"title":{"innerContent":{"desktop":{"value":"Eigene Felder bleiben"}},"decoration":{"font":{"font":{"desktop":{"value":{"headingLevel":"h2"}}}}}},"module":{"meta":{"adminLabel":{"desktop":{"value":"Eigene Felder – Überschrift"}}}},"builderVersion":"5.2.1"} --><!-- /wp:divi/heading --><!-- wp:divi/button {"button":{"innerContent":{"desktop":{"value":{"text":"Kontakt","linkUrl":"/kontakt"}}}},"module":{"meta":{"adminLabel":{"desktop":{"value":"Eigene Felder – Button"}}}},"builderVersion":"5.2.1"} --><!-- /wp:divi/button --><!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->
+1 -1
View File
@@ -41,6 +41,6 @@ foreach ($lit_page->tabSlugs() as $lit_slug) {
continue;
}
$lit_ok = str_contains($lit_html, 'nav-tab-active') && str_contains($lit_html, 'class="wrap"');
$lit_ok = str_contains($lit_html, 'nav-tab-active') && preg_match('/class="wrap[ "]/', $lit_html) === 1;
echo $lit_slug . ($lit_ok ? ' ok ' : ' FAIL ') . strlen($lit_html) . "\n";
}
+364
View File
@@ -0,0 +1,364 @@
#!/usr/bin/env bash
# End-to-end checks of the Etappe-2b Divi write tools through the OAuth-protected endpoint.
# Runs INSIDE the DDEV web container (Divi 5 must be the active theme):
# cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-write-smoke.sh
# Creates draft pages, a temporary role/user without unfiltered_html, and removes everything again
# (pages with their revisions, change-log rows of the temporary user, role, user, app passwords).
# Security probes (section 7, ruling S6) use benign markers only: a data-probe attribute and the
# literal text alert(1); the check is that no tag, event handler, javascript: URL or data-probe
# attribute reaches the stored bytes, the render summary or the rendered page.
set -euo pipefail
cd /var/www/html
# shellcheck source=lib.sh
source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh
ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1)
EDITOR_LOGIN=lit-smoke-editor
FIXTURES=$PLUGIN/tests/Fixtures/divi
ORIGINAL_ROLES=$(wp option get lit_mcp_allowed_roles --format=json 2>/dev/null || echo '["administrator"]')
CREATED_POSTS=()
cleanup() {
for id in "${CREATED_POSTS[@]}"; do
wp post delete "$id" --force >/dev/null 2>&1 || true
done
cleanup_app_passwords_for "$ADMIN_LOGIN"
cleanup_app_passwords_for "$EDITOR_LOGIN"
# Change-log rows of this run: every row of the temporary user and every row about a test page.
LIT_LOGIN="$EDITOR_LOGIN" LIT_IDS="${CREATED_POSTS[*]}" wp eval '
global $wpdb;
$table = $wpdb->prefix . "lit_mcp_log";
$u = get_user_by("login", getenv("LIT_LOGIN"));
if ($u) { $wpdb->delete($table, ["user_id" => $u->ID]); }
foreach (array_filter(explode(" ", (string) getenv("LIT_IDS"))) as $id) {
$wpdb->delete($table, ["object_type" => "page", "object_id" => $id]);
}' >/dev/null 2>&1 || true
wp user delete "$EDITOR_LOGIN" --yes >/dev/null 2>&1 || true
wp role delete lit_smoke_editor >/dev/null 2>&1 || true
wp option update lit_mcp_allowed_roles "$ORIGINAL_ROLES" --format=json >/dev/null 2>&1 || true
}
trap cleanup EXIT
create_page() { # $1 = fixture file ("" = empty page), $2 = title -> prints the ID
LIT_FILE="${1:+$FIXTURES/$1}" LIT_TITLE="$2" wp --user="$ADMIN_LOGIN" eval '
$file = (string) getenv("LIT_FILE");
$id = wp_insert_post(["post_type" => "page", "post_status" => "draft", "post_title" => getenv("LIT_TITLE"),
"post_content" => $file === "" ? "" : wp_slash((string) file_get_contents($file))]);
update_post_meta($id, "_et_pb_use_builder", "on");
update_post_meta($id, "_et_pb_use_divi_5", "on");
echo $id;'
}
content_of() { # $1 = post ID
wp post get "$1" --field=post_content
}
# Whether the stored content still contains the exact bytes of the fixture section "Leistungen".
keeps_leistungen() { # $1 = post ID
LIT_ID="$1" LIT_FILE="$FIXTURES/converted-landing.html" wp eval '
$fixture = (string) file_get_contents(getenv("LIT_FILE"));
$start = strpos($fixture, "<!-- wp:divi/section {\"module\":{\"meta\":{\"adminLabel\":{\"desktop\":{\"value\":\"Leistungen\"");
$section = substr($fixture, $start, strrpos($fixture, "<!-- /wp:divi/placeholder -->") - $start);
echo str_contains((string) get_post((int) getenv("LIT_ID"))->post_content, $section) ? "yes" : "no";'
}
# Prints every dangerous finding (one per line) in the stored content and in the rendered
# content area of a post: script tags, event-handler or data-probe attributes, javascript: URLs.
# Stored: every string value of every block attribute (decoded by parse_blocks, so "\u003c" is
# "<") and the markup between the blocks; rendered: the HTML of the content area.
probe_findings() { # $1 = post ID
LIT_ID="$1" wp --user="$ADMIN_LOGIN" eval '
$post = get_post((int) getenv("LIT_ID"));
$stored = [];
$walk = static function ($value) use (&$walk, &$stored): void {
if (is_array($value)) {
array_walk($value, static fn ($v) => $walk($v));
} elseif (is_string($value)) {
$stored[] = $value;
// $variable({...})$ references: their settings (before/after) are JSON inside the string.
if (preg_match("/^\\\$variable\\((.*)\\)\\\$$/s", $value, $ref) === 1) {
$walk(json_decode($ref[1], true));
}
}
};
$blocks = static function (array $list) use (&$blocks, $walk, &$stored): void {
foreach ($list as $block) {
$walk($block["attrs"] ?? []);
$stored[] = (string) ($block["innerHTML"] ?? "");
$blocks($block["innerBlocks"] ?? []);
}
};
$blocks(parse_blocks((string) $post->post_content));
$sources = [
"stored" => $stored,
"rendered" => [(new LIT\MCP\Integrations\WordPress\Render\PageRenderer())->render($post)],
];
$rules = [
"script tag" => "/<\s*script/i",
"event handler" => "/<[^>]*\son[a-z]+\s*=/i",
"data-probe attribute" => "/<[^>]*\sdata-probe\b/i",
"javascript: URL" => "/javascript\s*:/i",
];
foreach ($sources as $where => $strings) {
foreach ($rules as $what => $pattern) {
foreach ($strings as $html) {
if (preg_match($pattern, $html) === 1) {
echo "$where: $what in " . substr($html, 0, 120) . "\n";
break;
}
}
}
}'
}
assert_clean() { # $1 = post ID, $2 = context
local findings
findings=$(probe_findings "$1")
[ -z "$findings" ] || fail "$2: $findings"
}
[ "$(wp eval 'echo get_template();')" = "Divi" ] || fail "Divi must be the active theme"
PAGE_ID=$(create_page converted-landing.html "LIT Divi Schreiben")
CREATED_POSTS+=("$PAGE_ID")
assert_clean "$PAGE_ID" "fixture baseline"
# The checker itself must see probes in attribute strings and in $variable settings.
PROBE_ID=$(LIT_TITLE="LIT Divi Probe" wp --user="$ADMIN_LOGIN" eval '
$v = "\$variable(" . wp_json_encode(["type" => "content", "value" => ["name" => "post_title",
"settings" => ["before" => "<script>alert(1)</script>", "after" => ""]]]) . ")$";
$text = ["content" => ["innerContent" => ["desktop" => ["value" => "<p><b data-probe=\"1\" onclick=\"alert(1)\">x</b></p>"]]]];
$head = ["title" => ["innerContent" => ["desktop" => ["value" => $v]]]];
$id = wp_insert_post(["post_type" => "page", "post_status" => "draft", "post_title" => getenv("LIT_TITLE"),
"post_content" => wp_slash(serialize_block(["blockName" => "divi/text", "attrs" => $text, "innerBlocks" => [],
"innerHTML" => "", "innerContent" => []]) . serialize_block(["blockName" => "divi/heading", "attrs" => $head,
"innerBlocks" => [], "innerHTML" => "", "innerContent" => []]))]);
echo $id;')
CREATED_POSTS+=("$PROBE_ID")
PROBE_SELFTEST=$(probe_findings "$PROBE_ID")
for finding in "stored: script tag" "stored: event handler" "stored: data-probe attribute"; do
echo "$PROBE_SELFTEST" | grep -q "$finding" || fail "probe checker misses \"$finding\": $PROBE_SELFTEST"
done
wp post delete "$PROBE_ID" --force >/dev/null
connect "$ADMIN_LOGIN"
# 1. Direct write tools with correct annotations
LIST=$(rpc tools/list '{}')
for t in lit-divi-insert lit-divi-update lit-divi-move lit-divi-delete; do
echo "$LIST" | jq -e --arg t "$t" '.result.tools[] | select(.name == $t)' >/dev/null || fail "tools/list lacks $t"
done
echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-divi-delete") | .annotations.destructiveHint == true' >/dev/null \
|| fail "lit-divi-delete lacks destructiveHint"
if echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-divi-replace-text" or .name == "lit-divi-duplicate-node")' >/dev/null; then
fail "lit-divi-replace-text and lit-divi-duplicate-node must be indirect"
fi
GUIDE=$(tool lit-guide '{"topic":"divi-shortform"}')
echo "$GUIDE" | jq -e '.content | contains("lit-divi-insert")' >/dev/null || fail "guide divi-shortform: $GUIDE"
echo "$INIT_RESPONSE" | grep -q 'tree_rev' || fail "server instructions lack the tree_rev rule"
pass "tools/list: Divi write tools direct, duplicate/replace-text indirect; guide and instructions"
# 2. Insert a whole section in short form with placeholders
TREE_REV=$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}" | jq -r '.tree_rev')
INSERT=$(tool lit-divi-insert "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev, before: "s1",
nodes: [{type: "section", label: "Neu", background_color: "{{color:primary}}", rows: [{columns: "1_3,2_3", modules: [
[{type: "heading", text: "{{post:title}}", level: "h2"}],
[{type: "text", content: "<p>Hallo <strong>Welt</strong></p>"},
{type: "button", text: "Mehr erfahren", url: "javascript:alert(1)", label: "Neu – CTA"}]]}]}]}')")
[ "$(error_code "$INSERT")" = "validation_failed" ] || fail "javascript: URL accepted: $INSERT"
[ "$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}" | jq -r '.tree_rev')" = "$TREE_REV" ] || fail "refused insert wrote something"
INSERT=$(tool lit-divi-insert "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev, before: "s1",
nodes: [{type: "section", label: "Neu", background_color: "{{color:primary}}", rows: [{columns: "1_3,2_3", modules: [
[{type: "heading", text: "{{post:title}}", level: "h2"}],
[{type: "text", content: "<p>Hallo <strong>Welt</strong></p>"},
{type: "button", text: "Mehr erfahren", url: "/mehr", label: "Neu – CTA"}]]}]}]}')")
echo "$INSERT" | jq -e '.ok == true and .changed[0].id == "s1" and .revision_id != null and .log_id != null' >/dev/null \
|| fail "divi-insert: $INSERT"
TREE_REV=$(echo "$INSERT" | jq -r '.tree_rev')
[ "$(keeps_leistungen "$PAGE_ID")" = "yes" ] || fail "insert changed the bytes of an untouched section"
SUMMARY=$(tool lit-page-render-summary "{\"post_id\":$PAGE_ID}")
echo "$SUMMARY" | jq -e '.headings[] | select(.level == 2 and .text == "LIT Divi Schreiben")' >/dev/null \
|| fail "{{post:title}} not rendered: $SUMMARY"
pass "lit-divi-insert: javascript: URL refused, section with placeholders, untouched bytes kept, rendered"
# 3. Lock and all-or-nothing
STALE=$(tool lit-divi-update "{\"post_id\":$PAGE_ID,\"tree_rev\":\"000000000000\",\"changes\":[{\"id\":\"s0\",\"label\":\"x\"}]}")
[ "$(error_code "$STALE")" = "rev_mismatch" ] || fail "stale tree_rev accepted: $STALE"
BAD=$(tool lit-divi-update "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev,
changes: [{id: "s1.r0.c1.m1", set: {text: "Jetzt"}}, {id: "s1.r0.c0.m0", attrs: {"title.decoration.fnt": {desktop: "x"}}}]}')")
[ "$(error_code "$BAD")" = "invalid_attr_path" ] || fail "invalid path accepted: $BAD"
[ "$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}" | jq -r '.tree_rev')" = "$TREE_REV" ] || fail "failed update wrote something"
pass "tree_rev lock and invalid_attr_path leave the page unchanged"
# 4. Update, move, delete
UPDATE=$(tool lit-divi-update "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev,
changes: [{id: "s1.r0.c1.m1", set: {text: "Jetzt anfragen"}, attrs: {"button.decoration.background": {desktop: {color: "{{color:secondary}}"}}}}]}')")
echo "$UPDATE" | jq -e '.ok == true and .changed[0].action == "updated"' >/dev/null || fail "divi-update: $UPDATE"
TREE_REV=$(echo "$UPDATE" | jq -r '.tree_rev')
MOVE=$(tool lit-divi-move "{\"post_id\":$PAGE_ID,\"tree_rev\":\"$TREE_REV\",\"id\":\"s1.r0.c1.m1\",\"parent\":\"s1.r0.c0\"}")
echo "$MOVE" | jq -e '.changed[0].id == "s1.r0.c0.m1"' >/dev/null || fail "divi-move: $MOVE"
TREE_REV=$(echo "$MOVE" | jq -r '.tree_rev')
ASK=$(tool lit-divi-delete "{\"post_id\":$PAGE_ID,\"tree_rev\":\"$TREE_REV\",\"ids\":[\"s1.r0.c0.m1\"]}")
[ "$(error_code "$ASK")" = "confirm_required" ] || fail "delete without confirm: $ASK"
DELETE=$(tool lit-divi-delete "{\"post_id\":$PAGE_ID,\"tree_rev\":\"$TREE_REV\",\"ids\":[\"s1.r0.c0.m1\"],\"confirm\":true}")
echo "$DELETE" | jq -e '.changed[0].action == "deleted"' >/dev/null || fail "divi-delete: $DELETE"
TREE_REV=$(echo "$DELETE" | jq -r '.tree_rev')
[ "$(keeps_leistungen "$PAGE_ID")" = "yes" ] || fail "update/move/delete changed an untouched section"
pass "lit-divi-update, lit-divi-move, lit-divi-delete"
# 5. Duplicate and replace text (indirect)
DUP=$(ability lit/divi-duplicate-node "{\"post_id\":$PAGE_ID,\"tree_rev\":\"$TREE_REV\",\"id\":\"s1\"}")
echo "$DUP" | jq -e '.changed[0].id == "s2" and (.changed[0].label | endswith("(Kopie)"))' >/dev/null || fail "duplicate: $DUP"
DRY=$(ability lit/divi-replace-text '{"search":"Köln","replace":"Bonn","limit":100}')
TARGET=$(echo "$DRY" | jq -c --argjson id "$PAGE_ID" '[.matches[] | select(.post_id == $id) | {post_id, tree_rev}]')
[ "$TARGET" != "[]" ] || fail "dry run found nothing: $DRY"
if content_of "$PAGE_ID" | grep -q 'Bonn'; then fail "dry run changed content"; fi
RUN=$(ability lit/divi-replace-text "$(jq -cn --argjson t "$TARGET" '{search: "Köln", replace: "Bonn", dry_run: false, confirm: true, targets: $t}')")
echo "$RUN" | jq -e '.results[0].ok == true and .results[0].count >= 1' >/dev/null || fail "replace-text: $RUN"
content_of "$PAGE_ID" | grep -q 'Bonn' || fail "replace-text wrote nothing"
pass "lit/divi-duplicate-node and lit/divi-replace-text (dry run, confirmed run)"
# 6. New Divi page in one call; first section on an empty Divi page (ruling S9)
CREATED=$(tool lit-content-create '{"post_type":"page","title":"LIT Divi Neu","divi":[{"type":"section","label":"Start","modules":[{"type":"heading","text":"Neu hier"},{"type":"text","content":"<p>Text</p>"}]}]}')
NEW_ID=$(echo "$CREATED" | jq -r '.changed[0].id // empty')
[ -n "$NEW_ID" ] || fail "content-create with divi: $CREATED"
CREATED_POSTS+=("$NEW_ID")
echo "$CREATED" | jq -e '.tree_rev != null' >/dev/null || fail "content-create lacks tree_rev: $CREATED"
[ "$(wp post meta get "$NEW_ID" _et_pb_use_divi_5)" = "on" ] || fail "Divi flag missing"
[ "$(wp post get "$NEW_ID" --field=post_status)" = "draft" ] || fail "new Divi page is not a draft"
tool lit-page-render-summary "{\"post_id\":$NEW_ID}" | jq -e '.headings[0].text == "Neu hier"' >/dev/null || fail "new page not rendered"
EMPTY_ID=$(create_page "" "LIT Divi Leer")
CREATED_POSTS+=("$EMPTY_ID")
EMPTY=$(tool lit-divi-outline "{\"post_id\":$EMPTY_ID}")
echo "$EMPTY" | jq -e '.hints[0] | contains("lit-divi-insert")' >/dev/null || fail "empty-page hint: $EMPTY"
FIRST=$(tool lit-divi-insert "$(jq -cn --argjson id "$EMPTY_ID" --arg rev "$(echo "$EMPTY" | jq -r '.tree_rev')" '{post_id: $id, tree_rev: $rev,
nodes: [{type: "section", label: "Start", modules: [{type: "heading", text: "Erster Abschnitt"}]}]}')")
echo "$FIRST" | jq -e '.changed[0].id == "s0"' >/dev/null || fail "insert into empty page: $FIRST"
content_of "$EMPTY_ID" | grep -q '^<!-- wp:divi/placeholder --><!-- wp:divi/section ' || fail "first section not wrapped in divi/placeholder"
pass "lit-content-create with Divi short form; first section of an empty page wrapped like the builder"
# 7. A user without unfiltered_html on the admin-authored page (rulings D7, S6): untouched bytes
# identical, nothing dangerous stored or rendered.
wp role create lit_smoke_editor "LIT Smoke Editor" --clone=editor >/dev/null
wp cap remove lit_smoke_editor unfiltered_html >/dev/null
wp option update lit_mcp_allowed_roles '["administrator","lit_smoke_editor"]' --format=json >/dev/null
wp user create "$EDITOR_LOGIN" lit-smoke-editor@example.invalid --role=lit_smoke_editor >/dev/null
[ "$(LIT_LOGIN="$EDITOR_LOGIN" wp eval 'echo user_can(get_user_by("login", getenv("LIT_LOGIN")), "unfiltered_html") ? "yes" : "no";')" = "no" ] \
|| fail "the editor must not have unfiltered_html"
connect "$EDITOR_LOGIN"
TREE_REV=$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}" | jq -r '.tree_rev')
# 7a. Rich text with markup: kses for the editor
EDIT=$(tool lit-divi-update "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev,
changes: [{id: "s0.r0.c0.m1", set: {content: "<p>Neu vom Redakteur</p><script>alert(1)</script><img src=\"x\" onerror=\"alert(1)\"><a href=\"javascript:alert(1)\">Link</a>"}}]}')")
echo "$EDIT" | jq -e '.ok == true' >/dev/null || fail "editor update: $EDIT"
TREE_REV=$(echo "$EDIT" | jq -r '.tree_rev')
[ "$(keeps_leistungen "$PAGE_ID")" = "yes" ] || fail "kses re-serialized untouched blocks of an editor save"
assert_clean "$PAGE_ID" "7a rich text"
# 7b. Whole-value $variable with markup in before: re-serialized with cleaned settings, or refused.
# before/after take the HTML wp_kses_post allows (data-* attributes included), so the data-probe
# marker is only used in text fields (7c/7d), where no attribute may ever appear.
VAR='$variable({"type":"content","value":{"name":"post_title","settings":{"before":"<script>alert(1)</script><img src=\"x\" onerror=\"alert(1)\"><a href=\"javascript:alert(1)\">x</a>","after":"<svg onload=\"alert(1)\"></svg>"}}})$'
WHOLE=$(tool lit-divi-update "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" --arg v "$VAR" '{post_id: $id, tree_rev: $rev,
changes: [{id: "s0.r0.c0.m0", set: {text: $v}}, {id: "s0.r0.c0.m1", set: {content: $v}}]}')")
if echo "$WHOLE" | jq -e '.ok == true' >/dev/null; then
TREE_REV=$(echo "$WHOLE" | jq -r '.tree_rev')
else
[ "$(error_code "$WHOLE")" = "validation_failed" ] || fail "whole-value variable: $WHOLE"
fi
assert_clean "$PAGE_ID" "7b whole-value \$variable (${WHOLE:0:60})"
outcome() { echo "$1" | jq -r 'if .ok == true then "saved (cleaned)" else "refused: " + ((.__error // "") | (try fromjson catch {}) | (.code // "?")) end'; }
echo "info - 7b whole-value \$variable with markup in before/after: $(outcome "$WHOLE")"
# 7c. Embedded $variable inside rich text: refused, nothing written
PROBE_VAR='$variable({"type":"content","value":{"name":"post_title","settings":{"before":"\"><b data-probe=1>","after":""}}})$'
EMBEDDED=$(tool lit-divi-update "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" --arg v "<p>Hallo $PROBE_VAR Welt</p>" '{post_id: $id, tree_rev: $rev,
changes: [{id: "s0.r0.c0.m1", set: {content: $v}}]}')")
[ "$(error_code "$EMBEDDED")" = "validation_failed" ] || fail "embedded variable accepted: $EMBEDDED"
[ "$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}" | jq -r '.tree_rev')" = "$TREE_REV" ] || fail "refused embedded variable wrote something"
# 7d. Text field with tag-split payloads, and a javascript: background image
SPLIT=$(tool lit-divi-update "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev,
changes: [{id: "s0.r0.c0.m0", set: {text: "<scr<script>ipt>alert(1)</scr</script>ipt> \"><<span data-probe=1>span data-probe=1>Titel"}},
{id: "s0.r0.c0.m2", set: {text: "<<b>b data-probe=1>Knopf</b>"}}]}')")
if echo "$SPLIT" | jq -e '.ok == true' >/dev/null; then
TREE_REV=$(echo "$SPLIT" | jq -r '.tree_rev')
else
[ "$(error_code "$SPLIT")" = "validation_failed" ] || fail "tag-split text: $SPLIT"
fi
BG=$(tool lit-divi-update "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev,
changes: [{id: "s0", attrs: {"module.decoration.background": {desktop: {image: {url: "javascript:alert(1)"}}}}}]}')")
if echo "$BG" | jq -e '.ok == true' >/dev/null; then
TREE_REV=$(echo "$BG" | jq -r '.tree_rev')
else
[ "$(error_code "$BG")" = "validation_failed" ] || [ "$(error_code "$BG")" = "invalid_attr_path" ] || fail "javascript: background: $BG"
fi
assert_clean "$PAGE_ID" "7d tag-split text / javascript: background"
echo "info - 7d tag-split text: $(outcome "$SPLIT"); javascript: background image: $(outcome "$BG")"
SUMMARY=$(tool lit-page-render-summary "{\"post_id\":$PAGE_ID}")
# The summary reports visible text (a probe shows up there only as harmless literal text, e.g. an
# entity-escaped "<script>" from sanitize_text_field); its link and image targets must be clean.
echo "$SUMMARY" | jq -e '[.links[].href, .images[].src] | map(select(. != null and test("^\\s*(javascript|data|vbscript)\\s*:"; "i"))) | length == 0' >/dev/null \
|| fail "render summary shows a dangerous link or image: $SUMMARY"
[ "$(keeps_leistungen "$PAGE_ID")" = "yes" ] || fail "editor saves changed an untouched section"
pass "user without unfiltered_html: untouched bytes identical; no script, handler, javascript: or data-probe stored or rendered"
# 8. Divi's own save filters (ruling S7). On a page with custom attributes or elementType Divi's
# AttributeSecurity/HtmlSecurity (wp_insert_post_data, every user, never removed) parse and
# re-serialize the WHOLE page with serialize_blocks() on every save.
# 8a: the page as Divi itself stored it (already in serialize_blocks() form): our write changes
# only the edited node, no warning.
# 8b: the same page in another valid byte form (empty blocks as <!-- wp:x --><!-- /wp:x -->,
# e.g. imported without Divi's filters): Divi normalizes untouched blocks too – the bytes
# change beyond the edit, the answer carries CHANGED_ON_SAVE, tree_rev is the stored state,
# custom fields survive (Divi 5.2.1 renders the custom attribute; the text module's wrapper
# ignores elementType), the page renders, and the next write is byte-stable again.
connect "$ADMIN_LOGIN"
FILTER_ID=$(create_page synthetic-divi-filters.html "LIT Divi Filter")
CREATED_POSTS+=("$FILTER_ID")
node_bytes() { # $1 = post ID, $2 = admin label -> the stored opener of that block
LIT_ID="$1" LIT_LABEL="$2" wp eval '
$c = (string) get_post((int) getenv("LIT_ID"))->post_content;
$needle = "\"value\":\"" . getenv("LIT_LABEL") . "\"";
$at = strpos($c, $needle);
$start = strrpos(substr($c, 0, $at), "<!-- wp:");
echo substr($c, $start, strpos($c, "-->", $at) + 3 - $start);'
}
ARTICLE_BEFORE=$(node_bytes "$FILTER_ID" "Eigene Felder – Artikel")
TREE_REV=$(tool lit-divi-outline "{\"post_id\":$FILTER_ID}" | jq -r '.tree_rev')
CANONICAL=$(tool lit-divi-update "$(jq -cn --argjson id "$FILTER_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev,
changes: [{id: "s0.r0.c0.m2", set: {text: "Schreiben"}}]}')")
echo "$CANONICAL" | jq -e '.ok == true and (.warnings | any(contains("beim Speichern verändert")) | not)' >/dev/null \
|| fail "8a: write on a Divi-stored page: $CANONICAL"
[ "$(node_bytes "$FILTER_ID" "Eigene Felder – Artikel")" = "$ARTICLE_BEFORE" ] || fail "8a: untouched block changed"
pass "Divi save filters, page as Divi stored it: only the edited node changes, no warning"
LIT_ID="$FILTER_ID" LIT_FILE="$FIXTURES/synthetic-divi-filters.html" wp eval '
global $wpdb;
$id = (int) getenv("LIT_ID");
$wpdb->update($wpdb->posts, ["post_content" => (string) file_get_contents(getenv("LIT_FILE"))], ["ID" => $id]);
clean_post_cache($id);'
ARTICLE_BEFORE=$(node_bytes "$FILTER_ID" "Eigene Felder – Artikel")
TREE_REV=$(tool lit-divi-outline "{\"post_id\":$FILTER_ID}" | jq -r '.tree_rev')
FILTERED=$(tool lit-divi-update "$(jq -cn --argjson id "$FILTER_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev,
changes: [{id: "s0.r0.c0.m1", set: {text: "Eigene Felder bleiben erhalten"}}]}')")
echo "$FILTERED" | jq -e '.ok == true' >/dev/null || fail "8b: update on a page with custom fields: $FILTERED"
echo "$FILTERED" | jq -e '.warnings | any(contains("beim Speichern verändert"))' >/dev/null \
|| fail "8b: CHANGED_ON_SAVE missing (Divi no longer normalizes – update the S7 documentation): $FILTERED"
[ "$(node_bytes "$FILTER_ID" "Eigene Felder – Artikel")" != "$ARTICLE_BEFORE" ] \
|| fail "8b: untouched block kept its bytes (Divi no longer normalizes – update the S7 documentation)"
OUTLINE=$(tool lit-divi-outline "{\"post_id\":$FILTER_ID}")
[ "$(echo "$OUTLINE" | jq -r '.tree_rev')" = "$(echo "$FILTERED" | jq -r '.tree_rev')" ] \
|| fail "8b: tree_rev of the answer is not the stored state: $FILTERED"
NODE=$(tool lit-divi-node-get "{\"post_id\":$FILTER_ID,\"id\":\"s0.r0.c0.m0\"}")
echo "$NODE" | jq -e 'tostring | contains("article") and contains("data-track")' >/dev/null \
|| fail "8b: elementType or custom attribute lost: $NODE"
RENDERED=$(LIT_ID="$FILTER_ID" wp --user="$ADMIN_LOGIN" eval \
'echo (new LIT\MCP\Integrations\WordPress\Render\PageRenderer())->render(get_post((int) getenv("LIT_ID")));')
echo "$RENDERED" | grep -q 'data-track="hero"' || fail "8b: custom attribute not rendered"
echo "$RENDERED" | grep -q 'Eigene Felder bleiben erhalten' || fail "8b: update not rendered"
AGAIN=$(tool lit-divi-update "$(jq -cn --argjson id "$FILTER_ID" --arg rev "$(echo "$OUTLINE" | jq -r '.tree_rev')" '{post_id: $id, tree_rev: $rev,
changes: [{id: "s0.r0.c0.m2", set: {text: "Nochmal"}}]}')")
echo "$AGAIN" | jq -e '.ok == true and (.warnings | any(contains("beim Speichern verändert")) | not)' >/dev/null \
|| fail "8b: follow-up write with the new tree_rev: $AGAIN"
pass "Divi save filters, other byte form: normalized with CHANGED_ON_SAVE, tree_rev = stored state, page valid, then stable"
echo "ALL CHECKS PASSED"
@@ -57,7 +57,9 @@ final class InstructionsBuilderTest extends TestCase
$without = (new InstructionsBuilder(new IntegrationRegistry($core)))->build();
self::assertStringContainsString('lit-divi-outline und lit-divi-design-system lesen', $with);
self::assertStringContainsString('tree_rev', $with);
self::assertStringNotContainsString('lit-divi-outline und', $without);
self::assertStringNotContainsString('tree_rev', $without);
self::assertLessThan(2000, strlen($with));
}
+34
View File
@@ -96,6 +96,40 @@ final class ToolNamingTest extends TestCase
}
}
/** Every guide topic named in a description, hint or the overview exists as docs/guides/<topic>.md. */
public function testReferencedGuideTopicsExist(): void
{
$root = dirname(__DIR__, 3);
$referenced = [];
foreach (self::texts() as $path => $text) {
if (str_ends_with($path, '.php')) {
preg_match_all('/topic \\\\?"([a-z0-9-]+)\\\\?"/', $text, $matches);
$referenced = array_merge($referenced, $matches[1]);
}
}
self::assertContains('divi-shortform', $referenced, 'lit/divi-insert and lit/content-create name divi-shortform');
foreach (array_unique($referenced) as $topic) {
self::assertFileExists($root . '/docs/guides/' . $topic . '.md', 'guide topic "' . $topic . '"');
}
}
public function testOverviewListsExactlyTheOtherGuideTopics(): void
{
$root = dirname(__DIR__, 3);
$overview = (string) file_get_contents($root . '/docs/guides/overview.md');
preg_match('/Weitere Themen:(.*?)\n\n/s', $overview, $section);
preg_match_all('/`([a-z0-9-]+)`/', $section[1] ?? '', $listed);
$listedTopics = array_values(array_diff($listed[1], ['topic', 'lit-guide']));
sort($listedTopics);
$files = array_map(static fn (string $f): string => basename($f, '.md'), glob($root . '/docs/guides/*.md') ?: []);
$files = array_values(array_diff($files, ['overview']));
sort($files);
self::assertSame($files, $listedTopics);
}
public function testOverviewListsExactlyTheDirectTools(): void
{
$overview = (string) file_get_contents(dirname(__DIR__, 3) . '/docs/guides/overview.md');
@@ -84,11 +84,12 @@ final class DiviReadAbilitiesTest extends TestCase
{
DiviPageLoaderTest::givenPost('');
$ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design());
self::assertSame(['Die Seite hat noch keinen Divi-Inhalt.'], $ability->execute(['post_id' => 7])['hints']);
self::assertStringContainsString('lit-divi-insert', $ability->execute(['post_id' => 7])['hints'][0]);
DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-mixed.html'));
$hints = $ability->execute(['post_id' => 7])['hints'];
self::assertStringContainsString('nur lesbar', $hints[0]);
self::assertStringContainsString('nur als Ganzes', $hints[0]);
}
public function testNodeGetReturnsFullFormShortformAndPresets(): void
@@ -35,6 +35,8 @@ final class DiviCatalogAbilityTest extends TestCase
));
self::assertFalse($header[0]['shortform']);
self::assertSame(DiviCatalogAbility::CODE_NOTE, $result['notes'][0]);
self::assertStringContainsString('"divi-shortform"', $result['notes'][1]);
self::assertStringContainsString('"divi-attrs"', $result['notes'][1]);
}
public function testDetailOfOneModule(): void
@@ -97,6 +97,10 @@ final class PostPresenterTest extends TestCase
{
self::assertStringContainsString('lit-divi-outline', PostPresenter::diviHint(true));
self::assertStringContainsString('lit-content-update', PostPresenter::diviHint(true));
foreach (['lit-divi-insert', 'lit-divi-update', 'lit-divi-move', 'lit-divi-delete'] as $tool) {
self::assertStringContainsString($tool, PostPresenter::diviHint(true));
}
self::assertStringNotContainsString('vorerst im Divi-Builder', PostPresenter::diviHint(true));
self::assertStringNotContainsString('lit-divi', PostPresenter::diviHint(false));
self::assertStringContainsString(PostPresenter::DIVI_INACTIVE_NOTE, PostPresenter::diviHint());
}
+1 -1
View File
@@ -3,7 +3,7 @@
/**
* Plugin Name: WP-LIT-MCP
* Description: MCP-Server für WordPress, Divi 5 und Pods – mit OAuth für claude.ai.
* Version: 0.2.0
* Version: 0.3.0
* Requires at least: 6.9
* Requires PHP: 8.1
* Tested up to: 7.0