merge: Etappe 3a (v0.4.0) – Divi-Bibliothek, globale Module, Link-Platzhalter, dynamische Quellen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 12:16:10 +02:00
co-authored by Claude Opus 5.5
147 changed files with 18844 additions and 178 deletions
+1 -1
View File
@@ -25,7 +25,7 @@
"psr-4": { "LIT\\MCP\\Tests\\": "tests/" } "psr-4": { "LIT\\MCP\\Tests\\": "tests/" }
}, },
"scripts": { "scripts": {
"test": "@php vendor/bin/phpunit", "test": "@php -d memory_limit=512M vendor/bin/phpunit",
"lint": "@php vendor/bin/phpcs" "lint": "@php vendor/bin/phpcs"
}, },
"config": { "config": {
+6 -3
View File
@@ -38,9 +38,12 @@ zusammengeführt): z. B. bei `module.decoration.sizing` einer Spalte `flexType`
Unbekannte Pfade → Fehler `invalid_attr_path` mit Vorschlägen unter `fields`. Texte werden bereinigt 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, (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 geprüft und vom Plugin neu erzeugt (siehe `divi-placeholders`). Eigenes HTML, CSS, HTML-Attribute,
(`…decoration.attributes`, `…advanced.html`, `…css…`) nur mit dem Recht `unfiltered_html`, sonst „nur im Interaktionen und die Lottie-Quelle (`…decoration.attributes`, `…decoration.interactions`, `…advanced.html`, `…css…`,
Divi-Builder“. Unberührte Teile der Seite bleiben byte-identisch; Ausnahme: Divi selbst normalisiert Seiten `lottie.innerContent`) nur mit dem Recht `unfiltered_html`, sonst „nur im
Divi-Builder“ – Interaktionen können im Browser beliebige HTML-Attribute und Cookies setzen, die
Lottie-Quelle lädt eine Animation von einer fremden Adresse, die Code ausführen kann. Dieselbe Grenze gilt
beim Kopieren und Einfügen gespeicherter Knoten (siehe `divi-library`). Unberührte Teile der Seite bleiben byte-identisch; Ausnahme: Divi selbst normalisiert Seiten
mit eigenen HTML-/Attribut-Feldern beim Speichern – nur wenn die Seite noch nicht in Divis eigener gespeicherter Form vorliegt (z. B. importierter oder direkt in die Datenbank geschriebener Inhalt); über den Divi-Builder oder LIT MCP gespeicherte Seiten betrifft das praktisch nie (Warnung in der Antwort, neue `tree_rev` gilt). mit eigenen HTML-/Attribut-Feldern beim Speichern – nur wenn die Seite noch nicht in Divis eigener gespeicherter Form vorliegt (z. B. importierter oder direkt in die Datenbank geschriebener Inhalt); über den Divi-Builder oder LIT MCP gespeicherte Seiten betrifft das praktisch nie (Warnung in der Antwort, neue `tree_rev` gilt).
## Pfade finden ## Pfade finden
+4
View File
@@ -11,6 +11,10 @@
- **Presets** (`presets`): Modul-Presets je Modul (`kind: module`, z. B. alle Button-Presets) und - **Presets** (`presets`): Modul-Presets je Modul (`kind: module`, z. B. alle Button-Presets) und
Gruppen-Presets (`kind: group`); `default: true` markiert das Standard-Preset eines Moduls. Gruppen-Presets (`kind: group`); `default: true` markiert das Standard-Preset eines Moduls.
Auf großen Websites eingrenzen: `only` (Liste aus `colors`, `variables`, `presets`) liefert nur diese Teile
(Schriften und Größen sind immer dabei), `module` (z. B. `"button"`) nur die Presets dieses Moduls; höchstens
200 Presets je Antwort (`notes` sagt, wenn es mehr gibt).
Regeln: Regeln:
1. Vor dem Gestalten einer Seite das Designsystem lesen. 1. Vor dem Gestalten einer Seite das Designsystem lesen.
+121
View File
@@ -0,0 +1,121 @@
# Divi-Bibliothek und globale Module
Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederverwendung.
- **Normale Einträge** werden beim Einfügen kopiert; die Kopie ist danach unabhängig.
- **Globale Einträge** werden als Instanz eingefügt. Divi zeigt überall den aktuellen Inhalt des
Bibliothekseintrags: eine Änderung dort wirkt sofort auf allen Seiten mit diesem Modul – auch auf
veröffentlichten.
## Werkzeuge
- `lit/library-list` (über `mcp-adapter-execute-ability`): Einträge auflisten, Filter `layout_type`
(`layout`, `section`, `row`, `module`), `scope` (`normal`, `global`), `search` (Titel), `category`
(Name), weiterblättern mit `cursor`. Gelistet werden nur Einträge, die das Konto bearbeiten darf. Mit `id`:
ein Eintrag im Detail – Knoten-Übersicht (`outline`), `tree_rev` und die Seiten, die auf ihn verweisen
(`affected`, `affected_count`, `hidden_count`). Erkannt werden Divi-5-Instanzen (auch mit maskierten Zeichen)
und Divi-4-Shortcodes mit `global_module` – auch in Texten von Divi-5-Modulen. Im Zweifel zählt eine Seite
mit: ein Verweis, der sich nicht eindeutig lesen lässt, und ein `global_module`, das keine gültige Zahl ist
(Divi zeigt dann den neuesten Eintrag), gelten als Verweis auf jeden Eintrag. Sehr ungewöhnliche Schreibweisen
können trotzdem unerkannt bleiben. Steckt der Eintrag in einem wiederverwendbaren Block (`wp_block`), erscheint
dieser Block in der Liste, nicht die Seite, die ihn einbettet – wie bei Divi selbst.
- `lit-library-insert`: einen Eintrag in eine Divi-Seite einfügen. `mode: "global"` (Standard bei globalen
Einträgen) fügt eine verknüpfte Instanz ein, `mode: "copy"` (Standard sonst) eine unabhängige Kopie.
Position wie bei `lit-divi-insert` (`parent`, `before`, `after`, ohne Angabe ans Seitenende); `tree_rev`
der Zielseite ist Pflicht. Die Kopie eines globalen Eintrags ist vollständig gelöst (auch darin enthaltene
globale Module); in der Kopie eines normalen Eintrags bleiben globale Module verknüpft. `mode: "global"`
geht nur bei globalen Einträgen mit genau einem Abschnitt, einer Zeile oder einem Modul ohne globale Module
darin – sonst `mode: "copy"` verwenden. Nur aktive Einträge (nicht im Papierkorb) lassen sich einfügen.
- `lit/library-save`: einen Abschnitt, eine Zeile oder ein Modul als neuen Eintrag speichern (`post_id`,
`tree_rev`, Pfad-ID `id`, `title`, `scope: "normal"` oder `"global"`, vorhandene `categories`/`tags`). Der
Knoten auf der Seite bleibt eine eigenständige Kopie – um ihn zu verknüpfen, ihn durch eine Instanz
ersetzen (siehe Ablauf unten). Ein Knoten, der selbst ein globales Modul ist oder eines enthält, lässt sich
nicht als globaler Eintrag speichern.
- `lit/divi-node-copy`: einen Knoten von einem Eintrag an eine Position eines anderen kopieren
(`source_post_id`, `source_id`, `source_tree_rev` aus `lit-divi-outline` der Quelle – Pflicht, weil
Pfad-IDs nur für diesen Stand gelten; Ziel mit `post_id`, `tree_rev`, Position). Quelle und Ziel dürfen
Seiten oder Bibliothekseinträge sein. Die Kopie ist byte-gleich; globale Module bleiben verknüpft. Aus
einem Bibliothekseintrag zu kopieren ist Einfügen aus der Bibliothek: es braucht dieselben Rechte und
Freigaben wie `lit-library-insert`.
- Die Divi-Werkzeuge arbeiten auch auf Bibliothekseinträgen: `lit-divi-outline`, `lit-divi-node-get`,
`lit-divi-insert`, `lit-divi-update`, `lit-divi-move`, `lit-divi-delete`, `lit/divi-duplicate-node` und
`lit/divi-replace-text` mit der ID des Eintrags als `post_id`.
## Globale Module erkennen und ändern
1. In `lit-divi-outline` einer Seite erscheinen Instanzen als z. B. `g0 section "Kontakt" [global 1058]
(nur lesbar)`; `lit-divi-node-get` nennt unter `global` die ID des Bibliothekseintrags. Auf der Seite
lassen sie sich nur als Ganzes verschieben, duplizieren oder löschen.
2. `lit-divi-outline` mit `post_id` = ID des Eintrags lesen und die Änderung wie auf einer Seite senden.
3. Ohne `confirm` ist jede Änderung eines globalen Eintrags ein Probelauf – ebenso bei einem normalen Eintrag,
auf den Seiten verweisen (Divi zeigt dort dessen aktuellen Inhalt): Antwort `confirm_required` mit
`affected` (betroffene Seiten, höchstens 50), `affected_count` (alle) und `hidden_count` (Seiten, die das
Konto nicht lesen darf – sie werden nur gezählt, ohne ID); gespeichert wird nichts.
4. Dem Nutzer die betroffenen Seiten nennen und fragen. Erst nach Zustimmung dieselbe Änderung mit
`confirm: true` wiederholen (bei `lit-divi-delete` gilt dasselbe `confirm`).
5. Vorher entsteht eine Revision des Eintrags (`revision_id`); sind Revisionen abgeschaltet, eine
Sicherungskopie im Papierkorb (`backup_id`). Schlägt das Speichern danach fehl, nennt der Fehler die
`backup_id`. Nach dem Speichern wird Divis CSS-Cache für alle Seiten geleert.
Die Bestätigung wird abgelehnt (`forbidden`), wenn das Konto einen veröffentlichten, privaten oder geplanten
Eintrag, der das Modul zeigt, nicht bearbeiten darf – schon der Probelauf warnt davor und nennt diese IDs in
`blocked_ids`, nicht lesbare nur als Anzahl in `blocked_hidden_count` (Entwürfe blockieren nicht).
Theme-Builder-Layouts (Header, Body, Footer, Vorlagen) blockieren außerdem, wenn die Divi-Rolle des Kontos
„Theme Builder“ nicht erlaubt. Zeigen
mehr als 2000 veröffentlichte, private oder geplante Einträge das Modul, lehnt LIT MCP die Bestätigung ab
(`unsupported`); solche Einträge im Divi-Builder bearbeiten. Sind Revisionen für die Bibliothek abgeschaltet, lehnt das
Werkzeug die Änderung außerdem ab (`unsupported`), wenn der Papierkorb deaktiviert ist oder das Konto kein
`unfiltered_html` hat (die Sicherungskopie wäre dann nicht verlustfrei).
`lit/divi-replace-text` nimmt Bibliothekseinträge als `targets` (ID und `tree_rev` aus `lit-divi-outline`;
der Probelauf listet nur Seiten des Inhaltstyps): normale Einträge werden gespeichert, globale (und normale,
auf die Seiten verweisen) nie – ihr
Ergebnis nennt stattdessen die betroffenen Seiten; die Änderung dann nach Rückfrage mit `lit-divi-update`
und `confirm: true` vornehmen.
Regeln für Bibliothekseinträge: ein Abschnitts-, Zeilen- oder Modul-Eintrag behält genau einen Knoten dieses
Typs (die Instanzen auf den Seiten erwarten ihn); ein Layout-Eintrag enthält nur Abschnitte; kein Eintrag
wird leer; ein globaler Eintrag enthält keine globalen Module. Nur aktive Einträge lassen sich ändern.
`lit-page-render-summary` zeigt globale Module nicht (über die Schnittstelle rendert Divi sie nicht) und nennt
sie in `notes`; ihren Inhalt mit `lit-divi-outline` und der ID lesen.
## Rechte
- WordPress: die Bibliothek nutzen = Beiträge bearbeiten dürfen; einen Eintrag lesen oder ändern = diesen
Eintrag bearbeiten dürfen; neue Einträge anlegen = Beiträge veröffentlichen dürfen (Bibliothekseinträge
sind immer aktiv).
- Divi → Rollen-Editor: „Divi-Bibliothek“, „In der Bibliothek speichern“ (anlegen und ändern), „Aus der
Bibliothek hinzufügen“ (einfügen), „Globale Bibliothekselemente bearbeiten“ (globale Einträge anlegen und
ändern). Fehlt „Divi-Bibliothek“, gilt jeder Eintrag als nicht vorhanden (`not_found`) und die Liste
antwortet mit `forbidden`; fehlt eines der anderen, antwortet das Werkzeug mit `forbidden` und diesem
Hinweis.
- Einstellungen → LIT MCP → Tools: Gruppe „Divi-Bibliothek“ auf „nur lesen“ sperrt jeden Schreibzugriff auf
Bibliothekseinträge, auch über die Divi-Werkzeuge; deaktiviert gilt jeder Eintrag als nicht vorhanden.
Grenze: Die Einträge selbst sind dann nicht lesbar und nicht änderbar, aber `lit-divi-outline`,
`lit-divi-node-get` und `lit-page-render-summary` einer Seite nennen die ID eines dort verwendeten globalen
Moduls weiterhin (auch ohne das Recht „Divi-Bibliothek“). Diese ID mit den Divi-Werkzeugen zu öffnen,
antwortet `not_found`; dann nicht weiter versuchen.
`lit-library-insert` und `lit/divi-node-copy` sind außerdem gesperrt, wenn `lit-divi-insert` gesperrt ist;
`lit/divi-node-copy` mit einem Bibliothekseintrag als Quelle auch, wenn `lit-library-insert` gesperrt ist.
- Ohne das Recht `unfiltered_html` überträgt LIT MCP beim Kopieren, Einfügen und Speichern (`lit/divi-node-copy`,
`lit-library-insert`, `lit/library-save`) nur Knoten, die das Konto auch selbst schreiben dürfte. Abgelehnt
werden – „nur im Divi-Builder“ – Knoten mit eigenem HTML, CSS oder HTML-Attributen, Interaktionen, einer
Lottie-Quelle, dem Code-Modul, fremden Blöcken oder Freitext zwischen den Blöcken, Text mit HTML, das der
HTML-Filter von WordPress verändern würde, und `$variable`-Verweisen, die die normale Prüfung nicht
bestehen (z. B. HTML davor/danach). Das ist strenger als Divi selbst; die Antwort nennt jede Fundstelle
(`validation_failed`), gespeichert wird nichts. Relative Links (`kontakt/`, `/pfad`, `#anker`) bleiben
kopierbar.
## Ablauf: globale Module vorschlagen
1. Die Seiten lesen (`lit-divi-outline`) und wiederkehrende Abschnitte suchen (gleiche Admin-Labels oder
Texte, z. B. Kontakt, Öffnungszeiten, Call-to-Action).
2. Dem Nutzer vorschlagen, welche davon globale Module werden sollen, und erklären: eine Änderung wirkt
danach auf allen Seiten.
3. Nach Zustimmung den Abschnitt einer Seite mit `lit/library-save` und `scope: "global"` speichern.
4. Auf jeder Seite (auch der ersten) die Instanz mit `lit-library-insert` direkt vor dem bisherigen Abschnitt
einfügen und den bisherigen Abschnitt nach Rückfrage mit `lit-divi-delete` entfernen.
5. Den Nutzer bitten, das Ergebnis im Divi-Builder anzusehen.
Unterscheiden sich die Abschnitte in Details, vorher mit dem Nutzer klären, welche Fassung gilt.
+14 -3
View File
@@ -12,6 +12,7 @@ sich auch der Button.
| `{{font:heading}}`, `{{font:body}}` | Theme-Schrift für Überschriften bzw. Fließtext | | `{{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 | | `{{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}}` | | `{{field:<meta_key>}}` | dynamischer Inhalt aus einem Feld (Pods/Meta), z. B. `{{field:telefon}}` |
| `{{link:<ID>}}` | Link auf einen anderen Eintrag (Seite, Beitrag …), z. B. `{{link:42}}` – nur in Link-Feldern; Divi setzt beim Anzeigen die aktuelle Adresse ein, also auch für Entwürfe, die erst später veröffentlicht werden |
| `"preset": "<Name>"` | Modul-Preset (kein Platzhalter, sondern eigenes Feld) | | `"preset": "<Name>"` | Modul-Preset (kein Platzhalter, sondern eigenes Feld) |
Beispiele: Beispiele:
@@ -30,9 +31,19 @@ Unbekannte Namen lehnt das Werkzeug ab und nennt die vorhandenen; ist ein Name d
Namens die ID aus `lit-divi-design-system` angeben (z. B. `{{color:gcid-…}}`). Felder, deren Schlüssel mit `_` 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. beginnt (geschützte Meta-Daten), sind nicht erlaubt.
In Link-Feldern (`url`, `button_url` …) nimmt das Werkzeug jeden dynamischen Inhalt an, auch `{{post:title}}`; In Link-Feldern (`url`, `button_url` …) für interne Links immer `{{link:<ID>}}` verwenden statt fester
sinnvoll sind dort nur Werte, die eine Adresse ergeben (`{{post:url}}`, ein Feld mit einer URL, eine Adressen wie `/?page_id=…`. Der Eintrag muss existieren und für das Konto lesbar sein; Einträge im Papierkorb,
Link-Variable). automatische Entwürfe und Medien (Anhänge – dafür die Adresse aus der Mediathek) lehnt das Werkzeug ab,
ebenso `{{link:…}}` außerhalb von Link-Feldern. Solange das Ziel ein Entwurf oder privat ist, zeigt Divi
Besuchern nur die Adresse `?page_id=<ID>` (kein Titel); nach dem Veröffentlichen die normale Adresse. Sonst
nimmt ein Link-Feld jeden dynamischen Inhalt an; sinnvoll sind nur Werte, die eine Adresse ergeben
(`{{post:url}}`, ein Feld mit einer URL, eine Link-Variable).
Welche dynamischen Quellen und Felder es für einen Inhaltstyp gibt, liefert `lit/divi-dynamic-sources`
(über `mcp-adapter-execute-ability`): Divis Quellen mit Platzhalter und die Felder, die sich als
`{{field:…}}` eignen. Gelistet werden nur textartige Felder; Beziehungs- und Dateifelder (Divi zeigt sie nur
als IDs), Tabellen-Felder und Felder unbekannten Typs stehen mit Grund unter `omitted`. Bei Auswahlfeldern
zeigt Divi den gespeicherten Wert, nicht die Beschriftung.
## `$variable`-Werte ## `$variable`-Werte
+4 -2
View File
@@ -106,8 +106,10 @@ Bedarf die Vorlage im Builder neu wählen).
Alle Werte werden vor dem Speichern bereinigt: Texte ohne HTML, Rich-Text ohne `unfiltered_html` nur mit dem 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:`. 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`, `$variable`-Verweise werden geparst, geprüft und vom Plugin neu erzeugt. Eigenes HTML, CSS, HTML-Attribute, Interaktionen und die Lottie-Quelle eines Moduls (`…decoration.attributes`,
`…css…`) ändert nur, wer das Recht `unfiltered_html` hat – sonst „nur im Divi-Builder“. Unberührte Teile der `…decoration.interactions`, `…advanced.html`, `…css…`, `lottie.innerContent`) ändert nur, wer das Recht `unfiltered_html` hat – sonst „nur im Divi-Builder“ (Interaktionen können im
Browser beliebige HTML-Attribute und Cookies setzen, die Lottie-Quelle lädt Animationen von fremden Adressen,
die Code ausführen können). Unberührte Teile der
Seite bleiben byte-identisch; Ausnahme: Divi selbst normalisiert Seiten mit eigenen HTML-/Attribut-Feldern Seite bleiben byte-identisch; Ausnahme: Divi selbst normalisiert Seiten mit eigenen HTML-/Attribut-Feldern
beim Speichern – nur wenn die Seite noch nicht in Divis eigener gespeicherter Form vorliegt (z. B. importierter oder direkt in die Datenbank geschriebener Inhalt); über den Divi-Builder oder LIT MCP gespeicherte Seiten betrifft das praktisch nie (dann Warnung, dass der Inhalt beim Speichern verändert wurde – mit der neuen `tree_rev` beim Speichern – nur wenn die Seite noch nicht in Divis eigener gespeicherter Form vorliegt (z. B. importierter oder direkt in die Datenbank geschriebener Inhalt); über den Divi-Builder oder LIT MCP gespeicherte Seiten betrifft das praktisch nie (dann Warnung, dass der Inhalt beim Speichern verändert wurde – mit der neuen `tree_rev`
weiterarbeiten). weiterarbeiten).
+6 -3
View File
@@ -38,8 +38,10 @@ Neue Inhalte und Änderungen: siehe `divi-shortform` (Kurzform, `lit-divi-insert
## Was nicht bearbeitet wird ## Was nicht bearbeitet wird
- Knoten mit „nur lesbar“ (globale Module `g…`, fremde Blöcke `x…`) lassen sich nur als Ganzes verschieben, - 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). duplizieren oder löschen. Globale Module zeigt die Übersicht mit `[global <ID>]`: ihren Inhalt im
`lit/divi-replace-text` ändert sie nicht, sondern nennt sie in `skipped_nodes`. Bibliothekseintrag ändern (`lit-divi-outline` mit `post_id` = ID; Ablauf mit Probelauf und `confirm` siehe
`lit-guide` topic `divi-library`). `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 - 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. ö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 - Ist das Markup beschädigt, lehnen die Werkzeuge ab (`unsupported`); die Seite im Divi-Builder öffnen und
@@ -49,4 +51,5 @@ Neue Inhalte und Änderungen: siehe `divi-shortform` (Kurzform, `lit-divi-insert
`lit-page-render-summary` rendert den Inhaltsbereich wie im Frontend und listet Überschriften, Texte, Links `lit-page-render-summary` rendert den Inhaltsbereich wie im Frontend und listet Überschriften, Texte, Links
und Bilder samt Warnungen (Sprünge in der Überschriftenhierarchie, fehlende Alt-Texte, Links ohne Ziel). und Bilder samt Warnungen (Sprünge in der Überschriftenhierarchie, fehlende Alt-Texte, Links ohne Ziel).
Header, Footer und Theme-Builder-Vorlagen sind nicht enthalten. Header, Footer und Theme-Builder-Vorlagen sind nicht enthalten; globale Module ebenfalls nicht (über die
Schnittstelle rendert Divi sie nicht) – `notes` nennt sie mit ihrer ID.
+10 -6
View File
@@ -21,14 +21,14 @@ Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-conte
`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`, `lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`,
`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-page-render-summary`, `lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-page-render-summary`,
`lit-divi-outline`, `lit-divi-node-get`, `lit-divi-catalog`, `lit-divi-design-system`, `lit-divi-insert`, `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`. `lit-divi-update`, `lit-divi-move`, `lit-divi-delete`, `lit-library-insert`.
Die Tools sind nach Integration gruppiert (Grundlagen, WordPress, Medien, Divi). Jede Gruppe hat unter Die Tools sind nach Integration gruppiert (Grundlagen, WordPress, Medien, Divi, Divi-Bibliothek). Jede Gruppe hat unter
Einstellungen → LIT MCP → Tools eine eigene Einstellung (direkt/indirekt/deaktiviert), die ihre Tools übernehmen 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“ („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 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 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. Tools warnt die Seite (Kontextkosten im Assistenten). Änderungen gelten ab der nächsten Verbindung.
Über `mcp-adapter-discover-abilities` / `mcp-adapter-execute-ability` (Ability-Namen `lit/…`; in Guides und Über `mcp-adapter-discover-abilities` / `mcp-adapter-execute-ability` (Ability-Namen `lit/…`; in Guides und
Hinweisen immer so geschrieben): Hinweisen immer so geschrieben):
@@ -36,12 +36,16 @@ Status (`lit/post-unpublish`, `lit/post-schedule`, `lit/post-trash`), Kopien (`l
`lit/post-replace-with-copy`), Revisionen (`lit/revisions-list`, `lit/revision-restore`), Menüs `lit/post-replace-with-copy`), Revisionen (`lit/revisions-list`, `lit/revision-restore`), Menüs
(`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`), (`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`),
Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`), Medien (`lit/media-get`, `lit/media-upload`, Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`), Medien (`lit/media-get`, `lit/media-upload`,
`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`). `lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`,
`lit/divi-node-copy`, `lit/divi-dynamic-sources`), Divi-Bibliothek (`lit/library-list`, `lit/library-save`).
Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist. Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist. Sie arbeiten auch auf Einträgen
der Divi-Bibliothek (ID als `post_id`); globale Module: `lit-guide` topic `divi-library`.
Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows`, `media`, `divi-structure`, Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows`, `media`, `divi-structure`,
`divi-shortform`, `divi-attrs`, `divi-placeholders` oder `divi-design-system`. `divi-shortform`, `divi-attrs`, `divi-placeholders`, `divi-design-system` oder `divi-library`.
Für den Einstieg liefert `lit-guide` mit dem Thema „all“ alle Themen in einem Aufruf.
## Rückmeldungen ## Rückmeldungen
+9 -1
View File
@@ -22,6 +22,13 @@
4. `lit-page-render-summary` prüfen, `warnings` beheben, dem Nutzer `preview_url` und `edit_url` (Divi-Builder) 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`). nennen. Veröffentlichen nur auf Wunsch (`lit-post-publish`).
## Bibliothek und globale Module
Vorhandene Abschnitte aus der Divi-Bibliothek zuerst suchen (`lit/library-list`) und mit `lit-library-insert`
einfügen, statt sie neu zu bauen. Einen gelungenen Abschnitt für später speichern: `lit/library-save`.
Abschnitte zwischen Seiten kopieren: `lit/divi-node-copy`. Globale Module ändern und neue vorschlagen:
`lit-guide` topic `divi-library`.
## Text auf vielen Seiten ersetzen ## Text auf vielen Seiten ersetzen
`lit/divi-replace-text` zuerst mit `dry_run: true` (Standard): zeigt Treffer je Seite mit `tree_rev`. Nach `lit/divi-replace-text` zuerst mit `dry_run: true` (Standard): zeigt Treffer je Seite mit `tree_rev`. Nach
@@ -29,7 +36,8 @@ Rückfrage mit `dry_run: false`, `targets` (post_id + tree_rev aus dem Probelauf
ausführen; jede Seite bekommt eine eigene Revision, `results` meldet das Ergebnis je Seite. Ersetzt wird nur 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, 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 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. geändert, sondern in `skipped_nodes` genannt – sie im Bibliothekseintrag ändern (`lit-guide` topic
`divi-library`).
## Menüs pflegen ## Menüs pflegen
@@ -0,0 +1,222 @@
# Etappe 3 – Entscheidungen der Planung (Entwurf)
Vorbereitung von Etappe 3 („Library & Theme Builder“), 2026-09-26/27. Grundlage: Spec §3, §4.3, §5, §7, §10, §12, §15; Entscheidungen Etappe 2 (D1–D12); Rulings 2b (S1–S52), Praxistest und Security-Audit 2b. Quelle: Divi 5.2.1 (`wp-content/themes/Divi`, `style.css:4`), Pfade relativ zu `themes/Divi/includes/`. Live geprüft nur lesend (DDEV, `wp eval-file`). Entwurf – noch nicht vom Controller freigegeben. Kosten, falls falsch, jeweils am Ende.
## 1. Verifizierte Fakten
### 1.1 Divi Library (`et_pb_layout`)
- F1: Post-Typ `et_pb_layout` (`ET_BUILDER_LAYOUT_POST_TYPE`, `builder/post/type/Layout.php:4`), `capability_type post`, `map_meta_cap`, `show_ui true`, `show_in_rest false`, unterstützt `revisions` (`Layout.php:79`). Taxonomien `layout_category`, `layout_tag`, `layout_pack`, `layout_type`, `module_width`, `scope`, `layout_location` (`Layout.php:83-90`). Live: `assign_terms` = `edit_posts`, verwalten = `manage_categories`; `layout_location` ist lokal nicht registriert.
- F2: Anlegen (Divi 4-Weg, von Divi 5 weiter benutzt): `et_pb_submit_layout()` (`builder/shortcode-core.php:654-734`) setzt Terme `scope` (`global`/`not_global`), `layout_type` (`layout`/`section`/`row`/`module`), `module_width` (`regular`/`fullwidth`), Kategorien/Tags und Meta `_et_pb_row_layout` (Zeilen), `_et_pb_module_type` (Module), `_et_pb_excluded_global_options = "[]"` (globale Module), `_et_pb_built_for_post_type`, `_et_pb_template_type`; `et_pb_create_layout()` (`:738-800`) speichert immer mit `post_status publish` über `wp_insert_post` (also mit kses). Im REST-/CLI-Kontext ist `et_pb_submit_layout` nicht geladen (live: `function_exists` = false; geladen nur über `builder/shortcode-framework.php:6`).
- F3: Divi 5 legt Library-Einträge über die Builder-Route `POST divi/v1/divi-library/create-item` an (`builder-5/server/VisualBuilder/REST/RESTRegistration.php:743-749`, `DiviLibraryController::create_item` `DiviLibraryController.php:606-757`): D4-Konvertierung, Preset-/Farb-/Variablen-Import, dann `et_pb_submit_layout` (`:715`). Recht: `edit_posts` + `et_pb_is_allowed('divi_library')` + `('save_library')` (`:958-966`). Inhalt wird vorher mit `SavingUtility::prepare_content_for_db` bereinigt (`:980`; `VisualBuilder/Saving/SavingUtility.php:230-254`: ohne `unfiltered_html` wird die Seite komplett geparst und neu serialisiert). Der Versuch, danach `_et_pb_use_divi_5` zu setzen, liest `$new_layout_meta['layout_id']` aus einem JSON-String (`:715-720`) – auf dieses Flag ist bei Library-Einträgen kein Verlass.
- F4: Library-Einträge speichern ihren Inhalt in `post_content` in einem `divi/placeholder`-Wrapper (`DiviLibraryController.php:2395, 2461-2466`).
- F5: „Global“ = Term `scope=global`; Rückfall für importierte D4-Module: `_et_pb_module_type` und `_et_pb_excluded_global_options` gesetzt (`builder-5/server/Packages/GlobalLayout/GlobalLayout.php:34-61`, `GlobalLayout::is_global_layout_template()`).
- F6: Instanz auf einer Seite = Platzhalterblock `divi/global-layout` mit `{"globalModule":"<ID>","blockName":"divi/<typ>","localAttrs":{…}}` und den Kindern als `innerBlocks` (Schnappschuss). Belegt durch die D4→D5-Konvertierung (`builder-5/server/Packages/Conversion/Conversion.php:1454-1479`) und die Serialisierung des Builders (`builder-5/visual-builder/build/serialized-post.js`, Filter `divi.serializedPost.parseFlatModuleObjectIntoBlockArray.nested`: alle Attribute außer `globalModule`, `localAttrsMap`, `localChildren`, `globalParent`, `blockName`, `globalModuleTemplate` wandern nach `localAttrs`). Kinder innerhalb einer globalen Vorlage tragen `globalParent` (`Conversion.php:1491-1494`). Modul-Metadaten: `divi/global-layout` hat keine Attribute (`builder-5/server/_all_modules_metadata.php:18550-18557`).
- F7: **Ausgabe per Verweis.** Beim Rendern ersetzt `BlockParser` jeden `divi/global-layout` durch den aktuellen Inhalt des Library-Posts (`builder-5/server/FrontEnd/BlockParser/BlockParser.php:838-884`; `BlockParserStore::get_global_layout_content`, `BlockParserStore.php:1113-1330`). Aus dem Schnappschuss `localAttrs` werden nur die Attribute übernommen, die die Vorlage in `localAttrsMap` als „nicht synchronisiert“ führt (`apply_local_attrs_filtering`, `BlockParserStore.php:348`); die Kinder aus der Seite nur, wenn die Vorlage `localChildren: true` hat (`:1307-1311`). Die Seitenkopie ist also ein Schnappschuss, maßgeblich ist der Library-Post.
- F8: **Keine Expansion bei REST-POST.** `BlockParser` überspringt die Expansion bei `MigrationContext`, beim Speichern und bei jedem REST-Request mit POST/PUT/PATCH (`BlockParser.php:827-831`, `_is_rest_update_request` `:690-701`). Unsere MCP-Aufrufe sind REST-POST – `lit/page-render-summary` (D5, `the_content` im Prozess) sieht globale Module daher **nicht**.
- F9: Bearbeitet man eine globale Instanz im Visual Builder, speichert der Builder sofort den Library-Post über `POST divi/v1/divi-library/save` (`visual-builder/build/global-layouts.js`; `DiviLibraryController::save` `:1439-1447` = `wp_update_post(wp_slash(…))`, Inhalt vorher `prepare_content_for_db`). Recht: `edit_posts` + `et_pb_is_allowed('edit_global_library')` (`:1494-1500`) – **ohne** `edit_post` auf den Eintrag.
- F10: Verschachtelte globale Module sind verboten: beim Öffnen einer globalen Vorlage im Builder werden innere `divi/global-layout` „entglobalisiert“ (`VisualBuilder/SettingsData/SettingsDataCallbacks.php:739-743`, `Framework/Portability/PortabilityPost.php:3521-3605` `maybe_deglobalize_nested_global_modules`: Platzhalter → `blockName` mit `localAttrs` als Attribute, `globalParent` entfernt). Divi selbst liest `globalModule` aus Markup per String-Scan, weil `parse_blocks()` damit nicht zuverlässig umgeht (`PortabilityPost.php:3510`, `BlockParserStore.php:1137-1184`, `FrontEnd/Assets/DetectFeature.php:349-359`).
- F11: Library-REST-Routen (`RESTRegistration.php:671-775`): `divi-library` (Liste), `/item`, `/update-terms`, `/update-item`, `/convert-item`, `/split-item`, `/load`, `/create-item`, `/save`, `/upload-image`, `/item-location` (löscht trotz Namen Posts, `DiviLibraryController.php:1197-1221`). Alle nonce-geschützt, nur für den Builder.
- F12: Rechte im Divi-Rollen-Editor: `et_pb_is_allowed()` (`builder/core.php:1128-1162`); Library-Admin braucht `divi_library` + `save_library` (`builder/functions.php:340-346`), globale Einträge zusätzlich `edit_global_library` (`functions.php:366-372`; im Builder `core.php:4248-4262`). `theme_builder` und `read_dynamic_content_custom_fields` sind „gefährlich“: für andere Rollen als Administrator standardmäßig **aus** (`core.php:1138-1156`), Multisite-Super-Admins immer an. Live: keine Rollen-Einstellungen gespeichert (`et_pb_role_settings` leer) → Standardwerte.
- F13: Live-Bestand: 0 `et_pb_layout`, 0 `et_template`, 0 `et_theme_builder`, 0 TB-Layouts, kein Inhalt mit `globalModule` (Probe `s1.php`). **Echte Library-/TB-Daten fehlen vollständig.**
### 1.2 Theme Builder
- F14: Post-Typen `et_theme_builder`, `et_template`, `et_header_layout`, `et_body_layout`, `et_footer_layout` (`builder/frontend-builder/theme-builder/theme-builder.php:7-23, 89-252`), alle `capability_type post`, `show_ui false`, `show_in_rest false`; Layouts unterstützen `revisions` (`:184, 216, 248`), Vorlagen nicht (`:152`).
- F15: Live- und Entwurfsstand: `et_theme_builder_get_theme_builder_post_id($live)` sucht den TB-Post mit Status `publish` (live) bzw. `auto-draft` (Entwurf des TB-Editors), ohne Meta `_et_library_theme_builder` (`theme-builder.php:378-413`). Die Vorlagen hängen als mehrfaches Meta `_et_template` am TB-Post, mit Sicherung in der Option `et_tb_templates_backup_<id>` während des Speicherns (`:424-441`; `api.php:223-243`).
- F16: Vorlage (`et_template`) = Meta `_et_default`, `_et_enabled`, `_et_autogenerated_title`, `_et_{header,body,footer}_layout_id`, `_et_{…}_layout_enabled`, `_et_{…}_layout_global`, `_et_library_item_id`, mehrfach `_et_use_on`/`_et_exclude_from` (`theme-builder.php:471-521`). `override` = Layout-ID ≠ 0 oder Bereich deaktiviert (`:502`).
- F17: `et_theme_builder_store_template($tb_id, $template, $allow_default)` (`theme-builder.php:1463-1586`): `wp_die()` ohne `edit_others_posts` (`:1464-1466`); neue Vorlagen sofort `publish` (`:1515-1521`); Layout-IDs nur übernommen, wenn der passende Typ **und `publish`** (`:1486-1496`); Bedingungen `sanitize_text_field`, dedupliziert (`:1565-1583`). Die Funktion hängt die Vorlage **nicht** an den TB-Post – das macht nur `et_theme_builder_api_save` (`api.php:223-243`).
- F18: `et_theme_builder_insert_layout($options)` (`theme-builder.php:1643-1667`): `wp_die()` ohne `edit_others_posts`, `wp_insert_post` mit `post_status publish` (kses!), Term `layout_type=layout`, `et_builder_enable_for_post()` (setzt `_et_pb_use_builder`, `core.php:4438-4449`).
- F19: Divis TB-Speichern läuft über admin-ajax (`wp_ajax_et_theme_builder_api_save`, `api.php:160-273`: `et_builder_security_check('theme_builder', 'edit_others_posts', …)`), ersetzt die gesamte `_et_template`-Liste des TB-Posts (`:233-240`), trasht beim Live-Speichern den Entwurf und markiert unreferenzierte Vorlagen/Layouts mit `_et_theme_builder_marked_as_unused`; nach 7 Tagen werden sie gelöscht (max. 50 je Lauf, `theme-builder.php:591-678`). Danach `et_theme_builder_clear_wp_cache('all')` und `ET_Core_PageResource::remove_static_resources('all','all')` (`api.php:249-254`).
- F20: `api.php` wird nur bei `is_admin() || wp_doing_ajax()` geladen (`theme-builder.php:50-61`); im REST-Kontext fehlen die `et_theme_builder_api_*`-Funktionen. Vorhanden (live geprüft): `et_theme_builder_insert_layout`, `et_theme_builder_store_template`, `et_theme_builder_get_theme_builder_templates`, `et_theme_builder_get_template_settings_options`, `et_theme_builder_get_flat_template_settings_options`, `et_theme_builder_clear_wp_cache`. Divi 5 hat keine eigenen TB-REST-Routen (nur `ThemeBuilder/Layout.php`, `ThemeBuilder.php` für die Ausgabe).
- F21: Bedingungs-IDs mit Trenner `:` (`theme-builder.php:28`): `singular:post_type:<pt>:all` (`:703-710`), `archive:post_type:<pt>` (nur `post` oder `has_archive`, `:724-734`), `singular:post_type:<pt>:id:<id>` (`:805-813`), `singular:post_type:<pt>:children:id:<id>` (`:822-829`), `homepage` (`:717`), `search`, `404` (`:1030-1039`), Archiv-, Taxonomie-, Autor-, Datumsvarianten (`:873-987`). Jede hat einen `validate`-Callback (`template-setting-validations.php:13-325`); `et_theme_builder_get_flat_template_settings_options()` (`:1143`) liefert die flache Liste.
- F22: Die Layouts des Theme Builders werden bei der Ausgabe live gelesen (`et_theme_builder_get_template_layouts`, `theme-builder.php:1295-1406`); Änderungen an einem aktiven Layout sind sofort öffentlich. Divi-Module mit `appearance.themeBuilderLayouts` (z. B. `divi/post-content`, `_all_modules_metadata.php:25503`; `fullwidth-post-content` `:14455`) werden in anderen Bereichen nicht gerendert (`Packages/ModuleLibrary/ModuleRegistration.php:509-527`).
- F23: Divi-Recht für TB-Layouts im Builder: `et_pb_is_allowed('theme_builder')` (`functions.php:2353`) plus die WP-Rechte; TB-Admin-AJAX zusätzlich `edit_others_posts`.
### 1.3 Dynamic Content, Pods, Loop
- F24: `DynamicContentOptions::get_options($post_id, $context)` sammelt alles über den Filter `divi_module_dynamic_content_options` (`builder-5/server/Packages/Module/Layout/Components/DynamicContent/DynamicContentOptions.php:85-133`). Live für Seite 831: 57 Optionen, davon 34 `loop_*`, `post_meta_key` (Felder `before|after|select_meta_key|meta_key|date_format|custom_date_format|enable_html`) und je Post-Typ `post_link_url_<pt>` mit Feld `post_id`.
- F25: Meta-Schlüssel-Liste: „meistgenutzte“ Meta-Schlüssel der ganzen Website plus ACF-Felder – nur mit `read_dynamic_content_custom_fields` oder im Kontext `display` (`DynamicContentOptionPostMetaKey.php:80-140`). Pods kennt Divi nicht.
- F26: Ausgabe von `custom_meta_<key>`: Wert über `get_post_meta`/ACF, Filter `divi_module_dynamic_content_resolved_custom_meta_value` (`DynamicContentOptionPostMetaKey.php:404`), dann `esc_html` ohne `enable_html` (`:452-457`). Arrays von Strings werden mit „, “ verbunden; Beziehungs-/Dateifelder ohne ACF bleiben rohe IDs.
- F27: Interner Link als natives Dynamic Content: `post_link_url_<post_type>` mit `settings.post_id`, Ausgabe `esc_url(get_permalink($id))` zur Renderzeit (`DynamicContentOptionCustomPostLinkUrl.php:34, 186-217`) – entwurfssicher, nach dem Veröffentlichen automatisch die schöne URL.
- F28: Loop Builder: `module.advanced.loop.desktop.value.{enable, queryType, …}` (`Packages/Module/Options/Loop/LoopUtils.php:135-145`), Werte im Loop über `loop_*`-Optionen; die Expansion läuft nur beim Rendern (`BlockParser.php:895-905`).
### 1.4 Stand im Plugin
- F29: `ContentSchema::EXCLUDED` schließt `et_pb_layout`, `et_theme_builder`, `et_template` und die drei TB-Layout-Typen aus (`src/Integrations/WordPress/Schema/ContentSchema.php:19-24`); `PostGuard::editable` liefert dafür `not_found`. Die Divi-Werkzeuge erreichen Library und TB heute nicht.
- F30: Opak sind Blöcke mit `globalModule`, `divi/global-layout` oder nicht dekodierbaren Attributen (`src/Integrations/Divi/BlockTree/Parser.php:56`); `Block::setAttrs` wirft dort (`Block.php:91-96`).
- F31: Parser-Kosten (live, 8 Divi-Posts, 28 KB, 20 Durchläufe): Parse + NodeIndex ≈ 2 ms je 100 KB Inhalt (Probe `s3.php`).
- F32: Divi 5.2.1 registriert **keine** WordPress Abilities (`grep wp_register_ability|wp_abilities_api_init` im Theme: 0 Treffer) und enthält kein MCP.
## 2. Empfohlene Entscheidungen
- E1 – Ein Zugang für alle Divi-Posts. Die bestehenden Divi-Werkzeuge (`lit/divi-outline`, `-node-get`, `-insert`, `-update`, `-move`, `-delete`, `-duplicate-node`) akzeptieren zusätzlich IDs von `et_pb_layout` und `et_header/body/footer_layout` (Spec §5.1). Umsetzung: eine `DiviTarget`-Richtlinie neben `PostGuard` (Typ, Recht, Status-Regel je Typ), die `DiviPageLoader` verwendet; `ContentSchema::EXCLUDED` bleibt (die Content-Tools sehen diese Typen weiter nicht). – Grund: keine zweite Werkzeugfamilie, gleiche Pipeline (D7, S39). – Kosten, falls falsch: Rechteprüfung pro Typ an einer Stelle falsch → Zugriff auf Library/TB ohne Divi-Recht; abgefangen durch eigene Tests je Typ.
- E2 – Globale Module werden im Library-Post bearbeitet, nie in der Seitenkopie. Auf Seiten bleiben `divi/global-layout`-Knoten opak (D3); `lit/divi-node-get`/`-outline` liefern für sie `global_id`, `block_name` und einen Hinweis „mit post_id=<global_id> bearbeiten“ (S19-Teil 2). Schreiben in den Library-Post wirkt auf alle Instanzen (F7); die Antwort nennt die betroffenen Seiten (Verwendungssuche über `"globalModule":"<id>"` in `post_content`, wie Divi F10) und verlangt bei mehr als einer veröffentlichten Instanz `confirm: true`. `localAttrs`/Kinder-Schnappschüsse in Seiten werden nie angefasst; das Bearbeiten nicht synchronisierter Attribute einer einzelnen Instanz ist nicht Teil von Etappe 3. – Grund: Divis Ausgabe ist per Verweis (F7), der Builder macht es genauso (F9). – Kosten: Instanz-spezifische Abweichungen nur im Builder.
- E3 – Rechte (strenger als Divi, nie lockerer). Library lesen: `edit_posts` + `divi_library`; speichern/anlegen: + `save_library`; einfügen aus der Library: + `add_library`; globalen Eintrag bearbeiten: + `edit_global_library` **und** `edit_post` auf den Eintrag (Divi prüft das nicht, F9). Theme Builder lesen und schreiben: `edit_others_posts` + `et_pb_is_allowed('theme_builder')` (F19, F23; Spec §4.5 um die Divi-Rolle ergänzt). Dynamic-Content-Meta-Liste nur mit `read_dynamic_content_custom_fields` (F25). Weil `et_theme_builder_store_template`/`insert_layout` bei fehlendem Recht `wp_die()` aufrufen (F17, F18), prüft das Plugin vorher selbst. `et_pb_is_allowed` steckt in einem Adapter (E15). – Kosten: ein Redakteur mit WP-Rechten, aber ohne Divi-Rolle, bekommt `forbidden` (gewollt, wie im Builder).
- E4 – Library-Einträge legt das Plugin selbst an (kein `et_pb_submit_layout`, keine Builder-Route): `wp_insert_post` mit leerem Inhalt und Status `publish` (Divi kennt keine Library-Entwürfe, F2), Terme/Meta wie F2, danach Inhalt über `DiviSaver::saveNew` im kses-Fenster (D7, D11-Nachtrag), eingewickelt in `divi/placeholder` (F4), Flags wie `DiviFlags`. Ist ein Schritt fehlerhaft, wird der Entwurf endgültig gelöscht (wie `lit/content-create`). – Grund: `et_pb_submit_layout` ist im REST-Kontext nicht geladen und speichert mit kses (F2); die Builder-Route braucht Nonce und importiert Presets/Farben (F3). – Kosten: Divi ändert Terme/Meta → Einträge fehlen in der Library-Ansicht; abgesichert durch Fixture-Vergleich mit echten Builder-Einträgen (E13) und Kompatibilitätsprüfung (E15).
- E5 – `lit/library-insert` als Kopie oder globale Instanz. Kopie: Inhalt ohne `divi/placeholder`, verschachtelte globale Platzhalter entglobalisiert wie Divi (F10), eingefügt wie `lit/divi-insert` (StructureRules, AdminLabel). Globale Instanz: das Plugin erzeugt `<!-- wp:divi/global-layout {"globalModule":"<id>","blockName":"<Wurzeltyp>"} --><!-- /wp:divi/global-layout -->` ohne `localAttrs` (voll synchronisiert). Prüfungen: Eintrag existiert, `publish`, `scope=global`, `read_post`, Wurzeltyp passt an die Zielposition, kein globales Modul in einem globalen Library-Post (F10). – Kosten: falls der Builder leere `localAttrs`/fehlende `innerBlocks` anders erwartet, sieht der Mensch im Builder evtl. eine andere Darstellung – vor dem Bau mit einer Builder-Fixture verifizieren (E13).
- E6 – Werte aus gespeicherten Inhalten (Library → Seite, Seite → Seite, Seite → Library) gelten als „gespeichert, nicht vom Client“ wie bei `lit/divi-duplicate-node`, werden aber beim Wechsel des Posts erneut geprüft: für Benutzer ohne `unfiltered_html` werden Knoten mit Builder-only-Feldern (S7/S26: `*.decoration.attributes`, `*.advanced.html`, `*.css.*`) und Code-Modul abgelehnt („nur im Divi-Builder“). Alles andere geht unverändert durch den BlockTree (byte-identisch). – Grund: Audit M1 – der Weg über eine andere Post-ID darf die Builder-only-Sperre nicht umgehen; Autor und damit Divis `before/after`-Filterung ändern sich mit dem Ziel-Post (R2-Befund 1). – Kosten: Redakteure können admin-gebaute Abschnitte mit eigenem CSS nicht kopieren.
- E7 – Theme Builder: Vorlagen und Layouts über Divis vorhandene Funktionen (`et_theme_builder_insert_layout` mit leerem Inhalt, Inhalt danach über `DiviSaver::saveNew`; `et_theme_builder_store_template`), Zuordnung zum Live-TB-Post selbst (`add_post_meta($live_id, '_et_template', …)`, gleiche Reihenfolge wie F19 inkl. Backup-Option), danach Cache wie F19. Nie der Entwurfs-TB-Post, nie `et_theme_builder_trash_draft_and_unused_posts()`. Existiert ein TB-Entwurf (`auto-draft`, F15), wird abgelehnt mit Hinweis „Theme Builder ist in Bearbeitung – dort speichern oder verwerfen“ (sonst überschreibt ein späteres Speichern im TB-Editor die Zuordnung und die neue Vorlage wird nach 7 Tagen gelöscht, F19). Bedingungen nur aus `et_theme_builder_get_flat_template_settings_options()` plus deren `validate`-Callback, Post-IDs mit `read_post`. – Kosten: Divi ändert die TB-Speicherung → Vorlage fehlt im TB-Editor; Kompatibilitätsprüfung + E2E.
- E8 – Neue TB-Vorlagen werden **deaktiviert** angelegt (`_et_enabled=0`); aktivieren ist eine explizite Aktion in `lit/tb-assign` (`enabled: true`, `confirm: true`, Liste der betroffenen Bedingungen). Standardvorlage (`_et_default`) nie über MCP. – Grund: Spec §2 Prinzip 5 (Status bleibt, Veröffentlichen explizit); TB-Posts kennen keinen Entwurf (F17, F18, F22). – Kosten: ein Aufruf mehr. Offen als Frage F-1.
- E9 – Werkzeugliste Etappe 3 und Exposition. Direkt: nur `lit/library-insert` (Spec §4.3) → 22 direkte Tools, unter der Warnschwelle 25 des Tools-Tabs. Indirekt: `lit/library-list` (mit `id` → Detail; ersetzt `lit/library-get`), `lit/library-save`, `lit/divi-node-copy`, `lit/library-make-global`, `lit/library-find-similar`, `lit/divi-dynamic-sources`, `lit/tb-list`, `lit/tb-create-template`, `lit/tb-assign`. Gruppen: Library und Theme Builder als eigene Gruppen („nur lesen“ wirkt wie bei Divi, Audit M2: `lit/library-insert` und `lit/divi-node-copy` folgen zusätzlich dem Modus von `lit/divi-insert`). Destruktiv-Hinweis: `lit/library-make-global` (ersetzt Knoten), `lit/tb-assign` (öffentliche Wirkung). – Kosten: `lit/library-get` aus der Spec entfällt als eigener Name (Spec §5.7 anpassen).
- E10 – `lit/library-make-global`: legt einen globalen Eintrag aus dem Knoten an (E4) und ersetzt den Knoten durch eine Instanz (E5), in einem Aufruf, alles oder nichts (Library-Post wird bei Fehlschlag der Seite gelöscht). „Gleichartige Vorkommen ersetzen“ nur für **inhaltsgleiche** Knoten (Inhalts-Fingerabdruck, E11), mit Probelauf (Standard) und `confirm: true` je Seite mit `tree_rev` (Muster D10). Strukturgleiche Knoten mit anderen Texten werden nur gemeldet, nie ersetzt (Spec §3.3 „fast gleich“ = später). – Kosten: Kunde muss ähnliche Abschnitte vorher angleichen.
- E11 – `lit/library-find-similar`: zwei Fingerabdrücke je Abschnitt/Zeile: Struktur (Blocknamen und Tiefe, Spalten-`flexType`, Preset-IDs) und Inhalt (kanonische Attribute ohne Admin-Label, Bytes der Kinder). Suche seitenweise mit `cursor`, Post-Typ-Filter, höchstens 200 Posts je Aufruf, nur Posts, die der Benutzer bearbeiten darf; kein Index/Meta (F31: ≈1 s Parse für 1000 Seiten à 50 KB, Datenbanklast dominiert). Die Schreibwarnung „ähnliche Abschnitte“ (Spec §5.5, D6) vergleicht nur mit Library-Einträgen (klein) und verweist sonst auf das Tool. – Kosten: auf sehr großen Websites mehrere Aufrufe; bei Bedarf später ein Cache nach `tree_rev`.
- E12 – Dynamic Content. `lit/divi-dynamic-sources(post_type)` liefert `DynamicContentOptions::get_options` für einen Beispiel-Post des Typs (sonst `0`), ohne `loop_*` (S36), dazu Pods-Felder des Typs aus `PodsFieldSource` mit Speicherart und Eignung (Text-ähnlich = `{{field:<name>}}`; Beziehung/Datei = „zeigt IDs“, F26). Beim Schreiben in einen TB-Body-Layout gilt als Kontext-Post-Typ der Typ aus den Bedingungen der zugeordneten Vorlage (`singular:post_type:<pt>:…`); ohne Zuordnung Warnung. Loop Builder bleibt lesbar/opak (Werte unverändert), wird nicht erzeugt. – Kosten: CPT-Archive mit Pods-Feldern in Schleifen erst später.
- E13 – Übernahmen aus Etappe 2: (a) `{{link:<post_id>}}` → `post_link_url_<post_type>` mit `settings.post_id` (F27), Prüfung `read_post`, kein Papierkorb; nur in URL-Feldern; (b) `lit/guide` mit `topic: "all"` (alle Themen in einem Aufruf, Größenlimit); (c) S19: `lit/divi-design-system` mit Filter (`section`: colors/variables/presets/fonts, `module` für Presets) und `global_id` in `node-get`; (d) `lit/page-render-summary` meldet globale Platzhalter als Hinweis „globales Modul <id> – im Auszug nicht enthalten“ (F8) und erhält für Library-/TB-IDs keinen Zugang. – Kosten: gering.
- E14 – Reihenfolge und Aufteilung. **3a „Library“**: Fixtures (E16) → Übernahmen E13 → `DiviTarget`/Rechte (E1, E3) → `lit/library-list` → Divi-Werkzeuge auf Library-Posts inkl. globaler (E2) → `lit/library-save` (E4) → `lit/library-insert` (E5) → `lit/divi-node-copy` (E6) → `lit/divi-dynamic-sources` (E12) → Guides/Prompt `globale-module-vorschlagen`. **3b „Theme Builder & Konsolidierung“**: `lit/tb-list` → TB-Layouts in den Divi-Werkzeugen (inkl. `themeBuilderLayouts`-Regel F22 in StructureRules) → `lit/tb-create-template` (E7, E8) → `lit/tb-assign` → `lit/library-find-similar` + Warnung (E11) → `lit/library-make-global` (E10) → Guide `theme-builder`, Prompt `custom-post-type-vorlage`. Abschluss jeder Teiletappe wie 2b (Review je Task, Security-Audit, Praxistest). – Kosten: find-similar/make-global kommen spät; sie hängen an stabilen Library-Grundlagen.
- E15 – Divi-Zugriffe über Adapter mit Live-Kompatibilitätsprüfung (siehe Abschnitt 5): je Divi-Bereich eine Klasse (`DiviModules` und `DesignSystemReader` gibt es schon; neu `DiviLibraryStore`, `DiviThemeBuilder`, `DiviRoles`), die Funktions-/Klassen-Existenz und Signaturen prüft und bei Abweichung `unsupported` statt Fatal liefert; `lit/site-status` zeigt den Befund. – Kosten: etwas mehr Code; spart Ausfälle nach Divi-Updates.
- E16 – Teststrategie. (1) Unit: Platzhalter-Erkennung, Fingerabdrücke, Bedingungs-Validierung, Instanz-Markup, Entglobalisieren gegen Divis `maybe_deglobalize_nested_global_modules` (gleiche Eingaben, gleiches Ergebnis). (2) Fixtures aus echten Builder-Daten: Jan-Niclas baut vor Beginn in DDEV im Visual Builder nach Checkliste globales Modul, globale Zeile mit einem nicht synchronisierten Attribut, globaler Abschnitt, normaler Library-Abschnitt, je eine Seite mit Instanzen, eine TB-Vorlage „Person“ mit Header/Body/Footer und Bedingungen; ein neues Lese-Skript `tests/Integration/divi-export-library.php` exportiert `post_content`, Terme, Meta und TB-Meta als `builder-library-*.html` / `builder-tb-*.json` (wie D12). Roundtrip-Test läuft automatisch mit. (3) DDEV-Smoke `divi-library-smoke.sh` / `tb-smoke.sh` (mit Aufräumen). (4) Manuelle Abnahme: vom Plugin erzeugte Einträge/Instanzen/Vorlagen im Builder und TB-Editor öffnen und speichern. – Kosten: ohne Fixtures (F13) bleiben E4/E5/E7 Vermutung; daher Fixtures als erster Schritt und Bedingung für den Bau.
- E17 – Sicherheit neuer Schreibwege: jeder Weg nach `post_content` von Library- und TB-Posts geht durch Composer/AttrWriter/TreeEditor → `DiviSaver` (D7, S39); Titel, Terme und Meta über WordPress mit üblicher Filterung (`sanitize_text_field`, `wp_set_object_terms` mit bestehenden Term-IDs; neue Terme nur mit `manage_categories`); keine Builder-Route und kein `et_pb_create_layout` mit Client-Inhalt; TB-Bedingungen nur aus Divis Liste; Revision vor jeder Änderung (Library und TB-Layouts unterstützen Revisionen, F1/F14), Protokolleintrag je Post; nach Library-/TB-Änderungen `remove_static_resources('all','all',true)` und `et_theme_builder_clear_wp_cache('all')` (Spec §5.6 Schritt 5). – Kosten: Review-Pflicht wie D7.
## 3. Fragen an den Benutzer (nach Wichtigkeit)
1. **Sollen neue Theme-Builder-Vorlagen sofort wirken?** Divi kennt für Vorlagen keinen Entwurf.
- (a) Deaktiviert anlegen, Aktivieren nur ausdrücklich mit `lit/tb-assign` und Bestätigung. **Empfehlung.**
- (b) Sofort aktiv, wie im TB-Editor.
- (c) Aktiv nur auf Bedingungen für einzelne IDs, alles Breitere (ganzer Post-Typ, Archiv) deaktiviert.
2. **Wie frei darf der KI-Assistent globale Module ändern?** Jede Änderung wirkt sofort auf allen Seiten, auch veröffentlichten.
- (a) Erlaubt mit Rechten wie im Builder, `confirm: true` und Liste der betroffenen Seiten. **Empfehlung.**
- (b) Nur Administratoren (`unfiltered_html`/`manage_options`).
- (c) Globale Einträge nur lesen, Änderungen nur im Builder.
3. **Was zuerst: Library oder Theme Builder?**
- (a) 3a Library (globale Module, Einfügen, Kopieren), dann 3b Theme Builder. **Empfehlung** (technisch sauberere Grundlage).
- (b) Theme Builder zuerst, weil ein Kunde CPT-Vorlagen braucht (Library-Teil danach).
4. **Pods-Beziehungs- und Dateifelder im Theme Builder:** Divi zeigt davon nur IDs.
- (a) In Etappe 3 nur Text-ähnliche Pods-Felder anbieten, andere als „nicht geeignet“ melden. **Empfehlung.**
- (b) Das Plugin formatiert Pods-Werte beim Rendern (Filter `divi_module_dynamic_content_resolved_custom_meta_value`) – wirkt dann auf der Website, auch ohne MCP.
- (c) Ganz auf später verschieben.
5. **Woher kommen die echten Fixtures?**
- (a) Du baust sie lokal in DDEV nach Checkliste, ein Skript exportiert sie. **Empfehlung.**
- (b) Export von einer Kundenseite (anonymisiert).
- (c) Nur aus Divi-Code abgeleitet (riskant, F13).
## 4. Risiken
| Risiko | Gegenmaßnahme |
|---|---|
| Keine echten Library-/TB-Daten lokal (F13); Instanz-Format (`localAttrs`, `innerBlocks`, `globalModule` an der Vorlagenwurzel) nur aus Code/minifiziertem JS abgeleitet | Fixtures zuerst (E16, Frage 5), Builder-Abnahme |
| Späteres Speichern im TB-Editor überschreibt unsere `_et_template`-Zuordnung, Vorlage wird nach 7 Tagen gelöscht (F19) | Ablehnung bei TB-Entwurf (E7), `lit/tb-list` zeigt unzugeordnete Vorlagen |
| Globale Änderung wirkt sofort auf veröffentlichte Seiten | `confirm`, Seitenliste, Revision des Library-Posts (E2, Frage 2) |
| `page-render-summary` sieht globale Module nicht (F8) | Hinweis im Auszug (E13d) |
| `et_theme_builder_*` rufen `wp_die()` statt Fehler zu liefern (F17, F18) | Vorabprüfung der Rechte, Adapter (E3, E15) |
| Divi-Interna ändern sich (Library-Meta, TB-Meta, Instanz-Format); ET kündigt ein offizielles MCP an | Adapter + Kompatibilitätsprüfung (E15), Fixture-Tests je Divi-Update |
| Builder-only-Werte gelangen über Kopie in fremde Posts (Audit M1-Muster) | E6 |
| Pods mit Tabellen-Speicherung: `get_post_meta` findet nichts | `lit/divi-dynamic-sources` meldet solche Felder als ungeeignet (E12) |
| Rollen-Editor: „gefährliche“ Divi-Rechte standardmäßig nur für Administratoren | E3, klare `forbidden`-Meldung mit Hinweis auf Divi → Rollen-Editor |
| Große Websites: find-similar lädt viel `post_content` | Cursor, 200 Posts je Aufruf (E11) |
| Loop Builder / Archive nicht abgedeckt | bewusst später (E12) |
## 5. Schnittstellen-Einordnung
Stufen: (a) öffentliche/dokumentierte API (WordPress-Kern, dokumentierte Divi-Hooks), (b) Divis Builder-REST/AJAX (`divi/v1`, admin-ajax, nonce-geschützt), (c) interne PHP-Klassen/Funktionen/Meta-Konventionen, (d) selbst nachgebildete interne Formate. Empfehlung: **B** = behalten, **W** = wechseln auf offizielleren Weg, **A** = in Adapter mit Live-Kompatibilitätsprüfung (E15).
Zur Offizialität: Elegant Themes dokumentiert Hooks und die Modul-API (dev.elegantthemes.com, u. a. `divi_module_dynamic_content_options`, `divi_module_dynamic_content_resolved_custom_meta_value`, `divi_module_dynamic_content_custom_meta_options`, `divi_module_library_register_module_attrs`), aber keine API für externen oder KI-Zugriff. Der Divi AI Agent läuft im Builder (im Browser, AI-Wallet-Abrechnung); in den Kommentaren zur Ankündigung schreibt ET, ein „official MCP is on the way“. Divi registriert keine WordPress Abilities (F32).
### 5.1 Bereits genutzt (Etappe 2a/2b)
| Schnittstelle | Stelle im Plugin | Stufe | Risiko bei Divi-Updates | Empfehlung |
|---|---|---|---|---|
| WordPress-Kern: `wp_update_post`, Revisionen, kses an/aus, Meta-API | `DiviSaver`, `RevisionPoint` | a | gering | B |
| Block-Delimiter-Format (`<!-- wp:… -->`, WP_Block_Parser-Muster) | `BlockTree/Parser` | a (WP-Format) | gering | B |
| Divi-Blocknamen und Attribut-JSON (`module.decoration…`, `flexType`, `divi/placeholder`) | `Composer`, `AttrWriter`, `ColumnWidths` | d (Schema aus c) | mittel | B – Pfade werden gegen den Live-Katalog validiert; Builder-Fixtures |
| `ModuleRegistration::get_all_core_modules_metadata()`, `get_default_attrs()` | `Schema/DiviModules` | c | mittel | A (Adapter existiert; Prüfung in `lit/site-status` ergänzen) |
| `GlobalData::get_global_colors/-variables`, `GlobalPreset::get_data`, Option `et_divi` | `Design/DesignSystemReader`, `MediaUsageFinder` | c (REST-Pendant `divi/v1/global-data/*` = b) | mittel | A; kein Wechsel auf b (nonce, Builder-Kontext) |
| `DynamicContentOptions::get_options()` | `Pages/DynamicContentMap` | c; Füllweg über dokumentierten Filter `divi_module_dynamic_content_options` (a); REST `divi/v1/dynamic-content/options` (b) | mittel | A (Rückfall auf Standard-Map existiert) |
| `$variable(...)$`-Syntax, gcid/gvid, Dynamic-Content-JSON | `Compose/VariableRef`, `Placeholders` | d | mittel | B + Prüfung gegen Divis Regex `ET_THEME_BUILDER_DYNAMIC_CONTENT_REGEX_D5` (`theme-builder.php:36`) im Kompatibilitätstest |
| Meta `_et_pb_use_divi_5`, `_et_pb_use_builder`, `_et_pb_show_page_creation` | `Pages/DiviFlags`, `PostPresenter` | d (nachgebildet aus `SyncToServerController`) | mittel | B; `_et_pb_use_builder` könnte über `et_builder_enable_for_post()` (c) laufen – kein Gewinn |
| `ET_Core_PageResource::remove_static_resources()` | `DiviSaver` | c (dokumentierter Hook `et_core_page_resource_post_id_before_clear` deutet auf stabile API) | gering | B |
| `et_builder_enabled_for_post_type()` | `ContentSchema` | c | gering | B |
| `ET_BUILDER_PRODUCT_VERSION` | `DiviModules` | c | gering | B |
| Divis `wp_insert_post_data`-Filter (AttributeSecurity, HtmlSecurity) als Verteidigung in der Tiefe | Verhalten, kein Aufruf | c | mittel | B (nie entfernen, S7) |
### 5.2 Geplant (Etappe 3)
| Schnittstelle | Zweck | Stufe | Risiko | Empfehlung |
|---|---|---|---|---|
| Post-Typen, Taxonomien, Meta der Library (F1, F2) | Liste, Anlegen (E4) | c/d | mittel | A (`DiviLibraryStore`), Vergleich mit Builder-Fixtures |
| `GlobalLayout::is_global_layout_template()` | „global?“ | c | gering | B (Divis eigene Definition statt Term-Logik) |
| `divi/global-layout`-Platzhalter (`globalModule`, `blockName`, `localAttrs`) | Instanzen (E5, E10) | d | hoch | A + Fixtures; nur minimale Form ohne `localAttrs` erzeugen |
| Entglobalisieren wie `PortabilityPost::maybe_deglobalize_nested_global_modules` | Kopie einfügen (E5) | c | mittel | Eigene Umsetzung (d), per Test gegen Divis Methode geprüft (Aufruf direkt wäre möglich, arbeitet aber auf Strings ohne unsere Byte-Garantie) |
| Builder-Routen `divi/v1/divi-library/*` (F11) | – | b | mittel | nicht nutzen: Nonce, fehlende Pro-Eintrag-Rechte (F9), `create-item` hängt an nicht geladenem Code (F2, F3) |
| `et_pb_is_allowed()` | Divi-Rollen (E3) | c | gering | A (`DiviRoles`) |
| `et_theme_builder_insert_layout`, `et_theme_builder_store_template` | TB schreiben (E7) | c (in Spec genannt) | mittel | B über Adapter `DiviThemeBuilder`, Rechte vorab (wp_die) |
| `et_theme_builder_get_theme_builder_post_id`, `…_get_theme_builder_templates`, `…_get_template` | TB lesen | c | gering | B/A |
| `_et_template`-Zuordnung + Backup-Option (F15, F19) | Vorlage aktivieren | d (admin-ajax-Weg `et_theme_builder_api_save` = b, im REST-Kontext nicht geladen) | mittel | A; bei Divi-Update prüfen, ob `et_theme_builder_api_save` die Logik ändert |
| `et_theme_builder_get_flat_template_settings_options()` + `validate`-Callbacks | Bedingungen (E7) | c | gering | B |
| `et_theme_builder_clear_wp_cache`, `remove_static_resources('all','all',true)` | Cache | c | gering | B |
| `DynamicContentOptions::get_options()` je Post-Typ | `lit/divi-dynamic-sources` | c (Filter a) | mittel | A (vorhandener `DynamicContentMap`) |
| `post_link_url_<pt>` + `post_id` | `{{link:ID}}` (E13a) | d (Format), Registry c | gering | B – Name über die Registry prüfen, nicht fest verdrahten |
| `divi_module_dynamic_content_resolved_custom_meta_value` | nur falls Frage 4 = (b) | a | gering | B (dokumentiert) |
| `appearance.themeBuilderLayouts` aus den Modul-Metadaten | StructureRules für TB-Bereiche | c | gering | B (aus Live-Katalog) |
| Loop Builder (`module.advanced.loop`) | – | d | hoch | nicht erzeugen (E12) |
| WordPress Abilities von Divi | – | – | – | gibt es nicht (F32); beobachten, ob ETs angekündigtes MCP eine offizielle Schnittstelle bringt, die c/d-Teile ersetzen kann |
Quellen der Web-Recherche: [Divi AI Agent – Ankündigung](https://www.elegantthemes.com/blog/theme-releases/ai-agent), [Divi Dev Docs – Filter](https://dev.elegantthemes.com/docs/hooks/php/filters/), [Divi Developers](https://www.elegantthemes.com/developers/).
## 5. Antworten des Benutzers (2026-09-27)
- F1 Theme-Builder-Vorlagen: standardmäßig deaktiviert angelegt; Aktivieren über ein eigenes Tool (explizit).
- F2 Globale Module bearbeiten: erlaubt mit Trockenlauf (Liste der betroffenen Seiten) und `confirm`, vorher Revision/Sicherung des Bibliothekseintrags.
- F3 Reihenfolge: erst 3a Bibliothek, dann 3b Theme Builder.
- F4 Pods-Beziehungs-/Dateifelder: als dynamische Quelle nur textartige Felder anbieten; andere mit Hinweis auslassen.
- F5 Testdaten: selbst erzeugen („Probier es selbst“) – über Divis eigene Speicherwege (Builder-REST-Controller/Services im Prozess), als „[Fixture] …“ markiert, danach als Fixtures ins Plugin exportiert. Pods ist lokal deaktiviert; Aktivierung für 3b beim Benutzer anfragen.
## 6. Umsetzung Etappe 3a (Plan 2026-09-27-etappe-3a-bibliothek.md)
- E5 präzisiert durch die Builder-Fixtures: Instanzen werden wie im Visual Builder erzeugt – `localAttrs` =
vollständiger Schnappschuss der Wurzel-Attribute (oberste Schlüssel sortiert), Kinder mit `globalParent`,
ohne Kinder selbstschließend – statt der minimalen Form ohne `localAttrs`. Byte-Test gegen
`page--globale-instanzen.html`.
- Neuer Befund 9: Bibliothekseinträge aus Divis Dialog haben `\r\n` zwischen Blöcken (Formular-Upload);
`LibraryContent::load()` vereinheitlicht das vor jeder Übernahme (undurchsichtige Blöcke bleiben unberührt).
- Kopie eines normalen Eintrags mit globalen Instanzen: Instanzen bleiben verknüpft (wie Divis `/load`);
entglobalisiert wird nur der Inhalt globaler Einträge (Divis Schadensbegrenzung, F10). `lit/library-save`
mit `scope: "normal"` übernimmt enthaltene Instanzen ebenfalls als Verweise. `mode: "global"` nur für
globale Einträge mit genau einer Wurzel ohne globale Module (P11: nie verschachtelte Instanzen).
- E6 erweitert: `StoredNodeGuard` prüft neben den S7/S26-Pfaden und dem Code-Modul auch fremde Blöcke,
Freitext, nicht lesbare Blöcke, kses-instabilen Rich-Text (P3) und `$variable`-Verweise (Ablehnung, wenn
`canonical()` sie für das Ziel ablehnt oder ihre Einstellungen verändern würde) – Grund: R2-Befund 1
(before/after nach dem Autor des Ziel-Posts). Lesbare globale Instanzen werden geprüft (localAttrs und
Schnappschuss), nicht abgelehnt (P15). Strenger als Divi; sichtbare Ablehnung. URL-Werte: `url($v) ===
trim($v)` oder relativer Link ohne Schema (P17/P18) – `kontakt/`, `/pfad`, `#anker` bleiben kopierbar.
- Builder-only erweitert (S7-Gate, gilt auch für Seiten): `decoration.interactions` (P16 – der Effekt
„Attribut“ setzt im Browser beliebige Attribute, z. B. `onclick`; „Cookie“ setzt Cookies) und
`lottie.innerContent` (P19 – `src` lädt fremdes JSON, lottie-web wertet Ausdrücke aus). Offen für
Etappe 4: Divi-Presets können beides tragen; Preset-Schreibwege brauchen dasselbe Gate.
- E3 ergänzt: neue Einträge zusätzlich nur mit `publish_posts` (Bibliothekseinträge sind immer `publish`);
`scope: "global"` braucht zusätzlich `edit_global_library` (P7). Ohne das Divi-Recht „Divi-Bibliothek“
(oder mit deaktivierter Gruppe, P9) ist jede Bibliotheks-ID `not_found`, byte-gleich mit einer fehlenden ID
(P12); ohne `edit_post` auf dem Eintrag `forbidden` ohne Titel. `lit/library-list` listet nur Einträge mit
`edit_post` und liest höchstens 500 Zeilen je Aufruf.
- E9 umgesetzt mit: `lit/divi-node-copy` und `lit/divi-dynamic-sources` in der Gruppe „Divi“; die Gruppe
„Divi-Bibliothek“ sperrt als Ganzes („nur lesen“/deaktiviert) auch Schreibzugriffe der Divi-Werkzeuge auf
Bibliothekseinträge (`ExposurePolicy::groupWritable`). `lit-library-insert` und `lit/divi-node-copy` folgen
zusätzlich dem Zustand von `lit/divi-insert` (M2). P13 in 3a nur dokumentiert (Guide „Rechte“, Grenze): bei
deaktivierter Gruppe oder ohne `divi_library` nennen Outline/node-get/Render-Auszug einer Seite noch die ID
globaler Module; der Eintrag selbst bleibt `not_found`. Vollständiges Ausblenden folgt in 3b.
- Antwort F2: jede Änderung eines globalen Eintrags ist zuerst ein Probelauf (auch ohne veröffentlichte
Instanzen); `lit-divi-delete` nutzt dasselbe `confirm`, sein `confirm_required` nennt dann ebenfalls die
betroffenen Seiten. P8a: die Bestätigung wird abgelehnt, wenn der Benutzer einen Eintrag mit Status
`publish`, `private` oder `future`, der den Eintrag zeigt, nicht bearbeiten darf (auch TB-Layouts und andere
Bibliothekseinträge); Entwürfe blockieren nicht. Der Probelauf nennt solche IDs vorab in `blocked_ids`
(P14). Revisionen aus: abgelehnt ohne `unfiltered_html` (die Sicherung liefe durch kses) oder bei
deaktiviertem Papierkorb (P9); scheitert das Speichern nach einer Sicherung, nennt der Fehler `backup_id`
(P14). Cache-Leerung für alle Seiten und den Theme Builder.
- P1: `lit/divi-replace-text` speichert normale Bibliothekseinträge über `DiviWriter::commit()`; globale nie
(die Ziele tragen kein `confirm`) – das Ergebnis nennt die betroffenen Seiten und verweist auf
`lit-divi-update` mit `confirm`.
- Bibliothekseinträge bekommen keine Divi-Flags (Befund 4); `_et_pb_row_layout` wird nicht geschrieben (Befund 5).
- E13a: `{{link:<ID>}}` nur in URL-Feldern; Ziel muss existieren, `read_post` bestehen, nicht Papierkorb,
`auto-draft` oder `inherit` sein – Anhänge sind damit ausgeschlossen (P10). Entwürfe und private Ziele
rendert Divi für Besucher als `?page_id=<ID>` (nur die ID wird sichtbar, Preflight).
- F4/E12: `lit/divi-dynamic-sources` listet nur textartige Felder; Beziehungs-/Dateifelder („nur IDs“),
Tabellen-Speicher und unbekannte Feldtypen (P9) stehen unter `omitted`; Auswahlfelder zeigen den
gespeicherten Wert, nicht die Beschriftung. Der Beispiel-Post ist nur einer, den der Benutzer bearbeiten
darf.
- E15: Adapter `DiviRoles`, `DiviLibraryStore`, Prüfung `DiviCompatibility` in `lit-site-status`
(`problems` je Komponente).
- E13d umgesetzt als Hinweis in `notes`; eine eigene Expansion globaler Module im Auszug unterbleibt.
- Tests: die Unit-Suite läuft mit `memory_limit` 512M (`phpunit.xml.dist` und `composer test`), weil der
Divi-Katalog beim Standard von 128M nicht mehr in den Speicher passt.
- Server-Instructions: Zeile „Divi-Bibliothek: …“ nur bei aktiver, beschreibbarer Gruppe; MCP-Prompts gibt es
noch nicht, der Ablauf „globale Module vorschlagen“ steht im Guide `divi-library` (E14).
@@ -0,0 +1,124 @@
# Etappe 3a – Manuelle Abnahme (Divi-Bibliothek)
Automatisch geprüft sind bereits:
- alle Unit-Tests (`composer test`) und Lint (`composer lint`);
- `divi-library-smoke.sh` (neu) und die fünf bisherigen 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-library-smoke.sh` prüft über den OAuth-Endpunkt, nur mit temporären Daten, die es danach wieder löscht:
- Werkzeugliste: `lit-library-insert` direkt, `lit/library-list`, `lit/library-save`, `lit/divi-node-copy` und
`lit/divi-dynamic-sources` indirekt, insgesamt 22 direkte Tools;
- Speichern als normaler und als globaler Eintrag: Inhalt byte-gleich mit Divis eigenen Einträgen, dieselben
Begriffe (`scope`, `layout_type`, `module_width`) und Divi-Metadaten, keine Divi-Kennzeichen, Quellseite
unverändert; `lit/library-list` zeigt beide Einträge;
- Einfügen als globale Instanz und als Kopie: Bytes wie beim Visual Builder, keine Windows-Zeilenumbrüche;
- globale Änderung: Probelauf mit `confirm_required` und den betroffenen Seiten, `confirm` ändert den Eintrag
mit Revision, die Bytes der Seiten bleiben gleich (sie verweisen nur auf den Eintrag);
- ein Redakteur, der eine private Seite mit der Instanz nicht bearbeiten darf: Probelauf mit `blocked_ids`,
Bestätigung abgelehnt (`forbidden`), nichts gespeichert;
- Knoten kopieren zwischen Seiten, auch die Ablehnung einer veralteten `source_tree_rev`;
- `{{link:ID}}` im Button: gespeichert als Divis „Seite Link“ (`post_link_url_page`), ein Entwurf als Ziel
erscheint nur als `?page_id=…`; in Textfeldern abgelehnt;
- ein Redakteur ohne `unfiltered_html`: Interaktionen, Lottie-Quelle und eigenes HTML werden von
`lit/library-save`, `lit-library-insert` (Kopie) und `lit/divi-node-copy` abgelehnt, harmlose Knoten gehen;
- Gruppe „Divi-Bibliothek“ deaktiviert: Bibliothekseinträge sind in den Divi-Lesetools `not_found`, die
Bibliothekstools verschwinden;
- `lit-guide` mit Thema „all“, `lit-site-status` ohne Befunde bei Divi, `lit/divi-dynamic-sources` für Seiten.
Die folgenden Punkte brauchen den echten Visual Builder bzw. einen verbundenen KI-Assistenten. Abweichungen
als kurze Notiz zurückmelden; sie werden Folgeaufgaben.
## Vorbereitung
- [ ] DDEV läuft, Divi 5.2.1 ist aktiv, LIT MCP zeigt Version 0.4.0 (Plugins-Seite).
- [ ] Unter Einstellungen → LIT MCP → Tools stehen die Gruppen „Divi“ und „Divi-Bibliothek“ auf ihrem
Standard (nicht „nur lesen“, nicht deaktiviert).
- [ ] Den KI-Assistenten neu verbinden, damit er die neuen Tools und Hinweise lädt. In einer neuen
Unterhaltung ist `lit-library-insert` sichtbar.
- [ ] Nach einem Lauf von `divi-library-smoke.sh` ist nichts übrig: unter Seiten → Alle Seiten und unter
Divi → Divi-Bibliothek nach „LIT Smoke“ suchen (jeweils auch im Papierkorb) – keine Treffer.
- [ ] Zwei Entwurfsseiten mit dem Divi Builder anlegen, z. B. „Abnahme 3a – Seite A“ und „Abnahme 3a – Seite B“,
jede mit mindestens einem Abschnitt, einer Zeile und einem Textmodul. Die „[Fixture]“- und
„[Test]“-Inhalte nicht verwenden und nicht ändern.
## 1. Vom Plugin angelegte Bibliothekseinträge im Visual Builder
- [ ] Den KI-Assistenten bitten, einen Abschnitt von Seite A als normalen Eintrag und ein Textmodul von
Seite A als globalen Eintrag in der Divi-Bibliothek zu speichern (Titel mit „Abnahme 3a“).
- [ ] Divi → Divi-Bibliothek öffnen: beide Einträge erscheinen mit dem richtigen Typ (Abschnitt bzw. Modul),
der Modul-Eintrag ist als global gekennzeichnet.
- [ ] Beide Einträge in der Bibliothek mit dem Visual Builder öffnen: der Inhalt stimmt mit dem Knoten auf
Seite A überein, der Builder meldet keine Fehler.
- [ ] Seite A im Visual Builder öffnen: sie ist unverändert (Speichern in die Bibliothek ändert die Quelle nicht).
- [ ] Im Visual Builder von Seite B „Aus Bibliothek hinzufügen“: beide Einträge sind auswählbar und lassen
sich einfügen. Danach die Seite ohne Speichern verlassen.
## 2. Vom Plugin eingefügte globale Instanz und Kopie
- [ ] Den KI-Assistenten bitten, den globalen Eintrag mit `lit-library-insert` in Seite B einzufügen (als
Instanz) und den normalen Eintrag als Kopie.
- [ ] Seite B im Visual Builder öffnen: das Modul ist als global markiert (Divis Kennzeichnung für globale
Module) und zeigt den Inhalt aus der Bibliothek; der kopierte Abschnitt ist nicht global.
- [ ] Die Kopie im Builder ändern und speichern: der Bibliothekseintrag und Seite A bleiben unverändert.
- [ ] Das globale Modul im Builder ändern und speichern: die Änderung erscheint im Bibliothekseintrag und
überall, wo die Instanz steht.
- [ ] Vorschau von Seite B: der Inhalt des globalen Moduls wird angezeigt.
- [ ] Hinweis: `lit-page-render-summary` zeigt globale Module nicht (über die Schnittstelle rendert Divi sie
nicht) und nennt sie unter `notes`; das ist erwartet.
## 3. Globales Modul über den KI-Assistenten ändern (Probelauf und Bestätigung)
- [ ] Den KI-Assistenten bitten, den Text des globalen Moduls zu ändern. Er muss zuerst den Probelauf zeigen:
welche Seiten betroffen sind (hier mindestens Seite B), und nachfragen, bevor er etwas speichert.
- [ ] Nach der Nachfrage ablehnen: der Eintrag bleibt unverändert (in der Divi-Bibliothek prüfen).
- [ ] Erneut bitten und zustimmen: der Assistent speichert mit Bestätigung (`confirm`).
- [ ] Vorschau aller betroffenen Seiten zeigt den neuen Text; die Seiten selbst wurden nicht neu gespeichert
(unter „Revisionen“ der Seite B keine neue Revision).
- [ ] Beim Bibliothekseintrag ist unter „Revisionen“ der Stand vor der Änderung sichtbar.
- [ ] Unter Einstellungen → LIT MCP → Protokoll steht die Änderung des Eintrags.
- [ ] Den Eintrag im Visual Builder öffnen: er lässt sich weiter bearbeiten und speichern.
## 4. Knoten zwischen Seiten kopieren
- [ ] Den KI-Assistenten bitten, einen Abschnitt von Seite A mit `lit/divi-node-copy` auf Seite B zu kopieren.
- [ ] Seite B im Builder öffnen, den kopierten Abschnitt ändern und speichern: Seite A bleibt unverändert.
## 5. Interner Link
- [ ] Den KI-Assistenten bitten, auf Seite B einen Button mit `{{link:<ID von Seite A>}}` einzufügen.
- [ ] Im Builder zeigt das Link-Feld des Buttons dynamischen Inhalt „Seite Link“ mit Seite A.
- [ ] Solange Seite A ein Entwurf ist, verlinkt der Button in der Vorschau auf `?page_id=<ID>`.
- [ ] Seite A veröffentlichen: der Button verlinkt auf die schöne Adresse. Danach Seite A wieder als Entwurf
speichern.
## 6. Rechte und die Gruppe „Divi-Bibliothek“
- [ ] Einstellungen → LIT MCP → Tools: die Gruppe „Divi-Bibliothek“ ist sichtbar, mit `lit-library-insert`,
`lit/library-list` und `lit/library-save`.
- [ ] Gruppe auf „nur lesen“: nach neuer Verbindung fehlt `lit-library-insert` in der Werkzeugliste;
Bibliothekseinträge lassen sich lesen (`lit-divi-outline`), Änderungen daran werden abgelehnt, und
`lit/divi-node-copy` mit einem Bibliothekseintrag als Quelle wird ebenfalls abgelehnt.
Hinweis: Steht dagegen die Gruppe „Divi“ auf „nur lesen“, bleibt `lit-library-insert` in der Liste, lehnt
aber jedes Einfügen ab (`unsupported`, „Divi-Änderungen sind … abgeschaltet“) – das ist so gewollt, kein
Fehler.
- [ ] Gruppe deaktivieren: nach neuer Verbindung fehlen die Bibliothekstools; eine Bibliotheks-ID gilt in
`lit-divi-outline` als nicht vorhanden. Seiten lassen sich weiter lesen und bearbeiten.
- [ ] Die Gruppe wieder auf ihren Standard stellen und neu verbinden.
- [ ] Optional mit einem Redakteurs-Konto: unter Einstellungen → LIT MCP → Zugriff die Rolle „Redakteur“
freigeben. Im KI-Assistenten die Verbindung trennen und neu verbinden; bei der Anmeldung (OAuth) mit dem
Redakteurs-Konto einloggen – am einfachsten in einem privaten Browserfenster, in dem niemand bei
WordPress angemeldet ist. Dann in Divi → Rollen-Editor für „Redakteur“ „Globale Bibliothekselemente
bearbeiten“ abschalten und den Assistenten ein globales Modul ändern lassen: Ablehnung mit Hinweis auf
den Rollen-Editor. Danach das Recht wieder einschalten, die Freigabe der Rolle zurücknehmen und den
Assistenten wieder als Administrator verbinden.
- [ ] `lit-site-status`: bei Divi keine Einträge unter `problems`.
## Aufräumen
- [ ] Die Einträge „Abnahme 3a …“ in der Divi-Bibliothek und die Seiten A und B löschen (auch aus dem Papierkorb).
- [ ] Divi legt fehlende Systembegriffe (`scope`, `layout_type`, `module_width`) beim nächsten Speichern selbst an;
sie nicht von Hand löschen.
- [ ] Optional: die Einträge der Abnahme im Protokoll-Tab über „Protokoll leeren“ entfernen.
@@ -0,0 +1,111 @@
# Etappe 3a – Entscheidungen (Rulings), Befunde und offene Punkte
Aus dem SDD-Ledger übernommen, 2026-09-27. Security-Audit: 2026-09-27-etappe-3a-security-audit.md.
## Offen für spätere Etappen
- Etappe 3b: vollständiges Ausblenden von Bibliotheks-IDs bei deaktivierter Gruppe (P13); gleiche Zielregeln für rohe post_link_url_* wie für {{link:}} (P10); Theme Builder, find-similar, make-global.
- Etappe 4: Presets können Lottie-Quelle/Interactions tragen – Preset-Schreiben braucht dieselben Builder-Sperren.
- Etappe U: try/catch um Divi-Aufrufe (P11), einheitliche Fehlercodes der Tools-Tab-Sperren, doppelte Helfer, Test-Feinschliff, „blurb ist leer“-Warnung, Rollen-Option in divi-write-smoke.
- Restrisiko (Low, P25): sehr ungewöhnliche Schreibweisen globaler Verweise auf Seiten, die der Benutzer nicht bearbeiten darf, können der Verwendungssuche entgehen (setzt Bearbeitungsrechte an der verweisenden Seite voraus).
## Pre-flight rulings
Pre-flight (opus): 0 Critical, 8 Important, 19 Minor, 4 Info; fixtures match byte for byte; {{link:ID}} to draft/private shows only ?page_id=. Full tables: preflight.md.
- Ruling P1 (I-1, → Task 9): lit/divi-replace-text on library entries goes through DiviWriter::commit() (dry run/confirm for global, site-wide cache clear, no Divi flags on library posts) or refuses library targets — implementer picks the variant that reuses commit(); F2 binding — cost if wrong: none
- Ruling P2 (I-2, → Task 11): rebuild the 3 test rows with Divi's \u0022 escaping and assert the parsed block is not opaque — cost if wrong: none
- Ruling P3 (I-3, → Task 11): StoredNodeGuard refuses, for users without unfiltered_html, non-Divi blocks, raw text parts, opaque blocks and rich-text HTML that is not kses-stable when moving stored nodes between posts — cost if wrong: editors copy less (visible refusal)
- Ruling P4 (I-4, → Task 2): only the link row uses URL mode in the new placeholder test — cost if wrong: none
- Ruling P5 (I-5, → Task 13): lit-library-insert reads the entry via LibraryAccess (edit_post) — cost if wrong: none
- Ruling P6 (I-6, → Task 12): lit/library-save reuses NewDraft (partial_write + recordPartial on failed rollback) — cost if wrong: none
- Ruling P7 (I-7, → Task 12): scope "global" additionally requires edit_global_library (Divi's rule, F12) — cost if wrong: none
- Ruling P8 (I-8, → Task 9): a confirmed global edit is refused when the user cannot edit_post one of the published pages that render the entry (stricter than Divi; security first) — cost if wrong: editors with partial rights can't change shared modules
- Ruling P9 (minors): confirm stays honour-based (D10); a disabled library group hides library IDs from the Divi read tools; lit/divi-node-copy follows lit/divi-insert exposure (E9); refuse the backup path when a user without unfiltered_html edits with revisions off or trash disabled (kses backup loses bytes); output schema gains affected, affected_count, backup_id; unknown dynamic field types are omitted (F4); dispatch notes per preflight (OutlineTest anchor ×3, >120-char lines, dead variable) — cost if wrong: small
- Note: common.md fixed — PostChangeResult was renamed to PostSaveFollowUp in 2b W3
## Progress
- Task 1: dispatched (sonnet, BASE cfc81dd)
- Task 1: complete (404f0f3, trailer normalized; 2-line config change verified by controller; 1597 tests)
- Task 2: dispatched (opus, BASE 404f0f3; P4, link read_post check)
- Task 2: implementer done (788943e; 1609 tests); note T16 docs: attachments refused by {{link:}}
- Task 2: review (opus): Approved; live: read_post enforced (private/foreign draft/trash refused, uniform message), ID forms refused, URL-only on all paths, no leak
- Ruling P10 (T2 minors → final wave): full-form post_link_url_* uses LinkTargets (same target rules as {{link:}}; trashed/unattached attachment consistent) + one shared ID parser; attachments refused by {{link:}} documented in T16 — cost if wrong: none
- Task 2: complete (404f0f3..788943e)
- Tasks 3–5: dispatched as one batch (sonnet, BASE 788943e, one commit per task)
- Tasks 3–5: review (sonnet): all Approved (858aa6a, a3a8070, b389f98 after trailer normalization; 1617 tests); global id in outline/node-get = library post ID only (accepted, like GlobalUsages); minors → final wave: preset-cap note only when presets requested; Outline duplicates Block::shortName prefix strip
- Tasks 3, 4, 5: complete (788943e..b389f98)
- Tasks 6–7: dispatched (opus, BASE b389f98, one commit per task)
- Tasks 6–7: implementer done (ce1ad7c, 2700fd6; 1646 tests); T7 deviations: unifyLineBreaks skips opaque blocks; deglobalize keeps invalid blockName instance opaque
- Tasks 6–7: review (opus): both Approved; byte fidelity independently verified incl. deglobalize == Divi's own PortabilityPost output; deviations accepted
- Ruling P11 (T6/T7 minors): → T12/T13 dispatch: refuse opaque/global roots before GlobalInstance::of (no nested instances); → final wave: try/catch Throwable around Divi calls in DiviLibraryStore/DiviRoles (E15 never fatal), stdClass localAttrs, self-closing of() for created childless roots, replaceChild attached-check, isSpecialtyColumn by effectiveName
- Tasks 6, 7: complete (b389f98..2700fd6)
- Task 8: dispatched (opus, BASE 2700fd6; P9 library-group hiding hook)
- Task 8: implementer done (48ecc62; 1660 tests); P9 hook LibraryAccess $visible + ExposurePolicy::groupEnabled, wired as libraryVisible (GROUP 'divi-library', live with T10); T9 must merge: $libraryWritable param + LibraryAccess::GROUP already exist
- Task 8: review (opus): Approved; live with temp editor/author/contributor/subscriber + role-editor right withdrawn; P9 hook verified with real ExposurePolicy
- Ruling P12 (T8 I-1): users without the Divi library right at all (canList false) get PostGuard::notFound (no existence/type oracle, like P9); missing edit_post on an entry stays `forbidden` (project convention for pages) — fix round 1 now — cost if wrong: less helpful hint for users lacking the role right
- Ruling P13 (T8 M-1/M-2 → final wave / T10): with the library group hidden, page outline/node-get omit global_id and the render-summary hint; T10 keeps groupEnabled semantics (group mode only)
- Task 8: fix round 1 (P12; 681bb75; 1661 tests; live: logged-out 1058 vs 99999 byte-identical not_found) — verified by controller against report + live claim
- Task 8: complete (2700fd6..681bb75). Note for T10+: canList()/canRead() still return forbidden when called directly (fine for list tool — no ID oracle)
- Task 9: dispatched (opus, BASE 681bb75; F2, P1, P8, P9 backup refusal + schema)
- Task 9: implementer done (d66ad36; 1684 tests; divi-write smoke ok; live dry run on 1059 lists 1053, bytes unchanged); concerns: backup id not named on failure after backup; P8 published only
- Task 9: review (opus): live verified dry run/confirm/revision/reference semantics/P8 refusal/no flags; changes requested: I-1 P8 gap (private page: editor without rights changed what it shows), I-2 backup_id lost on failed save after backup
- Ruling P8a (amends P8): confirmed global edit requires edit_post on every affected post with status publish, private or future (incl. TB layouts/other library entries found by the query); drafts/pending listed in dry run but not blocking — private/future render or will render — cost if wrong: editors blocked more often
- Ruling P14: WriteFailed after a backup carries backup_id + hint (WriteFailed::withDetails); dry run warns with blocked_ids when P8 would refuse (M-1); plural fix (M-2); shared status-parameterised query (M-3); _prime_post_caches before P8 loop (M-4); M-5/M-6 → final wave/T16
- Task 9: fix round 1 dispatched
- Task 9: fix round 1 (P8a, P14, M-1..M-4; 520a639; 1691 tests); re-review running
- Task 9: re-review clean (live: private/future block, draft not, blocked_ids in dry run, backup_id on failed save)
- Task 9: complete (681bb75..520a639)
- Task 10: dispatched (opus, BASE 520a639; P12, P13 if cheap, per-entry filtering)
- Task 10: implementer done (b7c7149; 1702 tests); P12 via item() for id; P13 → final wave
- Task 10: review (sonnet): Approved; live: group disabled → list unregistered + read tools not_found; nur lesen → list ok, writes forbidden; P12 id byte-identical; minor: MAX_SCAN cap silent → final wave
- Task 10: complete (520a639..b7c7149)
- Task 11: dispatched (opus, BASE b7c7149; P2, P3, stored $variable refs via VariableRef)
- Task 11: implementer done (f8275ad; 1741 tests; 30 malicious rows); concerns: readable global instances checked (localAttrs) not refused (per brief/preflight I-3); builder-written $variable refs often refused for editors (stricter than Divi); plain text kses-checked only when containing '<'; ValueCleaner::kindAt added
- Ruling P15: accept checking readable global instances (a reference renders existing library content; Divi lets library-right users insert them too); stricter $variable handling accepted (visible refusal, documented in T16) — cost if wrong: editors can't copy some builder nodes
- Task 11: review (opus, SEC): Approved (guard holds for P3/P15; Divi renders instances from published entries only, localAttrs+snapshot both checked). NEW Critical (pre-existing since 2b): module.decoration.interactions writable by editors → effect addAttribute onclick="…" → Divi's interactions script setAttribute → script on click; path is known in catalog so lit/divi-update can write it
- Ruling P16: add decoration.interactions to ValueCleaner::BUILDER_ONLY (S7 gate: nur im Divi-Builder für Benutzer ohne unfiltered_html) — closes main pipeline and StoredNodeGuard; separate commit with tests (full form, short form if any, stored guard, removal via null); also scan the catalog for other paths whose values Divi turns into attributes/HTML/script (e.g. other effect/attribute/script-like option groups) and gate them the same way, listing what was found — cost if wrong: editors can't set interactions via MCP
- Ruling P17 (T11 minors): guard URL rule `url($v) === trim($v)`; guard enforces VALUE_KEY key names and digits-only globalModule; STATE_KEYS reuses AttrWriter::STATES; >120-char test lines — in the same fix round
- Task 11: fix round 1 (P16 9d92808 interactions builder-only + scan: nothing else to gate; P17 e808832; 1748 tests; smoke ok)
- Ruling P18 (P17 too strict for relative links): StoredNodeGuard URL accept rule = url($v) === trim($v), OR the value has no scheme (no ':' before the first '/', '?', '#') and contains no whitespace, quotes, '<', '>' or control chars — relative links (kontakt/, /pfad, #anker, ?q=1) stay copyable, every scheme still must survive url() unchanged — cost if wrong: none (scheme-less values can't carry javascript:)
- Task 11: fix round 2 (P18; 16c6d59; 1750 tests); backslash hosts also refused, //host must survive url()
- Task 11: re-review: P16/P17/P18 verified live (interactions refused for editors incl. null removal + guard; 63 URL paths × entity-colon payloads safe; relative rule holds); NEW Important: divi/lottie lottie.innerContent.src → remote JSON loaded by lottie-web with expressions (eval) → script; editors could set it
- Ruling P19: divi/lottie lottie.innerContent (src and any animation-source keys) builder-only for users without unfiltered_html (same S7 gate as P16), in main pipeline and StoredNodeGuard; tests set/null/copy; guides list it — cost if wrong: editors can't set Lottie via MCP
- Task 11: fix round 3 (P19; 8fa285a; 1753 tests; smoke ok) verified by controller (pattern + tests)
- Note Etappe 4: Divi global presets can carry a Lottie src / interactions — preset writes (design system) must apply the same builder-only gates
- Task 11: complete (b7c7149..8fa285a)
- Task 12: dispatched (opus, BASE 8fa285a; P6, P7, P11, guard)
- Task 12: implementer done (5035092; 1783 tests; live: bytes equal Divi entry 1055, page unchanged, no flags); nested globals kept as references in normal entries (plan-conform, accepted)
- Task 12: review (opus): Approved; live: rights (edit_post source, publish_posts, divi_library/save_library, edit_global_library), guard refuses all builder-only kinds as editor, P11, P6 rollback + partial_write, terms/meta identical to Divi, exposure; bytes equal after CRLF→LF; minors → final wave: REASON_CREATE placement, test line length, partial_write hint for new entries
- Task 12: complete (8fa285a..5035092)
- Task 13: dispatched (opus, BASE 5035092; P5, P11, guard in copy mode, direct budget 22)
- Task 13: implementer done (7fe91c2; 1799 tests; smoke ok; live: 1059/1058 instance and 1055 copy byte-identical to fixture, probes cleaned)
- Task 13: review (opus): Approved; live: P5 (author vs admin/private/fixture forbidden, missing not_found), P11 incl. instance into global entry refused by shapeViolation, copy-mode guard refusals as editor, exposure 22 direct; minors → final wave (test line length, multi-root unit test, "zu bearbeiten" wording)
- Task 13: complete (5035092..7fe91c2)
- NEW (pre-existing, Important): editor saves via any Divi write tool rewrite untouched <p> → <p> (some content_save_pre filter still active despite kses removal); no CHANGED_ON_SAVE; tree_rev may mismatch stored content → investigation dispatched (opus) in parallel with Task 14
- Task 14: dispatched (opus, BASE 7fe91c2); parallel: editor-rewrite investigation (read-only on code)
- Task 14: implementer done (6f6c691; 1814 tests; smoke ok); source_tree_rev required check; not live-probed
- Investigation editor rewrite: NOT reproducible (45 inputs, editor+admin, plain wp_update_post in kses window and end-to-end lit/divi-insert: byte-identical, tree_rev == Rev::of(stored)); likely tool-chain \u decoding in the reviewer's probe (known artifact); CHANGED_ON_SAVE detection works (wp_strip_custom_css_from_blocks + Divi AttributeSecurity both detected). Ruling P20: no kses-window change; final wave adds a file-based editor regression test and switches synthetic-divi-filters.html to \u003c escapes — cost if wrong: none
- Task 14: review (opus): Approved; live via registered ability: source access P5/P12, guard refusals as editor (7 kinds), P11, same-page byte identity, restore point, source/target rev locks, exposure matrix; minors → final wave: shared source-node helper (dup with LibrarySave), OUTLINE_HINT reuse, test line length, 'after'=>null in test
- Task 14: complete (7fe91c2..6f6c691)
- Task 15: dispatched (opus, BASE 6f6c691; F4, P9 unknown types, allow-map equivalence test)
- Task 15: implementer done (8808a7d; 1827 tests; allow map byte-identical JSON snapshot); concerns → final wave: {{link:}} hint for attachments vs P10, choice fields show value not label (document)
- Task 15: review (sonnet): Approved; allow-map snapshot test mutation-verified (non-vacuous); sample post only editable by user (implementer fixed brief leak); live page 26 sources
- Task 15: complete (6f6c691..8808a7d)
- Task 16: dispatched (opus, BASE 8808a7d; docs must describe actual T1–T15 behaviour, collected points in dispatch)
- Task 16: implementer done (2729e28; 1829 tests; v0.4.0); library line in instructions only when group writable; spec 'Claude' wording → final wave
- Task 16 review (sonnet) and Task 17 (opus, BASE 2729e28) dispatched in parallel (disjoint files: docs vs tests/Integration + abnahme note)
- Task 16: review (sonnet): Approved (docs verified against code: 22 direct, result keys, P8a/P9/P12/P16/P19/P3/P15–P18, link rules, version, topics, neutral wording); → final wave: wp-lit-mcp.php plugin header still says "mit OAuth für claude.ai"; internal spec "Claude" wording
- Task 16: complete (8808a7d..2729e28)
- Task 17: done (186f7af; 6 smokes ALL CHECKS PASSED; 1829 tests); Ruling P21: T17 (test script + acceptance note only) is reviewed within the final whole-branch review instead of a separate task review — cost if wrong: none
- Task 17: complete (2729e28..186f7af)
- Final: whole-branch review (opus) + security audit (opus) dispatched in parallel on cfc81dd..186f7af
- Final review (opus): ready with fixes; must: I-1 node-copy from library skips add_library (+ library-insert exposure), I-2 P13 guide vs code
- Security audit (opus): no Critical/High/Medium; Low: L1 DoS in P8a/usages (loads full posts), L2 referenced-but-not-global entry skips dry run/P8a (live), L3 library-list search covers content + decodable cursor → existence oracle for authors (live), L4 dry run lists id/status/type of unreadable posts, L5 = I-1, L6 TB layouts accepted on WP caps only; hardening: guard vs first globalModule id, confirm not bound to dry run, "0123" refs
- Ruling P22: final fix wave per final-fix-brief.md (must + audit L1–L6 + cheap review items); P13 full hiding and P10 → Etappe 3b; guide sentence fixed now — cost if wrong: none
- Final fix wave: 17/17 (1b42f9b..f1937ca; 1868 tests; 6 smokes ok); decisions: referenced normal entries get dry run/confirm (drafts listed, only live block, no edit_global_library needed); cursor counts shown entries only; divi/global-layout check via Divi module list; link hint dropped for internal types; divi-write-smoke roles-option restore same issue → U; scoped re-review dispatched
- Final fix re-review (opus): items 1–17 addressed (L1/L3/L4/L6/item 8 live-verified); F1 open: usage search misses Divi 4 shortcode global_module="ID" and JSON-escaped globalModule key/value (Divi renders them) → author edited an entry used only on such admin pages without dry run/P8a (live); minor: 'Eintrag, die' → 'den'
- Ruling P23: GlobalUsages finds references fail-closed: SQL prefilter candidates = content containing 'globalModule' OR 'global_module' OR a Divi block opener with '\\u' escapes; then PHP-parse candidates: Divi 4 shortcodes (global_module attr, intval) and Divi 5 openers (json_decode attrs → globalModule intval); an escaped opener whose globalModule can't be decoded counts as a possible reference (blocking if not editable); guide sentence adjusted; wp_block embedding documented as parity (Info) — cost if wrong: more pages listed/blocking on odd content
- Final fix round 1 (P23; fec5d9d, 8f87ec6; 1887 tests; smokes ok): candidate prefilter globalModule/global_module/escaped ASCII letter; UsageMatcher parses Divi 5 (json_decode) + Divi 4; undecodable escaped opener = possible reference; parse cap 5000
- F1 re-check: live bypass fixed, prefilter not evadable; NEW G1 Divi 4 shortcode inside a Divi 5 attribute value (\u0022-escaped quotes) misparsed; G2 leading space / scientific notation cast by Divi (int); G3 global_module values casting to 0 render the NEWEST published layout (et_pb_load_global_module WP_Query p=0); _bin collation note
- Ruling P24: UsageMatcher scans decoded string values of every Divi 5 opener for Divi 4 shortcodes too; shortcode attrs parsed like WordPress (shortcode_parse_atts) and cast with (int) exactly like Divi; a global_module that casts to 0 counts as a reference to EVERY library entry (fail closed; rare); prefilter case-insensitive regardless of collation (LOWER() or explicit _ci COLLATE); guide wording without absolute claim; after this round remaining exotic forms are documented Low (breaker) — cost if wrong: coarse over-listing on odd content
- Final fix round 2 (P24; b6d791d; 1901 tests; smokes ok; live: shortcode in Divi 5 text value with ", leading space, abc, GLOBAL_MODULE found; other-ID control not found)
- Ruling P25 (breaker): accept after round 2 without another re-review — fail-closed rule in place, remaining exotic spellings need edit rights on the referencing page, documented as residual Low in the guide and rulings; G3 side effect (an invalid global_module on a real page makes every entry edit need confirm) accepted as fail-closed — cost if wrong: a crafted unusual spelling on a page the author can't edit escapes P8a (Low)
- Etappe 3a complete: cfc81dd..b6d791d
@@ -0,0 +1,210 @@
# Etappe 3a (Divi-Bibliothek): security audit
- **Scope:** branch etappe-3, cfc81dd..186f7af. Focus: what the per-task reviews covered least (object authorization, global edits, stored-content injection, disclosure, DoS).
- **Environment:** DDEV (WordPress 7.1.x, Divi 5.2.1), single site, no editors/authors present, `DISALLOW_UNFILTERED_HTML` off.
- **Method:** read all new/changed `src/` files of the range plus the relevant Divi 5 render code (`BlockParser`, `BlockParserStore`, `GlobalLayout`, `DynamicContentOptions*`, `LinkUtils`, MultiView, Divi 4 `class-et-builder-element.php`), scanned all `script-library-*.js` for DOM sinks, and ran four self-cleaning `wp eval-file` probes (temp posts 1906–1914, temp user `litaudit_author`; all deleted, verified: 0 `LITAUDIT` posts, 0 postmeta/term rows for those IDs, no temp user, no probe files, `git status` clean). No [Fixture]/[Test] content was touched; no code was changed.
**Result:** no Critical, High or Medium findings. The rulings P3/P5/P8a/P11/P12/P15–P19 hold. Six Low findings and three Info/hardening notes follow.
---
## Findings (most severe first)
### L1 – Low (DoS): the P8a check loads every live usage with full content, and each dry run scans `wp_posts` 3×
**Where**
- `src/Integrations/Divi/Library/GlobalUsages.php:68-79` (`liveIds()`: no LIMIT).
- `src/Integrations/Divi/Library/GlobalEdits.php:158-171` (`_prime_post_caches($live, …)`, then `current_user_can('edit_post')` per ID).
- `GlobalUsages.php:29-47`: COUNT plus SELECT, each an unindexed `post_content LIKE '%…%'`.
- Also reached from `GlobalEdits::notice()` (every delete dry run) and `LibraryListAbility::detail()`.
**Scenario**
1. A global module (newsletter box, CTA) sits in thousands of posts. That is a common Divi pattern.
2. Any user with library rights and `edit_post` on the entry calls a Divi tool on it without `confirm` (dry run), or `lit/library-list` with `id`.
3. `_prime_post_caches()` fetches `posts.*` for every live ID in one query, `post_content` included. At 5,000 posts × ~50 KB of Divi markup that is about 250 MB, which exceeds the usual `memory_limit` and ends the request in a fatal error.
4. Each call also runs 3 full-table LIKE scans (6 on confirm, because dry run and confirm both call `of()` and `blocked()`). A loop of calls ties up the database on large sites.
5. The effect stops when the requests stop, and it needs an authenticated MCP user.
**Evidence:** by reading. The DDEV data set is too small to reproduce the memory limit.
**Fix**
- Use one query: `SELECT ID, post_status, post_type, post_author, post_title … LIKE …`. Derive `count`, the first 50 items and the live list in PHP, instead of COUNT + SELECT + `liveIds()`.
- Evaluate `edit_post` in chunks of about 200 IDs, calling `_prime_post_caches()` per chunk and `clean_post_cache()` or `wp_cache_flush_runtime()` between chunks.
- Or fail closed: above a hard cap (e.g. 1,000 live usages), refuse the confirmed edit with a hint ("im Divi-Builder bzw. von einem Administrator bearbeiten").
### L2 – Low (global edits): "global" is decided by the scope term, but Divi renders every referenced entry, so edits to referenced non-global entries skip the dry run and P8a
**Where**
- `src/Integrations/Divi/Library/GlobalEdits.php:71` and `:88-90`: `if (! $global) return LibraryItem`.
- `src/Integrations/Divi/Library/DiviLibraryStore.php:37` (`isGlobal()` → `GlobalLayout::is_global_layout_template()`, which checks the scope term, with a meta fallback for modules only).
- Divi: `BlockParser.php:838-880` renders any `divi/global-layout` whose `globalModule` names a *published `et_pb_layout`*. It checks only `post_type` and `post_status`, not the scope.
**Scenario**
1. A live page that user U cannot edit holds an instance of library entry N, but N has no `global` scope term. This can happen when:
- the entry comes from an import (Divi's D4 fallback covers modules only, not sections or rows);
- the scope was changed later;
- the markup was written outside the Divi builder.
2. U has `edit_post` on N (as its author, or as an editor) and changes N with any Divi tool.
3. `GlobalEdits` treats N as a normal entry: no dry run, no `affected` list, no P8a check, no site-wide CSS clear.
4. The page U cannot edit now shows the changed content.
**Evidence (live)**
- Temp entry 1910 (`scope = non_global`) and temp published page 1911 with `<!-- wp:divi/global-layout {"globalModule":"1910","blockName":"divi/text"} /-->`.
- `isGlobal(1910) = false`, while `GlobalUsages::liveIds(1910) = [1911]`.
- The frontend HTTP render of 1911 shows the marker from 1910 (`LITAUDIT_NORMAL_ENTRY`).
- Both posts were deleted afterwards.
**Fix:** in `GlobalEdits::prepare()` and `notice()`, apply the global rules (confirm, `affected`, P8a, `GlobalItem` scope) whenever `isGlobal()` is true **or** `GlobalUsages` finds at least one usage. For the check, one `LIMIT 1` query is enough.
### L3 – Low (disclosure): the `lit/library-list` cursor is an oracle over the *content* of entries the caller may not read
**Where**
- `src/Integrations/Divi/Library/LibraryListAbility.php:176-194`: rows the caller may not read still advance `$position`, and `next_cursor = Cursor::encode($position)` is `base64("o:N")`.
- `src/Integrations/Divi/Library/DiviLibraryStore.php:103`: `'s' => search` makes WP search title, excerpt **and content**, although the schema at `LibraryListAbility.php:68` says "Suche im Titel".
**Scenario**
1. An author with the Divi library right creates two entries of their own that contain candidate words.
2. They then search with `limit: 1`.
3. The decoded cursor tells how many hidden entries match the search and sort before their own. That lets them guess words from other users' library content (codes, internal notes).
4. Plugin rule E3 / P12 is that authors may not read those entries: `lit/library-list id=…` returns `lit_forbidden`.
5. Mitigation: Divi's own Visual Builder library lists all entries to every user with the library right. Relative to Divi this is parity; relative to the plugin's own stricter rule it is a bypass. It is the same defect class that `PostGuard::ownPostsOnly()` already documents.
**Evidence (live)**
- Temp author and 3 temp entries: an admin entry "aaa" containing `LITAUDITSECRET pin 4711`, and author entries "zzz1" and "zzz2" containing `LITAUDITSECRET`.
- As the author, `search=LITAUDITSECRET, limit=1` returns items `["LITAUDIT zzz1"]` with `next_cursor=bzoy` (= `o:2`, which reveals one hidden match).
- A direct read of the admin entry returns `lit_forbidden`.
- All temp data was deleted and the deletion verified.
**Fix**
- Restrict the query in `find()`:
- add `'author' => PostGuard::ownPostsOnly(DiviLibraryStore::POST_TYPE)` when it is non-null, so the cursor counts only rows the caller can see;
- set `'search_columns' => ['post_title']` (WP ≥ 6.2) to match the documented title-only search.
- Keep the per-row `canRead()` filter as defence in depth.
### L4 – Low (disclosure): `affected`, `blocked_ids` and the P8a refusal expose IDs, status and type of posts the caller may not read
**Where**
- `src/Integrations/Divi/Library/GlobalUsages.php:48-56`: title masked, but `post_id`, `status` and `type` are always returned.
- `src/Integrations/Divi/Library/GlobalEdits.php:184-203`: IDs in message and details.
- Also `lit/library-list id=…` (`affected`).
**Scenario**
1. An author owns a global entry. An administrator used it on private pages and on other users' drafts.
2. The author's dry run lists those posts' IDs, their status (`private`, `draft`, `future`) and their post type.
3. Only the titles are hidden.
**Evidence:** by reading. The masking applies to `post_title` only.
**Fix**
- For rows without `read_post`, return only an aggregate: `hidden_count`, plus the count of blocking hidden posts.
- Name blocked IDs only where `read_post` holds; otherwise say "N Einträge, die dieses Konto nicht lesen darf".
### L5 – Low (authorization): `lit/divi-node-copy` from a library entry into a page skips Divi's role option `add_library`
**Where**
- `src/Integrations/Divi/Pages/DiviNodeCopyAbility.php:78-96`: the source goes through `DiviPageLoader::load(…, false)`, i.e. `LibraryAccess::item()` / `canRead()` (`edit_posts`, `divi_library`, `edit_post`).
- Compare `LibraryAccess::canInsert()` (`add_library`), which `lit/library-insert` requires.
**Scenario**
1. The site owner turns off "Aus der Bibliothek hinzufügen" (add from library) for a role in Divi → Rollen-Editor.
2. A user of that role can still copy any node from an entry they can edit into a page, via `lit/divi-node-copy` with `source_post_id = <library entry>`.
3. Security impact is small, because content still passes StoredNodeGuard. But it breaks E3 ("nie laxer als Divi", never laxer than Divi).
**Evidence:** by reading.
**Fix:** in `precheck()`, when the loaded source `isLibraryItem()`, additionally return `$access->canInsert()`. The target side is already covered by `canEdit()` / `save_library`.
### L6 – Low (global edits): P8a accepts Theme Builder layouts on WordPress `edit_post` alone
**Where**
- `src/Integrations/Divi/Library/GlobalEdits.php:158-171`.
- On DDEV, `et_header_layout`, `et_template`, `et_theme_builder` and `et_pb_layout` all use capability type `post` with `map_meta_cap`. So an editor has `edit_post` on the site header and footer layouts.
- Divi gates the Theme Builder with the role option `theme_builder`, which is off by default for every role but administrator.
**Scenario**
1. A global module sits in the Theme Builder header, footer or body.
2. An editor with `edit_global_library` passes P8a, because they have `edit_post` on the TB layout.
3. The confirmed edit changes the site-wide header, although Divi does not let this editor use the Theme Builder.
4. Divi's own Visual Builder allows the same global edit from any page (parity with Divi). But P8a's intent, "changes only on posts this account may edit", is not met.
**Evidence:** live, post type objects and caps (probe p1). The rest is by reading.
**Fix:** in `blocked()`, also treat IDs whose post type is one of `et_header_layout`, `et_body_layout`, `et_footer_layout` or `et_template` as blocked unless `DiviRoles::allows('theme_builder')`.
---
## Info / hardening
### I1 – StoredNodeGuard trusts the instance's own `blockName` and `globalModule`; Divi renders the real entry
**Where**
- `src/Integrations/Divi/Compose/StoredNodeGuard.php:90-91` and `:114-138`. Routing and the code-module check use `GlobalInstance::effectiveName()`, i.e. the stored `blockName`.
- `Json::decodeObject()`: the last duplicate key wins.
**What Divi does**
- `BlockParser.php:838` takes the *first textual* `"globalModule":"(\d+)"`. That can be a nested one inside `localAttrs`, or the first of two duplicate keys.
- `BlockParserStore.php:1113ff` merges `localAttrs` (filtered by the entry's `localAttrsMap`) onto the entry's *actual* root block.
**Consequence**
- A forged instance can be routed as `divi/text` (kses-checked richtext) while it is rendered onto a `divi/code` entry, or onto another entry than the guard saw.
- Exploiting this needs a forged instance in a source the user can edit. The user can already write that into their own page through core REST (parity, cf. 2b audit M1). The new part is that node-copy can carry it into a library entry (`et_pb_layout` has `show_in_rest = false`).
**Hardening**
- Refuse instances whose JSON contains more than one `"globalModule"` occurrence.
- Resolve `globalModule` to a published `et_pb_layout` the user may read.
- Require its root block name to equal `blockName`, and route `localAttrs` by that real name.
### I2 – Confirm is not bound to the dry run
- `GlobalEdits.php:95-117`: between the dry run and `confirm: true`, the `affected` set can grow; the user agreed to N pages, and M are changed.
- Rights are safe, because P8a is recomputed at confirm (verified by reading; a live check exists from T9).
- Optional: accept `expected_affected_count` (or a hash of the affected IDs) and refuse on mismatch.
### I3 – GlobalUsages misses non-canonical references Divi still renders
- `"globalModule":"0123"` renders entry 123: Divi's `([0-9]+)` capture is cast to int by `get_post()`.
- The LIKE on `"globalModule":"123"` does not match it.
- Only someone who can edit the containing post can write it, so there is no attacker gain.
- Optional: LIKE on `'"globalModule":"%'` plus a PHP regex with `ltrim($id, '0')`.
**Not a gap (checked live):** Divi 4 shortcode pages with `global_module="<id>"`. GlobalUsages does not find them (probe p2/p3: `liveIds = []`), but Divi 5 does not render the D5 entry content there either. The frontend showed the page's own local D4 content, not the entry's. The plugin refuses to edit D4-format entries, so no unlisted page changes.
---
## Areas checked and found sound
- **IDOR via `library_id`:** `LibraryInsertAbility::item()` goes through `LibraryAccess::item(false)` (P5/P12): same `not_found` for non-library IDs and revisions, `forbidden` only for users with the library right.
- **IDOR via `source_post_id`:** PostGuard (`edit_post`, managed types only; `revision`, TB and library types excluded) or LibraryAccess, re-checked in `execute()`, with its own `source_tree_rev`.
- **IDOR via `post_id` in `$variable` settings / `{{link:ID}}`:** `select_post` → `read_post` (`DynamicContentRules.php:246-259`), re-checked by StoredNodeGuard through `canonical()` against the target post. Only `post_link_url_*` uses `select_post` in Divi 5.2.1. `{{link:}}` to `et_pb_layout` is refused by the registry, with a uniform message.
- **Divi role options:**
- `divi_library`, `save_library`, `add_library` and `edit_global_library` are combined correctly with `edit_posts`, `edit_post` and `publish_posts` in `LibraryAccess` (except L5).
- The P9 group gates (writable/visible) are applied in `item()`, `canEdit()`, `canSave()` and `canInsert()`.
- **Global edits through replace-text, node-copy into, insert into, and delete in a global entry:**
- All go through `DiviWriter::commit()` → `GlobalEdits` (shape, dry run, P8a, backup, `GlobalItem` scope).
- replace-text never sends `confirm`.
- An instance inside a global entry is refused (P11/`shapeViolation`).
- Generic WP tools (`content-update`, `post-trash`, `revision-restore`, `duplicate`) cannot reach `et_pb_layout` (not managed).
- **Dry run/confirm race for rights:** P8a is recomputed at confirm from a fresh query. The `tree_rev` lock covers entry content changes.
- **Content injection:**
- `localAttrs` and the snapshot children of instances are walked, and so are local children, because the parser keeps inner blocks of opaque blocks.
- `deglobalize` runs before the guard.
- CRLF unification touches whitespace-only text between blocks only.
- Invalid or non-`divi/` `blockName` instances are refused as opaque.
- Library titles go through `sanitize_text_field` (plus core title kses).
- Terms are limited to existing names.
- **Divi frontend sinks beyond interactions and Lottie** (scan of all `script-library-*.js`):
- module link → `diviElementLinkData`, where `LinkUtils::generate_data` applies `esc_url` before `window.location`/`window.open`;
- multi-view `innerHTML`/`setAttribute`: data via `wp_localize_script` (nested values are not entity-decoded), with fixed attribute names and per-attribute sanitizers;
- `new Function` hits are bundler global shims;
- magnific-popup, slider and menu read `src`/`href` from escaped markup.
- Together with the T11 live URL matrix, no new sink was found.
- **Disclosure:**
- `dynamic-sources` samples only an editable post, uses the `edit` context (custom meta hidden without `read_dynamic_content_custom_fields`) and returns no option defaults.
- Render-summary notes list only IDs already present in content the caller may read.
- site-status `problems` are labels of missing Divi internals only.
- Error texts are uniform for missing and unreadable IDs (P12; `{{link:}}` from T2). The known P13 leftovers are left to the final wave.
- **Multisite:** everything is per-blog (`$wpdb->posts`, Divi role options, `current_user_can`). No network-level path exists. Without `unfiltered_html` (site admins), the guard and the P9 backup refusal only get stricter.
- **DoS (other):**
- `lit/library-list` scan is capped (`MAX_SCAN` 500, `limit` ≤ `Cursor::MAX_LIMIT`).
- Guide `topic=all` is capped at 100 KB of static files.
- `affected` is capped at 50 rows (the cost is in L1, not the output).
File diff suppressed because it is too large Load Diff
@@ -12,12 +12,12 @@ Die Pflege von Kunden-Websites (WordPress + Divi 5 + Pods) kostet viel Handarbei
### 1.2 Ziel ### 1.2 Ziel
Ein WordPress-Plugin, das auf jeder Kundenseite einen MCP-Server bereitstellt, mit dem Claude (claude.ai-Connector) die Website pflegt – **so, dass alle Ergebnisse native Divi-Strukturen sind, die der Mensch im Divi Visual Builder weiter verfeinern kann.** Ein WordPress-Plugin, das auf jeder Kundenseite einen MCP-Server bereitstellt, mit dem ein KI-Assistent (z. B. über den claude.ai-Connector) die Website pflegt – **so, dass alle Ergebnisse native Divi-Strukturen sind, die der Mensch im Divi Visual Builder weiter verfeinern kann.**
### 1.3 Erfolgskriterien ### 1.3 Erfolgskriterien
1. Ein claude.ai-Connector verbindet sich per OAuth mit einer Kundenseite; nur freigegebene Rollen können Tools nutzen. 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). 2. Vom KI-Assistenten erzeugte oder geänderte Divi-Inhalte sind im Visual Builder vollständig bearbeitbar (manuell verifiziert pro Etappe).
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) – aber nur, wenn die Seite noch nicht in Divis `serialize_blocks()`-Form gespeichert war (z. B. Import, direkter DB-Schreibzugriff); über Builder oder LIT MCP gespeicherte Seiten bleiben unverändert (E2E Abschnitt 8a); 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). 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) – aber nur, wenn die Seite noch nicht in Divis `serialize_blocks()`-Form gespeichert war (z. B. Import, direkter DB-Schreibzugriff); über Builder oder LIT MCP gespeicherte Seiten bleiben unverändert (E2E Abschnitt 8a); 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. 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). 5. Jede Änderung ist nachvollziehbar (Protokoll) und umkehrbar (Revision bzw. Snapshot).
@@ -114,7 +114,7 @@ Ob dieser Aufbau stabil funktioniert, entscheidet Etappe 0 (§12).
- **Direkte Tools (~20):** mit vollem Schema sofort sichtbar; nur direkte Tools können Bilder (`ImageContent`) zurückgeben. Standard-Set: - **Direkte Tools (~20):** mit vollem Schema sofort sichtbar; nur direkte Tools können Bilder (`ImageContent`) zurückgeben. Standard-Set:
`lit/guide`, `lit/content-types`, `lit/content-query`, `lit/content-get`, `lit/content-create`, `lit/content-update`, `lit/post-publish`, `lit/media-search`, `lit/media-preview`, `lit/media-upload-from-url`, `lit/divi-outline`, `lit/divi-node-get`, `lit/divi-insert`, `lit/divi-update`, `lit/divi-move`, `lit/divi-delete`, `lit/divi-design-system`, `lit/divi-catalog`, `lit/library-insert`, `lit/page-render-summary`. `lit/guide`, `lit/content-types`, `lit/content-query`, `lit/content-get`, `lit/content-create`, `lit/content-update`, `lit/post-publish`, `lit/media-search`, `lit/media-preview`, `lit/media-upload-from-url`, `lit/divi-outline`, `lit/divi-node-get`, `lit/divi-insert`, `lit/divi-update`, `lit/divi-move`, `lit/divi-delete`, `lit/divi-design-system`, `lit/divi-catalog`, `lit/library-insert`, `lit/page-render-summary`.
- **Stand nach Etappe 2 (Entscheidung D2, 2026-09-26):** 21 direkte Tools – das Standard-Set oben ohne `lit/library-insert` (kommt mit Etappe 3), dazu `lit/site-status` (leeres Eingabeschema, Anker der Verbindungstests) und `lit/media-download` (eingebettete Ressource, funktioniert nur direkt). `lit/divi-duplicate-node` und `lit/divi-replace-text` sind indirekt. Mit Etappe 3 sind es 22; das Budget wird dort erneut geprüft (Kosten entstehen vor allem durch große Eingabeschemas, nicht durch die Zahl). - **Stand nach Etappe 2 (Entscheidung D2, 2026-09-26):** 21 direkte Tools – das Standard-Set oben ohne `lit/library-insert` (kommt mit Etappe 3), dazu `lit/site-status` (leeres Eingabeschema, Anker der Verbindungstests) und `lit/media-download` (eingebettete Ressource, funktioniert nur direkt). `lit/divi-duplicate-node` und `lit/divi-replace-text` sind indirekt. Mit Etappe 3 sind es 22; das Budget wird dort erneut geprüft (Kosten entstehen vor allem durch große Eingabeschemas, nicht durch die Zahl). **Stand nach Etappe 3a:** 22 direkte Tools (dazu `lit/library-insert`); indirekt neu `lit/library-list`, `lit/library-save`, `lit/divi-node-copy`, `lit/divi-dynamic-sources`. Neue Tool-Gruppe „Divi-Bibliothek“ (`lit/library-*`); `lit/divi-node-copy` und `lit/divi-dynamic-sources` gehören zur Gruppe „Divi“.
- **Alle übrigen Abilities:** über die Meta-Tools des Adapters (`discover-abilities`, `get-ability-info`, `execute-ability`). - **Alle übrigen Abilities:** über die Meta-Tools des Adapters (`discover-abilities`, `get-ability-info`, `execute-ability`).
- In den Einstellungen änderbar, welche Abilities direkt, indirekt oder deaktiviert sind. - In den Einstellungen änderbar, welche Abilities direkt, indirekt oder deaktiviert sind.
- Ability-Namen: `lit/<bereich>-<aktion>`. Der Adapter bildet daraus Tool-Namen (`lit-<bereich>-<aktion>`). - Ability-Namen: `lit/<bereich>-<aktion>`. Der Adapter bildet daraus Tool-Namen (`lit-<bereich>-<aktion>`).
@@ -174,13 +174,13 @@ Seiten, Theme-Builder-Layouts (`et_header_layout`, `et_body_layout`, `et_footer_
- **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, was nur Bytes ändert, die noch nicht in Divis `serialize_blocks()`-Form vorlagen; gemeldet als `CHANGED_ON_SAVE` (Ruling S7). - **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, was nur Bytes ändert, die noch nicht in Divis `serialize_blocks()`-Form vorlagen; gemeldet als `CHANGED_ON_SAVE` (Ruling S7).
- Reines PHP ohne WordPress-Abhängigkeit (vollständig unit-testbar). - Reines PHP ohne WordPress-Abhängigkeit (vollständig unit-testbar).
- Attributwerte mit `$variable(...)$` werden beim Serialisieren wie von Divi erwartet escaped (`"` → `"`). - 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. - 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 (bearbeitet werden globale Module seit Etappe 3a im Bibliothekseintrag, E2). Unbalanciertes Markup wird abgelehnt, nie repariert.
### 5.3 Adressierung und optimistische Sperre ### 5.3 Adressierung und optimistische Sperre
- `lit/divi-outline` liefert den Baum mit Pfad-IDs (`s0`, `s0.r1`, `s0.r1.c0.m2`), Modultyp, Admin-Label, Textauszug und Preset-Namen. - `lit/divi-outline` liefert den Baum mit Pfad-IDs (`s0`, `s0.r1`, `s0.r1.c0.m2`), Modultyp, Admin-Label, Textauszug und Preset-Namen.
- Jede Antwort enthält `tree_rev` = Hash des aktuellen `post_content` (Sperre der Divi-Werkzeuge) und `rev` = Sperre des ganzen Eintrags (`Rev::ofPost`, dieselbe wie bei `lit/content-get` und `lit/content-update`; Ruling P16). - Jede Antwort enthält `tree_rev` = Hash des aktuellen `post_content` (Sperre der Divi-Werkzeuge) und `rev` = Sperre des ganzen Eintrags (`Rev::ofPost`, dieselbe wie bei `lit/content-get` und `lit/content-update`; Ruling P16).
- Jedes Divi-Schreib-Tool verlangt `tree_rev`. Weicht sie ab, wird abgelehnt (`rev_mismatch`), nichts geschrieben; Claude liest neu. Pfad-IDs gelten immer relativ zu einer `tree_rev`. Jede Schreib-Antwort liefert die neue `tree_rev` und die neuen Pfad-IDs der geänderten Knoten. - Jedes Divi-Schreib-Tool verlangt `tree_rev`. Weicht sie ab, wird abgelehnt (`rev_mismatch`), nichts geschrieben; der Client liest neu. Pfad-IDs gelten immer relativ zu einer `tree_rev`. Jede Schreib-Antwort liefert die neue `tree_rev` und die neuen Pfad-IDs der geänderten Knoten.
- Buchstaben der Pfad-IDs: `s` Abschnitt, `r` Zeile (auch `row-inner`), `c` Spalte (auch `column-inner`), `m` Modul (auch Kind-Module und Gruppen), `g` globales Modul bzw. globales Layout, `x` fremder Block (z. B. Block-Editor); gezählt wird je Buchstabe innerhalb des Elternknotens, `divi/placeholder` ist transparent. - Buchstaben der Pfad-IDs: `s` Abschnitt, `r` Zeile (auch `row-inner`), `c` Spalte (auch `column-inner`), `m` Modul (auch Kind-Module und Gruppen), `g` globales Modul bzw. globales Layout, `x` fremder Block (z. B. Block-Editor); gezählt wird je Buchstabe innerhalb des Elternknotens, `divi/placeholder` ist transparent.
### 5.4 Modulbeschreibung ### 5.4 Modulbeschreibung
@@ -216,6 +216,7 @@ Seiten, Theme-Builder-Layouts (`et_header_layout`, `et_body_layout`, `et_footer_
| `{{font:heading}}`, `{{font:body}}` | Theme-Schriften (`--et_global_heading_font` / `--et_global_body_font`) | | `{{font:heading}}`, `{{font:body}}` | Theme-Schriften (`--et_global_heading_font` / `--et_global_body_font`) |
| `{{post:title}}`, `{{post:excerpt}}`, `{{post:date}}`, … | Dynamic Content (Post-Daten) | | `{{post:title}}`, `{{post:excerpt}}`, `{{post:date}}`, … | Dynamic Content (Post-Daten) |
| `{{field:<meta_key>}}` | Dynamic Content `custom_meta_<meta_key>` (Pods/Meta) | | `{{field:<meta_key>}}` | Dynamic Content `custom_meta_<meta_key>` (Pods/Meta) |
| `{{link:<ID>}}` | interner Link: Dynamic Content `post_link_url_<Typ>` mit `post_id` (nur Link-Felder; Ziel muss für den Benutzer lesbar sein, nicht im Papierkorb, kein automatischer Entwurf, kein Anhang; Entwürfe/private Ziele rendert Divi als `?page_id=`; Etappe 3a, E13a) |
| `"preset": "<Name>"` | `modulePreset: ["<id>"]` | | `"preset": "<Name>"` | `modulePreset: ["<id>"]` |
Unbekannte Namen → Fehler mit Liste der vorhandenen. Unbekannte Namen → Fehler mit Liste der vorhandenen.
@@ -249,7 +250,7 @@ Kurzform setzt Admin-Labels automatisch (`<Abschnitt> – <Modul>`), sofern nich
| `lit/divi-node-get` | lesen | vollständige Attribute eines Knotens (Vollform) | | `lit/divi-node-get` | lesen | vollständige Attribute eines Knotens (Vollform) |
| `lit/divi-catalog` | lesen | Module, Kurzform-Felder, wichtige Attributpfade; Detail pro Modul | | `lit/divi-catalog` | lesen | Module, Kurzform-Felder, wichtige Attributpfade; Detail pro Modul |
| `lit/divi-design-system` | lesen | Farben, Variablen, Presets, Theme-Schriften/-Größen | | `lit/divi-design-system` | lesen | Farben, Variablen, Presets, Theme-Schriften/-Größen |
| `lit/divi-dynamic-sources` | lesen | verfügbare Dynamic-Content-Quellen für einen Post-Typ (über `DynamicContentOptions::get_options` mit einem Beispiel-Post des Typs) – Etappe 3, mit dem Theme Builder (D6) | | `lit/divi-dynamic-sources` | lesen | verfügbare Dynamic-Content-Quellen für einen Post-Typ (über `DynamicContentOptions::get_options` mit einem Beispiel-Post des Typs) – Etappe 3a (E12, E14); nur textartige Felder (Antwort F4): Beziehungs-, Datei-, Tabellen- und unbekannte Felder stehen mit Grund unter `omitted`, Auswahlfelder liefern den gespeicherten Wert; Beispiel-Post nur einer, den der Benutzer bearbeiten darf |
| `lit/page-render-summary` | lesen | gerenderte Seite als strukturierter Auszug (Überschriften, Texte, Links, Bilder+Alt); Inhaltsbereich, im Prozess über `the_content` gerendert (ohne Header/Footer/Theme Builder); Teil der WordPress-Integration, funktioniert auch ohne Divi (D5) | | `lit/page-render-summary` | lesen | gerenderte Seite als strukturierter Auszug (Überschriften, Texte, Links, Bilder+Alt); Inhaltsbereich, im Prozess über `the_content` gerendert (ohne Header/Footer/Theme Builder); Teil der WordPress-Integration, funktioniert auch ohne Divi (D5) |
| `lit/divi-insert` | schreiben | Knoten (Kurz-/Vollform, auch ganze Abschnitte) an Position `parent`/`before`/`after` einfügen | | `lit/divi-insert` | schreiben | Knoten (Kurz-/Vollform, auch ganze Abschnitte) an Position `parent`/`before`/`after` einfügen |
| `lit/divi-update` | schreiben | Werte setzen/entfernen (Kurz- oder Vollform) | | `lit/divi-update` | schreiben | Werte setzen/entfernen (Kurz- oder Vollform) |
@@ -257,10 +258,10 @@ Kurzform setzt Admin-Labels automatisch (`<Abschnitt> – <Modul>`), sofern nich
| `lit/divi-delete` | schreiben, destruktiv | Knoten entfernen | | `lit/divi-delete` | schreiben, destruktiv | Knoten entfernen |
| `lit/divi-duplicate-node` | schreiben | Knoten duplizieren | | `lit/divi-duplicate-node` | schreiben | Knoten duplizieren |
| `lit/divi-replace-text` | schreiben | Text seitenübergreifend (je Inhaltstyp) ersetzen, `dry_run` (Standard `true`) liefert Treffer je Seite mit `tree_rev`; ausgeführt wird nur auf diesen `targets` mit `confirm: true`, jede Seite mit eigener Revision (D10) | | `lit/divi-replace-text` | schreiben | Text seitenübergreifend (je Inhaltstyp) ersetzen, `dry_run` (Standard `true`) liefert Treffer je Seite mit `tree_rev`; ausgeführt wird nur auf diesen `targets` mit `confirm: true`, jede Seite mit eigener Revision (D10) |
| `lit/library-list`, `lit/library-get` | lesen | Library-Einträge nach Typ (`layout_type`) und Scope | | `lit/library-list` | lesen | Einträge nach Typ (`layout_type`) und Scope, mit `cursor`; mit `id` Detail inkl. Knoten-Übersicht und betroffener Seiten (ersetzt `lit/library-get`, E9); nur Einträge mit `edit_post` |
| `lit/library-save` | schreiben | Knoten als Library-Eintrag speichern (normal oder global) | | `lit/library-save` | schreiben | Knoten als Library-Eintrag speichern (normal oder global; global braucht Divis „Globale Bibliothekselemente bearbeiten“, P7); Knoten auf der Seite bleibt unverändert; StoredNodeGuard (E6) |
| `lit/library-insert` | schreiben | Library-Eintrag als Kopie oder globale Instanz einfügen | | `lit/library-insert` | schreiben | Library-Eintrag als Kopie oder globale Instanz im Format des Visual Builders (`localAttrs`-Schnappschuss, `globalParent`) einfügen; direkt |
| `lit/divi-node-copy` | schreiben | Knoten von Seite A nach Seite B kopieren | | `lit/divi-node-copy` | schreiben | Knoten von Eintrag A nach Eintrag B kopieren (Seiten und Bibliothekseinträge; byte-gleich, `source_tree_rev` Pflicht, S7/S26-Prüfung E6) |
| `lit/library-make-global` | schreiben | Knoten in globales Modul umwandeln; optional gleichartige Vorkommen ersetzen | | `lit/library-make-global` | schreiben | Knoten in globales Modul umwandeln; optional gleichartige Vorkommen ersetzen |
| `lit/library-find-similar` | lesen | ähnliche Abschnitte seitenübergreifend (Struktur-Fingerabdruck: Modultypen, Aufbau, Presets) | | `lit/library-find-similar` | lesen | ähnliche Abschnitte seitenübergreifend (Struktur-Fingerabdruck: Modultypen, Aufbau, Presets) |
| `lit/tb-list` | lesen | Vorlagen, Bedingungen, Layouts | | `lit/tb-list` | lesen | Vorlagen, Bedingungen, Layouts |
@@ -353,7 +354,7 @@ Regeln:
- Vor jeder Änderung wird der betroffene Bereich (Farben, Variablen, Presets oder Theme-Optionen) als **Snapshot** gesichert (eigene Option, begrenzte Anzahl, Standard 20). Snapshot-ID im `ToolResult` und im Protokoll. - Vor jeder Änderung wird der betroffene Bereich (Farben, Variablen, Presets oder Theme-Optionen) als **Snapshot** gesichert (eigene Option, begrenzte Anzahl, Standard 20). Snapshot-ID im `ToolResult` und im Protokoll.
- Voraussetzung: `edit_theme_options`; für Variablen zusätzlich Divis Rollen-Recht `variables_manager`. - Voraussetzung: `edit_theme_options`; für Variablen zusätzlich Divis Rollen-Recht `variables_manager`.
Ablauf „Corporate Design übernehmen“ (Prompt, §9.3): Claude liest das CD im Chat → `lit/divi-design-system` → schlägt Zuordnung vor → nach Bestätigung Schreiben über die Tools oben. Ablauf „Corporate Design übernehmen“ (Prompt, §9.3): der KI-Assistent liest das CD im Chat → `lit/divi-design-system` → schlägt Zuordnung vor → nach Bestätigung Schreiben über die Tools oben.
## 8. Rückmeldung, Fehler, Protokoll, Einstellungen ## 8. Rückmeldung, Fehler, Protokoll, Einstellungen
@@ -393,7 +394,7 @@ Rückgabe als `structuredContent` plus kurze Text-Zusammenfassung. Listen immer
- **Limits:** Upload-Größe, Protokoll-Aufbewahrung, Anzahl Snapshots. - **Limits:** Upload-Größe, Protokoll-Aufbewahrung, Anzahl Snapshots.
- Umsetzung mit Settings API, ohne JS-Framework. - Umsetzung mit Settings API, ohne JS-Framework.
## 9. Anleitungen für Claude ## 9. Anleitungen für den KI-Assistenten
### 9.1 Server-Instructions ### 9.1 Server-Instructions
@@ -401,15 +402,17 @@ Dynamisch pro Seite erzeugt (Server-Beschreibung → `instructions`), ca. 15 Zei
- aktive Integrationen und Post-Typen, - aktive Integrationen und Post-Typen,
- Kernregeln: vor dem Schreiben `lit/divi-outline` und `lit/divi-design-system` lesen; Variablen/Presets vor festen Werten; native Module vor Code-Modul; Status nie ändern ohne ausdrücklichen Wunsch; immer `rev` (bei Divi-Änderungen `tree_rev`) mitgeben, - Kernregeln: vor dem Schreiben `lit/divi-outline` und `lit/divi-design-system` lesen; Variablen/Presets vor festen Werten; native Module vor Code-Modul; Status nie ändern ohne ausdrücklichen Wunsch; immer `rev` (bei Divi-Änderungen `tree_rev`) mitgeben,
- Verweis auf `lit/guide`. - Verweis auf `lit/guide`.
- Etappe 3a: Zeile „Divi-Bibliothek: …“ (globale Module im Bibliothekseintrag ändern, Probelauf und `confirm: true`, Guide `divi-library`), nur wenn die Gruppe „Divi-Bibliothek“ aktiv und beschreibbar ist.
### 9.2 `lit/guide(topic)` ### 9.2 `lit/guide(topic)`
Direktes Tool; Anleitungen als Markdown in `docs/guides/`, mit dem Plugin versioniert, per Filter `lit_mcp_guides` erweiterbar. Direktes Tool; Anleitungen als Markdown in `docs/guides/`, mit dem Plugin versioniert, per Filter `lit_mcp_guides` erweiterbar.
Themen: `overview`, `divi-structure`, `divi-shortform`, `divi-attrs`, `divi-placeholders`, `divi-design-system`, `theme-builder`, `content-types`, `workflows`. Themen: `overview`, `divi-structure`, `divi-shortform`, `divi-attrs`, `divi-placeholders`, `divi-design-system`, `divi-library`, `theme-builder`, `content-types`, `media`, `workflows`. Thema `all` liefert alle Themen in einem Aufruf (Etappe 3a).
### 9.3 MCP-Prompts ### 9.3 MCP-Prompts
`corporate-design-uebernehmen`, `neue-website-aufsetzen`, `seite-pruefen`, `globale-module-vorschlagen`, `custom-post-type-vorlage`. `corporate-design-uebernehmen`, `neue-website-aufsetzen`, `seite-pruefen`, `globale-module-vorschlagen`, `custom-post-type-vorlage`.
(Stand Etappe 3a: noch keine MCP-Prompts im Plugin; `globale-module-vorschlagen` steht als Ablauf im Guide `divi-library`.)
## 10. Sicherheit ## 10. Sicherheit
@@ -462,7 +465,7 @@ Referenz: https://modelcontextprotocol.io/specification
|---|---|---| |---|---|---|
| **0 – Anbindung testen** | Plugin-Gerüst, Composer, mcp-oauth + Adapter, Rollen-Gate, App-Password-Scope-Filter, eigener Server mit direkten Tools, `lit/guide` (overview), ein Test-Tool; claude.ai-Connector über `ddev share`/Staging; MCP Inspector; Test: The Events Calendar-Eintrag generisch anlegen | Entscheidung: mcp-oauth bleibt oder Wechsel zu mcp-connect; Events-Erweiterungspunkt nötig ja/nein | | **0 – Anbindung testen** | Plugin-Gerüst, Composer, mcp-oauth + Adapter, Rollen-Gate, App-Password-Scope-Filter, eigener Server mit direkten Tools, `lit/guide` (overview), ein Test-Tool; claude.ai-Connector über `ddev share`/Staging; MCP Inspector; Test: The Events Calendar-Eintrag generisch anlegen | Entscheidung: mcp-oauth bleibt oder Wechsel zu mcp-connect; Events-Erweiterungspunkt nötig ja/nein |
| **1 – WordPress-Basis** | Schema-Erkennung inkl. Pods, Content-Tools, Status/Kopie/Austausch, Revisionen, Medien, Menüs, WP-Einstellungen, `ToolResult`, Protokoll, Einstellungsseite | Seiten/Beiträge/Pods-Einträge pflegen, Medien verwalten, Websites mit Struktur und Menü aufsetzen | | **1 – WordPress-Basis** | Schema-Erkennung inkl. Pods, Content-Tools, Status/Kopie/Austausch, Revisionen, Medien, Menüs, WP-Einstellungen, `ToolResult`, Protokoll, Einstellungsseite | Seiten/Beiträge/Pods-Einträge pflegen, Medien verwalten, Websites mit Struktur und Menü aufsetzen |
| **2 – Divi-Seiten** | BlockTree, Katalog, Kurz-/Vollform, Platzhalter, Outline/Insert/Update/Move/Delete/Duplicate/Replace, Warnungen, `page-render-summary`, Divi-Guides (umgesetzt in 2a: lesen, 2b: schreiben) | Divi-Seiten mit Claude bauen und pflegen | | **2 – Divi-Seiten** | BlockTree, Katalog, Kurz-/Vollform, Platzhalter, Outline/Insert/Update/Move/Delete/Duplicate/Replace, Warnungen, `page-render-summary`, Divi-Guides (umgesetzt in 2a: lesen, 2b: schreiben) | Divi-Seiten mit dem KI-Assistenten bauen und pflegen |
| **3 – Library & Theme Builder** | Library-Tools, Node-Copy, Make-Global, Find-Similar, Theme-Builder-Tools, Dynamic Content | CPT-Vorlagen bauen, globale Module einführen | | **3 – Library & Theme Builder** | Library-Tools, Node-Copy, Make-Global, Find-Similar, Theme-Builder-Tools, Dynamic Content | CPT-Vorlagen bauen, globale Module einführen |
| **4 – Designsystem** | Farben, Variablen, Presets, Theme-Optionen inkl. SEO, Snapshots, Prompts | Corporate Design in einem Rutsch übernehmen | | **4 – Designsystem** | Farben, Variablen, Presets, Theme-Optionen inkl. SEO, Snapshots, Prompts | Corporate Design in einem Rutsch übernehmen |
| **U – Auslieferung & Updates** (nach Etappe 1) | **OFFEN – bewusst noch nicht spezifiziert.** Bekannt: Build mit `composer install --no-dev` (~3,3 MB vendor), Updates nur anzeigen (manueller Klick), `plugin-update-checker`; Hosting (Gitea / GitHub / eigene Subdomain mit `update.json`) und Signaturprüfung werden später entschieden | – | | **U – Auslieferung & Updates** (nach Etappe 1) | **OFFEN – bewusst noch nicht spezifiziert.** Bekannt: Build mit `composer install --no-dev` (~3,3 MB vendor), Updates nur anzeigen (manueller Klick), `plugin-update-checker`; Hosting (Gitea / GitHub / eigene Subdomain mit `update.json`) und Signaturprüfung werden später entschieden | – |
+4
View File
@@ -3,6 +3,10 @@
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="tests/bootstrap.php" bootstrap="tests/bootstrap.php"
colors="true"> colors="true">
<php>
<!-- 1597+ tests with Mockery/Brain Monkey need ~180 MB; the CLI default is 128 MB. -->
<ini name="memory_limit" value="512M"/>
</php>
<testsuites> <testsuites>
<testsuite name="unit"> <testsuite name="unit">
<directory>tests/Unit</directory> <directory>tests/Unit</directory>
+51 -13
View File
@@ -1,20 +1,20 @@
=== WP-LIT-MCP === === WP-LIT-MCP ===
Contributors: loosenit Contributors: loosenit
Tags: mcp, claude, divi, pods, ai Tags: mcp, ai, divi, pods, oauth
Requires at least: 6.9 Requires at least: 6.9
Tested up to: 7.1 Tested up to: 7.1
Requires PHP: 8.1 Requires PHP: 8.1
Stable tag: 0.3.0 Stable tag: 0.4.0
License: GPL-2.0-or-later License: GPL-2.0-or-later
License URI: https://www.gnu.org/licenses/gpl-2.0.html License URI: https://www.gnu.org/licenses/gpl-2.0.html
MCP-Server für WordPress, Divi 5 und Pods – mit OAuth für claude.ai. MCP-Server für WordPress, Divi 5 und Pods – mit OAuth für KI-Assistenten.
== Description == == Description ==
WP-LIT-MCP stellt einen MCP-Server bereit, über den Claude (claude.ai-Connector) diese Website pflegt: WP-LIT-MCP stellt einen MCP-Server bereit, über den ein KI-Assistent (ein MCP-fähiger Client mit OAuth) diese
Inhalte aller Inhaltstypen inkl. Pods-Feldern, Status, Kopien, Revisionen, Menüs, freigegebene Website pflegt: Inhalte aller Inhaltstypen inkl. Pods-Feldern, Status, Kopien, Revisionen, Menüs, freigegebene
WordPress-Einstellungen und die Mediathek. Jede Änderung läuft mit den Rechten des verbundenen WordPress-Einstellungen, die Mediathek sowie Divi-Seiten und die Divi-Bibliothek (auch globale Module). Jede Änderung läuft mit den Rechten des verbundenen
WordPress-Benutzers und wird protokolliert. WordPress-Benutzers und wird protokolliert.
Einstellungen: Einstellungen → LIT MCP (Verbindung, Zugriff, Tools, Protokoll, Limits). Einstellungen: Einstellungen → LIT MCP (Verbindung, Zugriff, Tools, Protokoll, Limits).
@@ -29,7 +29,7 @@ Einstellungen: Einstellungen → LIT MCP (Verbindung, Zugriff, Tools, Protokoll,
* mcp-oauth registriert Rewrite-Regeln für /oauth/… und /.well-known/oauth-… sowie einen * mcp-oauth registriert Rewrite-Regeln für /oauth/… und /.well-known/oauth-… sowie einen
Website-Zustand-Test. Der Webserver muss /.well-known/ an WordPress durchreichen (bei nginx-Regeln, Website-Zustand-Test. Der Webserver muss /.well-known/ an WordPress durchreichen (bei nginx-Regeln,
die Punkt-Pfade sperren, eine Ausnahme für /.well-known/ anlegen). die Punkt-Pfade sperren, eine Ausnahme für /.well-known/ anlegen).
* Jede Verbindung ist ein Anwendungspasswort des Benutzers (Name = Client, z. B. „Claude“). Es ist im * Jede Verbindung ist ein Anwendungspasswort des Benutzers (Name = Client, z. B. der Name des Assistenten). Es ist im
Benutzerprofil sichtbar und unter Einstellungen → LIT MCP → Zugriff widerrufbar. Benutzerprofil sichtbar und unter Einstellungen → LIT MCP → Zugriff widerrufbar.
* Die Capability lit_mcp_use wird zur Laufzeit an die freigegebenen Rollen vergeben; Rollen in der * Die Capability lit_mcp_use wird zur Laufzeit an die freigegebenen Rollen vergeben; Rollen in der
Datenbank werden nicht verändert. Nicht freigegebene Benutzer können keine Verbindung herstellen: die Datenbank werden nicht verändert. Nicht freigegebene Benutzer können keine Verbindung herstellen: die
@@ -46,9 +46,11 @@ Einstellungen: Einstellungen → LIT MCP (Verbindung, Zugriff, Tools, Protokoll,
unfiltered_html ohne die kses-Filterung von WordPress, weil diese sonst jeden unveränderten Divi-Block neu 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, 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. 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 Inhalte des Code-Moduls bleiben nur mit unfiltered_html ungefiltert; eigenes HTML, CSS, HTML-Attribute,
von Modulen schreibt LIT MCP nur für Benutzer mit unfiltered_html. Divis eigene Speicherfilter bleiben Interaktionen und die Lottie-Quelle von Modulen schreibt LIT MCP nur für Benutzer mit unfiltered_html.
aktiv. Beim Kopieren, Einfügen und Speichern gespeicherter Knoten (Divi-Bibliothek, lit/divi-node-copy) lehnt
LIT MCP für Benutzer ohne unfiltered_html alles ab, was sie nicht selbst schreiben dürften – strenger als
Divi, mit sichtbarer Fehlermeldung. Divis eigene Speicherfilter bleiben aktiv.
* Das Feld content von lit-content-create und lit-content-update nimmt kein Divi-Markup an (Divi-Blöcke * Das Feld content von lit-content-create und lit-content-update nimmt kein Divi-Markup an (Divi-Blöcke
<!-- wp:divi/… und Divi-4-Shortcodes [et_pb_…]); Divi-Inhalte entstehen nur über divi bzw. die <!-- wp:divi/… und Divi-4-Shortcodes [et_pb_…]); Divi-Inhalte entstehen nur über divi bzw. die
lit-divi-*-Tools und damit über dieselbe Bereinigung. Anderes Block-Markup bleibt erlaubt und läuft wie lit-divi-*-Tools und damit über dieselbe Bereinigung. Anderes Block-Markup bleibt erlaubt und läuft wie
@@ -64,6 +66,19 @@ Einstellungen: Einstellungen → LIT MCP (Verbindung, Zugriff, Tools, Protokoll,
Seiten betrifft das praktisch nie (die Antwort warnt dann, dass der Inhalt beim Seiten betrifft das praktisch nie (die Antwort warnt dann, dass der Inhalt beim
Speichern verändert wurde). Speichern verändert wurde).
* Nach jeder Divi-Änderung wird der statische CSS-Cache von Divi für die Seite geleert. * Nach jeder Divi-Änderung wird der statische CSS-Cache von Divi für die Seite geleert.
* Divi-Bibliothek: LIT MCP legt Bibliothekseinträge selbst an (Post-Typ et_pb_layout, Status publish, mit
Divis Begriffen scope, layout_type, module_width und dessen Meta-Feldern) und benutzt keine Builder-Routen.
Eine Änderung an einem globalen Bibliothekseintrag wirkt sofort auf allen Seiten, die ihn zeigen; LIT MCP
verlangt dafür einen Probelauf mit Liste der betroffenen Seiten und confirm, legt vorher eine Revision (bei
abgeschalteten Revisionen eine Sicherungskopie im Papierkorb) an und leert danach Divis CSS-Cache aller Seiten
und den Theme-Builder-Cache. Die Bestätigung wird abgelehnt, wenn der Benutzer einen veröffentlichten,
privaten oder geplanten Eintrag, der das Modul zeigt, nicht bearbeiten darf.
* Divis Rollen-Editor gilt zusätzlich zu den WordPress-Rechten: ohne „Divi-Bibliothek“, „In der Bibliothek
speichern“, „Aus der Bibliothek hinzufügen“ bzw. „Globale Bibliothekselemente bearbeiten“ lehnt LIT MCP ab.
* Interne Links über {{link:<ID>}} nutzen Divis eigenen dynamischen Inhalt „<Typ> Link“: die Adresse wird beim
Anzeigen ermittelt. Für Entwürfe und private Ziele zeigt Divi Besuchern nur ?page_id=<ID> (ohne Titel).
* lit-site-status prüft, ob die von LIT MCP genutzten Divi-Funktionen vorhanden sind, und nennt nach einem
Divi-Update fehlende unter problems.
* Pods-Felder: Die Feldrechte von Pods gelten auch über LIT MCP. „Verborgen“ und für den Benutzer gesperrte * Pods-Felder: Die Feldrechte von Pods gelten auch über LIT MCP. „Verborgen“ und für den Benutzer gesperrte
Felder (nur Admins, Rollen- oder Capability-Beschränkung) werden weder angezeigt noch geschrieben; Felder (nur Admins, Rollen- oder Capability-Beschränkung) werden weder angezeigt noch geschrieben;
„schreibgeschützt“ (bzw. gesperrt mit „schreibgeschützt statt verborgen“) sind nur lesbar. „schreibgeschützt“ (bzw. gesperrt mit „schreibgeschützt statt verborgen“) sind nur lesbar.
@@ -92,6 +107,9 @@ Es gibt keine allgemeine "Rückgängig"-Funktion; was sich wie zurücksetzen lä
lit/revision-restore). Felder und Begriffe sind in Revisionen nicht enthalten. 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 * 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. lit/revision-restore. Beim seitenübergreifenden Ersetzen erhält jede geänderte Seite eine eigene Revision.
* Bibliothekseinträge: jede Änderung legt vorher eine Revision an (revision_id), bei abgeschalteten Revisionen
eine Sicherungskopie im Papierkorb (backup_id); zurück über WordPress (Eintrag der Divi-Bibliothek
bearbeiten → Revisionen). Neu angelegte Einträge (lit/library-save) in der Divi-Bibliothek löschen.
* Original durch eine Kopie ersetzen (lit/post-replace-with-copy): vorher legt das Werkzeug eine 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) – und eine vollständige Sicherung des bisherigen Originals im Papierkorb an (backup_id in der Antwort) –
von dort in WordPress wiederherstellbar. von dort in WordPress wiederherstellbar.
@@ -115,7 +133,7 @@ Es gibt keine allgemeine "Rückgängig"-Funktion; was sich wie zurücksetzen lä
MCP-Endpunkt und die OAuth-Adressen sind danach nicht mehr erreichbar. Einstellungen und Protokoll MCP-Endpunkt und die OAuth-Adressen sind danach nicht mehr erreichbar. Einstellungen und Protokoll
bleiben erhalten. Verbindungen werden dabei nicht widerrufen: Wird das Plugin wieder aktiviert, gelten bleiben erhalten. Verbindungen werden dabei nicht widerrufen: Wird das Plugin wieder aktiviert, gelten
sie weiter (Zugriffstoken eine Stunde, Refresh-Token 30 Tage, solange das Anwendungspasswort besteht). sie weiter (Zugriffstoken eine Stunde, Refresh-Token 30 Tage, solange das Anwendungspasswort besteht).
Wer Claude sicher trennen will, widerruft die Verbindungen vorher unter Zugriff. Wer den Assistenten sicher trennen will, widerruft die Verbindungen vorher unter Zugriff.
* Löschen entfernt zusätzlich alle eigenen Optionen und die Protokolltabelle (auf Multisite je Website) * Löschen entfernt zusätzlich alle eigenen Optionen und die Protokolltabelle (auf Multisite je Website)
sowie die Ansichtsoption „Einträge pro Seite“ des Protokolls bei allen Benutzern. sowie die Ansichtsoption „Einträge pro Seite“ des Protokolls bei allen Benutzern.
* Nicht entfernt werden die Daten von mcp-oauth (Option mcp_jwt_secret, Option * Nicht entfernt werden die Daten von mcp-oauth (Option mcp_jwt_secret, Option
@@ -138,13 +156,33 @@ abgeschaltet.
Ja, unter Einstellungen → LIT MCP → Tools einzeln oder je Gruppe; „nur lesen“ schaltet alle schreibenden Ja, unter Einstellungen → LIT MCP → Tools einzeln oder je Gruppe; „nur lesen“ schaltet alle schreibenden
Tools einer Gruppe auf einmal ab. Tools einer Gruppe auf einmal ab.
= Bleiben mit Claude bearbeitete Divi-Seiten im Visual Builder bearbeitbar? = = Bleiben vom Assistenten 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 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. Designsystem. Nach einer Änderung im Builder liest der Assistent die Seite neu, bevor er weiter ändert.
= Was passiert, wenn der Assistent ein globales Modul ändert? =
Die Änderung wirkt auf allen Seiten mit diesem Modul. LIT MCP speichert erst nach einem Probelauf, der die
betroffenen Seiten nennt, und einer ausdrücklichen Bestätigung (confirm); vorher entsteht eine Revision des
Bibliothekseintrags.
== Changelog == == Changelog ==
= 0.4.0 =
* Divi-Bibliothek: Einträge auflisten und lesen (lit/library-list), Knoten als normalen oder globalen Eintrag
speichern (lit/library-save), als Kopie oder globale Instanz einfügen (lit-library-insert, direkt).
* Globale Module: Bibliothekseinträge mit den Divi-Werkzeugen bearbeiten; bei globalen Einträgen Probelauf
mit betroffenen Seiten, confirm, Revision bzw. Sicherung und seitenweite Cache-Leerung.
* Knoten zwischen Seiten und Bibliothekseinträgen kopieren (lit/divi-node-copy).
* Interne Links mit {{link:<ID>}}; lit/divi-dynamic-sources listet Divis dynamische Quellen und geeignete Felder.
* lit-guide mit dem Thema „all“; lit-divi-design-system mit Filtern (only, module); lit-divi-node-get und
lit-divi-outline nennen die Bibliotheks-ID globaler Module; lit-page-render-summary weist auf globale Module
hin; lit-site-status prüft die genutzten Divi-Funktionen.
* Tools-Gruppe „Divi-Bibliothek“; „nur lesen“ sperrt auch Schreibzugriffe der Divi-Werkzeuge auf die Bibliothek.
* Sicherheit: Interaktionen und die Lottie-Quelle von Modulen nur mit unfiltered_html; gespeicherte Knoten
werden beim Kopieren, Einfügen und Speichern für Benutzer ohne unfiltered_html geprüft.
= 0.3.0 = = 0.3.0 =
* Divi-Seiten: Seitenbaum mit Pfad-IDs und tree_rev, Knoten in Voll- und Kurzform, Modulkatalog, * 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 Designsystem (Farben, Variablen, Schriften, Presets), gerenderte Zusammenfassung; Abschnitte, Zeilen und
+15
View File
@@ -181,6 +181,21 @@ final class ExposurePolicy
return isset($this->readonlyGroups[$group]); return isset($this->readonlyGroups[$group]);
} }
/** Whether abilities of $group may write: the group is neither "nur lesen" nor disabled as a whole. */
public function groupWritable(string $group): bool
{
return ! $this->isReadonly($group) && $this->groupMode($group) !== self::DISABLED;
}
/**
* Whether $group is switched on at all: not disabled as a whole ("nur lesen" still reads). The
* group "Divi-Bibliothek" disabled hides library entries from the Divi read tools (ruling P9).
*/
public function groupEnabled(string $group): bool
{
return $this->groupMode($group) !== self::DISABLED;
}
public function defaultMode(Ability $ability): string public function defaultMode(Ability $ability): string
{ {
return $ability->isDirect() ? self::DIRECT : self::INDIRECT; return $ability->isDirect() ? self::DIRECT : self::INDIRECT;
+23 -1
View File
@@ -32,7 +32,7 @@ final class WriteFailed extends RuntimeException
public readonly ?int $postId, public readonly ?int $postId,
public readonly ?int $revisionId, public readonly ?int $revisionId,
?Throwable $previous, ?Throwable $previous,
string $detail, private string $detail,
public readonly ?string $state, public readonly ?string $state,
public readonly ?string $hint, public readonly ?string $hint,
public readonly array $details public readonly array $details
@@ -82,6 +82,28 @@ final class WriteFailed extends RuntimeException
return new self($reason, false, $postId, $revisionId, $cause, $detail, $state, $hint, $details); return new self($reason, false, $postId, $revisionId, $cause, $detail, $state, $hint, $details);
} }
/**
* The same failure with $details merged into its client details; a non-null $hint or $state
* replaces the current one (see rolledBack()/partial()). For callers that learn about
* something kept (e.g. a backup) only after the failure was thrown.
*
* @param array<string, mixed> $details
*/
public function withDetails(array $details, ?string $hint = null, ?string $state = null): self
{
return new self(
$this->reason,
$this->rolledBack,
$this->postId,
$this->revisionId,
$this->getPrevious(),
$this->detail,
$state ?? $this->state,
$hint ?? $this->hint,
array_merge($this->details, $details)
);
}
public function toError(): WP_Error public function toError(): WP_Error
{ {
return $this->rolledBack return $this->rolledBack
+13 -1
View File
@@ -16,6 +16,9 @@ defined('ABSPATH') || exit;
*/ */
final class InstructionsBuilder final class InstructionsBuilder
{ {
/** Tool group of the Divi Library (LibraryAccess::GROUP); its rule needs a writable group. */
private const LIBRARY_GROUP = 'divi-library';
/** @var (callable(): list<string>)|null */ /** @var (callable(): list<string>)|null */
private $postTypeNames; private $postTypeNames;
@@ -63,6 +66,15 @@ final class InstructionsBuilder
] ]
: []; : [];
$library = str_contains(', ' . $active . ',', ', ' . self::LIBRARY_GROUP . ',')
&& $this->policy->groupWritable(self::LIBRARY_GROUP)
? [
'- Divi-Bibliothek: globale Module (lit-divi-outline zeigt [global <ID>]) im Bibliothekseintrag '
. 'ändern; das wirkt auf alle Seiten und braucht nach dem Probelauf confirm: true '
. '(lit-guide topic "divi-library").',
]
: [];
return implode("\n", array_merge($lines, [ return implode("\n", array_merge($lines, [
'Regeln:', 'Regeln:',
'- Vor dem ersten Arbeitsschritt lit-guide (ohne Topic) aufrufen; Details je Thema über lit-guide ' '- Vor dem ersten Arbeitsschritt lit-guide (ohne Topic) aufrufen; Details je Thema über lit-guide '
@@ -74,6 +86,6 @@ final class InstructionsBuilder
'- Beim Bearbeiten immer den rev-Wert aus dem letzten Lesevorgang mitgeben (Pflicht).', '- Beim Bearbeiten immer den rev-Wert aus dem letzten Lesevorgang mitgeben (Pflicht).',
'- Löschen und Ersetzen erst nach Rückfrage beim Nutzer (confirm: true).', '- Löschen und Ersetzen erst nach Rückfrage beim Nutzer (confirm: true).',
'- Fehler enthalten einen hint – ihm folgen, statt blind zu wiederholen.', '- Fehler enthalten einen hint – ihm folgen, statt blind zu wiederholen.',
], $divi)); ], $divi, $library));
} }
} }
+17 -3
View File
@@ -4,14 +4,21 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Core; namespace LIT\MCP\Integrations\Core;
use LIT\MCP\Integrations\Divi\DiviCompatibility;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/** /**
* Detects the themes/plugins LIT MCP integrates with. * Detects the themes/plugins LIT MCP integrates with; for Divi also the internals that are missing
* (DiviCompatibility).
*/ */
class EnvironmentProbe class EnvironmentProbe
{ {
/** @return list<array{id: string, active: bool, version: ?string}> */ public function __construct(private ?DiviCompatibility $divi = null)
{
}
/** @return list<array{id: string, active: bool, version: ?string, problems: list<string>}> */
public function components(): array public function components(): array
{ {
$theme = wp_get_theme(); $theme = wp_get_theme();
@@ -22,16 +29,23 @@ class EnvironmentProbe
$tecActive = class_exists('Tribe__Events__Main'); $tecActive = class_exists('Tribe__Events__Main');
return [ return [
['id' => 'divi', 'active' => $isDivi, 'version' => $diviVersion], [
'id' => 'divi',
'active' => $isDivi,
'version' => $diviVersion,
'problems' => $isDivi ? ($this->divi ?? new DiviCompatibility())->problems() : [],
],
[ [
'id' => 'pods', 'id' => 'pods',
'active' => defined('PODS_VERSION'), 'active' => defined('PODS_VERSION'),
'version' => defined('PODS_VERSION') ? (string) PODS_VERSION : null, 'version' => defined('PODS_VERSION') ? (string) PODS_VERSION : null,
'problems' => [],
], ],
[ [
'id' => 'the-events-calendar', 'id' => 'the-events-calendar',
'active' => $tecActive, 'active' => $tecActive,
'version' => $tecActive ? (string) constant('Tribe__Events__Main::VERSION') : null, 'version' => $tecActive ? (string) constant('Tribe__Events__Main::VERSION') : null,
'problems' => [],
], ],
]; ];
} }
+53 -2
View File
@@ -16,6 +16,12 @@ final class GuideAbility implements Ability
{ {
private const DEFAULT_TOPIC = 'overview'; private const DEFAULT_TOPIC = 'overview';
/** topic = all: every topic in one call (overview first). */
public const ALL = 'all';
/** Upper bound of the combined text of topic = all (bytes). */
public const ALL_LIMIT = 100000;
public function __construct(private GuideRepository $guides) public function __construct(private GuideRepository $guides)
{ {
} }
@@ -32,7 +38,8 @@ final class GuideAbility implements Ability
public function description(): string public function description(): string
{ {
return 'Liefert Anleitungen zur Bedienung dieses MCP-Servers. Ohne topic: Übersicht und Liste aller Themen.'; return 'Liefert Anleitungen zur Bedienung dieses MCP-Servers. Ohne topic: Übersicht und Liste aller Themen; '
. 'topic = all: alle Themen in einem Aufruf (gut für den Einstieg).';
} }
public function inputSchema(): array public function inputSchema(): array
@@ -42,7 +49,7 @@ final class GuideAbility implements Ability
'properties' => [ 'properties' => [
'topic' => [ 'topic' => [
'type' => 'string', 'type' => 'string',
'description' => 'Thema, z. B. "overview" oder "divi-shortform".', 'description' => 'Thema, z. B. "overview" oder "divi-shortform"; "all" = alle Themen.',
], ],
], ],
'additionalProperties' => false, 'additionalProperties' => false,
@@ -83,6 +90,10 @@ final class GuideAbility implements Ability
? $input['topic'] ? $input['topic']
: self::DEFAULT_TOPIC; : self::DEFAULT_TOPIC;
$topics = $this->guides->topics(); $topics = $this->guides->topics();
if ($topic === self::ALL) {
return ['topic' => self::ALL, 'topics' => $topics, 'content' => $this->all($topics)];
}
$content = $this->guides->get($topic); $content = $this->guides->get($topic);
if ($content === null) { if ($content === null) {
@@ -94,4 +105,44 @@ final class GuideAbility implements Ability
return ['topic' => $topic, 'topics' => $topics, 'content' => $content]; return ['topic' => $topic, 'topics' => $topics, 'content' => $content];
} }
/**
* Every topic, overview first, separated and labelled; stops before ALL_LIMIT bytes and names
* the topics left out.
*
* @param list<string> $topics
*/
private function all(array $topics): string
{
$ordered = in_array(self::DEFAULT_TOPIC, $topics, true)
? array_merge([self::DEFAULT_TOPIC], array_values(array_diff($topics, [self::DEFAULT_TOPIC])))
: $topics;
$parts = [];
$size = 0;
$left = [];
foreach ($ordered as $topic) {
$content = $this->guides->get($topic);
if ($content === null) {
continue;
}
$part = sprintf("---\nThema `%s`:\n\n%s", $topic, rtrim($content));
if ($size + strlen($part) > self::ALL_LIMIT) {
$left[] = $topic;
continue;
}
$parts[] = $part;
$size += strlen($part) + 2;
}
if ($left !== []) {
$parts[] = sprintf(
'Nicht enthalten (Größenlimit): %s – einzeln mit topic abrufen.',
implode(', ', $left)
);
}
return implode("\n\n", $parts);
}
} }
+2 -1
View File
@@ -30,7 +30,8 @@ final class SiteStatusAbility implements Ability
public function description(): string public function description(): string
{ {
return 'Überblick: Website, WordPress-Version, eigener Benutzer und Rollen, ' return 'Überblick: Website, WordPress-Version, eigener Benutzer und Rollen, '
. 'erkannte Integrationen (Divi, Pods, The Events Calendar).'; . 'erkannte Integrationen (Divi, Pods, The Events Calendar). '
. 'Bei Divi nennt problems Divi-Funktionen, die nach einem Update fehlen.';
} }
public function inputSchema(): array public function inputSchema(): array
+24
View File
@@ -141,6 +141,30 @@ final class Block extends Container
return clone $this; return clone $this;
} }
/**
* A deep copy of this block under another name and attributes: opener and closer are
* regenerated, children and the text between them are kept, the void flag too. Opaque exactly
* when the Parser would make it so. GlobalInstance uses it to turn a library root into an
* instance and back.
*
* @param array<string, mixed> $attrs
*/
public function copyAs(string $name, array $attrs): self
{
if (preg_match(self::NAME_PATTERN, $name) !== 1) {
throw new InvalidArgumentException(sprintf('"%s" is not a valid block name.', $name));
}
$copy = clone $this;
$copy->name = $name;
$copy->attrs = self::deepCloneAttrs($attrs);
$copy->rawOpen = null;
$copy->rawClose = null;
$copy->opaque = $name === 'divi/global-layout' || array_key_exists('globalModule', $attrs);
return $copy;
}
public function __clone() public function __clone()
{ {
foreach ($this->parts as $position => $part) { foreach ($this->parts as $position => $part) {
+30 -1
View File
@@ -93,12 +93,41 @@ abstract class Container
return $index; return $index;
} }
/** Puts $new where the child $old is; the text around it stays. */
public function replaceChild(Block $old, Block $new): void
{
$this->guardMutable();
$this->parts[$this->partPosition($old)] = $new;
$this->touched();
}
/**
* "\r\n" → "\n" in whitespace-only text between blocks, at every depth. Library entries saved
* through Divi's "add to library" form carry CRLF (browsers normalise form fields, fixture
* finding 9); the Visual Builder writes "\n". Text inside blocks' own content stays untouched,
* and so do opaque blocks (global instances keep Divi's bytes).
*/
public function unifyLineBreaks(): void
{
if ($this instanceof Block && $this->isOpaque()) {
return;
}
foreach ($this->parts as $position => $part) {
if ($part instanceof Block) {
$part->unifyLineBreaks();
} elseif (trim($part) === '' && str_contains($part, "\r\n")) {
$this->parts[$position] = str_replace("\r\n", "\n", $part);
}
}
}
/** Called after the list of children changed. */ /** Called after the list of children changed. */
protected function touched(): void protected function touched(): void
{ {
} }
/** insertChild()/removeChild() are the tools' structural edits – never on an opaque block. */ /** insertChild()/removeChild()/replaceChild() are the tools' structural edits – never on an opaque block. */
private function guardMutable(): void private function guardMutable(): void
{ {
if ($this instanceof Block && $this->isOpaque()) { if ($this instanceof Block && $this->isOpaque()) {
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\BlockTree;
use InvalidArgumentException;
defined('ABSPATH') || exit;
/**
* Global modules the way Divi 5 stores them (F6, F10, fixtures stage3): an instance on a page is a
* divi/global-layout block whose content Divi takes from the library entry at render time (F7).
* Instances stay opaque for the page tools; edits go to the library entry. Pure PHP.
*/
final class GlobalInstance
{
public const BLOCK = 'divi/global-layout';
/** Block name the node counts as for structure rules: an instance counts as its root type. */
public static function effectiveName(Block $block): string
{
if ($block->name() !== self::BLOCK) {
return $block->name();
}
$name = $block->attrs()['blockName'] ?? '';
return is_string($name) ? $name : '';
}
/**
* The instance the Visual Builder writes for library entry $libraryId whose content root is
* $root (serialized-post.js, filter divi.serializedPost.parseFlatModuleObjectIntoBlockArray.nested;
* fixture page--globale-instanzen.html): divi/global-layout with globalModule, blockName and
* localAttrs – the root's attributes, top-level keys sorted like the builder's store, left out
* when empty –, the root's children as snapshot with "globalParent": "<id>" appended to every
* block below. A void root gives a void instance. $root is not changed.
*/
public static function of(Block $root, int $libraryId): Block
{
$id = (string) $libraryId;
$local = $root->attrs();
ksort($local);
$attrs = ['globalModule' => $id, 'blockName' => $root->name()];
if ($local !== []) {
$attrs['localAttrs'] = $local;
}
$instance = $root->copyAs(self::BLOCK, $attrs);
foreach ($instance->children() as $child) {
self::markParent($child, $id);
}
return $instance;
}
/**
* Divi's PortabilityPost::maybe_deglobalize_nested_global_modules() on a tree (Divi 5.2.1
* PortabilityPost.php:3518, used by DiviLibraryController::load for global entries): every
* divi/global-layout at or below $block becomes its blockName with localAttrs as attributes,
* and globalParent disappears from every block. Other opaque blocks stay, and so does an instance
* without a usable blockName. Changes $block in place (pass a copy) and returns the result – a
* new block when $block itself was an instance.
*/
public static function deglobalize(Block $block): Block
{
if ($block->name() === self::BLOCK) {
$name = self::effectiveName($block);
if ($name === '') {
return $block;
}
$local = $block->attrs()['localAttrs'] ?? [];
try {
$block = $block->copyAs($name, is_array($local) ? $local : []);
} catch (InvalidArgumentException) {
return $block; // Stored blockName is no block name: the instance stays opaque.
}
}
if ($block->isOpaque()) {
return $block;
}
if (array_key_exists('globalParent', $block->attrs())) {
$attrs = $block->attrs();
unset($attrs['globalParent']);
$block->setAttrs($attrs);
}
foreach ($block->children() as $child) {
$plain = self::deglobalize($child);
if ($plain !== $child) {
$block->replaceChild($child, $plain);
}
}
return $block;
}
private static function markParent(Block $block, string $id): void
{
if ($block->isOpaque()) {
return;
}
$attrs = $block->attrs();
unset($attrs['globalParent']);
$attrs['globalParent'] = $id;
$block->setAttrs($attrs);
foreach ($block->children() as $child) {
self::markParent($child, $id);
}
}
}
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\BlockTree;
defined('ABSPATH') || exit;
/**
* post_content of Divi Library entries (F4, fixtures stage3): the nodes sit in a divi/placeholder
* frame; a section/row/module entry has exactly one root, a layout entry sections. Pure PHP.
*/
final class LibraryContent
{
public const WRAPPER = 'divi/placeholder';
/**
* Parses library content for copying and unifies its line breaks (Container::unifyLineBreaks()).
* Never used for the content a tool edits in place – that keeps its bytes.
*
* @throws MalformedMarkup
*/
public static function load(Parser $parser, string $content): Document
{
$document = $parser->parse($content);
$document->unifyLineBreaks();
return $document;
}
/** post_content of a library entry holding $node, framed like the builder's "add to library". */
public static function wrap(Block $node): string
{
return '<!-- wp:' . self::WRAPPER . ' -->' . Serializer::block($node) . '<!-- /wp:' . self::WRAPPER . ' -->';
}
/** @return list<Block> Top-level nodes: the children of the first divi/placeholder, else of the document. */
public static function roots(Document $document): array
{
foreach ($document->children() as $block) {
if ($block->isPlaceholder()) {
return $block->children();
}
}
return $document->children();
}
/** Whether $block is or contains a global module. */
public static function containsGlobal(Block $block): bool
{
if ($block->isGlobal()) {
return true;
}
foreach ($block->children() as $child) {
if (self::containsGlobal($child)) {
return true;
}
}
return false;
}
/**
* Why the roots $after may not replace $before, or null. Instances on pages expect their
* entry's root type (blockName), so a section/row/module entry keeps exactly one root of the
* same type; a layout entry (or one with several roots) holds sections only. A global entry
* contains no global modules (F10: Divi forbids nesting).
*
* @param list<Block> $before
* @param list<Block> $after
*/
public static function shapeViolation(array $before, array $after, ?string $layoutType, bool $global): ?string
{
if ($after === []) {
return 'Der Bibliothekseintrag darf nicht leer werden.';
}
if ($layoutType === 'layout' || count($before) !== 1) {
foreach ($after as $root) {
if (GlobalInstance::effectiveName($root) !== 'divi/section') {
return 'Ein Layout-Eintrag der Bibliothek enthält nur Abschnitte.';
}
}
} else {
$expected = GlobalInstance::effectiveName($before[0]);
if (count($after) !== 1 || GlobalInstance::effectiveName($after[0]) !== $expected) {
return sprintf(
'Der Bibliothekseintrag muss genau ein %s bleiben (Instanzen auf den Seiten erwarten diesen Typ).',
$expected
);
}
}
if ($global) {
foreach ($after as $root) {
if (self::containsGlobal($root)) {
return 'Ein globaler Bibliothekseintrag darf keine globalen Module enthalten (Divi erlaubt keine '
. 'Verschachtelung).';
}
}
}
return null;
}
}
@@ -25,7 +25,10 @@ final class NodeSummary
public static function label(Block $block): string public static function label(Block $block): string
{ {
$label = AttrMap::desktop($block->attrs(), 'module.meta.adminLabel'); $attrs = $block->name() === 'divi/global-layout' && is_array($block->attrs()['localAttrs'] ?? null)
? $block->attrs()['localAttrs']
: $block->attrs();
$label = AttrMap::desktop($attrs, 'module.meta.adminLabel');
if (! is_string($label) || $label === '') { if (! is_string($label) || $label === '') {
return ''; return '';
} }
@@ -55,6 +58,29 @@ final class NodeSummary
return ''; return '';
} }
/**
* Library entry of a global module: a divi/global-layout instance (its root type in blockName)
* or a block that carries globalModule itself (Divi 4 conversions). Null for every other block.
*
* @return array{id: string, block_name: string}|null
*/
public static function globalRef(Block $block): ?array
{
if (! $block->isGlobal()) {
return null;
}
$attrs = $block->attrs();
$id = $attrs['globalModule'] ?? null;
if (! is_string($id) && ! is_int($id)) {
return null;
}
$name = $block->name() === 'divi/global-layout' ? ($attrs['blockName'] ?? '') : $block->name();
return ['id' => (string) $id, 'block_name' => is_string($name) ? $name : ''];
}
/** @return list<string> */ /** @return list<string> */
public static function presetIds(Block $block): array public static function presetIds(Block $block): array
{ {
+14 -3
View File
@@ -59,7 +59,8 @@ final class Outline
/** /**
* @return list<array{id: string, type: string, label: string, excerpt: string, info: string, * @return list<array{id: string, type: string, label: string, excerpt: string, info: string,
* presets: list<string>, children: int, editable: bool, depth: int, truncated: bool}> * presets: list<string>, children: int, editable: bool, depth: int, truncated: bool,
* global_id: ?string}>
*/ */
public function rows(NodeIndex $index, ?string $rootId = null, ?int $maxDepth = null, ?int $limit = null): array public function rows(NodeIndex $index, ?string $rootId = null, ?int $maxDepth = null, ?int $limit = null): array
{ {
@@ -114,6 +115,7 @@ final class Outline
foreach ($rows as $row) { foreach ($rows as $row) {
$line = str_repeat(' ', (int) $row['depth']) . $row['id'] . ' ' . $row['type']; $line = str_repeat(' ', (int) $row['depth']) . $row['id'] . ' ' . $row['type'];
$line .= $row['label'] !== '' ? ' "' . $row['label'] . '"' : ''; $line .= $row['label'] !== '' ? ' "' . $row['label'] . '"' : '';
$line .= ($row['global_id'] ?? null) !== null ? ' [global ' . $row['global_id'] . ']' : '';
$line .= $row['info'] !== '' ? ' [' . $row['info'] . ']' : ''; $line .= $row['info'] !== '' ? ' [' . $row['info'] . ']' : '';
$line .= $row['presets'] !== [] ? ' {Preset: ' . implode(', ', $row['presets']) . '}' : ''; $line .= $row['presets'] !== [] ? ' {Preset: ' . implode(', ', $row['presets']) . '}' : '';
$line .= $row['excerpt'] !== '' ? ' – ' . $row['excerpt'] : ''; $line .= $row['excerpt'] !== '' ? ' – ' . $row['excerpt'] : '';
@@ -127,15 +129,23 @@ final class Outline
/** /**
* @return array{id: string, type: string, label: string, excerpt: string, info: string, * @return array{id: string, type: string, label: string, excerpt: string, info: string,
* presets: list<string>, children: int, editable: bool, depth: int, truncated: bool} * presets: list<string>, children: int, editable: bool, depth: int, truncated: bool,
* global_id: ?string}
*/ */
private function row(Block $block, string $id, int $depth): array private function row(Block $block, string $id, int $depth): array
{ {
$editable = $block->isDivi() && ! $block->isOpaque(); $editable = $block->isDivi() && ! $block->isOpaque();
$global = NodeSummary::globalRef($block);
$type = $block->isDivi() ? $block->shortName() : $block->name();
if ($global !== null && $global['block_name'] !== '') {
$type = str_starts_with($global['block_name'], 'divi/')
? substr($global['block_name'], 5)
: $global['block_name'];
}
return [ return [
'id' => $id, 'id' => $id,
'type' => $block->isDivi() ? $block->shortName() : $block->name(), 'type' => $type,
'label' => NodeSummary::label($block), 'label' => NodeSummary::label($block),
'excerpt' => NodeSummary::excerpt($block), 'excerpt' => NodeSummary::excerpt($block),
'info' => $editable ? NodeSummary::info($block) : '', 'info' => $editable ? NodeSummary::info($block) : '',
@@ -147,6 +157,7 @@ final class Outline
'editable' => $editable, 'editable' => $editable,
'depth' => $depth, 'depth' => $depth,
'truncated' => false, 'truncated' => false,
'global_id' => $global['id'] ?? null,
]; ];
} }
} }
+2 -1
View File
@@ -23,7 +23,8 @@ defined('ABSPATH') || exit;
*/ */
final class AttrWriter final class AttrWriter
{ {
private const STATES = ['value', 'hover', 'sticky']; /** State keys of a breakpoint entry (StoredNodeGuard uses them too). */
public const STATES = ['value', 'hover', 'sticky'];
/** /**
* Structure attributes (ruling S41), refused for everyone and whatever the catalog says: * Structure attributes (ruling S41), refused for everyone and whatever the catalog says:
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Compose;
defined('ABSPATH') || exit;
/**
* Entries {{link:<ID>}} may point to (E13a). Pure interface – WpLinkTargets asks WordPress.
*/
interface LinkTargets
{
/** Post type of entry $id when it may be linked (exists, not trashed, readable); null otherwise. */
public function postTypeOf(int $id): ?string;
}
+39 -7
View File
@@ -12,10 +12,10 @@ defined('ABSPATH') || exit;
/** /**
* Translates the placeholders of spec §5.4 into Divi's $variable(…)$ syntax: * Translates the placeholders of spec §5.4 into Divi's $variable(…)$ syntax:
* {{color:primary|<Name>|gcid-…}}, {{var:<Name>|gvid-…}}, {{font:heading|body}}, * {{color:primary|<Name>|gcid-…}}, {{var:<Name>|gvid-…}}, {{font:heading|body}},
* {{post:title|…}}, {{field:<meta_key>}}. A placeholder must be the whole value (D9); unknown * {{post:title|…}}, {{field:<meta_key>}}, {{link:<ID>}} (internal link, URL fields only – E13a).
* names fail with the list of existing ones, labels two entries share fail with their IDs * A placeholder must be the whole value (D9); unknown names fail with the list of existing ones,
* (ruling S13a, DesignSystem::colorIdFor/variableFor). The result is still checked by VariableRef * labels two entries share fail with their IDs (ruling S13a, DesignSystem::colorIdFor/variableFor).
* wherever it is written. Pure PHP. * The result is still checked by VariableRef wherever it is written. Pure PHP.
*/ */
final class Placeholders final class Placeholders
{ {
@@ -37,9 +37,10 @@ final class Placeholders
'post_title', 'post_excerpt', 'post_date', 'post_modified_date', 'post_author', 'post_categories', 'post_tags', 'post_title', 'post_excerpt', 'post_date', 'post_modified_date', 'post_author', 'post_categories', 'post_tags',
]; ];
private const PATTERN = '/^\{\{\s*(color|var|font|post|field)\s*:\s*([^{}]+?)\s*\}\}$/'; private const PATTERN = '/^\{\{\s*(color|var|font|post|field|link)\s*:\s*([^{}]+?)\s*\}\}$/';
public function __construct(private DesignSystem $design) /** @param LinkTargets|null $links Entries {{link:…}} may point to; without them every link fails. */
public function __construct(private DesignSystem $design, private ?LinkTargets $links = null)
{ {
} }
@@ -54,7 +55,7 @@ final class Placeholders
if (str_contains($value, '{{')) { if (str_contains($value, '{{')) {
throw new InvalidArgumentException( throw new InvalidArgumentException(
'Platzhalter müssen allein im Wert stehen, z. B. "{{color:primary}}" (bekannt: color, var, font, ' 'Platzhalter müssen allein im Wert stehen, z. B. "{{color:primary}}" (bekannt: color, var, font, '
. 'post, field).' . 'post, field, link).'
); );
} }
@@ -68,6 +69,7 @@ final class Placeholders
'var' => $this->variable($name), 'var' => $this->variable($name),
'font' => $this->font($name), 'font' => $this->font($name),
'post' => $this->post($name), 'post' => $this->post($name),
'link' => $this->link($name),
default => $this->field($name), default => $this->field($name),
}; };
} }
@@ -152,6 +154,36 @@ final class Placeholders
return VariableRef::serialize('content', $option, $settings); return VariableRef::serialize('content', $option, $settings);
} }
/**
* {{link:<ID>}} → Divi's own internal link post_link_url_<type> with settings.post_id (F27):
* rendered as get_permalink() at view time, so a draft target gets its pretty URL once published.
* Whether Divi offers that option (public post types only) VariableRef checks against the registry;
* that the value goes into a URL field and the target is readable (S37) the choke point checks again.
*
* @throws InvalidArgumentException
*/
private function link(string $name): string
{
// FILTER_VALIDATE_INT: no id beyond PHP_INT_MAX that would saturate to another entry.
$id = preg_match('/^[1-9][0-9]{0,18}$/D', $name) === 1 ? filter_var($name, FILTER_VALIDATE_INT) : false;
if (! is_int($id)) {
throw new InvalidArgumentException(sprintf(
'Ungültige Beitrags-ID "%s" in {{link:…}} (positive Zahl erwartet, z. B. {{link:42}}).',
$name
));
}
$type = $this->links?->postTypeOf($id);
if ($type === null) {
throw new InvalidArgumentException(sprintf(
'Eintrag %s für {{link:…}} gibt es nicht, er liegt im Papierkorb oder ist nicht lesbar.',
$name
));
}
return VariableRef::serialize('content', 'post_link_url_' . $type, ['post_id' => $name]);
}
private function field(string $name): string private function field(string $name): string
{ {
if (preg_match(DynamicContentRules::META_KEY, $name) !== 1) { if (preg_match(DynamicContentRules::META_KEY, $name) !== 1) {
@@ -0,0 +1,324 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Compose;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
use stdClass;
defined('ABSPATH') || exit;
/**
* E6 (security audit M1, R2 finding 1, rulings P2/P3): a stored node that moves to another post –
* page → page (lit/divi-node-copy), library → page (lit/library-insert), page → library
* (lit/library-save) – keeps its bytes. Stored nodes never passed ValueCleaner, and Divi saves bypass
* kses (D7), so without this check an editor could carry an administrator's markup into a post of
* their own. For users without unfiltered_html the node is REFUSED (never cleaned) when anything in
* its subtree – the snapshot and the localAttrs of global instances included (F7) – is
* - a block outside the divi/ namespace, or non-whitespace text between blocks (freeform HTML,
* Divi 4 shortcodes),
* - an opaque block other than a readable divi/global-layout instance (undecodable attributes,
* a globalModule reference on a plain block, an instance without a digits-only globalModule,
* with more than one or an escaped globalModule in its opener, or without a usable
* blockName/localAttrs),
* - a code module,
* - a builder-only path (ValueCleaner::guardPath(): own HTML, HTML attributes, custom CSS – S7/S26),
* also when the builder-only key sits below the state value,
* - a string with control characters (S32), rich text or code that the sanitizer would change
* (not kses-stable), a plain-text string with markup that is not kses-stable, a URL the
* sanitizer would change (plain relative links excepted, ruling P18), or a key inside a value
* that is no plain identifier (S13) – routed exactly like ValueCleaner (kindAt(), VALUE_KEY),
* - a $variable(…)$ reference VariableRef::canonical() refuses for the target post, or whose
* settings canonical() would clean (e.g. before/after with markup such a user may not write).
* Users with unfiltered_html may move anything (the same line as the S7 gate). Pure PHP.
*/
final class StoredNodeGuard
{
public const CODE_MODULES = ['divi/code', 'divi/fullwidth-code'];
/** An instance's globalModule: the library post ID, digits only – Divi intval()s "0123" to 123. */
private const LIBRARY_ID = '/^[0-9]+$/D';
/** Divi's render (BlockParser.php:838) takes the first textual match of this in the opener. */
private const RENDERED_ID = '/"globalModule":"([0-9]+)"/';
/** P18: no scheme before the first "/", "?" or "#"; no whitespace, quotes, <, >, backslash or controls. */
private const RELATIVE_URL = '~^[^:/?#\s"\'<>\\\\\x00-\x1F\x7F]*(?:[/?#][^\s"\'<>\\\\\x00-\x1F\x7F]*)?$~D';
private const BUILDER_ONLY = 'nur im Divi-Builder übertragbar (oder mit dem Recht unfiltered_html)';
public function __construct(
private ValueCleaner $cleaner,
private VariableRef $references,
private ValueSanitizer $sanitizer
) {
}
/** @return list<string> German problems; [] when $root may move to the target post. */
public function problems(Block $root): array
{
if ($this->sanitizer->allowsUnfilteredHtml()) {
return [];
}
$problems = [];
$this->walk($root, $problems);
return array_values(array_unique($problems));
}
/** @param list<string> $problems */
private function walk(Block $block, array &$problems): void
{
if (! $block->isDivi()) {
$problems[] = sprintf('%s: kein Divi-Block – %s.', $block->name(), self::BUILDER_ONLY);
return;
}
$attrs = $this->attributesOf($block);
if ($attrs === null) {
$problems[] = sprintf(
'%s: Attribute nicht lesbar (oder globaler Verweis ohne Moduldaten) – %s.',
$block->name(),
self::BUILDER_ONLY
);
return;
}
$module = GlobalInstance::effectiveName($block);
if (in_array($module, self::CODE_MODULES, true)) {
$problems[] = sprintf('%s: Code-Modul – %s.', $module, self::BUILDER_ONLY);
}
foreach (AttrMap::flatten($attrs) as $path => $value) {
$this->value($module, (string) $path, $value, $problems);
}
foreach ($block->parts() as $part) {
if ($part instanceof Block) {
$this->walk($part, $problems);
} elseif (trim($part) !== '') {
$problems[] = sprintf('%s: Freitext zwischen den Blöcken – %s.', $module, self::BUILDER_ONLY);
}
}
}
/**
* The attributes to check: a plain block's own, an instance's localAttrs (Divi renders them over
* the library entry, F7); null for an opaque block that cannot be read that way.
*
* @return array<string, mixed>|null
*/
private function attributesOf(Block $block): ?array
{
if (! $block->isOpaque()) {
return $block->attrs();
}
$attrs = $block->attrs();
$name = GlobalInstance::effectiveName($block);
if (
$block->name() !== GlobalInstance::BLOCK
|| ! str_starts_with($name, 'divi/')
|| $name === GlobalInstance::BLOCK
|| self::libraryId($block) === null
) {
return null;
}
$local = $attrs['localAttrs'] ?? [];
if ($local instanceof stdClass && get_object_vars($local) === []) {
return [];
}
return is_array($local) && ($local === [] || ! array_is_list($local)) ? $local : null;
}
/**
* The entry an instance shows, read like Divi's render (audit I1, hardening): the parsed
* globalModule must be digits naming an ID above 0 ("0123" = 123), and the stored opener must hold
* exactly one "globalModule" – written as plain digits that give the same ID – so the entry Divi
* renders is the one checked here (JSON's last duplicate key would win, Divi's first match).
*/
private static function libraryId(Block $block): ?int
{
$value = $block->attrs()['globalModule'] ?? null;
if (! is_string($value) || preg_match(self::LIBRARY_ID, $value) !== 1 || (int) $value < 1) {
return null;
}
$raw = $block->rawOpen();
if ($raw === null) {
return (int) $value;
}
return substr_count($raw, '"globalModule"') === 1
&& preg_match(self::RENDERED_ID, $raw, $match) === 1
&& (int) $match[1] === (int) $value ? (int) $value : null;
}
/**
* One flattened path: the builder-only gate, then every string below it – a breakpoint map's
* state values the way AttrWriter hands them to ValueCleaner::clean().
*
* @param list<string> $problems
*/
private function value(string $module, string $path, mixed $value, array &$problems): void
{
if (! $this->allowedPath($path, $problems)) {
return;
}
if (! AttrMap::isBreakpointMap($value)) {
$this->node($module, $path, $path, $value, null, null, $problems);
return;
}
foreach ($value as $states) {
if ($states instanceof stdClass) {
$states = get_object_vars($states);
}
if (! is_array($states) || array_is_list($states)) {
$this->node($module, $path, $path, $states, null, null, $problems);
continue;
}
foreach ($states as $state => $item) {
$field = in_array($state, AttrWriter::STATES, true) ? null : (string) $state;
$where = $field === null ? $path : $path . '.' . $field;
$this->node($module, $path, $where, $item, $field, $field, $problems);
}
}
}
/**
* @param string $where $path plus the keys below the state value: builder-only keys at any depth.
* @param string|null $subName First key below the state value (ValueCleaner's sub-kind).
* @param string|null $key Nearest key above the value.
* @param list<string> $problems
*/
private function node(
string $module,
string $path,
string $where,
mixed $value,
?string $subName,
?string $key,
array &$problems
): void {
if ($where !== $path && ! $this->allowedPath($where, $problems)) {
return;
}
if ($value instanceof stdClass) {
$value = get_object_vars($value);
}
if (is_array($value)) {
foreach ($value as $childKey => $item) {
if (is_string($childKey) && preg_match(ValueCleaner::VALUE_KEY, $childKey) !== 1) {
$problems[] = sprintf(
'%s: ungültiger Schlüssel im Wert (erlaubt: Buchstaben, Ziffern, "_" und "-") – %s.',
$where,
self::BUILDER_ONLY
);
} elseif (is_string($childKey)) {
$below = $where . '.' . $childKey;
$this->node($module, $path, $below, $item, $subName ?? $childKey, $childKey, $problems);
} else {
$this->node($module, $path, $where, $item, $subName, $key, $problems);
}
}
return;
}
if (is_string($value)) {
$problem = $this->stringProblem($module, $path, $value, $subName, $key);
if ($problem !== null) {
$problems[] = sprintf('%s: %s', $where, $problem);
}
}
}
/** @param list<string> $problems */
private function allowedPath(string $path, array &$problems): bool
{
try {
$this->cleaner->guardPath($path);
} catch (InvalidArgumentException $e) {
$problems[] = $e->getMessage();
return false;
}
return true;
}
private function stringProblem(string $module, string $path, string $value, ?string $subName, ?string $key): ?string
{
if (ControlChars::strip($value) !== $value) {
return 'Steuerzeichen im Wert – ' . self::BUILDER_ONLY . '.';
}
$kind = $this->cleaner->kindAt($module, $path, $subName, $key);
if (VariableRef::contains($value)) {
return $this->referenceProblem($value, $kind === ValueKind::Url);
}
$stable = match ($kind) {
ValueKind::Html => $this->sanitizer->html($value) === $value,
ValueKind::Code => $this->sanitizer->code($value) === $value,
ValueKind::Text => ! str_contains($value, '<') || $this->sanitizer->html($value) === $value,
ValueKind::Url => $this->sanitizer->url($value) === trim($value) || self::isPlainRelativeUrl($value),
};
return $stable ? null : 'Inhalt, den dieses Konto so nicht schreiben darf (HTML, Skript oder Link) – '
. self::BUILDER_ONLY . '.';
}
/**
* Ruling P18: a link without scheme – nothing before the first "/", "?" or "#" contains ":" – and
* without whitespace, quotes, "<", ">", backslashes or control characters ("kontakt/", "/pfad",
* "#anker", "?q=1"). esc_url_raw() would prefix "http://" to some of them, yet they cannot carry
* javascript: or data:. Protocol-relative "//host" (and "\\host", which browsers read the same)
* does not count: it must survive url() unchanged.
*/
private static function isPlainRelativeUrl(string $value): bool
{
return preg_match(self::RELATIVE_URL, $value) === 1 && ! str_starts_with($value, '//');
}
/** Same check as a client reference (VariableRef::canonical()); refused when cleaning would change it. */
private function referenceProblem(string $value, bool $url): ?string
{
try {
$canonical = $this->references->canonical($value, $this->sanitizer, $url);
} catch (InvalidArgumentException $e) {
return $e->getMessage();
}
$cleaned = self::normalized(VariableRef::settingsOf($canonical));
return $cleaned === self::normalized(VariableRef::settingsOf($value))
? null
: 'Divi-Verweis mit Einstellungen, die dieses Konto nicht schreiben darf – ' . self::BUILDER_ONLY . '.';
}
/**
* @param array<array-key, mixed> $settings
* @return array<array-key, mixed> Keys sorted, scalars as strings (post_id 839 = "839").
*/
private static function normalized(array $settings): array
{
ksort($settings);
return array_map(static fn (mixed $value): mixed => is_scalar($value) ? (string) $value : $value, $settings);
}
}
+5 -3
View File
@@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi\Compose;
use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\Container; use LIT\MCP\Integrations\Divi\BlockTree\Container;
use LIT\MCP\Integrations\Divi\BlockTree\Document; use LIT\MCP\Integrations\Divi\BlockTree\Document;
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -14,7 +15,8 @@ defined('ABSPATH') || exit;
/** /**
* Structural changes on a parsed page (spec §5.7): where to insert (parent / before / after, * Structural changes on a parsed page (spec §5.7): where to insert (parent / before / after,
* optional index), move, remove, duplicate – always checked against StructureRules. Works on * optional index), move, remove, duplicate – always checked against StructureRules. Works on
* the tree only; saving is DiviWriter's job. Pure PHP. * the tree only; saving is DiviWriter's job. Global instances count as their root type
* (blockName, GlobalInstance::effectiveName()). Pure PHP.
*/ */
final class TreeEditor final class TreeEditor
{ {
@@ -79,7 +81,7 @@ final class TreeEditor
public function insert(array $position, array $blocks): ?string public function insert(array $position, array $blocks): ?string
{ {
foreach ($blocks as $block) { foreach ($blocks as $block) {
$violation = $this->rules->violation($position['parent'], $block->name()); $violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($block));
if ($violation !== null) { if ($violation !== null) {
return $violation; return $violation;
} }
@@ -104,7 +106,7 @@ final class TreeEditor
} }
} }
$violation = $this->rules->violation($position['parent'], $node->name()); $violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($node));
if ($violation !== null) { if ($violation !== null) {
return $violation; return $violation;
} }
+22 -7
View File
@@ -45,8 +45,8 @@ final class ValueCleaner
/** Keys whose string values are URLs when the catalog names no kind (ruling S3). */ /** Keys whose string values are URLs when the catalog names no kind (ruling S3). */
private const URL_KEY = '/(url|src|link|href|image)$/i'; private const URL_KEY = '/(url|src|link|href|image)$/i';
/** Keys inside values: plain identifiers (ruling S13); list indexes are ints. */ /** Keys inside values: plain identifiers (ruling S13); list indexes are ints. StoredNodeGuard uses it too. */
private const VALUE_KEY = '/^[A-Za-z0-9_-]+$/D'; public const VALUE_KEY = '/^[A-Za-z0-9_-]+$/D';
/** Last path segment naming a URL when neither kind nor key does (ruling S24): redirectUrl. */ /** Last path segment naming a URL when neither kind nor key does (ruling S24): redirectUrl. */
private const URL_SEGMENT = '/url$/i'; private const URL_SEGMENT = '/url$/i';
@@ -54,9 +54,14 @@ final class ValueCleaner
/** /**
* Raw HTML/CSS fields, and the ones Divi re-serializes whole pages for (rulings S7/S26), by * Raw HTML/CSS fields, and the ones Divi re-serializes whole pages for (rulings S7/S26), by
* path shape at any depth: module.advanced.html(.htmlBefore), module.decoration.attributes, * path shape at any depth: module.advanced.html(.htmlBefore), module.decoration.attributes,
* any free-form custom CSS path of any module (css, css.*, title.css.*, css.advanced.customCss). * any free-form custom CSS path of any module (css, css.*, title.css.*, css.advanced.customCss),
* and interactions (ruling P16: *.decoration.interactions – Divi's frontend script applies the
* "Attribute" effect with setAttribute, e.g. an onclick handler, and sets cookies/presets),
* and the Lottie source (ruling P19: lottie.innerContent – src becomes data-lottie-src, and the
* lottie-web build runs animation expressions with eval).
*/ */
private const BUILDER_ONLY = '/(^|\.)(decoration\.attributes|advanced\.html|advanced\.customCss|css)(\.|$)/'; private const BUILDER_ONLY = '/(^|\.)(decoration\.attributes|decoration\.interactions|advanced\.html'
. '|advanced\.customCss|css|lottie\.innerContent)(\.|$)/';
public function __construct( public function __construct(
private ModuleCatalog $catalog, private ModuleCatalog $catalog,
@@ -93,7 +98,17 @@ final class ValueCleaner
*/ */
public function kindOf(string $module, string $path, ?string $subKey = null): ValueKind public function kindOf(string $module, string $path, ?string $subKey = null): ValueKind
{ {
return $this->kind($module, $path, $subKey, $subKey); return $this->kindAt($module, $path, $subKey, $subKey);
}
/**
* The kind clean() routes a string to that sits below the state value of $path: $subName is
* the first key there, $key the nearest key above the string (step 5, like cleanNode()).
* StoredNodeGuard checks stored values against exactly this routing.
*/
public function kindAt(string $module, string $path, ?string $subName, ?string $key): ValueKind
{
return $this->kind($module, $path, $subName, $key);
} }
/** /**
@@ -124,8 +139,8 @@ final class ValueCleaner
{ {
if (preg_match(self::BUILDER_ONLY, $path) === 1 && ! $this->sanitizer->allowsUnfilteredHtml()) { if (preg_match(self::BUILDER_ONLY, $path) === 1 && ! $this->sanitizer->allowsUnfilteredHtml()) {
throw new InvalidArgumentException(sprintf( throw new InvalidArgumentException(sprintf(
'"%s" (eigenes HTML, CSS oder HTML-Attribute) ist nur im Divi-Builder änderbar ' '"%s" (eigenes HTML, CSS, HTML-Attribute, Interaktionen oder Lottie-Quelle) ist nur im '
. '(oder mit dem Recht unfiltered_html).', . 'Divi-Builder änderbar (oder mit dem Recht unfiltered_html).',
$path $path
)); ));
} }
+28 -1
View File
@@ -26,7 +26,8 @@ defined('ABSPATH') || exit;
* settings hue/saturation/lightness/opacity, numbers only. * settings hue/saturation/lightness/opacity, numbers only.
* - type "content": DynamicContentRules – the dynamic content map (ruling S22), custom_meta_<key> * - type "content": DynamicContentRules – the dynamic content map (ruling S22), custom_meta_<key>
* (S12/S29/S30), non-color global variables and theme fonts (S27); it also cleans each content * (S12/S29/S30), non-color global variables and theme fonts (S27); it also cleans each content
* setting. "enable_html" is always rejected here. * setting. "enable_html" is always rejected here. Internal links post_link_url_<type> ({{link:…}},
* E13a) only in URL fields.
* A dynamic content map of the wrong shape fails in the constructor with InvalidArgumentException. * A dynamic content map of the wrong shape fails in the constructor with InvalidArgumentException.
*/ */
final class VariableRef final class VariableRef
@@ -75,6 +76,25 @@ final class VariableRef
return $type === 'color' || $type === 'content' ? $type : null; return $type === 'color' || $type === 'content' ? $type : null;
} }
/**
* The settings of $value when it is exactly one reference ([] otherwise). StoredNodeGuard compares
* a stored reference with canonical(): different settings mean cleaning would change them.
*
* @return array<array-key, mixed>
*/
public static function settingsOf(string $value): array
{
try {
$payload = self::payloadOf($value);
} catch (InvalidArgumentException) {
return [];
}
$settings = $payload['value']['settings'] ?? [];
return is_array($settings) ? $settings : [];
}
/** @param array<string, mixed>|stdClass $settings */ /** @param array<string, mixed>|stdClass $settings */
public static function serialize(string $type, string $name, array|stdClass $settings): string public static function serialize(string $type, string $name, array|stdClass $settings): string
{ {
@@ -232,6 +252,13 @@ final class VariableRef
*/ */
private function contentSettings(string $name, array $settings, ValueSanitizer $sanitizer, bool $url): array private function contentSettings(string $name, array $settings, ValueSanitizer $sanitizer, bool $url): array
{ {
if (! $url && str_starts_with($name, 'post_link_url_')) {
throw new InvalidArgumentException(sprintf(
'"%s" (Link auf einen Eintrag, {{link:…}}) nur in Link- und URL-Feldern.',
$name
));
}
$fields = $this->content->fieldsFor($name); $fields = $this->content->fieldsFor($name);
$clean = []; $clean = [];
foreach ($settings as $key => $value) { foreach ($settings as $key => $value) {
@@ -18,6 +18,11 @@ defined('ABSPATH') || exit;
*/ */
final class DiviDesignSystemAbility implements Ability final class DiviDesignSystemAbility implements Ability
{ {
/** Presets per answer (ruling S19); narrow with "module". */
public const PRESET_LIMIT = 200;
private const PARTS = ['colors', 'variables', 'presets'];
public function __construct(private DesignSystemReader $reader) public function __construct(private DesignSystemReader $reader)
{ {
} }
@@ -36,12 +41,28 @@ final class DiviDesignSystemAbility implements Ability
{ {
return 'Liest das Designsystem der Website aus Divi: globale Farben, globale Variablen, Theme-Schriften und ' return 'Liest das Designsystem der Website aus Divi: globale Farben, globale Variablen, Theme-Schriften und '
. '-Größen, Modul- und Gruppen-Presets – jeweils mit dem Platzhalter ({{color:…}}, {{var:…}}, ' . '-Größen, Modul- und Gruppen-Presets – jeweils mit dem Platzhalter ({{color:…}}, {{var:…}}, '
. '{{font:…}}, preset), den neue Inhalte statt fester Werte verwenden sollen.'; . '{{font:…}}, preset), den neue Inhalte statt fester Werte verwenden sollen. '
. 'Filter: only (colors/variables/presets) und module (Presets eines Moduls).';
} }
public function inputSchema(): array public function inputSchema(): array
{ {
return ['type' => 'object', 'properties' => [], 'additionalProperties' => false]; return [
'type' => 'object',
'properties' => [
'only' => [
'type' => 'array',
'items' => ['type' => 'string', 'enum' => self::PARTS],
'description' => 'Nur diese Teile liefern (Standard: alle). Schriften und Größen sind immer '
. 'dabei.',
],
'module' => [
'type' => 'string',
'description' => 'Nur Presets dieses Moduls, z. B. "button" oder "divi/button".',
],
],
'additionalProperties' => false,
];
} }
public function outputSchema(): ?array public function outputSchema(): ?array
@@ -110,26 +131,52 @@ final class DiviDesignSystemAbility implements Ability
public function execute(array $input): mixed public function execute(array $input): mixed
{ {
$design = $this->reader->read(); $design = $this->reader->read();
$only = is_array($input['only'] ?? null) && $input['only'] !== []
? array_values(array_intersect(self::PARTS, $input['only']))
: self::PARTS;
$notes = [
'Schriften: {{font:heading}} bzw. {{font:body}}; Presets in der Kurzform über "preset": "<Name>".',
'Feste Farben oder Schriften nur verwenden, wenn der Nutzer es ausdrücklich möchte.',
];
$skipped = array_values(array_diff(self::PARTS, $only));
if ($skipped !== []) {
$notes[] = 'Nicht angefordert: ' . implode(', ', $skipped) . '.';
}
$presets = $design->presets();
$module = is_string($input['module'] ?? null) ? trim($input['module']) : '';
if ($module !== '') {
$name = str_contains($module, '/') ? $module : 'divi/' . $module;
$presets = array_values(array_filter(
$presets,
static fn (array $preset): bool => $preset['module'] === $name
));
}
if (in_array('presets', $only, true) && count($presets) > self::PRESET_LIMIT) {
$notes[] = sprintf(
'%d von %d Presets gezeigt; mit module auf ein Modul eingrenzen.',
self::PRESET_LIMIT,
count($presets)
);
$presets = array_slice($presets, 0, self::PRESET_LIMIT);
}
return [ return [
'colors' => array_map( 'colors' => in_array('colors', $only, true) ? array_map(
static fn (array $color): array => $color + [ static fn (array $color): array => $color + [
'placeholder' => DesignSystem::colorPlaceholder($color['id'], $color['label']), 'placeholder' => DesignSystem::colorPlaceholder($color['id'], $color['label']),
], ],
$design->colors() $design->colors()
), ) : [],
'variables' => array_map( 'variables' => in_array('variables', $only, true) ? array_map(
static fn (array $variable): array => $variable static fn (array $variable): array => $variable
+ ['placeholder' => '{{var:' . $variable['label'] . '}}'], + ['placeholder' => '{{var:' . $variable['label'] . '}}'],
$design->variables() $design->variables()
), ) : [],
'fonts' => $design->fonts(), 'fonts' => $design->fonts(),
'sizes' => $design->sizes(), 'sizes' => $design->sizes(),
'presets' => $design->presets(), 'presets' => in_array('presets', $only, true) ? $presets : [],
'notes' => [ 'notes' => $notes,
'Schriften: {{font:heading}} bzw. {{font:body}}; Presets in der Kurzform über "preset": "<Name>".',
'Feste Farben oder Schriften nur verwenden, wenn der Nutzer es ausdrücklich möchte.',
],
]; ];
} }
} }
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi;
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use Throwable;
defined('ABSPATH') || exit;
/**
* Live check of the Divi internals LIT MCP relies on (decisions note §5, E15): classes,
* functions, the library post type and taxonomies, the instance block type. lit-site-status shows
* the failed checks, so a Divi update that moves one of them is visible before a tool reports
* "unsupported".
*/
final class DiviCompatibility
{
/** @param array<string, callable(): bool>|null $checks Label => check; null = defaults(). */
public function __construct(private ?array $checks = null)
{
}
/** @return list<string> Labels of the checks that failed or threw. */
public function problems(): array
{
$problems = [];
foreach ($this->checks ?? self::defaults() as $label => $check) {
try {
$ok = (bool) $check();
} catch (Throwable) {
$ok = false;
}
if (! $ok) {
$problems[] = (string) $label;
}
}
return $problems;
}
/** @return array<string, callable(): bool> */
public static function defaults(): array
{
return [
'ModuleRegistration (Modul-Katalog)' => static fn (): bool => DiviModules::isAvailable(),
'DynamicContentOptions (Dynamic Content)' =>
static fn (): bool => class_exists(DynamicContentMap::REGISTRY),
'et_pb_is_allowed (Divi-Rollen)' => static fn (): bool => function_exists('et_pb_is_allowed'),
'Post-Typ et_pb_layout (Bibliothek)' =>
static fn (): bool => post_type_exists(DiviLibraryStore::POST_TYPE),
'Taxonomien scope, layout_type, module_width (Bibliothek)' =>
static fn (): bool => (new DiviLibraryStore())->missingTaxonomies() === [],
'GlobalLayout::is_global_layout_template (globale Module)' =>
static fn (): bool => method_exists(DiviLibraryStore::GLOBAL_LAYOUT, 'is_global_layout_template'),
'ET_Core_PageResource (CSS-Cache)' => static fn (): bool => class_exists('ET_Core_PageResource'),
'et_theme_builder_clear_wp_cache (Theme-Builder-Cache)' =>
static fn (): bool => function_exists('et_theme_builder_clear_wp_cache'),
'Block-Typ divi/global-layout (Instanzen globaler Module)' =>
static fn (): bool => self::knowsGlobalLayout(),
'Taxonomien layout_category, layout_tag (Bibliothek)' =>
static fn (): bool => taxonomy_exists('layout_category') && taxonomy_exists('layout_tag'),
];
}
/**
* Whether Divi still has the block type of global instances (GlobalInstance::BLOCK): registered
* with WordPress, or in Divi's core module metadata (Divi renders instances itself, BlockParser,
* and does not always register the type with WordPress).
*/
private static function knowsGlobalLayout(): bool
{
if (
class_exists('WP_Block_Type_Registry')
&& \WP_Block_Type_Registry::get_instance()->is_registered(GlobalInstance::BLOCK)
) {
return true;
}
$registration = 'ET\Builder\Packages\ModuleLibrary\ModuleRegistration';
if (! method_exists($registration, 'get_all_core_modules_metadata')) {
return false;
}
foreach ((array) $registration::get_all_core_modules_metadata() as $module) {
if (is_array($module) && ($module['name'] ?? null) === GlobalInstance::BLOCK) {
return true;
}
}
return false;
}
}
+71 -5
View File
@@ -6,14 +6,23 @@ namespace LIT\MCP\Integrations\Divi;
use Closure; use Closure;
use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility; use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
use LIT\MCP\Integrations\Divi\Library\GlobalEdits;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
use LIT\MCP\Integrations\Divi\Library\LibraryIntegration;
use LIT\MCP\Integrations\Divi\Pages\DiviComposition; use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility; use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviDuplicateNodeAbility; use LIT\MCP\Integrations\Divi\Pages\DiviDuplicateNodeAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviDynamicSourcesAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility; use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviMoveAbility; use LIT\MCP\Integrations\Divi\Pages\DiviMoveAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviNodeCopyAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviNodeGetAbility; use LIT\MCP\Integrations\Divi\Pages\DiviNodeGetAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviOutlineAbility; use LIT\MCP\Integrations\Divi\Pages\DiviOutlineAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
@@ -21,6 +30,7 @@ use LIT\MCP\Integrations\Divi\Pages\DiviReplaceTextAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver; use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility; use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter; use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility; use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility;
use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\Integration; use LIT\MCP\Integrations\Integration;
@@ -39,11 +49,29 @@ final class DiviIntegration implements Integration
{ {
private DiviModules $modules; private DiviModules $modules;
private DesignSystemReader $design; private DesignSystemReader $design;
private LibraryAccess $access;
public function __construct(private ContentSchema $schema, private ChangeLog $log) /**
{ * @param (Closure(): bool)|null $libraryWritable Tools-tab gate for writes to library entries
* (group "Divi-Bibliothek" writable); null = always.
* @param (Closure(): bool)|null $libraryVisible Tools-tab gate for library entries at all (group
* "Divi-Bibliothek" not disabled, ruling P9); null = always.
* @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled – lit/divi-node-copy
* and lit-library-insert follow it (ruling P9, E9, audit M2); null = always.
* @param (Closure(): bool)|null $libraryInsertable Whether lit/library-insert is enabled –
* lit/divi-node-copy follows it for library entries as source (audit L5); null = always.
*/
public function __construct(
private ContentSchema $schema,
private ChangeLog $log,
?Closure $libraryWritable = null,
?Closure $libraryVisible = null,
private ?Closure $diviWritable = null,
private ?Closure $libraryInsertable = null
) {
$this->modules = new DiviModules(); $this->modules = new DiviModules();
$this->design = new DesignSystemReader(); $this->design = new DesignSystemReader();
$this->access = new LibraryAccess(new DiviLibraryStore(), new DiviRoles(), $libraryWritable, $libraryVisible);
} }
public function id(): string public function id(): string
@@ -80,9 +108,8 @@ final class DiviIntegration implements Integration
public function abilities(): array public function abilities(): array
{ {
$loader = new DiviPageLoader($this->schema); $loader = $this->loader();
$composition = new DiviComposition($this->modules, $this->design); $writer = $this->writer($loader);
$writer = new DiviWriter($loader, new DiviSaver(), $this->log, $this->design, $composition);
return [ return [
new DiviOutlineAbility($loader, $this->design), new DiviOutlineAbility($loader, $this->design),
@@ -95,6 +122,45 @@ final class DiviIntegration implements Integration
new DiviDeleteAbility($writer), new DiviDeleteAbility($writer),
new DiviDuplicateNodeAbility($writer), new DiviDuplicateNodeAbility($writer),
new DiviReplaceTextAbility($this->schema, $loader, $writer), new DiviReplaceTextAbility($this->schema, $loader, $writer),
new DiviNodeCopyAbility($writer, $loader, $this->diviWritable, $this->libraryInsertable),
new DiviDynamicSourcesAbility($this->schema, new DynamicContentMap(), $this->access->roles()),
]; ];
} }
/**
* The group "Divi-Bibliothek" (lit/library-*), sharing this integration's library access and its
* $diviWritable gate (lit-library-insert follows lit/divi-insert, audit M2).
*/
public function library(): LibraryIntegration
{
$loader = $this->loader();
return new LibraryIntegration(
$this->access,
$loader,
$this->design,
$this->writer($loader),
new DiviSaver(),
$this->log,
$this->modules,
$this->diviWritable
);
}
private function writer(DiviPageLoader $loader): DiviWriter
{
return new DiviWriter(
$loader,
new DiviSaver(),
$this->log,
$this->design,
new DiviComposition($this->modules, $this->design),
new GlobalEdits($this->access, new GlobalUsages(null, $this->access->roles()))
);
}
private function loader(): DiviPageLoader
{
return new DiviPageLoader($this->schema, new Parser(), $this->access);
}
} }
@@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\WordPress\Content\NewDraft;
use RuntimeException;
use Throwable;
use WP_Error;
use WP_Post;
use WP_Term;
defined('ABSPATH') || exit;
/**
* Adapter for the Divi Library (E4, E15): post type et_pb_layout, the taxonomies scope,
* layout_type, module_width, layout_category, layout_tag and the meta Divi's et_pb_submit_layout()
* writes (F1, F2, fixtures stage3). "Global" is Divi's own definition
* GlobalLayout::is_global_layout_template() (F5) with the scope term as fallback when the class is
* gone. find() filters by the scope term only, so an entry Divi counts as global through its D4
* meta fallback can show up under scope "normal" (preflight M-12, accepted). Library entries are
* always "publish" (F2). Not final so abilities can be tested with a mock.
*/
class DiviLibraryStore
{
public const POST_TYPE = 'et_pb_layout';
public const GLOBAL_LAYOUT = 'ET\Builder\Packages\GlobalLayout\GlobalLayout';
public const LAYOUT_TYPES = ['layout', 'section', 'row', 'module'];
/** Taxonomies every new entry needs (et_pb_submit_layout). */
public const TAXONOMIES = ['scope', 'layout_type', 'module_width'];
/** Client text when the terms or meta of a new entry could not be written. */
public const REASON_CREATE = 'Der Bibliothekseintrag konnte nicht vollständig angelegt werden.';
public function isGlobal(int $id): bool
{
if (method_exists(self::GLOBAL_LAYOUT, 'is_global_layout_template')) {
return (bool) call_user_func([self::GLOBAL_LAYOUT, 'is_global_layout_template'], $id);
}
return in_array('global', $this->termSlugs($id, 'scope'), true);
}
/** "layout", "section", "row" or "module"; null when the entry has no known type. */
public function layoutType(int $id): ?string
{
foreach ($this->termSlugs($id, 'layout_type') as $slug) {
if (in_array($slug, self::LAYOUT_TYPES, true)) {
return $slug;
}
}
return null;
}
/** @return list<string> */
public function termSlugs(int $id, string $taxonomy): array
{
return array_map(static fn (WP_Term $term): string => (string) $term->slug, $this->terms($id, $taxonomy));
}
/** @return list<string> */
public function termNames(int $id, string $taxonomy): array
{
return array_map(static fn (WP_Term $term): string => (string) $term->name, $this->terms($id, $taxonomy));
}
/**
* Published entries, $limit + 1 of them (Cursor::page() tells whether there are more).
*
* @param array{layout_type?: string, scope?: string, search?: string, category?: string, author?: int} $filters
* scope: "global" or "normal"; search: in the title; category: name of a layout_category
* term; author: only this user's entries.
* @return list<WP_Post>
*/
public function find(array $filters, int $limit, int $offset): array
{
$taxQuery = [];
if (in_array($filters['layout_type'] ?? '', self::LAYOUT_TYPES, true)) {
$taxQuery[] = ['taxonomy' => 'layout_type', 'field' => 'slug', 'terms' => [$filters['layout_type']]];
}
if (($filters['scope'] ?? '') === 'global') {
$taxQuery[] = ['taxonomy' => 'scope', 'field' => 'slug', 'terms' => ['global']];
} elseif (($filters['scope'] ?? '') === 'normal') {
$taxQuery[] = ['taxonomy' => 'scope', 'field' => 'slug', 'terms' => ['global'], 'operator' => 'NOT IN'];
}
if (($filters['category'] ?? '') !== '') {
$taxQuery[] = ['taxonomy' => 'layout_category', 'field' => 'name', 'terms' => [$filters['category']]];
}
$args = [
'post_type' => self::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => $limit + 1,
'offset' => $offset,
'orderby' => 'title',
'order' => 'ASC',
'no_found_rows' => true,
'suppress_filters' => false,
];
if (($filters['search'] ?? '') !== '') {
$args['s'] = $filters['search'];
// Audit L3: the title only – content is block JSON, and entries the user may not read
// must not be findable by their content.
$args['search_columns'] = ['post_title'];
}
if (is_int($filters['author'] ?? null)) {
$args['author'] = $filters['author'];
}
if ($taxQuery !== []) {
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- the library is small.
$args['tax_query'] = array_merge(['relation' => 'AND'], $taxQuery);
}
return array_values(array_filter(get_posts($args), static fn (mixed $post): bool => $post instanceof WP_Post));
}
/**
* @return array{id: int, title: string, layout_type: ?string, scope: string, module_type: ?string,
* categories: list<string>, tags: list<string>, modified: string}
*/
public function describe(WP_Post $post): array
{
$id = (int) $post->ID;
$moduleType = (string) get_post_meta($id, '_et_pb_module_type', true);
return [
'id' => $id,
'title' => (string) $post->post_title,
'layout_type' => $this->layoutType($id),
'scope' => $this->isGlobal($id) ? 'global' : 'normal',
'module_type' => $moduleType === '' ? null : $moduleType,
'categories' => $this->termNames($id, 'layout_category'),
'tags' => $this->termNames($id, 'layout_tag'),
'modified' => (string) $post->post_modified_gmt,
];
}
/**
* A published entry with the terms and meta of $spec and empty content (E4; the layout follows
* through DiviSaver::saveNew()). All or nothing through $draft (ruling P6, the transaction of
* lit/content-create): a refused insert or missing taxonomies create nothing; a failed term or
* meta write deletes the entry again (write_failed), or reports partial_write and logs it
* when that delete fails; a throwing insert hook is a partial write without ID.
*
* @throws WriteFailed
*/
public function create(LibraryItemSpec $spec, NewDraft $draft): int
{
$missing = $this->missingTaxonomies();
if ($missing !== []) {
throw WriteFailed::rolledBack(
'Die Divi-Bibliothek ist in dieser Anfrage nicht geladen.',
detail: 'Divi Library taxonomies not registered: ' . implode(', ', $missing)
);
}
$id = $draft->insert([
'post_type' => self::POST_TYPE,
'post_status' => 'publish',
'post_title' => $spec->title,
'post_content' => '',
]);
if ($id instanceof WP_Error) {
throw WriteFailed::rolledBack(
'WordPress hat den Bibliothekseintrag abgelehnt.',
detail: 'wp_insert_post refused the library entry: ' . $id->get_error_message()
);
}
try {
$this->describeNew($id, $spec);
} catch (Throwable $e) {
$draft->discard($id, $e, self::REASON_CREATE);
}
return $id;
}
/**
* Library taxonomies not registered in this request (F2: Divi registers them in
* ET_Builder_Library; fixture finding 6: not in every context).
*
* @return list<string>
*/
public function missingTaxonomies(): array
{
return array_values(array_filter(
self::TAXONOMIES,
static fn (string $taxonomy): bool => ! taxonomy_exists($taxonomy)
));
}
/**
* Terms and meta of $spec on the new entry $id, each checked.
*
* @throws RuntimeException
*/
private function describeNew(int $id, LibraryItemSpec $spec): void
{
foreach ($spec->terms() as $taxonomy => $terms) {
$result = wp_set_object_terms($id, $terms, $taxonomy);
if ($result instanceof WP_Error) {
throw new RuntimeException(sprintf('terms %s: %s', $taxonomy, $result->get_error_message()));
}
}
foreach ($spec->meta() as $key => $value) {
update_post_meta($id, $key, $value);
if ((string) get_post_meta($id, $key, true) !== $value) {
throw new RuntimeException(sprintf('meta %s of library entry %d not stored', $key, $id));
}
}
}
/** @return list<WP_Term> */
private function terms(int $id, string $taxonomy): array
{
$terms = get_the_terms($id, $taxonomy);
return is_array($terms)
? array_values(array_filter($terms, static fn (mixed $term): bool => $term instanceof WP_Term))
: [];
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library;
use Closure;
use LIT\MCP\Abilities\Errors;
use WP_Error;
defined('ABSPATH') || exit;
/**
* Adapter for Divi's role editor (E3, E15): et_pb_is_allowed() (Divi 5.2.1 builder/core.php:1128)
* answers for the current user's role, "dangerous" options (theme_builder,
* read_dynamic_content_custom_fields) default to off for every role but administrator. When
* Divi no longer has the function, nothing is allowed – DiviCompatibility reports it.
* Not final so tests can pass a check.
*/
class DiviRoles
{
private const LABELS = [
'divi_library' => 'Divi-Bibliothek',
'save_library' => 'In der Bibliothek speichern',
'add_library' => 'Aus der Bibliothek hinzufügen',
'edit_global_library' => 'Globale Bibliothekselemente bearbeiten',
'read_dynamic_content_custom_fields' => 'Dynamic Content: benutzerdefinierte Felder lesen',
'theme_builder' => 'Theme Builder',
];
/** @param (Closure(string): bool)|null $check Test seam; null = Divi's et_pb_is_allowed(). */
public function __construct(private ?Closure $check = null)
{
}
public function allows(string $capability): bool
{
if ($this->check !== null) {
return ($this->check)($capability);
}
return function_exists('et_pb_is_allowed') && (bool) et_pb_is_allowed($capability);
}
/** Null when the role allows $capability, else a forbidden error naming the option. */
public function refusal(string $capability): ?WP_Error
{
if ($this->allows($capability)) {
return null;
}
return Errors::forbidden(
sprintf('Die Divi-Rolle dieses Kontos erlaubt „%s“ nicht.', self::LABELS[$capability] ?? $capability),
'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.'
);
}
}
@@ -0,0 +1,292 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library;
use Closure;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
use LIT\MCP\Integrations\WordPress\Status\PostCloner;
use WP_Error;
use WP_Post;
defined('ABSPATH') || exit;
/**
* The extra rules of a Divi write to a library entry (E2, answer F2), checked after the whole
* change validated and before anything is saved:
* - every entry keeps its shape (LibraryContent::shapeViolation) – instances expect their root type;
* - a global entry – and any entry that pages show as an instance, global scope or not (audit L2:
* Divi renders every referenced entry) – changes every page that shows it at once (F7): without
* confirm: true nothing is saved and the answer lists those pages (a dry run of the fully
* validated change); with it the
* save scope is GlobalItem (site-wide cache) and, when revisions are off for the entry, a backup
* copy is made first – the revision itself is DiviSaver's restore point, as for every page.
* Confirm stays honour-based (ruling P9, like D10). A confirmed edit is refused while the user may
* not edit_post one of the published, private or scheduled posts that show the entry (rulings
* P8/P8a, stricter than Divi); the dry run names them already (blocked_ids – posts the user may
* not read only as counts, audit L4). One GlobalUsages report per call (audit L1); above
* GlobalUsages::HARD_CAP live posts a confirmed edit is refused (Divi builder instead).
* The backup (PostCloner, wp_insert_post with kses) is refused when it could not be faithful: for
* users without unfiltered_html, and when the trash is disabled (ruling P9, S37).
*/
final class GlobalEdits
{
/** Refusals name at most this many page IDs. */
private const LISTED_IDS = 10;
/** @var Closure(WP_Post): int */
private Closure $backup;
/** @var Closure(): bool */
private Closure $trashEnabled;
/**
* @param (Closure(WP_Post): int)|null $backup Backup copy of the entry; null = PostCloner::backup().
* @param (Closure(): bool)|null $trashEnabled Whether the trash keeps the backup; null = EMPTY_TRASH_DAYS.
*/
public function __construct(
private LibraryAccess $access,
private GlobalUsages $usages,
?Closure $backup = null,
private Parser $parser = new Parser(),
?Closure $trashEnabled = null
) {
$this->backup = $backup ?? static fn (WP_Post $item): int => (new PostCloner())->backup($item);
$this->trashEnabled = $trashEnabled
?? static fn (): bool => ! defined('EMPTY_TRASH_DAYS') || (bool) EMPTY_TRASH_DAYS;
}
/**
* @param array<string, mixed> $input
* @return array{scope: SaveScope, details: array<string, mixed>}|WP_Error
* @throws WriteFailed When the backup could not be made (nothing was saved).
*/
public function prepare(DiviPage $page, array $input): array|WP_Error
{
$item = $page->post;
$id = (int) $item->ID;
$global = $this->access->isGlobal($item);
try {
$before = LibraryContent::roots(LibraryContent::load($this->parser, (string) $item->post_content));
} catch (MalformedMarkup) {
$before = [];
}
$violation = LibraryContent::shapeViolation(
$before,
LibraryContent::roots($page->document),
$this->access->store()->layoutType($id),
$global
);
if ($violation !== null) {
return Errors::invalidInput($violation . ' Nichts wurde gespeichert.', ['post_id' => $violation]);
}
// Audit L2: Divi renders every entry an instance names, global scope or not.
$report = $this->usages->report($id);
if (! $global && ! $report->exists()) {
return ['scope' => SaveScope::LibraryItem, 'details' => []];
}
if (($input['confirm'] ?? false) !== true) {
return Errors::confirmRequired(
sprintf(
'%s: die Änderung wirkt sofort auf %d %s (siehe affected), auch veröffentlichte. Nichts wurde '
. 'gespeichert (Probelauf).',
self::label($id, $global),
$report->count,
self::pages($report->count)
) . self::hiddenNote($report) . self::blockedNote($report),
$report->details()
);
}
$refusal = $this->pagesRefusal(self::label($id, $global), $report) ?? $this->backupRefusal($item);
if ($refusal !== null) {
return $refusal;
}
$details = $report->listing();
if (! wp_revisions_enabled($item)) {
$details['backup_id'] = ($this->backup)($item);
}
return ['scope' => SaveScope::GlobalItem, 'details' => $details];
}
/**
* A tool's own confirm_required (lit-divi-delete) on a global or referenced entry (audit L2),
* extended by the affected pages – so the one confirm the client sends covers both questions.
*/
public function notice(DiviPage $page, WP_Error $error): WP_Error
{
if (Errors::codeOf($error) !== 'confirm_required') {
return $error;
}
$global = $this->access->isGlobal($page->post);
$report = $this->usages->report((int) $page->post->ID);
if (! $global && ! $report->exists()) {
return $error;
}
$data = $error->get_error_data();
$details = is_array($data) ? array_diff_key($data, ['status' => true, 'hint' => true]) : [];
return Errors::confirmRequired(
Errors::clientMessage($error)
. sprintf(
' %s: wirkt auf %d %s (siehe affected).',
$global ? 'Globaler Bibliothekseintrag' : 'Bibliothekseintrag (als globales Modul verwendet)',
$report->count,
self::pages($report->count)
)
. self::hiddenNote($report)
. self::blockedNote($report),
array_merge($details, $report->details())
);
}
/**
* Ruling P8a: a confirmed edit needs edit_post on every live post that shows entry $id; above
* GlobalUsages::HARD_CAP live posts it is refused as a whole (audit L1).
*/
private function pagesRefusal(string $label, UsageReport $report): ?WP_Error
{
if ($report->tooMany) {
return Errors::unsupported(
sprintf(
'%s erscheint auf %d veröffentlichten, privaten oder geplanten Einträgen – mehr als %d; so große '
. 'Änderungen nimmt LIT MCP nicht vor. Nichts wurde gespeichert.',
$label,
$report->live,
GlobalUsages::HARD_CAP
),
'Den Eintrag im Divi-Builder bearbeiten.'
);
}
if (! $report->blocks()) {
return null;
}
return Errors::forbidden(
sprintf(
'%s erscheint auf veröffentlichten, privaten oder geplanten Einträgen, die dieses Konto nicht '
. 'bearbeiten darf (%s); nichts wurde gespeichert.',
$label,
self::blockedList($report)
),
'Die Änderung von jemandem vornehmen lassen, der alle diese Einträge bearbeiten darf (z. B. Redakteur '
. 'oder Administrator).'
);
}
/** Audit L4: posts the caller may not read appear only as a count. */
private static function hiddenNote(UsageReport $report): string
{
return $report->hidden === 0 ? '' : sprintf(
' %d davon darf dieses Konto nicht lesen (nur gezählt, siehe hidden_count).',
$report->hidden
);
}
private static function blockedNote(UsageReport $report): string
{
if ($report->tooMany) {
return sprintf(
' Achtung: Die Bestätigung würde abgelehnt – mehr als %d veröffentlichte, private oder geplante '
. 'Einträge zeigen den Eintrag; ihn im Divi-Builder bearbeiten.',
GlobalUsages::HARD_CAP
);
}
return ! $report->blocks() ? '' : sprintf(
' Achtung: Die Bestätigung würde abgelehnt – veröffentlichte, private oder geplante Einträge, die dieses '
. 'Konto nicht bearbeiten darf, zeigen den Eintrag (%s).',
self::blockedList($report)
);
}
/** Readable blocking posts by ID (at most LISTED_IDS, see blocked_ids), unreadable ones as a count. */
private static function blockedList(UsageReport $report): string
{
$parts = [];
if ($report->blocked !== []) {
$parts[] = 'IDs ' . self::listed($report->blocked) . ', siehe blocked_ids';
}
if ($report->blockedHidden > 0) {
$parts[] = sprintf(
'%d %s dieses Konto nicht lesen darf',
$report->blockedHidden,
$report->blockedHidden === 1 ? 'Eintrag, den' : 'Einträge, die'
);
}
return implode('; ', $parts);
}
/** @param list<int> $ids At most LISTED_IDS of them, then "und N weitere". */
private static function listed(array $ids): string
{
$more = count($ids) - self::LISTED_IDS;
return implode(', ', array_slice($ids, 0, self::LISTED_IDS))
. ($more > 0 ? sprintf(' und %d weitere', $more) : '');
}
/** How messages name entry $id: global, or a normal entry that pages show as a global module (L2). */
private static function label(int $id, bool $global): string
{
return $global
? sprintf('Globaler Bibliothekseintrag %d', $id)
: sprintf('Bibliothekseintrag %d (auf Seiten als globales Modul verwendet)', $id);
}
private static function pages(int $count): string
{
return $count === 1 ? 'Seite' : 'Seiten';
}
/** Ruling P9: a backup (only made when revisions are off) must be faithful and must survive. */
private function backupRefusal(WP_Post $item): ?WP_Error
{
if (wp_revisions_enabled($item)) {
return null;
}
$hint = 'Revisionen für die Divi-Bibliothek aktivieren (WP_POST_REVISIONS) oder den Eintrag im Divi-Builder '
. 'bearbeiten.';
if (! current_user_can('unfiltered_html')) {
return Errors::unsupported(
sprintf(
'Revisionen sind für Bibliothekseintrag %d abgeschaltet, und die Sicherungskopie wäre für dieses '
. 'Konto nicht verlustfrei (WordPress filtert HTML); nichts wurde gespeichert.',
(int) $item->ID
),
$hint
);
}
if (! ($this->trashEnabled)()) {
return Errors::unsupported(
sprintf(
'Revisionen sind für Bibliothekseintrag %d abgeschaltet, und der Papierkorb ist deaktiviert '
. '(EMPTY_TRASH_DAYS = 0) – die Sicherungskopie würde sofort gelöscht; nichts wurde '
. 'gespeichert.',
(int) $item->ID
),
$hint
);
}
return null;
}
}
@@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library;
use WP_Post;
defined('ABSPATH') || exit;
/**
* Posts (pages, Theme Builder layouts, other library entries) that show library entry $id: every
* living post whose content references it in any form Divi renders (rows(), ruling P23 – Divi 5
* instances in any JSON spelling, Divi 4 global_module shortcodes; fail closed). Candidates come
* from one query (ID, status and type), their content is parsed in bounded chunks (audit L1); the
* rights of the caller are then checked in chunks with a bounded post cache:
* - readable posts are listed (at most LIMIT, with title); unreadable ones only count (audit L4);
* - live posts (ruling P8a) the caller may not edit_post block a confirmed edit – by ID when
* readable, else as a count; Theme Builder layouts also without Divi's theme_builder role option
* (audit L6); above HARD_CAP live posts the edit is refused as a whole.
* Not final so tests can mock rows().
*/
class GlobalUsages
{
/** At most this many readable posts are listed. */
public const LIMIT = 50;
/** Above this many live posts a confirmed edit is refused without checking each of them (audit L1). */
public const HARD_CAP = 2000;
/** At most this many posts are checked for read_post (the listing and hidden_count). */
public const CHECK_CAP = 2000;
/** Posts whose rights are checked with one primed cache. */
public const CHUNK = 200;
/** Candidates whose content is read and parsed per call; the rest count as references (ruling P23). */
public const PARSE_CAP = 5000;
/** Candidate contents read per query (bounded memory, audit L1). */
public const CONTENT_CHUNK = 50;
/** Posts that render now or will on their own: published, private, scheduled (ruling P8a). */
public const LIVE = ['publish', 'private', 'future'];
/** Every post that exists for the user: listed in the dry run. */
private const LISTED = "post_status NOT IN ('trash', 'auto-draft', 'inherit')";
/** Theme Builder post types (Divi's et_theme_builder_get_layout_post_types() plus the templates). */
public const THEME_BUILDER_TYPES = ['et_header_layout', 'et_body_layout', 'et_footer_layout', 'et_template'];
private DiviRoles $roles;
public function __construct(private ?\wpdb $db = null, ?DiviRoles $roles = null)
{
$this->roles = $roles ?? new DiviRoles();
}
/**
* The usages of $libraryId with the caller's rights; $withBlocked adds the P8a check (edit_post
* on every live post) – the dry run and the confirm of an edit need it, a listing does not.
*/
public function report(int $libraryId, bool $withBlocked = true): UsageReport
{
$rows = $this->rows($libraryId);
$live = count(array_filter($rows, static fn (array $row): bool => in_array($row['status'], self::LIVE, true)));
$tooMany = $withBlocked && $live > self::HARD_CAP;
$checkEdit = $withBlocked && ! $tooMany;
$items = [];
$hidden = 0;
$blocked = [];
$blockedHidden = 0;
$checked = 0;
foreach (array_chunk($rows, self::CHUNK) as $chunk) {
$todo = array_values(array_filter(
$chunk,
static function (array $row) use (&$checked, $checkEdit): bool {
$isLive = in_array($row['status'], self::LIVE, true);
if ($checked < self::CHECK_CAP || ($checkEdit && $isLive)) {
++$checked;
return true;
}
return false;
}
));
if ($todo === []) {
continue;
}
$ids = array_map(static fn (array $row): int => $row['id'], $todo);
$fresh = _get_non_cached_ids($ids, 'posts');
_prime_post_caches($ids, false, false);
foreach ($todo as $row) {
$id = $row['id'];
$readable = current_user_can('read_post', $id);
if ($readable && count($items) < self::LIMIT) {
$post = get_post($id);
$items[] = [
'post_id' => $id,
'title' => $post instanceof WP_Post ? (string) $post->post_title : '',
'status' => $row['status'],
'type' => $row['type'],
];
} elseif (! $readable) {
++$hidden;
}
if ($checkEdit && in_array($row['status'], self::LIVE, true) && ! $this->editable($row)) {
if ($readable) {
$blocked[] = $id;
} else {
++$blockedHidden;
}
}
}
foreach ($fresh as $id) {
wp_cache_delete((int) $id, 'posts');
}
}
return new UsageReport(count($rows), $items, $hidden, $live, $blocked, $blockedHidden, $tooMany);
}
/**
* Every post (no revision, not trashed) that shows $libraryId (ruling P23, fail closed). The
* candidates come from one query (ID, status and type only): content with "globalModule", a
* Divi 4 "global_module" (both case-insensitive) or a JSON escape of a letter (an escaped key such as globalModule
* always has one). Their content is then read in chunks of CONTENT_CHUNK and parsed by
* UsageMatcher; beyond PARSE_CAP candidates the rest counts as references unread (audit L1).
*
* @return list<array{id: int, status: string, type: string}>
*/
public function rows(int $libraryId): array
{
$candidates = $this->candidates();
$result = [];
foreach (array_chunk(array_slice($candidates, 0, self::PARSE_CAP), self::CONTENT_CHUNK) as $chunk) {
$contents = $this->contents(array_map(static fn (array $row): int => $row['id'], $chunk));
foreach ($chunk as $row) {
// A candidate whose content could not be read counts too (fail closed).
if (! isset($contents[$row['id']]) || UsageMatcher::shows($contents[$row['id']], $libraryId)) {
$result[] = $row;
}
}
}
return array_merge($result, array_slice($candidates, self::PARSE_CAP));
}
/** @return list<array{id: int, status: string, type: string}> */
private function candidates(): array
{
$db = $this->db();
$escapedLetter = str_repeat(chr(92), 2) . 'u00[4-7][0-9a-fA-F]';
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
$rows = $db->get_results(
$db->prepare(
// Ruling P24: LOWER() keeps the LIKEs case-insensitive under any column collation.
'SELECT ID, post_status, post_type FROM ' . $db->posts . ' WHERE (LOWER(post_content) LIKE %s OR '
. "LOWER(post_content) LIKE %s OR post_content REGEXP %s) AND post_type <> 'revision' AND "
. self::LISTED . ' ORDER BY ID ASC',
'%' . $db->esc_like('globalmodule') . '%',
'%' . $db->esc_like('global_module') . '%',
$escapedLetter
),
'ARRAY_A'
);
// phpcs:enable
$result = [];
foreach (is_array($rows) ? $rows : [] as $row) {
$result[] = [
'id' => (int) $row['ID'],
'status' => (string) $row['post_status'],
'type' => (string) $row['post_type'],
];
}
return $result;
}
/**
* @param list<int> $ids
* @return array<int, string> Content by ID.
*/
private function contents(array $ids): array
{
$db = $this->db();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
$rows = $db->get_results(
$db->prepare(
'SELECT ID, post_content FROM ' . $db->posts . ' WHERE ID IN ('
. implode(', ', array_fill(0, count($ids), '%d')) . ')',
$ids
),
'ARRAY_A'
);
// phpcs:enable
$contents = [];
foreach (is_array($rows) ? $rows : [] as $row) {
$contents[(int) $row['ID']] = (string) $row['post_content'];
}
return $contents;
}
/**
* Ruling P8a: whether the caller may edit_post the live post $row; a Theme Builder layout or
* template additionally needs Divi's role option theme_builder (audit L6 – as Divi's own
* et_pb_is_allowed check for TB layouts, builder/functions.php).
*
* @param array{id: int, status: string, type: string} $row
*/
private function editable(array $row): bool
{
if (in_array($row['type'], self::THEME_BUILDER_TYPES, true) && ! $this->roles->allows('theme_builder')) {
return false;
}
return current_user_can('edit_post', $row['id']);
}
private function db(): \wpdb
{
return $this->db ?? $GLOBALS['wpdb'];
}
}
@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library;
use Closure;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\WordPress\PostGuard;
use WP_Error;
use WP_Post;
defined('ABSPATH') || exit;
/**
* Rights on Divi Library entries (spec §4.5, E3 – never laxer than Divi): the library at all needs
* edit_posts and the Divi role option divi_library; one entry additionally edit_post on it (Divi's
* builder routes do not check that, F9). The Divi tools reach library entries only through
* item(); every other ID goes on to PostGuard unchanged.
* Not final so abilities can be tested with a mock.
*/
class LibraryAccess
{
/** Tool group "Divi-Bibliothek" (Tools tab); its switches gate library entries (E9, ruling P9). */
public const GROUP = 'divi-library';
/**
* @param (Closure(): bool)|null $writable Whether the Tools tab lets this connection write to the
* library (group "Divi-Bibliothek" not read-only or disabled); null = always.
* @param (Closure(): bool)|null $visible Whether library entries exist for this connection at all
* (group "Divi-Bibliothek" not disabled, ruling P9); null = always.
*/
public function __construct(
private DiviLibraryStore $store,
private DiviRoles $roles,
private ?Closure $writable = null,
private ?Closure $visible = null
) {
}
public function store(): DiviLibraryStore
{
return $this->store;
}
public function roles(): DiviRoles
{
return $this->roles;
}
/**
* The library entry $postId after the checks for reading, or their refusal; null when $postId
* names no library entry (the caller goes on with PostGuard). With the library hidden (ruling
* P9) or without the library right at all (ruling P12), an entry is refused exactly like an ID
* that does not exist, so its type and existence stay unknown; only a user with the library
* right who lacks edit_post on the entry gets forbidden (like pages). $forWrite adds canEdit().
*/
public function item(mixed $postId, bool $forWrite): WP_Post|WP_Error|null
{
$id = PostGuard::id($postId);
$post = $id === null ? null : get_post($id);
if (! $post instanceof WP_Post || $post->post_type !== DiviLibraryStore::POST_TYPE) {
return null;
}
if (($this->visible !== null && ! ($this->visible)()) || $this->canList() !== null) {
return PostGuard::notFound((int) $post->ID);
}
return ($forWrite ? $this->canEdit($post) : $this->canRead($post)) ?? $post;
}
/** Listing the library: edit_posts and the Divi role option divi_library. */
public function canList(): ?WP_Error
{
if (! current_user_can('edit_posts')) {
return Errors::forbidden('Keine Berechtigung, die Divi-Bibliothek zu nutzen.');
}
return $this->roles->refusal('divi_library');
}
/** Reading one entry: canList() and edit_post on the entry. */
public function canRead(WP_Post $item): ?WP_Error
{
$refusal = $this->canList();
if ($refusal !== null) {
return $refusal;
}
return current_user_can('edit_post', (int) $item->ID)
? null
: Errors::forbidden(sprintf('Keine Berechtigung, Bibliothekseintrag %d zu bearbeiten.', (int) $item->ID));
}
/**
* Changing one entry: canRead(), the role option save_library, the Tools-tab gate and a live
* entry; a global entry additionally needs edit_global_library (E3 – Divi's builder route
* does not check edit_post on the entry, F9; LIT MCP does).
*/
public function canEdit(WP_Post $item): ?WP_Error
{
$refusal = $this->canRead($item) ?? $this->roles->refusal('save_library') ?? $this->writableGate();
if ($refusal !== null) {
return $refusal;
}
if ((string) $item->post_status !== 'publish') {
return Errors::invalidInput(
sprintf(
'Bibliothekseintrag %d ist nicht aktiv (Status „%s“); nichts wurde gespeichert.',
(int) $item->ID,
(string) $item->post_status
),
['post_id' => 'nur aktive Bibliothekseinträge'],
'Den Eintrag zuerst in der Divi-Bibliothek wiederherstellen.'
);
}
return $this->isGlobal($item) ? $this->roles->refusal('edit_global_library') : null;
}
/**
* Creating an entry: canList(), publish_posts of et_pb_layout (library entries are always
* published, F2), the role option save_library and the Tools-tab gate; a global entry
* additionally edit_global_library (ruling P7 – the same option as for editing one, F12).
*/
public function canSave(bool $global = false): ?WP_Error
{
$refusal = $this->canList();
if ($refusal !== null) {
return $refusal;
}
$type = get_post_type_object(DiviLibraryStore::POST_TYPE);
$publish = is_object($type) && isset($type->cap->publish_posts)
? (string) $type->cap->publish_posts
: 'publish_posts';
if (! current_user_can($publish)) {
return Errors::forbidden('Keine Berechtigung, Einträge der Divi-Bibliothek anzulegen (publish_posts).');
}
$refusal = $this->roles->refusal('save_library') ?? $this->writableGate();
if ($refusal !== null || ! $global) {
return $refusal;
}
return $this->roles->refusal('edit_global_library');
}
/**
* Inserting from the library into a page (E3): canList(), the role option add_library (Divi's
* "Aus der Bibliothek hinzufügen") and a group "Divi-Bibliothek" that is not read-only or
* disabled (E9). Reading the entry itself stays item() (ruling P5).
*/
public function canInsert(): ?WP_Error
{
return $this->canList() ?? $this->roles->refusal('add_library') ?? $this->writableGate();
}
/** Refusal while the Tools tab keeps this connection from writing to the library. */
public function writableGate(): ?WP_Error
{
if ($this->writable === null || ($this->writable)()) {
return null;
}
return Errors::forbidden(
'Schreiben in die Divi-Bibliothek ist für diese Verbindung abgeschaltet (Gruppe „Divi-Bibliothek“ auf '
. '„nur lesen“ oder deaktiviert).',
'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.'
);
}
public function isGlobal(WP_Post $item): bool
{
return $this->store->isGlobal((int) $item->ID);
}
}
@@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library;
use Closure;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Pages\DiviChange;
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
use LIT\MCP\Integrations\Divi\Pages\DiviErrors;
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
use LIT\MCP\Integrations\Divi\Pages\DiviSchema;
use LIT\MCP\Integrations\Divi\Pages\DiviWriteAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\WordPress\PostGuard;
use WP_Error;
use WP_Post;
defined('ABSPATH') || exit;
/**
* lit/library-insert (spec §5.7, E5): a Divi Library entry into a Divi page – as the global instance
* the Visual Builder writes (GlobalInstance::of; Divi renders the entry's current content, F7) or
* as an independent copy of its roots (a global entry deglobalized like Divi's /load, F10). The
* target works like lit-divi-insert (tree_rev lock, StructureRules by effective name, all or
* nothing, DiviWriter); the entry is read through LibraryAccess::item() (ruling P5), never nests
* instances (ruling P11), and its nodes pass StoredNodeGuard (E6). Needs add_library (E3) and
* follows lit-divi-insert's Tools-tab state (audit M2).
*/
final class LibraryInsertAbility extends DiviWriteAbility
{
private const MODES = ['copy', 'global'];
/** @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled; null = always. */
public function __construct(
DiviWriter $writer,
private LibraryAccess $access,
private ?Closure $diviWritable = null,
private Parser $parser = new Parser()
) {
parent::__construct($writer);
}
public function name(): string
{
return 'lit/library-insert';
}
public function label(): string
{
return 'Aus der Divi-Bibliothek einfügen';
}
public function description(): string
{
return 'Fügt einen Eintrag der Divi-Bibliothek in eine Divi-Seite ein: mode "global" als verknüpfte Instanz '
. '(der Inhalt kommt immer aus der Bibliothek; Änderungen dort wirken auf allen Seiten) oder mode "copy" '
. 'als unabhängige Kopie. Standard: global für globale Einträge, sonst copy. Einträge über '
. 'lit/library-list; Position über parent, before oder after (Pfad-IDs aus lit-divi-outline), sonst ans '
. 'Seitenende. tree_rev ist Pflicht; alles oder nichts.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => array_merge(DiviSchema::write(), [
'library_id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'ID aus lit/library-list.'],
'mode' => ['type' => 'string', 'enum' => self::MODES],
], DiviSchema::position()),
'required' => ['post_id', 'tree_rev', 'library_id'],
'additionalProperties' => false,
];
}
public function annotations(): array
{
return Annotations::write(false);
}
protected function precheck(array $input): ?WP_Error
{
$refusal = $this->access->canInsert();
if ($refusal !== null) {
return $refusal;
}
if ($this->diviWritable !== null && ! ($this->diviWritable)()) {
return Errors::unsupported(
'Divi-Änderungen sind für diese Verbindung abgeschaltet (Gruppe „Divi“ auf „nur lesen“ oder '
. 'lit-divi-insert deaktiviert).',
'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.'
);
}
return null;
}
protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
{
$mode = $input['mode'] ?? null;
if ($mode !== null && ! in_array($mode, self::MODES, true)) {
return Errors::invalidInput('mode ist „copy“ oder „global“; nichts wurde gespeichert.', [
'mode' => 'copy oder global',
]);
}
$item = $this->item($input['library_id'] ?? null);
if ($item instanceof WP_Error) {
return $item;
}
$id = (int) $item->ID;
$global = $this->access->isGlobal($item);
$mode = is_string($mode) ? $mode : ($global ? 'global' : 'copy');
if ($mode === 'global' && ! $global) {
return Errors::invalidInput(
'Nur globale Bibliothekseinträge lassen sich als Instanz einfügen; nichts wurde gespeichert.',
['mode' => 'der Eintrag ist nicht global – mode "copy" verwenden']
);
}
$blocks = $this->blocks($item, $mode === 'global', $global);
if ($blocks instanceof WP_Error) {
return $blocks;
}
$guard = $composition->storedGuard();
foreach ($blocks as $block) {
$problems = $guard->problems($block);
if ($problems !== []) {
return Errors::invalidInput(
'Der Bibliothekseintrag enthält Einstellungen, die nur im Divi-Builder übertragen werden dürfen; '
. 'nichts wurde gespeichert.',
['library_id' => implode('; ', $problems)]
);
}
}
$editor = $composition->editor();
$position = $editor->position($page->index, $input);
if (is_string($position)) {
return DiviErrors::position($position);
}
$violation = $editor->insert($position, $blocks);
if ($violation !== null) {
return Errors::invalidInput(
'Der Bibliothekseintrag passt nicht an diese Stelle; nichts wurde gespeichert.',
['library_id' => $violation]
);
}
$change = new DiviChange(sprintf(
'Bibliothekseintrag „%s“ (%d) %s eingefügt',
(string) $item->post_title,
$id,
$mode === 'global' ? 'als globale Instanz' : 'als Kopie'
));
foreach ($blocks as $block) {
$change->record($block, 'inserted');
}
if ($mode === 'global') {
$change->warning(sprintf(
'Globale Instanz: den Inhalt liefert Bibliothekseintrag %d; Änderungen dort wirken auf alle Seiten mit '
. 'diesem Modul (lit-divi-outline mit post_id %d).',
$id,
$id
));
}
return $change;
}
/**
* The active library entry $libraryId after LibraryAccess' read checks (ruling P5: the library
* right and edit_post on the entry), or the refusal. Anything else is not found.
*/
private function item(mixed $libraryId): WP_Post|WP_Error
{
$id = PostGuard::id($libraryId);
if ($id === null) {
return Errors::invalidInput('library_id fehlt oder ist keine ID; nichts wurde gespeichert.', [
'library_id' => 'ID aus lit/library-list',
]);
}
$item = $this->access->item($id, false);
if ($item instanceof WP_Error) {
return $item;
}
if (! $item instanceof WP_Post || (string) $item->post_status !== 'publish') {
return Errors::notFound(
sprintf('Bibliothekseintrag %d gibt es nicht (oder er ist nicht aktiv).', $id),
'Einträge mit lit/library-list auflisten.'
);
}
return $item;
}
/** @return list<Block>|WP_Error The blocks to insert. */
private function blocks(WP_Post $item, bool $instance, bool $global): array|WP_Error
{
$id = (int) $item->ID;
try {
$roots = LibraryContent::roots(LibraryContent::load($this->parser, (string) $item->post_content));
} catch (MalformedMarkup) {
return Errors::unsupported(
sprintf('Bibliothekseintrag %d ist kein gültiges Block-Markup; nichts wurde gespeichert.', $id),
'Den Eintrag im Divi-Builder öffnen und speichern.'
);
}
if ($roots === []) {
return Errors::invalidInput(
sprintf('Bibliothekseintrag %d ist leer; nichts wurde gespeichert.', $id),
['library_id' => 'leer']
);
}
if ($instance) {
return $this->instance($roots, $id);
}
return array_map(
static fn (Block $root): Block => $global ? GlobalInstance::deglobalize($root->copy()) : $root->copy(),
$roots
);
}
/**
* The one instance of entry $id (GlobalInstance::of). Ruling P11: never nested instances – the
* root must be a single readable node holding no global module (Divi forbids nesting, F10).
*
* @param list<Block> $roots
* @return list<Block>|WP_Error
*/
private function instance(array $roots, int $id): array|WP_Error
{
if (count($roots) !== 1) {
return Errors::invalidInput(
'Dieser globale Eintrag hat mehrere Wurzeln und lässt sich nur als Kopie einfügen; nichts wurde '
. 'gespeichert.',
['mode' => 'mode "copy" verwenden']
);
}
$root = $roots[0];
if ($root->isOpaque() || LibraryContent::containsGlobal($root)) {
return Errors::invalidInput(
sprintf(
'Bibliothekseintrag %d ist selbst ein globales Modul, enthält eines oder ist nicht lesbar; als '
. 'Instanz entstünde eine Verschachtelung, die Divi nicht erlaubt. Nichts wurde gespeichert.',
$id
),
['mode' => 'mode "copy" verwenden oder den Eintrag im Divi-Builder prüfen']
);
}
return [GlobalInstance::of($root, $id)];
}
}
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library;
use Closure;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\Integration;
defined('ABSPATH') || exit;
/**
* The group "Divi-Bibliothek" (E9): lit/library-list, lit/library-save and lit-library-insert (direct).
* Built by DiviIntegration::library() so it shares the library access (rights, Tools-tab gates –
* its id LibraryAccess::GROUP is what the P9 hiding and the write gate ask). Available with the
* Divi 5 builder, like the Divi group.
*/
final class LibraryIntegration implements Integration
{
/** @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled (audit M2); null = always. */
public function __construct(
private LibraryAccess $access,
private DiviPageLoader $loader,
private DesignSystemReader $design,
private DiviWriter $writer,
private DiviSaver $saver,
private ChangeLog $log,
private DiviModules $modules,
private ?Closure $diviWritable = null
) {
}
public function id(): string
{
return LibraryAccess::GROUP;
}
public function label(): string
{
return __('Divi-Bibliothek', 'wp-lit-mcp');
}
public function isAvailable(): bool
{
return DiviModules::isAvailable();
}
public function abilities(): array
{
return [
new LibraryListAbility(
$this->access,
$this->loader,
$this->design,
new GlobalUsages(null, $this->access->roles())
),
new LibrarySaveAbility(
$this->access,
$this->loader,
$this->writer,
$this->saver,
$this->log,
$this->modules
),
new LibraryInsertAbility($this->writer, $this->access, $this->diviWritable),
];
}
}
@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
defined('ABSPATH') || exit;
/**
* Title, terms and meta of a new library entry, like Divi's et_pb_submit_layout() writes them
* (builder/shortcode-core.php:654-734; compared with the real builder entries, fixtures stage3):
* terms scope (global / non_global), layout_type, module_width, optional layout_category/layout_tag;
* meta _et_pb_module_type (modules), _et_pb_excluded_global_options = "[]" (global modules),
* _et_pb_built_for_post_type, _et_pb_template_type. _et_pb_row_layout is left out – Divi 5.2.1 stores
* "[object Object]" there (finding 5). Pure PHP.
*/
final class LibraryItemSpec
{
private const MODULE_CATEGORIES = ['module', 'fullwidth-module'];
/**
* @param list<int> $categories layout_category term IDs.
* @param list<int> $tags layout_tag term IDs.
*/
public function __construct(
public readonly string $title,
public readonly string $layoutType,
public readonly bool $global,
public readonly string $moduleWidth,
public readonly ?string $moduleType,
public readonly string $builtFor,
public readonly array $categories = [],
public readonly array $tags = []
) {
}
/**
* The entry that holds $node, or the German reason it cannot be saved: sections, rows and
* modules only (Divi's "add to library"); a global module is already an entry; a global entry
* holds no global module (F10). Opaque roots and global instances are refused before anything
* wraps them (ruling P11), so no entry ever starts with an instance.
*/
public static function forNode(
Block $node,
ModuleCatalog $catalog,
string $title,
bool $global,
string $builtFor
): self|string {
if ($node->isGlobal()) {
$ref = NodeSummary::globalRef($node);
return sprintf(
'Dieser Knoten ist bereits ein globales Modul (Bibliothekseintrag %s); ihn mit lit-library-insert '
. 'einfügen statt erneut speichern.',
$ref['id'] ?? '?'
);
}
if ($node->isOpaque()) {
return sprintf(
'Die Einstellungen von %s sind nicht lesbar; der Knoten lässt sich nicht in der Bibliothek speichern.',
$node->name()
);
}
$name = $node->name();
if ($name === 'divi/section') {
$type = 'section';
$width = AttrMap::desktop($node->attrs(), 'module.advanced.type') === 'fullwidth' ? 'fullwidth' : 'regular';
} elseif ($name === 'divi/row') {
$type = 'row';
$width = 'regular';
} elseif ($catalog->has($name) && in_array($catalog->category($name), self::MODULE_CATEGORIES, true)) {
$type = 'module';
$width = $catalog->category($name) === 'fullwidth-module' ? 'fullwidth' : 'regular';
} else {
return sprintf(
'%s lässt sich nicht einzeln in der Bibliothek speichern (nur Abschnitte, Zeilen und Module).',
$name
);
}
if ($global && LibraryContent::containsGlobal($node)) {
return 'Ein globaler Bibliothekseintrag darf keine globalen Module enthalten (Divi erlaubt keine '
. 'Verschachtelung); als normalen Eintrag speichern.';
}
return new self($title, $type, $global, $width, $type === 'module' ? $name : null, $builtFor);
}
/**
* @param list<int> $categories
* @param list<int> $tags
*/
public function withTerms(array $categories, array $tags): self
{
return new self(
$this->title,
$this->layoutType,
$this->global,
$this->moduleWidth,
$this->moduleType,
$this->builtFor,
$categories,
$tags
);
}
/** @return array<string, list<int|string>> Taxonomy => slugs (system terms) or term IDs. */
public function terms(): array
{
$terms = [
'scope' => [$this->global ? 'global' : 'non_global'],
'layout_type' => [$this->layoutType],
'module_width' => [$this->moduleWidth],
];
if ($this->categories !== []) {
$terms['layout_category'] = $this->categories;
}
if ($this->tags !== []) {
$terms['layout_tag'] = $this->tags;
}
return $terms;
}
/** @return array<string, string> */
public function meta(): array
{
$meta = [];
if ($this->moduleType !== null) {
$meta['_et_pb_module_type'] = $this->moduleType;
}
if ($this->global && $this->layoutType === 'module') {
$meta['_et_pb_excluded_global_options'] = '[]';
}
$meta['_et_pb_built_for_post_type'] = $this->builtFor;
$meta['_et_pb_template_type'] = $this->layoutType;
return $meta;
}
}
@@ -0,0 +1,263 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Cursor;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Integrations\Divi\BlockTree\Outline;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\WordPress\PostGuard;
use WP_Error;
use WP_Post;
defined('ABSPATH') || exit;
/**
* lit/library-list: the Divi Library (spec §5.7, E9 – replaces lit/library-get). Without id a page
* of published entries (filters, cursor); with id one entry with its node outline, tree_rev and
* the pages that show it as an instance (global entries always, others when any page does, L2).
* Rights: LibraryAccess (edit_posts + divi_library; every listed entry also edit_post – others are
* left out, without title or count). With id,
* everything but a library entry the user may read answers like a missing ID (ruling P12).
*/
final class LibraryListAbility implements Ability
{
/** Rows looked at per call beyond the entries already shown (the cursor), skipping hidden ones. */
public const MAX_SCAN = 500;
public function __construct(
private LibraryAccess $access,
private DiviPageLoader $loader,
private DesignSystemReader $design,
private GlobalUsages $usages
) {
}
public function name(): string
{
return 'lit/library-list';
}
public function label(): string
{
return 'Divi-Bibliothek auflisten';
}
public function description(): string
{
return 'Listet Einträge der Divi-Bibliothek (Layouts, Abschnitte, Zeilen, Module; normal oder global), '
. 'filterbar nach layout_type, scope, search und category, blätterbar mit cursor. Mit id: ein Eintrag '
. 'im Detail – Knoten-Übersicht, tree_rev und die Seiten, die ihn als globales Modul zeigen '
. '(affected). Einfügen mit lit-library-insert; bearbeiten mit den Divi-Werkzeugen und der ID als post_id.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => array_merge([
'id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Ein Eintrag im Detail.'],
'layout_type' => ['type' => 'string', 'enum' => DiviLibraryStore::LAYOUT_TYPES],
'scope' => ['type' => 'string', 'enum' => ['normal', 'global']],
'search' => ['type' => 'string', 'maxLength' => 100, 'description' => 'Suche im Titel.'],
'category' => ['type' => 'string', 'maxLength' => 100, 'description' => 'Name einer Kategorie.'],
], Cursor::inputProperties()),
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return [
'type' => 'object',
'properties' => [
'items' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer'],
'title' => ['type' => 'string'],
'layout_type' => ['type' => ['string', 'null']],
'scope' => ['type' => 'string'],
'module_type' => ['type' => ['string', 'null']],
'categories' => ['type' => 'array', 'items' => ['type' => 'string']],
'tags' => ['type' => 'array', 'items' => ['type' => 'string']],
'modified' => ['type' => 'string'],
],
'required' => [
'id', 'title', 'layout_type', 'scope', 'module_type', 'categories', 'tags', 'modified',
],
],
],
'next_cursor' => ['type' => ['string', 'null']],
'outline' => ['type' => 'string'],
'tree_rev' => ['type' => 'string'],
'rev' => ['type' => 'string'],
'edit_url' => ['type' => 'string'],
'affected' => ['type' => 'array', 'items' => ['type' => 'object']],
'affected_count' => ['type' => 'integer'],
'hidden_count' => ['type' => 'integer'],
'notes' => ['type' => 'array', 'items' => ['type' => 'string']],
],
'required' => ['items', 'next_cursor'],
];
}
public function annotations(): array
{
return Annotations::read();
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
if (isset($input['id'])) {
$page = $this->entry($input['id']);
return $page instanceof WP_Error ? $page : true;
}
return $this->access->canList() ?? true;
}
public function execute(array $input): mixed
{
if (isset($input['id'])) {
$page = $this->entry($input['id']);
return $page instanceof WP_Error ? $page : $this->detail($page);
}
$refusal = $this->access->canList();
if ($refusal !== null) {
return $refusal;
}
$offset = Cursor::decode(is_string($input['cursor'] ?? null) ? $input['cursor'] : null);
if ($offset === null) {
return Cursor::invalid();
}
$filters = array_filter(
[
'layout_type' => $input['layout_type'] ?? null,
'scope' => $input['scope'] ?? null,
'search' => $input['search'] ?? null,
'category' => $input['category'] ?? null,
],
static fn (mixed $value): bool => is_string($value) && $value !== ''
);
// Audit L3: without edit_others_posts the query holds the own entries only (PostGuard).
$author = PostGuard::ownPostsOnly(DiviLibraryStore::POST_TYPE);
if ($author !== null) {
$filters['author'] = $author;
}
return $this->page($filters, Cursor::limit($input['limit'] ?? null), $offset);
}
/**
* One page of the entries the user may edit. The cursor counts the entries this user was shown
* ($skip), never the rows left out (audit L3), so it tells nothing about hidden entries; each call
* reads the rows from the start again and skips the first $skip readable ones. next_cursor is
* null when no readable entry follows (or the scan cap is reached with nothing found).
*
* @param array<string, string|int> $filters
* @return array{items: list<array<string, mixed>>, next_cursor: ?string, notes?: list<string>}
*/
private function page(array $filters, int $limit, int $skip): array
{
$store = $this->access->store();
$items = [];
$readable = 0;
$position = 0;
while ($position < $skip + self::MAX_SCAN) {
$rows = $store->find($filters, $limit, $position);
foreach ($rows as $post) {
++$position;
if ($this->access->canRead($post) !== null || $readable++ < $skip) {
continue;
}
if (count($items) === $limit) {
return ['items' => $items, 'next_cursor' => Cursor::encode($skip + $limit)];
}
$items[] = $store->describe($post);
}
if (count($rows) <= $limit) {
return ['items' => $items, 'next_cursor' => null];
}
}
// Review M-12: say why the page is short, so the client neither thinks the list ended nor loops.
return [
'items' => $items,
'next_cursor' => $items === [] ? null : Cursor::encode($skip + count($items)),
'notes' => [sprintf(
'Nach %d gelesenen Bibliothekseinträgen abgebrochen (Einträge ohne Bearbeitungsrecht werden '
. 'übersprungen); %s',
self::MAX_SCAN,
$items === []
? 'weitere Einträge mit Filtern (layout_type, scope, search, category) eingrenzen.'
: 'mit next_cursor weiterblättern oder mit Filtern eingrenzen.'
)],
];
}
/**
* The library entry $id loaded for reading. Anything that is not a library entry reads like a
* missing ID, so the tool tells nothing about other posts (ruling P12).
*/
private function entry(mixed $id): DiviPage|WP_Error
{
$postId = PostGuard::id($id);
if ($postId === null) {
return Errors::invalidInput('id ist keine gültige Eintrags-ID.', ['id' => 'positive Ganzzahl']);
}
$item = $this->access->item($postId, false);
if (! $item instanceof WP_Post) {
return $item ?? PostGuard::notFound($postId);
}
$page = $this->loader->load($postId, false);
if ($page instanceof WP_Error) {
return $page;
}
return $page->isLibraryItem() ? $page : PostGuard::notFound($postId);
}
/** @return array<string, mixed> */
private function detail(DiviPage $page): array
{
$item = $this->access->store()->describe($page->post);
$outline = new Outline($this->design->read()->presetNames());
$result = [
'items' => [$item],
'next_cursor' => null,
'outline' => $outline->text($outline->rows($page->index)),
'tree_rev' => $page->treeRev(),
'rev' => Rev::ofPost($page->post),
'edit_url' => $page->editUrl(),
];
// Audit L2: a normal entry that pages show as an instance names them too.
$report = $this->usages->report((int) $page->post->ID, false);
if ($item['scope'] === 'global' || $report->exists()) {
$result += $report->listing();
}
return $result;
}
}
@@ -0,0 +1,284 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
use LIT\MCP\Integrations\Divi\Pages\DiviErrors;
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\WordPress\Content\NewDraft;
use LIT\MCP\Integrations\WordPress\PostSaveFollowUp;
use Throwable;
use WP_Error;
use WP_Term;
defined('ABSPATH') || exit;
/**
* lit/library-save: a node of a Divi page (or library entry) becomes a new Divi Library entry,
* normal or global (spec §5.7, E4). The entry gets the node's stored bytes in the
* divi/placeholder frame (LibraryContent::wrap, line breaks between blocks unified like
* LibraryContent::load), saved through DiviSaver::saveNew with SaveScope::LibraryItem (the one
* content write, D7; no Divi flags). The node is checked by StoredNodeGuard (E6) because it moves
* to another post. The source is never written – linking it to the entry is
* lit/library-make-global (Etappe 3b). All or nothing through NewDraft (ruling P6): any failure
* after the insert deletes the new entry, a failed delete is an honest partial_write.
*/
final class LibrarySaveAbility implements Ability
{
private const TYPE_LABELS = ['section' => 'Abschnitt', 'row' => 'Zeile', 'module' => 'Modul'];
private const SCOPES = ['normal', 'global'];
private const REASON_LAYOUT = 'Der Inhalt des Bibliothekseintrags konnte nicht gespeichert werden.';
public function __construct(
private LibraryAccess $access,
private DiviPageLoader $loader,
private DiviWriter $writer,
private DiviSaver $saver,
private ChangeLog $log,
private DiviModules $modules
) {
}
public function name(): string
{
return 'lit/library-save';
}
public function label(): string
{
return 'In der Divi-Bibliothek speichern';
}
public function description(): string
{
return 'Speichert einen Divi-Knoten (Abschnitt, Zeile oder Modul) als neuen Eintrag der Divi-Bibliothek – '
. 'normal oder global (scope). Der Knoten auf der Seite bleibt unverändert. post_id, tree_rev und die '
. 'Pfad-ID aus lit-divi-outline; Kategorien und Schlagwörter nur vorhandene. Globale Einträge mit '
. 'lit-library-insert (mode "global") auf Seiten einfügen.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'post_id' => [
'type' => 'integer',
'minimum' => 1,
'description' => 'Seite (oder Eintrag) mit dem Knoten.',
],
'tree_rev' => ['type' => 'string', 'description' => 'tree_rev aus lit-divi-outline dieser Seite.'],
'id' => ['type' => 'string', 'description' => 'Pfad-ID des Knotens, z. B. "s1".'],
'title' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 200],
'scope' => ['type' => 'string', 'enum' => self::SCOPES, 'description' => 'Standard: normal.'],
'categories' => ['type' => 'array', 'maxItems' => 10, 'items' => ['type' => 'string']],
'tags' => ['type' => 'array', 'maxItems' => 10, 'items' => ['type' => 'string']],
],
'required' => ['post_id', 'tree_rev', 'id', 'title'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema(
['library_id' => ['type' => 'integer'], 'tree_rev' => ['type' => 'string']],
['library_id', 'tree_rev']
);
}
public function annotations(): array
{
return Annotations::write(false);
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
$refusal = $this->access->canSave(self::isGlobal($input));
if ($refusal !== null) {
return $refusal;
}
$page = $this->loader->load($input['post_id'] ?? null, false);
return $page instanceof WP_Error ? $page : true;
}
/** @throws WriteFailed After the insert (the new entry is rolled back first, ruling P6). */
public function execute(array $input): mixed
{
if (! in_array($input['scope'] ?? 'normal', self::SCOPES, true)) {
return Errors::invalidInput('scope ist „normal“ oder „global“; nichts wurde angelegt.', [
'scope' => 'normal oder global',
]);
}
$refusal = $this->access->canSave(self::isGlobal($input));
if ($refusal !== null) {
return $refusal;
}
$page = $this->loader->load($input['post_id'] ?? null, false);
if ($page instanceof WP_Error) {
return $page;
}
if (! is_string($input['tree_rev'] ?? null) || $input['tree_rev'] !== $page->treeRev()) {
return Errors::revMismatch(
'Die Seite wurde seit dem letzten Lesen geändert (tree_rev passt nicht); nichts wurde angelegt.',
DiviPageLoader::OUTLINE_HINT
);
}
$id = is_string($input['id'] ?? null) ? $input['id'] : '';
$node = $page->index->find($id);
if ($node === null) {
return DiviErrors::unknownNode($id, $page->post);
}
$title = sanitize_text_field(is_string($input['title'] ?? null) ? $input['title'] : '');
if ($title === '') {
return Errors::invalidInput('Titel fehlt; nichts wurde angelegt.', ['title' => 'nicht leer']);
}
$spec = LibraryItemSpec::forNode(
$node,
$this->modules->catalog(),
$title,
self::isGlobal($input),
$this->builtFor($page)
);
if (is_string($spec)) {
return Errors::invalidInput($spec . ' Nichts wurde angelegt.', ['id' => $spec]);
}
$problems = $this->writer->composition($page)->storedGuard()->problems($node);
if ($problems !== []) {
return Errors::invalidInput(
'Der Knoten enthält Einstellungen, die nur im Divi-Builder übertragen werden dürfen; nichts wurde '
. 'angelegt.',
['id' => implode('; ', $problems)]
);
}
$categories = $this->termIds('layout_category', $input['categories'] ?? []);
$tags = $this->termIds('layout_tag', $input['tags'] ?? []);
foreach (['categories' => $categories, 'tags' => $tags] as $field => $ids) {
if (is_string($ids)) {
return Errors::invalidInput(
'Unbekannte Kategorie oder unbekanntes Schlagwort; nichts wurde angelegt.',
[$field => $ids]
);
}
}
return $this->create($page, $spec->withTerms($categories, $tags), self::content($node), $input);
}
/**
* @param array<string, mixed> $input
* @return array<string, mixed>
* @throws WriteFailed
*/
private function create(DiviPage $page, LibraryItemSpec $spec, string $content, array $input): array
{
$draft = new NewDraft(null, $this->log, $this->name(), DiviLibraryStore::POST_TYPE, 'Bibliothekseintrag');
$libraryId = $this->access->store()->create($spec, $draft);
$warnings = [];
try {
$warnings = $this->saver->saveNew($libraryId, $content, SaveScope::LibraryItem);
} catch (Throwable $e) {
$draft->discard($libraryId, $e, self::REASON_LAYOUT);
}
$followUp = (new PostSaveFollowUp($this->name(), $this->log, $libraryId, 'der Bibliothekseintrag', $warnings))
->afterSave($input);
$created = $followUp->reread();
$kind = (self::TYPE_LABELS[$spec->layoutType] ?? $spec->layoutType) . ($spec->global ? ', global' : '');
$followUp->log(
DiviLibraryStore::POST_TYPE,
sprintf('Bibliothek: „%s“ angelegt (%s) aus Eintrag %d', $spec->title, $kind, (int) $page->post->ID)
);
$result = $followUp->result(sprintf('Bibliothekseintrag „%s“ angelegt (%s).', $spec->title, $kind), $created)
->with('library_id', $libraryId)
->with('tree_rev', Rev::of($created !== null ? (string) $created->post_content : $content))
->urls(null, (string) get_edit_post_link($libraryId, 'raw'));
if ($spec->global) {
$result->warning(
'Der Knoten auf der Seite bleibt eine eigenständige Kopie; verknüpfte Instanzen mit lit-library-insert '
. '(mode "global") einfügen.'
);
}
return $result->toArray();
}
/** The node's stored bytes framed like the builder's "add to library", line breaks unified. */
private static function content(Block $node): string
{
$copy = $node->copy();
$copy->unifyLineBreaks();
return LibraryContent::wrap($copy);
}
/** _et_pb_built_for_post_type: the source's post type; a library entry passes its own on. */
private function builtFor(DiviPage $page): string
{
if (! $page->isLibraryItem()) {
return (string) $page->post->post_type;
}
$builtFor = (string) get_post_meta((int) $page->post->ID, '_et_pb_built_for_post_type', true);
return $builtFor !== '' ? $builtFor : 'page';
}
/** @param array<string, mixed> $input */
private static function isGlobal(array $input): bool
{
return ($input['scope'] ?? 'normal') === 'global';
}
/** @return list<int>|string Term IDs of existing terms by name, or the problem. */
private function termIds(string $taxonomy, mixed $names): array|string
{
$ids = [];
foreach (is_array($names) ? $names : [] as $name) {
$term = is_string($name) ? get_term_by('name', $name, $taxonomy) : false;
if (! $term instanceof WP_Term) {
return sprintf(
'„%s“ gibt es nicht; nur vorhandene Begriffe (neue in der Divi-Bibliothek anlegen).',
is_string($name) ? $name : '?'
);
}
$ids[] = (int) $term->term_id;
}
return array_values(array_unique($ids));
}
}
@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library;
defined('ABSPATH') || exit;
/**
* Whether post content shows library entry $id (rulings P23, P24), fail closed:
* - Divi 5: the textual "globalModule":"<digits>" Divi's render takes (leading zeros = intval), and
* every block opener whose JSON – in any spelling, escaped keys or values included – decodes to a
* globalModule with that ID at any depth;
* - Divi 4 shortcodes – in the content outside block openers and in every decoded string value of an
* opener (Divi runs them there too): attributes parsed like shortcode_parse_atts(), global_module
* cast with (int) like Divi; a non-empty value other than "0" that casts to 0 makes Divi render the
* newest layout (et_pb_load_global_module, WP_Query p=0), so it counts for EVERY entry;
* - an opener with JSON escapes (or a globalModule/global_module) that cannot be decoded, or content
* the regex engine cannot scan, counts as a possible reference.
* Pure PHP.
*/
final class UsageMatcher
{
/** A block opener and its attribute JSON (the Parser's delimiter grammar). */
private const OPENER = '/<!--\s+wp:(?:[a-z][a-z0-9_-]*\/)?[a-z][a-z0-9_-]*\s+'
. '({(?:(?:[^}]+|}+(?=})|(?!}\s+\/?-->).)*+)?})\s+\/?-->/s';
/** An opening shortcode tag and its attribute text (get_shortcode_regex(), any tag name). */
private const TAG = '/\[\[?([\w-]+)(?![\w-])([^\]\/]*(?:\/(?!\])[^\]\/]*)*?)\/?\]/';
/** WordPress' get_shortcode_atts_regex(). */
private const ATTS = '/([\w-]+)\s*=\s*"([^"]*)"(?:\s|$)|([\w-]+)\s*=\s*\'([^\']*)\'(?:\s|$)|([\w-]+)\s*=\s*'
. '([^\s\'"]+)(?:\s|$)|"([^"]*)"(?:\s|$)|\'([^\']*)\'(?:\s|$)|(\S+)(?:\s|$)/';
public static function shows(string $content, int $id): bool
{
$textual = preg_match('/"globalModule":"0*' . $id . '"/', $content);
if ($textual !== 0) {
return true; // A match, or a regex failure (fail closed).
}
$found = preg_match_all(self::OPENER, $content, $matches);
if ($found === false) {
return true;
}
// Shortcodes outside the openers (Divi 4 pages, free text between blocks).
$outside = $found === 0 ? $content : str_replace($matches[1], '', $content);
if (self::shortcodeShows($outside, $id)) {
return true;
}
$escape = chr(92) . 'u';
foreach ($matches[1] as $json) {
if (
! str_contains($json, $escape)
&& ! str_contains($json, 'globalModule')
&& stripos($json, 'global_module') === false
) {
continue;
}
$attrs = json_decode($json, true);
if (! is_array($attrs) || self::names($attrs, $id)) {
return true;
}
foreach (self::strings($attrs) as $value) {
if (self::shortcodeShows($value, $id)) {
return true;
}
}
}
return false;
}
/** Whether a Divi 4 shortcode in $text renders entry $id (or, casting to 0, any entry). */
private static function shortcodeShows(string $text, int $id): bool
{
if (stripos($text, 'global_module') === false) {
return false;
}
$found = preg_match_all(self::TAG, $text, $tags);
if ($found === false) {
return true;
}
foreach ($tags[2] as $attributes) {
$atts = self::parseAtts($attributes);
if ($atts === null) {
return true;
}
$value = $atts['global_module'] ?? null;
// Divi: ! empty( $global_module_id ), then WP_Query p => (int) $global_module_id.
if (! is_string($value) || $value === '' || $value === '0') {
continue;
}
$cast = (int) $value;
if ($cast === 0 || $cast === $id) {
return true;
}
}
return false;
}
/**
* shortcode_parse_atts() of WordPress 6.9 (named attributes only); null when PCRE fails.
*
* @return array<string, string>|null
*/
private static function parseAtts(string $text): ?array
{
$text = preg_replace('/[\x{00a0}\x{200b}]+/u', ' ', $text);
if (! is_string($text)) {
return null;
}
$found = preg_match_all(self::ATTS, $text, $matches, PREG_SET_ORDER);
if ($found === false) {
return null;
}
$atts = [];
foreach ($matches as $m) {
foreach ([1, 3, 5] as $key) {
if (! empty($m[$key])) {
$atts[strtolower($m[$key])] = stripcslashes($m[$key + 1]);
break;
}
}
}
// WordPress rejects values with unclosed HTML elements.
foreach ($atts as $name => $value) {
if (str_contains($value, '<') && preg_match('/^[^<]*+(?:<[^>]*+>[^<]*+)*+$/', $value) !== 1) {
$atts[$name] = '';
}
}
return $atts;
}
/** @param array<array-key, mixed> $attrs Whether a globalModule key at any depth names $id. */
private static function names(array $attrs, int $id): bool
{
foreach ($attrs as $key => $value) {
if (
$key === 'globalModule'
&& (is_int($value) || (is_string($value) && preg_match('/^\d+$/D', $value) === 1))
&& (int) $value === $id
) {
return true;
}
if (is_array($value) && self::names($value, $id)) {
return true;
}
}
return false;
}
/**
* @param array<array-key, mixed> $attrs
* @return list<string> Every string value at any depth.
*/
private static function strings(array $attrs): array
{
$strings = [];
array_walk_recursive($attrs, static function (mixed $value) use (&$strings): void {
if (is_string($value)) {
$strings[] = $value;
}
});
return $strings;
}
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library;
defined('ABSPATH') || exit;
/**
* The posts that show one library entry, as the caller may see them (GlobalUsages::report()):
* readable posts by ID and title, unreadable ones only as counts (audit L4). Pure PHP.
*/
final class UsageReport
{
/**
* @param int $count Every post that shows the entry.
* @param list<array{post_id: int, title: string, status: string, type: string}> $items Readable
* posts (at most GlobalUsages::LIMIT).
* @param int $hidden Posts the caller may not read.
* @param int $live Published, private and scheduled posts among them (ruling P8a).
* @param list<int> $blocked Readable live posts the caller may not edit.
* @param int $blockedHidden Unreadable live posts the caller may not edit.
* @param bool $tooMany More live posts than GlobalUsages::HARD_CAP – not checked one by one (audit L1).
*/
public function __construct(
public readonly int $count,
public readonly array $items,
public readonly int $hidden,
public readonly int $live,
public readonly array $blocked,
public readonly int $blockedHidden,
public readonly bool $tooMany
) {
}
public function exists(): bool
{
return $this->count > 0;
}
/** Whether a confirmed edit would be refused (ruling P8a, audit L1). */
public function blocks(): bool
{
return $this->tooMany || $this->blocked !== [] || $this->blockedHidden > 0;
}
/** @return array<string, mixed> affected, affected_count and, when there are any, hidden_count. */
public function listing(): array
{
return ['affected' => $this->items, 'affected_count' => $this->count]
+ ($this->hidden > 0 ? ['hidden_count' => $this->hidden] : []);
}
/** @return array<string, mixed> listing() plus blocked_ids and blocked_hidden_count when there are any. */
public function details(): array
{
return $this->listing()
+ ($this->blocked !== [] ? ['blocked_ids' => $this->blocked] : [])
+ ($this->blockedHidden > 0 ? ['blocked_hidden_count' => $this->blockedHidden] : []);
}
}
@@ -6,9 +6,11 @@ namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Integrations\Divi\Compose\AttrWriter; use LIT\MCP\Integrations\Divi\Compose\AttrWriter;
use LIT\MCP\Integrations\Divi\Compose\Composer; use LIT\MCP\Integrations\Divi\Compose\Composer;
use LIT\MCP\Integrations\Divi\Compose\LinkTargets;
use LIT\MCP\Integrations\Divi\Compose\MediaResolver; use LIT\MCP\Integrations\Divi\Compose\MediaResolver;
use LIT\MCP\Integrations\Divi\Compose\Placeholders; use LIT\MCP\Integrations\Divi\Compose\Placeholders;
use LIT\MCP\Integrations\Divi\Compose\ShortformWriter; use LIT\MCP\Integrations\Divi\Compose\ShortformWriter;
use LIT\MCP\Integrations\Divi\Compose\StoredNodeGuard;
use LIT\MCP\Integrations\Divi\Compose\StructureRules; use LIT\MCP\Integrations\Divi\Compose\StructureRules;
use LIT\MCP\Integrations\Divi\Compose\TreeEditor; use LIT\MCP\Integrations\Divi\Compose\TreeEditor;
use LIT\MCP\Integrations\Divi\Compose\ValueCleaner; use LIT\MCP\Integrations\Divi\Compose\ValueCleaner;
@@ -45,7 +47,8 @@ class DiviComposition
private DesignSystemReader $design, private DesignSystemReader $design,
private ValueSanitizer $sanitizer = new WpValueSanitizer(), private ValueSanitizer $sanitizer = new WpValueSanitizer(),
private MediaResolver $media = new WpMediaResolver(), private MediaResolver $media = new WpMediaResolver(),
private DynamicContentMap $dynamicContent = new DynamicContentMap() private DynamicContentMap $dynamicContent = new DynamicContentMap(),
private LinkTargets $links = new WpLinkTargets()
) { ) {
} }
@@ -83,7 +86,7 @@ class DiviComposition
*/ */
public function placeholders(): Placeholders public function placeholders(): Placeholders
{ {
return $this->placeholders ??= new Placeholders($this->design->read()); return $this->placeholders ??= new Placeholders($this->design->read(), $this->links);
} }
/** The one choke point of this composition (ValueCleaner, rulings S1–S3, S7, S13, S28, S32). */ /** The one choke point of this composition (ValueCleaner, rulings S1–S3, S7, S13, S28, S32). */
@@ -116,6 +119,12 @@ class DiviComposition
return new StructureRules($this->modules->catalog()); return new StructureRules($this->modules->catalog());
} }
/** E6: the check for stored nodes that move into this composition's entry (same cleaner and references). */
public function storedGuard(): StoredNodeGuard
{
return new StoredNodeGuard($this->cleaner(), $this->references(), $this->sanitizer);
}
public function editor(): TreeEditor public function editor(): TreeEditor
{ {
return new TreeEditor($this->rules()); return new TreeEditor($this->rules());
@@ -49,7 +49,6 @@ final class DiviDeleteAbility extends DiviWriteAbility
'items' => ['type' => 'string'], 'items' => ['type' => 'string'],
'description' => 'Pfad-IDs der zu löschenden Knoten (aus lit-divi-outline).', 'description' => 'Pfad-IDs der zu löschenden Knoten (aus lit-divi-outline).',
], ],
'confirm' => ['type' => 'boolean', 'description' => 'true erst nach Rückfrage beim Nutzer.'],
]), ]),
'required' => ['post_id', 'tree_rev', 'ids'], 'required' => ['post_id', 'tree_rev', 'ids'],
'additionalProperties' => false, 'additionalProperties' => false,
@@ -0,0 +1,301 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use Closure;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\JsonMap;
use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
use Throwable;
use WP_Error;
defined('ABSPATH') || exit;
/**
* lit/divi-dynamic-sources (spec §5.7, E12): Divi's dynamic content options for a post type
* (DynamicContentOptions::get_options with a sample entry of the type the user may edit, else 0;
* no loop_* – S36; via DynamicContentMap::options()) with the placeholder that writes each, and
* the fields of the type usable as {{field:<key>}}.
*
* Answer F4: only text-like fields are offered; relation/file fields show IDs in Divi (F26), Pods
* table storage is not post meta, protected keys never (S12), unknown types not (ruling P9) – each
* left out with its reason. Hidden fields (security audit M4) are neither offered nor named. The
* field list needs the Divi role option read_dynamic_content_custom_fields (E3, F25).
*/
final class DiviDynamicSourcesAbility implements Ability
{
/** Field types Divi can show as text (arrays of strings are joined with ", ", F26). */
private const TEXT_TYPES = ['string', 'text', 'number', 'integer', 'date', 'datetime', 'time', 'choice'];
private const ID_TYPES = ['relation', 'file'];
/** Candidates the default sample lookup checks for edit_post. */
private const SAMPLE_CANDIDATES = 10;
/** @var Closure(string): int */
private Closure $samplePost;
/** @var Closure(string): ?string */
private Closure $podStorage;
/**
* @param (Closure(string): int)|null $samplePost Post type → an entry to ask Divi with (0 = none).
* @param (Closure(string): ?string)|null $podStorage Post type → Pods storage ("meta", "table");
* null without a pod.
*/
public function __construct(
private ContentSchema $schema,
private DynamicContentMap $map,
private DiviRoles $roles,
?Closure $samplePost = null,
?Closure $podStorage = null
) {
$this->samplePost = $samplePost ?? self::defaultSample(...);
$this->podStorage = $podStorage ?? self::defaultStorage(...);
}
public function name(): string
{
return 'lit/divi-dynamic-sources';
}
public function label(): string
{
return 'Dynamic-Content-Quellen';
}
public function description(): string
{
return 'Listet die Dynamic-Content-Quellen, die Divi für einen Inhaltstyp anbietet (Beitragsdaten, Links, '
. 'Website-Daten …) mit dem Platzhalter dazu ({{post:…}}, {{link:<ID>}}) bzw. dem Namen für die Vollform, '
. 'und die Felder des Typs, die sich als {{field:<Schlüssel>}} eignen. Nur textartige Felder; Beziehungs-, '
. 'Datei-, Tabellen- und unbekannte Felder stehen mit Grund unter omitted.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => ['post_type' => ['type' => 'string', 'description' => 'z. B. "page" oder "person".']],
'required' => ['post_type'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
$entry = static fn (array $properties): array => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => array_fill_keys($properties, ['type' => 'string']),
'required' => $properties,
],
];
return [
'type' => 'object',
'properties' => [
'post_type' => ['type' => 'string'],
'sample_post_id' => ['type' => 'integer'],
'sources' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'name' => ['type' => 'string'],
'label' => ['type' => 'string'],
'group' => ['type' => 'string'],
'placeholder' => ['type' => ['string', 'null']],
'settings' => ['type' => 'object'],
],
'required' => ['name', 'label', 'group', 'placeholder', 'settings'],
],
],
'fields' => $entry(['key', 'label', 'type', 'source', 'placeholder']),
'omitted' => $entry(['key', 'label', 'reason']),
'notes' => ['type' => 'array', 'items' => ['type' => 'string']],
],
'required' => ['post_type', 'sample_post_id', 'sources', 'fields', 'omitted', 'notes'],
];
}
public function annotations(): array
{
return Annotations::read();
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
$type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts');
return $type instanceof WP_Error ? $type : true;
}
public function execute(array $input): mixed
{
$type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts');
if ($type instanceof WP_Error) {
return $type;
}
$postType = (string) $input['post_type'];
$sample = ($this->samplePost)($postType);
$notes = [
'Quellen ohne Platzhalter in der Vollform als $variable({"type":"content","value":{"name":"<name>",'
. '"settings":{…}}})$ mit den genannten Einstellungen schreiben.',
];
$options = $this->map->options($sample);
if ($options === null) {
$notes[] = 'Divis Quellenliste ist nicht verfügbar; gezeigt werden die Quellen der Platzhalter {{post:…}}.';
$options = [];
foreach (DynamicContentRules::DEFAULT_DYNAMIC_CONTENT as $name => $settings) {
$options[$name] = ['label' => $name, 'group' => '', 'settings' => $settings];
}
}
$sources = [];
foreach ($options as $name => $option) {
$sources[] = [
'name' => (string) $name,
'label' => $option['label'],
'group' => $option['group'],
'placeholder' => self::placeholder((string) $name),
'settings' => JsonMap::of($option['settings']),
];
}
$fields = [];
$omitted = [];
if ($this->roles->allows('read_dynamic_content_custom_fields')) {
[$fields, $omitted] = $this->fields($postType);
} else {
$notes[] = 'Felder werden nicht gelistet: die Divi-Rolle erlaubt „Dynamic Content: benutzerdefinierte '
. 'Felder lesen“ nicht (Divi → Rollen-Editor).';
}
return [
'post_type' => $postType,
'sample_post_id' => $sample,
'sources' => $sources,
'fields' => $fields,
'omitted' => $omitted,
'notes' => $notes,
];
}
private static function placeholder(string $name): ?string
{
$post = array_search($name, Placeholders::POST, true);
if ($post !== false) {
return '{{post:' . $post . '}}';
}
if (! str_starts_with($name, 'post_link_url_')) {
return null;
}
// Review M-9: {{link:}} refuses attachments (status inherit); internal types ("_…") are no link targets.
$type = substr($name, strlen('post_link_url_'));
return $type === 'attachment' || str_starts_with($type, '_') ? null : '{{link:<ID>}}';
}
/**
* @return array{0: list<array<string, string>>, 1: list<array<string, string>>} Offered and omitted fields.
*/
private function fields(string $postType): array
{
$storage = ($this->podStorage)($postType);
$fields = [];
$omitted = [];
foreach ($this->schema->fields($postType) as $field) {
if ($field->status === FieldDefinition::HIDDEN) {
continue;
}
$entry = ['key' => $field->key, 'label' => $field->label];
$reason = self::omission($field, $storage);
if ($reason !== null) {
$omitted[] = $entry + ['reason' => $reason];
continue;
}
$fields[] = $entry + [
'type' => $field->type,
'source' => $field->source,
'placeholder' => '{{field:' . $field->key . '}}',
];
}
return [$fields, $omitted];
}
private static function omission(FieldDefinition $field, ?string $storage): ?string
{
return match (true) {
! DynamicContentRules::isMetaKey($field->key)
=> 'geschütztes oder ungültiges Meta-Feld (beginnt mit „_“)',
$field->source === 'pods' && $storage === 'table'
=> 'Pods mit Tabellen-Speicherung – Divi liest nur Post-Meta',
in_array($field->type, self::ID_TYPES, true)
=> 'Beziehungs- oder Dateifeld – Divi zeigt davon nur IDs',
$field->type === 'unknown'
=> 'Feldtyp unbekannt – Divi zeigt womöglich keinen Text',
! in_array($field->type, self::TEXT_TYPES, true)
=> sprintf('Feldtyp „%s“ ist nicht textartig', $field->type),
default => null,
};
}
/** The most recently modified entry of the type the current user may edit, else 0. */
private static function defaultSample(string $postType): int
{
$ids = get_posts([
'post_type' => $postType,
'post_status' => ['publish', 'draft', 'pending', 'private', 'future'],
'posts_per_page' => self::SAMPLE_CANDIDATES,
'orderby' => 'modified',
'order' => 'DESC',
'fields' => 'ids',
'no_found_rows' => true,
]);
foreach (is_array($ids) ? $ids : [] as $id) {
if (current_user_can('edit_post', (int) $id)) {
return (int) $id;
}
}
return 0;
}
private static function defaultStorage(string $postType): ?string
{
if (! function_exists('pods_api')) {
return null;
}
try {
$pod = pods_api()->load_pod(['name' => $postType]);
} catch (Throwable) {
return null;
}
return is_object($pod) && method_exists($pod, 'get_storage') ? (string) $pod->get_storage() : null;
}
}
@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use Closure;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use WP_Error;
defined('ABSPATH') || exit;
/**
* lit/divi-node-copy (spec §5.7, E6): a node with everything inside from one Divi entry (page or
* library entry) to a position in another. The copy keeps its bytes – global instances stay linked –
* and passes StoredNodeGuard of the target (builder-only values never move to another post for users
* without unfiltered_html). Source: edit rights via DiviPageLoader (PostGuard / LibraryAccess, P9
* hiding) and its own source_tree_rev, because path IDs are only valid for the state they were read
* in; the source is only read, so it needs no lock. Target: like lit-divi-insert (tree_rev lock,
* StructureRules, all or nothing, DiviWriter::commit with GlobalEdits – a global target entry never
* gets a nested instance). Follows lit/divi-insert's Tools-tab state (ruling P9/E9). A library entry
* as source is an insert from the library (E3, audit L5): it needs LibraryAccess::canInsert()
* (add_library, writable group "Divi-Bibliothek") and follows lit-library-insert's Tools-tab state.
*/
final class DiviNodeCopyAbility extends DiviWriteAbility
{
/**
* @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled; null = always.
* @param (Closure(): bool)|null $libraryInsertable Whether lit/library-insert is enabled (gates
* library entries as source); null = always.
*/
public function __construct(
DiviWriter $writer,
private DiviPageLoader $loader,
private ?Closure $diviWritable = null,
private ?Closure $libraryInsertable = null
) {
parent::__construct($writer);
}
public function name(): string
{
return 'lit/divi-node-copy';
}
public function label(): string
{
return 'Divi-Knoten kopieren';
}
public function description(): string
{
return 'Kopiert einen Divi-Knoten samt Inhalt von einem Eintrag (source_post_id, source_id und '
. 'source_tree_rev aus lit-divi-outline der Quelle) an eine Position eines anderen Eintrags (post_id, '
. 'tree_rev, parent/before/after). Die Kopie ist byte-gleich; globale Module bleiben verknüpft. Quelle '
. 'und Ziel dürfen auch Bibliothekseinträge sein. Alles oder nichts.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => array_merge(DiviSchema::write(), [
'source_post_id' => ['type' => 'integer', 'minimum' => 1],
'source_id' => ['type' => 'string', 'description' => 'Pfad-ID des Knotens in der Quelle.'],
'source_tree_rev' => ['type' => 'string', 'description' => 'tree_rev der Quelle (lit-divi-outline).'],
], DiviSchema::position()),
'required' => ['post_id', 'tree_rev', 'source_post_id', 'source_id', 'source_tree_rev'],
'additionalProperties' => false,
];
}
public function annotations(): array
{
return Annotations::write(false);
}
public function isDirect(): bool
{
return false;
}
/**
* lit/divi-insert's Tools-tab state, then the source's own rights and, for a library entry as
* source, the rights of an insert from the library – in permission() and execute().
*/
protected function precheck(array $input): ?WP_Error
{
if ($this->diviWritable !== null && ! ($this->diviWritable)()) {
return Errors::unsupported(
'Divi-Änderungen sind für diese Verbindung abgeschaltet (Gruppe „Divi“ auf „nur lesen“ oder '
. 'lit-divi-insert deaktiviert).',
'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.'
);
}
$source = $this->loader->load($input['source_post_id'] ?? null, false);
if ($source instanceof WP_Error) {
return $source;
}
return $source->isLibraryItem() ? $this->libraryInsertRefusal() : null;
}
/** E3/audit L5: copying out of a library entry is inserting from the library (lit-library-insert). */
private function libraryInsertRefusal(): ?WP_Error
{
if ($this->libraryInsertable !== null && ! ($this->libraryInsertable)()) {
return Errors::unsupported(
'Einfügen aus der Divi-Bibliothek ist für diese Verbindung abgeschaltet (lit-library-insert '
. 'deaktiviert oder Gruppe „Divi-Bibliothek“ auf „nur lesen“).',
'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.'
);
}
return $this->loader->library()?->canInsert();
}
protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
{
$source = $this->loader->load($input['source_post_id'] ?? null, false);
if ($source instanceof WP_Error) {
return $source;
}
if (! is_string($input['source_tree_rev'] ?? null) || $input['source_tree_rev'] !== $source->treeRev()) {
return Errors::revMismatch(
'Die Quelle wurde seit dem letzten Lesen geändert (source_tree_rev passt nicht); nichts wurde '
. 'gespeichert.',
'Die Quelle mit lit-divi-outline neu lesen und source_tree_rev sowie die Pfad-ID übernehmen.'
);
}
$id = is_string($input['source_id'] ?? null) ? $input['source_id'] : '';
$node = $source->index->find($id);
if ($node === null) {
return DiviErrors::unknownNode($id, $source->post);
}
$copy = $node->copy();
if ($source->isLibraryItem()) {
// Finding 9: library entries from Divi's dialog use CRLF; unified before every takeover
// (LibraryContent::load), as lit-library-insert and lit/library-save do.
$copy->unifyLineBreaks();
}
$problems = $composition->storedGuard()->problems($copy);
if ($problems !== []) {
return Errors::invalidInput(
'Der Knoten enthält Einstellungen, die nur im Divi-Builder übertragen werden dürfen; nichts wurde '
. 'gespeichert.',
['source_id' => implode('; ', $problems)]
);
}
$editor = $composition->editor();
$position = $editor->position($page->index, $input);
if (is_string($position)) {
return DiviErrors::position($position);
}
$violation = $editor->insert($position, [$copy]);
if ($violation !== null) {
return Errors::invalidInput(
'Der Knoten passt nicht an diese Stelle; nichts wurde gespeichert.',
['source_id' => $violation]
);
}
return (new DiviChange(sprintf('Knoten %s aus Eintrag %d kopiert', $id, (int) $source->post->ID)))
->record($copy, 'inserted');
}
}
@@ -85,6 +85,7 @@ final class DiviNodeGetAbility implements Ability
], ],
'builder_version' => ['type' => ['string', 'null']], 'builder_version' => ['type' => ['string', 'null']],
'children' => ['type' => 'array', 'items' => ['type' => 'string']], 'children' => ['type' => 'array', 'items' => ['type' => 'string']],
'global' => ['type' => ['object', 'null']],
], ],
'required' => ['post_id', 'tree_rev', 'rev', 'id', 'type', 'editable', 'attrs', 'shortform', 'children'], 'required' => ['post_id', 'tree_rev', 'rev', 'id', 'type', 'editable', 'attrs', 'shortform', 'children'],
]; ];
@@ -128,6 +129,16 @@ final class DiviNodeGetAbility implements Ability
$editable = $block->isDivi() && ! $block->isOpaque(); $editable = $block->isDivi() && ! $block->isOpaque();
$version = $block->attrs()['builderVersion'] ?? null; $version = $block->attrs()['builderVersion'] ?? null;
$global = NodeSummary::globalRef($block);
if ($global !== null) {
$global['hint'] = sprintf(
'Globales Modul: Inhalt im Bibliothekseintrag %s bearbeiten (lit-divi-outline mit post_id %s); '
. 'jede Änderung dort wirkt auf alle Seiten mit diesem Modul.',
$global['id'],
$global['id']
);
}
return [ return [
'post_id' => (int) $page->post->ID, 'post_id' => (int) $page->post->ID,
'tree_rev' => $page->treeRev(), 'tree_rev' => $page->treeRev(),
@@ -147,6 +158,7 @@ final class DiviNodeGetAbility implements Ability
'children' => $editable 'children' => $editable
? array_values(array_filter(array_map([$page->index, 'idOf'], $block->children()))) ? array_values(array_filter(array_map([$page->index, 'idOf'], $block->children())))
: [], : [],
'global' => $global,
]; ];
} }
} }
@@ -98,10 +98,14 @@ final class DiviOutlineAbility implements Ability
'type' => 'boolean', 'type' => 'boolean',
'description' => 'Weitere Kinder dieses Knotens wurden wegen "limit" abgeschnitten.', 'description' => 'Weitere Kinder dieses Knotens wurden wegen "limit" abgeschnitten.',
], ],
'global_id' => [
'type' => ['string', 'null'],
'description' => 'Bibliotheks-ID eines globalen Moduls.',
],
], ],
'required' => [ 'required' => [
'id', 'type', 'label', 'excerpt', 'info', 'presets', 'children', 'editable', 'depth', 'id', 'type', 'label', 'excerpt', 'info', 'presets', 'children', 'editable', 'depth',
'truncated', 'truncated', 'global_id',
], ],
], ],
], ],
@@ -187,7 +191,8 @@ final class DiviOutlineAbility implements Ability
if (in_array(false, array_column($rows, 'editable'), true)) { if (in_array(false, array_column($rows, 'editable'), true)) {
$hints[] = 'Knoten mit "nur lesbar" (globale Module, fremde Blöcke) lassen sich nur als Ganzes ' $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.'; . 'verschieben, duplizieren oder löschen. Globale Module [global <ID>] im Bibliothekseintrag '
. 'bearbeiten: lit-divi-outline mit post_id <ID> – die Änderung wirkt auf alle Seiten.';
} }
return $hints; return $hints;
+12 -2
View File
@@ -21,10 +21,17 @@ final class DiviPage
public function __construct( public function __construct(
public readonly WP_Post $post, public readonly WP_Post $post,
public readonly Document $document, public readonly Document $document,
public readonly NodeIndex $index public readonly NodeIndex $index,
private bool $libraryItem = false
) { ) {
} }
/** Whether the entry is a Divi Library entry (et_pb_layout), loaded through LibraryAccess. */
public function isLibraryItem(): bool
{
return $this->libraryItem;
}
public function treeRev(): string public function treeRev(): string
{ {
return Rev::of((string) $this->post->post_content); return Rev::of((string) $this->post->post_content);
@@ -40,7 +47,10 @@ final class DiviPage
public function editUrl(): string public function editUrl(): string
{ {
return self::builderUrl($this->post); // A library entry has no public permalink (public: false); Divi's library list opens it here.
return $this->libraryItem
? (string) get_edit_post_link($this->post, 'raw')
: self::builderUrl($this->post);
} }
/** The entry opened in the Divi Visual Builder – the edit_url of every Divi write (review I-1). */ /** The entry opened in the Divi Visual Builder – the edit_url of every Divi write (review I-1). */
+24 -9
View File
@@ -8,31 +8,45 @@ use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup; use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Log; use LIT\MCP\Log;
use WP_Error; use WP_Error;
use WP_Post;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/** /**
* Loads an entry for the Divi tools: rights and status via PostGuard (spec §4.5), then the * Loads an entry for the Divi tools: Divi Library entries via LibraryAccess (E1/E3), every other
* block tree. Divi 4 shortcode content and unbalanced markup are refused with a hint – * entry with its rights and status via PostGuard (spec §4.5), then the block tree. Divi 4
* spec §3.4: Divi 4 content is kept, never edited. * shortcode content and unbalanced markup are refused with a hint – spec §3.4: Divi 4 content
* is kept, never edited.
*/ */
final class DiviPageLoader final class DiviPageLoader
{ {
public const OUTLINE_HINT = 'Aktuellen Stand mit lit/divi-outline (direkt: lit-divi-outline) neu lesen und die ' public const OUTLINE_HINT = 'Aktuellen Stand mit lit/divi-outline (direkt: lit-divi-outline) neu lesen und die '
. 'Änderung mit der neuen tree_rev wiederholen.'; . 'Änderung mit der neuen tree_rev wiederholen.';
public function __construct(private ContentSchema $schema, private Parser $parser = new Parser()) public function __construct(
private ContentSchema $schema,
private Parser $parser = new Parser(),
private ?LibraryAccess $library = null
) {
}
/** The library access the Divi tools use, null when library entries are not reachable. */
public function library(): ?LibraryAccess
{ {
return $this->library;
} }
/** Readers pass $forWrite = false (trashed entries may be read, like lit-content-get). */ /** Readers pass $forWrite = false (trashed entries may be read, like lit-content-get). */
public function load(mixed $postId, bool $forWrite): DiviPage|WP_Error public function load(mixed $postId, bool $forWrite): DiviPage|WP_Error
{ {
$post = PostGuard::editable($postId, $this->schema, 'post_id', ! $forWrite); // Library entries (E1): with a library access, for reading and writing.
$item = $this->library?->item($postId, $forWrite);
$post = $item ?? PostGuard::editable($postId, $this->schema, 'post_id', ! $forWrite);
if ($post instanceof WP_Error) { if ($post instanceof WP_Error) {
return $post; return $post;
} }
@@ -64,7 +78,7 @@ final class DiviPageLoader
); );
} }
return new DiviPage($post, $document, NodeIndex::build($document)); return new DiviPage($post, $document, NodeIndex::build($document), $item instanceof WP_Post);
} }
/** The entry loaded for writing and checked by writable() – the refusal of either step. */ /** The entry loaded for writing and checked by writable() – the refusal of either step. */
@@ -79,13 +93,14 @@ final class DiviPageLoader
} }
/** /**
* Additional checks for writing: the type must use the Divi builder, the content must be a * Additional checks for writing: the type must use the Divi builder (library entries always do),
* Divi 5 layout (or empty) and saving must not publish a missed schedule (spec §2). * the content must be a Divi 5 layout (or empty) and saving must not publish a missed schedule
* (spec §2).
*/ */
public function writable(DiviPage $page): ?WP_Error public function writable(DiviPage $page): ?WP_Error
{ {
$post = $page->post; $post = $page->post;
if (! $this->schema->diviEnabled((string) $post->post_type)) { if (! $page->isLibraryItem() && ! $this->schema->diviEnabled((string) $post->post_type)) {
return Errors::unsupported( return Errors::unsupported(
sprintf('Für den Inhaltstyp "%s" ist der Divi-Builder nicht aktiviert.', $post->post_type), sprintf('Für den Inhaltstyp "%s" ist der Divi-Builder nicht aktiviert.', $post->post_type),
'In Divi → Theme-Optionen → Builder den Builder für diesen Inhaltstyp aktivieren (Administrator).' 'In Divi → Theme-Optionen → Builder den Builder für diesen Inhaltstyp aktivieren (Administrator).'
@@ -29,9 +29,11 @@ defined('ABSPATH') || exit;
* *
* Per page all or nothing: DiviWriter::page() checks rights and the tree_rev lock, every hit is * Per page all or nothing: DiviWriter::page() checks rights and the tree_rev lock, every hit is
* computed first (TextReplacer, ruling S8), a single refused place fails the page and nothing of * computed first (TextReplacer, ruling S8), a single refused place fails the page and nothing of
* it is written; DiviWriter::save() stores it with its own restore-point revision and change * it is written; DiviWriter::commit() stores it with its own restore-point revision and change
* log. The batch reports every page (spec §8.2, ruling S10): any Throwable of one page is logged * log (ruling P1: a library entry keeps its shape and gets no Divi flags; a global entry is never
* and reported there, the others go on. Inserted text is cleaned by the field kind through * changed here – the target reports the pages it shows on, answer F2). The batch reports every
* page (spec §8.2, ruling S10): any Throwable of one page is logged and reported there, the
* others go on. Inserted text is cleaned by the field kind through
* ValueCleaner::textReplacementFor() (S2/S28); URL and code fields are never touched. * ValueCleaner::textReplacementFor() (S2/S28); URL and code fields are never touched.
*/ */
final class DiviReplaceTextAbility implements Ability final class DiviReplaceTextAbility implements Ability
@@ -366,7 +368,13 @@ final class DiviReplaceTextAbility implements Ability
$change->record($block, 'updated'); $change->record($block, 'updated');
} }
$saved = $this->writer->save($this->name(), $page, $change, $input); // Ruling P1: through commit() like every Divi tool – library entries keep their shape and get
// no Divi flags. $input never carries confirm, so a global entry only answers with its dry
// run (the pages it would change, answer F2) and is never saved from here.
$saved = $this->writer->commit($this->name(), $page, $change, $input);
if ($saved instanceof WP_Error) {
return $base + self::refused($saved);
}
return $base + [ return $base + [
'ok' => true, 'ok' => true,
@@ -378,6 +386,27 @@ final class DiviReplaceTextAbility implements Ability
]; ];
} }
/**
* A page commit() refused: its message, and for a global library entry the pages it shows on.
*
* @return array<string, mixed>
*/
private static function refused(WP_Error $error): array
{
$data = $error->get_error_data();
if (Errors::codeOf($error) !== 'confirm_required' || ! is_array($data)) {
return ['ok' => false, 'error' => Errors::clientMessage($error) . ' Diese Seite wurde nicht geändert.'];
}
return [
'ok' => false,
'error' => Errors::clientMessage($error) . ' Globale Bibliothekseinträge nicht mit lit/divi-replace-text, '
. 'sondern nach Rückfrage mit lit-divi-update (confirm: true) ändern.',
'affected' => $data['affected'] ?? [],
'affected_count' => $data['affected_count'] ?? 0,
] + (isset($data['hidden_count']) ? ['hidden_count' => $data['hidden_count']] : []);
}
/** /**
* Every hit of $page (DiviTextSearch::replace()), cleaned by the choke point of the page's * Every hit of $page (DiviTextSearch::replace()), cleaned by the choke point of the page's
* own composition (ruling S22). * own composition (ruling S22).
+30 -13
View File
@@ -40,16 +40,20 @@ defined('ABSPATH') || exit;
* those hooks except the content itself (already cleaned); anything a third-party hook writes * those hooks except the content itself (already cleaned); anything a third-party hook writes
* there is saved without kses, as it would be for an administrator. The rollback write in * there is saved without kses, as it would be for an administrator. The rollback write in
* restore() uses the same window with the previous stored content; saveNew() (the first layout * restore() uses the same window with the previous stored content; saveNew() (the first layout
* of a new draft, lit/content-create) uses it for its single wp_update_post(). * of a new post – a draft of lit/content-create or a library entry of lit/library-save) uses it
* for its single wp_update_post().
* *
* Divi's own save filters are NOT kses and are never removed (ruling S7, defense in depth): * Divi's own save filters are NOT kses and are never removed (ruling S7, defense in depth):
* Security::initialize() (Divi 5.2.1 Security.php:74-81) registers on wp_insert_post_data * Security::initialize() (Divi 5.2.1 Security.php:74-81) registers on wp_insert_post_data
* AttributeSecurity (priority 5, always), HtmlSecurity (priority 6, by capability) and * AttributeSecurity (priority 5, always), HtmlSecurity (priority 6, by capability) and
* DynamicContentFixes (priority 10: strips enable_html for users without unfiltered_html). The first two re-serialize * DynamicContentFixes (priority 10: strips enable_html for users without unfiltered_html). The first
* the whole page when it holds custom attributes or htmlBefore/htmlAfter/elementType, so on such * two re-serialize the whole page when it holds custom attributes or htmlBefore/htmlAfter/elementType, so on such
* pages the bytes change for every user – reported as CHANGED_ON_SAVE. Finding R2 / rulings * pages the bytes change for every user – reported as CHANGED_ON_SAVE. Finding R2 / rulings
* S5, S7: docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md. * S5, S7: docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md.
* *
* SaveScope: library entries get no Divi flags; a global (or referenced) entry additionally clears
* the static CSS of every page and the Theme Builder cache.
*
* If saving throws, the previous content is written back (WriteFailed: rolled back or not). * If saving throws, the previous content is written back (WriteFailed: rolled back or not).
* Not final so abilities can be tested with a mock. * Not final so abilities can be tested with a mock.
*/ */
@@ -64,7 +68,7 @@ class DiviSaver
* @return array{revision_id: ?int, warnings: list<string>} * @return array{revision_id: ?int, warnings: list<string>}
* @throws WriteFailed * @throws WriteFailed
*/ */
public function save(WP_Post $post, string $content): array public function save(WP_Post $post, string $content, SaveScope $scope = SaveScope::Page): array
{ {
$postId = (int) $post->ID; $postId = (int) $post->ID;
$revisionId = null; $revisionId = null;
@@ -90,6 +94,7 @@ class DiviSaver
} }
$warnings = []; $warnings = [];
if ($scope->setsFlags()) {
FollowUp::run( FollowUp::run(
static fn () => DiviFlags::set($postId), static fn () => DiviFlags::set($postId),
'divi builder flags', 'divi builder flags',
@@ -97,10 +102,19 @@ class DiviSaver
. 'einmal im Divi-Builder öffnen und speichern.', . 'einmal im Divi-Builder öffnen und speichern.',
$warnings $warnings
); );
}
FollowUp::run( FollowUp::run(
static function () use ($postId): void { static function () use ($postId, $scope): void {
if (class_exists('ET_Core_PageResource')) { if (! class_exists('ET_Core_PageResource')) {
return;
}
\ET_Core_PageResource::remove_static_resources($postId, 'all', true); \ET_Core_PageResource::remove_static_resources($postId, 'all', true);
// A global library entry is part of every page that shows it (spec §5.6 step 5).
if ($scope->clearsSiteCss()) {
\ET_Core_PageResource::remove_static_resources('all', 'all', true);
if (function_exists('et_theme_builder_clear_wp_cache')) {
et_theme_builder_clear_wp_cache('all');
}
} }
}, },
'divi static css', 'divi static css',
@@ -118,24 +132,27 @@ class DiviSaver
} }
/** /**
* The first content of a brand-new draft (lit/content-create with divi, which inserted the * The first content of a brand-new post: a draft of lit/content-create with divi or a library
* draft with its normal WordPress fields – title and excerpt went through kses as usual). * entry of lit/library-save, both inserted with their normal WordPress fields first (title and
* Same kses window as save(), holding only this wp_update_post() and its hooks; no restore * excerpt went through kses as usual). Same kses window as save(), holding only this
* point (the draft has no earlier state) and no static CSS to clear. The Divi flags are part * wp_update_post() and its hooks; no restore point (the post has no earlier state) and no
* of the write here: without them the new entry is no Divi layout. Every failure throws – * static CSS to clear. With SaveScope::Page the Divi flags are part of the write (without them
* the caller deletes the draft (all or nothing). * a new page is no Divi layout); SaveScope::LibraryItem sets none. Every failure throws – the
* caller deletes the new post (all or nothing).
* *
* @return list<string> Warnings (CHANGED_ON_SAVE). * @return list<string> Warnings (CHANGED_ON_SAVE).
* @throws RuntimeException * @throws RuntimeException
*/ */
public function saveNew(int $postId, string $content): array public function saveNew(int $postId, string $content, SaveScope $scope = SaveScope::Page): array
{ {
$refusal = self::refusal($this->writeContent($postId, $content)); $refusal = self::refusal($this->writeContent($postId, $content));
if ($refusal !== null) { if ($refusal !== null) {
throw new RuntimeException(sprintf('wp_update_post(%d) refused the Divi layout: %s', $postId, $refusal)); throw new RuntimeException(sprintf('wp_update_post(%d) refused the Divi layout: %s', $postId, $refusal));
} }
if ($scope->setsFlags()) {
DiviFlags::set($postId); DiviFlags::set($postId);
}
$stored = get_post($postId); $stored = get_post($postId);
@@ -18,6 +18,11 @@ final class DiviSchema
'type' => 'string', 'type' => 'string',
'description' => 'tree_rev aus lit-divi-outline bzw. der letzten Divi-Änderung (Pflicht).', 'description' => 'tree_rev aus lit-divi-outline bzw. der letzten Divi-Änderung (Pflicht).',
], ],
'confirm' => [
'type' => 'boolean',
'description' => 'Bei globalen Bibliothekseinträgen (und beim Löschen): true erst, nachdem der Nutzer '
. 'den Probelauf (confirm_required mit den betroffenen Seiten) bestätigt hat.',
],
]; ];
} }
@@ -14,8 +14,8 @@ defined('ABSPATH') || exit;
* The frame of the Divi write tools (spec §5.6): permission and execute both load the page * The frame of the Divi write tools (spec §5.6): permission and execute both load the page
* through DiviWriter::page() (rights, Divi layout, tree_rev lock – execute never trusts state * through DiviWriter::page() (rights, Divi layout, tree_rev lock – execute never trusts state
* from permission), the tool changes the tree in change() – only when its whole input * from permission), the tool changes the tree in change() – only when its whole input
* validated, all or nothing (ruling S39) – and DiviWriter::save() stores it and builds the * validated, all or nothing (ruling S39) – and DiviWriter::commit() stores it (library entries:
* result. The composition is bound to the page (DiviWriter::composition(), ruling S22). * GlobalEdits) and builds the result. The composition is bound to the page (DiviWriter::composition(), ruling S22).
*/ */
abstract class DiviWriteAbility implements Ability abstract class DiviWriteAbility implements Ability
{ {
@@ -33,7 +33,15 @@ abstract class DiviWriteAbility implements Ability
public function outputSchema(): ?array public function outputSchema(): ?array
{ {
return ToolResult::schema(['tree_rev' => ['type' => 'string']], ['tree_rev']); return ToolResult::schema([
'tree_rev' => ['type' => 'string'],
// Global library entries (answer F2): the pages that show the entry, and the backup
// made when revisions are off.
'affected' => ['type' => 'array', 'items' => ['type' => 'object']],
'affected_count' => ['type' => 'integer'],
'hidden_count' => ['type' => 'integer'],
'backup_id' => ['type' => 'integer'],
], ['tree_rev']);
} }
public function isDirect(): bool public function isDirect(): bool
@@ -43,6 +51,11 @@ abstract class DiviWriteAbility implements Ability
final public function permission(array $input): bool|WP_Error final public function permission(array $input): bool|WP_Error
{ {
$refusal = $this->precheck($input);
if ($refusal !== null) {
return $refusal;
}
$page = $this->writer->page($input); $page = $this->writer->page($input);
return $page instanceof WP_Error ? $page : true; return $page instanceof WP_Error ? $page : true;
@@ -50,6 +63,11 @@ abstract class DiviWriteAbility implements Ability
final public function execute(array $input): mixed final public function execute(array $input): mixed
{ {
$refusal = $this->precheck($input);
if ($refusal !== null) {
return $refusal;
}
$page = $this->writer->page($input); $page = $this->writer->page($input);
if ($page instanceof WP_Error) { if ($page instanceof WP_Error) {
return $page; return $page;
@@ -57,9 +75,20 @@ abstract class DiviWriteAbility implements Ability
$change = $this->change($page, $input, $this->writer->composition($page)); $change = $this->change($page, $input, $this->writer->composition($page));
if ($change instanceof WP_Error) { if ($change instanceof WP_Error) {
return $change; return $this->writer->notice($page, $change);
} }
return $this->writer->save($this->name(), $page, $change, $input); return $this->writer->commit($this->name(), $page, $change, $input);
}
/**
* Checks of a tool beyond the page (rights and Tools-tab gates of another group) – run in
* permission() and again in execute(). Null = none.
*
* @param array<string, mixed> $input
*/
protected function precheck(array $input): ?WP_Error
{
return null;
} }
} }
+89 -5
View File
@@ -13,6 +13,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Outline; use LIT\MCP\Integrations\Divi\BlockTree\Outline;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer; use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Library\GlobalEdits;
use LIT\MCP\Integrations\WordPress\PostSaveFollowUp; use LIT\MCP\Integrations\WordPress\PostSaveFollowUp;
use WP_Error; use WP_Error;
@@ -23,7 +24,8 @@ defined('ABSPATH') || exit;
* (ruling P16; a mismatch is rev_mismatch and nothing is written), let the ability change the * (ruling P16; a mismatch is rev_mismatch and nothing is written), let the ability change the
* tree – only when its whole input validated (all or nothing) –, serialize, save via DiviSaver, * tree – only when its whole input validated (all or nothing) –, serialize, save via DiviSaver,
* then lit_mcp_after_save, change log and the ToolResult with changed nodes, affected outline, * then lit_mcp_after_save, change log and the ToolResult with changed nodes, affected outline,
* quality warnings (spec §5.5), revision_id, the new rev and tree_rev. * quality warnings (spec §5.5), revision_id, the new rev and tree_rev. commit() is the entry point
* of the tools: library entries additionally pass GlobalEdits (shape, dry run/confirm, backup).
*/ */
final class DiviWriter final class DiviWriter
{ {
@@ -35,7 +37,8 @@ final class DiviWriter
private DiviSaver $saver, private DiviSaver $saver,
private ChangeLog $log, private ChangeLog $log,
private DesignSystemReader $design, private DesignSystemReader $design,
private DiviComposition $composition private DiviComposition $composition,
private ?GlobalEdits $globals = null
) { ) {
} }
@@ -79,8 +82,13 @@ final class DiviWriter
* @return array<string, mixed> * @return array<string, mixed>
* @throws WriteFailed * @throws WriteFailed
*/ */
public function save(string $ability, DiviPage $page, DiviChange $change, array $input): array public function save(
{ string $ability,
DiviPage $page,
DiviChange $change,
array $input,
SaveScope $scope = SaveScope::Page
): array {
$post = $page->post; $post = $page->post;
$content = Serializer::document($page->document); $content = Serializer::document($page->document);
if ($content === (string) $post->post_content) { if ($content === (string) $post->post_content) {
@@ -97,7 +105,7 @@ final class DiviWriter
$warnings = array_merge($change->warnings(), $quality); $warnings = array_merge($change->warnings(), $quality);
try { try {
$saved = $this->saver->save($post, $content); $saved = $this->saver->save($post, $content, $scope);
} catch (WriteFailed $e) { } catch (WriteFailed $e) {
if (! $e->rolledBack) { if (! $e->rolledBack) {
$this->log->recordPartial( $this->log->recordPartial(
@@ -140,6 +148,82 @@ final class DiviWriter
return $tool->toArray(); return $tool->toArray();
} }
/**
* save() for the Divi tools (E2): pages as before; a library entry keeps its shape, a global one
* needs confirm (dry run first), gets a backup when revisions are off and clears every page's
* cache; the result then names the affected pages.
*
* @param array<string, mixed> $input
* @return array<string, mixed>|WP_Error
* @throws WriteFailed
*/
public function commit(string $ability, DiviPage $page, DiviChange $change, array $input): array|WP_Error
{
if (! $page->isLibraryItem()) {
return $this->save($ability, $page, $change, $input);
}
if ($this->globals === null) {
return Errors::unsupported(
'Bibliothekseinträge lassen sich über diese Verbindung nicht ändern.',
'Den Eintrag im Divi-Builder bearbeiten.'
);
}
if (Serializer::document($page->document) === (string) $page->post->post_content) {
return $this->save($ability, $page, $change, $input, SaveScope::LibraryItem);
}
$prepared = $this->globals->prepare($page, $input);
if ($prepared instanceof WP_Error) {
return $prepared;
}
try {
$saved = $this->save($ability, $page, $change, $input, $prepared['scope']);
} catch (WriteFailed $e) {
throw self::keepingBackup($e, $prepared['details']['backup_id'] ?? null);
}
return array_merge($saved, $prepared['details']);
}
/**
* Ruling P14: GlobalEdits made a backup (revisions off) before the save failed – the error names
* it, because it stays in the trash and is the only earlier state.
*/
private static function keepingBackup(WriteFailed $e, mixed $backupId): WriteFailed
{
if (! is_int($backupId)) {
return $e;
}
$kept = sprintf(
'Sicherung %d liegt im Papierkorb (vorheriger Stand des Bibliothekseintrags, in WordPress '
. 'wiederherstellbar).',
$backupId
);
if ($e->rolledBack) {
return $e->withDetails(
['backup_id' => $backupId],
$kept . ' Den Aufruf erst nach Prüfung mit lit-divi-outline wiederholen.',
Errors::notSavedKeeping(sprintf('Sicherung %d im Papierkorb', $backupId))
);
}
return $e->withDetails(
['backup_id' => $backupId],
'Den Aufruf nicht einfach wiederholen. ' . $kept . ' Zuerst den Stand mit lit-divi-outline prüfen und '
. 'dem Nutzer Bescheid geben.'
);
}
/** A tool's confirm_required on a global library entry, extended by the affected pages. */
public function notice(DiviPage $page, WP_Error $error): WP_Error
{
return $page->isLibraryItem() && $this->globals !== null ? $this->globals->notice($page, $error) : $error;
}
/** /**
* Outline of the sections that contain changed nodes (breadth limit of ruling S18). A removed * Outline of the sections that contain changed nodes (breadth limit of ruling S18). A removed
* node has no block any more, but its ID from before the removal still names the (surviving) * node has no block any more, but its ID from before the removal still names the (surviving)
@@ -31,7 +31,8 @@ defined('ABSPATH') || exit;
*/ */
class DynamicContentMap class DynamicContentMap
{ {
private const REGISTRY = 'ET\Builder\Packages\Module\Layout\Components\DynamicContent\DynamicContentOptions'; /** Divi's dynamic-content registry class; also checked by DiviCompatibility. */
public const REGISTRY = 'ET\Builder\Packages\Module\Layout\Components\DynamicContent\DynamicContentOptions';
/** /**
* @param (Closure(int): mixed)|null $registry Registry lookup by post ID (tests); null = Divi's. * @param (Closure(int): mixed)|null $registry Registry lookup by post ID (tests); null = Divi's.
@@ -42,6 +43,51 @@ class DynamicContentMap
/** @return array<string, array<string, string>>|null Null = use DynamicContentRules' default map. */ /** @return array<string, array<string, string>>|null Null = use DynamicContentRules' default map. */
public function forPost(int $postId): ?array public function forPost(int $postId): ?array
{
$raw = $this->raw($postId);
return $raw === null ? null : self::fromOptions($raw);
}
/**
* The options for lit/divi-dynamic-sources: name => label, group and setting types (the same
* names and types as forPost(), loop_* left out). custom_meta_<key> options a filter registers
* for a key DynamicContentRules refuses (protected "_" keys, S12/S30) are left out as well, so
* the listing never names them. Only label, group and setting types leave Divi's registry –
* no select options or defaults (they list meta keys). Null when the registry is unavailable.
*
* @return array<string, array{label: string, group: string, settings: array<string, string>}>|null
*/
public function options(int $postId): ?array
{
$raw = $this->raw($postId);
if ($raw === null) {
return null;
}
$options = [];
foreach (self::fromOptions($raw) as $name => $settings) {
if (str_starts_with($name, 'custom_meta_') && ! DynamicContentRules::isMetaKey($name)) {
continue;
}
$option = is_array($raw[$name] ?? null) ? $raw[$name] : [];
$options[$name] = [
'label' => is_string($option['label'] ?? null) ? $option['label'] : $name,
'group' => is_string($option['group'] ?? null) ? $option['group'] : '',
'settings' => $settings,
];
}
return $options;
}
/**
* Divi's registry for the post; any filter error is logged (message only) and yields null.
*
* @return array<array-key, mixed>|null
*/
private function raw(int $postId): ?array
{ {
try { try {
$options = $this->registry !== null ? ($this->registry)($postId) : self::divi($postId); $options = $this->registry !== null ? ($this->registry)($postId) : self::divi($postId);
@@ -51,7 +97,7 @@ class DynamicContentMap
return null; return null;
} }
return is_array($options) && $options !== [] ? self::fromOptions($options) : null; return is_array($options) && $options !== [] ? $options : null;
} }
/** /**
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
defined('ABSPATH') || exit;
// The installed PHPCompatibility sniffs predate enums (PHP 8.1) and read their methods as plain functions.
// phpcs:disable PHPCompatibility.Variables.ForbiddenThisUseContexts.OutsideObjectContext
/** What DiviSaver writes to – it decides the Divi flags and how much static CSS is cleared. */
enum SaveScope
{
/** A page or post: Divi flags, its own static CSS. */
case Page;
/** A library entry: no Divi flags (Divi sets none there, fixture finding 4), its own CSS. */
case LibraryItem;
/** A global library entry: like LibraryItem, plus the CSS of every page and the Theme Builder cache (spec §5.6). */
case GlobalItem;
public function setsFlags(): bool
{
return $this === self::Page;
}
public function clearsSiteCss(): bool
{
return $this === self::GlobalItem;
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Integrations\Divi\Compose\LinkTargets;
use WP_Post;
defined('ABSPATH') || exit;
/**
* {{link:<ID>}} targets from WordPress: the entry exists, lives (no trash, auto-draft or
* revision) and the current user may read it (read_post, like select_post settings, S37).
* Missing and unreadable entries give the same null (no existence oracle).
*/
final class WpLinkTargets implements LinkTargets
{
private const DEAD = ['trash', 'auto-draft', 'inherit'];
public function postTypeOf(int $id): ?string
{
$post = get_post($id);
if (! $post instanceof WP_Post || in_array((string) $post->post_status, self::DEAD, true)) {
return null;
}
return current_user_can('read_post', $id) ? (string) $post->post_type : null;
}
}
@@ -22,7 +22,8 @@ defined('ABSPATH') || exit;
* returned like any invalid input; partial write if the delete fails. * returned like any invalid input; partial write if the delete fails.
* - discard(): writing after the insert failed – created terms and the draft are deleted; * - discard(): writing after the insert failed – created terms and the draft are deleted;
* rolled back, or partial write if that cleanup failed. * rolled back, or partial write if that cleanup failed.
* Partial writes are logged with result "partial". * Partial writes are logged with result "partial". lit/library-save reuses it for a new library
* entry (ruling P6): no PostWriter (no terms of its own to clean up), $noun names the entry.
*/ */
final class NewDraft final class NewDraft
{ {
@@ -30,11 +31,13 @@ final class NewDraft
public const REASON_EXTRAS = 'Felder, Begriffe oder Beitragsbild konnten nicht gespeichert werden.'; public const REASON_EXTRAS = 'Felder, Begriffe oder Beitragsbild konnten nicht gespeichert werden.';
public const REASON_LAYOUT = 'Das Divi-Layout konnte nicht gespeichert werden.'; public const REASON_LAYOUT = 'Das Divi-Layout konnte nicht gespeichert werden.';
/** @param string $noun What the new post is called in change-log summaries. */
public function __construct( public function __construct(
private PostWriter $writer, private ?PostWriter $writer,
private ChangeLog $log, private ChangeLog $log,
private string $ability, private string $ability,
private string $postType private string $postType,
private string $noun = 'Entwurf'
) { ) {
} }
@@ -50,7 +53,7 @@ final class NewDraft
} catch (Throwable $e) { } catch (Throwable $e) {
// A hook threw – the row may or may not exist and its ID is unknown; nothing is // A hook threw – the row may or may not exist and its ID is unknown; nothing is
// searched or deleted, the client is told to check (lit_partial_write without id). // searched or deleted, the client is told to check (lit_partial_write without id).
$this->logPartial('', 'Anlegen abgebrochen; Entwurf möglicherweise vorhanden'); $this->logPartial('', sprintf('Anlegen abgebrochen; %s möglicherweise vorhanden', $this->noun));
throw WriteFailed::partial(self::REASON_INSERT, $e); throw WriteFailed::partial(self::REASON_INSERT, $e);
} }
if ($postId instanceof WP_Error) { if ($postId instanceof WP_Error) {
@@ -77,7 +80,7 @@ final class NewDraft
} }
if (! $deleted) { if (! $deleted) {
$summary = sprintf('Entwurf %d: Anlegen nicht zurückgenommen', $postId); $summary = sprintf('%s %d: Anlegen nicht zurückgenommen', $this->noun, $postId);
$this->logPartial((string) $postId, $summary); $this->logPartial((string) $postId, $summary);
$fields = $denied instanceof WP_Error ? Errors::fieldsOf($denied) : $denied; $fields = $denied instanceof WP_Error ? Errors::fieldsOf($denied) : $denied;
throw WriteFailed::partial( throw WriteFailed::partial(
@@ -102,7 +105,7 @@ final class NewDraft
public function discard(int $postId, Throwable $cause, string $reason): never public function discard(int $postId, Throwable $cause, string $reason): never
{ {
$problems = Rollback::attempt( $problems = Rollback::attempt(
fn () => $this->writer->deleteCreatedTerms(), fn () => $this->writer?->deleteCreatedTerms(),
static fn () => self::deleteDraft($postId) static fn () => self::deleteDraft($postId)
); );
@@ -111,7 +114,7 @@ final class NewDraft
throw WriteFailed::rolledBack($reason, $cause, $detail); throw WriteFailed::rolledBack($reason, $cause, $detail);
} }
$summary = sprintf('Entwurf %d: Anlegen nur teilweise zurückgenommen', $postId); $summary = sprintf('%s %d: Anlegen nur teilweise zurückgenommen', $this->noun, $postId);
$this->logPartial((string) $postId, $summary); $this->logPartial((string) $postId, $summary);
throw WriteFailed::partial($reason, $cause, $detail . '; rollback: ' . implode('; ', $problems), $postId); throw WriteFailed::partial($reason, $cause, $detail . '; rollback: ' . implode('; ', $problems), $postId);
} }
+7 -1
View File
@@ -45,7 +45,7 @@ final class PostGuard
$post = get_post($id); $post = get_post($id);
if (! $post instanceof WP_Post || ! $schema->isManaged((string) $post->post_type)) { if (! $post instanceof WP_Post || ! $schema->isManaged((string) $post->post_type)) {
return Errors::notFound(sprintf('Eintrag %d nicht gefunden (oder kein unterstützter Inhaltstyp).', $id)); return self::notFound($id);
} }
if (! current_user_can('edit_post', $id)) { if (! current_user_can('edit_post', $id)) {
@@ -63,6 +63,12 @@ final class PostGuard
return $post; return $post;
} }
/** The refusal for an ID that names no entry the tools manage – the same for every such ID. */
public static function notFound(int $id): WP_Error
{
return Errors::notFound(sprintf('Eintrag %d nicht gefunden (oder kein unterstützter Inhaltstyp).', $id));
}
/** /**
* A scheduled entry whose publication time has passed (WordPress' cron missed it) or is * A scheduled entry whose publication time has passed (WordPress' cron missed it) or is
* less than a minute away: wp_insert_post() turns "future" into "publish" on any save when * less than a minute away: wp_insert_post() turns "future" into "publish" on any save when
@@ -19,6 +19,9 @@ defined('ABSPATH') || exit;
*/ */
final class PageRenderSummaryAbility implements Ability final class PageRenderSummaryAbility implements Ability
{ {
/** At most this many global-module notes per summary. */
private const GLOBAL_NOTES = 20;
public function __construct(private ContentSchema $schema, private PageRenderer $renderer) public function __construct(private ContentSchema $schema, private PageRenderer $renderer)
{ {
} }
@@ -40,7 +43,8 @@ final class PageRenderSummaryAbility implements Ability
. 'Alt-Texte). Header, Footer und Theme-Builder-Vorlagen sind nicht enthalten. Das Rendern führt die ' . 'Alt-Texte). Header, Footer und Theme-Builder-Vorlagen sind nicht enthalten. Das Rendern führt die '
. 'üblichen Inhalts-Filter aus (Shortcodes, Divi, andere Plugins) und kann dabei Netzwerkanfragen ' . 'üblichen Inhalts-Filter aus (Shortcodes, Divi, andere Plugins) und kann dabei Netzwerkanfragen '
. 'auslösen, z. B. oEmbed-Abrufe, die von WordPress oder anderen Plugins zwischengespeichert werden. ' . 'auslösen, z. B. oEmbed-Abrufe, die von WordPress oder anderen Plugins zwischengespeichert werden. '
. 'Gut zum Prüfen nach Änderungen.'; . 'Gut zum Prüfen nach Änderungen. Globale Module (Divi-Bibliothek) fehlen im Auszug und werden '
. 'unter notes genannt.';
} }
public function inputSchema(): array public function inputSchema(): array
@@ -104,10 +108,32 @@ final class PageRenderSummaryAbility implements Ability
$notes[] = 'Der Eintrag ist passwortgeschützt; Besucher sehen zuerst das Passwortformular.'; $notes[] = 'Der Eintrag ist passwortgeschützt; Besucher sehen zuerst das Passwortformular.';
} }
foreach (array_slice(self::globalModuleIds((string) $post->post_content), 0, self::GLOBAL_NOTES) as $id) {
$notes[] = sprintf(
'Globales Modul %s ist im Auszug nicht enthalten (beim Aufruf über die Schnittstelle rendert Divi '
. 'globale Module nicht); Inhalt mit lit-divi-outline mit post_id %s lesen.',
$id,
$id
);
}
return array_merge( return array_merge(
['post_id' => (int) $post->ID, 'url' => (string) get_permalink($post)], ['post_id' => (int) $post->ID, 'url' => (string) get_permalink($post)],
RenderSummary::fromHtml($this->renderer->render($post)), RenderSummary::fromHtml($this->renderer->render($post)),
['notes' => $notes] ['notes' => $notes]
); );
} }
/**
* IDs of the global modules in $content, found the way Divi finds them (a string scan for
* "globalModule", BlockParserStore::get_global_layout_content – parse_blocks() is unreliable there).
*
* @return list<string>
*/
private static function globalModuleIds(string $content): array
{
preg_match_all('/"globalModule":"?(\d+)"?/', $content, $matches);
return array_values(array_unique($matches[1]));
}
} }
+26 -3
View File
@@ -23,6 +23,7 @@ use LIT\MCP\ChangeLog\ChangeLogInstaller;
use LIT\MCP\Guides\InstructionsBuilder; use LIT\MCP\Guides\InstructionsBuilder;
use LIT\MCP\Integrations\Core\CoreIntegration; use LIT\MCP\Integrations\Core\CoreIntegration;
use LIT\MCP\Integrations\Divi\DiviIntegration; use LIT\MCP\Integrations\Divi\DiviIntegration;
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
use LIT\MCP\Integrations\IntegrationRegistry; use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Integrations\Media\MediaIntegration; use LIT\MCP\Integrations\Media\MediaIntegration;
use LIT\MCP\Integrations\Pods\PodsFieldSource; use LIT\MCP\Integrations\Pods\PodsFieldSource;
@@ -43,7 +44,7 @@ defined('ABSPATH') || exit;
*/ */
final class Plugin final class Plugin
{ {
public const VERSION = '0.3.0'; public const VERSION = '0.4.0';
private static string $pluginFile = ''; private static string $pluginFile = '';
private static ?SettingsPage $settingsPage = null; private static ?SettingsPage $settingsPage = null;
@@ -71,9 +72,12 @@ final class Plugin
(new ChangeLogCleanup($changeLog, $limits))->register(); (new ChangeLogCleanup($changeLog, $limits))->register();
$schema = self::contentSchema(); $schema = self::contentSchema();
$divi = new DiviIntegration($schema, $changeLog);
$registry = null; $registry = null;
$policy = null; $policy = null;
// Ruling P9: with the group "Divi-Bibliothek" disabled, the Divi tools do not see library entries.
$libraryVisible = static function () use (&$policy): bool {
return $policy instanceof ExposurePolicy && $policy->groupEnabled(LibraryAccess::GROUP);
};
// Security M2: content-create's divi input follows lit/divi-insert (asked at execute time, // Security M2: content-create's divi input follows lit/divi-insert (asked at execute time,
// when registry and policy exist), so the Divi group's switches stop every Divi layout write. // when registry and policy exist), so the Divi group's switches stop every Divi layout write.
$diviWritable = static function () use (&$registry, &$policy): bool { $diviWritable = static function () use (&$registry, &$policy): bool {
@@ -81,11 +85,30 @@ final class Plugin
&& $policy instanceof ExposurePolicy && $policy instanceof ExposurePolicy
&& $policy->allows('lit/divi-insert', $registry->abilities()); && $policy->allows('lit/divi-insert', $registry->abilities());
}; };
// The Divi tools write to library entries only while the group "Divi-Bibliothek" may write (E9).
$libraryWritable = static function () use (&$policy): bool {
return $policy instanceof ExposurePolicy && $policy->groupWritable(LibraryAccess::GROUP);
};
// Audit L5: lit/divi-node-copy from a library entry follows lit/library-insert's Tools-tab state.
$libraryInsertable = static function () use (&$registry, &$policy): bool {
return $registry instanceof IntegrationRegistry
&& $policy instanceof ExposurePolicy
&& $policy->allows('lit/library-insert', $registry->abilities());
};
$divi = new DiviIntegration(
$schema,
$changeLog,
$libraryWritable,
libraryVisible: $libraryVisible,
diviWritable: $diviWritable,
libraryInsertable: $libraryInsertable
);
$registry = new IntegrationRegistry( $registry = new IntegrationRegistry(
new CoreIntegration(self::pluginDir() . '/docs/guides'), new CoreIntegration(self::pluginDir() . '/docs/guides'),
new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)), new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)),
new MediaIntegration($schema, $changeLog, $limits), new MediaIntegration($schema, $changeLog, $limits),
$divi $divi,
$divi->library()
); );
$policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...)); $policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...));
+115
View File
@@ -0,0 +1,115 @@
# Divi-5-Fixtures für Etappe 3 (Library, globale Module, Theme Builder)
Echte Daten aus Divi 5.2.1, erzeugt am 2026-09-26 auf der lokalen DDEV-Site (`https://wordpress.ddev.site:8443`). Alle Einträge wurden über **Divis eigene Speicherwege** angelegt – den echten Visual Builder und die echte Theme-Builder-Oberfläche in einem headless Chrome (Chrome 153, per DevTools-Protokoll gesteuert, angemeldet als Administrator). Weder Plugin-Code noch eigene Schreibwege in die Datenbank waren beteiligt; einzige Ausnahme ist `et_builder_enable_for_post()` für die neue Seite (siehe 1).
Pfade zu Divi relativ zu `wp-content/themes/Divi/includes/`. Die Builder-JS-Dateien liegen in `builder-5/visual-builder/build/` und sind minifiziert – dort steht statt einer Zeilennummer ein Suchbegriff.
## Dateien
Je Post eine `<rolle>.json` (Post-Zeile, **alle** Meta-Einträge roh in Speicherreihenfolge, alle Term-Beziehungen direkt aus den Tabellen) und, falls Inhalt vorhanden, `<rolle>.html` mit `post_content` byte-genau. `manifest.json` ordnet Rolle → Post-ID zu, `theme-builder.json` enthält den Live-Theme-Builder-Post mit seiner Vorlagenliste, die `et_tb_*`-Optionen und die als ungenutzt markierten Posts.
| Rolle | ID | Typ | Inhalt |
|---|---|---|---|
| `page--globale-instanzen` | 1053 | `page` (Entwurf) | eine Instanz des globalen Abschnitts, eine Instanz des globalen Moduls, normale Abschnitte |
| `library--layout-ganze-seite` | 1054 | `et_pb_layout` | ganzes Seitenlayout (vor dem Globalmachen gespeichert), Kategorie „Fixture“ |
| `library--library-abschnitt` | 1055 | `et_pb_layout` | Abschnitt, nicht global |
| `library--library-zeile` | 1056 | `et_pb_layout` | Zeile, nicht global |
| `library--library-modul` | 1057 | `et_pb_layout` | Blurb-Modul, nicht global, Schlagwort „fixture“ |
| `library-global--globaler-abschnitt` | 1058 | `et_pb_layout` | globaler Abschnitt |
| `library-global--globales-modul` | 1059 | `et_pb_layout` | globales Text-Modul |
| `tb-header--globaler-header` | 1061 | `et_header_layout` | globaler Header (Header der Standardvorlage) |
| `tb-body--body-mit-dynamic-content` | 1062 | `et_body_layout` | Dynamic Content: Titel, Meta-Feld, Auszug, Modul „Beitragsinhalt“ |
| `tb-footer--footer` | 1063 | `et_footer_layout` | Footer |
| `tb-template--default` | 1065 | `et_template` | „Standard-Website-Template“, **deaktiviert**, Header = 1061 |
| `tb-template--vorlage-globale-instanzen` | 1066 | `et_template` | **deaktiviert**, Header 1061 / Body 1062 / Footer 1063, Bedingung `singular:post_type:page:id:1053` |
| (theme-builder.json) | 1064 | `et_theme_builder` | Live-TB-Post, `_et_template` = [1065, 1066] |
Nicht exportiert: die Revision 1060 der Seite.
## Wie die Fixtures entstanden sind
### 1. Seite „[Fixture] Globale Instanzen“ (1053)
- Angelegt als leerer Entwurf (`wp post create`), dann `et_builder_enable_for_post(1053)` (`builder/core.php:4438-4452`) – das tut der Button „Divi Builder verwenden“; ohne `_et_pb_use_builder` öffnet `et_fb=1` den Builder nicht (`et_fb_is_enabled`, `builder/core.php:4195-4273`).
- Visual Builder geöffnet (`?page_id=1053&et_fb=1`). Die App läuft im iframe `app_window=1`; dort `divi.data`.
- Inhalt in den Builder-Store gesetzt: `dispatch('divi/edit-post').setContent(flat)` (Aktion `SET_CONTENT`, `edit-post.js`, Suche `Ht=e=>({type:"SET_CONTENT"`). Die Modul-Attribute (Admin-Labels, Texte, `builderVersion 5.2.1`, Spalte `flexType 24_24`) haben **wir** gewählt – wie bei den Etappe-2-Seiten; es wurden keine Module per Klick eingefügt, Divis Standardattribute beim Einfügen fehlen also.
- Gespeichert wie der Button „Entwurf speichern“: `dispatch('divi/serialized-post').syncToServer({syncType:'draft'})` (`app-ui.js`, Suche `syncToServer({syncType:"draft"`) → Divis Serialisierer (`serialized-post.js`, Suche `k=(t,e="root",r="global-module")`) → `POST divi/v1/sync-to-server` (`builder-5/server/VisualBuilder/REST/RESTRegistration.php:819-826`, `SyncToServerController::update`, `builder-5/server/VisualBuilder/REST/SyncToServer/SyncToServerController.php:56`).
### 2. Library-Einträge (1054–1059)
Über den echten Dialog „Zur Bibliothek hinzufügen“: `dispatch('divi/modal-library').open({name:'divi/add-to-library', owner:<Modul-ID>})` (so öffnet ihn das Rechtsklickmenü, `right-click-options.js`, Suche `name:"divi/add-to-library",owner:e`; für das ganze Layout `owner:""` wie das Tastenkürzel in `root.js`). Name, neue Kategorie/Schlagwort und der Schalter „Als Global abspeichern“ wurden im Dialog-DOM gesetzt, dann „Zur Bibliothek hinzufügen“ geklickt. Damit lief Divis eigener `onSaveClick` (`modal-library.js`, Suche `onSaveClick:({moduleNameOption`):
- Serialisierung `serializeModuleFlatObjects` (ganzes Layout mit Kontext `"layout"`), FormData mit `layout_type`, `layout_scope`, `module_type`, `module_width`, `columns_layout`, `post_type`;
- `POST divi/v1/divi-library/create-item` (`RESTRegistration.php:743-749`, `DiviLibraryController::create_item`, `builder-5/server/VisualBuilder/REST/DiviLibrary/DiviLibraryController.php:606-757`) → `et_pb_submit_layout()` (`builder/shortcode-core.php:654-734`) → `et_pb_create_layout()` (`:738-800`);
- bei „global“ danach im Builder `Tn` (`modal-library.js`, Suche `Tn=({moduleId:e,templateId:t`): `setGlobalModuleAttribute(<Modul>, <Library-ID>)`, `setGlobalParentAttribute` für alle Nachkommen (`Nn`), `global-layouts.setLayoutContent`. Anschließend wurde die Seite gespeichert (siehe 1) – so entstanden die Instanzen in 1053.
Reihenfolge: Layout 1054, Abschnitt 1055, Zeile 1056, Modul 1057, globaler Abschnitt 1058, globales Modul 1059, dann Seite speichern.
### 3. Theme Builder (1061–1066)
Auf der Seite `wp-admin/admin.php?page=et_theme_builder` mit den dort lokalisierten Nonces (`et_theme_builder_bundle.config.nonces`, `wp_localize_script` in `builder/frontend-builder/theme-builder/admin.php:129`) und jQuery, wie die TB-App (`builder/frontend-builder/build/theme-builder.js`, Suche `createLayout:function(e){return(0,l.post)` und `save:function(e,t)`):
1. `et_theme_builder_api_create_layout` für `header`, `body`, `footer` (`theme-builder/api.php:12-46` → `et_theme_builder_insert_layout`, `theme-builder/theme-builder.php:1643-1667`) → 1061, 1062, 1063.
2. `et_theme_builder_api_save` mit `live=1` (`api.php:160-273` → `et_theme_builder_store_template`, `theme-builder.php:1463-1586`) mit zwei Vorlagen: Standardvorlage (Header 1061, **`enabled=0`**) und „[Fixture] Vorlage Globale Instanzen“ (Header 1061, Body 1062, Footer 1063, **`enabled=0`**, `use_on[]=singular:post_type:page:id:1053`). Divi hat dabei den Live-TB-Post 1064 angelegt (`et_theme_builder_get_theme_builder_post_id`, `theme-builder.php:378`), die Vorlagen 1065/1066 mit `publish` gespeichert und `_et_template` gesetzt.
3. `et_theme_builder_api_get_layout_url` (`api.php:116-147`) für die Builder-URLs; jede Layout-URL **ohne** `app_window=1` im Visual Builder geöffnet, Inhalt per `setContent` gesetzt, Titel „[Fixture] …“ über das Feld „Titel“ der Seiteneinstellungen eingetragen und mit `syncToServer({syncType:'publish'})` gespeichert (TB-Layouts sind immer `publish`) → `divi/v1/sync-to-server` wie oben.
„Globaler Header“ heißt im Theme Builder: Header-Layout der Standardvorlage, das andere Vorlagen mit derselben ID mitbenutzen (1065 und 1066 zeigen beide auf 1061). Die Meta `_et_{header,body,footer}_layout_global` gibt es nur für TB-Library-Einträge, nicht für die Live-Vorlagen.
### 4. Dynamic Content (Body-Layout 1062)
Im Body-Layout: Überschrift = `post_title`, Text = `post_meta_key` mit manuellem Schlüssel `fixture_untertitel`, Text = `post_excerpt`, dazu das Modul `divi/post-content`. Die Werte haben die Form `$variable({"type":"content","value":{"name":"…","settings":{…}}})$` (`module.js`, Suche ``$variable(${JSON.stringify(s)})$``; Server: `builder-5/server/Packages/Module/Layout/Components/DynamicContent/`).
## Was echt ist und was nachgebildet wurde
**Echt aus Divi (JS-Serialisierer + Server):** gesamte Blockstruktur und Serialisierung aller `.html`, der `divi/placeholder`-Rahmen, die Form der globalen Instanzen, alle Library-Terme und -Meta, Post-Zeilen, alle Meta der Seite und der TB-Layouts, TB-Vorlagen-Meta, `_et_template` am TB-Post, der Umgang mit Kategorie/Schlagwort-Neuanlage.
**Von uns vorgegeben bzw. aus JS nachgebildet:**
- Die Modul-**Attribute** im Builder-Store (siehe 1). Divi hat sie unverändert serialisiert, aber nicht selbst erzeugt.
- Die Vorlagen-Nutzdaten für `et_theme_builder_api_save`: nachgebaut nach `formatTemplatesForStorage` (`theme-builder.js`, Suche `formatTemplatesForStorage=function`): `{id:<uuid>, title, autogenerated_title:"0", default:"1"/"0", enabled:"1"/"0", layouts:{header|body|footer:{id, enabled:"1"}}, use_on:[…], exclude_from:[]}`, `hasDefault:"0"`, `first_request`/`last_request:"1"`. Der Server-Teil (Speichern, Zuordnung) ist echt.
- Die `settings` der Dynamic-Content-Werte: aus den `default`-Werten der Felder in `settings.dynamicContent.options` (so füllt der Einstellungsdialog vor) plus `meta_key`. Im echten Dialog können Schlüsselreihenfolge und ausgeblendete Felder (`custom_date_format`, `date_format` ohne Datumsfeld) abweichen. Die Namen (`post_title`, `post_meta_key`, `post_excerpt`) und die `$variable(…)$`-Hülle sind aus Divi belegt.
## Format-Befunde (für Etappe 3 wichtig)
1. **Der Visual Builder speichert anders als unsere bisherigen Seiten:** Zeilenumbrüche zwischen Blöcken, Module ohne Kinder **selbstschließend** (`<!-- wp:divi/text {…} /-->`, danach eine Leerzeile), `builderVersion` steht am Ende der Attribute; der `divi/placeholder`-Rahmen ohne Umbruch. Divis D4-Konvertierung und das Plugin schreiben dagegen gepaarte Kommentare ohne Umbrüche. Beide Formen kommen auf echten Seiten vor. Der Plugin-Parser liest alle zehn `.html` hier byte-identisch zurück (Parse → Serialize).
2. **Globale Instanz auf der Seite:** `<!-- wp:divi/global-layout {"globalModule":"1058","blockName":"divi/section","localAttrs":{…alle Attribute des Moduls…}} -->`, darin die Kinder mit ihren **echten** Blocknamen und jeweils `"globalParent":"1058"`. Ein globales Modul ohne Kinder ist ein selbstschließender `divi/global-layout`-Block. `localAttrs` ist ein vollständiger Schnappschuss (kein leeres Objekt); `localAttrsMap`/`localChildren` fehlen, solange nichts entkoppelt ist.
3. **Globaler Library-Post** enthält normalen Inhalt ohne `globalModule`/`globalParent`; „global“ steckt nur im Term `scope=global` (beim Modul zusätzlich `_et_pb_module_type`, `_et_pb_excluded_global_options = "[]"`).
4. Library-Einträge bekommen **kein** `_et_pb_use_divi_5` und kein `_et_pb_use_builder` (bestätigt F3). Seiten und TB-Layouts nach VB-Speichern schon.
5. **Zeilen-Bug in Divi:** `_et_pb_row_layout` ist `"[object Object]"` (der Builder schickt `columns_layout` als stringifiziertes Objekt). Nicht als Wert verwenden.
6. **TB-Layouts haben keinen `layout_type`-Term**, obwohl `et_theme_builder_insert_layout` ihn setzen will (`theme-builder.php:1663`). In `wp_term_relationships` steht für 1061–1063 nichts; vermutlich ist die Taxonomie im admin-ajax-Aufruf nicht registriert (Registrierung über `ET_Builder_Library`, `builder/feature/Library.php:397`) – die Ursache ist nicht weiter geprüft. Nicht auf Terme bei TB-Layouts verlassen.
7. **TB-Bedingungen** stehen als mehrfaches Meta `_et_use_on` / `_et_exclude_from` mit IDs wie `singular:post_type:page:id:1053`. Die Vorlagenzuordnung ist `_et_template` (mehrfach) am `et_theme_builder`-Post; Vorlagen-Titel der Standardvorlage ist übersetzt („Standard-Website-Template“).
8. TB-Layouts heißen nach dem Anlegen alle „Theme Builder Layout“ mit Slug `theme-builder-layout` (in jedem Post-Typ gleich); erst der Titel im Builder benennt sie um. `_et_pb_post_settings_title` spiegelt den Titel.
9. **Zeilenumbrüche in Bibliothekseinträgen:** Einträge aus dem Dialog „Zur Bibliothek hinzufügen“ haben zwischen den Blöcken `\r\n` (1054–1056, 1058; der Browser normalisiert Formularfelder beim Senden an `divi-library/create-item`), Seiten und TB-Layouts `\n`. Der Builder schreibt globale Instanzen mit `\n`. `LibraryContent::load()` vereinheitlicht deshalb Zeilenumbrüche im Leerraum zwischen Blöcken, bevor Inhalte aus der Bibliothek übernommen werden; damit entstehen Instanzen byte-genau wie in `page--globale-instanzen.html`.
## Wirkung auf die Live-Site
Vorher gab es keinen Theme-Builder-Post und keine Vorlagen. Jetzt existiert der Live-TB-Post 1064. Beide Vorlagen sind deaktiviert: `ET_Theme_Builder_Request::get_template()` überspringt deaktivierte Vorlagen (`theme-builder/ThemeBuilderRequest.php:393`) und nimmt die Standardvorlage nur, wenn sie aktiv ist (`:434`). Die Fixture-Vorlage wäre zudem nur für die Entwurfsseite 1053 zuständig. Geprüft: `get_template()` liefert für 1053 nichts, die Startseite enthält keinen Fixture-Header. Beim Live-Speichern hat Divi `et_theme_builder_trash_draft_and_unused_posts()` (`theme-builder.php:591`) ausgeführt; nichts wurde als ungenutzt markiert. **Achtung:** Wer im Theme Builder später speichert, speichert die komplette Vorlagenliste neu; Layouts, die dann nicht mehr zugeordnet sind, markiert Divi und löscht sie nach 7 Tagen.
Neu angelegte Terme (vorher gab es keine Library-Terme): `layout_category` „Fixture“ (26), `layout_tag` „fixture“ (32) sowie Divis Systemterme `scope` (27, 34), `layout_type` (28, 30, 31, 33), `module_width` (29).
## Neu exportieren
```
ddev wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-export-stage3-fixtures.php
```
Exportiert werden nur Posts mit Titel „[Fixture] …“ und die Standardvorlage, wenn sie auf Fixture-Layouts zeigt. Das Skript löscht vorher alle `*.json`/`*.html` in diesem Ordner.
## Aufräumen (lokal)
Die Daten bleiben vorerst zum Ansehen stehen. Zum Entfernen (DDEV, WordPress-Wurzel; IDs vorher mit `manifest.json` abgleichen):
```
# Theme Builder: TB-Post (von den Fixtures neu angelegt), Vorlagen, Layouts
ddev wp post delete 1064 1065 1066 1061 1062 1063 --force
# Library-Einträge und Seite (Revision 1060 fällt mit weg)
ddev wp post delete 1054 1055 1056 1057 1058 1059 1053 --force
# Von den Fixtures angelegte Terme
ddev wp term delete layout_category 26
ddev wp term delete layout_tag 32
ddev wp eval 'foreach ([27,28,29,30,31,33,34] as $t) { wp_delete_term($t, get_term($t)->taxonomy); }'
# Divi-Caches
ddev wp eval 'et_theme_builder_clear_wp_cache("all"); ET_Core_PageResource::remove_static_resources("all", "all");'
```
`scope`, `layout_type` und `module_width` legt Divi beim nächsten Speichern in die Library wieder an. Gab es bis dahin eigene Library-Einträge, die diese Terme benutzen, die dritte Zeile weglassen.
@@ -0,0 +1,27 @@
<!-- wp:divi/placeholder --><!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Global Abschnitt"}}}},"builderVersion":"5.2.1"} -->
<!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"Global Zeile"}}}},"builderVersion":"5.2.1"} -->
<!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}},"builderVersion":"5.2.1"} -->
<!-- wp:divi/heading {"module":{"meta":{"adminLabel":{"desktop":{"value":"Global Überschrift"}}}},"title":{"innerContent":{"desktop":{"value":"Globaler Abschnitt"}},"decoration":{"font":{"font":{"desktop":{"value":{"headingLevel":"h2"}}}}}},"builderVersion":"5.2.1"} /-->
<!-- wp:divi/text {"module":{"meta":{"adminLabel":{"desktop":{"value":"Global Text"}}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eDieser Abschnitt ist global und erscheint überall gleich.\u003c/p\u003e"}}},"builderVersion":"5.2.1"} /-->
<!-- /wp:divi/column -->
<!-- /wp:divi/row -->
<!-- /wp:divi/section -->
<!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Modul Abschnitt"}}}},"builderVersion":"5.2.1"} -->
<!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"Modul 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":"Globales Modul Öffnungszeiten"}}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eGlobales Textmodul: Öffnungszeiten Mo–Fr 8–17 Uhr.\u003c/p\u003e"}}},"builderVersion":"5.2.1"} /-->
<!-- wp:divi/button {"module":{"meta":{"adminLabel":{"desktop":{"value":"Lokaler Button"}}}},"button":{"innerContent":{"desktop":{"value":{"text":"Kontakt aufnehmen","linkUrl":"#kontakt"}}}},"builderVersion":"5.2.1"} /-->
<!-- /wp:divi/column -->
<!-- /wp:divi/row -->
<!-- /wp:divi/section -->
<!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Library Abschnitt"}}}},"builderVersion":"5.2.1"} -->
<!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"Library Zeile"}}}},"builderVersion":"5.2.1"} -->
<!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}},"builderVersion":"5.2.1"} -->
<!-- wp:divi/blurb {"module":{"meta":{"adminLabel":{"desktop":{"value":"Library Blurb"}}}},"title":{"innerContent":{"desktop":{"value":"Library-Blurb"}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eNicht globales Library-Modul mit Ä, ö, ü und ß.\u003c/p\u003e"}}},"builderVersion":"5.2.1"} /-->
<!-- /wp:divi/column -->
<!-- /wp:divi/row -->
<!-- /wp:divi/section --><!-- /wp:divi/placeholder -->
@@ -0,0 +1,51 @@
{
"post": {
"ID": 1054,
"post_type": "et_pb_layout",
"post_status": "publish",
"post_title": "[Fixture] Layout ganze Seite",
"post_name": "fixture-layout-ganze-seite",
"post_author": 1,
"post_parent": 0,
"menu_order": 0,
"post_excerpt": "",
"post_date_gmt": "2026-09-26 20:56:10",
"post_modified_gmt": "2026-09-26 20:56:10",
"content_file": "library--layout-ganze-seite.html",
"content_sha256": "2fcb9fb909d698b97581531ac69c5a3973647a57a4970d7a45a51fbe4ae24968"
},
"meta": {
"_et_pb_built_for_post_type": [
"page"
],
"_et_pb_template_type": [
"layout"
]
},
"terms": {
"layout_category": [
{
"slug": "fixture",
"name": "Fixture"
}
],
"layout_type": [
{
"slug": "layout",
"name": "layout"
}
],
"module_width": [
{
"slug": "regular",
"name": "regular"
}
],
"scope": [
{
"slug": "non_global",
"name": "non_global"
}
]
}
}
@@ -0,0 +1,7 @@
<!-- wp:divi/placeholder --><!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Library Abschnitt"}}}},"builderVersion":"5.2.1"} -->
<!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"Library Zeile"}}}},"builderVersion":"5.2.1"} -->
<!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}},"builderVersion":"5.2.1"} -->
<!-- wp:divi/blurb {"module":{"meta":{"adminLabel":{"desktop":{"value":"Library Blurb"}}}},"title":{"innerContent":{"desktop":{"value":"Library-Blurb"}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eNicht globales Library-Modul mit Ä, ö, ü und ß.\u003c/p\u003e"}}},"builderVersion":"5.2.1"} /-->
<!-- /wp:divi/column -->
<!-- /wp:divi/row -->
<!-- /wp:divi/section --><!-- /wp:divi/placeholder -->
@@ -0,0 +1,45 @@
{
"post": {
"ID": 1055,
"post_type": "et_pb_layout",
"post_status": "publish",
"post_title": "[Fixture] Library-Abschnitt",
"post_name": "fixture-library-abschnitt",
"post_author": 1,
"post_parent": 0,
"menu_order": 0,
"post_excerpt": "",
"post_date_gmt": "2026-09-26 20:56:14",
"post_modified_gmt": "2026-09-26 20:56:14",
"content_file": "library--library-abschnitt.html",
"content_sha256": "c26526fccd9a1dac416a91ddd1459059858783478921744698b77fe83dcb7e94"
},
"meta": {
"_et_pb_built_for_post_type": [
"page"
],
"_et_pb_template_type": [
"section"
]
},
"terms": {
"layout_type": [
{
"slug": "section",
"name": "section"
}
],
"module_width": [
{
"slug": "regular",
"name": "regular"
}
],
"scope": [
{
"slug": "non_global",
"name": "non_global"
}
]
}
}
@@ -0,0 +1 @@
<!-- wp:divi/placeholder --><!-- wp:divi/blurb {"module":{"meta":{"adminLabel":{"desktop":{"value":"Library Blurb"}}}},"title":{"innerContent":{"desktop":{"value":"Library-Blurb"}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eNicht globales Library-Modul mit Ä, ö, ü und ß.\u003c/p\u003e"}}},"builderVersion":"5.2.1"} /--><!-- /wp:divi/placeholder -->
@@ -0,0 +1,54 @@
{
"post": {
"ID": 1057,
"post_type": "et_pb_layout",
"post_status": "publish",
"post_title": "[Fixture] Library-Modul",
"post_name": "fixture-library-modul",
"post_author": 1,
"post_parent": 0,
"menu_order": 0,
"post_excerpt": "",
"post_date_gmt": "2026-09-26 20:56:21",
"post_modified_gmt": "2026-09-26 20:56:21",
"content_file": "library--library-modul.html",
"content_sha256": "fd36cf6168b7492d26601fb4ed11accecc30fe1f8b7ec5a4d4091fc40d210ba4"
},
"meta": {
"_et_pb_module_type": [
"divi/blurb"
],
"_et_pb_built_for_post_type": [
"page"
],
"_et_pb_template_type": [
"module"
]
},
"terms": {
"layout_tag": [
{
"slug": "fixture",
"name": "fixture"
}
],
"layout_type": [
{
"slug": "module",
"name": "module"
}
],
"module_width": [
{
"slug": "regular",
"name": "regular"
}
],
"scope": [
{
"slug": "non_global",
"name": "non_global"
}
]
}
}
@@ -0,0 +1,5 @@
<!-- wp:divi/placeholder --><!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"Library Zeile"}}}},"builderVersion":"5.2.1"} -->
<!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}},"builderVersion":"5.2.1"} -->
<!-- wp:divi/blurb {"module":{"meta":{"adminLabel":{"desktop":{"value":"Library Blurb"}}}},"title":{"innerContent":{"desktop":{"value":"Library-Blurb"}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eNicht globales Library-Modul mit Ä, ö, ü und ß.\u003c/p\u003e"}}},"builderVersion":"5.2.1"} /-->
<!-- /wp:divi/column -->
<!-- /wp:divi/row --><!-- /wp:divi/placeholder -->
@@ -0,0 +1,48 @@
{
"post": {
"ID": 1056,
"post_type": "et_pb_layout",
"post_status": "publish",
"post_title": "[Fixture] Library-Zeile",
"post_name": "fixture-library-zeile",
"post_author": 1,
"post_parent": 0,
"menu_order": 0,
"post_excerpt": "",
"post_date_gmt": "2026-09-26 20:56:18",
"post_modified_gmt": "2026-09-26 20:56:18",
"content_file": "library--library-zeile.html",
"content_sha256": "0a47d5133a4ca10a5fd0ebb49908894a5c20a17798d5954e0897958ad4704dfe"
},
"meta": {
"_et_pb_row_layout": [
"[object Object]"
],
"_et_pb_built_for_post_type": [
"page"
],
"_et_pb_template_type": [
"row"
]
},
"terms": {
"layout_type": [
{
"slug": "row",
"name": "row"
}
],
"module_width": [
{
"slug": "regular",
"name": "regular"
}
],
"scope": [
{
"slug": "non_global",
"name": "non_global"
}
]
}
}
@@ -0,0 +1,9 @@
<!-- wp:divi/placeholder --><!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Global Abschnitt"}}}},"builderVersion":"5.2.1"} -->
<!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"Global Zeile"}}}},"builderVersion":"5.2.1"} -->
<!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}},"builderVersion":"5.2.1"} -->
<!-- wp:divi/heading {"module":{"meta":{"adminLabel":{"desktop":{"value":"Global Überschrift"}}}},"title":{"innerContent":{"desktop":{"value":"Globaler Abschnitt"}},"decoration":{"font":{"font":{"desktop":{"value":{"headingLevel":"h2"}}}}}},"builderVersion":"5.2.1"} /-->
<!-- wp:divi/text {"module":{"meta":{"adminLabel":{"desktop":{"value":"Global Text"}}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eDieser Abschnitt ist global und erscheint überall gleich.\u003c/p\u003e"}}},"builderVersion":"5.2.1"} /-->
<!-- /wp:divi/column -->
<!-- /wp:divi/row -->
<!-- /wp:divi/section --><!-- /wp:divi/placeholder -->
@@ -0,0 +1,45 @@
{
"post": {
"ID": 1058,
"post_type": "et_pb_layout",
"post_status": "publish",
"post_title": "[Fixture] Globaler Abschnitt",
"post_name": "fixture-globaler-abschnitt",
"post_author": 1,
"post_parent": 0,
"menu_order": 0,
"post_excerpt": "",
"post_date_gmt": "2026-09-26 20:56:25",
"post_modified_gmt": "2026-09-26 20:56:25",
"content_file": "library-global--globaler-abschnitt.html",
"content_sha256": "dd1549b879730de4d937c84236bb8e548f50024e47e9910561f24ae0e91b13a6"
},
"meta": {
"_et_pb_built_for_post_type": [
"page"
],
"_et_pb_template_type": [
"section"
]
},
"terms": {
"layout_type": [
{
"slug": "section",
"name": "section"
}
],
"module_width": [
{
"slug": "regular",
"name": "regular"
}
],
"scope": [
{
"slug": "global",
"name": "global"
}
]
}
}
@@ -0,0 +1 @@
<!-- wp:divi/placeholder --><!-- wp:divi/text {"module":{"meta":{"adminLabel":{"desktop":{"value":"Globales Modul Öffnungszeiten"}}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eGlobales Textmodul: Öffnungszeiten Mo–Fr 8–17 Uhr.\u003c/p\u003e"}}},"builderVersion":"5.2.1"} /--><!-- /wp:divi/placeholder -->
@@ -0,0 +1,51 @@
{
"post": {
"ID": 1059,
"post_type": "et_pb_layout",
"post_status": "publish",
"post_title": "[Fixture] Globales Modul",
"post_name": "fixture-globales-modul",
"post_author": 1,
"post_parent": 0,
"menu_order": 0,
"post_excerpt": "",
"post_date_gmt": "2026-09-26 20:56:29",
"post_modified_gmt": "2026-09-26 20:56:29",
"content_file": "library-global--globales-modul.html",
"content_sha256": "3fe0aa7eb9a0aad0e79cc23702c3082f7ff8fbbb80e35185f01ccfc751c1690b"
},
"meta": {
"_et_pb_module_type": [
"divi/text"
],
"_et_pb_excluded_global_options": [
"[]"
],
"_et_pb_built_for_post_type": [
"page"
],
"_et_pb_template_type": [
"module"
]
},
"terms": {
"layout_type": [
{
"slug": "module",
"name": "module"
}
],
"module_width": [
{
"slug": "regular",
"name": "regular"
}
],
"scope": [
{
"slug": "global",
"name": "global"
}
]
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"divi_version": "5.2.1",
"posts": {
"page--globale-instanzen": 1053,
"library--layout-ganze-seite": 1054,
"library--library-abschnitt": 1055,
"library--library-zeile": 1056,
"library--library-modul": 1057,
"library-global--globaler-abschnitt": 1058,
"library-global--globales-modul": 1059,
"tb-header--globaler-header": 1061,
"tb-body--body-mit-dynamic-content": 1062,
"tb-footer--footer": 1063,
"tb-template--vorlage-globale-instanzen": 1066,
"tb-template--default": 1065
}
}
@@ -0,0 +1,27 @@
<!-- wp:divi/placeholder --><!-- wp:divi/global-layout {"globalModule":"1058","blockName":"divi/section","localAttrs":{"builderVersion":"5.2.1","module":{"meta":{"adminLabel":{"desktop":{"value":"Global Abschnitt"}}}}}} -->
<!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"Global Zeile"}}}},"builderVersion":"5.2.1","globalParent":"1058"} -->
<!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}},"builderVersion":"5.2.1","globalParent":"1058"} -->
<!-- wp:divi/heading {"module":{"meta":{"adminLabel":{"desktop":{"value":"Global Überschrift"}}}},"title":{"innerContent":{"desktop":{"value":"Globaler Abschnitt"}},"decoration":{"font":{"font":{"desktop":{"value":{"headingLevel":"h2"}}}}}},"builderVersion":"5.2.1","globalParent":"1058"} /-->
<!-- wp:divi/text {"module":{"meta":{"adminLabel":{"desktop":{"value":"Global Text"}}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eDieser Abschnitt ist global und erscheint überall gleich.\u003c/p\u003e"}}},"builderVersion":"5.2.1","globalParent":"1058"} /-->
<!-- /wp:divi/column -->
<!-- /wp:divi/row -->
<!-- /wp:divi/global-layout -->
<!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Modul Abschnitt"}}}},"builderVersion":"5.2.1"} -->
<!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"Modul Zeile"}}}},"builderVersion":"5.2.1"} -->
<!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}},"builderVersion":"5.2.1"} -->
<!-- wp:divi/global-layout {"globalModule":"1059","blockName":"divi/text","localAttrs":{"builderVersion":"5.2.1","content":{"innerContent":{"desktop":{"value":"\u003cp\u003eGlobales Textmodul: Öffnungszeiten Mo–Fr 8–17 Uhr.\u003c/p\u003e"}}},"module":{"meta":{"adminLabel":{"desktop":{"value":"Globales Modul Öffnungszeiten"}}}}}} /-->
<!-- wp:divi/button {"module":{"meta":{"adminLabel":{"desktop":{"value":"Lokaler Button"}}}},"button":{"innerContent":{"desktop":{"value":{"text":"Kontakt aufnehmen","linkUrl":"#kontakt"}}}},"builderVersion":"5.2.1"} /-->
<!-- /wp:divi/column -->
<!-- /wp:divi/row -->
<!-- /wp:divi/section -->
<!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Library Abschnitt"}}}},"builderVersion":"5.2.1"} -->
<!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"Library Zeile"}}}},"builderVersion":"5.2.1"} -->
<!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}},"builderVersion":"5.2.1"} -->
<!-- wp:divi/blurb {"module":{"meta":{"adminLabel":{"desktop":{"value":"Library Blurb"}}}},"title":{"innerContent":{"desktop":{"value":"Library-Blurb"}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eNicht globales Library-Modul mit Ä, ö, ü und ß.\u003c/p\u003e"}}},"builderVersion":"5.2.1"} /-->
<!-- /wp:divi/column -->
<!-- /wp:divi/row -->
<!-- /wp:divi/section --><!-- /wp:divi/placeholder -->
@@ -0,0 +1,71 @@
{
"post": {
"ID": 1053,
"post_type": "page",
"post_status": "draft",
"post_title": "[Fixture] Globale Instanzen",
"post_name": "",
"post_author": 1,
"post_parent": 0,
"menu_order": 0,
"post_excerpt": "",
"post_date_gmt": "0000-00-00 00:00:00",
"post_modified_gmt": "2026-09-26 20:56:33",
"content_file": "page--globale-instanzen.html",
"content_sha256": "e5691e17e77315c449c5bf536cb3944ea0bc0bee316c45aa26e3b04485253623"
},
"meta": {
"_et_pb_use_builder": [
"on"
],
"_et_pb_show_page_creation": [
"off"
],
"_et_pb_ab_current_shortcode": [
"[et_pb_split_track id=\"1053\" /]"
],
"_et_pb_content_area_background_color": [
""
],
"_et_pb_custom_css": [
""
],
"_et_pb_enable_shortcode_tracking": [
"off"
],
"_et_pb_gutter_width": [
"3"
],
"_et_pb_page_z_index": [
"auto"
],
"_thumbnail_id": [
""
],
"_et_pb_section_background_color": [
""
],
"_et_pb_post_settings_title": [
"[Fixture] Globale Instanzen"
],
"_et_pb_post_settings_excerpt": [
""
],
"_et_pb_first_image": [
""
],
"_et_pb_truncate_post": [
""
],
"_et_pb_truncate_post_date": [
""
],
"_divi_off_canvas_data": [
"a:2:{s:14:\"activeCanvasId\";s:36:\"4d4f306d-8e36-4b8f-9a7a-076eb1b075ac\";s:14:\"mainCanvasName\";s:11:\"Main Canvas\";}"
],
"_et_pb_use_divi_5": [
"on"
]
},
"terms": {}
}
@@ -0,0 +1,13 @@
<!-- wp:divi/placeholder --><!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"[Fixture] Body mit Dynamic Content Abschnitt"}}}},"builderVersion":"5.2.1"} -->
<!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"[Fixture] Body mit Dynamic Content Zeile"}}}},"builderVersion":"5.2.1"} -->
<!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}},"builderVersion":"5.2.1"} -->
<!-- wp:divi/heading {"module":{"meta":{"adminLabel":{"desktop":{"value":"Body Titel dynamisch"}}}},"title":{"innerContent":{"desktop":{"value":"$variable({\u0022type\u0022:\u0022content\u0022,\u0022value\u0022:{\u0022name\u0022:\u0022post_title\u0022,\u0022settings\u0022:{\u0022before\u0022:\u0022\u0022,\u0022after\u0022:\u0022\u0022}}})$"}},"decoration":{"font":{"font":{"desktop":{"value":{"headingLevel":"h1"}}}}}},"builderVersion":"5.2.1"} /-->
<!-- wp:divi/text {"module":{"meta":{"adminLabel":{"desktop":{"value":"Body Meta-Feld dynamisch"}}}},"content":{"innerContent":{"desktop":{"value":"$variable({\u0022type\u0022:\u0022content\u0022,\u0022value\u0022:{\u0022name\u0022:\u0022post_meta_key\u0022,\u0022settings\u0022:{\u0022before\u0022:\u0022\u0022,\u0022after\u0022:\u0022\u0022,\u0022select_meta_key\u0022:\u0022custom_meta_manual_custom_field_value\u0022,\u0022date_format\u0022:\u0022default\u0022,\u0022custom_date_format\u0022:\u0022\u0022,\u0022enable_html\u0022:\u0022off\u0022,\u0022meta_key\u0022:\u0022fixture_untertitel\u0022}}})$"}}},"builderVersion":"5.2.1"} /-->
<!-- wp:divi/text {"module":{"meta":{"adminLabel":{"desktop":{"value":"Body Auszug dynamisch"}}}},"content":{"innerContent":{"desktop":{"value":"$variable({\u0022type\u0022:\u0022content\u0022,\u0022value\u0022:{\u0022name\u0022:\u0022post_excerpt\u0022,\u0022settings\u0022:{\u0022before\u0022:\u0022\u0022,\u0022after\u0022:\u0022\u0022,\u0022words\u0022:\u0022\u0022,\u0022read_more_label\u0022:\u0022\u0022}}})$"}}},"builderVersion":"5.2.1"} /-->
<!-- wp:divi/post-content {"module":{"meta":{"adminLabel":{"desktop":{"value":"Body Beitragsinhalt"}}}},"builderVersion":"5.2.1"} /-->
<!-- /wp:divi/column -->
<!-- /wp:divi/row -->
<!-- /wp:divi/section --><!-- /wp:divi/placeholder -->
@@ -0,0 +1,71 @@
{
"post": {
"ID": 1062,
"post_type": "et_body_layout",
"post_status": "publish",
"post_title": "[Fixture] Body mit Dynamic Content",
"post_name": "theme-builder-layout",
"post_author": 1,
"post_parent": 0,
"menu_order": 0,
"post_excerpt": "",
"post_date_gmt": "2026-09-26 20:58:34",
"post_modified_gmt": "2026-09-26 21:00:07",
"content_file": "tb-body--body-mit-dynamic-content.html",
"content_sha256": "4f2830538ea84c8c490ee278be883f09d89816c9635c7571be294c8bdadce3a2"
},
"meta": {
"_et_pb_use_builder": [
"on"
],
"_et_pb_show_page_creation": [
"off"
],
"_et_pb_ab_current_shortcode": [
"[et_pb_split_track id=\"1062\" /]"
],
"_et_pb_content_area_background_color": [
""
],
"_et_pb_custom_css": [
""
],
"_et_pb_enable_shortcode_tracking": [
"off"
],
"_et_pb_gutter_width": [
"3"
],
"_et_pb_page_z_index": [
"auto"
],
"_thumbnail_id": [
""
],
"_et_pb_section_background_color": [
""
],
"_et_pb_post_settings_title": [
"[Fixture] Body mit Dynamic Content"
],
"_et_pb_post_settings_excerpt": [
""
],
"_et_pb_first_image": [
""
],
"_et_pb_truncate_post": [
""
],
"_et_pb_truncate_post_date": [
""
],
"_divi_off_canvas_data": [
"a:2:{s:14:\"activeCanvasId\";s:36:\"b773e1de-72ba-4640-85d6-249ce9e4a82d\";s:14:\"mainCanvasName\";s:11:\"Main Canvas\";}"
],
"_et_pb_use_divi_5": [
"on"
]
},
"terms": {}
}
@@ -0,0 +1,7 @@
<!-- wp:divi/placeholder --><!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"[Fixture] Footer Abschnitt"}}}},"builderVersion":"5.2.1"} -->
<!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"[Fixture] Footer 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":"Footer Text"}}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eFixture-Footer – Tischlerei Holzwerk Berger\u003c/p\u003e"}}},"builderVersion":"5.2.1"} /-->
<!-- /wp:divi/column -->
<!-- /wp:divi/row -->
<!-- /wp:divi/section --><!-- /wp:divi/placeholder -->
@@ -0,0 +1,71 @@
{
"post": {
"ID": 1063,
"post_type": "et_footer_layout",
"post_status": "publish",
"post_title": "[Fixture] Footer",
"post_name": "theme-builder-layout",
"post_author": 1,
"post_parent": 0,
"menu_order": 0,
"post_excerpt": "",
"post_date_gmt": "2026-09-26 20:58:34",
"post_modified_gmt": "2026-09-26 21:00:21",
"content_file": "tb-footer--footer.html",
"content_sha256": "7e6f6744cbc55a903cf346328ab383e7ddda85292990cf93b49594fa6f3d7f53"
},
"meta": {
"_et_pb_use_builder": [
"on"
],
"_et_pb_show_page_creation": [
"off"
],
"_et_pb_ab_current_shortcode": [
"[et_pb_split_track id=\"1063\" /]"
],
"_et_pb_content_area_background_color": [
""
],
"_et_pb_custom_css": [
""
],
"_et_pb_enable_shortcode_tracking": [
"off"
],
"_et_pb_gutter_width": [
"3"
],
"_et_pb_page_z_index": [
"auto"
],
"_thumbnail_id": [
""
],
"_et_pb_section_background_color": [
""
],
"_et_pb_post_settings_title": [
"[Fixture] Footer"
],
"_et_pb_post_settings_excerpt": [
""
],
"_et_pb_first_image": [
""
],
"_et_pb_truncate_post": [
""
],
"_et_pb_truncate_post_date": [
""
],
"_divi_off_canvas_data": [
"a:2:{s:14:\"activeCanvasId\";s:36:\"7f8e1cce-7f40-4ac6-bc6d-239e0050ed4a\";s:14:\"mainCanvasName\";s:11:\"Main Canvas\";}"
],
"_et_pb_use_divi_5": [
"on"
]
},
"terms": {}
}
@@ -0,0 +1,9 @@
<!-- wp:divi/placeholder --><!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"[Fixture] Globaler Header Abschnitt"}}}},"builderVersion":"5.2.1"} -->
<!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"[Fixture] Globaler Header Zeile"}}}},"builderVersion":"5.2.1"} -->
<!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}},"builderVersion":"5.2.1"} -->
<!-- wp:divi/heading {"module":{"meta":{"adminLabel":{"desktop":{"value":"Header Titel"}}}},"title":{"innerContent":{"desktop":{"value":"Tischlerei – Globaler Header (Fixture)"}},"decoration":{"font":{"font":{"desktop":{"value":{"headingLevel":"h2"}}}}}},"builderVersion":"5.2.1"} /-->
<!-- wp:divi/text {"module":{"meta":{"adminLabel":{"desktop":{"value":"Header Text"}}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eGlobaler Header der Standard-Vorlage.\u003c/p\u003e"}}},"builderVersion":"5.2.1"} /-->
<!-- /wp:divi/column -->
<!-- /wp:divi/row -->
<!-- /wp:divi/section --><!-- /wp:divi/placeholder -->
@@ -0,0 +1,71 @@
{
"post": {
"ID": 1061,
"post_type": "et_header_layout",
"post_status": "publish",
"post_title": "[Fixture] Globaler Header",
"post_name": "theme-builder-layout",
"post_author": 1,
"post_parent": 0,
"menu_order": 0,
"post_excerpt": "",
"post_date_gmt": "2026-09-26 20:58:33",
"post_modified_gmt": "2026-09-26 20:59:52",
"content_file": "tb-header--globaler-header.html",
"content_sha256": "aa7c71a22cd8d7b3ecb7f6fbcbeaff289322b3797db3f3aac87763d29bb4b851"
},
"meta": {
"_et_pb_use_builder": [
"on"
],
"_et_pb_show_page_creation": [
"off"
],
"_et_pb_ab_current_shortcode": [
"[et_pb_split_track id=\"1061\" /]"
],
"_et_pb_content_area_background_color": [
""
],
"_et_pb_custom_css": [
""
],
"_et_pb_enable_shortcode_tracking": [
"off"
],
"_et_pb_gutter_width": [
"3"
],
"_et_pb_page_z_index": [
"auto"
],
"_thumbnail_id": [
""
],
"_et_pb_section_background_color": [
""
],
"_et_pb_post_settings_title": [
"[Fixture] Globaler Header"
],
"_et_pb_post_settings_excerpt": [
""
],
"_et_pb_first_image": [
""
],
"_et_pb_truncate_post": [
""
],
"_et_pb_truncate_post_date": [
""
],
"_divi_off_canvas_data": [
"a:2:{s:14:\"activeCanvasId\";s:36:\"b20ac30f-aeb9-4b8a-863f-44d36b514202\";s:14:\"mainCanvasName\";s:11:\"Main Canvas\";}"
],
"_et_pb_use_divi_5": [
"on"
]
},
"terms": {}
}
@@ -0,0 +1,47 @@
{
"post": {
"ID": 1065,
"post_type": "et_template",
"post_status": "publish",
"post_title": "Standard-Website-Template",
"post_name": "standard-website-template",
"post_author": 1,
"post_parent": 0,
"menu_order": 0,
"post_excerpt": "",
"post_date_gmt": "2026-09-26 20:58:34",
"post_modified_gmt": "2026-09-26 20:58:34",
"content_file": null,
"content_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"meta": {
"_et_autogenerated_title": [
"0"
],
"_et_default": [
"1"
],
"_et_enabled": [
"0"
],
"_et_header_layout_id": [
"1061"
],
"_et_header_layout_enabled": [
"1"
],
"_et_body_layout_id": [
"0"
],
"_et_body_layout_enabled": [
"1"
],
"_et_footer_layout_id": [
"0"
],
"_et_footer_layout_enabled": [
"1"
]
},
"terms": {}
}
@@ -0,0 +1,50 @@
{
"post": {
"ID": 1066,
"post_type": "et_template",
"post_status": "publish",
"post_title": "[Fixture] Vorlage Globale Instanzen",
"post_name": "fixture-vorlage-globale-instanzen",
"post_author": 1,
"post_parent": 0,
"menu_order": 0,
"post_excerpt": "",
"post_date_gmt": "2026-09-26 20:58:34",
"post_modified_gmt": "2026-09-26 20:58:34",
"content_file": null,
"content_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"meta": {
"_et_autogenerated_title": [
"0"
],
"_et_default": [
"0"
],
"_et_enabled": [
"0"
],
"_et_header_layout_id": [
"1061"
],
"_et_header_layout_enabled": [
"1"
],
"_et_body_layout_id": [
"1062"
],
"_et_body_layout_enabled": [
"1"
],
"_et_footer_layout_id": [
"1063"
],
"_et_footer_layout_enabled": [
"1"
],
"_et_use_on": [
"singular:post_type:page:id:1053"
]
},
"terms": {}
}
@@ -0,0 +1,20 @@
{
"theme_builder_post": {
"ID": 1064,
"post_status": "publish",
"post_title": "Theme Builder",
"meta": {
"_et_template": [
"1065",
"1066"
]
}
},
"template_ids_in_order": [
1065,
1066
],
"draft_theme_builder_posts": [],
"options_et_tb": {},
"marked_as_unused": []
}
@@ -1 +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 --> <!-- 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":"\u003cp\u003eText mit eigenem Element und Attribut.\u003c/p\u003e"}}},"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 -->
@@ -0,0 +1,185 @@
<?php
/**
* Exports the stage 3 Divi fixtures (library items, global instances, Theme Builder) from the
* local site into tests/Fixtures/divi/stage3/: one <role>.json per post (post row, all meta,
* all term relationships), the raw post_content byte-exact as <role>.html, the live Theme
* Builder post with its template list and the et_tb_* options as theme-builder.json, and a
* manifest.json (role → post ID). How the fixtures were created: tests/Fixtures/divi/stage3/README.md.
*
* Usage (DDEV, WordPress root):
* wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-export-stage3-fixtures.php
*
* Only posts titled "[Fixture] …" are exported, plus the Default Website Template when its
* layouts are fixture layouts. Read-only. Fixtures are committed – no personal data.
* No `declare(strict_types=1);` – wp eval-file runs the file through eval().
*/
defined('WP_CLI') && WP_CLI || exit;
$lit_prefix = '[Fixture] ';
$lit_dir = dirname(__DIR__) . '/Fixtures/divi/stage3';
$lit_skip_meta = ['_edit_lock', '_edit_last'];
/** Role name for a fixture post, derived from post type, title and scope. */
$lit_role = static function (WP_Post $post, array $terms): string {
$slug = sanitize_title(preg_replace('/^\[Fixture\]\s*/', '', $post->post_title));
$prefix = [
'page' => 'page',
'et_pb_layout' => in_array('global', array_column($terms['scope'] ?? [], 'slug'), true)
? 'library-global'
: 'library',
'et_template' => 'tb-template',
'et_header_layout' => 'tb-header',
'et_body_layout' => 'tb-body',
'et_footer_layout' => 'tb-footer',
][$post->post_type] ?? $post->post_type;
return $prefix . '--' . $slug;
};
/** All term relationships by taxonomy – read from the tables, some Divi taxonomies are not registered under WP-CLI. */
$lit_terms = static function (int $postId): array {
global $wpdb;
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT tt.taxonomy, t.slug, t.name FROM {$wpdb->term_relationships} tr
JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
WHERE tr.object_id = %d ORDER BY tt.taxonomy, t.slug",
$postId
));
$terms = [];
foreach ($rows as $row) {
$terms[$row->taxonomy][] = ['slug' => $row->slug, 'name' => $row->name];
}
return $terms;
};
/** Raw meta rows (value as stored, multiple values kept in order). */
$lit_meta = static function (int $postId) use ($lit_skip_meta): array {
global $wpdb;
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT meta_key, meta_value FROM {$wpdb->postmeta} WHERE post_id = %d ORDER BY meta_id",
$postId
));
$meta = [];
foreach ($rows as $row) {
if (! in_array($row->meta_key, $lit_skip_meta, true)) {
$meta[$row->meta_key][] = $row->meta_value;
}
}
return $meta;
};
$lit_write = static function (string $file, string $data): void {
if (file_put_contents($file, $data) === false) {
WP_CLI::error('Cannot write ' . $file);
}
};
$lit_json = static fn (array $data): string => wp_json_encode(
$data,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
) . "\n";
global $wpdb;
$lit_types = ['page', 'et_pb_layout', 'et_template', 'et_header_layout', 'et_body_layout', 'et_footer_layout'];
$lit_ids = array_map('intval', $wpdb->get_col($wpdb->prepare(
"SELECT ID FROM {$wpdb->posts}
WHERE post_type IN (" . implode(', ', array_fill(0, count($lit_types), '%s')) . ")
AND post_status NOT IN ('inherit', 'auto-draft')
AND post_title LIKE %s
ORDER BY ID",
...[...$lit_types, $wpdb->esc_like($lit_prefix) . '%']
)));
// The live Theme Builder post and the Default Website Template, when it points to fixture layouts.
$lit_tb_id = (int) $wpdb->get_var(
"SELECT ID FROM {$wpdb->posts} WHERE post_type = 'et_theme_builder' AND post_status = 'publish' ORDER BY ID LIMIT 1"
);
$lit_tb_templates = $lit_tb_id ? array_map('intval', get_post_meta($lit_tb_id, '_et_template', false)) : [];
foreach ($lit_tb_templates as $templateId) {
if (get_post_meta($templateId, '_et_default', true) !== '1') {
continue;
}
foreach (['header', 'body', 'footer'] as $area) {
$layoutId = (int) get_post_meta($templateId, "_et_{$area}_layout_id", true);
if ($layoutId > 0 && in_array($layoutId, $lit_ids, true) && ! in_array($templateId, $lit_ids, true)) {
$lit_ids[] = $templateId;
}
}
}
if ($lit_ids === []) {
WP_CLI::error('No "[Fixture] " posts found – create them first (see tests/Fixtures/divi/stage3/README.md).');
}
if (! is_dir($lit_dir) && ! wp_mkdir_p($lit_dir)) {
WP_CLI::error('Cannot create ' . $lit_dir);
}
foreach (glob($lit_dir . '/*.{json,html}', GLOB_BRACE) ?: [] as $old) {
unlink($old);
}
$lit_manifest = ['divi_version' => wp_get_theme('Divi')->get('Version'), 'posts' => []];
foreach ($lit_ids as $postId) {
$post = get_post($postId);
$terms = $lit_terms($postId);
$role = $post->post_type === 'et_template' && get_post_meta($postId, '_et_default', true) === '1'
? 'tb-template--default'
: $lit_role($post, $terms);
$record = [
'post' => [
'ID' => $post->ID,
'post_type' => $post->post_type,
'post_status' => $post->post_status,
'post_title' => $post->post_title,
'post_name' => $post->post_name,
'post_author' => (int) $post->post_author,
'post_parent' => $post->post_parent,
'menu_order' => $post->menu_order,
'post_excerpt' => $post->post_excerpt,
'post_date_gmt' => $post->post_date_gmt,
'post_modified_gmt' => $post->post_modified_gmt,
'content_file' => $post->post_content === '' ? null : $role . '.html',
'content_sha256' => hash('sha256', $post->post_content),
],
'meta' => (object) $lit_meta($postId),
'terms' => (object) $terms,
];
$lit_write($lit_dir . '/' . $role . '.json', $lit_json($record));
if ($post->post_content !== '') {
$lit_write($lit_dir . '/' . $role . '.html', $post->post_content);
}
$lit_manifest['posts'][$role] = $postId;
}
$lit_options = [];
$lit_option_rows = $wpdb->get_results(
"SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE 'et\\_tb\\_%' ORDER BY option_name"
);
foreach ($lit_option_rows as $row) {
$lit_options[$row->option_name] = $row->option_value;
}
$lit_write($lit_dir . '/theme-builder.json', $lit_json([
'theme_builder_post' => $lit_tb_id ? [
'ID' => $lit_tb_id,
'post_status' => get_post_status($lit_tb_id),
'post_title' => get_the_title($lit_tb_id),
'meta' => (object) $lit_meta($lit_tb_id),
] : null,
'template_ids_in_order' => $lit_tb_templates,
'draft_theme_builder_posts' => array_map('intval', $wpdb->get_col(
"SELECT ID FROM {$wpdb->posts} WHERE post_type = 'et_theme_builder' AND post_status = 'auto-draft'"
)),
'options_et_tb' => (object) $lit_options,
'marked_as_unused' => array_map('intval', $wpdb->get_col(
"SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_et_theme_builder_marked_as_unused'"
)),
]));
$lit_write($lit_dir . '/manifest.json', $lit_json($lit_manifest));
WP_CLI::success(sprintf('%d posts exported to %s', count($lit_manifest['posts']), $lit_dir));
+473
View File
@@ -0,0 +1,473 @@
#!/usr/bin/env bash
# End-to-end checks of Etappe 3a (Divi Library, global modules, node copy, {{link:ID}}) 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-library-smoke.sh
# Creates only temporary data: draft/private pages and library entries whose titles start with
# "LIT Smoke <run>", a role and a user without unfiltered_html. The EXIT trap removes all of it by
# exact ID (posts with their revisions, change-log rows, user, role, app passwords) and restores the
# options it changed. [Fixture]/[Test] content is only read (fixture files), never written.
# Security probes (section 10) use benign markers only: data-probe attributes and an example.invalid URL.
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)
STAGE3=$PLUGIN/tests/Fixtures/divi/stage3
RUN="$(date +%s)-$$"
PREFIX="LIT Smoke $RUN"
# Per-run names: cleanup never touches an account or role that existed before this run.
EDITOR_LOGIN="lit-lib-editor-$RUN"
EDITOR_ROLE="lit_lib_editor_${RUN//-/_}"
EDITOR_CREATED=0
ROLE_CREATED=0
ORIGINAL_ROLES=$(wp option get lit_mcp_allowed_roles --format=json 2>/dev/null || echo '__none__')
ORIGINAL_EXPOSURE=$(wp option get lit_mcp_exposure --format=json 2>/dev/null || echo '__none__')
CREATED_POSTS=()
restore_roles() {
if [ "$ORIGINAL_ROLES" = "__none__" ]; then
wp option delete lit_mcp_allowed_roles >/dev/null 2>&1 || true
else
wp option update lit_mcp_allowed_roles "$ORIGINAL_ROLES" --format=json >/dev/null 2>&1 || true
fi
}
restore_exposure() {
if [ "$ORIGINAL_EXPOSURE" = "__none__" ]; then
wp option delete lit_mcp_exposure >/dev/null 2>&1 || true
else
wp option update lit_mcp_exposure "$ORIGINAL_EXPOSURE" --format=json >/dev/null 2>&1 || true
fi
}
cleanup() {
# Pages and library entries of this run whose ID a stopped run could not record (title prefix with the run token).
local found
found=$(LIT_PREFIX="$PREFIX " wp eval '
global $wpdb;
echo implode(" ", $wpdb->get_col($wpdb->prepare(
"SELECT ID FROM $wpdb->posts WHERE post_type IN (%s, %s) AND post_title LIKE %s",
"page",
"et_pb_layout",
$wpdb->esc_like((string) getenv("LIT_PREFIX")) . "%"
)));' 2>/dev/null || true)
for id in $found; do
CREATED_POSTS+=("$id")
done
# Change-log rows: every row of the temporary user and every row about a post of this run.
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_unique(array_filter(explode(" ", (string) getenv("LIT_IDS")))) as $id) {
foreach (["page", "et_pb_layout"] as $type) {
$wpdb->delete($table, ["object_type" => $type, "object_id" => $id]);
}
}' >/dev/null 2>&1 || true
# wp_delete_post(…, true) also deletes the revisions of each post.
for id in "${CREATED_POSTS[@]}"; do
wp post delete "$id" --force >/dev/null 2>&1 || true
done
cleanup_app_passwords_for "$ADMIN_LOGIN"
if [ "$EDITOR_CREATED" = 1 ]; then
cleanup_app_passwords_for "$EDITOR_LOGIN"
wp user delete "$EDITOR_LOGIN" --yes >/dev/null 2>&1 || true
fi
if [ "$ROLE_CREATED" = 1 ]; then
wp role delete "$EDITOR_ROLE" >/dev/null 2>&1 || true
fi
restore_roles
restore_exposure
}
trap 'rc=$?; cleanup; guard_exit "$rc" || exit $?' EXIT
# Creates a page as the admin (no kses) from markup and prints its ID.
create_page() { # $1 = markup, $2 = title suffix, $3 = status (default draft)
LIT_CONTENT="$1" LIT_TITLE="$PREFIX $2" LIT_STATUS="${3:-draft}" wp --user="$ADMIN_LOGIN" eval '
$id = wp_insert_post(["post_type" => "page", "post_status" => getenv("LIT_STATUS"), "post_title" => getenv("LIT_TITLE"),
"post_content" => wp_slash((string) getenv("LIT_CONTENT"))]);
update_post_meta($id, "_et_pb_use_builder", "on");
update_post_meta($id, "_et_pb_use_divi_5", "on");
echo $id;'
}
# Exact stored bytes of a post (PHP, so no trailing newline is lost or added).
content_of() { # $1 = post ID
wp eval "echo (string) get_post($1)->post_content;"
}
content_hash() { # $1 = post ID
wp eval "echo hash('sha256', (string) get_post($1)->post_content);"
}
outline() { # $1 = post ID -> the lit-divi-outline result
tool lit-divi-outline "{\"post_id\":$1}"
}
rev_of() { # $1 = post ID -> tree_rev
outline "$1" | jq -r '.tree_rev'
}
# "yes" when the stored content equals $2 with exactly the bytes $3 inserted at one position.
inserted_once() { # $1 = post ID, $2 = content before, $3 = inserted bytes
LIT_ID="$1" LIT_BEFORE="$2" LIT_NEEDLE="$3" wp eval '
$after = (string) get_post((int) getenv("LIT_ID"))->post_content;
$before = (string) getenv("LIT_BEFORE");
$needle = (string) getenv("LIT_NEEDLE");
$ok = $needle !== "" && strlen($after) === strlen($before) + strlen($needle);
for ($at = $ok ? strpos($after, $needle) : false, $ok = false; $at !== false; $at = strpos($after, $needle, $at + 1)) {
if (substr($after, 0, $at) . substr($after, $at + strlen($needle)) === $before) { $ok = true; break; }
}
echo $ok ? "yes" : "no";'
}
# Divi's _et_* meta and the library terms of an entry, as sorted JSON (compared with the stage3 fixtures).
entry_shape() { # $1 = post ID
wp eval "
\$meta = [];
foreach (get_post_meta($1) as \$k => \$v) { if (str_starts_with(\$k, '_et_')) { \$meta[\$k] = \$v; } }
ksort(\$meta);
\$terms = [];
foreach (['layout_type', 'module_width', 'scope'] as \$t) { \$terms[\$t] = wp_get_object_terms($1, \$t, ['fields' => 'slugs']); }
echo wp_json_encode(['meta' => \$meta, 'terms' => \$terms]);"
}
fixture_shape() { # $1 = stage3 fixture name
jq -cS '{meta: (.meta | with_entries(select(.key | startswith("_et_")))),
terms: {layout_type: [.terms.layout_type[]?.slug], module_width: [.terms.module_width[]?.slug], scope: [.terms.scope[]?.slug]}}' \
"$STAGE3/$1.json"
}
# Byte snippets from Divi's own stage3 saves (fixtures were written by the Visual Builder).
snippet() { # $1 = what -> prints the bytes
LIT_DIR="$STAGE3" LIT_WHAT="$1" wp eval '
$dir = getenv("LIT_DIR");
$read = static fn (string $f): string => (string) file_get_contents("$dir/$f.html");
$unframe = static fn (string $c): string => substr($c, strlen("<!-- wp:divi/placeholder -->"), -strlen("<!-- /wp:divi/placeholder -->"));
$page = $read("page--globale-instanzen");
$from = strpos($page, "<!-- wp:divi/global-layout {\"globalModule\":\"1059\"");
$instance = substr($page, $from, strpos($page, " /-->", $from) + 5 - $from);
$globalSection = substr($page, strlen("<!-- wp:divi/placeholder -->"),
strpos($page, "<!-- /wp:divi/global-layout -->\n\n") + strlen("<!-- /wp:divi/global-layout -->\n\n") - strlen("<!-- wp:divi/placeholder -->"));
$text = $unframe($read("library-global--globales-modul"));
switch (getenv("LIT_WHAT")) {
case "source": // the fixture page without global instances: the global module as a plain text module
echo str_replace([$globalSection, $instance], ["", $text], $page); break;
case "target": // the section "Modul Abschnitt" with only the local button
echo str_replace([$globalSection, $instance . "\n\n"], ["", ""], substr($page, 0, strpos($page, "<!-- wp:divi/section {\"module\":{\"meta\":{\"adminLabel\":{\"desktop\":{\"value\":\"Library Abschnitt\""))) . "<!-- /wp:divi/placeholder -->"; break;
case "section-entry": echo str_replace("\r\n", "\n", $read("library--library-abschnitt")); break;
case "section": echo $unframe(str_replace("\r\n", "\n", $read("library--library-abschnitt"))); break;
case "module-entry": echo $read("library-global--globales-modul"); break;
case "instance": echo $instance; break;
}'
}
[ "$(wp eval 'echo get_template();')" = "Divi" ] || fail "Divi must be the active theme"
# 0. Temporary pages built from Divi's own stage3 bytes
SOURCE_ID=$(create_page "$(snippet source)" "Quelle"); CREATED_POSTS+=("$SOURCE_ID")
TARGET_ID=$(create_page "$(snippet target)" "Ziel"); CREATED_POSTS+=("$TARGET_ID")
SECTION_BYTES=$(snippet section)
content_of "$SOURCE_ID" | grep -qF "$SECTION_BYTES" || fail "setup: source page lacks the library section bytes"
if content_of "$SOURCE_ID" | grep -q 'globalModule'; then fail "setup: source page still holds a global instance"; fi
SOURCE_HASH=$(content_hash "$SOURCE_ID")
connect "$ADMIN_LOGIN"
# 1. Exposure: lit-library-insert direct, the other new tools indirect, 22 direct lit tools
LIST=$(rpc tools/list '{}')
echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-library-insert") | .annotations.readOnlyHint == false' >/dev/null \
|| fail "lit-library-insert is not a direct write tool"
for t in lit-library-list lit-library-save lit-divi-node-copy lit-divi-dynamic-sources; do
if echo "$LIST" | jq -e --arg t "$t" '.result.tools[] | select(.name == $t)' >/dev/null; then fail "$t must be indirect"; fi
done
DIRECT='["lit-content-create","lit-content-get","lit-content-query","lit-content-types","lit-content-update","lit-divi-catalog",
"lit-divi-delete","lit-divi-design-system","lit-divi-insert","lit-divi-move","lit-divi-node-get","lit-divi-outline","lit-divi-update",
"lit-guide","lit-library-insert","lit-media-download","lit-media-preview","lit-media-search","lit-media-upload-from-url",
"lit-page-render-summary","lit-post-publish","lit-site-status"]'
echo "$LIST" | jq -e --argjson want "$DIRECT" '[.result.tools[].name | select(startswith("lit-"))] | sort == ($want | sort) and length == 22' >/dev/null \
|| fail "direct lit tools: $(echo "$LIST" | jq -c '[.result.tools[].name]')"
for a in lit/library-list lit/library-save lit/divi-node-copy lit/divi-dynamic-sources; do
tool mcp-adapter-get-ability-info "$(jq -cn --arg a "$a" '{ability_name: $a}')" | jq -e 'has("__error") | not' >/dev/null \
|| fail "$a is not reachable as an indirect ability"
done
pass "tools/list: lit-library-insert direct, library-list/-save, node-copy, dynamic-sources indirect, 22 direct tools"
# 2. Save a section as normal entry: bytes, terms and meta as Divi's own entry, no Divi flags, source untouched
REV=$(rev_of "$SOURCE_ID")
SAVED=$(ability lit/library-save "$(jq -cn --argjson id "$SOURCE_ID" --arg rev "$REV" --arg t "$PREFIX Abschnitt" \
'{post_id: $id, tree_rev: $rev, id: "s1", title: $t}')")
LIB_ID=$(echo "$SAVED" | jq -r '.library_id // empty')
[ -n "$LIB_ID" ] || fail "library-save: $SAVED"
CREATED_POSTS+=("$LIB_ID")
[ "$(content_of "$LIB_ID")" = "$(snippet section-entry)" ] || fail "normal entry bytes differ from Divi's entry: $(content_of "$LIB_ID")"
[ "$(entry_shape "$LIB_ID" | jq -cS .)" = "$(fixture_shape library--library-abschnitt)" ] \
|| fail "normal entry terms/meta: $(entry_shape "$LIB_ID")"
[ -z "$(wp post meta get "$LIB_ID" _et_pb_use_divi_5 2>/dev/null || true)" ] || fail "library entry got Divi flags"
[ "$(wp post get "$LIB_ID" --field=post_status)" = "publish" ] || fail "library entry is not published"
echo "$SAVED" | jq -e --arg r "$(rev_of "$LIB_ID")" '.tree_rev == $r' >/dev/null || fail "library-save tree_rev: $SAVED"
[ "$(content_hash "$SOURCE_ID")" = "$SOURCE_HASH" ] || fail "library-save changed the source page"
pass "lit/library-save normal: bytes, terms and meta like Divi's entry, no Divi flags, source unchanged"
# 3. Save the text module as global entry; library-list shows both entries
GSAVED=$(ability lit/library-save "$(jq -cn --argjson id "$SOURCE_ID" --arg rev "$REV" --arg t "$PREFIX Globales Modul" \
'{post_id: $id, tree_rev: $rev, id: "s0.r0.c0.m0", title: $t, scope: "global"}')")
GLOB_ID=$(echo "$GSAVED" | jq -r '.library_id // empty')
[ -n "$GLOB_ID" ] || fail "global library-save: $GSAVED"
CREATED_POSTS+=("$GLOB_ID")
[ "$(content_of "$GLOB_ID")" = "$(snippet module-entry)" ] || fail "global entry bytes differ from Divi's entry: $(content_of "$GLOB_ID")"
[ "$(entry_shape "$GLOB_ID" | jq -cS .)" = "$(fixture_shape library-global--globales-modul)" ] \
|| fail "global entry terms/meta: $(entry_shape "$GLOB_ID")"
[ "$(content_hash "$SOURCE_ID")" = "$SOURCE_HASH" ] || fail "global library-save changed the source page"
LISTED=$(ability lit/library-list "$(jq -cn --arg s "$PREFIX" '{search: $s}')")
echo "$LISTED" | jq -e --argjson l "$LIB_ID" --argjson g "$GLOB_ID" '
(.items | map(select(.id == $l and .scope == "normal" and .layout_type == "section")) | length == 1) and
(.items | map(select(.id == $g and .scope == "global" and .layout_type == "module")) | length == 1)' >/dev/null \
|| fail "library-list: $LISTED"
ability lit/library-list "{\"id\":$GLOB_ID}" | jq -e '.outline | length > 0' >/dev/null || fail "library-list detail of $GLOB_ID"
pass "lit/library-save global: bytes, terms and meta like Divi's entry; lit/library-list shows both"
# A normal section entry with the CRLF bytes of Divi's library dialog (stage3 fixture as-is, finding 9).
CRLF_ID=$(LIT_FILE="$STAGE3/library--library-abschnitt.html" LIT_TITLE="$PREFIX CRLF" wp --user="$ADMIN_LOGIN" eval '
$id = wp_insert_post(["post_type" => "et_pb_layout", "post_status" => "publish", "post_title" => getenv("LIT_TITLE"),
"post_content" => wp_slash((string) file_get_contents(getenv("LIT_FILE")))]);
wp_set_object_terms($id, "section", "layout_type");
wp_set_object_terms($id, "regular", "module_width");
wp_set_object_terms($id, "non_global", "scope");
update_post_meta($id, "_et_pb_template_type", "section");
update_post_meta($id, "_et_pb_built_for_post_type", "page");
echo $id;')
CREATED_POSTS+=("$CRLF_ID")
content_of "$CRLF_ID" | grep -q $'\r' || fail "setup: the CRLF entry holds no CR"
# 4. Insert the global entry as instance, the CRLF entry as copy: bytes as the Visual Builder writes them
BEFORE=$(content_of "$TARGET_ID")
INST=$(tool lit-library-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(rev_of "$TARGET_ID")" --argjson lib "$GLOB_ID" \
'{post_id: $id, tree_rev: $rev, library_id: $lib, parent: "s0.r0.c0", index: 0}')")
echo "$INST" | jq -e '.ok == true and .changed[0].id == "s0.r0.c0.g0"' >/dev/null || fail "library-insert global: $INST"
EXPECTED_INSTANCE=$(snippet instance | sed "s/\"globalModule\":\"1059\"/\"globalModule\":\"$GLOB_ID\"/")
[ "$(inserted_once "$TARGET_ID" "$BEFORE" "$EXPECTED_INSTANCE")" = "yes" ] \
|| fail "instance bytes differ from the Visual Builder's: $(content_of "$TARGET_ID")"
outline "$TARGET_ID" | jq -e --arg g "$GLOB_ID" '.outline | contains($g)' >/dev/null || fail "outline does not name the instance"
BEFORE=$(content_of "$TARGET_ID")
COPY=$(tool lit-library-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(echo "$INST" | jq -r .tree_rev)" --argjson lib "$CRLF_ID" \
'{post_id: $id, tree_rev: $rev, library_id: $lib, mode: "copy"}')")
echo "$COPY" | jq -e '.ok == true and .changed[0].id == "s1"' >/dev/null || fail "library-insert copy: $COPY"
[ "$(inserted_once "$TARGET_ID" "$BEFORE" "$SECTION_BYTES")" = "yes" ] || fail "copy bytes differ from the entry's section"
if content_of "$TARGET_ID" | grep -q $'\r'; then fail "copy brought CRLF into the page"; fi
pass "lit-library-insert: instance and copy byte-exact in the Visual Builder's format"
# 5. Global edit: dry run lists the pages, confirm changes the entry with a revision, page bytes unchanged
PRIVATE_ID=$(create_page "<!-- wp:divi/placeholder --><!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column {\"module\":{\"decoration\":{\"sizing\":{\"desktop\":{\"value\":{\"flexType\":\"24_24\"}}}}}} -->$EXPECTED_INSTANCE<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->" "Privat" private)
CREATED_POSTS+=("$PRIVATE_ID")
TARGET_HASH=$(content_hash "$TARGET_ID")
PRIVATE_HASH=$(content_hash "$PRIVATE_ID")
GLOB_HASH=$(content_hash "$GLOB_ID")
GREV=$(rev_of "$GLOB_ID")
CHANGE='[{"id":"m0","set":{"content":"<p>LIT Smoke geändert</p>"}}]'
DRY=$(tool lit-divi-update "$(jq -cn --argjson id "$GLOB_ID" --arg rev "$GREV" --argjson c "$CHANGE" '{post_id: $id, tree_rev: $rev, changes: $c}')")
[ "$(error_code "$DRY")" = "confirm_required" ] || fail "global edit without confirm: $DRY"
echo "$DRY" | jq -r '.__error' | jq -e --argjson t "$TARGET_ID" --argjson p "$PRIVATE_ID" \
'(.affected | map(.post_id) | (index($t) != null and index($p) != null)) and .affected_count >= 2 and (has("blocked_ids") | not)' >/dev/null \
|| fail "dry run: $DRY"
[ "$(content_hash "$GLOB_ID")" = "$GLOB_HASH" ] || fail "dry run wrote something"
REVISIONS=$(wp eval "echo count(wp_get_post_revisions($GLOB_ID));")
DONE=$(tool lit-divi-update "$(jq -cn --argjson id "$GLOB_ID" --arg rev "$GREV" --argjson c "$CHANGE" '{post_id: $id, tree_rev: $rev, confirm: true, changes: $c}')")
echo "$DONE" | jq -e '.ok == true and .revision_id != null and .affected_count >= 2' >/dev/null || fail "confirmed global edit: $DONE"
[ "$(wp eval "echo count(wp_get_post_revisions($GLOB_ID));")" -gt "$REVISIONS" ] || fail "confirmed global edit made no revision"
[ "$(wp post get "$(echo "$DONE" | jq -r .revision_id)" --field=post_parent)" = "$GLOB_ID" ] || fail "revision_id is not a revision of $GLOB_ID"
content_of "$GLOB_ID" | grep -q 'LIT Smoke geändert' || fail "the entry was not changed"
[ -z "$(wp post meta get "$GLOB_ID" _et_pb_use_divi_5 2>/dev/null || true)" ] || fail "global edit set Divi flags"
[ "$(content_hash "$TARGET_ID")" = "$TARGET_HASH" ] || fail "the global edit changed the page bytes"
[ "$(content_hash "$PRIVATE_ID")" = "$PRIVATE_HASH" ] || fail "the global edit changed the private page bytes"
# Divi does not render global modules outside a front-end request; the summary names the instance instead.
tool lit-page-render-summary "{\"post_id\":$TARGET_ID}" | jq -e --arg g "Globales Modul $GLOB_ID " '.notes | map(contains($g)) | any' >/dev/null \
|| fail "render summary does not note the global module"
pass "global edit: dry run lists the pages, confirm changes the entry with a revision, pages keep their bytes"
# 6. Copy a node between pages; a stale source_tree_rev writes nothing
TREV=$(rev_of "$TARGET_ID")
STALE=$(ability lit/divi-node-copy "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --argjson src "$SOURCE_ID" \
'{post_id: $id, tree_rev: $rev, source_post_id: $src, source_id: "s1", source_tree_rev: "abcdef012345"}')")
[ "$(error_code "$STALE")" = "rev_mismatch" ] || fail "stale source_tree_rev: $STALE"
[ "$(rev_of "$TARGET_ID")" = "$TREV" ] || fail "stale node-copy wrote something"
BEFORE=$(content_of "$TARGET_ID")
NODE_COPY=$(ability lit/divi-node-copy "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --argjson src "$SOURCE_ID" \
--arg srev "$(rev_of "$SOURCE_ID")" '{post_id: $id, tree_rev: $rev, source_post_id: $src, source_id: "s1", source_tree_rev: $srev}')")
echo "$NODE_COPY" | jq -e '.ok == true and .revision_id != null' >/dev/null || fail "node-copy: $NODE_COPY"
[ "$(inserted_once "$TARGET_ID" "$BEFORE" "$SECTION_BYTES")" = "yes" ] || fail "node-copy bytes differ from the source node"
[ "$(content_hash "$SOURCE_ID")" = "$SOURCE_HASH" ] || fail "node-copy changed the source page"
BEFORE=$(content_of "$TARGET_ID")
LIB_COPY=$(ability lit/divi-node-copy "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(echo "$NODE_COPY" | jq -r .tree_rev)" \
--argjson src "$CRLF_ID" --arg srev "$(rev_of "$CRLF_ID")" \
'{post_id: $id, tree_rev: $rev, source_post_id: $src, source_id: "s0", source_tree_rev: $srev}')")
echo "$LIB_COPY" | jq -e '.ok == true' >/dev/null || fail "node-copy from the CRLF entry: $LIB_COPY"
[ "$(inserted_once "$TARGET_ID" "$BEFORE" "$SECTION_BYTES")" = "yes" ] || fail "node-copy from a library entry: bytes differ"
if content_of "$TARGET_ID" | grep -q $'\r'; then fail "node-copy brought CRLF into the page"; fi
NODE_COPY=$LIB_COPY
pass "lit/divi-node-copy: stale source_tree_rev refused, copy byte-exact (CRLF unified from a library entry), source unchanged"
# 7. {{link:ID}} in a button URL: Divi's post_link_url_page; a draft target renders only ?page_id=
TREV=$(echo "$NODE_COPY" | jq -r '.tree_rev')
LINKED=$(tool lit-divi-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --arg u "{{link:$SOURCE_ID}}" \
'{post_id: $id, tree_rev: $rev, parent: "s0.r0.c0", nodes: [{type: "button", text: "Zur Quelle", url: $u}]}')")
echo "$LINKED" | jq -e '.ok == true' >/dev/null || fail "{{link}} in a button: $LINKED"
LINK_VALUE=$(LIT_ID="$TARGET_ID" wp eval '
$find = static function (array $blocks) use (&$find): ?string {
foreach ($blocks as $b) {
$text = $b["attrs"]["button"]["innerContent"]["desktop"]["value"]["text"] ?? null;
if ($b["blockName"] === "divi/button" && $text === "Zur Quelle") {
return (string) ($b["attrs"]["button"]["innerContent"]["desktop"]["value"]["linkUrl"] ?? "");
}
$in = $find($b["innerBlocks"] ?? []);
if ($in !== null) { return $in; }
}
return null;
};
echo $find(parse_blocks((string) get_post((int) getenv("LIT_ID"))->post_content));')
echo "$LINK_VALUE" | grep -qE "^\\\$variable\(\{\"type\":\"content\",\"value\":\{\"name\":\"post_link_url_page\",\"settings\":\{\"post_id\":\"$SOURCE_ID\"\}\}\}\)\\\$\$" \
|| fail "{{link}} not stored as Divi's post_link_url_page: $LINK_VALUE"
RENDERED=$(LIT_ID="$TARGET_ID" wp eval 'echo (new LIT\MCP\Integrations\WordPress\Render\PageRenderer())->render(get_post((int) getenv("LIT_ID")));')
echo "$RENDERED" | grep -qE "href=\"[^\"]*\\?page_id=$SOURCE_ID\"" || fail "draft link target does not render as ?page_id=$SOURCE_ID"
if echo "$RENDERED" | grep -q "$PREFIX Quelle"; then fail "the rendered link reveals the draft's title"; fi
TEXT_LINK=$(tool lit-divi-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(echo "$LINKED" | jq -r .tree_rev)" --arg u "{{link:$SOURCE_ID}}" \
'{post_id: $id, tree_rev: $rev, parent: "s0.r0.c0", nodes: [{type: "heading", text: $u}]}')")
[ "$(error_code "$TEXT_LINK")" = "validation_failed" ] || fail "{{link}} in a text field: $TEXT_LINK"
pass "{{link:ID}} stored as post_link_url_page, draft target renders only ?page_id=, refused in text fields"
# 8. Carry-overs: guide "all", site-status compatibility, dynamic sources for page
tool lit-guide '{"topic":"all"}' | jq -e '.content | contains("divi-library") and contains("lit-library-insert")' >/dev/null || fail "guide all"
STATUS=$(tool lit-site-status '{}')
echo "$STATUS" | jq -e '.components[] | select(.id == "divi") | .active == true and .problems == []' >/dev/null \
|| fail "Divi compatibility findings: $STATUS"
SOURCES=$(ability lit/divi-dynamic-sources '{"post_type":"page"}')
echo "$SOURCES" | jq -e '.post_type == "page" and (.sources | length > 0) and
(.sources | map(select(.name == "post_title" and (.placeholder // "" | startswith("{{post:")))) | length == 1) and
(.sources | map(select(.name == "post_link_url_page" and .placeholder == "{{link:<ID>}}")) | length == 1) and
(.sources | map(select(.name | startswith("loop_"))) | length == 0) and
([.fields[].key] | map(select(startswith("_"))) | length == 0)' >/dev/null || fail "dynamic sources: $SOURCES"
pass "lit-guide all, lit-site-status without Divi findings, lit/divi-dynamic-sources for page"
# 9. Probe page (admin-authored, benign markers): interactions, Lottie source, custom HTML
PROBE_ID=$(LIT_TITLE="$PREFIX Probe" wp --user="$ADMIN_LOGIN" eval '
$v = static fn ($x): array => ["desktop" => ["value" => $x]];
$wrap = static fn (array $module): array => ["blockName" => "divi/section", "attrs" => [], "innerContent" => [null],
"innerBlocks" => [["blockName" => "divi/row", "attrs" => [], "innerContent" => [null], "innerBlocks" => [[
"blockName" => "divi/column", "attrs" => ["module" => ["decoration" => ["sizing" => $v(["flexType" => "24_24"])]]],
"innerContent" => [null], "innerBlocks" => [$module]]]]]];
$module = static fn (string $name, array $attrs): array => ["blockName" => $name, "attrs" => $attrs, "innerBlocks" => [], "innerContent" => []];
$text = ["innerContent" => $v("<p>Probe</p>")];
$sections = [
$wrap($module("divi/text", ["content" => $text, "module" => ["decoration" => ["interactions" => $v(["interactions" => [[
"id" => "probe", "trigger" => "click", "effect" => "addAttribute", "attributeName" => "data-probe", "attributeValue" => "1"]]])]]])),
$wrap($module("divi/lottie", ["lottie" => ["innerContent" => $v(["src" => "https://example.invalid/data-probe.json"])]])),
$wrap($module("divi/text", ["content" => $text, "module" => ["advanced" => ["html" => $v(["htmlBefore" => "<b data-probe=\"1\">Probe</b>"])]]])),
];
$id = wp_insert_post(["post_type" => "page", "post_status" => "draft", "post_title" => getenv("LIT_TITLE"),
"post_content" => wp_slash(serialize_block(["blockName" => "divi/placeholder", "attrs" => [], "innerBlocks" => $sections,
"innerContent" => array_fill(0, count($sections), null)]))]);
update_post_meta($id, "_et_pb_use_builder", "on");
update_post_meta($id, "_et_pb_use_divi_5", "on");
echo $id;')
CREATED_POSTS+=("$PROBE_ID")
PROBE_REV=$(rev_of "$PROBE_ID")
PROBE_ENTRIES=()
for s in s0 s1 s2; do
P=$(ability lit/library-save "$(jq -cn --argjson id "$PROBE_ID" --arg rev "$PROBE_REV" --arg s "$s" --arg t "$PREFIX Probe $s" \
'{post_id: $id, tree_rev: $rev, id: $s, title: $t}')")
PID=$(echo "$P" | jq -r '.library_id // empty')
[ -n "$PID" ] || fail "admin library-save of probe $s: $P"
CREATED_POSTS+=("$PID")
PROBE_ENTRIES+=("$PID")
done
pass "admin (unfiltered_html) may save interactions, Lottie source and custom HTML to the library"
# 10. Temporary editor without unfiltered_html and without edit_private_pages
if wp user get "$EDITOR_LOGIN" >/dev/null 2>&1 || wp role exists "$EDITOR_ROLE" >/dev/null 2>&1; then
fail "user $EDITOR_LOGIN or role $EDITOR_ROLE exists already"
fi
wp role create "$EDITOR_ROLE" "LIT Smoke Bibliothek" --clone=editor >/dev/null
ROLE_CREATED=1
wp cap remove "$EDITOR_ROLE" unfiltered_html edit_private_pages >/dev/null
wp option update lit_mcp_allowed_roles "[\"administrator\",\"$EDITOR_ROLE\"]" --format=json >/dev/null
wp user create "$EDITOR_LOGIN" "$EDITOR_LOGIN@example.invalid" --role="$EDITOR_ROLE" >/dev/null
EDITOR_CREATED=1
[ "$(LIT_LOGIN="$EDITOR_LOGIN" LIT_ID="$PRIVATE_ID" wp eval '
$u = get_user_by("login", getenv("LIT_LOGIN"));
echo (user_can($u, "unfiltered_html") ? "html " : "") . (user_can($u, "edit_post", (int) getenv("LIT_ID")) ? "private" : "");')" = "" ] \
|| fail "the editor must lack unfiltered_html and edit_post on the private page"
connect "$EDITOR_LOGIN"
# 10a. P8a: the private page blocks the confirmed global edit; the dry run names it in blocked_ids
GLOB_HASH=$(content_hash "$GLOB_ID")
GREV=$(rev_of "$GLOB_ID")
CHANGE='[{"id":"m0","set":{"content":"<p>LIT Smoke Redakteur</p>"}}]'
DRY=$(tool lit-divi-update "$(jq -cn --argjson id "$GLOB_ID" --arg rev "$GREV" --argjson c "$CHANGE" '{post_id: $id, tree_rev: $rev, changes: $c}')")
[ "$(error_code "$DRY")" = "confirm_required" ] || fail "editor dry run: $DRY"
echo "$DRY" | jq -r '.__error' | jq -e --argjson p "$PRIVATE_ID" '.blocked_ids == [$p]' >/dev/null || fail "dry run lacks blocked_ids: $DRY"
REFUSED=$(tool lit-divi-update "$(jq -cn --argjson id "$GLOB_ID" --arg rev "$GREV" --argjson c "$CHANGE" '{post_id: $id, tree_rev: $rev, confirm: true, changes: $c}')")
[ "$(error_code "$REFUSED")" = "forbidden" ] || fail "confirmed global edit with a blocked private page: $REFUSED"
echo "$REFUSED" | grep -q "$PRIVATE_ID" || fail "refusal does not name the private page ID: $REFUSED"
if echo "$REFUSED" | grep -q "$PREFIX Privat"; then fail "refusal reveals the private page's title"; fi
[ "$(content_hash "$GLOB_ID")" = "$GLOB_HASH" ] || fail "refused global edit wrote something"
pass "P8a: editor without rights on a private page gets blocked_ids in the dry run and forbidden on confirm"
# 10b. Control: the editor may copy benign nodes and entries
TREV=$(rev_of "$TARGET_ID")
OK_COPY=$(ability lit/divi-node-copy "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --argjson src "$SOURCE_ID" \
--arg srev "$(rev_of "$SOURCE_ID")" '{post_id: $id, tree_rev: $rev, source_post_id: $src, source_id: "s1", source_tree_rev: $srev}')")
echo "$OK_COPY" | jq -e '.ok == true' >/dev/null || fail "editor node-copy of a benign section: $OK_COPY"
OK_INSERT=$(tool lit-library-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(echo "$OK_COPY" | jq -r .tree_rev)" --argjson lib "$LIB_ID" \
'{post_id: $id, tree_rev: $rev, library_id: $lib, mode: "copy"}')")
echo "$OK_INSERT" | jq -e '.ok == true' >/dev/null || fail "editor library-insert copy of a benign entry: $OK_INSERT"
pass "editor without unfiltered_html copies benign nodes and entries"
# 10c. Interactions, Lottie source and custom HTML: refused by library-save, library-insert copy and node-copy
TARGET_HASH=$(content_hash "$TARGET_ID")
TREV=$(rev_of "$TARGET_ID")
PROBE_REV=$(rev_of "$PROBE_ID")
PROBE_PATHS=(decoration.interactions lottie.innerContent advanced.html)
refused() { # $1 = result, $2 = context, $3 = path the refusal must name
[ "$(error_code "$1")" = "validation_failed" ] || fail "$2 not refused: $1"
echo "$1" | grep -q 'Divi-Builder' && echo "$1" | grep -qF "$3" || fail "$2 refused for another reason: $1"
}
i=0
for s in s0 s1 s2; do
p=${PROBE_PATHS[$i]}
refused "$(ability lit/library-save "$(jq -cn --argjson id "$PROBE_ID" --arg rev "$PROBE_REV" --arg s "$s" --arg t "$PREFIX Probe Redakteur $s" \
'{post_id: $id, tree_rev: $rev, id: $s, title: $t}')")" "library-save $s" "$p"
refused "$(tool lit-library-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --argjson lib "${PROBE_ENTRIES[$i]}" \
'{post_id: $id, tree_rev: $rev, library_id: $lib, mode: "copy"}')")" "library-insert copy $s" "$p"
refused "$(ability lit/divi-node-copy "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --argjson src "$PROBE_ID" --arg s "$s" \
--arg srev "$PROBE_REV" '{post_id: $id, tree_rev: $rev, source_post_id: $src, source_id: $s, source_tree_rev: $srev}')")" "node-copy $s" "$p"
i=$((i + 1))
done
[ "$(content_hash "$TARGET_ID")" = "$TARGET_HASH" ] || fail "a refused probe wrote to the target page"
[ "$(wp post list --post_type=et_pb_layout --post_status=any --s="$PREFIX Probe Redakteur" --format=count)" = "0" ] \
|| fail "a refused library-save created an entry"
if content_of "$TARGET_ID" | grep -q 'data-probe'; then fail "a probe marker reached the target page"; fi
pass "editor: interactions, Lottie source and custom HTML refused by library-save, library-insert copy and node-copy"
# 11. Group "Divi-Bibliothek" disabled: library IDs not_found in the Divi read tools, library tools gone
connect "$ADMIN_LOGIN"
[ "$(error_code "$(outline "$LIB_ID")")" = "" ] || fail "library entry not readable before disabling the group"
wp option update lit_mcp_exposure '{"@group:divi-library":"disabled"}' --format=json >/dev/null
connect "$ADMIN_LOGIN"
LIST=$(rpc tools/list '{}')
if echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-library-insert")' >/dev/null; then fail "lit-library-insert listed with the group disabled"; fi
for a in lit/library-list lit/library-save lit/library-insert; do
GONE=$(ability "$a" '{}')
echo "$GONE" | jq -e 'has("__error")' >/dev/null && echo "$GONE" | grep -q 'not found' || fail "$a still executable: $GONE"
done
for id in "$LIB_ID" "$GLOB_ID"; do
[ "$(error_code "$(outline "$id")")" = "not_found" ] || fail "outline of library entry $id with the group disabled"
[ "$(error_code "$(tool lit-divi-node-get "{\"post_id\":$id,\"id\":\"s0\"}")")" = "not_found" ] || fail "node-get of $id with the group disabled"
done
[ "$(error_code "$(outline "$TARGET_ID")")" = "" ] || fail "pages must stay readable with the library group disabled"
restore_exposure
connect "$ADMIN_LOGIN"
rpc tools/list '{}' | jq -e '.result.tools[] | select(.name == "lit-library-insert")' >/dev/null || fail "exposure not restored"
pass "group Divi-Bibliothek disabled: library IDs not_found in outline/node-get, library tools gone; option restored"
finish
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
if (! class_exists('ET_Core_PageResource')) {
/**
* Divi's static CSS cache for the saver tests: records every remove_static_resources() call.
*/
class ET_Core_PageResource
{
/** @var list<array{0: int|string, 1: string, 2: bool}> */
public static array $removed = [];
public static function remove_static_resources($postId, $owner, $force = false): void
{
self::$removed[] = [$postId, $owner, (bool) $force];
}
}
}

Some files were not shown because too many files have changed in this diff Show More