Merge divi-absicherung: Divi-Grenze, Versionsfreigabe und Admin-Prüfung (0.6.0)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 06:03:39 +02:00
co-authored by Claude Opus 5.5
120 changed files with 14061 additions and 397 deletions
+8
View File
@@ -62,3 +62,11 @@ Für den Einstieg liefert `lit-guide` mit dem Thema „all“ alle Themen in ein
- Fehler: JSON mit `code` (z. B. `validation_failed`, `rev_mismatch`, `write_failed`, `partial_write`),
`message`, `hint` (und ggf. `fields`). Dem `hint` folgen.
- `confirm_required`: Nutzer fragen, dann mit `confirm: true` wiederholen.
## Divi-Version nicht freigegeben
Antwortet ein schreibendes Divi-Werkzeug mit `divi_untested`, ist die installierte Divi-Version für LIT MCP noch nicht
geprüft; bei `divi_incompatible` hat Divi Stellen geändert, die LIT MCP nutzt. In beiden Fällen wurde nichts
geändert. Lesende Divi-Werkzeuge funktionieren weiter und melden den Zustand in `warnings`; `lit-site-status` zeigt
ihn unter `components` → `divi` → `release`. Den Nutzer bitten, unter Einstellungen → LIT MCP → Divi die Prüfung
auszuführen und die Version freizugeben (`divi_untested`) bzw. LIT MCP zu aktualisieren (`divi_incompatible`).
@@ -0,0 +1,15 @@
# Divi-Absicherung – manuelle Abnahme
Stand 2026-09-30, Branch `divi-absicherung`, Version 0.6.0.
1. Einstellungen → LIT MCP → Divi: Zustand „freigegeben – im Plugin als getestet hinterlegt“, Divi 5.2.1.
2. „Prüfung starten“: nach einigen Sekunden Hinweis „Divi-Prüfung abgeschlossen“, fünf ✓; unter Seiten keine
„[LIT MCP Prüfung]“-Seite, auch nicht im Papierkorb.
3. Ungetestete Version simulieren (nur lokal, nicht committen): in `src/Integrations/Divi/Release/DiviReleases.php`
`TESTED = []` setzen. Reiter zeigt „ungetestet“; ein MCP-Client bekommt beim Schreiben `divi_untested` mit dem
Hinweistext, beim Lesen den Hinweis in `warnings`.
4. „Prüfung starten“, dann „Divi 5.2.1 freigeben“: Zustand „freigegeben – auf dieser Website freigegeben am … von …“;
`lit-site-status` zeigt `release.source = database`; Protokoll zeigt den Eintrag `lit/divi-release`.
5. „Freigabe zurückziehen“: wieder „ungetestet“, Schreiben gesperrt, Protokoll-Eintrag. Danach `TESTED` zurücksetzen
(`git diff` leer).
6. Optional: eine vom Plugin geschriebene Seite im Visual Builder öffnen und speichern.
@@ -0,0 +1,40 @@
# Divi-Absicherung – Security-Audit
Stand 2026-09-30, Branch `divi-absicherung`, Version 0.6.0. Durchgeführt als abschließender Gesamt-Review des Branches
(903246d..35ea47d) mit Sicherheitsschwerpunkt; Korrekturen in 43c0a78, nachgeprüft.
## Geprüft
- **Schreiben trotz nicht freigegebener Divi-Version:** Kein Weg gefunden. Zentral lehnt `DiviToolGate` jedes nicht
lesende Tool der Gruppen Divi, Divi-Bibliothek und Divi Theme Builder vor `execute` ab (ohne `readonly`-Annotation gilt
ein Tool als schreibend). `lit-content-create` mit `divi` lehnt vor dem Anlegen des Entwurfs ab. In den Adaptern ruft
jede schreibende Methode zuerst `ReleaseGate::assertWritable()` auf: `DiviSaver::save/saveNew`,
`DiviLibraryStore::create`, alle Schreibmethoden von `DiviThemeBuilder` inkl. `liveId(true)`, `DiviPages::setFlags`
(die letzten beiden nachgezogen in 43c0a78). Ein in `WriteFailed` verpacktes `DiviLocked` wird als Sperre gemeldet.
- **Fail-closed:** Ausnahme im Resolver → `incompatible` (geloggt); fehlende oder unlesbare `fingerprints.json` bzw.
Divi-Dateien → rote Prüfung, nie eine Ausnahme nach außen; Divi nicht aktiv → Version leer → `untested`.
- **Prüfmodus:** Ausnahme gilt nur für die ID der Wegwerfseite, nur während `whileProbing()`, wird im `finally`
zurückgesetzt, ist nicht von außen setzbar; Aufrufe ohne Post-ID (Bibliothek, Theme Builder) sind nie ausgenommen.
- **Freigabe:** nur `manage_options` + Nonce (`check_admin_referer`), nur aus frischem grünem Bericht, serverseitig gegen
Divi-Version, Plugin-Version und Zustandsschlüssel geprüft; Zurückziehen nutzt die installierte Version, keine Eingabe.
- **REST-Route der Prüfung:** Permission `manage_options`, Cookie-Anmeldung mit `wp_rest`-Nonce; atomare Sperre per
`INSERT IGNORE` bzw. bedingtem `UPDATE` (vorbereitete Statements, Tabellenname aus `$wpdb`), Freigabe im `finally`;
Fehler im Prüflauf → Hinweis statt 500-Antwort.
- **Ausgabe im Reiter:** durchgehend escaped (`esc_html`, `esc_attr`, `esc_url`).
- **Wegwerfseite:** immer endgültig gelöscht; verwaiste Seiten nur als Entwurf mit Marker und passendem Titel, älter als
10 Minuten.
- **Deinstallation:** entfernt `lit_mcp_divi_releases`, `lit_mcp_divi_check_report`, `lit_mcp_divi_check_lock` und den
Transient `lit_mcp_divi_state` auf jeder Website.
- **WP-CLI:** `--update-reference` schreibt nur in einer Entwicklungskopie (mit `tests/`), Referenzquellen zuerst,
`fingerprints.json` zuletzt; ein Schreibfehler lässt die Sperre unverändert.
## Bewusst nicht geändert
- Administratoren mit Anwendungspasswort können die Prüf-Route ohne Nonce aufrufen – keine Rechteausweitung, Freigeben
bleibt hinter admin-post und Nonce.
- `lit-content-create`-Ablehnungen stehen nicht als „abgelehnt“ im Protokoll (Fehler geht an den Client).
- `lit-site-status` liest die Fingerabdrücke ohne Zwischenspeicher (selten aufgerufen).
## Ergebnis
Keine kritischen oder wichtigen offenen Befunde.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,210 @@
# Divi-Absicherung – Design-Spezifikation
- **Datum:** 2026-09-29
- **Status:** Entwurf zur Freigabe
- **Autor:** Jan-Niclas Loosen (Loosen-IT), erarbeitet mit Claude
- **Einordnung:** eigene Etappe vor Etappe 4 (Designsystem schreiben); Etappe 4 baut darauf auf.
## 1. Ziel und Kontext
### 1.1 Problem
Die Divi-Integration hängt an Divi-Interna, nicht an einer zugesicherten Schnittstelle (Divi 5 bietet zum Schreiben keine an). Stand 0.5.0:
- 19 Dateien rufen Divi direkt auf (`et_*`-Funktionen, `ET\…`-Klassen, `_et_*`-Meta, Option `et_divi`), auch außerhalb von `Integrations/Divi/` (`MediaUsageFinder`, `PostPresenter`, `ContentSchema`).
- Einige Stellen **bilden Divi-Speicherlogik nach** (Theme-Builder-Templateliste/Bedingungen/`_et_enabled` nach `et_theme_builder_api_save()`, Bibliothekseinträge nach `et_pb_submit_layout()`, Builder-Kennzeichen nach `SyncToServerController`). Ändert Divi diese Logik, schreibt das Plugin still falsche Daten.
- Welche Divi-Teile genutzt werden, steht an drei Orten (`DiviCompatibility`, `DiviThemeBuilder::FUNCTIONS`, Docblocks mit Zeilennummern aus Divi 5.2.1). `DiviCompatibility` prüft nur Existenz, nicht Verhalten.
- Eine Divi-Version wird nicht geprüft.
### 1.2 Ziele (Vorgabe Auftraggeber)
1. **Weniger Aufwand pro Divi-Update.**
2. **Ungetestete Divi-Version → nichts schreiben, klare Antwort.** Lesende Divi-Tools laufen weiter mit Hinweis „Achtung, ungetestet“; schreibende Divi-Tools sind gesperrt und sagen warum.
3. **Freigabe ohne Plugin-Update möglich:** getestete Versionen stehen fest im Code; zusätzlich kann ein Administrator auf einer Website im WP-Admin eine Prüfung laufen lassen und die Version danach freigeben (Eintrag in der Datenbank). Freigeben geht nur nach bestandener Prüfung.
4. **Wartbarkeit:** Divi-Zugriffe an einer Stelle bündeln (bestehender Code wird umgebaut, nicht nur neuer Code).
### 1.3 Erfolgskriterien
1. Außerhalb der fünf Divi-Adapter (§3) gibt es keinen direkten Divi-Zugriff; ein Unit-Test erzwingt das.
2. Jede genutzte Divi-Funktion/-Klasse/-Methode, jeder Post-Typ, jede Taxonomie, jeder Meta-Schlüssel und jede Option steht im Manifest ihres Adapters; nachgebaute Divi-Funktionen haben einen Fingerabdruck.
3. Mit einer nicht freigegebenen Divi-Version schreibt kein Divi-Tool; die Antwort trägt `divi_untested` bzw. `divi_incompatible` und einen deutschen Hinweis. Lesende Divi-Tools liefern ihr Ergebnis mit Hinweis in `warnings`.
4. Die Admin-Prüfung läuft auf einer Live-Website ohne sichtbare Spuren: die Wegwerfseite ist danach in jedem Fall gelöscht.
5. Alle bestehenden Unit-Tests und Smoke-Skripte sind nach dem Umbau unverändert grün (Umbau ohne Verhaltensänderung).
6. Eine neue Divi-Version lokal prüfen ist ein Befehl (`wp lit-mcp divi-check`); bei Rot zeigt `--diff` die geänderten Divi-Funktionen.
### 1.4 Nicht Teil dieser Etappe
- Sperren für Pods (nutzt nur die öffentliche Pods-API) oder den MCP-Adapter.
- Automatisierter Browsertest des Visual Builders.
- Schreiben ins Designsystem (Etappe 4 – dort gleich mit Manifest).
## 2. Begriffe
- **Adapter:** Klasse, die als einzige Divi direkt anspricht (§3).
- **Manifest:** Beschreibung der Divi-Abhängigkeiten eines Adapters (§4).
- **Nachbau (Mirror):** Plugin-Code, der Speicherlogik einer Divi-Funktion wiederholt, statt sie aufzurufen.
- **Fingerabdruck:** SHA-256 des normalisierten Quelltexts einer Divi-Funktion/-Methode (§4.2).
- **Live-Prüfung:** Abgleich aller Manifeste mit dem installierten Divi (Existenz + Fingerabdrücke), zur Laufzeit, zwischengespeichert (§5.4).
- **Admin-Prüfung:** vollständige Prüfung aus dem WP-Admin inkl. Schreibtest (§6).
## 3. Divi-Grenze
Nur fünf Adapter greifen auf Divi zu. Vorhandene Klassen werden wo möglich zu Adaptern bzw. in sie hineingezogen, damit der Umbau klein bleibt.
| Adapter | Übernimmt (Stand 0.5.0) |
|---|---|
| **Seiten** | `DiviSaver` (Divi-Teil), `DiviFlags`, CSS-Cache `ET_Core_PageResource`, `WpLinkTargets` (Divi-Teil) |
| **Bibliothek** | `DiviLibraryStore`, `LibraryItemSpec`, `NewLibraryEntry`, `DiviRoles`, `LibraryAccess` (Divi-Teil), `GlobalLayout` |
| **Theme Builder** | `DiviThemeBuilder`, `Template`, `TemplateConditions` (Divi-Teile) |
| **Designsystem** | `DesignSystemReader`, `DesignSystem` (Divi-Teil), Option `et_divi` inkl. `divi_logo` für `MediaUsageFinder` |
| **Katalog** | `DiviModules`, `DynamicContentMap`, Divi-Post-Typen/Flags für `PostPresenter` und `ContentSchema` |
Die genaue Klassenaufteilung legt der Plan fest; Regel: jede Divi-Berührung liegt in genau einem Adapter, der Rest des Plugins nutzt nur dessen Methoden.
Parser, Serializer und `Compose/` bleiben, wo sie sind: sie kennen das Divi-**Format** (Blockmarkup, Attributpfade), rufen aber keine Divi-**Funktionen** auf. Ihr Schutz ist die Katalog- und Formatprüfung (§6.2 Schritte 2–3).
**Architekturtest (Unit):** durchsucht `src/`; außerhalb der Adapter-Dateien sind `et_*(`-Aufrufe, `ET\…`- und `ET_…`-Klassen sowie `'_et_…'`/`'et_divi'`-Schlüssel ein Fehler. Innerhalb der Adapter ist jede solche Fundstelle ein Fehler, die nicht im Manifest des Adapters steht. Während des Umbaus gibt es eine Ausnahmeliste für Altbestand; am Ende der Etappe ist sie leer und wird entfernt.
## 4. Manifest
### 4.1 Inhalt
Jeder Adapter liefert ein Manifest (festes PHP-Wertobjekt, keine Konfigurationsdatei) mit:
- `functions`: Divi-Funktionen, die aufgerufen werden;
- `methods`: Klasse + Methode (+ erwartete Mindest-Parameterzahl), z. B. `ET\Builder\Packages\GlobalData\GlobalData::get_global_colors`;
- `classes`: Klassen, deren Existenz genügt;
- `post_types`, `taxonomies`, `block_types`;
- `meta_keys`, `options`: Divi-Schlüssel, die gelesen oder geschrieben werden (dokumentarisch; Architekturtest nutzt sie);
- `mirrors`: nachgebaute Divi-Funktionen/-Methoden mit Fingerabdruck und Kurzbeschreibung, was nachgebaut wird;
- `sources`: Quellenangaben (Divi-Datei, Funktion) – ersetzt die Zeilennummern in Docblocks. Zeilennummern entfallen.
### 4.2 Fingerabdruck
`sha256` über den Quelltext der Funktion bzw. Methode (per `ReflectionFunction`/`ReflectionMethod`: Datei, Start- und Endzeile), normalisiert mit `token_get_all()`: `T_COMMENT`, `T_DOC_COMMENT`, `T_WHITESPACE` entfernt, übrige Tokens aneinandergehängt. Kommentar- und Formatänderungen ändern den Fingerabdruck nicht; Codeänderungen schon.
Die Soll-Fingerabdrücke werden mit dem Plugin ausgeliefert (erzeugte Datei im Plugin, z. B. JSON neben den Manifesten). Die normalisierten Soll-Quelltexte liegen nur für die Entwicklung unter `tests/Fixtures/divi/reference/` (per `.gitattributes` nicht im Plugin-Paket) und dienen `--diff` (§7).
### 4.3 `DiviCompatibility`
Verliert ihre handgepflegte Liste und prüft alle Manifeste: Existenz von Funktionen/Klassen/Methoden (inkl. Parameterzahl), Post-Typen, Taxonomien, Block-Typen; Fingerabdrücke der Mirrors. Ergebnis: Liste roter Punkte mit Adapter, Divi-Symbol, Fundort in der installierten Version. Die bisherigen Sonderprüfungen (z. B. `divi/global-layout` registriert oder in den Core-Metadaten) werden Manifest-Einträge mit eigener Prüfregel.
## 5. Freigabe und Sperre zur Laufzeit
### 5.1 Divi-Version
Aus dem aktiven Theme wie bisher (`EnvironmentProbe`: Template `Divi`, bei Child-Theme Version des Eltern-Themes).
### 5.2 Zustände
| Zustand | Bedingung | Schreibende Divi-Tools | Lesende Divi-Tools |
|---|---|---|---|
| `released` | Version freigegeben (§5.3) **und** Live-Prüfung grün | laufen | laufen |
| `untested` | Live-Prüfung grün, Version nicht freigegeben | gesperrt, `divi_untested` | laufen, Hinweis |
| `incompatible` | Live-Prüfung rot | gesperrt, `divi_incompatible` | laufen, Hinweis |
Die Live-Prüfung zählt immer, auch für Versionen aus der Code-Liste (erkennt z. B. Hotfixes mit gleicher Versionsnummer). Ist Divi nicht aktiv, gibt es wie bisher keine Divi-Tools; der Zustand ist dann ohne Bedeutung.
### 5.3 Quellen der Freigabe
Eine Divi-Version ist freigegeben, wenn sie
1. **im Code** steht: feste Liste getesteter Versionen (PHP-Konstante), vom Entwickler gepflegt; **oder**
2. **in der Datenbank der Website** steht: Option `lit_mcp_divi_releases`, Liste von Einträgen `{divi_version, plugin_version, user_id, released_at, report_id}`. Ein Eintrag entsteht nur über die Admin-Prüfung nach grünem Bericht (§6.3) und gilt **nur für die Plugin-Version, mit der geprüft wurde**. Nach einem Plugin-Update muss eine nur per Datenbank freigegebene Divi-Version neu geprüft werden, außer sie steht inzwischen in der Code-Liste. Ein Administrator kann Einträge zurückziehen.
Multisite: pro Website (Optionen der jeweiligen Website).
### 5.4 Live-Prüfung und Zwischenspeicher
Die Live-Prüfung (Manifeste, §4.3) wird im Transient `lit_mcp_divi_state` zwischengespeichert, Schlüssel aus Divi-Version, Plugin-Version und `filemtime` der Dateien aller Mirrors und Methoden. Weicht der Schlüssel ab oder lief ein Theme-Update (`upgrader_process_complete` für das Divi-Theme), wird neu berechnet. Kosten ohne Cache: Reflection + Tokenisieren von rund zehn Funktionen.
### 5.5 Sperre
**Zentral (`AbilityRegistrar`):** Für jedes Tool der Divi-Integration wird vor `execute` der Zustand ermittelt.
- Schreibendes Tool (Annotation nicht `readonly`) und Zustand ≠ `released`: sofort `WP_Error` mit Code `divi_untested` bzw. `divi_incompatible`, keine Ausführung, Protokolleintrag als abgelehnter Aufruf.
- Lesendes Tool und Zustand ≠ `released`: normale Ausführung, Hinweis in `warnings` des Ergebnisses.
Welche Tools „Divi-Tools“ sind, ergibt sich aus der Integrations-Zugehörigkeit (`IntegrationRegistry::groupMap()`); Divi-lastige Tools anderer Integrationen deklarieren sich zusätzlich (Ability-Eigenschaft „nutzt Divi“), ein Unit-Test prüft die Zuordnung.
**In den Adaptern:** Jede schreibende Adapter-Methode prüft den Zustand selbst und wirft bei ≠ `released` eine eigene Ausnahme, die auf dieselben Fehlercodes abgebildet wird; bestehendes Rollback greift. Das deckt Wege ab, die Divi-Interna indirekt nutzen (z. B. Content-Update einer Divi-Seite über `DiviSaver`). Generisches Kopieren/Wiederherstellen von Posts (`PostCloner`, Revisionen) berührt keine Divi-Interna und bleibt ungesperrt.
**Prüfmodus:** Der Schreibtest der Admin-Prüfung (§6.2 Schritt 4) muss trotz `untested` schreiben. Er setzt dafür eine request-lokale Ausnahme, die ausschließlich für die ID der Wegwerfseite gilt; sie ist nicht von außen setzbar (kein Parameter, keine Option).
### 5.6 Hinweistexte
- `divi_untested`: „Achtung: Divi {version} ist auf dieser Website noch nicht für LIT MCP freigegeben. Schreibende Divi-Werkzeuge sind gesperrt, bis ein Administrator unter Einstellungen → LIT MCP → Divi die Prüfung ausführt und die Version freigibt. Es wurde nichts geändert.“
- `divi_incompatible`: „Divi {version} hat Stellen geändert, die LIT MCP nutzt. Schreibende Divi-Werkzeuge sind gesperrt, bis LIT MCP aktualisiert wurde. Es wurde nichts geändert.“
- Lesehinweis (`warnings`): „Achtung: Divi {version} ist für LIT MCP ungetestet (bzw. inkompatibel); Lesen funktioniert, Schreiben ist gesperrt.“
### 5.7 `lit-site-status`
Zeigt für Divi zusätzlich: Zustand, Quelle der Freigabe (`code`/`database` mit Datum und Benutzer), rote Punkte der Live-Prüfung.
## 6. Admin-Prüfung
### 6.1 Oberfläche und Rechte
Neuer Reiter **„Divi“** unter Einstellungen → LIT MCP: Zustand, Divi-Version, Quelle der Freigabe, letzter Prüfbericht, Knöpfe „Prüfung starten“, „Divi {version} freigeben“, „Freigabe zurückziehen“. Alle Aktionen: `manage_options`, Nonce, `admin-post`-Aktionen wie die übrigen Reiter (`AdminPostAction`). Eine Sperre (Transient) verhindert zwei gleichzeitige Prüfungen.
### 6.2 Prüfschritte
1. **Manifeste:** Live-Prüfung (§4.3), ohne Cache.
2. **Modulkatalog:** Alle Attributpfade aus `ShortformMap` existieren im Live-Katalog von Divi. Die Logik von `tests/Integration/divi-shortform-check.php` wandert dafür ins Plugin; das Skript nutzt danach die Plugin-Klasse.
3. **Format im Speicher:** Ein mit dem Plugin ausgelieferter Referenzinhalt (Divi-5-Blockmarkup, deckt die Alltagsmodule ab) wird mit dem Plugin-Parser gelesen und serialisiert und muss byte-identisch herauskommen; `parse_blocks()` muss dieselbe Blockstruktur (Namen, Verschachtelung) liefern.
4. **Schreibtest auf Wegwerfseite:** privater Entwurf „[LIT MCP Prüfung] {Datum}“ (Post-Typ `page`); über den normalen Schreibweg (Composer → Serializer → Seiten-Adapter) je ein Exemplar der Alltagsmodule schreiben; prüfen: Inhalt gespeichert wie serialisiert, Builder-Kennzeichen gesetzt, Rendern über Divi ohne PHP-Fehler/Warnung und mit den erwarteten Texten in der Ausgabe. Kein Eintrag im Änderungsprotokoll. Im `finally` immer `wp_delete_post($id, true)` (endgültig, mit Revisionen und Meta); schlägt das fehl, nennt der Bericht die ID zum Löschen von Hand und der Bericht ist rot.
Jeder Schritt meldet grün/rot mit Einzelpunkten. Ein Fehler in einem Schritt bricht die übrigen nicht ab (außer Schritt 4 bei rotem Schritt 1: dann kein Schreibtest).
### 6.3 Bericht und Freigabe
Der Bericht wird in der Option `lit_mcp_divi_check_report` gespeichert (letzter Bericht; ID, Zeit, Benutzer, Divi-Version, Plugin-Version, Cache-Schlüssel aus §5.4, Ergebnisse). „Freigeben“ ist nur aktiv – und wird serverseitig nur ausgeführt –, wenn der letzte Bericht vollständig grün ist und Divi-Version, Plugin-Version und Cache-Schlüssel zum aktuellen Stand passen. Freigabe und Zurückziehen werden im Änderungsprotokoll vermerkt.
Hinweis im Bericht (blockiert nichts): „Optional: eine Divi-Seite im Visual Builder öffnen und speichern.“
## 7. WP-CLI für die Entwicklung
`wp lit-mcp divi-check` (nur registriert, wenn `WP_CLI` definiert ist) führt die Prüfschritte aus §6.2 aus und gibt den Bericht aus; Rückgabecode ≠ 0 bei Rot. Freigaben schreibt der Befehl nicht.
- `--diff`: zeigt für jeden roten Mirror den Unterschied zwischen Soll-Quelltext (`tests/Fixtures/divi/reference/`) und installiertem Quelltext, mit Datei und Zeilen in der installierten Version.
- `--update-reference`: schreibt Soll-Fingerabdrücke und Referenz-Quelltexte neu – nur, wenn alle übrigen Prüfschritte grün sind. Gedacht nach dem Anpassen eines Nachbaus.
Ablauf bei neuer Divi-Version: lokal einspielen → `wp lit-mcp divi-check` → bei Grün Version in die Code-Liste eintragen; bei Rot `--diff`, Nachbau anpassen, `--update-reference`, Smoke-Skripte, Version eintragen → Plugin-Release.
## 8. Daten und Deinstallation
Neu: Optionen `lit_mcp_divi_releases`, `lit_mcp_divi_check_report`; Transients `lit_mcp_divi_state`, Prüfsperre. `Uninstaller` und `uninstall.php`-Doku werden ergänzt.
## 9. Fehlerbehandlung
- Reflection/Tokenisieren scheitert (Funktion fehlt, Datei nicht lesbar): Punkt rot → `incompatible`, nie Ausnahme nach außen.
- Zustand nicht ermittelbar (unerwartete Ausnahme): wie `incompatible` behandeln (Schreiben gesperrt), Protokoll-Fehlereintrag.
- Admin-Prüfung bricht ab (Timeout, Fatal im Rendern): Wegwerfseite im `finally` gelöscht; bei Fatal zusätzlich beim nächsten Aufruf des Reiters nach verwaisten „[LIT MCP Prüfung]“-Entwürfen des Plugins suchen (Marker-Meta) und löschen.
## 10. Tests
**Unit:**
- Zustandsberechnung: alle Kombinationen aus Code-Liste, DB-Eintrag (passende/andere Plugin-Version), Live-Prüfung grün/rot.
- Sperre in `AbilityRegistrar`: lesend/schreibend × drei Zustände; Fehlercodes und Texte; `warnings` bei lesenden Tools.
- Sperre in Adaptern inkl. Prüfmodus nur für die Wegwerfseiten-ID.
- Fingerabdruck: Kommentar/Leerraum ändert nichts, Codeänderung schon.
- Architekturtest (§3) und Test „jedes Divi-Tool ist als solches erkannt“.
- Freigabe nur bei frischem grünem Bericht (Version, Plugin-Version, Cache-Schlüssel); Rechte und Nonce.
- `Uninstaller` entfernt die neuen Optionen/Transients.
**Integration gegen echtes Divi (Smoke-Skript):**
- Admin-Prüfung komplett grün; Wegwerfseite danach nicht mehr vorhanden (auch keine Revisionen).
- Künstlich veränderter Soll-Fingerabdruck → `incompatible`, schreibendes Tool liefert `divi_incompatible`, lesendes Tool liefert Ergebnis + Hinweis.
- Version aus Code-Liste entfernt → `untested`; nach Admin-Prüfung + Freigabe → `released`.
- `wp lit-mcp divi-check` grün; `--diff` bei verändertem Soll zeigt den Unterschied.
**Regression:** alle bestehenden Unit-Tests und Smoke-Skripte unverändert grün.
## 11. Umsetzungsreihenfolge
1. Manifest-Format, Fingerabdruck, Architekturtest mit Ausnahmeliste für Altbestand.
2. Adapter nacheinander umbauen, bis die Ausnahmeliste leer ist; `DiviCompatibility` auf Manifeste umstellen; Docblock-Zeilennummern entfernen.
3. Zustand, Freigabe (Code-Liste, Datenbank), zentrale und Adapter-Sperre, `lit-site-status`.
4. Admin-Reiter „Divi“, Prüfschritte, Wegwerfseite, Bericht, Freigabe.
5. WP-CLI-Befehl mit `--diff` und `--update-reference`.
6. Security-Audit; danach Etappe 4.
+12 -1
View File
@@ -4,7 +4,7 @@ Tags: mcp, ai, divi, pods, oauth
Requires at least: 6.9
Tested up to: 7.1
Requires PHP: 8.1
Stable tag: 0.5.0
Stable tag: 0.6.0
License: GPL-2.0-or-later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
@@ -190,8 +190,19 @@ Die Änderung wirkt auf allen Seiten mit diesem Modul. LIT MCP speichert erst na
betroffenen Seiten nennt, und einer ausdrücklichen Bestätigung (confirm); vorher entsteht eine Revision des
Bibliothekseintrags.
== Divi-Freigabe ==
LIT MCP schreibt nur mit Divi-Versionen, die für diese Plugin-Version geprüft sind. Nach einem Divi-Update lesen die Divi-Werkzeuge weiter; schreibende Divi-Werkzeuge antworten mit „divi_untested“, bis ein Administrator unter Einstellungen → LIT MCP → Divi die Prüfung startet und die Version freigibt. Die Prüfung legt eine Entwurfsseite an, schreibt und rendert sie und löscht sie danach endgültig. Hat Divi Stellen geändert, die LIT MCP nachbildet, ist die Version „inkompatibel“ (divi_incompatible) – dann hilft nur ein Update von LIT MCP. Für Entwickler: `wp --user=<admin> lit-mcp divi-check [--diff] [--update-reference]`.
== Changelog ==
= 0.6.0 =
* Divi-Absicherung: Divi-Zugriffe gebündelt in Adaptern mit Manifest; Fingerabdrücke der nachgebildeten Divi-Funktionen.
* Ungetestete oder veränderte Divi-Versionen sperren alle schreibenden Divi-Wege (divi_untested / divi_incompatible); lesende Divi-Werkzeuge melden es in warnings.
* Neuer Reiter „Divi“: Prüfung mit Wegwerfseite, Freigabe nach grüner Prüfung, Freigabe zurückziehen.
* lit-site-status zeigt den Freigabe-Zustand (release).
* WP-CLI: wp lit-mcp divi-check mit --diff und --update-reference.
= 0.5.0 =
* Divi Theme Builder: Vorlagen, Bedingungen und Layouts auflisten (lit/tb-list), Vorlagen mit Kopfzeile,
Inhalt und Fußzeile anlegen – immer deaktiviert (lit/tb-create-template) –, Bedingungen setzen
+37 -11
View File
@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace LIT\MCP\Abilities;
use LIT\MCP\Access;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\DiviToolGate;
use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Log;
use LIT\MCP\Server\RequestContext;
@@ -14,15 +16,18 @@ use WP_Error;
defined('ABSPATH') || exit;
/**
* Registers all abilities of available integrations with the Abilities API and
* wraps them with the role gate and neutral error handling.
* Registers all abilities of available integrations with the Abilities API and wraps them
* with the role gate, the Divi release lock (DiviToolGate) and neutral error handling.
*/
final class AbilityRegistrar
{
public const CATEGORY = 'lit-mcp';
public function __construct(private IntegrationRegistry $registry, private ?ExposurePolicy $policy = null)
{
public function __construct(
private IntegrationRegistry $registry,
private ?ExposurePolicy $policy = null,
private ?DiviToolGate $divi = null
) {
}
public function register(): void
@@ -50,6 +55,7 @@ final class AbilityRegistrar
/** @return array<string, mixed> */
public function argsFor(Ability $ability): array
{
$divi = $this->divi ?? new DiviToolGate($this->registry->groupMap(...));
$args = [
'label' => $ability->label(),
'description' => $ability->description(),
@@ -71,15 +77,18 @@ final class AbilityRegistrar
return Errors::internal();
}
},
'execute_callback' => static function (mixed $input = null) use ($ability): mixed {
'execute_callback' => static function (mixed $input = null) use ($ability, $divi): mixed {
$refusal = $divi->refusal($ability);
if ($refusal !== null) {
return $refusal;
}
try {
return $ability->execute(is_array($input) ? $input : []);
} catch (WriteFailed $e) {
Log::failure($ability->name(), $e);
return $e->toError();
return $divi->annotate($ability, $ability->execute(is_array($input) ? $input : []));
} catch (Throwable $e) {
Log::failure($ability->name(), $e);
return Errors::internal();
return self::errorFor($e);
}
},
'meta' => [
@@ -95,11 +104,28 @@ final class AbilityRegistrar
],
];
$output = $ability->outputSchema();
$output = $divi->outputSchema($ability, $ability->outputSchema());
if ($output !== null) {
$args['output_schema'] = $output;
}
return $args;
}
/**
* A partial write stays partial_write; otherwise a Divi release lock anywhere in the cause chain is its refusal
* (spec §5.5); a rolled-back write is write_failed; anything else internal_error.
*/
private static function errorFor(Throwable $e): WP_Error
{
if ($e instanceof WriteFailed && ! $e->rolledBack) {
return $e->toError();
}
$locked = DiviLocked::in($e);
if ($locked !== null) {
return $locked->toError();
}
return $e instanceof WriteFailed ? $e->toError() : Errors::internal();
}
}
+309
View File
@@ -0,0 +1,309 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Admin;
use Closure;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\Release\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CheckReportStore;
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
use LIT\MCP\Integrations\Divi\Release\DiviEnvironment;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Integrations\Divi\Release\ReleaseStore;
use LIT\MCP\Integrations\Divi\Release\WriteProbe;
use LIT\MCP\Plugin;
defined('ABSPATH') || exit;
/**
* Tab "Divi" (spec §6): release status of the installed Divi, the latest check report, "Prüfung starten" (REST,
* DiviCheckRoute), "Divi <Version> freigeben" (only after a fresh green report) and "Freigabe zurückziehen" (only
* for a database release). Opening the tab removes check pages a dead check left behind.
*/
final class DiviTab implements AdminTab
{
public const SLUG = 'divi';
public const RELEASE_ACTION = 'lit_mcp_divi_release';
public const WITHDRAW_ACTION = 'lit_mcp_divi_withdraw';
/**
* @param (Closure(): string)|null $version Installed Divi version ('' = none); null = DiviEnvironment.
* @param (Closure(string): string)|null $stateKey null = ReleaseResolver::stateKey().
*/
public function __construct(
private ?CheckReportStore $reports = null,
private ?ReleaseStore $releases = null,
private ?ChangeLog $log = null,
private ?AdminPostAction $action = null,
private ?Closure $version = null,
private ?Closure $stateKey = null
) {
}
public function slug(): string
{
return self::SLUG;
}
public function label(): string
{
return __('Divi', 'wp-lit-mcp');
}
public function registerSettings(): void
{
}
public function registerActions(): void
{
add_action('admin_post_' . self::RELEASE_ACTION, [$this, 'release']);
add_action('admin_post_' . self::WITHDRAW_ACTION, [$this, 'withdraw']);
}
public function release(): void
{
$action = $this->action ?? new AdminPostAction();
$action->guard();
check_admin_referer(self::RELEASE_ACTION);
$version = $this->version();
$report = $this->reports()->latest();
$fresh = $report !== null
&& $report->freshFor($version, Plugin::VERSION, $this->stateKey($version));
if ($version === '' || ! $fresh) {
$action->redirect(self::SLUG, 'divi_release_refused');
return;
}
($this->releases ?? new ReleaseStore())->add($version, Plugin::VERSION, get_current_user_id(), $report->id);
ReleaseGate::forget();
$this->log?->record(
'lit/divi-release',
'divi',
$version,
sprintf('Divi %s für LIT MCP %s freigegeben (Prüfbericht %s).', $version, Plugin::VERSION, $report->id)
);
$action->redirect(self::SLUG, 'divi_released');
}
public function withdraw(): void
{
$action = $this->action ?? new AdminPostAction();
$action->guard();
check_admin_referer(self::WITHDRAW_ACTION);
$version = $this->version();
if (! ($this->releases ?? new ReleaseStore())->withdraw($version, Plugin::VERSION)) {
$action->redirect(self::SLUG, 'divi_withdraw_failed');
return;
}
ReleaseGate::forget();
$this->log?->record(
'lit/divi-release',
'divi',
$version,
sprintf('Freigabe von Divi %s zurückgezogen.', $version)
);
$action->redirect(self::SLUG, 'divi_withdrawn');
}
public function render(): void
{
if ($this->version() === '') {
echo '<p>' . esc_html__(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text.
'Divi ist auf dieser Website nicht aktiv; die Divi-Freigabe ist ohne Bedeutung.',
'wp-lit-mcp'
) . '</p>';
return;
}
$removed = WriteProbe::removeOrphans();
$status = ReleaseGate::status();
$report = $this->reports()->latest();
echo '<h2>' . esc_html__('Divi-Freigabe', 'wp-lit-mcp') . '</h2>';
echo '<p>' . esc_html__(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument.
'LIT MCP schreibt nur mit Divi-Versionen, die geprüft sind. Nach einem Divi-Update hier die Prüfung starten und die Version freigeben; bis dahin lesen die Divi-Werkzeuge nur.',
'wp-lit-mcp'
) . '</p>';
if ($removed !== []) {
printf(
'<div class="notice notice-warning inline"><p>%s</p></div>',
esc_html(sprintf(
/* translators: %s: comma separated page IDs */
__('Liegengebliebene Prüfseiten gelöscht: %s', 'wp-lit-mcp'),
implode(', ', $removed)
))
);
}
$this->renderStatus($status);
$this->renderForms($status, $report);
if ($report !== null) {
$this->renderReport($report);
}
}
private function renderStatus(ReleaseStatus $status): void
{
echo '<table class="form-table" role="presentation"><tbody>';
printf(
'<tr><th scope="row">%s</th><td>%s</td></tr>',
esc_html__('Divi-Version', 'wp-lit-mcp'),
esc_html($status->version === '' ? __('nicht erkannt', 'wp-lit-mcp') : $status->version)
);
printf(
'<tr><th scope="row">%s</th><td><strong>%s</strong> – %s</td></tr>',
esc_html__('Zustand', 'wp-lit-mcp'),
esc_html($this->stateLabel($status->state)),
esc_html($this->explanation($status))
);
if ($status->problems !== []) {
echo '<tr><th scope="row">' . esc_html__('Geändert in Divi', 'wp-lit-mcp') . '</th><td><ul>';
foreach ($status->problems as $problem) {
echo '<li>' . esc_html($problem) . '</li>';
}
echo '</ul></td></tr>';
}
echo '</tbody></table>';
}
private function stateLabel(ReleaseState $state): string
{
return match ($state) {
ReleaseState::Released => __('freigegeben', 'wp-lit-mcp'),
ReleaseState::Untested => __('ungetestet', 'wp-lit-mcp'),
ReleaseState::Incompatible => __('inkompatibel', 'wp-lit-mcp'),
};
}
private function explanation(ReleaseStatus $status): string
{
if ($status->state === ReleaseState::Incompatible) {
return __(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument.
'Divi hat Stellen geändert, die LIT MCP nutzt. Schreiben bleibt gesperrt, bis ein Plugin-Update von LIT MCP eingespielt ist.',
'wp-lit-mcp'
);
}
if ($status->state === ReleaseState::Untested) {
return __(
'Diese Version ist nicht geprüft. Die Divi-Werkzeuge lesen nur, bis sie freigegeben ist.',
'wp-lit-mcp'
);
}
if ($status->source === ReleaseStatus::SOURCE_CODE) {
return __('Diese Version ist im Plugin als getestet hinterlegt.', 'wp-lit-mcp');
}
$user = get_userdata((int) ($status->release['user_id'] ?? 0));
$releasedAt = (int) strtotime((string) ($status->release['released_at'] ?? ''));
return sprintf(
/* translators: 1: date, 2: user name */
__('Auf dieser Website freigegeben am %1$s von %2$s.', 'wp-lit-mcp'),
date_i18n((string) get_option('date_format'), $releasedAt),
is_object($user) && isset($user->display_name) ? (string) $user->display_name : '?'
);
}
private function renderForms(ReleaseStatus $status, ?CheckReport $report): void
{
printf('<form method="post" action="%s">', esc_url(DiviCheckRoute::url()));
printf('<input type="hidden" name="_wpnonce" value="%s">', esc_attr(wp_create_nonce('wp_rest')));
submit_button(__('Prüfung starten', 'wp-lit-mcp'), 'secondary', 'lit-mcp-divi-check', false);
echo '</form>';
if ($status->state === ReleaseState::Untested && $status->version !== '') {
$fresh = $report !== null
&& $report->freshFor($status->version, Plugin::VERSION, $this->stateKey($status->version));
if ($fresh) {
$this->actionForm(
self::RELEASE_ACTION,
/* translators: %s: Divi version */
sprintf(__('Divi %s freigeben', 'wp-lit-mcp'), $status->version),
'primary'
);
} else {
echo '<p>' . esc_html__(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument.
'Freigeben geht erst nach einer vollständig grünen Prüfung dieser Divi-Version – Prüfung erneut ausführen.',
'wp-lit-mcp'
) . '</p>';
}
}
if ($status->state === ReleaseState::Released && $status->source === ReleaseStatus::SOURCE_DATABASE) {
$this->actionForm(self::WITHDRAW_ACTION, __('Freigabe zurückziehen', 'wp-lit-mcp'), 'secondary');
}
}
private function actionForm(string $action, string $label, string $type): void
{
printf('<form method="post" action="%s">', esc_url(admin_url('admin-post.php')));
printf('<input type="hidden" name="action" value="%s">', esc_attr($action));
wp_nonce_field($action);
submit_button($label, $type, 'submit', false);
echo '</form>';
}
private function renderReport(CheckReport $report): void
{
printf(
'<h3>%s</h3><p>%s</p><ul class="lit-mcp-divi-report">',
esc_html__('Letzte Prüfung', 'wp-lit-mcp'),
esc_html(sprintf(
/* translators: 1: Divi version, 2: plugin version, 3: date */
__('Divi %1$s mit LIT MCP %2$s, %3$s', 'wp-lit-mcp'),
$report->diviVersion,
$report->pluginVersion,
date_i18n((string) get_option('date_format'), (int) strtotime($report->createdAt))
))
);
foreach ($report->steps as $step) {
$mark = $step->skipped ? '–' : ($step->green() ? '✓' : '✗');
printf('<li>%s %s', esc_html($mark), esc_html($step->label()));
if ($step->skipped) {
echo ' (' . esc_html__('nicht ausgeführt', 'wp-lit-mcp') . ')';
}
if ($step->problems !== []) {
echo '<ul>';
foreach ($step->problems as $problem) {
echo '<li>' . esc_html($problem) . '</li>';
}
echo '</ul>';
}
echo '</li>';
}
echo '</ul><p class="description">' . esc_html__(
'Optional: eine Divi-Seite im Visual Builder öffnen und speichern.',
'wp-lit-mcp'
) . '</p>';
}
private function version(): string
{
return ($this->version ?? static fn (): string => DiviEnvironment::version() ?? '')();
}
private function stateKey(string $version): string
{
return ($this->stateKey ?? static fn (string $v): string => ReleaseResolver::stateKey($v))($version);
}
private function reports(): CheckReportStore
{
return $this->reports ?? new CheckReportStore();
}
}
+3 -2
View File
@@ -126,8 +126,8 @@ final class LogListTable extends \WP_List_Table
}
/**
* Marks rows whose write did not complete (result "partial": failed after an
* irreversible step, spec §2 principle 7/8.2) so admins notice them while scrolling.
* Marks rows whose write did not complete (result "partial": failed after an irreversible step,
* spec §2 principle 7/8.2) or was refused (Divi release lock) so admins notice them while scrolling.
*
* @param array<string, mixed> $item
*/
@@ -150,6 +150,7 @@ final class LogListTable extends \WP_List_Table
return match ($result) {
'ok' => null,
'partial' => ['warning', __('teilweise', 'wp-lit-mcp')],
'refused' => ['warning', __('abgelehnt', 'wp-lit-mcp')],
'failed', 'error' => ['error', __('fehlgeschlagen', 'wp-lit-mcp')],
default => ['error', __('unbekannt', 'wp-lit-mcp')],
};
+6 -2
View File
@@ -22,7 +22,7 @@ final class LogTab implements AdminTab
/** Screen option "Einträge pro Seite" (user option; WordPress needs the "_per_page" end). */
public const PER_PAGE_OPTION = 'lit_mcp_log_per_page';
public const PER_PAGE_DEFAULT = 20;
private const RESULTS = ['ok', 'partial'];
private const RESULTS = ['ok', 'partial', 'refused'];
private AdminPostAction $action;
@@ -233,7 +233,11 @@ final class LogTab implements AdminTab
self::filterLabel('result', __('Nach Ergebnis filtern', 'wp-lit-mcp'));
echo '<select name="result" id="lit-mcp-filter-result">';
printf('<option value="">%s</option>', esc_html__('Alle Ergebnisse', 'wp-lit-mcp'));
$labels = ['ok' => __('erfolgreich', 'wp-lit-mcp'), 'partial' => __('teilweise', 'wp-lit-mcp')];
$labels = [
'ok' => __('erfolgreich', 'wp-lit-mcp'),
'partial' => __('teilweise', 'wp-lit-mcp'),
'refused' => __('abgelehnt', 'wp-lit-mcp'),
];
foreach (self::RESULTS as $result) {
printf(
'<option value="%s"%s>%s</option>',
+26
View File
@@ -160,6 +160,32 @@ final class SettingsPage
'revoked' => ['success', __('Verbindung widerrufen.', 'wp-lit-mcp')],
'revoke_failed' => ['error', __('Die Verbindung konnte nicht widerrufen werden.', 'wp-lit-mcp')],
'cleared' => ['success', __('Protokoll geleert.', 'wp-lit-mcp')],
'divi_checked' => ['success', __('Divi-Prüfung abgeschlossen; das Ergebnis steht unten.', 'wp-lit-mcp')],
'divi_check_running' => [
'warning',
__('Es läuft bereits eine Divi-Prüfung; bitte in ein paar Minuten erneut versuchen.', 'wp-lit-mcp'),
],
'divi_check_failed' => [
'error',
__('Die Divi-Prüfung ist abgebrochen; Details stehen im PHP-Error-Log.', 'wp-lit-mcp'),
],
'divi_released' => ['success', __('Die Divi-Version ist für LIT MCP freigegeben.', 'wp-lit-mcp')],
'divi_release_refused' => [
'error',
__(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument.
'Freigabe nicht möglich: Der letzte Prüfbericht ist nicht grün oder passt nicht mehr zur installierten Divi- oder Plugin-Version. Bitte die Prüfung erneut ausführen.',
'wp-lit-mcp'
),
],
'divi_withdrawn' => [
'success',
__('Freigabe zurückgezogen; schreibende Divi-Werkzeuge sind wieder gesperrt.', 'wp-lit-mcp'),
],
'divi_withdraw_failed' => [
'error',
__('Für die installierte Divi-Version gibt es auf dieser Website keine Freigabe.', 'wp-lit-mcp'),
],
default => null,
};
+206
View File
@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Cli;
use Closure;
use LIT\MCP\Integrations\Divi\Release\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CheckReportStore;
use LIT\MCP\Integrations\Divi\Release\CheckStep;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\DiviEnvironment;
use LIT\MCP\Integrations\Divi\Release\DiviReference;
use LIT\MCP\Integrations\Divi\Release\LineDiff;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
use LIT\MCP\Integrations\Divi\Release\SourceFingerprint;
use RuntimeException;
defined('ABSPATH') || exit;
/**
* wp lit-mcp divi-check (spec §7): the admin check from the command line, for a developer checking a new Divi
* version. Never releases anything – a green version goes into DiviReleases::TESTED by hand.
*/
final class DiviCheckCommand
{
/** @var Closure(string): void */
private Closure $out;
/**
* @param Closure(): CompatibilityCheck $check
* @param (Closure(string): void)|null $out null = WP_CLI::line().
* @param (Closure(): ?string)|null $themeDir null = DiviEnvironment::themeDir().
* @param string|null $expectedFile null = DiviReference::FILE.
* @param (Closure(): ?string)|null $sourceDir null = DiviReference::sourceDir().
*/
public function __construct(
private Closure $check,
private ?CheckReportStore $reports = null,
?Closure $out = null,
private ?Closure $themeDir = null,
private ?string $expectedFile = null,
private ?Closure $sourceDir = null
) {
$this->out = $out ?? static function (string $line): void {
\WP_CLI::line($line);
};
}
/** @param Closure(): CompatibilityCheck $check */
public static function register(Closure $check): void
{
if (defined('WP_CLI') && WP_CLI) {
\WP_CLI::add_command('lit-mcp divi-check', new self($check));
}
}
/**
* Prüft die installierte Divi-Version wie der Reiter „Divi“: Manifeste, Fingerabdrücke, Modulkatalog, Format
* und Schreibtest auf einer Wegwerfseite. Gibt nichts frei.
*
* ## OPTIONS
*
* [--diff]
* : Zeigt für jede geänderte nachgebaute Divi-Funktion den Unterschied zur Referenz.
*
* [--update-reference]
* : Schreibt Soll-Fingerabdrücke und Referenzquellen neu – nur wenn alle übrigen Schritte grün sind.
*
* ## EXAMPLES
*
* wp --user=admin lit-mcp divi-check --diff
*
* @param list<string> $args
* @param array<string, mixed> $assoc
*/
public function __invoke(array $args, array $assoc): void
{
$code = $this->run($assoc);
if ($code !== 0) {
\WP_CLI::halt($code);
}
}
/** @param array<string, mixed> $assoc */
public function run(array $assoc): int
{
if (! current_user_can('manage_options')) {
($this->out)('Bitte mit --user=<Administrator> ausführen: der Schreibtest legt eine Seite an.');
return 1;
}
$report = ($this->check)()->run(get_current_user_id());
($this->reports ?? new CheckReportStore())->save($report);
($this->out)(sprintf('Divi %s mit LIT MCP %s', $report->diviVersion ?: '?', $report->pluginVersion));
foreach ($report->steps as $step) {
($this->out)(($step->skipped ? '–' : ($step->green() ? '✓' : '✗')) . ' ' . $step->label());
foreach ($step->problems as $problem) {
($this->out)(' ' . $problem);
}
}
if (isset($assoc['diff'])) {
$this->diff();
}
if (isset($assoc['update-reference'])) {
return $this->updateReference($report);
}
return $report->green() ? 0 : 1;
}
private function diff(): void
{
$themeDir = ($this->themeDir ?? DiviEnvironment::themeDir(...))();
$sourceDir = ($this->sourceDir ?? DiviReference::sourceDir(...))();
$expected = DiviReference::expected($this->expectedFile);
foreach (DiviAdapters::mirrors() as $mirror) {
try {
$print = SourceFingerprint::of(
(string) $themeDir . '/' . $mirror->file,
$mirror->function,
$mirror->class
);
} catch (RuntimeException $e) {
($this->out)(sprintf('%s: nicht lesbar (%s)', $mirror->id, $e->getMessage()));
continue;
}
if (($expected[$mirror->id] ?? null) === $print->hash) {
continue;
}
($this->out)(sprintf(
'%s: %s, Zeilen %d–%d',
$mirror->id,
$mirror->file,
$print->startLine,
$print->endLine
));
$reference = $sourceDir === null ? null : DiviReference::readSource($mirror->id, $sourceDir);
if ($reference === null) {
($this->out)(' keine Referenzquelle (nur in einer Entwicklungskopie mit tests/)');
continue;
}
foreach (LineDiff::lines(explode("\n", rtrim($reference, "\n")), explode("\n", $print->source)) as $line) {
($this->out)(' ' . $line);
}
}
}
private function updateReference(CheckReport $report): int
{
foreach ($report->steps as $step) {
if ($step->key !== CheckStep::MIRRORS && ! $step->green()) {
($this->out)('Referenz nicht aktualisiert: nicht nur Fingerabdrücke sind rot (siehe oben).');
return 1;
}
}
$sourceDir = ($this->sourceDir ?? DiviReference::sourceDir(...))();
if ($sourceDir === null) {
($this->out)('Referenz nicht aktualisiert: nur in einer Entwicklungskopie mit tests/ möglich.');
return 1;
}
$themeDir = ($this->themeDir ?? DiviEnvironment::themeDir(...))();
try {
$prints = DiviReference::current((string) $themeDir);
} catch (RuntimeException $e) {
($this->out)(
'Referenz nicht aktualisiert – eine nachgebaute Divi-Funktion ist nicht lesbar: ' . $e->getMessage()
);
return 1;
}
try {
// Sources first, fingerprints.json last: the fingerprints decide the release gate, so a write that
// fails before this last one leaves the gate exactly as it was.
foreach ($prints as $id => $print) {
DiviReference::writeSource($id, $print->source, $sourceDir);
}
DiviReference::writeExpected(
array_map(static fn (SourceFingerprint $print): string => $print->hash, $prints),
$this->expectedFile
);
} catch (RuntimeException $e) {
($this->out)('Referenz nicht vollständig aktualisiert: ' . $e->getMessage());
return 1;
}
delete_transient(ReleaseResolver::CACHE);
ReleaseGate::forget();
($this->out)(sprintf(
'Referenz aktualisiert (%d Fingerabdrücke). Smoke-Skripte laufen lassen, dann committen.',
count($prints)
));
return 0;
}
}
+11 -5
View File
@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Core;
use LIT\MCP\Integrations\Divi\DiviCompatibility;
use LIT\MCP\Integrations\Divi\Release\DiviEnvironment;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
defined('ABSPATH') || exit;
@@ -18,13 +20,14 @@ class EnvironmentProbe
{
}
/** @return list<array{id: string, active: bool, version: ?string, problems: list<string>}> */
/**
* @return list<array{id: string, active: bool, version: ?string, problems: list<string>,
* release: ?array<string, mixed>}>
*/
public function components(): array
{
$theme = wp_get_theme();
$isDivi = $theme->get_template() === 'Divi';
$parent = $theme->parent();
$diviVersion = $isDivi ? (string) ($parent ? $parent->get('Version') : $theme->get('Version')) : null;
$diviVersion = DiviEnvironment::version();
$isDivi = $diviVersion !== null;
$tecActive = class_exists('Tribe__Events__Main');
@@ -34,18 +37,21 @@ class EnvironmentProbe
'active' => $isDivi,
'version' => $diviVersion,
'problems' => $isDivi ? ($this->divi ?? new DiviCompatibility())->problems() : [],
'release' => $isDivi ? ReleaseGate::status()->toArray() : null,
],
[
'id' => 'pods',
'active' => defined('PODS_VERSION'),
'version' => defined('PODS_VERSION') ? (string) PODS_VERSION : null,
'problems' => [],
'release' => null,
],
[
'id' => 'the-events-calendar',
'active' => $tecActive,
'version' => $tecActive ? (string) constant('Tribe__Events__Main::VERSION') : null,
'problems' => [],
'release' => null,
],
];
}
+2 -1
View File
@@ -31,7 +31,8 @@ final class SiteStatusAbility implements Ability
{
return 'Überblick: Website, WordPress-Version, eigener Benutzer und Rollen, '
. 'erkannte Integrationen (Divi, Pods, The Events Calendar). '
. 'Bei Divi nennt problems Divi-Funktionen, die nach einem Update fehlen.';
. 'Bei Divi nennt problems Divi-Stellen, die nach einem Update fehlen oder sich geändert haben, und '
. 'release, ob LIT MCP mit dieser Divi-Version schreiben darf (released, untested, incompatible).';
}
public function inputSchema(): array
@@ -6,24 +6,45 @@ namespace LIT\MCP\Integrations\Divi\Design;
use ET\Builder\Packages\GlobalData\GlobalData;
use ET\Builder\Packages\GlobalData\GlobalPreset;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\Manifest;
defined('ABSPATH') || exit;
/**
* Reads the design system from Divi's own APIs once per request (spec §2 principle 2 – no
* copy is ever stored). Not final so abilities can be unit-tested with a mock.
* Adapter "Designsystem" (spec §3): reads the design system from Divi's own APIs
* once per request (spec §2 principle 2 – no copy is ever stored), and the logo of
* Divi's theme options. Not final so abilities can be unit-tested with a mock.
*/
class DesignSystemReader
class DesignSystemReader implements DiviAdapter
{
/** Divi's theme options (one array option); customizer colors, fonts and
* the logo live there. */
private const OPTIONS = 'et_divi';
private ?DesignSystem $designSystem = null;
public static function manifest(): Manifest
{
return new Manifest(
Manifest::DESIGN,
calls: [
new Call(GlobalData::class . '::get_global_colors', 0),
new Call(GlobalData::class . '::get_global_variables', 0),
new Call(GlobalPreset::class . '::get_data', 0),
],
keys: [self::OPTIONS]
);
}
public function read(): DesignSystem
{
if ($this->designSystem === null) {
$colors = GlobalData::get_global_colors();
$variables = GlobalData::get_global_variables();
$presets = GlobalPreset::get_data();
$options = get_option('et_divi', []);
$options = get_option(self::OPTIONS, []);
$this->designSystem = DesignSystem::fromDivi(
is_array($colors) ? $colors : [],
is_array($variables) ? $variables : [],
@@ -34,4 +55,15 @@ class DesignSystemReader
return $this->designSystem;
}
/** The logo URL of Divi's theme options (Divi → Theme-Optionen → Logo),
* '' when none is set. */
public static function diviLogo(): string
{
$options = get_option(self::OPTIONS);
return is_array($options) && is_string($options['divi_logo'] ?? null)
? $options['divi_logo']
: '';
}
}
+8 -75
View File
@@ -4,20 +4,14 @@ 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 LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
use Throwable;
use LIT\MCP\Integrations\Divi\Release\ManifestCheck;
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".
* Live check of the Divi internals LIT MCP relies on (spec §4.3): every check of every adapter manifest – calls
* with their argument count, constants, registrations and the fingerprints of the mirrors. 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
{
@@ -29,75 +23,14 @@ final class DiviCompatibility
/** @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 ManifestCheck::failed($this->checks ?? self::defaults());
}
/** @return array<string, callable(): bool> */
public static function defaults(): array
public static function defaults(?ManifestCheck $check = null): 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'),
'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'),
// Includes et_theme_builder_clear_wp_cache (Theme Builder cache of every site-wide clear, review M-2).
'Theme Builder: Funktionen et_theme_builder_* und et_()' =>
static fn (): bool => (new DiviThemeBuilder())->missing() === [],
'Theme Builder: Post-Typen et_template und Layouts' => static fn (): bool => array_filter(
DiviThemeBuilder::postTypes(),
static fn (string $type): bool => ! post_type_exists($type)
) === [],
];
}
$check ??= new ManifestCheck();
/**
* 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;
return array_merge($check->codeChecks(), $check->registrationChecks(), $check->mirrorChecks());
}
}
@@ -5,6 +5,12 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\Mirror;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\WordPress\Content\NewDraft;
use RuntimeException;
use Throwable;
@@ -15,15 +21,15 @@ 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
* Adapter "Bibliothek" (spec §3) for the Divi Library (E4, E15): post type et_pb_layout, the taxonomies scope,
* layout_type, module_width, layout_category, layout_tag and the terms and meta Divi's et_pb_submit_layout() writes
* (mirror library-submit-layout: metaFor()) (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
class DiviLibraryStore implements DiviAdapter
{
public const POST_TYPE = 'et_pb_layout';
@@ -34,9 +40,67 @@ class DiviLibraryStore
/** Taxonomies every new entry needs (et_pb_submit_layout). */
public const TAXONOMIES = ['scope', 'layout_type', 'module_width'];
public const MODULE_TYPE = '_et_pb_module_type';
public const EXCLUDED_OPTIONS = '_et_pb_excluded_global_options';
public const BUILT_FOR = '_et_pb_built_for_post_type';
public const TEMPLATE_TYPE = '_et_pb_template_type';
/** 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 static function manifest(): Manifest
{
return new Manifest(
Manifest::LIBRARY,
calls: [new Call(self::GLOBAL_LAYOUT . '::is_global_layout_template', 1)],
postTypes: [self::POST_TYPE],
taxonomies: [...self::TAXONOMIES, 'layout_category', 'layout_tag'],
keys: [self::MODULE_TYPE, self::EXCLUDED_OPTIONS, self::BUILT_FOR, self::TEMPLATE_TYPE],
mirrors: [
new Mirror(
'library-submit-layout',
'includes/builder/shortcode-core.php',
'et_pb_submit_layout',
null,
'Terme und Meta neuer Bibliothekseinträge (scope, layout_type, module_width, _et_pb_module_type, '
. '_et_pb_excluded_global_options, _et_pb_built_for_post_type, _et_pb_template_type)'
),
]
);
}
/**
* The meta of a new entry as et_pb_submit_layout() writes it (compared with the real builder entries, fixtures
* stage3): _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).
*
* @return array<string, string>
*/
public static function metaFor(LibraryItemSpec $spec): array
{
$meta = [];
if ($spec->moduleType !== null) {
$meta[self::MODULE_TYPE] = $spec->moduleType;
}
if ($spec->global && $spec->layoutType === 'module') {
$meta[self::EXCLUDED_OPTIONS] = '[]';
}
$meta[self::BUILT_FOR] = $spec->builtFor;
$meta[self::TEMPLATE_TYPE] = $spec->layoutType;
return $meta;
}
/** _et_pb_built_for_post_type of entry $id ('' when unset): the type an entry was built for. */
public static function builtFor(int $id): string
{
return (string) get_post_meta($id, self::BUILT_FOR, true);
}
public function isGlobal(int $id): bool
{
if (method_exists(self::GLOBAL_LAYOUT, 'is_global_layout_template')) {
@@ -127,7 +191,7 @@ class DiviLibraryStore
public function describe(WP_Post $post): array
{
$id = (int) $post->ID;
$moduleType = (string) get_post_meta($id, '_et_pb_module_type', true);
$moduleType = (string) get_post_meta($id, self::MODULE_TYPE, true);
return [
'id' => $id,
@@ -149,9 +213,11 @@ class DiviLibraryStore
* when that delete fails; a throwing insert hook is a partial write without ID.
*
* @throws WriteFailed
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
*/
public function create(LibraryItemSpec $spec, NewDraft $draft): int
{
ReleaseGate::assertWritable();
$missing = $this->missingTaxonomies();
if ($missing !== []) {
throw WriteFailed::rolledBack(
@@ -209,7 +275,7 @@ class DiviLibraryStore
throw new RuntimeException(sprintf('terms %s: %s', $taxonomy, $result->get_error_message()));
}
}
foreach ($spec->meta() as $key => $value) {
foreach (self::metaFor($spec) 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));
+10 -2
View File
@@ -6,18 +6,21 @@ namespace LIT\MCP\Integrations\Divi\Library;
use Closure;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\Manifest;
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)
* Adapter "Bibliothek" (spec §3) for Divi's role editor (E3, E15): et_pb_is_allowed() (Divi's builder/core.php)
* 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
class DiviRoles implements DiviAdapter
{
private const LABELS = [
'divi_library' => 'Divi-Bibliothek',
@@ -28,6 +31,11 @@ class DiviRoles
'theme_builder' => 'Theme Builder',
];
public static function manifest(): Manifest
{
return new Manifest(Manifest::LIBRARY, calls: [new Call('et_pb_is_allowed', 1)]);
}
/** @param (Closure(string): bool)|null $check Test seam; null = Divi's et_pb_is_allowed(). */
public function __construct(private ?Closure $check = null)
{
@@ -13,12 +13,9 @@ 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.
* Title and terms of a new library entry, like Divi's et_pb_submit_layout() writes them (compared with the real
* builder entries, fixtures stage3): terms scope (global / non_global), layout_type, module_width, optional
* layout_category/layout_tag. The meta follows from DiviLibraryStore::metaFor(). Pure PHP.
*/
final class LibraryItemSpec
{
@@ -129,20 +126,4 @@ final class LibraryItemSpec
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;
}
}
@@ -127,7 +127,7 @@ final class NewLibraryEntry
return (string) $page->post->post_type;
}
$builtFor = (string) get_post_meta((int) $page->post->ID, '_et_pb_built_for_post_type', true);
$builtFor = DiviLibraryStore::builtFor((int) $page->post->ID);
return $builtFor !== '' ? $builtFor : 'page';
}
@@ -10,6 +10,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Outline;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
@@ -20,7 +21,7 @@ defined('ABSPATH') || exit;
/**
* Divi short form for lit/content-create (spec §6.2): the sections become a Divi 5 layout in
* exactly one divi/placeholder – inserted by TreeEditor like the first section of an empty page
* (ruling S9) – stored through DiviSaver's kses window, with the builder flags (DiviFlags, S11).
* (ruling S9) – stored through DiviSaver's kses window, with the builder flags (DiviPages, S11).
*
* Allow map (ruling S22) for an entry that does not exist yet: check() runs before the insert
* with the composition bound to post ID 0 – Divi's own registry for "no entry" (get_options(0)
@@ -59,6 +60,13 @@ final class DiviContentComposer implements ContentComposer
return ($this->writable)();
}
public function refusal(): ?WP_Error
{
$status = ReleaseGate::status();
return $status->writable() ? null : $status->refusal();
}
public function check(string $postType, array $sections): ?WP_Error
{
if (! $this->schema->diviEnabled($postType)) {
-40
View File
@@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use RuntimeException;
defined('ABSPATH') || exit;
/**
* Marks an entry as a Divi 5 builder layout, like the Visual Builder's own save does
* (Divi 5.2.1 SyncToServerController.php:382-387: _et_pb_use_divi_5 = "on", legacy
* _et_pb_use_builder = "on", and _et_pb_show_page_creation = "off" so the builder does not show
* its page creation flow on the first open). Ruling S11: update_post_meta() returns false for
* an unchanged value too, so success is checked by reading the flags back. Used by DiviSaver
* (inside FollowUp) and the Divi content creation.
*/
final class DiviFlags
{
public const FLAGS = [
'_et_pb_use_divi_5' => 'on',
'_et_pb_use_builder' => 'on',
'_et_pb_show_page_creation' => 'off',
];
/** @throws RuntimeException When a flag does not read back as set. */
public static function set(int $postId): void
{
foreach (self::FLAGS as $key => $value) {
update_post_meta($postId, $key, $value);
}
foreach (self::FLAGS as $key => $value) {
if (get_post_meta($postId, $key, true) !== $value) {
throw new RuntimeException(sprintf('Divi flag %s of post %d is not "%s".', $key, $postId, $value));
}
}
}
}
+1 -1
View File
@@ -74,6 +74,6 @@ final class DiviPage
/** The entry opened in the Divi Visual Builder – the edit_url of every Divi write (review I-1). */
public static function builderUrl(WP_Post|int $post): string
{
return (string) add_query_arg('et_fb', '1', (string) get_permalink($post));
return DiviPages::builderUrl($post);
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\Mirror;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use RuntimeException;
use WP_Post;
defined('ABSPATH') || exit;
/**
* Adapter "Seiten" (spec §3): the Divi 5 builder flags of an entry as the Visual Builder's own save sets them
* (mirror vb-sync-flags: _et_pb_use_divi_5 = "on", legacy _et_pb_use_builder = "on", _et_pb_show_page_creation =
* "off" so the builder skips its page creation flow), whether an entry uses the builder, its builder URL, the
* static CSS of one page and Divi's list of linkable post types. Ruling S11: update_post_meta() returns false for an
* unchanged value too, so setFlags() checks by reading the flags back. Used by DiviSaver, the Divi content creation,
* WpLinkTargets and PostPresenter.
*/
final class DiviPages implements DiviAdapter
{
public const FLAGS = [
'_et_pb_use_divi_5' => 'on',
'_et_pb_use_builder' => 'on',
'_et_pb_show_page_creation' => 'off',
];
public static function manifest(): Manifest
{
return new Manifest(
Manifest::PAGES,
calls: [
new Call('ET_Core_PageResource::remove_static_resources', 3),
new Call('et_builder_get_public_post_types', 0),
],
keys: ['_et_pb_use_divi_5', '_et_pb_use_builder', '_et_pb_show_page_creation', 'et_fb'],
mirrors: [
new Mirror(
'vb-sync-flags',
'includes/builder-5/server/VisualBuilder/REST/SyncToServer/SyncToServerController.php',
'update',
'SyncToServerController',
'Builder-Kennzeichen einer Seite (_et_pb_use_divi_5, _et_pb_use_builder, _et_pb_show_page_creation)'
),
]
);
}
/**
* @throws RuntimeException When a flag does not read back as set.
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
*/
public static function setFlags(int $postId): void
{
ReleaseGate::assertWritable($postId);
foreach (self::FLAGS as $key => $value) {
update_post_meta($postId, $key, $value);
}
foreach (self::FLAGS as $key => $value) {
if (get_post_meta($postId, $key, true) !== $value) {
throw new RuntimeException(sprintf('Divi flag %s of post %d is not "%s".', $key, $postId, $value));
}
}
}
/** Whether Divi's builder flag marks $postId as a builder layout. */
public static function usesBuilder(int $postId): bool
{
return get_post_meta($postId, '_et_pb_use_builder', true) === 'on';
}
/** The entry opened in the Divi Visual Builder: its permalink with et_fb=1. */
public static function builderUrl(WP_Post|int $post): string
{
return (string) add_query_arg('et_fb', '1', (string) get_permalink($post));
}
/** Removes the static CSS files Divi generated for $postId (spec §5.6 step 5). */
public static function clearPageCss(int $postId): void
{
if (class_exists('ET_Core_PageResource')) {
\ET_Core_PageResource::remove_static_resources($postId, 'all', true);
}
}
/** Whether Divi offers a post_link_url_<type> option for $postType. Fail closed: without Divi's list, none. */
public static function isLinkable(string $postType): bool
{
if (! function_exists('et_builder_get_public_post_types')) {
return false;
}
$types = et_builder_get_public_post_types();
return is_array($types) && array_key_exists($postType, $types);
}
}
+11 -7
View File
@@ -8,6 +8,8 @@ use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\FollowUp;
use LIT\MCP\Abilities\Rollback;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\RevisionPoint;
@@ -20,7 +22,7 @@ defined('ABSPATH') || exit;
/**
* Save pipeline of spec §5.6, steps 3–5: restore point and wp_update_post() with wp_slash() (the
* status stays), Divi 5 flags (DiviFlags), Divi's static CSS cache. A failure after saving is
* status stays), Divi 5 flags and the page's static CSS (DiviPages). A failure after saving is
* only a warning (FollowUp); a filter that changed the stored content is reported as well.
*
* kses (spec D7): for users without unfiltered_html WordPress' kses would run
@@ -45,7 +47,7 @@ defined('ABSPATH') || exit;
* for its single wp_update_post().
*
* 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's Security.php) registers on wp_insert_post_data
* 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 the whole page when it holds custom attributes or htmlBefore/htmlAfter/elementType, so on such
@@ -69,10 +71,12 @@ class DiviSaver
/**
* @return array{revision_id: ?int, warnings: list<string>}
* @throws WriteFailed
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
*/
public function save(WP_Post $post, string $content, SaveScope $scope = SaveScope::Page): array
{
$postId = (int) $post->ID;
ReleaseGate::assertWritable($postId);
$revisionId = null;
try {
@@ -98,7 +102,7 @@ class DiviSaver
$warnings = [];
if ($scope->setsFlags()) {
FollowUp::run(
static fn () => DiviFlags::set($postId),
static fn () => DiviPages::setFlags($postId),
'divi builder flags',
'Die Divi-Kennzeichen der Seite konnten nicht gesetzt werden; der Inhalt ist gespeichert – die Seite '
. 'einmal im Divi-Builder öffnen und speichern.',
@@ -107,9 +111,7 @@ class DiviSaver
}
FollowUp::run(
static function () use ($postId, $scope): void {
if (class_exists('ET_Core_PageResource')) {
\ET_Core_PageResource::remove_static_resources($postId, 'all', true);
}
DiviPages::clearPageCss($postId);
// A global library entry is part of every page that shows it (spec §5.6 step 5).
if ($scope->clearsSiteCss()) {
DiviThemeBuilder::clearSiteCaches();
@@ -140,16 +142,18 @@ class DiviSaver
*
* @return list<string> Warnings (CHANGED_ON_SAVE).
* @throws RuntimeException
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
*/
public function saveNew(int $postId, string $content, SaveScope $scope = SaveScope::Page): array
{
ReleaseGate::assertWritable($postId);
$refusal = self::refusal($this->writeContent($postId, $content));
if ($refusal !== null) {
throw new RuntimeException(sprintf('wp_update_post(%d) refused the Divi layout: %s', $postId, $refusal));
}
if ($scope->setsFlags()) {
DiviFlags::set($postId);
DiviPages::setFlags($postId);
}
$stored = get_post($postId);
@@ -7,13 +7,16 @@ namespace LIT\MCP\Integrations\Divi\Pages;
use Closure;
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Log;
use Throwable;
defined('ABSPATH') || exit;
/**
* DynamicContentRules' dynamic content allow map (ruling S22) from Divi's live registry
* Adapter "Katalog" (spec §3): DynamicContentRules' dynamic content allow map (ruling S22) from Divi's live registry
* DynamicContentOptions::get_options($postId, 'edit') – the options the Visual Builder offers
* for this entry (filter divi_module_dynamic_content_options; the list depends on the post:
* post_categories/post_tags only for types with those taxonomies). Shape:
@@ -29,9 +32,9 @@ defined('ABSPATH') || exit;
*
* Not final so abilities can be tested with a fake registry.
*/
class DynamicContentMap
class DynamicContentMap implements DiviAdapter
{
/** Divi's dynamic-content registry class; also checked by DiviCompatibility. */
/** Divi's dynamic-content registry class; listed in manifest(). */
public const REGISTRY = 'ET\Builder\Packages\Module\Layout\Components\DynamicContent\DynamicContentOptions';
/**
@@ -41,6 +44,11 @@ class DynamicContentMap
{
}
public static function manifest(): Manifest
{
return new Manifest(Manifest::CATALOG, calls: [new Call(self::REGISTRY . '::get_options', 2)]);
}
/** @return array<string, array<string, string>>|null Null = use DynamicContentRules' default map. */
public function forPost(int $postId): ?array
{
+5 -15
View File
@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Divi\Pages;
use Closure;
use LIT\MCP\Integrations\Divi\Compose\LinkTargets;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use WP_Post;
defined('ABSPATH') || exit;
@@ -22,18 +23,19 @@ final class WpLinkTargets implements LinkTargets
private const DEAD = ['trash', 'auto-draft', 'inherit'];
/** Never link targets, whatever Divi lists: attachments and the Divi Library (ruling Q14). */
private const NEVER = ['attachment', 'et_pb_layout'];
private const NEVER = ['attachment', DiviLibraryStore::POST_TYPE];
/** @var Closure(string): bool */
private Closure $linkable;
/**
* @param (Closure(string): bool)|null $linkable Whether Divi offers post_link_url_<type> (tests);
* null = Divi's et_builder_get_public_post_types(), the list its link option uses.
* null = DiviPages::isLinkable() (Divi's et_builder_get_public_post_types(), the list its
* link option uses).
*/
public function __construct(?Closure $linkable = null)
{
$this->linkable = $linkable ?? self::diviLinkable(...);
$this->linkable = $linkable ?? DiviPages::isLinkable(...);
}
public function postTypeOf(int $id): ?string
@@ -50,16 +52,4 @@ final class WpLinkTargets implements LinkTargets
return current_user_can('read_post', $id) ? $type : null;
}
/** Fail closed: without Divi's list no type has a link option. */
private static function diviLinkable(string $type): bool
{
if (! function_exists('et_builder_get_public_post_types')) {
return false;
}
$types = et_builder_get_public_post_types();
return is_array($types) && array_key_exists($type, $types);
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/**
* One Divi function or static method an adapter calls (spec §4.1), with the number of arguments the plugin passes.
* $args null: only its existence matters (a function Divi itself needs, e.g. et_() inside store_template).
*/
final class Call
{
/** @param string $symbol "function_name" or "Fully\Qualified\Class::method". */
public function __construct(public readonly string $symbol, public readonly ?int $args = null)
{
}
public function className(): ?string
{
return str_contains($this->symbol, '::') ? ltrim(explode('::', $this->symbol, 2)[0], '\\') : null;
}
public function member(): string
{
return str_contains($this->symbol, '::') ? explode('::', $this->symbol, 2)[1] : $this->symbol;
}
public function label(): string
{
$class = $this->className();
return ($class === null ? '' : SourceFingerprint::shortName($class) . '::') . $this->member() . '()';
}
}
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/** The result of one admin check (spec §6.3); a release needs a fresh green one. */
final class CheckReport
{
/** @param list<CheckStep> $steps */
public function __construct(
public readonly string $id,
public readonly string $createdAt,
public readonly int $userId,
public readonly string $diviVersion,
public readonly string $pluginVersion,
public readonly string $stateKey,
public readonly array $steps
) {
}
public function green(): bool
{
if ($this->steps === []) {
return false;
}
foreach ($this->steps as $step) {
if (! $step->green()) {
return false;
}
}
return true;
}
/** Green and made for exactly this Divi version, plugin version and Divi files (ReleaseResolver::stateKey()). */
public function freshFor(string $diviVersion, string $pluginVersion, string $stateKey): bool
{
return $this->green()
&& $this->diviVersion === $diviVersion
&& $this->pluginVersion === $pluginVersion
&& $this->stateKey === $stateKey;
}
public function step(string $key): ?CheckStep
{
foreach ($this->steps as $step) {
if ($step->key === $key) {
return $step;
}
}
return null;
}
/** @return array<string, mixed> */
public function toArray(): array
{
return [
'id' => $this->id,
'created_at' => $this->createdAt,
'user_id' => $this->userId,
'divi_version' => $this->diviVersion,
'plugin_version' => $this->pluginVersion,
'state_key' => $this->stateKey,
'steps' => array_map(static fn (CheckStep $step): array => $step->toArray(), $this->steps),
];
}
public static function fromArray(mixed $data): ?self
{
if (! is_array($data)) {
return null;
}
foreach (['id', 'created_at', 'divi_version', 'plugin_version', 'state_key'] as $key) {
if (! is_string($data[$key] ?? null)) {
return null;
}
}
if (! is_int($data['user_id'] ?? null) || ! is_array($data['steps'] ?? null)) {
return null;
}
$steps = [];
foreach ($data['steps'] as $step) {
$parsed = CheckStep::fromArray($step);
if ($parsed === null) {
return null;
}
$steps[] = $parsed;
}
return new self(
$data['id'],
$data['created_at'],
$data['user_id'],
$data['divi_version'],
$data['plugin_version'],
$data['state_key'],
$steps
);
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/** The latest admin check report of this site (spec §6.3). Not final so the Divi tab can be tested with a mock. */
class CheckReportStore
{
public const OPTION = 'lit_mcp_divi_check_report';
public function latest(): ?CheckReport
{
return CheckReport::fromArray(get_option(self::OPTION, null));
}
public function save(CheckReport $report): void
{
update_option(self::OPTION, $report->toArray(), false);
}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/** One step of the admin check (spec §6.2); skipped = not run (the write test after missing Divi functions). */
final class CheckStep
{
public const MANIFEST = 'manifest';
public const MIRRORS = 'mirrors';
public const CATALOG = 'catalog';
public const FORMAT = 'format';
public const WRITE = 'write';
private const LABELS = [
self::MANIFEST => 'Divi-Funktionen, Klassen und Registrierungen',
self::MIRRORS => 'Fingerabdrücke nachgebauter Divi-Funktionen',
self::CATALOG => 'Modulkatalog (Kurzform-Pfade)',
self::FORMAT => 'Format im Speicher (Parser und Serializer)',
self::WRITE => 'Schreibtest auf einer Wegwerfseite',
];
/** @param list<string> $problems */
public function __construct(
public readonly string $key,
public readonly array $problems,
public readonly bool $skipped = false
) {
}
public function green(): bool
{
return ! $this->skipped && $this->problems === [];
}
public function label(): string
{
return self::LABELS[$this->key] ?? $this->key;
}
/** @return array{key: string, problems: list<string>, skipped: bool} */
public function toArray(): array
{
return ['key' => $this->key, 'problems' => $this->problems, 'skipped' => $this->skipped];
}
public static function fromArray(mixed $data): ?self
{
if (! is_array($data) || ! is_string($data['key'] ?? null) || ! is_array($data['problems'] ?? null)) {
return null;
}
return new self(
$data['key'],
array_values(array_filter($data['problems'], 'is_string')),
($data['skipped'] ?? false) === true
);
}
}
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\Divi\Schema\ShortformCheck;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Plugin;
use Throwable;
defined('ABSPATH') || exit;
/**
* The admin check (spec §6.2) – also wp lit-mcp divi-check: manifests (code and registrations), fingerprints,
* module catalog, format round trip and the write test. A step that throws is a red step; the write test is skipped
* only when Divi functions are missing (it would fail anyway), not when only fingerprints changed – so
* --update-reference can see whether everything else still works. Not final so the route and the command can be
* tested with a mock.
*/
class CompatibilityCheck
{
/**
* @param Closure(): list<string> $catalog Short-form problems against Divi's live catalog.
* @param Closure(): string $version Installed Divi version ('' = unknown).
* @param Closure(string): string $stateKey ReleaseResolver::stateKey().
*/
public function __construct(
private ManifestCheck $manifests,
private Closure $catalog,
private FormatCheck $format,
private WriteProbe $probe,
private Closure $version,
private Closure $stateKey
) {
}
public static function create(ContentComposer $composer): self
{
return new self(
new ManifestCheck(),
static fn (): array => DiviModules::isAvailable()
? ShortformCheck::problems((new DiviModules())->catalog())
: ['Divi 5 ist in dieser Anfrage nicht geladen.'],
new FormatCheck(),
new WriteProbe($composer),
static fn (): string => DiviEnvironment::version() ?? '',
static fn (string $version): string => ReleaseResolver::stateKey($version)
);
}
public function run(int $userId): CheckReport
{
$manifest = self::guarded(fn (): array => ManifestCheck::failed(
array_merge($this->manifests->codeChecks(), $this->manifests->registrationChecks())
));
$steps = [
new CheckStep(CheckStep::MANIFEST, $manifest),
new CheckStep(CheckStep::MIRRORS, self::guarded(
fn (): array => ManifestCheck::failed($this->manifests->mirrorChecks())
)),
new CheckStep(CheckStep::CATALOG, self::guarded($this->catalog)),
new CheckStep(CheckStep::FORMAT, self::guarded(fn (): array => $this->format->problems())),
$manifest === []
? new CheckStep(CheckStep::WRITE, self::guarded(fn (): array => $this->probe->run()))
: new CheckStep(CheckStep::WRITE, [], true),
];
$version = ($this->version)();
return new CheckReport(
wp_generate_uuid4(),
gmdate('c'),
$userId,
$version,
Plugin::VERSION,
($this->stateKey)($version),
$steps
);
}
/** @return list<string> */
private static function guarded(Closure $step): array
{
try {
return array_values(array_filter((array) $step(), 'is_string'));
} catch (Throwable $e) {
return ['Prüfschritt abgebrochen: ' . $e->getMessage()];
}
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/** A class that may touch Divi (spec §3); it lists every Divi dependency in its manifest. */
interface DiviAdapter
{
public static function manifest(): Manifest;
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
use LIT\MCP\Integrations\Divi\Pages\DiviPages;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
defined('ABSPATH') || exit;
/** The Divi boundary (spec §3): the only classes that touch Divi, in the order
* reports list them. */
final class DiviAdapters
{
/** @var list<class-string<DiviAdapter>> */
public const CLASSES = [
DiviPages::class,
DiviLibraryStore::class,
DiviRoles::class,
DiviThemeBuilder::class,
DesignSystemReader::class,
DiviModules::class,
DynamicContentMap::class,
];
/** @return array<class-string<DiviAdapter>, Manifest> */
public static function manifests(): array
{
$manifests = [];
foreach (self::CLASSES as $class) {
$manifests[$class] = $class::manifest();
}
return $manifests;
}
/** @return list<Mirror> */
public static function mirrors(): array
{
$mirrors = [];
foreach (self::manifests() as $manifest) {
array_push($mirrors, ...$manifest->mirrors);
}
return $mirrors;
}
}
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Admin\DiviTab;
use LIT\MCP\Admin\SettingsPage;
use LIT\MCP\Log;
use Throwable;
use WP_REST_Response;
defined('ABSPATH') || exit;
/**
* "Prüfung starten" of the Divi tab (spec §6.1): POST /wp-json/lit-mcp/v1/divi-check. A REST request on purpose –
* not admin-post – so the check runs in the same context as MCP calls (Divi renders its blocks in REST requests,
* lit-page-render-summary). The form sends the wp_rest nonce as _wpnonce (cookie authentication); only manage_options.
* One check at a time: an atomic "INSERT IGNORE" on the options table (self::LOCK) is the lock – add_option() reads
* then writes (wp-includes/option.php) and is not atomic, so two simultaneous requests could both "acquire" it. A
* lock older than LOCK_TTL is a dead check's, taken over with a conditional UPDATE (compare-and-swap on the value
* just read) so only one of several concurrent takers wins. A check that throws still releases the lock (finally)
* and redirects with divi_check_failed (logged, never shown to the browser as an unhandled 500). Answers 303 back
* to the tab.
*/
final class DiviCheckRoute
{
public const NAMESPACE = 'lit-mcp/v1';
public const ROUTE = '/divi-check';
public const LOCK = 'lit_mcp_divi_check_lock';
private const LOCK_TTL = 600;
/** @param Closure(): CompatibilityCheck $check */
public function __construct(private Closure $check, private ?CheckReportStore $reports = null)
{
}
public function register(): void
{
add_action('rest_api_init', [$this, 'registerRoute']);
}
public function registerRoute(): void
{
register_rest_route(self::NAMESPACE, self::ROUTE, [
'methods' => 'POST',
'callback' => [$this, 'handle'],
'permission_callback' => static fn (): bool => current_user_can('manage_options'),
]);
}
public static function url(): string
{
return rest_url(self::NAMESPACE . self::ROUTE);
}
public function handle(): WP_REST_Response
{
$notice = 'divi_check_running';
if (self::lock()) {
try {
$report = ($this->check)()->run(get_current_user_id());
($this->reports ?? new CheckReportStore())->save($report);
ReleaseGate::forget();
$notice = 'divi_checked';
} catch (Throwable $e) {
Log::failure('divi check', $e);
$notice = 'divi_check_failed';
} finally {
delete_option(self::LOCK);
}
}
return new WP_REST_Response(
null,
303,
['Location' => SettingsPage::url(DiviTab::SLUG, ['lit_mcp_notice' => $notice])]
);
}
private static function lock(): bool
{
global $wpdb;
if (self::insertLock($wpdb, time())) {
return true;
}
// get_var(), not get_option(): the row was written with raw SQL, bypassing the options cache.
$since = $wpdb->get_var($wpdb->prepare(
"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
self::LOCK
));
if ($since === null) {
// The lock was released between our failed insert and this read; try once more.
return self::insertLock($wpdb, time());
}
if (time() - (int) $since < self::LOCK_TTL) {
return false;
}
$taken = $wpdb->query($wpdb->prepare(
"UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s",
(string) time(),
self::LOCK,
$since
));
return (int) $taken === 1;
}
/** INSERT IGNORE: 1 row affected = the lock did not exist and is now ours; 0 = someone else holds it. */
private static function insertLock(\wpdb $wpdb, int $now): bool
{
$inserted = $wpdb->query($wpdb->prepare(
"INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
self::LOCK,
(string) $now
));
return (int) $inserted === 1;
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/**
* The installed Divi theme: version (the parent's for a child theme) and directory; null when Divi is not the
* template.
*/
final class DiviEnvironment
{
public const TEMPLATE = 'Divi';
public static function version(): ?string
{
$theme = wp_get_theme();
if ($theme->get_template() !== self::TEMPLATE) {
return null;
}
$parent = $theme->parent();
return (string) ($parent ? $parent->get('Version') : $theme->get('Version'));
}
public static function themeDir(): ?string
{
return wp_get_theme()->get_template() === self::TEMPLATE ? (string) get_template_directory() : null;
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use RuntimeException;
use Throwable;
use WP_Error;
defined('ABSPATH') || exit;
/** A write refused because the installed Divi is not released (spec §5.5); AbilityRegistrar returns toError(). */
final class DiviLocked extends RuntimeException
{
public function __construct(public readonly ReleaseStatus $status)
{
parent::__construct(sprintf(
'%s is %s for LIT MCP – write refused',
$status->versionLabel(),
$status->state->value
));
}
public function toError(): WP_Error
{
return $this->status->refusal();
}
/** The lock anywhere in $e's chain of previous exceptions (callers wrap failures in WriteFailed). */
public static function in(Throwable $e): ?self
{
for ($cause = $e; $cause !== null; $cause = $cause->getPrevious()) {
if ($cause instanceof self) {
return $cause;
}
}
return null;
}
}
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use RuntimeException;
defined('ABSPATH') || exit;
/**
* The expected fingerprints of the mirrors (fingerprints.json, shipped with the plugin) and their reference sources
* (tests/Fixtures/divi/reference/<id>.txt, development only – not in the plugin package). Written by
* wp lit-mcp divi-check --update-reference.
*/
final class DiviReference
{
public const FILE = __DIR__ . '/fingerprints.json';
/** @return array<string, string> Mirror ID => sha256; [] when the file is missing or broken. */
public static function expected(?string $file = null): array
{
$file ??= self::FILE;
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local plugin file.
$raw = is_file($file) && is_readable($file) ? file_get_contents($file) : false;
$data = is_string($raw) ? json_decode($raw, true) : null;
if (! is_array($data)) {
return [];
}
$expected = [];
foreach ($data as $id => $hash) {
if (is_string($id) && is_string($hash)) {
$expected[$id] = $hash;
}
}
ksort($expected);
return $expected;
}
/**
* @param array<string, string> $hashes Mirror ID => sha256.
* @throws RuntimeException
*/
public static function writeExpected(array $hashes, ?string $file = null): void
{
ksort($hashes);
// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP, no WordPress.
$json = json_encode($hashes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- dev fixture.
if (! is_string($json) || file_put_contents($file ?? self::FILE, $json . "\n") === false) {
throw new RuntimeException('fingerprints.json not written');
}
}
/** tests/Fixtures/divi/reference of a development checkout; null in an installed plugin. */
public static function sourceDir(): ?string
{
$dir = dirname(__DIR__, 4) . '/tests/Fixtures/divi/reference';
return is_dir($dir) ? $dir : null;
}
public static function readSource(string $id, string $dir): ?string
{
$file = $dir . '/' . $id . '.txt';
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local dev fixture.
$source = is_file($file) ? file_get_contents($file) : false;
return is_string($source) ? $source : null;
}
/** @throws RuntimeException */
public static function writeSource(string $id, string $source, string $dir): void
{
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- dev fixture.
if (file_put_contents($dir . '/' . $id . '.txt', $source . "\n") === false) {
throw new RuntimeException(sprintf('reference source %s not written', $id));
}
}
/**
* Fingerprints of every mirror in the installed Divi.
*
* @return array<string, SourceFingerprint>
* @throws RuntimeException When a mirrored function cannot be read.
*/
public static function current(string $themeDir): array
{
$prints = [];
foreach (DiviAdapters::mirrors() as $mirror) {
$file = $themeDir . '/' . $mirror->file;
$prints[$mirror->id] = SourceFingerprint::of($file, $mirror->function, $mirror->class);
}
return $prints;
}
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/**
* Divi versions checked with this plugin version (spec §5.3, source "code"): add a version after
* wp lit-mcp divi-check ran green against it. Any other version needs a release in the Divi tab of each site.
*/
final class DiviReleases
{
public const TESTED = ['5.2.1'];
}
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;
use WP_Error;
defined('ABSPATH') || exit;
/**
* The central release lock of the Divi tools (spec §5.5): a writing tool of the groups Divi, Divi-Bibliothek or Divi
* Theme Builder is refused before it runs while Divi is not released (logged with result "refused"); a reading tool
* runs and gets the notice in warnings. Other tools that reach Divi (lit/content-create with divi) meet the adapter
* lock (ReleaseGate::assertWritable()).
*/
final class DiviToolGate
{
public const GROUPS = ['divi', LibraryAccess::GROUP, ThemeBuilderAccess::GROUP];
/** @var Closure(): array<string, string> */
private Closure $groups;
/**
* @param Closure(): array<string, string> $groups Ability name => integration ID (IntegrationRegistry::groupMap).
*/
public function __construct(Closure $groups, private ?ChangeLog $log = null)
{
$this->groups = $groups;
}
public function covers(Ability $ability): bool
{
return in_array(($this->groups)()[$ability->name()] ?? '', self::GROUPS, true);
}
public function refusal(Ability $ability): ?WP_Error
{
if (! $this->covers($ability) || self::readsOnly($ability)) {
return null;
}
$status = ReleaseGate::status();
if ($status->writable()) {
return null;
}
$this->log?->record(
$ability->name(),
'divi',
$status->version,
sprintf(
'Abgelehnt: %s ist %s.',
$status->versionLabel(),
$status->state === ReleaseState::Incompatible ? 'inkompatibel' : 'nicht freigegeben'
),
result: 'refused'
);
return $status->refusal();
}
public function annotate(Ability $ability, mixed $result): mixed
{
if (! is_array($result) || array_is_list($result) || ! $this->covers($ability) || ! self::readsOnly($ability)) {
return $result;
}
$status = ReleaseGate::status();
if ($status->writable()) {
return $result;
}
$warnings = is_array($result['warnings'] ?? null) ? array_values($result['warnings']) : [];
$warnings[] = $status->notice();
$result['warnings'] = $warnings;
return $result;
}
/**
* @param array<string, mixed>|null $schema
* @return array<string, mixed>|null
*/
public function outputSchema(Ability $ability, ?array $schema): ?array
{
if ($schema === null || ($schema['type'] ?? null) !== 'object' || ! $this->covers($ability)) {
return $schema;
}
$properties = is_array($schema['properties'] ?? null) ? $schema['properties'] : [];
$properties['warnings'] ??= ['type' => 'array', 'items' => ['type' => 'string']];
$schema['properties'] = $properties;
return $schema;
}
private static function readsOnly(Ability $ability): bool
{
return ($ability->annotations()['readonly'] ?? false) === true;
}
}
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/**
* Finds where PHP code touches Divi (DiviBoundaryTest, spec §3): calls of et_* functions, ET\… / ET_Core… /
* ET_Builder… classes (also as strings), ET_* constants (also as strings) and the strings 'et_…'/'_et_…' (post
* types, meta keys, options, function names for function_exists()). Comments are ignored; method calls named
* et_* ($x->et_a(), Foo::et_b()) are not Divi's.
*/
final class DiviTouches
{
private const IGNORED = [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT];
private const NOT_A_CALL = [
T_OBJECT_OPERATOR, T_NULLSAFE_OBJECT_OPERATOR, T_DOUBLE_COLON, T_FUNCTION, T_NEW, T_CONST,
];
/** @return list<array{kind: string, name: string, line: int}> */
public static function inFile(string $path): array
{
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local PHP source, not a remote request.
$source = file_get_contents($path);
return is_string($source) ? self::inSource($source) : [];
}
/** @return list<array{kind: string, name: string, line: int}> */
public static function inSource(string $source): array
{
$tokens = array_values(array_filter(
token_get_all($source),
static fn (mixed $token): bool => ! is_array($token) || ! in_array($token[0], self::IGNORED, true)
));
$touches = [];
foreach ($tokens as $i => $token) {
if (! is_array($token)) {
continue;
}
$touch = match ($token[0]) {
T_STRING, T_NAME_FULLY_QUALIFIED => self::name($tokens, $i, ltrim($token[1], '\\')),
T_NAME_QUALIFIED => str_starts_with($token[1], 'ET\\') ? ['class', $token[1]] : null,
T_CONSTANT_ENCAPSED_STRING => self::literal($token[1]),
default => null,
};
if ($touch !== null) {
$touches[] = ['kind' => $touch[0], 'name' => $touch[1], 'line' => (int) $token[2]];
}
}
return $touches;
}
/** Whether inSource() reports $name when the code uses it (DiviBoundaryTest's unused-entry check). */
public static function detects(string $name): bool
{
return self::classify($name) !== null || preg_match('/^_?et_[a-z0-9_]*$/', $name) === 1;
}
/**
* @param array<int, mixed> $tokens
* @return array{0: string, 1: string}|null
*/
private static function name(array $tokens, int $i, string $name): ?array
{
$classified = self::classify($name);
if ($classified !== null) {
return $classified;
}
if (preg_match('/^et_[a-z0-9_]*$/', $name) !== 1 || ($tokens[$i + 1] ?? null) !== '(') {
return null;
}
$previous = $tokens[$i - 1] ?? null;
return is_array($previous) && in_array($previous[0], self::NOT_A_CALL, true) ? null : ['function', $name];
}
/** @return array{0: string, 1: string}|null */
private static function literal(string $text): ?array
{
$value = substr($text, 1, -1);
if ($text[0] === "'") {
$value = str_replace(['\\\\', "\\'"], ['\\', "'"], $value);
}
$value = ltrim($value, '\\');
$classified = self::classify($value);
if ($classified !== null) {
return $classified;
}
return preg_match('/^_?et_[a-z0-9_]*$/', $value) === 1 ? ['string', $value] : null;
}
/** @return array{0: string, 1: string}|null A Divi class or constant name. */
private static function classify(string $name): ?array
{
if (
preg_match('/^ET\\\\[A-Za-z0-9_\\\\]+$/', $name) === 1
|| preg_match('/^ET_(?:Core|Builder)[A-Za-z0-9_]*$/', $name) === 1
) {
return ['class', $name];
}
return preg_match('/^ET_[A-Z0-9_]+$/', $name) === 1 ? ['constant', $name] : null;
}
}
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
defined('ABSPATH') || exit;
/**
* Spec §6.2 step 3: a builder-made Divi 5 page (reference-page.html, from the converted fixture) parses and
* serializes byte-identically, and WordPress' parse_blocks() sees the same block names and nesting as the plugin's
* parser. Nothing is written. Not final so CompatibilityCheck can be tested with a mock.
*/
class FormatCheck
{
public const REFERENCE = __DIR__ . '/reference-page.html';
/** @param (Closure(string): array<int, mixed>)|null $wpBlocks null = parse_blocks(). */
public function __construct(private ?string $file = null, private ?Closure $wpBlocks = null)
{
}
/** @return list<string> */
public function problems(): array
{
$file = $this->file ?? self::REFERENCE;
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local plugin file.
$content = is_file($file) ? file_get_contents($file) : false;
if (! is_string($content) || $content === '') {
return ['Der Referenzinhalt der Prüfung fehlt im Plugin.'];
}
$document = (new Parser())->parse($content);
$problems = [];
if (Serializer::document($document) !== $content) {
$problems[] = 'Parser und Serializer geben den Referenzinhalt nicht byte-gleich zurück.';
}
$wordPress = ($this->wpBlocks ?? static fn (string $markup): array => parse_blocks($markup))($content);
if (self::structure($document->children()) !== self::wpStructure($wordPress)) {
$problems[] = 'WordPress liest eine andere Blockstruktur als LIT MCP.';
}
return $problems;
}
/**
* @param list<Block> $blocks
* @return list<array{0: string, 1: list<mixed>}>
*/
private static function structure(array $blocks): array
{
return array_map(
static fn (Block $block): array => [$block->name(), self::structure($block->children())],
$blocks
);
}
/**
* @param array<int, mixed> $blocks parse_blocks() result; freeform parts (blockName null) are left out.
* @return list<array{0: string, 1: list<mixed>}>
*/
private static function wpStructure(array $blocks): array
{
$structure = [];
foreach ($blocks as $block) {
if (is_array($block) && is_string($block['blockName'] ?? null)) {
$inner = is_array($block['innerBlocks'] ?? null) ? $block['innerBlocks'] : [];
$structure[] = [$block['blockName'], self::wpStructure($inner)];
}
}
return $structure;
}
}
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/** A small line diff (longest common subsequence) for wp lit-mcp divi-check --diff. */
final class LineDiff
{
/**
* @param list<string> $old
* @param list<string> $new
* @return list<string> " kept", "- removed", "+ added"; "…" between changes more than 2 × $context lines apart.
*/
public static function lines(array $old, array $new, int $context = 2): array
{
$operations = self::operations($old, $new);
$keep = [];
foreach ($operations as $i => [$op]) {
if ($op === ' ') {
continue;
}
for ($j = max(0, $i - $context); $j <= min(count($operations) - 1, $i + $context); ++$j) {
$keep[$j] = true;
}
}
$lines = [];
$last = null;
foreach ($operations as $i => [$op, $line]) {
if (! isset($keep[$i])) {
continue;
}
if ($last !== null && $i > $last + 1) {
$lines[] = '…';
}
$lines[] = $op . ' ' . $line;
$last = $i;
}
return $lines;
}
/**
* @param list<string> $a
* @param list<string> $b
* @return list<array{0: string, 1: string}>
*/
private static function operations(array $a, array $b): array
{
$n = count($a);
$m = count($b);
$lcs = array_fill(0, $n + 1, array_fill(0, $m + 1, 0));
for ($i = $n - 1; $i >= 0; --$i) {
for ($j = $m - 1; $j >= 0; --$j) {
$lcs[$i][$j] = $a[$i] === $b[$j] ? $lcs[$i + 1][$j + 1] + 1 : max($lcs[$i + 1][$j], $lcs[$i][$j + 1]);
}
}
$operations = [];
$i = 0;
$j = 0;
while ($i < $n && $j < $m) {
if ($a[$i] === $b[$j]) {
$operations[] = [' ', $a[$i]];
++$i;
++$j;
} elseif ($lcs[$i + 1][$j] >= $lcs[$i][$j + 1]) {
$operations[] = ['-', $a[$i++]];
} else {
$operations[] = ['+', $b[$j++]];
}
}
while ($i < $n) {
$operations[] = ['-', $a[$i++]];
}
while ($j < $m) {
$operations[] = ['+', $b[$j++]];
}
return $operations;
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/**
* The Divi dependencies of one adapter (spec §4.1) – the single source for DiviCompatibility, the release gate,
* the admin check and DiviBoundaryTest. Pure data, no WordPress calls.
*/
final class Manifest
{
public const PAGES = 'Seiten';
public const LIBRARY = 'Bibliothek';
public const THEME_BUILDER = 'Theme Builder';
public const DESIGN = 'Designsystem';
public const CATALOG = 'Katalog';
/**
* @param string $area One of the constants above (the five areas of spec §3).
* @param list<Call> $calls Divi functions and static methods the adapter calls.
* @param list<string> $constants Divi constants it reads.
* @param list<string> $postTypes Divi post types it needs registered.
* @param list<string> $taxonomies Divi taxonomies it needs registered.
* @param list<string> $blockTypes Divi block types it needs (registered or in Divi's module metadata).
* @param list<string> $keys Other Divi names it uses as strings: meta keys, options, internal post
* types that need no registration check, query arguments.
* @param list<Mirror> $mirrors Divi functions whose storage logic it repeats.
*/
public function __construct(
public readonly string $area,
public readonly array $calls = [],
public readonly array $constants = [],
public readonly array $postTypes = [],
public readonly array $taxonomies = [],
public readonly array $blockTypes = [],
public readonly array $keys = [],
public readonly array $mirrors = []
) {
}
/**
* @return list<string> Every Divi name the adapter's code may contain (DiviBoundaryTest): called functions and
* classes, constants, post types, taxonomies, keys and the names of mirrored functions.
*/
public function names(): array
{
$names = [];
foreach ($this->calls as $call) {
$names[] = $call->className() ?? $call->member();
}
$mirrored = array_map(static fn (Mirror $mirror): string => $mirror->function, $this->mirrors);
return array_values(array_unique(array_merge(
$names,
$this->constants,
$this->postTypes,
$this->taxonomies,
$this->keys,
$mirrored
)));
}
}
@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use ReflectionFunction;
use ReflectionMethod;
use Throwable;
defined('ABSPATH') || exit;
/**
* The checks of all adapter manifests (spec §4.3), as label => check. Code checks (calls with the argument count
* the adapter passes, constants) and mirror checks decide the release gate; registration checks (post types,
* taxonomies, block types) depend on the request context (stage3 finding 6) and only show in reports.
*
* Not final so the resolver can be tested with a mock.
*/
class ManifestCheck
{
/** @var array<string, Manifest> */
private array $manifests;
/** @var Closure(Mirror): ?string */
private Closure $current;
/** @var Closure(string): bool */
private Closure $knowsBlock;
/**
* @param array<string, Manifest>|null $manifests null = DiviAdapters::manifests().
* @param array<string, string>|null $expected Mirror ID => sha256; null = DiviReference::expected().
* @param (Closure(Mirror): ?string)|null $current Hash of the installed mirror, null = unreadable;
* null = SourceFingerprint in the Divi theme directory.
* @param (Closure(string): bool)|null $knowsBlock null = DiviModules::knowsBlock().
*/
public function __construct(
?array $manifests = null,
private ?array $expected = null,
?Closure $current = null,
?Closure $knowsBlock = null
) {
$this->manifests = $manifests ?? DiviAdapters::manifests();
$this->current = $current ?? self::installed(...);
$this->knowsBlock = $knowsBlock ?? DiviModules::knowsBlock(...);
}
/** @return array<string, Closure(): bool> */
public function codeChecks(): array
{
$checks = [];
foreach ($this->manifests as $manifest) {
foreach ($manifest->calls as $call) {
$checks[$manifest->area . ': ' . $call->label()] = static fn (): bool => self::accepts($call);
}
foreach ($manifest->constants as $constant) {
$checks[$manifest->area . ': Konstante ' . $constant] = static fn (): bool => defined($constant);
}
}
return $checks;
}
/** @return array<string, Closure(): bool> */
public function mirrorChecks(): array
{
$checks = [];
foreach ($this->manifests as $manifest) {
foreach ($manifest->mirrors as $mirror) {
$label = sprintf('%s: Nachbau von %s – %s', $manifest->area, $mirror->label(), $mirror->what);
$checks[$label] = function () use ($mirror): bool {
$expected = ($this->expected ??= DiviReference::expected())[$mirror->id] ?? null;
return $expected !== null && ($this->current)($mirror) === $expected;
};
}
}
return $checks;
}
/** @return array<string, Closure(): bool> */
public function registrationChecks(): array
{
$checks = [];
foreach ($this->manifests as $manifest) {
foreach ($manifest->postTypes as $type) {
$checks[$manifest->area . ': Post-Typ ' . $type] = static fn (): bool => post_type_exists($type);
}
foreach ($manifest->taxonomies as $taxonomy) {
$checks[$manifest->area . ': Taxonomie ' . $taxonomy] =
static fn (): bool => taxonomy_exists($taxonomy);
}
foreach ($manifest->blockTypes as $block) {
$checks[$manifest->area . ': Block-Typ ' . $block] = fn (): bool => ($this->knowsBlock)($block);
}
}
return $checks;
}
/**
* @param array<string, callable(): bool> $checks
* @return list<string> Labels of the checks that failed or threw.
*/
public static function failed(array $checks): array
{
$failed = [];
foreach ($checks as $label => $check) {
try {
$ok = (bool) $check();
} catch (Throwable) {
$ok = false;
}
if (! $ok) {
$failed[] = (string) $label;
}
}
return $failed;
}
/** The function or method exists and takes the number of arguments the adapter passes. */
public static function accepts(Call $call): bool
{
$class = $call->className();
if ($class === null) {
if (! function_exists($call->member())) {
return false;
}
$reflection = new ReflectionFunction($call->member());
} else {
if (! method_exists($class, $call->member())) {
return false;
}
$reflection = new ReflectionMethod($class, $call->member());
}
return $call->args === null
|| ($reflection->getNumberOfRequiredParameters() <= $call->args
&& ($reflection->isVariadic() || $reflection->getNumberOfParameters() >= $call->args));
}
private static function installed(Mirror $mirror): ?string
{
$dir = DiviEnvironment::themeDir();
return $dir === null
? null
: SourceFingerprint::of($dir . '/' . $mirror->file, $mirror->function, $mirror->class)->hash;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/**
* A Divi function whose storage logic an adapter repeats instead of calling it (spec §4.1 "mirrors"). Its
* fingerprint (SourceFingerprint) must match the one in fingerprints.json, else Divi changed what we copy.
*/
final class Mirror
{
/**
* @param string $id Key in fingerprints.json and name of tests/Fixtures/divi/reference/<id>.txt.
* @param string $file Divi file, relative to the Divi theme directory.
* @param string $function Function or method name.
* @param string|null $class Class of a method – the short name is enough (the file fixes where it
* lives, and a short name is no Divi name for DiviBoundaryTest); null for a function.
* @param string $what What the plugin repeats (German, for reports).
*/
public function __construct(
public readonly string $id,
public readonly string $file,
public readonly string $function,
public readonly ?string $class,
public readonly string $what
) {
}
public function label(): string
{
$class = $this->class === null ? '' : SourceFingerprint::shortName($this->class) . '::';
return $class . $this->function . '()';
}
}
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Log;
use Throwable;
defined('ABSPATH') || exit;
/**
* The release status of this request and the lock every writing Divi path asks (spec §5.5). whileProbing() lets the
* admin check write its throwaway page whatever the status – only that post, only during the call, and only from
* code (there is no parameter or option for it).
*/
final class ReleaseGate
{
private static ?ReleaseStatus $status = null;
/** @var (Closure(): ReleaseStatus)|null */
private static ?Closure $resolver = null;
private static ?int $probe = null;
/** Spec §9: a status that cannot be determined locks writes like an incompatible Divi (and is logged). */
public static function status(): ReleaseStatus
{
if (self::$status !== null) {
return self::$status;
}
try {
self::$status = (self::$resolver ?? static fn (): ReleaseStatus => (new ReleaseResolver())->resolve())();
} catch (Throwable $e) {
Log::failure('divi release status', $e);
self::$status = new ReleaseStatus(
ReleaseState::Incompatible,
'',
null,
['Der Freigabe-Zustand ließ sich nicht ermitteln (Details im PHP-Error-Log).']
);
}
return self::$status;
}
/** @param (Closure(): ReleaseStatus)|null $resolver null = ReleaseResolver; tests set their own. */
public static function useResolver(?Closure $resolver): void
{
self::$resolver = $resolver;
self::$status = null;
self::$probe = null;
}
/** The next status() computes again (after a check, a release or a withdrawal). */
public static function forget(): void
{
self::$status = null;
}
/** @throws DiviLocked */
public static function assertWritable(?int $postId = null): void
{
if (self::$probe !== null && $postId === self::$probe) {
return;
}
$status = self::status();
if (! $status->writable()) {
throw new DiviLocked($status);
}
}
public static function whileProbing(int $postId, callable $write): mixed
{
$previous = self::$probe;
self::$probe = $postId;
try {
return $write();
} finally {
self::$probe = $previous;
}
}
}
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Plugin;
defined('ABSPATH') || exit;
/**
* Computes the release status (spec §5.2–§5.4): code checks live (cheap), mirror checks cached in a transient under
* stateKey() – Divi version, plugin version, fingerprints.json and the modification times of the mirrored Divi
* files, so a Divi update or hotfix is checked again at once.
*/
final class ReleaseResolver
{
public const CACHE = 'lit_mcp_divi_state';
/**
* @param (Closure(): ?string)|null $version null = DiviEnvironment::version().
* @param (Closure(string): string)|null $stateKey null = self::stateKey().
*/
public function __construct(
private ?ManifestCheck $check = null,
private ?ReleaseStore $store = null,
private ?Closure $version = null,
private ?Closure $stateKey = null
) {
}
public function resolve(): ReleaseStatus
{
$version = ($this->version ?? DiviEnvironment::version(...))() ?? '';
$check = $this->check ?? new ManifestCheck();
$problems = array_merge(
ManifestCheck::failed($check->codeChecks()),
$this->mirrorProblems($check, $version)
);
if ($problems !== []) {
return new ReleaseStatus(ReleaseState::Incompatible, $version, null, $problems);
}
if (in_array($version, DiviReleases::TESTED, true)) {
return ReleaseStatus::released($version);
}
$entry = ($this->store ?? new ReleaseStore())->find($version, Plugin::VERSION);
return $entry !== null
? new ReleaseStatus(ReleaseState::Released, $version, ReleaseStatus::SOURCE_DATABASE, [], $entry)
: new ReleaseStatus(ReleaseState::Untested, $version);
}
/** What the cached mirror results and a check report are valid for. */
public static function stateKey(string $version, ?string $themeDir = null): string
{
$themeDir ??= DiviEnvironment::themeDir();
$parts = [
$version,
Plugin::VERSION,
is_readable(DiviReference::FILE) ? (string) md5_file(DiviReference::FILE) : '',
];
foreach (DiviAdapters::mirrors() as $mirror) {
$file = $themeDir === null ? '' : $themeDir . '/' . $mirror->file;
$parts[] = $mirror->file . '@' . ($file !== '' && is_file($file) ? (string) filemtime($file) : '-');
}
return hash('sha256', implode('|', $parts));
}
/** @return list<string> */
private function mirrorProblems(ManifestCheck $check, string $version): array
{
$key = ($this->stateKey ?? self::stateKey(...))($version);
$cached = get_transient(self::CACHE);
if (is_array($cached) && ($cached['key'] ?? null) === $key && is_array($cached['problems'] ?? null)) {
return array_values(array_filter($cached['problems'], 'is_string'));
}
$problems = ManifestCheck::failed($check->mirrorChecks());
set_transient(self::CACHE, ['key' => $key, 'problems' => $problems], DAY_IN_SECONDS);
return $problems;
}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/** Spec §5.2. */
enum ReleaseState: string
{
case Released = 'released';
case Untested = 'untested';
case Incompatible = 'incompatible';
}
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use LIT\MCP\Abilities\Errors;
use LogicException;
use WP_Error;
defined('ABSPATH') || exit;
/** Whether LIT MCP may write with the installed Divi (spec §5.2), with the texts of spec §5.6. */
final class ReleaseStatus
{
public const SOURCE_CODE = 'code';
public const SOURCE_DATABASE = 'database';
private const UNTESTED = 'Achtung: %s ist auf dieser Website noch nicht für LIT MCP freigegeben. Schreibende '
. 'Divi-Werkzeuge sind gesperrt, bis ein Administrator unter Einstellungen → LIT MCP → Divi die Prüfung '
. 'ausführt und die Version freigibt. Es wurde nichts geändert.';
private const UNTESTED_HINT = 'Den Nutzer bitten, die Divi-Prüfung auszuführen und die Version freizugeben; '
. 'lesende Divi-Werkzeuge funktionieren weiter.';
private const INCOMPATIBLE = '%s hat Stellen geändert, die LIT MCP nutzt. Schreibende Divi-Werkzeuge sind '
. 'gesperrt, bis LIT MCP aktualisiert wurde. Es wurde nichts geändert.';
private const INCOMPATIBLE_HINT = 'Den Nutzer informieren, dass LIT MCP aktualisiert werden muss; lesende '
. 'Divi-Werkzeuge funktionieren weiter.';
private const NOTICE = 'Achtung: %s ist für LIT MCP %s; Lesen funktioniert, Schreiben ist gesperrt.';
/**
* @param string $version Divi version, '' when unknown (Divi is not the active template).
* @param string|null $source SOURCE_CODE or SOURCE_DATABASE for a released version.
* @param list<string> $problems Failed code and mirror checks (ManifestCheck labels).
* @param array<string, mixed>|null $release The database entry of a SOURCE_DATABASE release.
*/
public function __construct(
public readonly ReleaseState $state,
public readonly string $version,
public readonly ?string $source = null,
public readonly array $problems = [],
public readonly ?array $release = null
) {
}
public static function released(string $version, string $source = self::SOURCE_CODE): self
{
return new self(ReleaseState::Released, $version, $source);
}
public function writable(): bool
{
return $this->state === ReleaseState::Released;
}
public function versionLabel(): string
{
return $this->version === '' ? 'Divi (Version unbekannt)' : 'Divi ' . $this->version;
}
/** The error of a refused write. @throws LogicException For a released status. */
public function refusal(): WP_Error
{
return match ($this->state) {
ReleaseState::Untested => Errors::make(
'divi_untested',
sprintf(self::UNTESTED, $this->versionLabel()),
self::UNTESTED_HINT,
423
),
ReleaseState::Incompatible => Errors::make(
'divi_incompatible',
sprintf(self::INCOMPATIBLE, $this->versionLabel()),
self::INCOMPATIBLE_HINT,
423,
['problems' => $this->problems]
),
ReleaseState::Released => throw new LogicException('a released Divi refuses nothing'),
};
}
/** The warning a reading Divi tool carries while writes are locked. */
public function notice(): string
{
return sprintf(
self::NOTICE,
$this->versionLabel(),
$this->state === ReleaseState::Incompatible ? 'inkompatibel' : 'ungetestet'
);
}
/**
* @return array{state: string, version: string, source: ?string, released_at: ?string, released_by: ?int,
* problems: list<string>}
*/
public function toArray(): array
{
return [
'state' => $this->state->value,
'version' => $this->version,
'source' => $this->source,
'released_at' => is_string($this->release['released_at'] ?? null)
? $this->release['released_at']
: null,
'released_by' => is_int($this->release['user_id'] ?? null) ? $this->release['user_id'] : null,
'problems' => $this->problems,
];
}
}
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/**
* Divi versions released on this site after a green admin check (spec §5.3, source "database"). An entry holds for
* the plugin version that ran the check only.
*
* Not final so the resolver can be tested with a mock.
*/
class ReleaseStore
{
public const OPTION = 'lit_mcp_divi_releases';
/**
* @return list<array{divi_version: string, plugin_version: string, user_id: int, released_at: string,
* report_id: string}>
*/
public function entries(): array
{
$raw = get_option(self::OPTION, []);
$entries = [];
foreach (is_array($raw) ? $raw : [] as $row) {
if (
is_array($row)
&& is_string($row['divi_version'] ?? null)
&& is_string($row['plugin_version'] ?? null)
&& is_int($row['user_id'] ?? null)
&& is_string($row['released_at'] ?? null)
&& is_string($row['report_id'] ?? null)
) {
$entries[] = [
'divi_version' => $row['divi_version'],
'plugin_version' => $row['plugin_version'],
'user_id' => $row['user_id'],
'released_at' => $row['released_at'],
'report_id' => $row['report_id'],
];
}
}
return $entries;
}
/**
* @return array{divi_version: string, plugin_version: string, user_id: int, released_at: string,
* report_id: string}|null
*/
public function find(string $diviVersion, string $pluginVersion): ?array
{
foreach ($this->entries() as $entry) {
if ($entry['divi_version'] === $diviVersion && $entry['plugin_version'] === $pluginVersion) {
return $entry;
}
}
return null;
}
public function add(string $diviVersion, string $pluginVersion, int $userId, string $reportId): void
{
$entries = $this->without($diviVersion, $pluginVersion);
$entries[] = [
'divi_version' => $diviVersion,
'plugin_version' => $pluginVersion,
'user_id' => $userId,
'released_at' => gmdate('c'),
'report_id' => $reportId,
];
update_option(self::OPTION, $entries, false);
}
public function withdraw(string $diviVersion, string $pluginVersion): bool
{
if ($this->find($diviVersion, $pluginVersion) === null) {
return false;
}
update_option(self::OPTION, $this->without($diviVersion, $pluginVersion), false);
return true;
}
/**
* @return list<array{divi_version: string, plugin_version: string, user_id: int, released_at: string,
* report_id: string}>
*/
private function without(string $diviVersion, string $pluginVersion): array
{
return array_values(array_filter(
$this->entries(),
static fn (array $entry): bool => $entry['divi_version'] !== $diviVersion
|| $entry['plugin_version'] !== $pluginVersion
));
}
}
@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use RuntimeException;
defined('ABSPATH') || exit;
/**
* Fingerprint of one Divi function or method (spec §4.2), read with token_get_all(): the file is never included,
* so functions Divi loads only in some requests (theme-builder/api.php, admin-ajax only) are readable in every
* request. The hash covers the tokens from "function" to the closing brace without comments and whitespace –
* new comments or reformatting keep it, any code change changes it. $source is the verbatim text of those
* tokens for the human diff (wp lit-mcp divi-check --diff).
*/
final class SourceFingerprint
{
private const IGNORED = [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT];
private const TYPES = [T_CLASS, T_TRAIT, T_INTERFACE, T_ENUM];
private function __construct(
public readonly string $hash,
public readonly string $source,
public readonly int $startLine,
public readonly int $endLine
) {
}
/** @throws RuntimeException When the file is unreadable or holds no such function. */
public static function of(string $file, string $function, ?string $class = null): self
{
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading local PHP file, not remote content.
$source = is_file($file) && is_readable($file) ? file_get_contents($file) : false;
if (! is_string($source)) {
throw new RuntimeException(sprintf('%s is not readable', $file));
}
return self::fromSource($source, $function, $class) ?? throw new RuntimeException(sprintf(
'%s not found in %s',
$class === null ? $function . '()' : self::shortName($class) . '::' . $function . '()',
$file
));
}
/**
* The function $function outside every class body, or – with $class – the method $function declared directly
* in the class (trait, interface, enum) named like $class's last segment. The namespace is not compared: the
* file path of the manifest fixes where to look.
*/
public static function fromSource(string $source, string $function, ?string $class = null): ?self
{
$tokens = token_get_all($source);
$wanted = $class === null ? null : self::shortName($class);
$depth = 0;
/** @var list<array{0: int, 1: string}> $bodies Open class bodies: [brace depth, name]. */
$bodies = [];
$pending = null;
foreach ($tokens as $i => $token) {
$id = is_array($token) ? $token[0] : null;
$text = is_array($token) ? $token[1] : $token;
if ($id !== null && in_array($id, self::TYPES, true) && self::previousId($tokens, $i) !== T_DOUBLE_COLON) {
$pending = self::nextName($tokens, $i) ?? '';
continue;
}
if ($text === '{' || $id === T_CURLY_OPEN || $id === T_DOLLAR_OPEN_CURLY_BRACES) {
++$depth;
if ($pending !== null && $text === '{') {
$bodies[] = [$depth, $pending];
$pending = null;
}
continue;
}
if ($text === '}') {
if ($bodies !== [] && $bodies[count($bodies) - 1][0] === $depth) {
array_pop($bodies);
}
--$depth;
continue;
}
if ($id !== T_FUNCTION || self::previousId($tokens, $i) === T_USE) {
continue;
}
$name = self::nextName($tokens, $i);
if ($name === null || strcasecmp($name, $function) !== 0) {
continue;
}
$body = $bodies === [] ? null : $bodies[count($bodies) - 1];
$matches = $wanted === null
? $body === null
: $body !== null && $body[0] === $depth && strcasecmp($body[1], $wanted) === 0;
if ($matches) {
return self::capture($tokens, $i);
}
}
return null;
}
/** @param array<int, mixed> $tokens */
private static function capture(array $tokens, int $start): ?self
{
$startLine = (int) $tokens[$start][2];
$parts = [];
$verbatim = '';
$depth = 0;
$opened = false;
$count = count($tokens);
for ($i = $start; $i < $count; ++$i) {
$token = $tokens[$i];
$id = is_array($token) ? $token[0] : null;
$text = is_array($token) ? $token[1] : $token;
$verbatim .= $text;
if ($id === null || ! in_array($id, self::IGNORED, true)) {
$parts[] = $text;
}
if ($text === '{' || $id === T_CURLY_OPEN || $id === T_DOLLAR_OPEN_CURLY_BRACES) {
++$depth;
$opened = true;
} elseif ($text === '}') {
--$depth;
if ($opened && $depth === 0) {
return new self(
hash('sha256', implode(' ', $parts)),
$verbatim,
$startLine,
$startLine + substr_count($verbatim, "\n")
);
}
} elseif ($text === ';' && ! $opened) {
return null;
}
}
return null;
}
/** The next name after $i (skipping whitespace, comments and "&"), null when something else follows. */
private static function nextName(array $tokens, int $i): ?string
{
$count = count($tokens);
for ($j = $i + 1; $j < $count; ++$j) {
$token = $tokens[$j];
if (is_array($token) && in_array($token[0], self::IGNORED, true)) {
continue;
}
if ($token === '&' || (is_array($token) && $token[1] === '&')) {
continue;
}
return is_array($token) && $token[0] === T_STRING ? $token[1] : null;
}
return null;
}
/** Token ID of the previous significant token, null for a single-character token or none. */
private static function previousId(array $tokens, int $i): ?int
{
for ($j = $i - 1; $j >= 0; --$j) {
$token = $tokens[$j];
if (is_array($token) && in_array($token[0], self::IGNORED, true)) {
continue;
}
return is_array($token) ? $token[0] : null;
}
return null;
}
public static function shortName(string $class): string
{
$position = strrpos($class, '\\');
return $position === false ? $class : substr($class, $position + 1);
}
}
@@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Pages\DiviPages;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Integrations\WordPress\Render\PageRenderer;
use Throwable;
use WP_Error;
use WP_Post;
defined('ABSPATH') || exit;
/**
* Spec §6.2 step 4: a throwaway draft page written through the normal Divi write path (ContentComposer → Composer
* → Serializer → DiviSaver, with the release lock opened for this page only), checked (content, builder flags,
* Divi renders every module without a PHP warning from Divi's files) and always deleted for good – in finally,
* not to the trash, with its revisions and meta. Pages a dead check left behind carry MARKER; removeOrphans()
* deletes them. No change log entry. Not final so CompatibilityCheck can be tested with a mock.
*/
class WriteProbe
{
public const MARKER = '_lit_mcp_check_page';
public const TITLE = '[LIT MCP Prüfung]';
/** Everyday modules without media, each with a text to find in the rendered page. */
public const SECTIONS = [[
'type' => 'section',
'label' => 'LIT MCP Prüfung',
'rows' => [
[
'columns' => '1_2,1_2',
'modules' => [
[
['type' => 'heading', 'text' => 'litcheck-heading', 'level' => 'h2'],
['type' => 'text', 'content' => '<p>litcheck-text</p>'],
['type' => 'button', 'text' => 'litcheck-button', 'url' => '/'],
],
[
['type' => 'blurb', 'title' => 'litcheck-blurb', 'content' => '<p>litcheck-blurb-text</p>'],
[
'type' => 'cta',
'title' => 'litcheck-cta',
'button_text' => 'litcheck-cta-button',
'button_url' => '/',
],
['type' => 'divider'],
],
],
],
[
'columns' => '4_4',
'modules' => [
['type' => 'toggle', 'title' => 'litcheck-toggle', 'content' => '<p>litcheck-toggle-text</p>'],
['type' => 'accordion', 'items' => [['title' => 'litcheck-accordion', 'content' => '<p>a</p>']]],
['type' => 'tabs', 'items' => [['title' => 'litcheck-tab', 'content' => '<p>t</p>']]],
['type' => 'icon-list', 'items' => [['text' => 'litcheck-icon-list']]],
],
],
],
]];
public const TEXTS = [
'litcheck-heading', 'litcheck-text', 'litcheck-button', 'litcheck-blurb', 'litcheck-cta', 'litcheck-toggle',
'litcheck-accordion', 'litcheck-tab', 'litcheck-icon-list',
];
/** Only pages older than this are orphans – a running check's page is younger. */
private const ORPHAN_AGE = '10 minutes ago';
/**
* @param string|null $diviDir PHP warnings from files below it fail the check; null = DiviEnvironment::themeDir().
*/
public function __construct(
private ContentComposer $composer,
private ?PageRenderer $renderer = null,
private ?string $diviDir = null
) {
}
/** @return list<string> German problems; [] = green. */
public function run(): array
{
$id = wp_insert_post([
'post_type' => 'page',
'post_status' => 'draft',
'post_title' => self::TITLE . ' ' . gmdate('Y-m-d H:i'),
'post_content' => '',
'meta_input' => [self::MARKER => '1'],
], true);
if ($id instanceof WP_Error || (int) $id <= 0) {
return ['Die Testseite konnte nicht angelegt werden: '
. ($id instanceof WP_Error ? $id->get_error_message() : 'keine ID')];
}
$id = (int) $id;
try {
$problems = $this->exercise($id);
} catch (Throwable $e) {
$problems = ['Schreibtest abgebrochen: ' . $e->getMessage()];
} finally {
if (! self::delete($id)) {
$problems[] = sprintf(
'Die Testseite %d konnte nicht gelöscht werden; bitte unter Seiten endgültig löschen.',
$id
);
}
}
return $problems;
}
/**
* @return list<int> IDs of check pages a dead check left behind, deleted now. Only drafts whose title starts
* with self::TITLE are considered – the marker meta alone is not enough (it is only a query hint here).
*/
public static function removeOrphans(): array
{
$posts = get_posts([
'post_type' => 'page',
'post_status' => 'draft',
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- a handful of marked pages at most.
'meta_key' => self::MARKER,
'date_query' => [['before' => self::ORPHAN_AGE, 'column' => 'post_date_gmt']],
'fields' => 'all',
'posts_per_page' => 20,
'no_found_rows' => true,
'suppress_filters' => true,
]);
$removed = [];
foreach (is_array($posts) ? $posts : [] as $post) {
if (! $post instanceof WP_Post || ! str_starts_with((string) $post->post_title, self::TITLE)) {
continue;
}
if (self::delete((int) $post->ID)) {
$removed[] = (int) $post->ID;
}
}
return $removed;
}
/** @return list<string> */
private function exercise(int $id): array
{
$written = ReleaseGate::whileProbing(
$id,
fn (): array|WP_Error => $this->composer->write($id, 'page', self::SECTIONS)
);
if ($written instanceof WP_Error) {
return ['Die Kurzform der Testseite wurde abgelehnt: ' . Errors::clientMessage($written)];
}
$post = get_post($id);
if (! $post instanceof WP_Post) {
return ['Die Testseite ist nach dem Schreiben nicht lesbar.'];
}
$problems = [];
if (! str_contains((string) $post->post_content, '<!-- wp:divi/')) {
$problems[] = 'Es wurde kein Divi-Inhalt gespeichert.';
}
foreach (DiviPages::FLAGS as $key => $value) {
if (get_post_meta($id, $key, true) !== $value) {
$problems[] = sprintf('Builder-Kennzeichen %s fehlt.', $key);
}
}
[$html, $warnings] = $this->render($post);
foreach (self::TEXTS as $text) {
if (! str_contains($html, $text)) {
$problems[] = sprintf('„%s“ fehlt in der gerenderten Seite.', $text);
}
}
foreach ($warnings as $warning) {
$problems[] = 'PHP-Warnung aus Divi beim Rendern: ' . $warning;
}
return $problems;
}
/** @return array{0: string, 1: list<string>} The rendered content and PHP warnings raised in Divi's files. */
private function render(WP_Post $post): array
{
$dir = $this->diviDir ?? DiviEnvironment::themeDir();
$warnings = [];
set_error_handler(
static function (
int $level,
string $message,
string $file = '',
int $line = 0
) use (
$dir,
&$warnings
): bool {
if ($dir !== null && str_starts_with($file, $dir)) {
$warnings[] = sprintf('%s (%s:%d)', $message, basename($file), $line);
}
return false;
},
E_WARNING | E_USER_WARNING
);
try {
$html = ($this->renderer ?? new PageRenderer())->render($post);
} finally {
restore_error_handler();
}
return [$html, $warnings];
}
private static function delete(int $id): bool
{
return wp_delete_post($id, true) instanceof WP_Post;
}
}
@@ -0,0 +1,7 @@
{
"library-submit-layout": "594b3843d4c42e1a91e5eda06f108c79583a0036e46c92dd749f4f541e362d02",
"tb-api-save": "7ba452f4d73c9b676281ca6ddcfddd669502965db868bf478ef7804937e5bfc4",
"tb-load-setting-options": "2ea0a6925a1da5522489a417104b384b2bd77a16b25f4122b9503873a4c8a24e",
"tb-store-template": "3bf4ac6028ed99ed8ac3523c659392a5fce7818ef8a80ea226e01f5404eb441d",
"vb-sync-flags": "f8e85421197b0f823219dad6081af7e47f9b4e388c45e162e9188f892766fde9"
}
File diff suppressed because one or more lines are too long
+68 -3
View File
@@ -5,17 +5,51 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Schema;
use ET\Builder\Packages\ModuleLibrary\ModuleRegistration;
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
defined('ABSPATH') || exit;
/**
* Reads Divi's module metadata once per request (Divi 5 only). Not final so abilities can be
* unit-tested with a mock.
* Adapter "Katalog" (spec §3): Divi's module metadata once per request (Divi 5 only), Divi's own post types and
* its per-type builder switch for the content tools, and whether Divi knows a block type. Not final so abilities
* can be unit-tested with a mock.
*/
class DiviModules
class DiviModules implements DiviAdapter
{
/** Divi's own post types the content tools leave alone (library, Theme Builder, canvases, speculation). */
public const INTERNAL_POST_TYPES = [
DiviLibraryStore::POST_TYPE,
DiviThemeBuilder::THEME_BUILDER,
DiviThemeBuilder::TEMPLATE,
DiviThemeBuilder::LAYOUT_TYPES['header'],
DiviThemeBuilder::LAYOUT_TYPES['body'],
DiviThemeBuilder::LAYOUT_TYPES['footer'],
'et_pb_canvas',
'_et_pb_speculation',
];
private ?ModuleCatalog $catalog = null;
public static function manifest(): Manifest
{
return new Manifest(
Manifest::CATALOG,
calls: [
new Call(ModuleRegistration::class . '::get_all_core_modules_metadata', 0),
new Call(ModuleRegistration::class . '::get_default_attrs', 2),
new Call('et_builder_enabled_for_post_type', 1),
],
constants: ['ET_BUILDER_PRODUCT_VERSION'],
blockTypes: [GlobalInstance::BLOCK],
keys: ['et_pb_canvas', '_et_pb_speculation']
);
}
public static function isAvailable(): bool
{
return class_exists(ModuleRegistration::class);
@@ -43,4 +77,35 @@ class DiviModules
{
return defined('ET_BUILDER_PRODUCT_VERSION') ? (string) ET_BUILDER_PRODUCT_VERSION : '5.2.1';
}
/**
* Whether Divi's builder is switched on for $postType (Divi → Theme-Optionen → Builder); false without Divi.
*/
public static function builderEnabledFor(string $postType): bool
{
return function_exists('et_builder_enabled_for_post_type')
&& (bool) et_builder_enabled_for_post_type($postType);
}
/**
* Whether Divi still has block type $name: registered with WordPress, or in Divi's core module metadata (Divi
* renders global instances itself and does not always register the type with WordPress).
*/
public static function knowsBlock(string $name): bool
{
if (class_exists('WP_Block_Type_Registry') && \WP_Block_Type_Registry::get_instance()->is_registered($name)) {
return true;
}
if (! method_exists(ModuleRegistration::class, 'get_all_core_modules_metadata')) {
return false;
}
foreach ((array) ModuleRegistration::get_all_core_modules_metadata() as $module) {
if (is_array($module) && ($module['name'] ?? null) === $name) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Schema;
defined('ABSPATH') || exit;
/**
* The curated short form against a module catalog (spec §6.2 step 2): every module exists, every field path is a
* known attribute path, every item type is an allowed child. Pure PHP; the admin check passes Divi's live catalog.
*/
final class ShortformCheck
{
/** @return list<string> German problems; [] = the short form matches. */
public static function problems(ModuleCatalog $catalog): array
{
$problems = [];
foreach (ShortformMap::types() as $type) {
$name = ModuleCatalog::normalize($type);
if (! $catalog->has($name)) {
$problems[] = $name . ': unbekanntes Modul';
continue;
}
foreach (ShortformMap::fields($type) as $field => $spec) {
if (! $catalog->isKnownPath($name, (string) $spec['path'])) {
$problems[] = sprintf(
'%s.%s: unbekannter Pfad %s (ähnlich: %s)',
$type,
$field,
$spec['path'],
implode(', ', $catalog->suggestions($name, (string) $spec['path']))
);
}
}
$child = ShortformMap::childType($type);
if ($child !== null && ! in_array(ModuleCatalog::normalize($child), $catalog->childrenOf($name), true)) {
$problems[] = sprintf(
'%s: %s ist kein erlaubtes Kind (%s)',
$type,
$child,
implode(', ', $catalog->childrenOf($name))
);
}
}
return $problems;
}
}
@@ -4,6 +4,12 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\Mirror;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use RuntimeException;
use Throwable;
use WP_Error;
@@ -11,26 +17,26 @@ use WP_Error;
defined('ABSPATH') || exit;
/**
* Adapter for Divi's Theme Builder (E7, E15; decisions note F14–F23; fixtures stage3/stage3b).
* Reading uses Divi's own functions (theme-builder.php:378-521). Writing:
* Adapter "Theme Builder" (spec §3; E7, E15; decisions note F14–F23; fixtures stage3/stage3b).
* Reading uses Divi's own functions (theme-builder.php). Writing:
* - layouts and new templates through et_theme_builder_insert_layout() and et_theme_builder_store_template()
* (theme-builder.php:1463-1667). Both wp_die() without edit_others_posts: the right is checked first,
* (theme-builder.php). Both wp_die() without edit_others_posts: the right is checked first,
* and while they run every wp_die handler throws instead (guarded(), defense in depth);
* - the live Theme Builder post's template list, conditions and the enabled flag are mirrored from
* et_theme_builder_api_save() (api.php:223-243; admin-ajax only, not loaded in REST requests, F20) and
* store_template (theme-builder.php:1545-1583) – store_template on an existing template would also
* rewrite its default flag, title and onboarding meta.
* et_theme_builder_api_save() (admin-ajax only, not loaded in REST requests, F20) and
* et_theme_builder_store_template() – see the mirrors in manifest(); store_template on an existing
* template would also rewrite its default flag, title and onboarding meta.
* Never touched: the Theme Builder draft (auto-draft) and et_theme_builder_trash_draft_and_unused_posts().
* Not final so abilities can be tested with a mock.
*/
class DiviThemeBuilder
class DiviThemeBuilder implements DiviAdapter
{
public const TEMPLATE = 'et_template';
/** Post type of the Theme Builder posts (live, draft, library) that list the templates. */
public const THEME_BUILDER = 'et_theme_builder';
/** Area => layout post type (theme-builder.php:7-23). */
/** Area => layout post type (theme-builder.php). */
public const LAYOUT_TYPES = [
'header' => 'et_header_layout',
'body' => 'et_body_layout',
@@ -44,18 +50,6 @@ class DiviThemeBuilder
public const UNUSED = '_et_theme_builder_marked_as_unused';
public const BACKUP_OPTION = 'et_tb_templates_backup_';
/** Divi functions this adapter calls; DiviCompatibility reports missing ones. */
public const FUNCTIONS = [
'et_theme_builder_get_theme_builder_post_id',
'et_theme_builder_get_theme_builder_template_ids',
'et_theme_builder_get_template',
'et_theme_builder_insert_layout',
'et_theme_builder_store_template',
'et_theme_builder_get_flat_template_settings_options',
'et_theme_builder_clear_wp_cache',
'et_',
];
private const DIE_FILTERS = [
'wp_die_handler', 'wp_die_ajax_handler', 'wp_die_json_handler', 'wp_die_jsonp_handler',
'wp_die_xml_handler', 'wp_die_xmlrpc_handler',
@@ -83,15 +77,66 @@ class DiviThemeBuilder
return is_string($area) ? $area : null;
}
/** @return list<string> */
public function missing(): array
public static function manifest(): Manifest
{
return array_values(array_filter(self::FUNCTIONS, static fn (string $name): bool => ! function_exists($name)));
$dir = 'includes/builder/frontend-builder/theme-builder/';
return new Manifest(
Manifest::THEME_BUILDER,
calls: [
new Call('et_theme_builder_get_theme_builder_post_id', 2),
new Call('et_theme_builder_get_theme_builder_template_ids', 2),
new Call('et_theme_builder_get_template', 1),
new Call('et_theme_builder_insert_layout', 1),
new Call('et_theme_builder_store_template', 3),
new Call('et_theme_builder_get_flat_template_settings_options', 0),
new Call('et_theme_builder_clear_wp_cache', 1),
// store_template needs Divi's et_() helper (E15).
new Call('et_'),
new Call('ET_Core_PageResource::remove_static_resources', 3),
],
postTypes: self::postTypes(),
keys: [
self::TEMPLATE_LIST, self::USE_ON, self::EXCLUDE_FROM, self::ENABLED, self::UNUSED, self::BACKUP_OPTION,
],
mirrors: [
new Mirror(
'tb-api-save',
$dir . 'api.php',
'et_theme_builder_api_save',
null,
'Vorlagenliste des Live-Theme-Builders (_et_template mit Sicherung et_tb_templates_backup_) und '
. '„ungenutzt“-Markierung'
),
new Mirror(
'tb-store-template',
$dir . 'theme-builder.php',
'et_theme_builder_store_template',
null,
'Bedingungen (_et_use_on, _et_exclude_from) und Aktiv-Schalter (_et_enabled) einer Vorlage'
),
new Mirror(
'tb-load-setting-options',
$dir . 'theme-builder.php',
'et_theme_builder_load_template_setting_options',
null,
'Zerlegen der Bedingungs-IDs (TemplateConditions)'
),
]
);
}
/** The live Theme Builder post (publish, no library TB); $create makes it like Divi's first save. 0 = none. */
/**
* The live Theme Builder post (publish, no library TB); $create makes it like Divi's first save. 0 = none.
*
* @throws DiviLocked When $create and Divi is not released (spec §5.5) – before anything is written.
*/
public function liveId(bool $create = false): int
{
if ($create) {
ReleaseGate::assertWritable();
}
return function_exists('et_theme_builder_get_theme_builder_post_id')
? (int) et_theme_builder_get_theme_builder_post_id(true, $create)
: 0;
@@ -194,9 +239,13 @@ class DiviThemeBuilder
return $found !== null;
}
/** @throws RuntimeException */
/**
* @throws RuntimeException
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
*/
public function insertLayout(string $area, string $title): int
{
ReleaseGate::assertWritable();
$type = self::LAYOUT_TYPES[$area] ?? throw new RuntimeException('unknown Theme Builder area ' . $area);
$this->assertCanWrite('et_theme_builder_insert_layout');
$id = $this->guarded(
@@ -218,9 +267,11 @@ class DiviThemeBuilder
* @param array{title: string, layouts: array<string, array{id: int, enabled: bool}>, use_on: list<string>,
* exclude_from: list<string>} $template
* @throws RuntimeException
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
*/
public function storeTemplate(int $themeBuilderId, array $template): int
{
ReleaseGate::assertWritable();
$this->assertCanWrite('et_theme_builder_store_template', 'et_');
$payload = [
'title' => $template['title'],
@@ -250,9 +301,11 @@ class DiviThemeBuilder
* template unused (stage3b README finding 10) – detachedIds() finds it, callers unmarkUnused() on reattach.
*
* @throws RuntimeException
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
*/
public function attach(int $themeBuilderId, int $templateId): void
{
ReleaseGate::assertWritable();
$existing = get_post_meta($themeBuilderId, self::TEMPLATE_LIST, false);
if (is_array($existing) && in_array((string) $templateId, array_map('strval', $existing), true)) {
return;
@@ -269,21 +322,25 @@ class DiviThemeBuilder
}
}
/** @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. */
public function detach(int $themeBuilderId, int $templateId): void
{
ReleaseGate::assertWritable();
delete_post_meta($themeBuilderId, self::TEMPLATE_LIST, (string) $templateId);
}
/**
* Replaces both condition lists like store_template (theme-builder.php:1565-1583): delete, then one
* Replaces both condition lists like store_template (theme-builder.php): delete, then one
* row per unique condition.
*
* @param list<string> $useOn
* @param list<string> $excludeFrom
* @throws RuntimeException
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
*/
public function setConditions(int $templateId, array $useOn, array $excludeFrom): void
{
ReleaseGate::assertWritable();
foreach ([self::USE_ON => $useOn, self::EXCLUDE_FROM => $excludeFrom] as $key => $conditions) {
delete_post_meta($templateId, $key);
foreach (array_values(array_unique($conditions)) as $condition) {
@@ -294,9 +351,13 @@ class DiviThemeBuilder
}
}
/** @throws RuntimeException */
/**
* @throws RuntimeException
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
*/
public function setEnabled(int $templateId, bool $enabled): void
{
ReleaseGate::assertWritable();
$value = $enabled ? '1' : '0';
update_post_meta($templateId, self::ENABLED, $value);
if ((string) get_post_meta($templateId, self::ENABLED, true) !== $value) {
@@ -304,9 +365,14 @@ class DiviThemeBuilder
}
}
/** Removes Divi's "unused" mark (F19), as Divi's own save does for every listed post. */
/**
* Removes Divi's "unused" mark (F19), as Divi's own save does for every listed post.
*
* @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written.
*/
public function unmarkUnused(int $postId): void
{
ReleaseGate::assertWritable();
delete_post_meta($postId, self::UNUSED);
}
@@ -338,7 +404,7 @@ class DiviThemeBuilder
return $flat;
}
/** Divi's Theme Builder cache and every static CSS file (spec §5.6 step 5; api.php:249-254). */
/** Divi's Theme Builder cache and every static CSS file (spec §5.6 step 5; api.php). */
public function clearCaches(): void
{
self::clearSiteCaches();
@@ -360,7 +426,7 @@ class DiviThemeBuilder
/**
* Before any Divi write function runs: the functions exist (E15 – no fatal error halfway) and the user
* holds edit_others_posts, without which Divi calls wp_die() (theme-builder.php:1464, 1644).
* holds edit_others_posts, without which Divi calls wp_die() (theme-builder.php).
*
* @throws RuntimeException
*/
@@ -369,7 +435,7 @@ class DiviThemeBuilder
if (! current_user_can('edit_others_posts')) {
throw new RuntimeException('edit_others_posts missing – Divi would call wp_die()');
}
$missing = array_values(array_intersect($functions, $this->missing()));
$missing = array_values(array_filter($functions, static fn (string $name): bool => ! function_exists($name)));
if ($missing !== []) {
throw new RuntimeException('Divi function missing: ' . implode(', ', $missing));
}
@@ -9,7 +9,7 @@ use LIT\MCP\Abilities\Rev;
defined('ABSPATH') || exit;
/**
* A Theme Builder template as Divi reads it (et_theme_builder_get_template(), theme-builder.php:471-521;
* A Theme Builder template as Divi reads it (et_theme_builder_get_template(), theme-builder.php;
* fixtures stage3/stage3b), plus whether the live Theme Builder post lists it (attached) and Divi's
* "unused" mark (set when a Theme Builder save drops it from the list, stage3b README finding 10).
* rev() is template_rev, the lock of the Theme Builder tools. No WordPress calls.
@@ -238,7 +238,7 @@ final class Template
}
/**
* The page families a condition covers (Divi's IDs, theme-builder.php:689-1060): singular:post_type:<type>,
* The page families a condition covers (Divi's IDs, theme-builder.php): singular:post_type:<type>,
* archive:taxonomy:<taxonomy>, archive:post_type:<type>, archive:user, archive:date; "<kind>:all" and posts
* in a term (singular:taxonomy:…) the whole kind; the front page may be a page or the posts archive.
*
@@ -13,11 +13,12 @@ defined('ABSPATH') || exit;
/**
* Theme Builder conditions (_et_use_on/_et_exclude_from, F21, E7): only IDs from Divi's own list
* (et_theme_builder_get_flat_template_settings_options()) – exactly, or for an entry ending in ":" that entry
* plus one object segment, split the way Divi reads it back (et_theme_builder_load_template_setting_options():
* the last segment is the object). The object must be readable by the user (ruling P10: PostIdParser): a post
* of that type with read_post (not trashed, no auto-draft/attachment status), a term of that taxonomy with
* assign_term, a user only with list_users (ruling Q10, stricter than Divi), an existing role. Refusals for
* missing and unreadable objects read the same and never name an object's title (P12). Nothing is written here.
* plus one object segment, split the way Divi reads it back (mirror tb-load-setting-options in
* DiviThemeBuilder::manifest(): the last segment is the object). The object must be readable by the user
* (ruling P10: PostIdParser): a post of that type with read_post (not trashed, no auto-draft/attachment status),
* a term of that taxonomy with assign_term, a user only with list_users (ruling Q10, stricter than Divi), an
* existing role. Refusals for missing and unreadable objects read the same and never name an object's title
* (P12). Nothing is written here.
* Every condition ID is limited to letters, digits, "_", ":" and "-" (at most 200 characters): a role slug with
* other characters (possible with role plugins) is refused as an invalid condition ID and must be set in Divi's
* Theme Builder (review M-6).
+8 -3
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Media;
use Closure;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\WordPress\PostStatuses;
use LIT\MCP\Log;
@@ -256,10 +257,14 @@ class MediaUsageFinder
);
}
$divi = get_option('et_divi');
$diviLogo = is_array($divi) && is_string($divi['divi_logo'] ?? null) ? $divi['divi_logo'] : '';
$diviLogo = DesignSystemReader::diviLogo();
if ($diviLogo !== '' && ContentMediaReferences::matches($fileRegex, $diviLogo) !== false) {
$found['site:divi_logo'] = self::siteUsage('Divi-Logo', 'option', 'divi_logo', self::POSSIBLE);
$found['site:divi_logo'] = self::siteUsage(
'Divi-Logo',
'option',
'divi_logo',
self::POSSIBLE
);
}
$this->addWidgetUsages($found, $attachmentId, $fileRegex);
@@ -29,6 +29,12 @@ interface ContentComposer
*/
public function writable(): bool;
/**
* Why writing such content is refused right now although it is enabled (e.g. the installed Divi version is not
* released for LIT MCP, spec §5.5); null = allowed.
*/
public function refusal(): ?WP_Error;
/**
* Validates the description for a new entry of $postType. Nothing is written.
*
@@ -133,6 +133,11 @@ final class ContentCreateAbility implements Ability
);
}
$locked = $withDivi ? $this->divi->refusal() : null;
if ($locked !== null) {
return $locked;
}
$prepared = $this->writer->prepare($postType, $input, true);
$problem = self::combined($prepared, $withDivi ? $this->checkDivi($postType, $input) : null);
if ($problem !== null) {
@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\WordPress\Content;
use LIT\MCP\Abilities\JsonMap;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Integrations\Divi\Pages\DiviPages;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
@@ -95,7 +96,7 @@ class PostPresenter
public static function usesDivi(WP_Post $post): bool
{
return get_post_meta((int) $post->ID, '_et_pb_use_builder', true) === 'on'
return DiviPages::usesBuilder((int) $post->ID)
|| str_contains((string) $post->post_content, '<!-- wp:divi/');
}
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Schema;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LogicException;
defined('ABSPATH') || exit;
@@ -18,9 +19,8 @@ class ContentSchema
/** Post types handled elsewhere (media tools, Divi tools, WordPress internals). */
public const EXCLUDED = [
'attachment', 'nav_menu_item', 'revision', 'wp_block', 'wp_template', 'wp_template_part',
'wp_navigation', 'wp_global_styles', 'wp_font_family', 'wp_font_face', 'et_pb_layout',
'et_theme_builder', 'et_template', 'et_header_layout', 'et_body_layout', 'et_footer_layout',
'et_pb_canvas', '_et_pb_speculation',
'wp_navigation', 'wp_global_styles', 'wp_font_family', 'wp_font_face',
...DiviModules::INTERNAL_POST_TYPES,
];
private const COUNTED_STATUSES = ['publish', 'draft', 'pending', 'private', 'future'];
@@ -36,10 +36,7 @@ class ContentSchema
*/
public function __construct(private array $sources, ?callable $diviEnabled = null)
{
$this->diviEnabled = $diviEnabled ?? static function (string $postType): bool {
return function_exists('et_builder_enabled_for_post_type')
&& (bool) et_builder_enabled_for_post_type($postType);
};
$this->diviEnabled = $diviEnabled ?? DiviModules::builderEnabledFor(...);
}
/** @return list<string> */
+19 -2
View File
@@ -12,6 +12,7 @@ use LIT\MCP\Admin\ConnectionRepository;
use LIT\MCP\Admin\ConnectionStatus;
use LIT\MCP\Admin\ConnectionTab;
use LIT\MCP\Admin\ConnectionUsage;
use LIT\MCP\Admin\DiviTab;
use LIT\MCP\Admin\LimitsTab;
use LIT\MCP\Admin\LogTab;
use LIT\MCP\Admin\SettingsPage;
@@ -20,10 +21,15 @@ use LIT\MCP\Admin\ToolsTab;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\ChangeLog\ChangeLogCleanup;
use LIT\MCP\ChangeLog\ChangeLogInstaller;
use LIT\MCP\Cli\DiviCheckCommand;
use LIT\MCP\Guides\InstructionsBuilder;
use LIT\MCP\Integrations\Core\CoreIntegration;
use LIT\MCP\Integrations\Divi\DiviIntegration;
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
use LIT\MCP\Integrations\Divi\Release\DiviToolGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;
use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Integrations\Media\MediaIntegration;
@@ -45,7 +51,7 @@ defined('ABSPATH') || exit;
*/
final class Plugin
{
public const VERSION = '0.5.0';
public const VERSION = '0.6.0';
private static string $pluginFile = '';
private static ?SettingsPage $settingsPage = null;
@@ -72,6 +78,11 @@ final class Plugin
(new ChangeLogInstaller($changeLog))->maybeInstall();
(new ChangeLogCleanup($changeLog, $limits))->register();
// Spec §5.4: a theme update checks the Divi fingerprints again at once (the state key would, too).
add_action('upgrader_process_complete', static function (): void {
delete_transient(ReleaseResolver::CACHE);
});
$schema = self::contentSchema();
$registry = null;
$policy = null;
@@ -127,8 +138,13 @@ final class Plugin
$divi->themeBuilder()
);
// Spec §6: the admin check runs as a REST request (same context as MCP calls).
$diviCheck = static fn (): CompatibilityCheck => CompatibilityCheck::create($divi->contentComposer());
(new DiviCheckRoute($diviCheck))->register();
DiviCheckCommand::register($diviCheck);
$policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...));
(new AbilityRegistrar($registry, $policy))->register();
(new AbilityRegistrar($registry, $policy, new DiviToolGate($registry->groupMap(...), $changeLog)))->register();
(new ServerRegistrar(
$registry,
new InstructionsBuilder($registry, static fn (): array => $schema->postTypeNames(), $policy),
@@ -146,6 +162,7 @@ final class Plugin
new ToolsTab($registry, $policy),
new LogTab($changeLog, $limits, $registry),
new LimitsTab(),
new DiviTab(log: $changeLog),
];
self::$settingsPage = new SettingsPage(
$tabs,
+18 -11
View File
@@ -7,19 +7,21 @@ namespace LIT\MCP;
defined('ABSPATH') || exit;
/**
* Removes WP-LIT-MCP's own data (uninstall.php, Task 12): options, the endpoint-check
* transient, the log-cleanup cron hook and the change log table – on every site of a
* network – and the Protokoll tab's per-user screen option.
* Removes WP-LIT-MCP's own data (uninstall.php, Task 12): options, the endpoint-check and
* Divi-release transients, the log-cleanup cron hook and the change log table – on every
* site of a network – and the Protokoll tab's per-user screen option.
*
* Deliberately self-contained (no reference to any other LIT\MCP\* class): uninstall.php
* is executed by WordPress core standalone, without loading the plugin's main file first,
* so the Composer/Jetpack autoloader is not guaranteed to have run. The option/hook/table
* names below are therefore duplicated from Access::OPTION, Abilities\ExposurePolicy::OPTION,
* Limits::OPTION, ChangeLog\ChangeLogInstaller::VERSION_OPTION, Admin\ConnectionStatus::
* ENDPOINT_TRANSIENT, ChangeLog\ChangeLogCleanup::HOOK, ChangeLog\ChangeLog::TABLE and
* Admin\LogTab::PER_PAGE_OPTION on
* purpose; tests/Unit/UninstallerTest.php and tests/Unit/UninstallTest.php keep them in
* sync with those constants.
* Limits::OPTION, ChangeLog\ChangeLogInstaller::VERSION_OPTION, Integrations\Divi\Release\
* ReleaseStore::OPTION, Integrations\Divi\Release\CheckReportStore::OPTION, Integrations\
* Divi\Release\DiviCheckRoute::LOCK, Admin\ConnectionStatus::ENDPOINT_TRANSIENT,
* Integrations\Divi\Release\ReleaseResolver::CACHE, ChangeLog\ChangeLogCleanup::HOOK,
* ChangeLog\ChangeLog::TABLE and Admin\LogTab::PER_PAGE_OPTION on purpose;
* tests/Unit/UninstallerTest.php and tests/Unit/UninstallTest.php keep them in sync with
* those constants.
*
* Never touches the bundled wp-media/mcp-oauth library's data (option mcp_jwt_secret,
* option wpmedia_mcp_oauth_rewrite_version, user meta mcp_refresh_jti_*, application
@@ -28,8 +30,11 @@ defined('ABSPATH') || exit;
*/
final class Uninstaller
{
private const OPTIONS = ['lit_mcp_allowed_roles', 'lit_mcp_exposure', 'lit_mcp_limits', 'lit_mcp_db_version'];
private const TRANSIENT = 'lit_mcp_endpoint_check';
private const OPTIONS = [
'lit_mcp_allowed_roles', 'lit_mcp_exposure', 'lit_mcp_limits', 'lit_mcp_db_version',
'lit_mcp_divi_releases', 'lit_mcp_divi_check_report', 'lit_mcp_divi_check_lock',
];
private const TRANSIENTS = ['lit_mcp_endpoint_check', 'lit_mcp_divi_state'];
private const CRON_HOOK = 'lit_mcp_log_cleanup';
private const TABLE = 'lit_mcp_log';
@@ -61,7 +66,9 @@ final class Uninstaller
foreach (self::OPTIONS as $option) {
delete_option($option);
}
delete_transient(self::TRANSIENT);
foreach (self::TRANSIENTS as $transient) {
delete_transient($transient);
}
wp_clear_scheduled_hook(self::CRON_HOOK);
$table = $wpdb->prefix . self::TABLE;
@@ -0,0 +1,69 @@
function et_pb_submit_layout( $args ) {
/**
* Filters the layout data passed to {@see et_pb_submit_layout()}.
*
* @since 3.0.99
*
* @param string[] $args See {@see et_pb_submit_layout()} for array structure definition.
*/
$args = apply_filters( 'et_pb_submit_layout_args', $args );
if ( empty( $args ) ) {
return '';
}
$layout_cats_processed = array();
$layout_tags_processed = array();
if ( '' !== $args['layout_selected_cats'] ) {
$layout_cats_array = explode( ',', $args['layout_selected_cats'] );
$layout_cats_processed = array_map( 'intval', $layout_cats_array );
}
if ( '' !== $args['layout_selected_tags'] ) {
$layout_tags_array = explode( ',', $args['layout_selected_tags'] );
$layout_tags_processed = array_map( 'intval', $layout_tags_array );
}
$meta = array();
if ( 'row' === $args['layout_type'] && '0' !== $args['columns_layout'] ) {
$meta = array_merge( $meta, array( '_et_pb_row_layout' => $args['columns_layout'] ) );
}
if ( 'module' === $args['layout_type'] ) {
$meta = array_merge( $meta, array( '_et_pb_module_type' => $args['module_type'] ) );
// save unsynced options for global modules. Always empty for new modules.
if ( 'global' === $args['layout_scope'] ) {
$meta = array_merge( $meta, array( '_et_pb_excluded_global_options' => wp_json_encode( array() ) ) );
}
}
// et_layouts_built_for_post_type.
$meta = array_merge(
$meta,
array(
'_et_pb_built_for_post_type' => $args['built_for_post_type'],
'_et_pb_template_type' => $args['template_type'] ?? '',
)
);
$tax_input = array(
'scope' => $args['layout_scope'],
'layout_type' => $args['layout_type'],
'module_width' => $args['module_width'],
'layout_category' => $layout_cats_processed,
'layout_tag' => $layout_tags_processed,
'layout_location' => et_()->array_get_sanitized( $args, 'layout_location', 'local' ),
);
$post_date = isset( $args['post_date'] ) && ! empty( $args['post_date'] ) ? $args['post_date'] : null;
$new_layout_id = et_pb_create_layout( $args['layout_name'], $args['layout_content'], $meta, $tax_input, $args['layout_new_cat'], $args['layout_new_tag'], 'publish', $post_date );
$new_post_data['post_id'] = (int) $new_layout_id;
$new_post_data['edit_link'] = esc_url_raw( get_edit_post_link( $new_layout_id ) );
$json_post_data = wp_json_encode( $new_post_data );
return $json_post_data;
}
@@ -0,0 +1,113 @@
function et_theme_builder_api_save() {
et_builder_security_check( 'theme_builder', 'edit_others_posts', 'et_theme_builder_api_save', 'nonce' );
// phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce is done in `et_builder_security_check`.
$_ = et_();
$live = '1' === $_->array_get( $_POST, 'live', '0' );
$first_request = '1' === $_->array_get( $_POST, 'first_request', '1' );
$last_request = '1' === $_->array_get( $_POST, 'last_request', '1' );
$templates = wp_unslash( $_->array_get( $_POST, 'templates', array() ) );
$processed_templates = wp_unslash( $_->array_get( $_POST, 'processed_templates', array() ) );
$library_tb_id = (int) $_->array_get( $_POST, 'library_theme_builder_id', 0 );
$library_item_id = (int) $_->array_get( $_POST, 'library_item_id', 0 );
$theme_builder_id = $library_tb_id ? $library_tb_id : et_theme_builder_get_theme_builder_post_id( $live, true );
$has_default = '1' === $_->array_get( $_POST, 'hasDefault', '0' );
$updated_ids = array();
// phpcs:enable
// Remove this action as it not necessary when we're saving entire TB.
// save_post_cb is a heavy operation and significanlty slows down the saving of TB.
// We remove static page resources after TB save below in this function.
remove_action( 'save_post', array( 'ET_Core_PageResource', 'save_post_cb' ), 10, 3 );
$templates_to_process = array();
// Populate the templates.
foreach ( $templates as $index => $template ) {
$templates_to_process[ $_->array_get( $template, 'id', 'unsaved_' . $index ) ] = $template;
}
$affected_templates = array();
// Update or insert templates.
foreach ( $templates_to_process as $template ) {
$raw_post_id = $_->array_get( $template, 'id', 0 );
$post_id = is_numeric( $raw_post_id ) ? (int) $raw_post_id : 0;
$new_post_id = et_theme_builder_store_template( $theme_builder_id, $template, ! $has_default );
if ( ! $new_post_id ) {
continue;
}
$is_default = get_post_meta( $new_post_id, '_et_default', true ) === '1';
if ( $is_default ) {
$has_default = true;
}
// Add template ID into $affected_templates for later use
// to Add mapping template ID to theme builder ID
// and delete existing template mapping.
$affected_templates[] = array(
'raw' => $raw_post_id,
'normalized' => $post_id,
'new_post_id' => $new_post_id,
);
}
foreach ( $affected_templates as $template_pair ) {
if ( $template_pair['normalized'] !== $template_pair['new_post_id'] ) {
$updated_ids[ $template_pair['raw'] ] = $template_pair['new_post_id'];
}
}
if ( $last_request ) {
$existing_templates = get_post_meta( $theme_builder_id, '_et_template', false );
if ( $existing_templates ) {
// Store existing template mapping as backup to avoid data lost
// when user interrupting the saving process before completed.
update_option( 'et_tb_templates_backup_' . $theme_builder_id, $existing_templates );
}
// Delete existing template mapping.
delete_post_meta( $theme_builder_id, '_et_template' );
$processed_templates = array_merge( $processed_templates, $affected_templates );
// Insert new template mapping.
foreach ( $processed_templates as $template_pair ) {
add_post_meta( $theme_builder_id, '_et_template', $template_pair['new_post_id'] );
}
// Delete existing template mapping backup.
delete_option( 'et_tb_templates_backup_' . $theme_builder_id );
if ( $live ) {
et_theme_builder_trash_draft_and_unused_posts();
}
et_theme_builder_clear_wp_cache( 'all' );
// Clear all CSS cache files when Theme Builder templates are saved.
// Theme builder CSS files include layout IDs in the filename (e.g., et-core-unified-tb-285804-tb-285806-279784.min),
// so we need to clear all CSS files to ensure files with these layout IDs are removed.
ET_Core_PageResource::remove_static_resources( 'all', 'all' );
}
// Edit Template and Edit Preset: Save the templates into local library.
if ( $library_tb_id && $library_item_id ) {
et_theme_builder_update_library_item( $library_item_id, $templates );
}
// Add this action back.
add_action( 'save_post', array( 'ET_Core_PageResource', 'save_post_cb' ), 10, 3 );
wp_send_json_success(
array(
'updatedTemplateIds' => (object) $updated_ids,
'processedTemplatesData' => (object) $affected_templates,
'hasDefault' => $has_default ? '1' : '0',
)
);
}
@@ -0,0 +1,36 @@
function et_theme_builder_load_template_setting_options( $setting_ids ) {
$flat_parent_settings = et_theme_builder_get_flat_template_settings_options();
$groups = array();
foreach ( $setting_ids as $setting_id ) {
$parent_id = explode( ET_THEME_BUILDER_SETTING_SEPARATOR, $setting_id );
$entity_id = implode( '', array_slice( $parent_id, -1 ) );
$parent_id = array_slice( $parent_id, 0, -1 );
$parent_id = implode( ET_THEME_BUILDER_SETTING_SEPARATOR, $parent_id ) . ET_THEME_BUILDER_SETTING_SEPARATOR;
if ( ! isset( $flat_parent_settings[ $parent_id ] ) ) {
// Top-level, invalid or unknown setting.
continue;
}
if ( ! isset( $groups[ $parent_id ] ) ) {
$groups[ $parent_id ] = array(
'parent' => $flat_parent_settings[ $parent_id ],
'settings' => array(),
);
}
$groups[ $parent_id ]['settings'][ $setting_id ] = $entity_id;
}
$settings = array();
foreach ( $groups as $parent_id => $group ) {
$settings = array_merge(
$settings,
et_theme_builder_get_template_setting_child_options( $group['parent'], $group['settings'] )
);
}
return $settings;
}
@@ -0,0 +1,124 @@
function et_theme_builder_store_template( $theme_builder_id, $template, $allow_default ) {
if ( ! current_user_can( 'edit_others_posts' ) ) {
wp_die();
}
$_ = et_();
$raw_post_id = $_->array_get( $template, 'id', 0 );
$post_id = is_numeric( $raw_post_id ) ? (int) $raw_post_id : 0;
$title = sanitize_text_field( $_->array_get( $template, 'title', '' ) );
$default = $allow_default && '1' === $_->array_get( $template, 'default', '1' );
$enabled = '1' === $_->array_get( $template, 'enabled', '1' );
$header_id = (int) $_->array_get( $template, 'layouts.header.id', 0 );
$header_enabled = (bool) $_->array_get( $template, 'layouts.header.enabled', true );
$body_id = (int) $_->array_get( $template, 'layouts.body.id', 0 );
$body_enabled = (bool) $_->array_get( $template, 'layouts.body.enabled', true );
$footer_id = (int) $_->array_get( $template, 'layouts.footer.id', 0 );
$footer_enabled = (bool) $_->array_get( $template, 'layouts.footer.enabled', true );
$use_on = array_map( 'sanitize_text_field', $_->array_get( $template, 'use_on', array() ) );
$exclude_from = array_map( 'sanitize_text_field', $_->array_get( $template, 'exclude_from', array() ) );
$exists = $post_id > 0 && ET_THEME_BUILDER_TEMPLATE_POST_TYPE === get_post_type( $post_id ) && 'publish' === get_post_status( $post_id );
$autogenerated_title = '1' === $_->array_get( $template, 'autogenerated_title', '1' );
$onboarding = (bool) $_->array_get( $template, 'onboarding', '0' );
if ( ET_THEME_BUILDER_HEADER_LAYOUT_POST_TYPE !== get_post_type( $header_id ) || 'publish' !== get_post_status( $header_id ) ) {
$header_id = 0;
}
if ( ET_THEME_BUILDER_BODY_LAYOUT_POST_TYPE !== get_post_type( $body_id ) || 'publish' !== get_post_status( $body_id ) ) {
$body_id = 0;
}
if ( ET_THEME_BUILDER_FOOTER_LAYOUT_POST_TYPE !== get_post_type( $footer_id ) || 'publish' !== get_post_status( $footer_id ) ) {
$footer_id = 0;
}
if ( $exists ) {
// Preform update only when needed.
if ( get_post_field( 'post_title', $post_id, 'raw' ) !== $title ) {
wp_update_post(
array(
'ID' => $post_id,
'post_title' => $title,
)
);
// Update layout title for each template.
et_theme_builder_update_layout_title( $template );
}
update_post_meta( $post_id, '_et_onboarding_created', '0' );
} else {
$post_id = wp_insert_post(
array(
'post_type' => ET_THEME_BUILDER_TEMPLATE_POST_TYPE,
'post_status' => 'publish',
'post_title' => $title,
)
);
if ( $onboarding ) {
update_post_meta( $post_id, '_et_onboarding_created', '1' );
}
}
if ( 0 === $post_id || is_wp_error( $post_id ) ) {
return false;
}
$metas = array(
'_et_autogenerated_title' => $autogenerated_title ? '1' : '0',
'_et_default' => $default ? '1' : '0',
'_et_enabled' => $enabled ? '1' : '0',
'_et_header_layout_id' => $header_id,
'_et_header_layout_enabled' => $header_enabled ? '1' : '0',
'_et_body_layout_id' => $body_id,
'_et_body_layout_enabled' => $body_enabled ? '1' : '0',
'_et_footer_layout_id' => $footer_id,
'_et_footer_layout_enabled' => $footer_enabled ? '1' : '0',
);
foreach ( $metas as $key => $value ) {
if ( strval( $value ) === strval( get_post_meta( $post_id, $key, true ) ) ) {
continue;
}
update_post_meta( $post_id, $key, $value );
}
if ( $onboarding ) {
$onboarding_use_on = get_post_meta( $post_id, '_et_use_on' );
$onboarding_exclude_from = get_post_meta( $post_id, '_et_exclude_from' );
foreach ( $onboarding_use_on as $condition ) {
add_post_meta( $post_id, '_et_old_use_on', $condition );
}
foreach ( $onboarding_exclude_from as $condition ) {
add_post_meta( $post_id, '_et_old_exclude_from', $condition );
}
}
// Handle _et_use_on meta.
delete_post_meta( $post_id, '_et_use_on' );
if ( $use_on ) {
$use_on_unique = array_unique( $use_on );
foreach ( $use_on_unique as $condition ) {
add_post_meta( $post_id, '_et_use_on', $condition );
}
}
// Handle _et_exclude_from meta.
delete_post_meta( $post_id, '_et_exclude_from' );
if ( $exclude_from ) {
$exclude_from_unique = array_unique( $exclude_from );
foreach ( $exclude_from_unique as $condition ) {
add_post_meta( $post_id, '_et_exclude_from', $condition );
}
}
return $post_id;
}
@@ -0,0 +1,444 @@
function update( WP_REST_Request $request ) {
$post_id = (int) $request->get_param( 'post_id' );
$post_status = $request->get_param( 'post_status' );
$content = $request->get_param( 'content' );
$preferences = $request->get_param( 'preferences' );
$page_settings_by_layout = $request->get_param( 'pageSettingsByLayout' );
$sync_type = $request->get_param( 'syncType' );
$options = $request->get_param( 'options' );
$workspace = $request->get_param( 'workspace' );
$prime_cache_url = $request->get_param( 'prime_cache_url' );
$main_loop_type = $request->get_param( 'mainLoopType' );
$main_loop_type = is_string( $main_loop_type ) ? $main_loop_type : 'singular';
$loop_settings_data = $request->get_param( 'mainLoopSettingsData' );
$layout_post_ids = $request->get_param( 'layout_post_ids' );
$layout_post_ids = is_array( $layout_post_ids ) ? $layout_post_ids : [];
$layout_post_ids = self::sanitize_layout_post_ids( $layout_post_ids );
$page_settings_by_layout = is_array( $page_settings_by_layout ) ? $page_settings_by_layout : [];
$layout_post_ids = self::_validate_and_normalize_layout_post_ids( $layout_post_ids, $post_id, $main_loop_type );
if ( is_wp_error( $layout_post_ids ) ) {
return self::response_error(
$layout_post_ids->get_error_code(),
$layout_post_ids->get_error_message(),
$layout_post_ids->get_error_data()
);
}
foreach ( $page_settings_by_layout as $layout => $layout_page_settings ) {
if ( ! is_array( $layout_page_settings ) || ! isset( $layout_page_settings['customCss'] ) ) {
continue;
}
$page_settings_by_layout[ $layout ]['customCss'] = str_replace( '\\', '\\\\', $layout_page_settings['customCss'] );
}
$post_content = isset( $content['post_content'] ) ? $content['post_content'] : '';
$header_content = isset( $content['header'] ) ? $content['header'] : '';
$body_content = isset( $content['body'] ) ? $content['body'] : '';
$footer_content = isset( $content['footer'] ) ? $content['footer'] : '';
$has_post_content_payload = '' !== $post_content;
$post_content_post_id = isset( $layout_post_ids['postContent'] ) ? absint( $layout_post_ids['postContent'] ) : $post_id;
$header_post_id = isset( $layout_post_ids['header'] ) ? absint( $layout_post_ids['header'] ) : 0;
$body_post_id = isset( $layout_post_ids['body'] ) ? absint( $layout_post_ids['body'] ) : 0;
$footer_post_id = isset( $layout_post_ids['footer'] ) ? absint( $layout_post_ids['footer'] ) : 0;
$post_content_page_settings = isset( $page_settings_by_layout['postContent'] ) && is_array( $page_settings_by_layout['postContent'] )
? $page_settings_by_layout['postContent']
: [];
// Guardrail: never allow empty placeholder-only payloads to overwrite Theme Builder layouts.
$header_has_content = self::_has_meaningful_layout_content( $header_content );
$body_has_content = self::_has_meaningful_layout_content( $body_content );
$footer_has_content = self::_has_meaningful_layout_content( $footer_content );
if ( ! $header_has_content ) {
$header_post_id = 0;
}
if ( ! $body_has_content ) {
$body_post_id = 0;
}
if ( ! $footer_has_content ) {
$footer_post_id = 0;
}
// Extract off-canvas data and make it available for hooks.
$off_canvas_data = $request->get_param( 'off_canvas_data' );
if ( $off_canvas_data ) {
// Normalize to array format - decode JSON string if needed.
$normalized_canvas_data = self::_normalize_off_canvas_data( $off_canvas_data );
if ( $normalized_canvas_data ) {
// Store off-canvas data globally so hooks can access it.
$GLOBALS['divi_off_canvas_data'] = $normalized_canvas_data;
}
}
// Check if there is an autosave that is newer.
$post_author = get_current_user_id();
// Store one autosave per author. If there is already an autosave, overwrite it.
$autosave = wp_get_post_autosave( $post_id, $post_author );
if ( ! empty( $autosave ) ) {
wp_delete_post_revision( $autosave->ID );
}
// Check if what is being synced to server is preview post.
$is_saving_preview = 'preview' === $sync_type;
// Preview post aims to have similar post previewing experience as WordPress block editor.
// How preview post works in WordPress block editor:
// 1. Preview button is clicked.
// 2. REST request is sent to `/wp-json/wp/v2/pages/POST_ID/autosaves?_locale=user` endpoint.
// 3. An autosave post for current post is created. Autosave post is another record on `wp_post` table that:
// - has post_status: `inherit`
// - has post_type: `revision`
// - has post_parent pointing to the published / draft post ID
// 4. Basically the autosave post is the most recent revision of the post. A user commonly can have only have
// one autosave per post.
// 5. Once autosave post is created, the preview post URL is generated and returned to the client.
// 6. User is being redirected to the preview post URL
// For WordPress block editor's preview post mechanism, see: `WP_REST_Autosaves_Controller->create_item()`.
if ( $is_saving_preview ) {
// Create new autosave as post revision that will be used for preview.
$revision_id = Revision::put_post_revision(
[
'ID' => $post_id,
'post_title' => $post_content_page_settings['postTitle'] ?? '',
// `_wp_put_post_revision()` already does the `wp_slash()` for the content. No need apply `wp_slash()` here.
'post_content' => $post_content,
],
true
);
// Create preview post link.
// Equivalent of this on WordPress block editor can be seen at WP_REST_Autosaves_Controller->prepare_item_for_response().
$preview_url = get_preview_post_link(
$post_id,
[
'preview_id' => $post_id,
'preview_nonce' => wp_create_nonce( 'post_preview_' . $post_id ),
]
);
return self::response_success(
[
'previewId' => $revision_id,
'previewUrl' => $preview_url,
'syncType' => 'preview',
'post_status' => $post_status,
'save_verification' => $revision_id ? true : false,
]
);
}
/**
* Filters whether to proceed with the post save operation.
*
* This filter allows modules to intercept the save process and prevent
* the default wp_update_post() call. If the filter returns an array,
* that array will be returned as the success response and no save will occur.
* If the filter returns null, the save will proceed normally.
*
* @since ??
*
* @param array|null $abort_save_response Response array to return if save should be aborted, or null to proceed.
* @param int $post_id The post ID being saved.
* @param string $post_content The post content being saved.
* @param string $post_status The post status being saved.
* @param array $options The options array containing conditional_tags and other metadata.
* @param array $post_content_page_settings The post content page settings array (if any).
*/
$abort_save_response = apply_filters(
'divi_visual_builder_rest_before_save_post',
null,
$post_id,
$post_content,
$post_status,
$options,
$post_content_page_settings
);
if ( is_array( $abort_save_response ) ) {
// A filter has intercepted the save and provided a custom response.
return self::response_success( $abort_save_response );
}
// Build the post update array.
$post_update_data = [
'ID' => $post_id,
'post_status' => $post_status,
];
// Ignore invalid empty-string post content payloads, while still allowing status/title/excerpt updates.
if ( $has_post_content_payload ) {
$post_update_data['post_content'] = wp_slash( $post_content );
}
// Include post_title and post_excerpt in this update to avoid a second wp_update_post() call.
// This prevents duplicate revisions and duplicate filter processing.
// Note: wp_update_post() internally calls sanitize_post() which sanitizes both fields,
// but we sanitize here for consistency with the existing approach in et_builder_update_settings().
if ( $post_content_page_settings ) {
if ( isset( $post_content_page_settings['postTitle'] ) ) {
$post_update_data['post_title'] = sanitize_text_field( $post_content_page_settings['postTitle'] );
}
if ( isset( $post_content_page_settings['postExcerpt'] ) ) {
// Use wp_kses_post for excerpt to allow certain HTML tags (same as et_builder_update_settings).
$post_update_data['post_excerpt'] = wp_kses_post( $post_content_page_settings['postExcerpt'] );
}
}
$main_post = get_post( $post_id );
$has_main_post_context = $main_post instanceof \WP_Post;
$update = $has_main_post_context ? wp_update_post( $post_update_data ) : 0;
$layout_updates = [
[
'layout' => 'postContent',
'id' => $post_content_post_id,
'content' => $post_content,
],
[
'layout' => 'header',
'id' => $header_post_id,
'content' => $header_content,
],
[
'layout' => 'body',
'id' => $body_post_id,
'content' => $body_content,
],
[
'layout' => 'footer',
'id' => $footer_post_id,
'content' => $footer_content,
],
];
$updated_theme_builder_layout_post_ids = [];
foreach ( $layout_updates as $layout_update ) {
$target_layout = $layout_update['layout'] ?? 'postContent';
$target_post_id = absint( $layout_update['id'] ?? 0 );
$target_content = $layout_update['content'] ?? '';
// Main post content is already updated above.
if ( $post_id === $target_post_id ) {
continue;
}
$layout_update_result = self::_update_layout_post_content( $target_post_id, $target_content );
if ( is_wp_error( $layout_update_result ) ) {
return self::response_error(
$layout_update_result->get_error_code(),
$layout_update_result->get_error_message(),
$layout_update_result->get_error_data()
);
}
if ( 0 !== (int) $layout_update_result && in_array( $target_layout, [ 'header', 'body', 'footer' ], true ) ) {
$updated_theme_builder_layout_post_ids[] = $target_post_id;
}
}
// Ensure Theme Builder layout saves clear template-targeted frontend caches.
// In non-singular Visual Builder contexts, the main request post ID is not a layout post,
// so cache clearing must also run for explicitly saved layout post IDs.
if ( ! empty( $updated_theme_builder_layout_post_ids ) ) {
foreach ( array_unique( $updated_theme_builder_layout_post_ids ) as $updated_layout_post_id ) {
et_theme_builder_clear_wp_post_cache( $updated_layout_post_id );
}
}
// Save page settings by layout post ID.
if ( ! empty( $page_settings_by_layout ) ) {
self::_save_page_settings_by_layout(
$page_settings_by_layout,
[
'postContent' => $post_content_post_id,
'header' => $header_post_id,
'body' => $body_post_id,
'footer' => $footer_post_id,
],
$post_id
);
}
// Save non-singular page settings (term properties, author properties, etc.).
// The frontend syncs field edits back into `mainLoopSettingsData`, so the
// updated values arrive here directly — no separate property needed.
if ( 'singular' !== $main_loop_type && is_array( $loop_settings_data ) ) {
$expected_main_loop_owner_context = self::_get_expected_main_loop_owner_context( $main_loop_type );
SavingUtility::save_non_singular_page_settings(
$loop_settings_data,
$main_loop_type,
$expected_main_loop_owner_context
);
}
/**
* Action hook to fire when the Post is being saved.
*
* This is for backward compatibility with hooks written for Divi version <5.0.0.
*
* @since 3.20
* @deprecated 5.0.0 Use `divi_visual_builder_rest_save_post` hook instead.
*
* @param int $post_id Post ID.
*/
do_action(
'et_save_post',
$post_id
);
/**
* Action hook to fire when the Post is being saved.
*
* @since ??
*
* @param int $post_id Post ID.
*/
// Pass request context into save hooks.
// This avoids relying on globals and is extendable for future non-singular owners.
do_action(
'divi_visual_builder_rest_save_post',
$post_id,
[
'mainLoopType' => $main_loop_type,
'mainLoopSettingsData' => $loop_settings_data,
]
);
// Prime page cache AFTER off-canvas data and other post meta is saved.
// This ensures that when the cache priming request triggers early detection,
// all post meta (including appended canvas content) is available.
SavingUtility::prime_page_cache_on_save( $post_id, $prime_cache_url );
if ( ( $update && ! is_wp_error( $update ) ) || ( ! $has_main_post_context && ! is_wp_error( $update ) ) ) {
// Update post meta so we know D5 is used and Readiness migrator will skip it.
if ( $has_main_post_context ) {
update_post_meta( $post_id, '_et_pb_use_divi_5', 'on' );
// Also set the legacy meta for backward compatibility with D4 components.
update_post_meta( $post_id, '_et_pb_use_builder', 'on' );
// Clear the page creation flow flag so it doesn't show again on subsequent loads.
update_post_meta( $post_id, '_et_pb_show_page_creation', 'off' );
}
$layout_ids_to_mark = [
$post_content_post_id,
$header_post_id,
$body_post_id,
$footer_post_id,
];
foreach ( $layout_ids_to_mark as $layout_id ) {
$layout_id = absint( $layout_id );
if ( 0 === $layout_id || $post_id === $layout_id ) {
continue;
}
update_post_meta( $layout_id, '_et_pb_use_divi_5', 'on' );
update_post_meta( $layout_id, '_et_pb_use_builder', 'on' );
}
$preferences = SavingUtility::sanitize_app_preferences( $preferences );
foreach ( $preferences as $preference_key => $preference_value ) {
$option_name = 'et_fb_pref_' . $preference_key;
et_update_option( $option_name, $preference_value );
}
// Updated last used workspace.
$last_used_workspace = $workspace['last-used'] ?? [];
Workspace::update_item( 'builtIn', 'last-used', $last_used_workspace );
/**
* Action hook to fire when the Post is updated.
*
* This is for backward compatibility with hooks written for Divi version <5.0.0.
*
* @param int $post_id Post ID.
*
* @since 3.29
* @deprecated 5.0.0 Use `divi_visual_builder_rest_update_post` hook instead.
*/
do_action(
'et_update_post',
$post_id
);
/**
* Action hook to fire when the Post is updated.
*
* @param int $post_id Post ID.
*
* @since ??
*/
do_action( 'divi_visual_builder_rest_update_post', $post_id );
$saved_post_content = $has_main_post_context ? get_post_field( 'post_content', $update ) : $post_content;
$verification = $has_main_post_context
? self::_verify_post_content_matches_after_sanitization( $post_content, $saved_post_content )
: true;
/**
* Filter to modify the save verification result.
*
* @since ??
* @deprecated 5.0.0 Use the {@see 'divi_visual_builder_rest_save_verification'} filter instead.
*
* @param bool $verification Whether to save the verification result.
*/
$verification = apply_filters(
'et_fb_ajax_save_verification_result',
$verification
);
/**
* Filter to modify the save verification result.
*
* @since ??
*
* @param bool $verification Whether to save the verification result.
*/
$save_verification_filtered = apply_filters(
'divi_visual_builder_rest_save_post_save_verification',
$verification
);
$return_rendered_content = $options['return_rendered_content'] ?? false;
if ( $return_rendered_content && $has_main_post_context ) {
// Replace dynamic data in the content with the actual value.
$normalized_content = DynamicData::get_processed_dynamic_data( $saved_post_content, $post_id, true );
// Apply the_content filter to the content.
$rendered_content = apply_filters( 'the_content', $normalized_content );
} else {
$rendered_content = $saved_post_content;
}
return self::response_success(
[
'post_status' => $has_main_post_context ? get_post_status( $update ) : $post_status,
'save_verification' => $save_verification_filtered,
'rendered_content' => $rendered_content,
]
);
}
if ( is_wp_error( $update ) ) {
return self::response_error( $update->get_error_code(), $update->get_error_message(), $update->get_error_data() );
}
return self::response_error( 'unknown_error', esc_html__( 'Unknown error.', 'et_builder_5' ) );
}
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# End-to-end checks of the Divi release gate (spec 2026-09-29-divi-absicherung).
# Runs INSIDE the DDEV web container (Divi 5.2.1 must be the active theme):
# cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-release-smoke.sh
# Leaves nothing behind: pages, the check report, the check lock, the state transient and the application password
# are removed again.
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)
CREATED_POSTS=()
BASE=http://localhost
cleanup() {
for id in "${CREATED_POSTS[@]}"; do
wp post delete "$id" --force >/dev/null 2>&1 || true
done
wp option delete lit_mcp_divi_check_report lit_mcp_divi_check_lock >/dev/null 2>&1 || true
wp transient delete lit_mcp_divi_state >/dev/null 2>&1 || true
cleanup_app_passwords_for "$ADMIN_LOGIN"
}
trap 'rc=$?; cleanup; guard_exit "$rc" || exit $?' EXIT
check_pages() { wp post list --post_type=page --post_status=any --meta_key=_lit_mcp_check_page --format=count; }
[ "$(wp eval 'echo get_template();')" = "Divi" ] || fail "Divi must be the active theme"
# 1. Code, registrations and fingerprints match Divi 5.2.1
[ "$(wp eval 'echo wp_json_encode((new LIT\MCP\Integrations\Divi\DiviCompatibility())->problems());')" = "[]" ] \
|| fail "Divi compatibility problems under WP-CLI"
pass "manifests and fingerprints match the installed Divi"
connect "$ADMIN_LOGIN"
STATUS=$(tool lit-site-status '{}')
echo "$STATUS" | jq -e '.components[] | select(.id == "divi") | .problems == [] and .release.state == "released" and .release.source == "code"' >/dev/null \
|| fail "site status must show Divi released from code: $STATUS"
pass "lit-site-status: Divi 5.2.1 released (code), no problems in an MCP request"
# 2. Admin check through the REST route (application password instead of the cookie nonce).
# X-Forwarded-Proto: https is required here: without it the request to http://localhost with the
# site's own Host header never reaches the route (a 404 before any auth check even runs), the same
# header mcp-smoke.sh and tb-smoke.sh already send for their own curl calls against this DDEV site.
APP_PASSWORD=$(wp user application-password create "$ADMIN_LOGIN" lit-mcp-integration-test --porcelain)
HEADERS=$(curl -s -o /dev/null -D - -X POST -H "Host: $HOST" -H 'X-Forwarded-Proto: https' -u "$ADMIN_LOGIN:$APP_PASSWORD" "$BASE/wp-json/lit-mcp/v1/divi-check")
echo "$HEADERS" | grep -qi '^HTTP/[0-9.]* 303' || fail "check route must answer 303: $HEADERS"
echo "$HEADERS" | grep -qi '^location:.*tab=divi.*lit_mcp_notice=divi_checked' || fail "redirect to the tab: $HEADERS"
REPORT=$(wp option get lit_mcp_divi_check_report --format=json)
echo "$REPORT" | jq -e '[.steps[] | (.problems == [] and .skipped == false)] | all and length == 5' >/dev/null \
|| fail "every check step must be green: $REPORT"
[ "$(check_pages)" = "0" ] || fail "the throwaway page must be gone"
[ "$(wp option get lit_mcp_divi_check_lock 2>/dev/null || true)" = "" ] || fail "the check lock must be released"
pass "admin check (REST): five green steps, throwaway page deleted, lock released"
UNAUTH=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "Host: $HOST" -H 'X-Forwarded-Proto: https' "$BASE/wp-json/lit-mcp/v1/divi-check")
[ "$UNAUTH" = "401" ] || [ "$UNAUTH" = "403" ] || fail "anonymous check must be refused, got $UNAUTH"
pass "check route refuses anonymous requests"
# 3. Incompatible Divi (simulated: a failed fingerprint in the state cache) locks every write path
wp eval '
$key = LIT\MCP\Integrations\Divi\Release\ReleaseResolver::stateKey((string) LIT\MCP\Integrations\Divi\Release\DiviEnvironment::version());
set_transient(LIT\MCP\Integrations\Divi\Release\ReleaseResolver::CACHE, ["key" => $key, "problems" => ["Smoke: Nachbau geändert"]], 600);'
PAGE_ID=$(wp --user="$ADMIN_LOGIN" post create --post_type=page --post_status=draft --post_title="LIT Freigabe Smoke" --porcelain)
CREATED_POSTS+=("$PAGE_ID")
wp post meta update "$PAGE_ID" _et_pb_use_builder on >/dev/null
wp post meta update "$PAGE_ID" _et_pb_use_divi_5 on >/dev/null
BEFORE=$(wp post get "$PAGE_ID" --field=post_content)
OUTLINE=$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}")
echo "$OUTLINE" | jq -e '.warnings | any(test("inkompatibel"))' >/dev/null || fail "read tool must carry the notice: $OUTLINE"
pass "reading Divi tool runs and warns"
INSERT=$(tool lit-divi-insert "{\"post_id\":$PAGE_ID,\"tree_rev\":$(echo "$OUTLINE" | jq '.tree_rev'),\"nodes\":[{\"type\":\"section\",\"modules\":[{\"type\":\"text\",\"content\":\"<p>x</p>\"}]}]}")
echo "$INSERT" | jq -e '.__error | fromjson | .code == "divi_incompatible"' >/dev/null || fail "write must be refused: $INSERT"
[ "$(wp post get "$PAGE_ID" --field=post_content)" = "$BEFORE" ] || fail "nothing may be written"
pass "writing Divi tool refused with divi_incompatible, page unchanged"
CREATE=$(tool lit-content-create '{"post_type":"page","title":"LIT Freigabe Smoke Neu","divi":[{"type":"section","modules":[{"type":"text","content":"<p>x</p>"}]}]}')
echo "$CREATE" | jq -e '.__error | fromjson | .code == "divi_incompatible"' >/dev/null || fail "content-create with divi must be refused: $CREATE"
[ "$(wp post list --post_type=page --post_status=any --title='LIT Freigabe Smoke Neu' --format=count)" = "0" ] \
|| fail "no draft may remain"
pass "lit-content-create with divi refused before a draft exists"
STATUS=$(tool lit-site-status '{}')
echo "$STATUS" | jq -e '.components[] | select(.id == "divi") | .release.state == "incompatible"' >/dev/null \
|| fail "site status must show incompatible: $STATUS"
# wp db query --skip-column-names misreports this as a write query (it treats "insert" anywhere in the SQL,
# even inside a quoted value like 'lit/divi-insert', as an INSERT and prints "Rows affected" instead of the
# result) - wp eval with $wpdb->get_var() runs the same COUNT() reliably.
REFUSED_LOGGED=$(wp eval '
global $wpdb;
echo (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->prefix}lit_mcp_log WHERE result = %s AND ability = %s",
"refused",
"lit/divi-insert"
));')
[ "$REFUSED_LOGGED" -ge 1 ] || fail "the refusal must be in the change log"
pass "site status incompatible, refusal logged"
# 4. Back to released: writes work again
wp transient delete lit_mcp_divi_state >/dev/null
OUTLINE=$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}")
INSERT=$(tool lit-divi-insert "{\"post_id\":$PAGE_ID,\"tree_rev\":$(echo "$OUTLINE" | jq '.tree_rev'),\"nodes\":[{\"type\":\"section\",\"modules\":[{\"type\":\"text\",\"content\":\"<p>x</p>\"}]}]}")
echo "$INSERT" | jq -e '.ok == true' >/dev/null || fail "write must work when released: $INSERT"
pass "released again: writing works"
# 5. WP-CLI
wp --user="$ADMIN_LOGIN" lit-mcp divi-check >/dev/null || fail "wp lit-mcp divi-check must succeed"
[ "$(check_pages)" = "0" ] || fail "the CLI check must delete its page"
pass "wp lit-mcp divi-check is green and leaves no page"
finish
+2 -26
View File
@@ -10,7 +10,7 @@
defined('WP_CLI') && WP_CLI || exit;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
use LIT\MCP\Integrations\Divi\Schema\ShortformCheck;
use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
if (! DiviModules::isAvailable()) {
@@ -19,31 +19,7 @@ if (! DiviModules::isAvailable()) {
$started = microtime(true);
$catalog = (new DiviModules())->catalog();
$problems = [];
foreach (ShortformMap::types() as $type) {
$name = ModuleCatalog::normalize($type);
if (! $catalog->has($name)) {
$problems[] = $name . ': unknown module';
continue;
}
foreach (ShortformMap::fields($type) as $field => $spec) {
if (! $catalog->isKnownPath($name, (string) $spec['path'])) {
$problems[] = sprintf(
'%s.%s: unknown path %s (closest: %s)',
$type,
$field,
$spec['path'],
implode(', ', $catalog->suggestions($name, (string) $spec['path']))
);
}
}
$child = ShortformMap::childType($type);
if ($child !== null && ! in_array(ModuleCatalog::normalize($child), $catalog->childrenOf($name), true)) {
$problems[] = sprintf('%s: %s is no allowed child (%s)', $type, $child, implode(', ', $catalog->childrenOf($name)));
}
}
$problems = ShortformCheck::problems($catalog);
if ($problems !== []) {
WP_CLI::error("Short form does not match the catalog:\n" . implode("\n", $problems));
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
// WP_HTTP_Response first: real WordPress' WP_REST_Response extends it (ConnectionUsage::successful()
// tells a WP_REST_Response apart from a WP_Error/array via instanceof WP_HTTP_Response).
if (! class_exists('WP_HTTP_Response')) {
class WP_HTTP_Response
{
/** @param array<string, string> $headers */
public function __construct(public mixed $data = null, public int $status = 200, public array $headers = [])
{
}
public function get_status(): int
{
return $this->status;
}
/** @return array<string, string> */
public function get_headers(): array
{
return $this->headers;
}
}
}
if (! class_exists('WP_REST_Response')) {
class WP_REST_Response extends WP_HTTP_Response
{
}
}
+72 -2
View File
@@ -9,6 +9,11 @@ use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\AbilityRegistrar;
use LIT\MCP\Abilities\ExposurePolicy;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\DiviToolGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Server\RequestContext;
@@ -43,7 +48,18 @@ final class AbilityRegistrarTest extends TestCase
return new AbilityRegistrar(new IntegrationRegistry($integration));
}
private function ability(): Mockery\MockInterface
private function diviRegistrar(Ability $ability): AbilityRegistrar
{
$integration = Mockery::mock(Integration::class);
$integration->allows('id')->andReturn('divi');
$integration->allows('isAvailable')->andReturn(true);
$integration->allows('abilities')->andReturn([$ability]);
$registry = new IntegrationRegistry($integration);
return new AbilityRegistrar($registry, null, new DiviToolGate($registry->groupMap(...)));
}
private function ability(bool $readonly = true): Mockery\MockInterface
{
$ability = Mockery::mock(Ability::class);
$ability->allows('name')->andReturn('lit/test');
@@ -51,7 +67,7 @@ final class AbilityRegistrarTest extends TestCase
$ability->allows('description')->andReturn('Test ability');
$ability->allows('inputSchema')->andReturn(['type' => 'object', 'properties' => []]);
$ability->allows('outputSchema')->andReturn(['type' => 'object']);
$ability->allows('annotations')->andReturn(['readonly' => true]);
$ability->allows('annotations')->andReturn(['readonly' => $readonly]);
$ability->allows('isDirect')->andReturn(true);
return $ability;
@@ -202,6 +218,60 @@ final class AbilityRegistrarTest extends TestCase
unlink($log);
}
public function testUntestedDiviRefusesAWritingDiviToolBeforeItRuns(): void
{
ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
$ability = $this->ability(false);
$ability->expects('execute')->never();
$args = $this->diviRegistrar($ability)->argsFor($ability);
self::assertToolError(($args['execute_callback'])([]), 'divi_untested');
}
public function testALockThrownInsideAWriteBecomesTheLockError(): void
{
$locked = new DiviLocked(new ReleaseStatus(ReleaseState::Incompatible, '5.3.0', null, ['x']));
$ability = $this->ability();
$ability->allows('execute')->andThrow(WriteFailed::rolledBack('Nicht gespeichert.', $locked));
self::assertToolError($this->executeLogged($ability), 'divi_incompatible');
}
public function testAPartialWriteStaysPartialEvenWithALockInside(): void
{
$locked = new DiviLocked(new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
$ability = $this->ability();
$ability->allows('execute')->andThrow(WriteFailed::partial('Teilweise gespeichert.', $locked));
self::assertToolError($this->executeLogged($ability), 'partial_write');
}
/**
* Runs the execute callback with error_log() redirected (a PHP internal, not stubbable – like the tests above).
*/
private function executeLogged(Ability $ability): mixed
{
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $log);
try {
return ($this->registrarFor($ability)->argsFor($ability)['execute_callback'])([]);
} finally {
ini_set('error_log', (string) $previous);
unlink($log);
}
}
public function testDiviReadToolsGetWarningsInTheirSchema(): void
{
$ability = $this->ability();
$args = $this->diviRegistrar($ability)->argsFor($ability);
self::assertArrayHasKey('warnings', $args['output_schema']['properties']);
}
public function testRegisterAbilitiesCallsWordPressForEachAbility(): void
{
$ability = $this->ability();
+242
View File
@@ -0,0 +1,242 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Admin;
use Brain\Monkey\Functions;
use LIT\MCP\Admin\AdminPostAction;
use LIT\MCP\Admin\DiviTab;
use LIT\MCP\Integrations\Divi\Release\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CheckReportStore;
use LIT\MCP\Integrations\Divi\Release\CheckStep;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Integrations\Divi\Release\ReleaseStore;
use LIT\MCP\Plugin;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class DiviTabTest extends TestCase
{
private ?string $redirect = null;
protected function setUp(): void
{
parent::setUp();
$this->redirect = null;
Functions\stubEscapeFunctions();
Functions\stubTranslationFunctions();
Functions\when('current_user_can')->justReturn(true);
Functions\when('check_admin_referer')->justReturn(1);
Functions\when('get_current_user_id')->justReturn(1);
Functions\when('wp_safe_redirect')->alias(function (string $url): bool {
$this->redirect = $url;
return true;
});
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://k.de/wp-admin/' . $path);
Functions\when('add_query_arg')->alias(
static fn (array $args, string $url): string => $url . '?' . http_build_query($args)
);
}
private static function report(bool $green = true, string $version = '5.3.0', string $key = 'key'): CheckReport
{
$steps = array_map(
static fn (string $step): CheckStep => new CheckStep($step, $green ? [] : ['rot']),
[CheckStep::MANIFEST, CheckStep::MIRRORS, CheckStep::CATALOG, CheckStep::FORMAT, CheckStep::WRITE]
);
return new CheckReport('r1', '2026-10-01T08:00:00+00:00', 1, $version, Plugin::VERSION, $key, $steps);
}
private function tab(?CheckReport $report, ReleaseStore $releases, ?FakeChangeLog $log = null): DiviTab
{
$reports = Mockery::mock(CheckReportStore::class);
$reports->allows('latest')->andReturn($report);
return new DiviTab(
$reports,
$releases,
$log ?? FakeChangeLog::forbidden(),
new AdminPostAction(static function (): void {
}),
static fn (): string => '5.3.0',
static fn (string $version): string => 'key'
);
}
public function testReleaseNeedsAFreshGreenReport(): void
{
$reports = [null, self::report(false), self::report(true, '5.2.9'), self::report(true, '5.3.0', 'old')];
foreach ($reports as $report) {
$this->redirect = null;
$releases = Mockery::mock(ReleaseStore::class);
$releases->expects('add')->never();
$this->tab($report, $releases)->release();
self::assertStringContainsString('lit_mcp_notice=divi_release_refused', (string) $this->redirect);
}
}
public function testReleaseStoresTheEntryAndLogsIt(): void
{
$releases = Mockery::mock(ReleaseStore::class);
$releases->expects('add')->once()->with('5.3.0', Plugin::VERSION, 1, 'r1');
$log = new FakeChangeLog();
$this->tab(self::report(), $releases, $log)->release();
self::assertStringContainsString('lit_mcp_notice=divi_released', (string) $this->redirect);
self::assertSame('lit/divi-release', $log->records[0]['ability']);
self::assertStringContainsString('Divi 5.3.0', $log->records[0]['summary']);
}
public function testWithdrawRemovesTheEntryOfTheInstalledVersion(): void
{
$releases = Mockery::mock(ReleaseStore::class);
$releases->expects('withdraw')->once()->with('5.3.0', Plugin::VERSION)->andReturn(true);
$log = new FakeChangeLog();
$this->tab(null, $releases, $log)->withdraw();
self::assertStringContainsString('lit_mcp_notice=divi_withdrawn', (string) $this->redirect);
self::assertSame('lit/divi-release', $log->records[0]['ability']);
}
public function testActionsNeedNonceAndManageOptions(): void
{
Functions\when('current_user_can')->justReturn(false);
Functions\expect('wp_die')->twice()->andReturnUsing(static function (): never {
throw new \RuntimeException('died');
});
$releases = Mockery::mock(ReleaseStore::class);
$releases->expects('add')->never();
$releases->expects('withdraw')->never();
$tab = $this->tab(self::report(), $releases);
foreach (['release', 'withdraw'] as $method) {
try {
$tab->{$method}();
self::fail($method . ' without manage_options');
} catch (\RuntimeException $e) {
self::assertSame('died', $e->getMessage());
}
}
}
public function testReleaseChecksTheNonce(): void
{
Functions\when('check_admin_referer')->alias(static function (string $action): never {
throw new \RuntimeException('bad nonce for ' . $action);
});
$releases = Mockery::mock(ReleaseStore::class);
$releases->expects('add')->never();
$this->expectExceptionMessage('bad nonce for lit_mcp_divi_release');
$this->tab(self::report(), $releases)->release();
}
/** @return string The rendered tab. */
private function rendered(ReleaseStatus $status, ?CheckReport $report): string
{
ReleaseGate::useResolver(static fn (): ReleaseStatus => $status);
Functions\when('get_posts')->justReturn([]);
Functions\when('rest_url')->justReturn('https://k.de/wp-json/lit-mcp/v1/divi-check');
Functions\when('wp_create_nonce')->justReturn('n');
Functions\when('wp_nonce_field')->justReturn('');
Functions\when('submit_button')->alias(static function (string $text): void {
echo '<button>' . $text . '</button>';
});
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Jan']);
Functions\when('date_i18n')->justReturn('01.10.2026 10:00');
Functions\when('get_option')->justReturn('d.m.Y');
ob_start();
$this->tab($report, Mockery::mock(ReleaseStore::class))->render();
return (string) ob_get_clean();
}
public function testUntestedWithAFreshGreenReportOffersTheRelease(): void
{
$html = $this->rendered(new ReleaseStatus(ReleaseState::Untested, '5.3.0'), self::report());
self::assertStringContainsString('ungetestet', $html);
self::assertStringContainsString('Prüfung starten', $html);
self::assertStringContainsString('Divi 5.3.0 freigeben', $html);
self::assertStringContainsString('name="_wpnonce" value="n"', $html);
}
public function testUntestedWithAStaleReportAsksForANewCheck(): void
{
$html = $this->rendered(new ReleaseStatus(ReleaseState::Untested, '5.3.0'), self::report(true, '5.2.9'));
self::assertStringNotContainsString('Divi 5.3.0 freigeben', $html);
self::assertStringContainsString('Prüfung erneut ausführen', $html);
}
public function testReleasedFromCodeOffersNeitherReleaseNorWithdrawal(): void
{
$html = $this->rendered(ReleaseStatus::released('5.3.0'), self::report());
self::assertStringContainsString('freigegeben', $html);
self::assertStringContainsString('im Plugin als getestet hinterlegt', $html);
self::assertStringNotContainsString('freigeben</button>', $html);
self::assertStringNotContainsString('Freigabe zurückziehen', $html);
}
public function testADatabaseReleaseCanBeWithdrawn(): void
{
$status = new ReleaseStatus(ReleaseState::Released, '5.3.0', ReleaseStatus::SOURCE_DATABASE, [], [
'released_at' => '2026-10-01T08:00:00+00:00', 'user_id' => 1,
]);
self::assertStringContainsString('Freigabe zurückziehen', $this->rendered($status, self::report()));
}
public function testWithoutDiviShowsOnlyAShortNotice(): void
{
ReleaseGate::useResolver(static function (): ReleaseStatus {
self::fail('resolver called although Divi is not active');
});
$reports = Mockery::mock(CheckReportStore::class);
$reports->expects('latest')->never();
$tab = new DiviTab(
$reports,
Mockery::mock(ReleaseStore::class),
FakeChangeLog::forbidden(),
new AdminPostAction(static function (): void {
}),
static fn (): string => '',
static fn (string $version): string => 'key'
);
ob_start();
$tab->render();
$html = (string) ob_get_clean();
self::assertStringContainsString(
'Divi ist auf dieser Website nicht aktiv; die Divi-Freigabe ist ohne Bedeutung.',
$html
);
self::assertStringNotContainsString('Prüfung starten', $html);
}
public function testIncompatibleListsTheProblems(): void
{
$html = $this->rendered(
new ReleaseStatus(ReleaseState::Incompatible, '5.3.0', null, ['Theme Builder: Nachbau von x() – y']),
null
);
self::assertStringContainsString('inkompatibel', $html);
self::assertStringContainsString('Theme Builder: Nachbau von x() – y', $html);
self::assertStringContainsString('Plugin-Update', $html);
}
}
+1
View File
@@ -141,6 +141,7 @@ final class LogListTableTest extends TestCase
{
return [
['partial', 'warning', 'teilweise'],
['refused', 'warning', 'abgelehnt'],
['failed', 'error', 'fehlgeschlagen'],
['<b>kaputt', 'error', 'unbekannt'],
];
+28
View File
@@ -158,4 +158,32 @@ final class SettingsPageTest extends TestCase
$page->register();
}
/** @dataProvider diviNotices */
public function testDiviNoticesAreShown(string $key, string $class, string $text): void
{
Functions\when('current_user_can')->justReturn(true);
$_GET['lit_mcp_notice'] = $key;
ob_start();
(new SettingsPage([$this->tab('verbindung', 'Verbindung')]))->render();
$html = (string) ob_get_clean();
self::assertStringContainsString('notice-' . $class, $html);
self::assertStringContainsString($text, $html);
}
/** @return list<list<string>> */
public static function diviNotices(): array
{
return [
['divi_checked', 'success', 'Divi-Prüfung abgeschlossen'],
['divi_check_running', 'warning', 'bereits eine Divi-Prüfung'],
['divi_check_failed', 'error', 'PHP-Error-Log'],
['divi_released', 'success', 'für LIT MCP freigegeben'],
['divi_release_refused', 'error', 'Prüfung erneut ausführen'],
['divi_withdrawn', 'success', 'Freigabe zurückgezogen'],
['divi_withdraw_failed', 'error', 'keine Freigabe'],
];
}
}
+216
View File
@@ -0,0 +1,216 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Cli;
use Brain\Monkey\Functions;
use LIT\MCP\Cli\DiviCheckCommand;
use LIT\MCP\Integrations\Divi\Release\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CheckReportStore;
use LIT\MCP\Integrations\Divi\Release\CheckStep;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\DiviReference;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class DiviCheckCommandTest extends TestCase
{
/** @var list<string> */
private array $lines = [];
private string $themeDir = '';
private string $sourceDir = '';
private string $expectedFile = '';
protected function setUp(): void
{
parent::setUp();
$this->lines = [];
$base = sys_get_temp_dir() . '/lit-cli-' . uniqid();
$this->themeDir = $base . '/Divi';
$this->sourceDir = $base . '/reference';
mkdir($this->themeDir . '/includes/builder', 0777, true);
mkdir($this->sourceDir, 0777, true);
$this->expectedFile = $base . '/fingerprints.json';
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_current_user_id')->justReturn(1);
Functions\when('delete_transient')->justReturn(true);
}
protected function tearDown(): void
{
exec('rm -rf ' . escapeshellarg(dirname($this->themeDir)));
parent::tearDown();
}
/**
* @param array<string, list<string>> $problems Step key => problems.
* @param bool $expectSave True asserts save() is called exactly once (the green happy path); otherwise it is
* merely allowed, since some tests (e.g. no admin) never reach the save() call.
*/
private function command(
array $problems = [],
bool $expectSave = false,
bool $noSourceDir = false
): DiviCheckCommand {
$steps = array_map(
static fn (string $key): CheckStep => new CheckStep($key, $problems[$key] ?? []),
[CheckStep::MANIFEST, CheckStep::MIRRORS, CheckStep::CATALOG, CheckStep::FORMAT, CheckStep::WRITE]
);
$check = Mockery::mock(CompatibilityCheck::class);
$check->allows('run')->andReturn(new CheckReport('r1', 'd', 1, '5.3.0', '0.6.0', 'k', $steps));
$reports = Mockery::mock(CheckReportStore::class);
if ($expectSave) {
$reports->expects('save')->once();
} else {
$reports->allows('save');
}
return new DiviCheckCommand(
static fn () => $check,
$reports,
function (string $line): void {
$this->lines[] = $line;
},
fn (): ?string => $this->themeDir,
$this->expectedFile,
$noSourceDir ? static fn (): ?string => null : fn (): ?string => $this->sourceDir
);
}
public function testNeedsAnAdministrator(): void
{
Functions\when('current_user_can')->justReturn(false);
self::assertSame(1, $this->command()->run([]));
self::assertStringContainsString('--user=', implode("\n", $this->lines));
}
public function testPrintsTheStepsAndSucceedsWhenGreen(): void
{
self::assertSame(0, $this->command(expectSave: true)->run([]));
self::assertContains('✓ Schreibtest auf einer Wegwerfseite', $this->lines);
}
public function testFailsWhenAStepIsRed(): void
{
self::assertSame(1, $this->command([CheckStep::CATALOG => ['divi/text: unbekanntes Modul']])->run([]));
self::assertContains('✗ Modulkatalog (Kurzform-Pfade)', $this->lines);
self::assertContains(' divi/text: unbekanntes Modul', $this->lines);
}
public function testUpdateReferenceRefusesWhenMoreThanFingerprintsIsRed(): void
{
$code = $this->command([CheckStep::FORMAT => ['kaputt']])->run(['update-reference' => true]);
self::assertSame(1, $code);
self::assertFileDoesNotExist($this->expectedFile);
}
public function testUpdateReferenceRefusesWithoutADevelopmentSourceDir(): void
{
$code = $this->command(noSourceDir: true)->run(['update-reference' => true]);
self::assertSame(1, $code);
self::assertStringContainsString(
'Referenz nicht aktualisiert: nur in einer Entwicklungskopie mit tests/ möglich.',
implode("\n", $this->lines)
);
self::assertFileDoesNotExist($this->expectedFile);
}
public function testDiffShowsWhatChangedInAMirroredFunction(): void
{
file_put_contents(
$this->themeDir . '/includes/builder/shortcode-core.php',
"<?php\nfunction et_pb_submit_layout( \$args ) {\n\t\$a = 1;\n\treturn 2;\n}\n"
);
DiviReference::writeExpected(['library-submit-layout' => str_repeat('0', 64)], $this->expectedFile);
DiviReference::writeSource(
'library-submit-layout',
"function et_pb_submit_layout( \$args ) {\n\t\$a = 1;\n\treturn 1;\n}",
$this->sourceDir
);
$this->command([CheckStep::MIRRORS => ['Bibliothek: Nachbau von et_pb_submit_layout() – Terme']])
->run(['diff' => true]);
$output = implode("\n", $this->lines);
self::assertStringContainsString('library-submit-layout', $output);
self::assertStringContainsString("- \treturn 1;", $output);
self::assertStringContainsString("+ \treturn 2;", $output);
}
public function testUpdateReferenceWritesNothingWhenAMirrorIsUnreadable(): void
{
file_put_contents(
$this->themeDir . '/includes/builder/shortcode-core.php',
"<?php\nfunction et_pb_submit_layout( \$args ) {\n\treturn 2;\n}\n"
);
$code = $this->command([CheckStep::MIRRORS => ['geändert']])->run(['update-reference' => true]);
// The other mirrored Divi files are missing in the temp theme.
self::assertSame(1, $code);
self::assertStringContainsString('nicht lesbar', implode("\n", $this->lines));
self::assertFileDoesNotExist($this->expectedFile, 'all or nothing');
}
public function testUpdateReferenceWritesNothingWhenASourceIsUnwritable(): void
{
$this->writeDivi();
chmod($this->sourceDir, 0500);
try {
$code = $this->command([CheckStep::MIRRORS => ['geändert']])->run(['update-reference' => true]);
self::assertSame(1, $code);
self::assertStringContainsString(
'Referenz nicht vollständig aktualisiert:',
implode("\n", $this->lines)
);
self::assertFileDoesNotExist($this->expectedFile, 'sources are written before fingerprints.json');
} finally {
chmod($this->sourceDir, 0777);
}
}
public function testUpdateReferenceWritesFingerprintsAndSources(): void
{
$this->writeDivi();
$code = $this->command([CheckStep::MIRRORS => ['geändert']])->run(['update-reference' => true]);
self::assertSame(0, $code);
self::assertSame(
['library-submit-layout', 'tb-api-save', 'tb-load-setting-options', 'tb-store-template', 'vb-sync-flags'],
array_keys(DiviReference::expected($this->expectedFile))
);
self::assertStringStartsWith(
'function et_pb_submit_layout(',
(string) DiviReference::readSource('library-submit-layout', $this->sourceDir)
);
}
/** Minimal stand-ins for the five mirrored Divi functions at their real paths. */
private function writeDivi(): void
{
$files = [
'includes/builder/shortcode-core.php' => "function et_pb_submit_layout( \$args ) {\n\treturn 1;\n}",
'includes/builder/frontend-builder/theme-builder/api.php' => "function et_theme_builder_api_save() {\n}",
'includes/builder/frontend-builder/theme-builder/theme-builder.php' =>
"function et_theme_builder_store_template( \$a, \$b, \$c ) {\n}\n"
. "function et_theme_builder_load_template_setting_options( \$ids ) {\n}",
'includes/builder-5/server/VisualBuilder/REST/SyncToServer/SyncToServerController.php' =>
"class SyncToServerController {\n\tpublic static function update( \$request ) {\n\t}\n}",
];
foreach ($files as $path => $code) {
$file = $this->themeDir . '/' . $path;
if (! is_dir(dirname($file))) {
mkdir(dirname($file), 0777, true);
}
file_put_contents($file, "<?php\n" . $code . "\n");
}
}
}
@@ -26,4 +26,22 @@ final class EnvironmentProbeTest extends TestCase
self::assertSame(['GlobalLayout'], $components['divi']['problems']);
self::assertSame([], $components['pods']['problems']);
}
public function testDiviComponentCarriesTheReleaseStatus(): void
{
$theme = Mockery::mock();
$theme->allows('get_template')->andReturn('Divi');
$theme->allows('parent')->andReturn(false);
$theme->allows('get')->with('Version')->andReturn('5.2.1');
Functions\when('wp_get_theme')->justReturn($theme);
$components = array_column(
(new EnvironmentProbe(new DiviCompatibility([])))->components(),
null,
'id'
);
self::assertSame('released', $components['divi']['release']['state'] ?? null);
self::assertNull($components['pods']['release'] ?? null);
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Tests\Unit\TestCase;
final class DesignSystemReaderTest extends TestCase
{
public function testIsTheRegisteredDesignAdapter(): void
{
$manifest = DesignSystemReader::manifest();
self::assertContains(DesignSystemReader::class, DiviAdapters::CLASSES);
self::assertSame(Manifest::DESIGN, $manifest->area);
self::assertSame(
['GlobalData::get_global_colors()', 'GlobalData::get_global_variables()',
'GlobalPreset::get_data()'],
array_map(static fn (Call $call): string => $call->label(), $manifest->calls)
);
self::assertSame(['et_divi'], $manifest->keys);
}
public function testDiviLogoReadsTheThemeOptions(): void
{
Functions\when('get_option')->alias(
static fn (string $name): mixed => $name === 'et_divi' ?
['divi_logo' => 'https://k.de/logo.svg'] : false
);
self::assertSame('https://k.de/logo.svg', DesignSystemReader::diviLogo());
}
public function testDiviLogoIsEmptyWithoutOptions(): void
{
Functions\when('get_option')->justReturn(false);
self::assertSame('', DesignSystemReader::diviLogo());
}
}
@@ -5,6 +5,10 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi;
use LIT\MCP\Integrations\Divi\DiviCompatibility;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\ManifestCheck;
use LIT\MCP\Integrations\Divi\Release\Mirror;
use PHPUnit\Framework\TestCase;
use RuntimeException;
@@ -23,34 +27,35 @@ final class DiviCompatibilityTest extends TestCase
self::assertSame(['B', 'C'], $compatibility->problems());
}
public function testDefaultsCoverTheDiviInternalsInUse(): void
public function testDefaultsAreCodeRegistrationsAndFingerprints(): void
{
$check = new ManifestCheck(
['X' => new Manifest(
Manifest::LIBRARY,
calls: [new Call('strlen', 1)],
postTypes: ['et_pb_layout'],
mirrors: [new Mirror('m', 'a.php', 'a', null, 'Terme')]
)],
[],
static fn (): ?string => null,
static fn (): bool => true
);
self::assertSame(
[
'ModuleRegistration (Modul-Katalog)',
'DynamicContentOptions (Dynamic Content)',
'et_pb_is_allowed (Divi-Rollen)',
'Post-Typ et_pb_layout (Bibliothek)',
'Taxonomien scope, layout_type, module_width (Bibliothek)',
'GlobalLayout::is_global_layout_template (globale Module)',
'ET_Core_PageResource (CSS-Cache)',
'Block-Typ divi/global-layout (Instanzen globaler Module)',
'Taxonomien layout_category, layout_tag (Bibliothek)',
'Theme Builder: Funktionen et_theme_builder_* und et_()',
'Theme Builder: Post-Typen et_template und Layouts',
],
array_keys(DiviCompatibility::defaults())
['Bibliothek: strlen()', 'Bibliothek: Post-Typ et_pb_layout', 'Bibliothek: Nachbau von a() – Terme'],
array_keys(DiviCompatibility::defaults($check))
);
}
/** Review M-2: et_theme_builder_clear_wp_cache is one of DiviThemeBuilder::FUNCTIONS – reported once. */
public function testChecksTheThemeBuilderInternals(): void
public function testRealDefaultsCoverEveryAdapterArea(): void
{
$labels = array_keys(DiviCompatibility::defaults());
$labels = implode("\n", array_keys(DiviCompatibility::defaults()));
self::assertNotContains('et_theme_builder_clear_wp_cache (Theme-Builder-Cache)', $labels);
self::assertContains('Theme Builder: Funktionen et_theme_builder_* und et_()', $labels);
self::assertContains('Theme Builder: Post-Typen et_template und Layouts', $labels);
$areas = [Manifest::PAGES, Manifest::LIBRARY, Manifest::THEME_BUILDER, Manifest::DESIGN, Manifest::CATALOG];
foreach ($areas as $area) {
self::assertStringContainsString($area . ': ', $labels);
}
self::assertStringContainsString('Theme Builder: Nachbau von et_theme_builder_api_save()', $labels);
self::assertStringContainsString('Katalog: Block-Typ divi/global-layout', $labels);
}
}
@@ -8,6 +8,8 @@ use Brain\Monkey\Functions;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Library\LibraryItemSpec;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\WordPress\Content\NewDraft;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
@@ -114,6 +116,32 @@ final class DiviLibraryStoreTest extends TestCase
self::assertSame(12, $captured['author']);
}
public function testIsTheRegisteredLibraryAdapterWithTheSubmitLayoutMirror(): void
{
$manifest = DiviLibraryStore::manifest();
self::assertContains(DiviLibraryStore::class, DiviAdapters::CLASSES);
self::assertSame(Manifest::LIBRARY, $manifest->area);
self::assertSame(['et_pb_layout'], $manifest->postTypes);
self::assertSame(
['scope', 'layout_type', 'module_width', 'layout_category', 'layout_tag'],
$manifest->taxonomies
);
self::assertSame(['library-submit-layout'], array_map(static fn ($m): string => $m->id, $manifest->mirrors));
self::assertSame('et_pb_submit_layout()', $manifest->mirrors[0]->label());
}
public function testBuiltForReadsTheSourceType(): void
{
Functions\when('get_post_meta')->alias(
static fn (int $id, string $key): string => $id === 1054
&& $key === '_et_pb_built_for_post_type' ? 'post' : ''
);
self::assertSame('post', DiviLibraryStore::builtFor(1054));
self::assertSame('', DiviLibraryStore::builtFor(1055));
}
public function testReportsMissingTaxonomies(): void
{
Functions\when('taxonomy_exists')->alias(static fn (string $taxonomy): bool => $taxonomy !== 'scope');
@@ -26,4 +26,10 @@ final class DiviRolesTest extends TestCase
// Unit tests have no et_pb_is_allowed(): a missing Divi function never means "allowed".
self::assertFalse((new DiviRoles())->allows('divi_library'));
}
public function testIsTheRegisteredRolesAdapter(): void
{
self::assertContains(DiviRoles::class, \LIT\MCP\Integrations\Divi\Release\DiviAdapters::CLASSES);
self::assertSame('et_pb_is_allowed()', DiviRoles::manifest()->calls[0]->label());
}
}
@@ -133,7 +133,7 @@ final class GlobalEditsTest extends TestCase
);
}
public function testPlainLibraryItemIsSavedWithoutConfirmAndWithoutDiviFlags(): void
public function testPlainLibraryItemIsSavedWithoutConfirmAndWithoutPageFlags(): void
{
LibraryFixtures::given([LibraryFixtures::item(1057)]);
@@ -563,7 +563,7 @@ final class GlobalEditsTest extends TestCase
}
/** Ruling P1: lit-divi-replace-text saves library entries through DiviWriter::commit(). */
public function testReplaceTextSavesAPlainLibraryItemWithoutDiviFlags(): void
public function testReplaceTextSavesAPlainLibraryItemWithoutPageFlags(): void
{
LibraryFixtures::given([LibraryFixtures::item(1057)]);
@@ -8,6 +8,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Library\LibraryItemSpec;
use LIT\MCP\Tests\Support\DiviFixtures;
use PHPUnit\Framework\TestCase;
@@ -60,7 +61,7 @@ final class LibraryItemSpecTest extends TestCase
self::assertEquals($expectedTerms, $spec->terms());
$expectedMeta = array_map(static fn (array $values): string => $values[0], $json['meta']);
unset($expectedMeta['_et_pb_row_layout']);
self::assertEquals($expectedMeta, $spec->meta());
self::assertEquals($expectedMeta, DiviLibraryStore::metaFor($spec));
}
public function testRefusesInstancesColumnsAndGlobalsInsideGlobals(): void
@@ -7,6 +7,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Library\LibraryItemSpec;
use LIT\MCP\Integrations\Divi\Library\LibrarySaveAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
@@ -112,8 +113,8 @@ final class LibrarySaveAbilityTest extends TestCase
self::assertTrue($result['ok']);
self::assertTrue($this->specs[0]->global);
self::assertSame('divi/button', $this->specs[0]->meta()['_et_pb_module_type']);
self::assertSame('[]', $this->specs[0]->meta()['_et_pb_excluded_global_options']);
self::assertSame('divi/button', DiviLibraryStore::metaFor($this->specs[0])['_et_pb_module_type']);
self::assertSame('[]', DiviLibraryStore::metaFor($this->specs[0])['_et_pb_excluded_global_options']);
}
public function testRefusesAGlobalInstanceAndColumnsAndCreatesNothing(): void
@@ -1,58 +0,0 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Pages\DiviFlags;
use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException;
final class DiviFlagsTest extends TestCase
{
public function testSetsBothFlagsAndAcceptsAnUnchangedValue(): void
{
$written = [];
// update_post_meta() is false when the value is already "on" – no failure (ruling S11).
Functions\when('update_post_meta')->alias(static function (int $id, string $key, string $value) use (&$written): bool {
$written[] = "$id:$key=$value";
return false;
});
Functions\when('get_post_meta')->alias(
static fn (int $id, string $key): string => $key === '_et_pb_show_page_creation' ? 'off' : 'on'
);
DiviFlags::set(7);
// Like Divi's own save (SyncToServerController.php:382-387), incl. the page creation flow flag.
self::assertSame(
['7:_et_pb_use_divi_5=on', '7:_et_pb_use_builder=on', '7:_et_pb_show_page_creation=off'],
$written
);
}
public function testThrowsWhenAFlagDoesNotReadBack(): void
{
Functions\when('update_post_meta')->justReturn(true);
Functions\when('get_post_meta')->alias(
static fn (int $id, string $key): string => $key === '_et_pb_use_builder' ? 'off' : 'on'
);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('_et_pb_use_builder');
DiviFlags::set(7);
}
public function testThrowsWhenThePageCreationFlagDoesNotReadBack(): void
{
Functions\when('update_post_meta')->justReturn(true);
Functions\when('get_post_meta')->justReturn('on');
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('_et_pb_show_page_creation');
DiviFlags::set(7);
}
}
@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Pages\DiviPages;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException;
use WP_Post;
final class DiviPagesTest extends TestCase
{
public function testSetsBothFlagsAndAcceptsAnUnchangedValue(): void
{
$written = [];
// update_post_meta() is false when the value is already "on" – no failure (ruling S11).
Functions\when('update_post_meta')->alias(static function (int $id, string $key, string $value) use (&$written): bool {
$written[] = "$id:$key=$value";
return false;
});
Functions\when('get_post_meta')->alias(
static fn (int $id, string $key): string => $key === '_et_pb_show_page_creation' ? 'off' : 'on'
);
DiviPages::setFlags(7);
// Like Divi's own save (SyncToServerController::update, Mirror vb-sync-flags), incl. the page creation flag.
self::assertSame(
['7:_et_pb_use_divi_5=on', '7:_et_pb_use_builder=on', '7:_et_pb_show_page_creation=off'],
$written
);
}
public function testThrowsWhenAFlagDoesNotReadBack(): void
{
Functions\when('update_post_meta')->justReturn(true);
Functions\when('get_post_meta')->alias(
static fn (int $id, string $key): string => $key === '_et_pb_use_builder' ? 'off' : 'on'
);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('_et_pb_use_builder');
DiviPages::setFlags(7);
}
public function testThrowsWhenThePageCreationFlagDoesNotReadBack(): void
{
Functions\when('update_post_meta')->justReturn(true);
Functions\when('get_post_meta')->justReturn('on');
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('_et_pb_show_page_creation');
DiviPages::setFlags(7);
}
public function testIsTheRegisteredPagesAdapterWithTheFlagMirror(): void
{
$manifest = DiviPages::manifest();
self::assertContains(DiviPages::class, DiviAdapters::CLASSES);
self::assertSame(Manifest::PAGES, $manifest->area);
self::assertSame(['vb-sync-flags'], array_map(static fn ($mirror): string => $mirror->id, $manifest->mirrors));
self::assertSame('SyncToServerController::update()', $manifest->mirrors[0]->label());
}
public function testUsesBuilderReadsDivisFlag(): void
{
Functions\when('get_post_meta')->alias(
static fn (int $id, string $key): string => $id === 7 && $key === '_et_pb_use_builder' ? 'on' : ''
);
self::assertTrue(DiviPages::usesBuilder(7));
self::assertFalse(DiviPages::usesBuilder(8));
}
public function testBuilderUrlOpensTheVisualBuilder(): void
{
Functions\when('get_permalink')->justReturn('https://k.de/start/');
Functions\when('add_query_arg')->alias(
static fn (string $key, string $value, string $url): string => $url . '?' . $key . '=' . $value
);
self::assertSame('https://k.de/start/?et_fb=1', DiviPages::builderUrl(new WP_Post(['ID' => 7])));
}
public function testClearPageCssRemovesThePagesStaticFiles(): void
{
\ET_Core_PageResource::$removed = [];
DiviPages::clearPageCss(7);
self::assertSame([[7, 'all', true]], \ET_Core_PageResource::$removed);
}
/**
* Own process (Brain Monkey's FunctionStub eval's a real global function the first time a name is
* stubbed – function_exists() would report it as permanently defined for the rest of the run
* otherwise, breaking WpLinkTargetsTest's "without Divi" case).
*
* @runInSeparateProcess
*/
public function testIsLinkableFollowsDivisPublicPostTypes(): void
{
Functions\when('et_builder_get_public_post_types')->justReturn(['page' => 'Seiten', 'post' => 'Beiträge']);
self::assertTrue(DiviPages::isLinkable('page'));
self::assertFalse(DiviPages::isLinkable('product'));
}
}
@@ -62,7 +62,7 @@ final class DiviSaverTest extends TestCase
parent::tearDown();
}
public function testSavesWithoutKsesForFilteredUsersAndSetsDiviFlags(): void
public function testSavesWithoutKsesForFilteredUsersAndSetsThePageFlags(): void
{
Functions\when('current_user_can')->justReturn(false);
Functions\when('has_filter')->justReturn(10);
@@ -269,7 +269,7 @@ final class DiviSaverTest extends TestCase
(new DiviSaver())->saveNew(42, 'NEU');
}
public function testLibraryItemsGetNoDiviFlagsAndGlobalOnesClearEveryPage(): void
public function testLibraryItemsGetNoPageFlagsAndGlobalOnesClearEveryPage(): void
{
\ET_Core_PageResource::$removed = [];
Functions\when('current_user_can')->justReturn(true);
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\Release\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CheckStep;
use PHPUnit\Framework\TestCase;
final class CheckReportTest extends TestCase
{
private static function report(array $steps): CheckReport
{
return new CheckReport('r1', '2026-10-01T08:00:00+00:00', 1, '5.3.0', '0.6.0', 'key', $steps);
}
private static function green(): array
{
return array_map(
static fn (string $key): CheckStep => new CheckStep($key, []),
[CheckStep::MANIFEST, CheckStep::MIRRORS, CheckStep::CATALOG, CheckStep::FORMAT, CheckStep::WRITE]
);
}
public function testGreenOnlyWithEveryStepGreenAndNoneSkipped(): void
{
self::assertTrue(self::report(self::green())->green());
self::assertFalse(self::report([])->green());
$red = self::green();
$red[2] = new CheckStep(CheckStep::CATALOG, ['divi/text: unbekanntes Modul']);
self::assertFalse(self::report($red)->green());
$skipped = self::green();
$skipped[4] = new CheckStep(CheckStep::WRITE, [], true);
self::assertFalse(self::report($skipped)->green());
}
public function testFreshOnlyForTheSameVersionsAndStateKey(): void
{
$report = self::report(self::green());
self::assertTrue($report->freshFor('5.3.0', '0.6.0', 'key'));
self::assertFalse($report->freshFor('5.3.1', '0.6.0', 'key'), 'Divi updated');
self::assertFalse($report->freshFor('5.3.0', '0.7.0', 'key'), 'plugin updated');
self::assertFalse($report->freshFor('5.3.0', '0.6.0', 'other'), 'a Divi file changed');
}
public function testRoundTripsThroughAnArray(): void
{
$steps = self::green();
$steps[1] = new CheckStep(CheckStep::MIRRORS, ['Theme Builder: Nachbau von x() – y']);
$report = self::report($steps);
self::assertEquals($report, CheckReport::fromArray($report->toArray()));
self::assertNull(CheckReport::fromArray('broken'));
self::assertNull(CheckReport::fromArray(['id' => 'r1']));
self::assertSame('Fingerabdrücke nachgebauter Divi-Funktionen', $report->step(CheckStep::MIRRORS)?->label());
}
}
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Release\CheckStep;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\FormatCheck;
use LIT\MCP\Integrations\Divi\Release\ManifestCheck;
use LIT\MCP\Integrations\Divi\Release\WriteProbe;
use LIT\MCP\Plugin;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
final class CompatibilityCheckTest extends TestCase
{
private function check(
array $code,
array $mirrors,
?WriteProbe $probe = null,
?\Closure $catalog = null
): CompatibilityCheck {
Functions\when('wp_generate_uuid4')->justReturn('uuid-1');
$manifests = Mockery::mock(ManifestCheck::class);
$manifests->allows('codeChecks')->andReturn($code);
$manifests->allows('registrationChecks')->andReturn([]);
$manifests->allows('mirrorChecks')->andReturn($mirrors);
$format = Mockery::mock(FormatCheck::class);
$format->allows('problems')->andReturn([]);
if ($probe === null) {
$probe = Mockery::mock(WriteProbe::class);
$probe->allows('run')->andReturn([]);
}
return new CompatibilityCheck(
$manifests,
$catalog ?? static fn (): array => [],
$format,
$probe,
static fn (): string => '5.3.0',
static fn (string $version): string => 'key-' . $version
);
}
public function testAGreenRunNamesVersionsAndStateKey(): void
{
$report = $this->check([], [])->run(7);
self::assertTrue($report->green());
self::assertSame(['uuid-1', 7, '5.3.0', Plugin::VERSION, 'key-5.3.0'], [
$report->id, $report->userId, $report->diviVersion, $report->pluginVersion, $report->stateKey,
]);
self::assertSame(
['manifest', 'mirrors', 'catalog', 'format', 'write'],
array_map(static fn (CheckStep $step): string => $step->key, $report->steps)
);
}
public function testNoWriteTestWhenDiviFunctionsAreMissing(): void
{
$probe = Mockery::mock(WriteProbe::class);
$probe->expects('run')->never();
$report = $this->check(['Seiten: et_x()' => static fn (): bool => false], [], $probe)->run(7);
self::assertSame(['Seiten: et_x()'], $report->step(CheckStep::MANIFEST)?->problems);
self::assertTrue($report->step(CheckStep::WRITE)?->skipped);
}
public function testChangedFingerprintsStillRunTheWriteTest(): void
{
$probe = Mockery::mock(WriteProbe::class);
$probe->expects('run')->once()->andReturn([]);
$report = $this->check([], ['TB: Nachbau von y()' => static fn (): bool => false], $probe)->run(7);
self::assertFalse($report->green());
self::assertTrue($report->step(CheckStep::WRITE)?->green());
}
public function testAThrowingStepIsARedStep(): void
{
$report = $this->check([], [], null, static function (): never {
throw new RuntimeException('Katalog weg');
})->run(7);
self::assertSame(['Prüfschritt abgebrochen: Katalog weg'], $report->step(CheckStep::CATALOG)?->problems);
}
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use FilesystemIterator;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\DiviTouches;
use PHPUnit\Framework\TestCase;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use ReflectionClass;
/**
* Spec §3: only the adapters in DiviAdapters::CLASSES touch Divi, and every Divi name an adapter uses stands in its
* manifest (and nothing more).
*/
final class DiviBoundaryTest extends TestCase
{
public function testOnlyAdaptersTouchDivi(): void
{
$offenders = [];
foreach (self::sourceFiles() as $relative => $path) {
$touches = DiviTouches::inFile($path);
if ($touches === []) {
continue;
}
if (! in_array(self::classOf($relative), DiviAdapters::CLASSES, true)) {
$offenders[] = $relative . ': ' . implode(', ', array_unique(array_column($touches, 'name')));
}
}
self::assertSame([], $offenders, "Divi darf nur ein Adapter aus DiviAdapters::CLASSES berühren:\n"
. implode("\n", $offenders));
}
public function testAdaptersDeclareExactlyTheDiviNamesTheyUse(): void
{
foreach (DiviAdapters::CLASSES as $class) {
self::assertTrue(is_subclass_of($class, DiviAdapter::class), $class . ' implementiert DiviAdapter nicht');
$declared = $class::manifest()->names();
$used = array_values(array_unique(array_column(
DiviTouches::inFile((string) (new ReflectionClass($class))->getFileName()),
'name'
)));
self::assertSame([], array_values(array_diff($used, $declared)), $class . ': nicht im Manifest');
self::assertSame(
[],
array_values(array_diff(array_filter($declared, [DiviTouches::class, 'detects']), $used)),
$class . ': im Manifest, aber nicht benutzt'
);
}
}
public function testMirrorIdsAreUniqueAndFilesRelative(): void
{
$ids = [];
foreach (DiviAdapters::mirrors() as $mirror) {
self::assertNotContains($mirror->id, $ids, 'doppelte Mirror-ID ' . $mirror->id);
self::assertStringStartsNotWith('/', $mirror->file);
$ids[] = $mirror->id;
}
}
/** @return array<string, string> Path below src/ => absolute path. */
private static function sourceFiles(): array
{
$root = dirname(__DIR__, 5) . '/src/';
$files = [];
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS));
foreach ($iterator as $file) {
if ($file->getExtension() === 'php') {
$files[substr((string) $file->getPathname(), strlen($root))] = (string) $file->getPathname();
}
}
ksort($files);
return $files;
}
private static function classOf(string $relative): string
{
return 'LIT\\MCP\\' . str_replace(['/', '.php'], ['\\', ''], $relative);
}
}
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Release\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CheckReportStore;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
final class DiviCheckRouteTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://k.de/wp-admin/' . $path);
Functions\when('add_query_arg')->alias(
static fn (array $args, string $url): string => $url . '?' . http_build_query($args)
);
Functions\when('get_current_user_id')->justReturn(1);
}
protected function tearDown(): void
{
unset($GLOBALS['wpdb']);
parent::tearDown();
}
/** Mock of the global $wpdb, its INSERT IGNORE returning $inserted rows affected. */
private static function wpdb(int $inserted): \wpdb
{
$db = Mockery::mock(\wpdb::class)->makePartial();
$db->expects('query')->once()->with(Mockery::on(static function (string $sql): bool {
return str_starts_with(
$sql,
"INSERT IGNORE INTO wp_options (option_name, option_value, autoload) "
. "VALUES ('lit_mcp_divi_check_lock', '"
) && str_ends_with($sql, "', 'no')");
}))->andReturn($inserted);
$GLOBALS['wpdb'] = $db;
return $db;
}
public function testRegistersAPostRouteForAdministratorsOnly(): void
{
Functions\expect('register_rest_route')->once()->with(
'lit-mcp/v1',
'/divi-check',
Mockery::on(static function (array $args): bool {
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'manage_options');
$allowed = ($args['permission_callback'])();
Functions\when('current_user_can')->justReturn(false);
return $args['methods'] === 'POST' && $allowed === true && ($args['permission_callback'])() === false;
})
);
(new DiviCheckRoute(static fn () => null))->registerRoute();
}
public function testRunsTheCheckStoresTheReportAndRedirectsToTheTab(): void
{
self::wpdb(1);
$report = new CheckReport('r1', 'd', 1, '5.3.0', '0.6.0', 'k', []);
$check = Mockery::mock(CompatibilityCheck::class);
$check->expects('run')->with(1)->andReturn($report);
$reports = Mockery::mock(CheckReportStore::class);
$reports->expects('save')->with($report);
Functions\expect('delete_option')->once()->with(DiviCheckRoute::LOCK);
$response = (new DiviCheckRoute(static fn () => $check, $reports))->handle();
self::assertSame(303, $response->get_status());
self::assertStringContainsString('tab=divi', $response->get_headers()['Location']);
self::assertStringContainsString('lit_mcp_notice=divi_checked', $response->get_headers()['Location']);
}
public function testASecondCheckWaitsForTheFirst(): void
{
$db = self::wpdb(0);
$db->expects('get_var')->once()->andReturn((string) time());
Functions\expect('delete_option')->never();
$reports = Mockery::mock(CheckReportStore::class);
$reports->expects('save')->never();
$response = (new DiviCheckRoute(static fn () => self::fail('no second check'), $reports))->handle();
self::assertStringContainsString('lit_mcp_notice=divi_check_running', $response->get_headers()['Location']);
}
public function testAStaleLockIsTakenOver(): void
{
$db = self::wpdb(0);
$db->expects('get_var')->once()->andReturn('1');
$db->expects('query')->once()->with(Mockery::on(static function (string $sql): bool {
return str_starts_with($sql, "UPDATE wp_options SET option_value = '")
&& str_ends_with(
$sql,
"' WHERE option_name = 'lit_mcp_divi_check_lock' AND option_value = '1'"
);
}))->andReturn(1);
Functions\expect('delete_option')->once()->with(DiviCheckRoute::LOCK);
$check = Mockery::mock(CompatibilityCheck::class);
$check->expects('run')->andReturn(new CheckReport('r1', 'd', 1, '5.3.0', '0.6.0', 'k', []));
$reports = Mockery::mock(CheckReportStore::class);
$reports->expects('save');
$response = (new DiviCheckRoute(static fn () => $check, $reports))->handle();
self::assertStringContainsString('lit_mcp_notice=divi_checked', $response->get_headers()['Location']);
}
public function testATakeoverLostToAnotherRequestWaitsForIt(): void
{
$db = self::wpdb(0);
$db->expects('get_var')->once()->andReturn('1');
$db->expects('query')->once()->with(Mockery::on(static fn (string $sql): bool => str_starts_with(
$sql,
"UPDATE wp_options SET option_value = '"
)))->andReturn(0);
Functions\expect('delete_option')->never();
$reports = Mockery::mock(CheckReportStore::class);
$reports->expects('save')->never();
$response = (new DiviCheckRoute(static fn () => self::fail('no second check'), $reports))->handle();
self::assertStringContainsString('lit_mcp_notice=divi_check_running', $response->get_headers()['Location']);
}
public function testTheLockIsReleasedAndTheCheckMarkedFailedWhenItThrows(): void
{
self::wpdb(1);
Functions\expect('delete_option')->once()->with(DiviCheckRoute::LOCK);
$check = Mockery::mock(CompatibilityCheck::class);
$check->expects('run')->andThrow(new RuntimeException('divi kaputt'));
$reports = Mockery::mock(CheckReportStore::class);
$reports->expects('save')->never();
// error_log() is a PHP internal and cannot be stubbed; redirect it to a temp file instead.
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $log);
try {
$response = (new DiviCheckRoute(static fn () => $check, $reports))->handle();
} finally {
ini_set('error_log', (string) $previous);
}
self::assertStringContainsString('lit_mcp_notice=divi_check_failed', $response->get_headers()['Location']);
self::assertStringContainsString(
'divi check failed: RuntimeException: divi kaputt',
(string) file_get_contents($log)
);
unlink($log);
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\DiviReference;
use PHPUnit\Framework\TestCase;
final class DiviReferenceTest extends TestCase
{
public function testEveryMirrorHasAnExpectedFingerprintAndAReferenceSource(): void
{
$ids = array_map(static fn ($mirror): string => $mirror->id, DiviAdapters::mirrors());
sort($ids);
$expected = DiviReference::expected();
self::assertSame($ids, array_keys($expected));
foreach ($expected as $id => $hash) {
self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $hash, $id);
self::assertNotNull(DiviReference::readSource($id, (string) DiviReference::sourceDir()), $id);
}
}
public function testWritesAndReadsExpectedFingerprintsSorted(): void
{
$file = (string) tempnam(sys_get_temp_dir(), 'lit');
try {
DiviReference::writeExpected(['b' => str_repeat('b', 64), 'a' => str_repeat('a', 64)], $file);
self::assertSame(['a' => str_repeat('a', 64), 'b' => str_repeat('b', 64)], DiviReference::expected($file));
self::assertStringEndsWith("\n", (string) file_get_contents($file));
} finally {
unlink($file);
}
}
public function testMissingOrBrokenFileMeansNoExpectations(): void
{
self::assertSame([], DiviReference::expected('/nonexistent/fingerprints.json'));
}
}
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Integrations\Divi\Release\DiviToolGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Error;
final class DiviToolGateTest extends TestCase
{
private const GROUPS = [
'lit/divi-insert' => 'divi',
'lit/divi-outline' => 'divi',
'lit/library-save' => 'divi-library',
'lit/tb-create-template' => 'divi-theme-builder',
'lit/content-create' => 'wordpress',
];
private function ability(string $name, bool $readonly): Ability
{
$ability = Mockery::mock(Ability::class);
$ability->allows('name')->andReturn($name);
$ability->allows('annotations')->andReturn(['readonly' => $readonly]);
return $ability;
}
private function gate(?FakeChangeLog $log = null): DiviToolGate
{
return new DiviToolGate(static fn (): array => self::GROUPS, $log);
}
private function untested(): void
{
ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
}
public function testReleasedDiviRefusesNothingAndAddsNothing(): void
{
$gate = $this->gate(FakeChangeLog::forbidden());
self::assertNull($gate->refusal($this->ability('lit/divi-insert', false)));
self::assertSame(['ok' => true], $gate->annotate($this->ability('lit/divi-outline', true), ['ok' => true]));
}
public function testWriteToolOfEveryDiviGroupIsRefused(): void
{
$this->untested();
$log = new FakeChangeLog();
$gate = $this->gate($log);
foreach (['lit/divi-insert', 'lit/library-save', 'lit/tb-create-template'] as $name) {
self::assertToolError($gate->refusal($this->ability($name, false)), 'divi_untested', $name);
}
self::assertNull($gate->refusal($this->ability('lit/content-create', false)), 'not a Divi tool (adapter lock)');
self::assertNull($gate->refusal($this->ability('lit/divi-outline', true)), 'reading stays allowed');
self::assertSame(['refused', 'refused', 'refused'], array_column($log->records, 'result'));
self::assertSame('Abgelehnt: Divi 5.3.0 ist nicht freigegeben.', $log->records[0]['summary']);
}
public function testReadResultsKeepTheirShapeAndGetTheNotice(): void
{
$this->untested();
$gate = $this->gate();
$read = $this->ability('lit/divi-outline', true);
$notice = 'Achtung: Divi 5.3.0 ist für LIT MCP ungetestet; Lesen funktioniert, Schreiben ist gesperrt.';
self::assertSame(['post_id' => 7, 'warnings' => [$notice]], $gate->annotate($read, ['post_id' => 7]));
self::assertSame(
['warnings' => ['alt', $notice]],
$gate->annotate($read, ['warnings' => ['alt']])
);
self::assertSame([1, 2], $gate->annotate($read, [1, 2]), 'a list stays a list');
$error = new WP_Error('lit_not_found', 'x');
self::assertSame($error, $gate->annotate($read, $error));
$other = $this->ability('lit/content-create', true);
self::assertSame(['post_id' => 7], $gate->annotate($other, ['post_id' => 7]));
}
public function testOutputSchemaAllowsWarnings(): void
{
$gate = $this->gate();
$read = $this->ability('lit/divi-outline', true);
$properties = ['post_id' => ['type' => 'integer']];
$schema = $gate->outputSchema($read, ['type' => 'object', 'properties' => $properties]);
$warnings = ['type' => 'array', 'items' => ['type' => 'string']];
self::assertSame($warnings, $schema['properties']['warnings'] ?? null);
$own = ['type' => 'object', 'properties' => ['warnings' => ['type' => 'array']]];
self::assertSame($own, $gate->outputSchema($read, $own), 'an existing warnings property stays');
self::assertNull($gate->outputSchema($read, null));
self::assertSame(
['type' => 'object'],
$gate->outputSchema($this->ability('lit/content-create', true), ['type' => 'object'])
);
}
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\Release\DiviTouches;
use PHPUnit\Framework\TestCase;
final class DiviTouchesTest extends TestCase
{
public function testFindsCallsClassesConstantsAndNames(): void
{
$source = <<<'PHP'
<?php
use ET\Builder\Packages\GlobalData\GlobalData;
// et_comment_only() and 'et_in_a_comment' are ignored.
/** @see et_theme_builder_get_template() */
final class X {
public const REGISTRY = 'ET\Builder\Packages\Module\DynamicContentOptions';
public function a(): void {
et_pb_is_allowed('theme_builder');
\et_theme_builder_clear_wp_cache('all');
\ET_Core_PageResource::remove_static_resources(1, 'all', true);
$v = defined('ET_BUILDER_PRODUCT_VERSION') ? ET_BUILDER_PRODUCT_VERSION : '';
get_post_meta(1, '_et_pb_use_builder', true);
function_exists('et_');
$this->et_method();
Foo::et_static();
get_option('lit_mcp_limits');
}
}
PHP;
$touches = array_map(
static fn (array $t): string => $t['kind'] . ':' . $t['name'],
DiviTouches::inSource($source)
);
self::assertSame(
[
'class:ET\Builder\Packages\GlobalData\GlobalData',
'class:ET\Builder\Packages\Module\DynamicContentOptions',
'function:et_pb_is_allowed',
'function:et_theme_builder_clear_wp_cache',
'class:ET_Core_PageResource',
'constant:ET_BUILDER_PRODUCT_VERSION',
'constant:ET_BUILDER_PRODUCT_VERSION',
'string:_et_pb_use_builder',
'string:et_',
],
$touches
);
}
public function testDetectsOnlyNamesTheScannerReports(): void
{
self::assertTrue(DiviTouches::detects('et_pb_layout'));
self::assertTrue(DiviTouches::detects('_et_template'));
self::assertTrue(DiviTouches::detects('ET\Builder\Packages\GlobalData\GlobalData'));
self::assertTrue(DiviTouches::detects('ET_Core_PageResource'));
self::assertTrue(DiviTouches::detects('ET_BUILDER_PRODUCT_VERSION'));
self::assertFalse(DiviTouches::detects('scope'));
self::assertFalse(DiviTouches::detects('divi/global-layout'));
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Release\FormatCheck;
use PHPUnit\Framework\TestCase;
final class FormatCheckTest extends TestCase
{
/** parse_blocks() as WordPress would answer for well-formed Divi markup: the same names and nesting. */
private static function wordPress(): \Closure
{
$convert = static function (array $blocks) use (&$convert): array {
return array_map(static fn (Block $block): array => [
'blockName' => $block->name(),
'innerBlocks' => $convert($block->children()),
], $blocks);
};
return static fn (string $content): array => $convert((new Parser())->parse($content)->children());
}
public function testTheShippedReferenceRoundTripsAndMatchesWordPress(): void
{
self::assertSame([], (new FormatCheck(null, self::wordPress()))->problems());
self::assertFileEquals(
dirname(__DIR__, 5) . '/tests/Fixtures/divi/converted-landing.html',
FormatCheck::REFERENCE
);
}
public function testADifferentStructureInWordPressIsReported(): void
{
$check = new FormatCheck(null, static fn (): array => [['blockName' => 'divi/section', 'innerBlocks' => []]]);
self::assertSame(['WordPress liest eine andere Blockstruktur als LIT MCP.'], $check->problems());
}
public function testAMissingReferenceIsReported(): void
{
self::assertSame(
['Der Referenzinhalt der Prüfung fehlt im Plugin.'],
(new FormatCheck('/nonexistent/reference.html', self::wordPress()))->problems()
);
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\Release\LineDiff;
use PHPUnit\Framework\TestCase;
final class LineDiffTest extends TestCase
{
public function testShowsChangedLinesWithTwoLinesOfContext(): void
{
$old = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'];
$new = ['a', 'b', 'c', 'd', 'E', 'f', 'g', 'h', 'i', 'j'];
self::assertSame(
[' c', ' d', '- e', '+ E', ' f', ' g', ' h', ' i', '+ j'],
LineDiff::lines($old, $new)
);
}
public function testMarksAGapBetweenDistantChanges(): void
{
$old = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'];
$new = ['A', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'J'];
self::assertSame(['- a', '+ A', ' b', ' c', '…', ' h', ' i', '- j', '+ J'], LineDiff::lines($old, $new));
}
public function testEqualInputHasNoLines(): void
{
self::assertSame([], LineDiff::lines(['a', 'b'], ['a', 'b']));
}
}

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