Files
WP-LIT-MCP/docs/superpowers/plans/2026-09-26-etappe-2a-divi-lesen.md
T

5739 lines
249 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Etappe 2a – Divi-Seiten lesen: BlockTree, Katalog, Designsystem, Seitenbaum – Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Claude liest Divi-5-Seiten als Baum mit Pfad-IDs und `tree_rev`, jeden Knoten in Vollform samt Kurzform-Werten, den Modul-Katalog und das Designsystem der Website live aus Divi sowie eine Zusammenfassung der gerenderten Seite – als sichere, byte-exakte Grundlage für die Schreibwerkzeuge in Etappe 2b.
**Architecture:** Neue Integration `DiviIntegration` (nur mit geladenem Divi-5-Builder). Reines PHP ohne WordPress: `BlockTree/` (eigener Tokenizer mit dem Delimiter-Muster von `WP_Block_Parser`, der jedes Byte des Originals behält; Serializer wie `serialize_block_attributes()`; Pfad-Index; Outline), `Schema/` (Modul-Katalog und Attributpfade aus `ModuleRegistration`-Metadaten, kuratierte Kurzform), `Design/DesignSystem`. Dünne WordPress/Divi-Adapter (`DiviModules`, `DesignSystemReader`, `DiviPageLoader`) und vier direkte Lese-Tools; `lit/page-render-summary` gehört zur WordPress-Integration (funktioniert für jeden Eintrag). Die Divi-Hinweise in `lit-content-get`, `lit-content-update` und `lit/revisions-list` zeigen auf die neuen Werkzeuge.
**Tech Stack:** PHP ≥ 8.1, WordPress 7.0 (Abilities API), Divi 5.2.1 (`ET\Builder\Packages\ModuleLibrary\ModuleRegistration`, `ET\Builder\Packages\GlobalData\GlobalData`, `ET\Builder\Packages\GlobalData\GlobalPreset`), `wordpress/mcp-adapter` 0.5, `wp-media/mcp-oauth` 1.1, PHPUnit 9.6 + Brain Monkey + Mockery, ext-dom, phpcs (PSR-12, WordPress.Security, PHPCompatibilityWP), DDEV.
**Spec:** `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` (insb. §2, §4.3, §4.4, §5.1–5.4, §5.7, §8.1, §9, §11.3, §12 Etappe 2, §13)
**Voraussetzung:** Etappe 1 (1a + 1b) ist umgesetzt (Branch `etappe-2`, Stand `ef44593`): `Errors`, `ToolResult`, `Rev` (`of`, `ofPost`), `JsonMap`, `Cursor`, `ChangeLog`, `PostGuard`, `PostPresenter`, `ContentSchema` (inkl. `diviEnabled()`), `ExposurePolicy`, `InstructionsBuilder`, `GuideRepository`, `tests/Integration/lib.sh`, `ToolNamingTest`. Danach folgt Plan `docs/superpowers/plans/2026-09-26-etappe-2b-divi-schreiben.md`.
## Global Constraints
- Dateien ausschließlich im Plugin-Verzeichnis `/home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP` anlegen oder ändern (auch Fixtures). Theme und andere Plugins nur lesen; Seiten, Benutzer und Rollen, die Integrationsskripte anlegen, löschen sie wieder.
- PHP ≥ 8.1 ohne 8.2+-Features (keine `readonly`-Klassen, keine DNF-Typen, keine typisierten Klassenkonstanten); WordPress ≥ 6.9 (getestet 7.0); Divi 5.2.1.
- Namespace `LIT\MCP\` → `src/`, PSR-4, eine Klasse pro Datei, PSR-12, höchstens 120 Zeichen pro Zeile; `composer lint` und `composer test` nach jedem Task sauber.
- Reines PHP ohne WordPress-Funktionen (Spec §4.4): `src/Integrations/Divi/BlockTree/*`, `src/Integrations/Divi/Schema/{AttrPathIndex,ModuleCatalog,ShortformMap}.php`, `src/Integrations/Divi/Design/DesignSystem.php`, `src/Integrations/WordPress/Render/RenderSummary.php`. Deren Tests erben von `PHPUnit\Framework\TestCase`. Wo phpcs dort WordPress-Alternativen verlangt (`json_encode`, `strip_tags`), steht `// phpcs:ignore <Sniff> -- pure PHP (spec §4.4).`
- Unit-Tests der WordPress-Adapter und Abilities über `LIT\MCP\Tests\Unit\TestCase` (Brain Monkey + Mockery); die dort per `Functions\stubs()` vorbelegten Funktionen (`wp_json_encode`, `is_wp_error`, `absint`, `wp_slash`, `wp_unslash`, `sanitize_text_field`) nie mit `Functions\expect()` prüfen. `error_log()` lässt sich nicht stubben – per `ini_set('error_log', <tempfile>)` umleiten.
- Integrationstests im DDEV-Container: `cd /home/janniclas/Projekte/WordPress && timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/<skript>.sh`; Hilfsskripte mit `timeout 300 ddev exec wp eval-file …` (ohne `declare(strict_types=1);`, da `wp eval-file` über `eval()` läuft).
- **Byte-Invariante (Spec §5.2, §1.3 Nr. 3):** Parse → Serialize ergibt für jede Fixture byte-identischen Inhalt (auch mit CRLF). Unveränderte Knoten werden nie neu erzeugt; unbalanciertes Markup wird abgelehnt (`MalformedMarkup`), nie repariert.
- **Sperren (Ruling P16):** `rev` bleibt die Sperre des ganzen Eintrags (`Rev::ofPost`); die Divi-Werkzeuge liefern zusätzlich `tree_rev` = `Rev::of(post_content)`. Ab Etappe 2b verlangt jede Divi-Änderung `tree_rev`.
- Ability-Namen `lit/divi-<aktion>` bzw. `lit/page-render-summary`; in Guides, Beschreibungen und Hinweisen direkte Tools als `lit-<name>`, indirekte als `lit/<name>` (`ToolNamingTest`); jeder Task, der ein direktes Tool hinzufügt, ergänzt im selben Task die Zeile „Direkt sichtbar:“ in `docs/guides/overview.md` (Ruling Q2).
- Lese-Tools: Annotations `readonly: true`, `destructive: false`, `idempotent: true`; `inputSchema` mit `additionalProperties: false`, `outputSchema` immer gesetzt (JSON Schema 2020-12 im von `rest_validate_value_from_schema()` geprüften Umfang – kein `$ref`).
- Fachliche Fehler nur über `Errors` (`lit_*`, JSON mit `hint`); Divi-4-Inhalt und beschädigtes Markup → `unsupported` mit Hinweis.
- Divi-Daten nur live über Divis APIs (`ModuleRegistration`, `GlobalData`, `GlobalPreset`, Option `et_divi`) und nur für die Dauer der Anfrage gehalten (Spec §2 Nr. 2); die Divi-Integration ist nur verfügbar, wenn `ModuleRegistration` existiert (Spec §2 Nr. 4).
- Commits: `git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit …` mit den Trailern `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>` und `Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7`.
## Review Focus
1. **Echte Builder-Seiten mit Leerraum und Zeilenumbrüchen zwischen Blöcken, CRLF, Block-Editor-Blöcken, globalen Modulen (`globalModule`, `divi/global-layout` mit und ohne Inhalt) und nicht dekodierbaren Attributen:** Roundtrip byte-identisch, solche Knoten erscheinen „nur lesbar“ und werden nicht betreten → Tests in Task 2 (`ParserTest`: jede Fixture auch als CRLF, Opazität) und Task 3 (`OutlineTest::testReadOnlyNodesAreMarked`).
2. **Divi-4-Seite (Shortcodes) oder beschädigtes Markup:** klarer `unsupported`-Fehler mit Hinweis, Fehlerdetail im PHP-Error-Log, nichts wird geraten → Test in Task 6 (`DiviPageLoaderTest`).
3. **Divi liefert Gruppen als `stdClass` (globale Variablen) oder leere Presets:** Designsystem bleibt vollständig lesbar (live in DDEV gefunden: alle Variablentypen kommen als `stdClass`) → Test in Task 5 (`DesignSystemTest::sample()` mit `stdClass`-Gruppen).
4. **Divi nicht aktiv (anderes Theme oder Divi 4):** keine Divi-Tools registriert, Hinweise nennen keine Werkzeuge, die es nicht gibt → Tests in Task 4 (`DiviIntegrationTest`) und Task 8 (`PostPresenterTest::testDiviHintNamesTheDiviToolsOnlyWhenDiviIsActive`).
5. **Divi-Update mit geänderten Modul-Metadaten:** ein Kurzform-Pfad existiert nicht mehr → das Prüfskript schlägt laut fehl, statt dass später falsche Attribute geschrieben werden → Task 4 (`divi-shortform-check.php`, auch im Smoke-Test von Task 8) und `ModuleCatalogTest` gegen echte 5.2.1-Metadaten.
---
## File Structure
```
docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md (geändert) tree_rev, Tool-Budget, Entscheidungen Etappe 2
docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md Entscheidungen D1–D12 der Planung
src/
Plugin.php (geändert) DiviIntegration registrieren
Guides/InstructionsBuilder.php (geändert) Divi-Regel, wenn Divi aktiv
Integrations/
Divi/
DiviIntegration.php id "divi", nur mit Divi 5; Lese-Tools
BlockTree/ reines PHP
Json.php JSON der Attribute ({} bleibt {})
Container.php Teile (Blöcke + Text) eines Dokuments/Blocks
Block.php Block mit Original-Delimitern, opak/void
Document.php geparster post_content
MalformedMarkup.php unbalanciertes Markup
Parser.php Tokenizer (WP_Block_Parser-Muster), byte-exakt
Serializer.php wie serialize_block_attributes()
NodeIndex.php Pfad-IDs s0.r1.c0.m2, Eltern, Wurzel
AttrMap.php Attributpfade, Breakpoint-Maps, flatten/get/set/remove
NodeSummary.php Admin-Label, Textauszug, Presets, Spaltenbreiten
Outline.php Baum als Zeilen und Text
Schema/
AttrPathIndex.php gültige Attributpfade aus Metadaten + Defaults (rein)
ModuleCatalog.php Module, Titel, Kategorien, Kinder, Pfade (rein)
ShortformMap.php kuratierte Kurzform für ~20 Module (rein)
DiviModules.php Adapter ModuleRegistration (+ builderVersion)
DiviCatalogAbility.php lit/divi-catalog
Design/
DesignSystem.php Farben, Variablen, Schriften, Größen, Presets (rein)
DesignSystemReader.php Adapter GlobalData/GlobalPreset/et_divi
DiviDesignSystemAbility.php lit/divi-design-system
Pages/
DiviPage.php Post + Dokument + Index + tree_rev + edit_url
DiviPageLoader.php Rechte (PostGuard), Divi-4/Markup-Prüfung, writable()
DiviOutlineAbility.php lit/divi-outline
DiviNodeGetAbility.php lit/divi-node-get
WordPress/
WordPressIntegration.php (geändert) lit/page-render-summary
Render/
RenderSummary.php HTML → Überschriften, Texte, Links, Bilder, Warnungen (rein)
PageRenderer.php the_content im Prozess rendern, globals zurücksetzen
PageRenderSummaryAbility.php lit/page-render-summary
Content/PostPresenter.php (geändert) diviNote()/diviHint() statt DIVI_NOTE/DIVI_HINT
Content/ContentUpdateAbility.php (geändert) Hinweis auf die Divi-Werkzeuge
Revisions/RevisionsListAbility.php (geändert) diviHint()
docs/guides/
overview.md (geändert) direkte Tools, Themen
content-types.md (geändert) Divi-Hinweis
divi-structure.md Aufbau, Pfad-IDs, tree_rev, nur lesbar, render summary
divi-attrs.md Vollform: Pfade, Breakpoints, Zustände
divi-design-system.md Farben, Variablen, Schriften, Presets, Regeln
tests/
Fixtures/divi/
synthetic-basic.html handgeschriebene Divi-5-Seite (Presets, $variable, \u-Escapes)
synthetic-mixed.html Block-Editor, Leerraum, specialty, globale Module, {} / [] / "0"
divi4-shortcodes.html Divi-4-Inhalt
source/landing.shortcode Divi-4-Quelle für die Konvertierung
source/features.shortcode Divi-4-Quelle für die Konvertierung
converted-landing.html (generiert) von Divis eigener Konvertierung
converted-features.html (generiert) von Divis eigener Konvertierung
metadata-sample.json (generiert) echte Modul-Metadaten + Defaults (13 Module)
builder-*.html (später, manuell) im Visual Builder gebaute Seiten
Integration/
divi-convert-fixture.php Divi-4-Shortcodes → Divi-5-Fixture (Divis Conversion)
divi-export-page.php Builder-Seite byte-exakt als Fixture exportieren
divi-metadata-fixture.php metadata-sample.json erzeugen
divi-shortform-check.php Kurzform gegen den Live-Katalog prüfen
divi-read-smoke.sh Ende-zu-Ende-Test der Lese-Tools
Unit/Integrations/Divi/… Tests je Klasse (siehe Tasks)
Unit/Integrations/WordPress/Render/… RenderSummary, PageRenderer, Ability
```
---
### Task 1: Spec-Nachtrag und Entscheidungen der Etappe 2
Ruling P16 verlangt, die Spec anzupassen: `rev` ist die Sperre des ganzen Eintrags, die Divi-Werkzeuge sperren mit `tree_rev`. Dazu kommen die bei der Planung getroffenen Entscheidungen (Tool-Budget, kses beim Speichern, Verschiebungen nach Etappe 3). Reiner Doku-Task; Prüfung per `grep`.
**Files:**
- Modify: `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` (§4.3, §5.2, §5.3, §5.5, §5.6, §5.7, §8.1, §9.1, §12, §15)
- Create: `docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md`
**Interfaces:**
- Consumes: –
- Produces: verbindliche Begriffe für alle folgenden Tasks: `tree_rev` (Hash von `post_content`, Sperre der Divi-Werkzeuge), `rev` (`Rev::ofPost`), Pfad-ID-Buchstaben `s r c m g x`, direkte Tools nach Etappe 2 (21).
- [ ] **Step 1: §4.3 – Tool-Budget ergänzen**
In `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` ersetzen:
```markdown
- **Alle übrigen Abilities:** über die Meta-Tools des Adapters (`discover-abilities`, `get-ability-info`, `execute-ability`).
```
durch:
```markdown
- **Stand nach Etappe 2 (Entscheidung D2, 2026-09-26):** 21 direkte Tools – das Standard-Set oben ohne `lit/library-insert` (kommt mit Etappe 3), dazu `lit/site-status` (leeres Eingabeschema, Anker der Verbindungstests) und `lit/media-download` (eingebettete Ressource, funktioniert nur direkt). `lit/divi-duplicate-node` und `lit/divi-replace-text` sind indirekt. Mit Etappe 3 sind es 22; das Budget wird dort erneut geprüft (Kosten entstehen vor allem durch große Eingabeschemas, nicht durch die Zahl).
- **Alle übrigen Abilities:** über die Meta-Tools des Adapters (`discover-abilities`, `get-ability-info`, `execute-ability`).
```
- [ ] **Step 2: §5.2 – Umsetzung des BlockTree festhalten**
In `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` ersetzen:
```markdown
- Attributwerte mit `$variable(...)$` werden beim Serialisieren wie von Divi erwartet escaped (`"` → `"`).
```
durch:
```markdown
- Attributwerte mit `$variable(...)$` werden beim Serialisieren wie von Divi erwartet escaped (`"` → `"`).
- Umsetzung (D3): eigener Tokenizer mit dem Delimiter-Muster von `WP_Block_Parser` statt `parse_blocks()` – ohne WordPress testbar, jedes Byte zwischen den Delimitern bleibt erhalten. Knoten mit `globalModule`, `divi/global-layout` oder nicht dekodierbaren Attributen sind undurchsichtig: sie werden nicht betreten, nur als Ganzes verschoben, dupliziert oder gelöscht (Bearbeitung ab Etappe 3). Unbalanciertes Markup wird abgelehnt, nie repariert.
```
- [ ] **Step 3: §5.3 – `tree_rev` statt `rev` für die Divi-Sperre (Ruling P16)**
In `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` ersetzen:
```markdown
- Jede Antwort enthält `rev` = Hash des aktuellen `post_content`.
- Jedes Schreib-Tool verlangt `rev`. Weicht er ab, wird abgelehnt (`rev_mismatch`), nichts geschrieben; Claude liest neu. Pfad-IDs gelten immer relativ zu einem `rev`.
```
durch:
```markdown
- Jede Antwort enthält `tree_rev` = Hash des aktuellen `post_content` (Sperre der Divi-Werkzeuge) und `rev` = Sperre des ganzen Eintrags (`Rev::ofPost`, dieselbe wie bei `lit/content-get` und `lit/content-update`; Ruling P16).
- Jedes Divi-Schreib-Tool verlangt `tree_rev`. Weicht sie ab, wird abgelehnt (`rev_mismatch`), nichts geschrieben; Claude liest neu. Pfad-IDs gelten immer relativ zu einer `tree_rev`. Jede Schreib-Antwort liefert die neue `tree_rev` und die neuen Pfad-IDs der geänderten Knoten.
- Buchstaben der Pfad-IDs: `s` Abschnitt, `r` Zeile (auch `row-inner`), `c` Spalte (auch `column-inner`), `m` Modul (auch Kind-Module und Gruppen), `g` globales Modul bzw. globales Layout, `x` fremder Block (z. B. Block-Editor); gezählt wird je Buchstabe innerhalb des Elternknotens, `divi/placeholder` ist transparent.
```
- [ ] **Step 4: §5.5, §5.6, §5.7 – Verschiebungen und Speicherdetails**
In `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` ersetzen:
```markdown
- ähnlichen, bereits existierenden Abschnitten (Hinweis auf `lit/library-find-similar`).
```
durch:
```markdown
- ähnlichen, bereits existierenden Abschnitten (Hinweis auf `lit/library-find-similar`) – ab Etappe 3 zusammen mit `lit/library-find-similar` (D6).
```
In `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` ersetzen:
```markdown
3. Serialisieren, `wp_slash()`, `wp_update_post()` – Status unverändert, WordPress legt eine Revision an.
```
durch:
```markdown
3. Serialisieren, `wp_slash()`, `wp_update_post()` – Status unverändert, vorher Wiederherstellungspunkt (Revision). Für Benutzer ohne `unfiltered_html` wird kses für genau diesen Aufruf abgeschaltet: `filter_block_content()` würde sonst jeden unveränderten Block neu serialisieren (Byte-Identität, globale Module; in DDEV nachgewiesen). Alle Werte, die LIT MCP schreibt, sind vorher mit `wp_kses_post` / `sanitize_text_field` / `esc_url_raw` bereinigt (D7).
```
In `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` ersetzen:
```markdown
| `lit/divi-dynamic-sources` | lesen | verfügbare Dynamic-Content-Quellen für einen Post-Typ (über `DynamicContentOptions::get_options` mit einem Beispiel-Post des Typs) |
| `lit/page-render-summary` | lesen | gerenderte Seite als strukturierter Auszug (Überschriften, Texte, Links, Bilder+Alt) |
```
durch:
```markdown
| `lit/divi-dynamic-sources` | lesen | verfügbare Dynamic-Content-Quellen für einen Post-Typ (über `DynamicContentOptions::get_options` mit einem Beispiel-Post des Typs) – Etappe 3, mit dem Theme Builder (D6) |
| `lit/page-render-summary` | lesen | gerenderte Seite als strukturierter Auszug (Überschriften, Texte, Links, Bilder+Alt); Inhaltsbereich, im Prozess über `the_content` gerendert (ohne Header/Footer/Theme Builder); Teil der WordPress-Integration, funktioniert auch ohne Divi (D5) |
```
In `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` ersetzen:
```markdown
| `lit/divi-replace-text` | schreiben | Text seitenübergreifend ersetzen, `dry_run` (Standard `true`) |
```
durch:
```markdown
| `lit/divi-replace-text` | schreiben | Text seitenübergreifend (je Inhaltstyp) ersetzen, `dry_run` (Standard `true`) liefert Treffer je Seite mit `tree_rev`; ausgeführt wird nur auf diesen `targets` mit `confirm: true`, jede Seite mit eigener Revision (D10) |
```
- [ ] **Step 5: §8.1, §9.1, §12, §15**
In `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` (ToolResult-Beispiel) ersetzen:
```markdown
"rev": "a1b2c3",
```
durch:
```markdown
"rev": "a1b2c3", "tree_rev": "d4e5f6",
```
In `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` ersetzen:
```markdown
Status nie ändern ohne ausdrücklichen Wunsch; immer `rev` mitgeben,
```
durch:
```markdown
Status nie ändern ohne ausdrücklichen Wunsch; immer `rev` (bei Divi-Änderungen `tree_rev`) mitgeben,
```
In `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` ersetzen:
```markdown
| **2 – Divi-Seiten** | BlockTree, Katalog, Kurz-/Vollform, Platzhalter, Outline/Insert/Update/Move/Delete/Duplicate/Replace, Warnungen, `page-render-summary`, Divi-Guides | Divi-Seiten mit Claude bauen und pflegen |
```
durch:
```markdown
| **2 – Divi-Seiten** | BlockTree, Katalog, Kurz-/Vollform, Platzhalter, Outline/Insert/Update/Move/Delete/Duplicate/Replace, Warnungen, `page-render-summary`, Divi-Guides (umgesetzt in 2a: lesen, 2b: schreiben) | Divi-Seiten mit Claude bauen und pflegen |
```
In `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` ersetzen:
```markdown
- Etappe 2: Exakte Kurzform-Felder je Modul (aus Modul-Metadaten abgeleitet, im Guide dokumentiert).
```
durch:
```markdown
- Etappe 2: erledigt – Kurzform-Felder aus Divis Conversion-Outlines abgeleitet (`ShortformMap`), gegen den Live-Katalog geprüft (`tests/Integration/divi-shortform-check.php`), dokumentiert im Guide `divi-shortform`.
```
- [ ] **Step 6: Entscheidungen festhalten**
`docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md`:
```markdown
# Etappe 2 – Entscheidungen der Planung
Getroffen bei der Planung von Etappe 2a/2b, 2026-09-26. Kosten, falls falsch, jeweils am Ende.
- D1 (Ruling P16): `rev` = `Rev::ofPost` (ganzer Eintrag); Divi-Sperre = `tree_rev` = `Rev::of(post_content)`. Divi-Lese-Tools liefern beide, Divi-Schreib-Tools verlangen `tree_rev` und liefern beide neu. – Kosten: keine.
- D2: Direkte Tools nach Etappe 2 = 21 (Spec §4.3 ohne `lit/library-insert`, plus `lit/site-status`, `lit/media-download`). `lit/site-status` bleibt direkt (leeres Schema, Anker von mcp-smoke.sh), `lit/media-download` muss direkt bleiben. Indirekt: `lit/divi-duplicate-node`, `lit/divi-replace-text`. – Kosten: etwas mehr Kontext je Unterhaltung; in Etappe 3 neu bewerten.
- D3: BlockTree mit eigenem Tokenizer (Muster von `WP_Block_Parser`), opake Knoten für globale Module/Layouts und undekodierbare Attribute, unbalanciertes Markup → Ablehnung. – Kosten: globale Module erst ab Etappe 3 bearbeitbar.
- D4: `ExposurePolicy::DIRECT_ONLY` bleibt eine Liste; Etappe 2 bringt keine Bild-/Ressourcen-Tools, ein `Ability::requiresDirect()` wäre YAGNI. – Kosten: bei weiteren Bild-Tools die Liste ergänzen.
- D5: `lit/page-render-summary` gehört zur WordPress-Integration (funktioniert für jeden Eintrag) und rendert den Inhaltsbereich im Prozess (`the_content`), ohne HTTP-Loopback. – Kosten: Header/Footer/Theme Builder fehlen im Auszug.
- D6: `lit/divi-dynamic-sources` und die Warnung „ähnliche Abschnitte“ kommen mit Etappe 3 (Theme Builder, `lit/library-find-similar`). – Kosten: bis dahin keine Quellenliste; `{{field:…}}` nimmt jeden Meta-Schlüssel.
- D7: Divi-Speichern ohne kses für Benutzer ohne `unfiltered_html` (sonst re-serialisiert WordPress jeden Block); alle geschriebenen Werte vorher bereinigt; Code-Modul ungefiltert nur mit `unfiltered_html`. – Kosten: jeder neue Schreibpfad muss bereinigen (Review-Pflicht).
- D8: Spalten neuer Zeilen über `module.decoration.sizing.desktop.value.flexType` (`"1_3,2_3"` → `8_24`, `16_24`; Fünftel und 1/7, 1/9, 1/10, 1/11 nativ), wie Divi 5.2 (Flex-Layout ist Standard). – Kosten: falls der Builder anders speichert, nur Composer anpassen.
- D9: Platzhalter stehen allein im Wert; Überschriften der Kurzform ohne `level` werden `h2`. – Kosten: eingebettete dynamische Texte nur über die Vollform.
- D10: `lit/divi-replace-text` je Inhaltstyp; Probelauf liefert `tree_rev` je Seite, Ausführung nur auf diesen `targets` mit `confirm: true`; ersetzt nur sichtbaren Text (`…innerContent`, Schlüssel text/title/label/alt/titleText), nie URLs oder Stile. – Kosten: Text mit HTML-Entitäten muss so gesucht werden, wie er gespeichert ist.
- D11: `lit/content-create` mit `divi` speichert über `wp_insert_post` mit WordPress' üblicher Filterung (nur neuer, selbst erzeugter Inhalt – nichts Unberührtes, das byte-identisch bleiben müsste). – Kosten: für Benutzer ohne `unfiltered_html` kann WordPress Entitäten normalisieren; `tree_rev` in der Antwort beschreibt den gespeicherten Stand.
- D12: Fixtures: handgeschriebene Grenzfälle (`synthetic-*.html`), mit Divis eigener Konvertierung erzeugte Seiten (`converted-*.html`), echte Modul-Metadaten (`metadata-sample.json`); im Visual Builder gebaute Seiten exportiert Jan-Niclas später mit `tests/Integration/divi-export-page.php` als `builder-*.html` – sie laufen automatisch im Roundtrip-Test mit.
```
- [ ] **Step 7: Prüfen**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && grep -c 'tree_rev' docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md && grep -c '^- D' docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md`
Expected: erste Zahl ≥ 6, zweite Zahl `12`.
- [ ] **Step 8: Commit**
```bash
cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
git add docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "docs: spec – tree_rev lock (P16), tool budget and decisions for Etappe 2
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
```
---
### Task 2: BlockTree – byte-exakter Parser und Serializer, Roundtrip-Fixtures
Spec §5.2: Parse → Serialize ohne Änderung ergibt byte-identischen Inhalt; fremde Blöcke bleiben unberührt; reines PHP. Der Parser nutzt das Delimiter-Muster von `WP_Block_Parser::next_token()` (WordPress 7.0), merkt sich aber die Original-Bytes jedes Delimiters und jeden Text dazwischen. Der Serializer erzeugt nur für geänderte Blöcke neue Delimiter – genau wie `serialize_block_attributes()` (inkl. `\u0022` in `$variable(…)$`). Attribute werden so dekodiert, dass `{}` und Objekte mit Schlüsseln `"0"`, `"1"` Objekte bleiben (Divi zerbricht an `[]`, siehe Divis `EmptyArrayCorruptionMigration`). Blöcke mit `globalModule`, `divi/global-layout` oder nicht dekodierbaren Attributen sind opak (D3).
Fixtures: handgeschriebene Grenzfälle, zwei mit Divis eigener Konvertierung erzeugte Seiten (`Conversion::maybeConvertContent()` – derselbe Code wie der Divi-5-Migrator, lokal verifiziert) und ein Exportskript für echte Builder-Seiten, die Jan-Niclas später ergänzt. Jede `*.html` in `tests/Fixtures/divi/` läuft automatisch im Roundtrip-Test (auch mit CRLF).
**Files:**
- Create: `src/Integrations/Divi/BlockTree/{Json,Container,Block,Document,MalformedMarkup,Parser,Serializer}.php`
- Create: `tests/Fixtures/divi/{synthetic-basic,synthetic-mixed,divi4-shortcodes}.html`, `tests/Fixtures/divi/source/{landing,features}.shortcode`
- Create (generiert in Step 3): `tests/Fixtures/divi/converted-landing.html`, `tests/Fixtures/divi/converted-features.html`
- Create: `tests/Integration/divi-convert-fixture.php`, `tests/Integration/divi-export-page.php`
- Test: `tests/Unit/Integrations/Divi/BlockTree/ParserTest.php`, `tests/Unit/Integrations/Divi/BlockTree/SerializerTest.php`
**Interfaces:**
- Consumes: –
- Produces (alle `LIT\MCP\Integrations\Divi\BlockTree\`):
- `Json::decodeObject(string $json): ?array` (null, wenn kein JSON-Objekt); `Json::fromJson(mixed $value): mixed`; `Json::encode(mixed $value): string` (`JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE`, wirft `JsonException`).
- `abstract class Container`: `parts(): list<string|Block>`, `children(): list<Block>`, `append(string|Block $part): void` (nur Parser), `indexOf(Block $b): ?int`, `insertChild(int $index, Block $b): void` (Index unter den Kind-Blöcken; ≥ Anzahl = ans Ende; Text bleibt stehen), `removeChild(Block $b): int` (bisheriger Index).
- `final class Block extends Container`: `static parsed(string $name, array $attrs, string $rawOpen, bool $void, bool $opaque): self`, `static create(string $name, array $attrs = [], list<Block> $children = []): self` (neu, als Paar geschrieben), `closeWith(string $rawClose)`, `name(): string` (`divi/section`, `core/paragraph`), `shortName(): string` (ohne `divi/`), `attrs(): array`, `setAttrs(array $attrs): void` (verwirft den Original-Opener), `rawOpen(): ?string`, `rawClose(): ?string`, `isVoid(): bool`, `isOpaque(): bool`, `isDivi(): bool`, `isGlobal(): bool`, `copy(): self` (tiefe Kopie).
- `final class Document extends Container`.
- `final class MalformedMarkup extends RuntimeException`.
- `final class Parser`: `parse(string $content): Document` (wirft `MalformedMarkup`).
- `final class Serializer`: `static document(Document $d): string`, `static block(Block $b): string`, `static attributes(array $attrs): string` (= `serialize_block_attributes()`).
- [ ] **Step 1: Handgeschriebene Fixtures anlegen**
Die Dateien exakt so anlegen. `synthetic-basic.html` und `divi4-shortcodes.html` **ohne** abschließenden Zeilenumbruch speichern (z. B. `printf '%s' '…' > datei`), `synthetic-mixed.html` mit Zeilenumbrüchen (LF) wie gezeigt. Die `\u0022`/`\u003c`-Folgen sind wörtlich (Backslash, `u`, vier Hex-Ziffern) – so speichert WordPress Attribute.
`tests/Fixtures/divi/synthetic-basic.html`:
```html
<!-- wp:divi/placeholder --><!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Hero"}}},"decoration":{"background":{"desktop":{"value":{"color":"$variable({\u0022type\u0022:\u0022color\u0022,\u0022value\u0022:{\u0022name\u0022:\u0022gcid-primary-color\u0022,\u0022settings\u0022:{}}})$"}}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"Hero – Zeile"}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"8_24"}}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/image {"image":{"innerContent":{"desktop":{"value":{"src":"https://kunde.test/wp-content/uploads/2026/09/team.jpg","id":"33","alt":"Unser Team vor dem Büro"}}}},"module":{"meta":{"adminLabel":{"desktop":{"value":"Hero – Bild"}}}},"builderVersion":"5.2.1"} /--><!-- /wp:divi/column --><!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"16_24"}}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/heading {"title":{"innerContent":{"desktop":{"value":"Willkommen bei Müller \u0026 Söhne"}},"decoration":{"font":{"font":{"desktop":{"value":{"headingLevel":"h1","color":"#1a1a1a"}},"phone":{"value":{"size":"28px"}}}}}},"modulePreset":["a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"],"builderVersion":"5.2.1"} --><!-- /wp:divi/heading --><!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eSeit 1987 \u003cstrong\u003ezuverlässig\u003c/strong\u003e – \u003ca href=\u0022/kontakt\u0022\u003ejetzt anfragen\u003c/a\u003e.\u003c/p\u003e\u003c!\u002d\u002d more \u002d\u002d\u003e"}}},"builderVersion":"5.2.1"} --><!-- /wp:divi/text --><!-- wp:divi/button {"button":{"innerContent":{"desktop":{"value":{"text":"Termin buchen","linkUrl":"/kontakt","linkTarget":"off"}}}},"module":{"meta":{"adminLabel":{"desktop":{"value":"Hero – Button"}}}},"builderVersion":"5.2.1"} --><!-- /wp:divi/button --><!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Leistungen"}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/row {"builderVersion":"5.2.1"} --><!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"12_24"}}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/blurb {"title":{"innerContent":{"desktop":{"value":{"text":"Beratung","url":""}}},"decoration":{"font":{"font":{"desktop":{"value":{"headingLevel":"h3"}}}}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003ePersönlich und \u0022ehrlich\u0022.\u003c/p\u003e"}}},"imageIcon":{"innerContent":{"desktop":{"value":{"useIcon":"on","icon":{"unicode":"\u0026#xe08b;","type":"divi","weight":"400"}}}}},"builderVersion":"5.2.1"} --><!-- /wp:divi/blurb --><!-- /wp:divi/column --><!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"12_24"}}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/accordion {"builderVersion":"5.2.1"} --><!-- wp:divi/accordion-item {"title":{"innerContent":{"desktop":{"value":"Was kostet das?"}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eKommt drauf an.\u003c/p\u003e"}}},"module":{"advanced":{"open":{"desktop":{"value":"on"}}}},"builderVersion":"5.2.1"} --><!-- /wp:divi/accordion-item --><!-- wp:divi/accordion-item {"title":{"innerContent":{"desktop":{"value":"Wie schnell?"}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eIn 2 Wochen.\u003c/p\u003e"}}},"builderVersion":"5.2.1"} --><!-- /wp:divi/accordion-item --><!-- /wp:divi/accordion --><!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->
```
`tests/Fixtures/divi/synthetic-mixed.html`:
```html
<!-- wp:paragraph -->
<p>Einleitung aus dem Block-Editor mit <a href="https://example.test" target="_blank">Link</a>.</p>
<!-- /wp:paragraph -->
<!-- wp:divi/placeholder -->
<!-- wp:divi/section {"module":{"advanced":{"type":{"desktop":{"value":"specialty"}}}},"builderVersion":"5.2.1"} -->
<!-- wp:divi/column {"module":{"advanced":{"type":{"desktop":{"value":"1_3"}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":"Seitenleiste"}}},"builderVersion":"5.2.1"} /--><!-- /wp:divi/column -->
<!-- wp:divi/column {"module":{"advanced":{"type":{"desktop":{"value":"2_3"}},"specialtyColumns":{"desktop":{"value":"2"}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/row-inner {"module":{"advanced":{"columnStructure":{"desktop":{"value":"1_2,1_2"}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/column-inner {"builderVersion":"5.2.1"} --><!-- wp:divi/code {"content":{"innerContent":{"desktop":{"value":"\u003cscript\u003econsole.log(\u0022a\u002d\u002db\u0022)\u003c/script\u003e"}}},"builderVersion":"5.2.1"} /--><!-- /wp:divi/column-inner --><!-- wp:divi/column-inner {"builderVersion":"5.2.1"} --><!-- /wp:divi/column-inner --><!-- /wp:divi/row-inner --><!-- /wp:divi/column -->
<!-- /wp:divi/section -->
<!-- wp:divi/section {"builderVersion":"5.2.1","module":{"decoration":{"spacing":{"desktop":{"value":{}}},"background":{"desktop":{"value":[]}}}},"localAttrs":{"0":"x","1":"y"}} --><!-- wp:divi/global-layout {"globalModule":"812","builderVersion":"5.2.1"} /--><!-- wp:divi/row {"globalModule":"913","module":{"meta":{"adminLabel":{"desktop":{"value":"Globale Zeile"}}}}} --><!-- wp:divi/column {"builderVersion":"5.2.1"} --><!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":"lokal"}}}} /--><!-- /wp:divi/column --><!-- /wp:divi/row --><!-- wp:divi/global-layout {"globalModule":"914","localAttrs":{"title":"x"}} --><!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":"Kind"}}}} /--><!-- /wp:divi/global-layout --><!-- /wp:divi/section -->
<!-- /wp:divi/placeholder -->
Freier Text am Ende [et_pb_legacy]
```
`tests/Fixtures/divi/divi4-shortcodes.html`:
```html
[et_pb_section fb_built="1" _builder_version="4.27.4"][et_pb_row _builder_version="4.27.4"][et_pb_column type="4_4"][et_pb_text]<p>Divi 4</p>[/et_pb_text][/et_pb_column][/et_pb_row][/et_pb_section]
```
`tests/Fixtures/divi/source/landing.shortcode`:
```text
[et_pb_section fb_built="1" admin_label="Hero" _builder_version="4.27.4" background_color="#0b3d59"][et_pb_row column_structure="1_2,1_2" _builder_version="4.27.4"][et_pb_column type="1_2" _builder_version="4.27.4"][et_pb_heading title="Handwerk mit Herz" title_level="h1" _builder_version="4.27.4"][/et_pb_heading][et_pb_text _builder_version="4.27.4"]<p>Seit 1987 in <strong>Köln</strong> – "fair" &amp; pünktlich.</p>[/et_pb_text][et_pb_button button_text="Termin buchen" button_url="/kontakt" _builder_version="4.27.4"][/et_pb_button][/et_pb_column][et_pb_column type="1_2" _builder_version="4.27.4"][et_pb_image src="https://example.test/wp-content/uploads/2026/09/werkstatt.jpg" alt="Werkstatt" title_text="Werkstatt" _builder_version="4.27.4"][/et_pb_image][/et_pb_column][/et_pb_row][/et_pb_section][et_pb_section fb_built="1" admin_label="Leistungen" _builder_version="4.27.4"][et_pb_row column_structure="1_3,1_3,1_3" _builder_version="4.27.4"][et_pb_column type="1_3" _builder_version="4.27.4"][et_pb_blurb title="Beratung" use_icon="on" font_icon="&#xe08b;||divi||400" _builder_version="4.27.4"]<p>Persönlich vor Ort.</p>[/et_pb_blurb][/et_pb_column][et_pb_column type="1_3" _builder_version="4.27.4"][et_pb_blurb title="Planung" image="https://example.test/wp-content/uploads/2026/09/plan.png" alt="Plan" _builder_version="4.27.4"]<p>Mit 3D-Ansicht.</p>[/et_pb_blurb][/et_pb_column][et_pb_column type="1_3" _builder_version="4.27.4"][et_pb_blurb title="Umsetzung" _builder_version="4.27.4"]<p>Termingerecht.</p>[/et_pb_blurb][/et_pb_column][/et_pb_row][et_pb_row _builder_version="4.27.4"][et_pb_column type="4_4" _builder_version="4.27.4"][et_pb_cta title="Bereit?" button_text="Anfragen" button_url="/kontakt" _builder_version="4.27.4"]<p>Wir melden uns binnen 24 Stunden.</p>[/et_pb_cta][et_pb_testimonial author="Anna B." job_title="Bauherrin" company_name="Privat" _builder_version="4.27.4"]<p>Top Arbeit!</p>[/et_pb_testimonial][et_pb_accordion _builder_version="4.27.4"][et_pb_accordion_item title="Was kostet das?" open="on" _builder_version="4.27.4"]<p>Kommt drauf an.</p>[/et_pb_accordion_item][et_pb_accordion_item title="Wie schnell?" open="off" _builder_version="4.27.4"]<p>2 Wochen.</p>[/et_pb_accordion_item][/et_pb_accordion][et_pb_contact_form email="info@example.test" title="Kontakt" _builder_version="4.27.4"][et_pb_contact_field field_id="Name" field_title="Name" _builder_version="4.27.4"][/et_pb_contact_field][et_pb_contact_field field_id="Email" field_title="E-Mail" field_type="email" _builder_version="4.27.4"][/et_pb_contact_field][et_pb_contact_field field_id="Message" field_title="Nachricht" field_type="text" fullwidth_field="on" _builder_version="4.27.4"][/et_pb_contact_field][/et_pb_contact_form][/et_pb_column][/et_pb_row][/et_pb_section]
```
`tests/Fixtures/divi/source/features.shortcode`:
```text
[et_pb_section fb_built="1" admin_label="Details" _builder_version="4.27.4"][et_pb_row column_structure="2_3,1_3" _builder_version="4.27.4"][et_pb_column type="2_3" _builder_version="4.27.4"][et_pb_tabs _builder_version="4.27.4"][et_pb_tab title="Ablauf" _builder_version="4.27.4"]<p>Erst reden, dann bauen.</p>[/et_pb_tab][et_pb_tab title="Preise" _builder_version="4.27.4"]<p>Ab 99 €.</p>[/et_pb_tab][/et_pb_tabs][et_pb_toggle title="Garantie" open="off" _builder_version="4.27.4"]<p>5 Jahre.</p>[/et_pb_toggle][et_pb_slider _builder_version="4.27.4"][et_pb_slide heading="Neu" button_text="Mehr" button_link="/neu" _builder_version="4.27.4"]<p>Unsere neue Halle.</p>[/et_pb_slide][/et_pb_slider][et_pb_gallery gallery_ids="12,33" _builder_version="4.27.4"][/et_pb_gallery][et_pb_code _builder_version="4.27.4"]<div class="hinweis">Code</div>[/et_pb_code][/et_pb_column][et_pb_column type="1_3" _builder_version="4.27.4"][et_pb_team_member name="Max Meister" position="Inhaber" _builder_version="4.27.4"]<p>Meister seit 2001.</p>[/et_pb_team_member][et_pb_number_counter title="Projekte" number="480" percent_sign="off" _builder_version="4.27.4"][/et_pb_number_counter][et_pb_divider show_divider="on" _builder_version="4.27.4"][/et_pb_divider][et_pb_social_media_follow _builder_version="4.27.4"][et_pb_social_media_follow_network social_network="facebook" url="https://facebook.com/example" _builder_version="4.27.4"]facebook[/et_pb_social_media_follow_network][/et_pb_social_media_follow][et_pb_map address="Domkloster 4, 50667 Köln" address_lat="50.9413" address_lng="6.9583" _builder_version="4.27.4"][et_pb_map_pin title="Büro" pin_address="Domkloster 4, 50667 Köln" pin_address_lat="50.9413" pin_address_lng="6.9583" _builder_version="4.27.4"][/et_pb_map_pin][/et_pb_map][et_pb_video src="https://www.youtube.com/watch?v=dQw4w9WgXcQ" _builder_version="4.27.4"][/et_pb_video][/et_pb_column][/et_pb_row][/et_pb_section]
```
- [ ] **Step 2: Skripte für generierte und exportierte Fixtures anlegen**
`tests/Integration/divi-convert-fixture.php`:
```php
<?php
/**
* Generates a Divi 5 fixture from a Divi 4 shortcode file with Divi's own server-side
* conversion (the same code the Divi 5 migrator uses) and writes it into tests/Fixtures/divi/.
* Usage (inside DDEV, from the WordPress root):
* wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-convert-fixture.php <source.shortcode> <target.html>
* Paths are relative to the plugin's tests/Fixtures/divi directory.
*/
use ET\Builder\Packages\Conversion\Conversion;
$args = $args ?? [];
if (count($args) !== 2) {
WP_CLI::error('Usage: divi-convert-fixture.php <source.shortcode> <target.html>');
}
if (! class_exists(Conversion::class)) {
WP_CLI::error('Divi 5 is not active.');
}
$dir = dirname(__DIR__) . '/Fixtures/divi/';
$source = file_get_contents($dir . $args[0]);
if ($source === false) {
WP_CLI::error('Cannot read ' . $dir . $args[0]);
}
$converted = Conversion::maybeConvertContent(trim($source));
if (! str_contains($converted, '<!-- wp:divi/section')) {
WP_CLI::error('Conversion produced no Divi 5 sections.');
}
$markup = '<!-- wp:divi/placeholder -->' . $converted . '<!-- /wp:divi/placeholder -->';
if (file_put_contents($dir . $args[1], $markup) === false) {
WP_CLI::error('Cannot write ' . $dir . $args[1]);
}
WP_CLI::success(sprintf('%s: %d bytes', $args[1], strlen($markup)));
```
`tests/Integration/divi-export-page.php`:
```php
<?php
/**
* Exports the post_content of a page built in the Divi Visual Builder byte-exact into
* tests/Fixtures/divi/builder-<name>.html. Every file there is part of the parse → serialize
* round-trip test (tests/Unit/Integrations/Divi/BlockTree/ParserTest.php).
* Usage (DDEV, WordPress root):
* wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-export-page.php <post_id> <name>
* <name>: lowercase letters, digits and dashes, e.g. "startseite-mit-global".
* Only use pages without personal data – fixtures are committed.
* No `declare(strict_types=1);` – wp eval-file runs the file through eval().
*/
defined('WP_CLI') && WP_CLI || exit;
$args = $args ?? [];
$postId = (int) ($args[0] ?? 0);
$name = (string) ($args[1] ?? '');
if ($postId <= 0 || preg_match('/^[a-z0-9]+(-[a-z0-9]+)*$/', $name) !== 1) {
WP_CLI::error('Usage: divi-export-page.php <post_id> <name>');
}
$post = get_post($postId);
if (! $post instanceof WP_Post) {
WP_CLI::error('No post ' . $postId);
}
$content = (string) $post->post_content;
if (! str_contains($content, '<!-- wp:divi/')) {
WP_CLI::error('Post ' . $postId . ' has no Divi 5 content (open and save it in the Divi builder first).');
}
$file = dirname(__DIR__) . '/Fixtures/divi/builder-' . $name . '.html';
if (file_put_contents($file, $content) === false) {
WP_CLI::error('Cannot write ' . $file);
}
WP_CLI::success(sprintf(
'%s: %d bytes, %d blocks, globalModule: %s, tree_rev %s. Now run composer test.',
basename($file),
strlen($content),
substr_count($content, '<!-- wp:'),
str_contains($content, 'globalModule') ? 'yes' : 'no',
substr(hash('sha256', $content), 0, 12)
));
```
- [ ] **Step 3: Konvertierte Fixtures mit Divi erzeugen (DDEV)**
Run:
```bash
cd /home/janniclas/Projekte/WordPress
timeout 300 ddev exec wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-convert-fixture.php source/landing.shortcode converted-landing.html
timeout 300 ddev exec wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-convert-fixture.php source/features.shortcode converted-features.html
head -c 200 wp-content/plugins/WP-LIT-MCP/tests/Fixtures/divi/converted-landing.html
```
Expected: `Success: converted-landing.html: … bytes` (ca. 6,6 kB) und `Success: converted-features.html: … bytes` (ca. 4,5 kB); die Datei beginnt mit `<!-- wp:divi/placeholder --><!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Hero"}}}`. Die Dateien nicht von Hand ändern – sie sind Divis eigene Ausgabe (Strukturblöcke mit `builderVersion` `5.0.0-public-alpha.18.2`, Module mit `4.27.4`).
- [ ] **Step 4: Failing Tests**
`tests/Unit/Integrations/Divi/BlockTree/ParserTest.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use PHPUnit\Framework\TestCase;
use stdClass;
final class ParserTest extends TestCase
{
/** @return array<string, array{string}> Every fixture page, also with Windows line endings. */
public static function fixtures(): array
{
$cases = [];
foreach (glob(dirname(__DIR__, 4) . '/Fixtures/divi/*.html') ?: [] as $file) {
$content = (string) file_get_contents($file);
$cases[basename($file)] = [$content];
$cases[basename($file) . ' (CRLF)'] = [str_replace("\n", "\r\n", $content)];
}
return $cases;
}
/** @dataProvider fixtures */
public function testParseThenSerializeIsByteIdentical(string $content): void
{
self::assertSame($content, Serializer::document((new Parser())->parse($content)));
}
public function testFixturesArePresent(): void
{
$names = array_map('basename', glob(dirname(__DIR__, 4) . '/Fixtures/divi/*.html') ?: []);
self::assertContains('synthetic-basic.html', $names);
self::assertContains('synthetic-mixed.html', $names);
self::assertContains('converted-landing.html', $names);
self::assertContains('converted-features.html', $names);
}
public function testBuildsTheTree(): void
{
$document = (new Parser())->parse($this->fixture('synthetic-basic.html'));
$placeholder = $document->children()[0];
self::assertSame('divi/placeholder', $placeholder->name());
$sections = $placeholder->children();
self::assertCount(2, $sections);
self::assertSame('divi/section', $sections[0]->name());
self::assertFalse($sections[0]->isOpaque());
self::assertSame(
'$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$',
$sections[0]->attrs()['module']['decoration']['background']['desktop']['value']['color']
);
$columns = $sections[0]->children()[0]->children();
self::assertSame(['divi/column', 'divi/column'], array_map(static fn (Block $b): string => $b->name(), $columns));
self::assertTrue($columns[0]->children()[0]->isVoid());
self::assertSame('image', $columns[0]->children()[0]->shortName());
self::assertSame(
'Willkommen bei Müller & Söhne',
$columns[1]->children()[0]->attrs()['title']['innerContent']['desktop']['value']
);
}
public function testKeepsTextBetweenBlocksAndNamesCoreBlocks(): void
{
$document = (new Parser())->parse("<!-- wp:paragraph -->\n<p>x</p>\n<!-- /wp:paragraph -->\n\ntext");
self::assertSame('core/paragraph', $document->children()[0]->name());
self::assertSame(["\n<p>x</p>\n"], $document->children()[0]->parts());
self::assertSame("\n\ntext", $document->parts()[1]);
}
public function testMarksGlobalAndUndecodableBlocksOpaque(): void
{
$document = (new Parser())->parse($this->fixture('synthetic-mixed.html'));
$placeholder = $document->children()[1];
$second = $placeholder->children()[1];
[$globalLayout, $globalRow, $globalWithChild] = $second->children();
self::assertTrue($globalLayout->isOpaque());
self::assertTrue($globalLayout->isGlobal());
self::assertTrue($globalRow->isOpaque());
self::assertTrue($globalRow->isGlobal());
self::assertTrue($globalWithChild->isOpaque());
self::assertFalse($second->isOpaque());
$broken = (new Parser())->parse('<!-- wp:divi/text {"a":} /-->');
self::assertTrue($broken->children()[0]->isOpaque());
self::assertSame([], $broken->children()[0]->attrs());
}
public function testKeepsEmptyAndListLikeObjectsAsObjects(): void
{
$document = (new Parser())->parse($this->fixture('synthetic-mixed.html'));
$attrs = $document->children()[1]->children()[1]->attrs();
self::assertInstanceOf(stdClass::class, $attrs['module']['decoration']['spacing']['desktop']['value']);
self::assertSame([], $attrs['module']['decoration']['background']['desktop']['value']);
self::assertInstanceOf(stdClass::class, $attrs['localAttrs']);
}
public function testDivi4ShortcodesAreOneTextPart(): void
{
$content = $this->fixture('divi4-shortcodes.html');
$document = (new Parser())->parse($content);
self::assertSame([], $document->children());
self::assertSame([$content], $document->parts());
}
/** @return array<string, array{string}> */
public static function malformed(): array
{
return [
'closer without opener' => ['<!-- /wp:divi/section -->'],
'unclosed block' => ['<!-- wp:divi/section --><!-- wp:divi/row --><!-- /wp:divi/row -->'],
'crossed blocks' => ['<!-- wp:divi/row --><!-- wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/column -->'],
];
}
/** @dataProvider malformed */
public function testRefusesUnbalancedMarkup(string $content): void
{
$this->expectException(MalformedMarkup::class);
(new Parser())->parse($content);
}
private function fixture(string $name): string
{
return (string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/' . $name);
}
}
```
`tests/Unit/Integrations/Divi/BlockTree/SerializerTest.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\Document;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use PHPUnit\Framework\TestCase;
use stdClass;
final class SerializerTest extends TestCase
{
public function testEscapesAttributesLikeWordPress(): void
{
$variable = '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$';
self::assertSame(
'{"c":"$variable({\u0022type\u0022:\u0022color\u0022,\u0022value\u0022:{\u0022name\u0022:'
. '\u0022gcid-primary-color\u0022,\u0022settings\u0022:{}}})$"}',
Serializer::attributes(['c' => $variable])
);
self::assertSame(
'{"t":"\u003cp\u003eA \u0026 B\u003c/p\u003e \u002d\u002d \u005c/ ä","e":{},"l":[]}',
Serializer::attributes(['t' => '<p>A & B</p> -- \\/ ä', 'e' => new stdClass(), 'l' => []])
);
}
public function testRegeneratesOnlyTheChangedOpener(): void
{
$content = '<!-- wp:divi/section {"b":1} --><!-- wp:divi/text {"x":"y"} /--><!-- /wp:divi/section -->';
$document = (new Parser())->parse($content);
$text = $document->children()[0]->children()[0];
$text->setAttrs(['x' => 'z']);
self::assertSame(
'<!-- wp:divi/section {"b":1} --><!-- wp:divi/text {"x":"z"} /--><!-- /wp:divi/section -->',
Serializer::document($document)
);
}
public function testNewBlocksAreWrittenAsPairs(): void
{
$block = Block::create('divi/row', ['builderVersion' => '5.2.1'], [Block::create('divi/column')]);
self::assertSame(
'<!-- wp:divi/row {"builderVersion":"5.2.1"} --><!-- wp:divi/column --><!-- /wp:divi/column -->'
. '<!-- /wp:divi/row -->',
Serializer::block($block)
);
self::assertSame('<!-- wp:paragraph --><!-- /wp:paragraph -->', Serializer::block(Block::create('core/paragraph')));
}
public function testVoidBlockGettingAChildBecomesAPair(): void
{
$document = (new Parser())->parse('<!-- wp:divi/column {"a":1} /-->');
$column = $document->children()[0];
$column->insertChild(0, Block::create('divi/text'));
self::assertSame(
'<!-- wp:divi/column {"a":1} --><!-- wp:divi/text --><!-- /wp:divi/text --><!-- /wp:divi/column -->',
Serializer::document($document)
);
}
public function testInsertAndRemoveKeepSurroundingText(): void
{
$document = (new Parser())->parse("A<!-- wp:divi/text /-->\nB<!-- wp:divi/code /-->C");
[$text, $code] = $document->children();
$new = Block::create('divi/button');
$document->insertChild(1, $new);
self::assertSame(
"A<!-- wp:divi/text /-->\nB<!-- wp:divi/button --><!-- /wp:divi/button --><!-- wp:divi/code /-->C",
Serializer::document($document)
);
self::assertSame(2, $document->removeChild($code));
self::assertSame(0, $document->indexOf($text));
$document->insertChild(99, $code);
self::assertSame(2, $document->indexOf($code));
$empty = new Document();
$empty->append("\n");
$empty->insertChild(0, Block::create('divi/section'));
self::assertSame("\n<!-- wp:divi/section --><!-- /wp:divi/section -->", Serializer::document($empty));
}
public function testCopyIsDeepAndSerializesIdentically(): void
{
$content = '<!-- wp:divi/row {"a":1} --><!-- wp:divi/column /--><!-- /wp:divi/row -->';
$row = (new Parser())->parse($content)->children()[0];
$copy = $row->copy();
$copy->children()[0]->setAttrs(['b' => 2]);
self::assertSame($content, Serializer::block($row));
self::assertSame('<!-- wp:divi/row {"a":1} --><!-- wp:divi/column {"b":2} /--><!-- /wp:divi/row -->', Serializer::block($copy));
}
}
```
- [ ] **Step 5: Test laufen lassen (rot)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter BlockTree`
Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\BlockTree\Parser" not found` (bzw. Serializer).
- [ ] **Step 6: Implementierung**
`src/Integrations/Divi/BlockTree/Json.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\BlockTree;
use JsonException;
use stdClass;
/**
* JSON of block attributes. PHP arrays cannot tell an empty JSON object from an empty list,
* nor an object with the keys "0", "1", … from a list. decodeObject() therefore keeps such
* objects as stdClass, so a regenerated opener encodes them as {} again (Divi breaks on
* "[]" where it expects "{}" – see its EmptyArrayCorruptionMigration).
*/
final class Json
{
/** @return array<string, mixed>|null Null when $json is not a JSON object. */
public static function decodeObject(string $json): ?array
{
try {
$value = json_decode($json, false, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return null;
}
if (! $value instanceof stdClass) {
return null;
}
$converted = self::fromJson($value);
return is_array($converted) ? $converted : [];
}
/** Converts json_decode(…, false) output: objects become arrays unless that would lose their object-ness. */
public static function fromJson(mixed $value): mixed
{
if ($value instanceof stdClass) {
$array = [];
foreach (get_object_vars($value) as $key => $item) {
$array[$key] = self::fromJson($item);
}
return $array === [] || array_is_list($array) ? (object) $array : $array;
}
if (is_array($value)) {
return array_map([self::class, 'fromJson'], $value);
}
return $value;
}
/** @throws JsonException */
public static function encode(mixed $value): string
{
// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP (spec §4.4).
return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}
}
```
`src/Integrations/Divi/BlockTree/Container.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\BlockTree;
use InvalidArgumentException;
/**
* Ordered parts of a document or of a block's inner content: blocks and the raw text
* between them (whitespace, freeform HTML, Divi 4 shortcodes). Keeping every byte of that
* text is what makes parse → serialize byte-identical (spec §5.2).
*/
abstract class Container
{
/** @var list<string|Block> */
protected array $parts = [];
/** @return list<string|Block> */
public function parts(): array
{
return $this->parts;
}
/** @return list<Block> */
public function children(): array
{
return array_values(array_filter($this->parts, static fn (string|Block $part): bool => $part instanceof Block));
}
/** Used by the Parser: adds a part without marking the container as changed. */
public function append(string|Block $part): void
{
$this->parts[] = $part;
}
/** Child position of $block (0-based among the child blocks), null when it is no child. */
public function indexOf(Block $block): ?int
{
$index = array_search($block, $this->children(), true);
return $index === false ? null : (int) $index;
}
/**
* Inserts $block so it becomes child number $index (0 = first; count or more = after the
* last child). Text between existing children stays where it is.
*/
public function insertChild(int $index, Block $block): void
{
$children = $this->children();
$index = max(0, min($index, count($children)));
if ($index < count($children)) {
$position = $this->partPosition($children[$index]);
} elseif ($children === []) {
$position = count($this->parts);
} else {
$position = $this->partPosition($children[count($children) - 1]) + 1;
}
array_splice($this->parts, $position, 0, [$block]);
$this->touched();
}
/** Removes the child block; the text around it stays. Returns its former child index. */
public function removeChild(Block $block): int
{
$index = $this->indexOf($block);
if ($index === null) {
throw new InvalidArgumentException('Block is not a child of this container.');
}
array_splice($this->parts, $this->partPosition($block), 1);
$this->touched();
return $index;
}
/** Called after the list of children changed. */
protected function touched(): void
{
}
private function partPosition(Block $block): int
{
$position = array_search($block, $this->parts, true);
if ($position === false) {
throw new InvalidArgumentException('Block is not a child of this container.');
}
return (int) $position;
}
}
```
`src/Integrations/Divi/BlockTree/Block.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\BlockTree;
/**
* One block of the tree. A parsed block keeps its original opener/closer bytes; they are
* dropped only when the block itself changes (setAttrs(), a void block getting children),
* so the Serializer regenerates exactly the changed openers and nothing else.
*
* Opaque blocks (attributes that are no JSON object, divi/global-layout, anything carrying
* globalModule) are never regenerated and never descended into by the tools – Divi's own
* code warns that parse_blocks() mishandles global modules; Etappe 3 edits them.
*/
final class Block extends Container
{
/**
* @param array<string, mixed> $attrs
*/
private function __construct(
private string $name,
private array $attrs,
private ?string $rawOpen,
private ?string $rawClose,
private bool $void,
private bool $opaque
) {
}
/** @param array<string, mixed> $attrs */
public static function parsed(string $name, array $attrs, string $rawOpen, bool $void, bool $opaque): self
{
return new self($name, $attrs, $rawOpen, null, $void, $opaque);
}
/**
* A new block (opener and closer are generated; written as a pair like Divi does).
*
* @param array<string, mixed> $attrs
* @param list<Block> $children
*/
public static function create(string $name, array $attrs = [], array $children = []): self
{
$block = new self($name, $attrs, null, null, false, false);
foreach ($children as $child) {
$block->parts[] = $child;
}
return $block;
}
/** Used by the Parser for the closing delimiter. */
public function closeWith(string $rawClose): void
{
$this->rawClose = $rawClose;
}
/** Full block name, e.g. "divi/section"; blocks without namespace are "core/…". */
public function name(): string
{
return $this->name;
}
/** Name without the "divi/" namespace, e.g. "button"; other namespaces stay. */
public function shortName(): string
{
return str_starts_with($this->name, 'divi/') ? substr($this->name, 5) : $this->name;
}
/** @return array<string, mixed> */
public function attrs(): array
{
return $this->attrs;
}
/** @param array<string, mixed> $attrs */
public function setAttrs(array $attrs): void
{
$this->attrs = $attrs;
$this->rawOpen = null;
}
public function rawOpen(): ?string
{
return $this->rawOpen;
}
public function rawClose(): ?string
{
return $this->rawClose;
}
public function isVoid(): bool
{
return $this->void;
}
public function isOpaque(): bool
{
return $this->opaque;
}
public function isDivi(): bool
{
return str_starts_with($this->name, 'divi/');
}
/** A global module reference or global layout (edited only in Etappe 3). */
public function isGlobal(): bool
{
return $this->name === 'divi/global-layout' || array_key_exists('globalModule', $this->attrs);
}
/** Deep copy – unchanged copies serialize to exactly the same bytes. */
public function copy(): self
{
return clone $this;
}
public function __clone()
{
foreach ($this->parts as $position => $part) {
if ($part instanceof Block) {
$this->parts[$position] = clone $part;
}
}
}
protected function touched(): void
{
if ($this->void) {
$this->void = false;
$this->rawOpen = null;
}
}
}
```
`src/Integrations/Divi/BlockTree/Document.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\BlockTree;
/**
* The parsed post_content: top-level blocks and the text between them.
*/
final class Document extends Container
{
}
```
`src/Integrations/Divi/BlockTree/MalformedMarkup.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\BlockTree;
use RuntimeException;
/**
* post_content is no balanced block markup (closer without opener, unclosed block, PCRE
* failure). The Divi tools then refuse to read or write the entry – nothing is guessed.
*/
final class MalformedMarkup extends RuntimeException
{
}
```
`src/Integrations/Divi/BlockTree/Parser.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\BlockTree;
/**
* Parses block markup into a Document without WordPress (spec §4.4, §5.2). Uses the
* block-delimiter pattern of WP_Block_Parser, but records the exact bytes of every
* delimiter and of all text between them, so Serializer::document() reproduces the input
* byte for byte. Unbalanced markup is refused (MalformedMarkup), never repaired.
*/
final class Parser
{
/** WP_Block_Parser::next_token() (WordPress 7.0). */
private const TOKEN = '/<!--\s+(?P<closer>\/)?wp:(?P<namespace>[a-z][a-z0-9_-]*\/)?(?P<name>[a-z][a-z0-9_-]*)\s+'
. '(?P<attrs>{(?:(?:[^}]+|}+(?=})|(?!}\s+\/?-->).)*+)?}\s+)?(?P<void>\/)?-->/s';
/** @throws MalformedMarkup */
public function parse(string $content): Document
{
$document = new Document();
/** @var list<Block> $stack */
$stack = [];
$offset = 0;
$length = strlen($content);
while ($offset < $length) {
$found = preg_match(self::TOKEN, $content, $match, PREG_OFFSET_CAPTURE, $offset);
if ($found === false) {
throw new MalformedMarkup('Block delimiters could not be scanned (PCRE error).');
}
if ($found === 0) {
break;
}
[$token, $start] = $match[0];
$container = $stack === [] ? $document : $stack[count($stack) - 1];
if ($start > $offset) {
$container->append(substr($content, $offset, $start - $offset));
}
$name = self::name($match);
if (self::captured($match, 'closer')) {
$open = array_pop($stack);
if ($open === null || $open->name() !== $name) {
throw new MalformedMarkup(sprintf('Closer of %s at byte %d has no opener.', $name, $start));
}
$open->closeWith($token);
} else {
$json = self::captured($match, 'attrs') ? $match['attrs'][0] : '';
$attrs = $json === '' ? [] : Json::decodeObject($json);
$void = self::captured($match, 'void');
$opaque = $attrs === null || $name === 'divi/global-layout' || array_key_exists('globalModule', $attrs);
$block = Block::parsed($name, $attrs ?? [], $token, $void, $opaque);
$container->append($block);
if (! $void) {
$stack[] = $block;
}
}
$offset = $start + strlen($token);
}
if ($offset < $length) {
($stack === [] ? $document : $stack[count($stack) - 1])->append(substr($content, $offset));
}
if ($stack !== []) {
throw new MalformedMarkup(sprintf('Block %s is never closed.', $stack[count($stack) - 1]->name()));
}
return $document;
}
/** @param array<int|string, array{0: string, 1: int}> $match */
private static function name(array $match): string
{
$namespace = self::captured($match, 'namespace') ? $match['namespace'][0] : 'core/';
return $namespace . $match['name'][0];
}
/** @param array<int|string, array{0: string, 1: int}> $match */
private static function captured(array $match, string $group): bool
{
return isset($match[$group]) && $match[$group][1] !== -1 && $match[$group][0] !== '';
}
}
```
`src/Integrations/Divi/BlockTree/Serializer.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\BlockTree;
/**
* Turns a Document back into block markup. Unchanged blocks reuse their original
* delimiters; changed or new blocks get delimiters generated exactly like WordPress'
* get_comment_delimited_block_content() / serialize_block_attributes() do, which also
* yields the escaping Divi expects inside $variable(…)$ payloads (" → ").
*/
final class Serializer
{
/** serialize_block_attributes() replacements (WordPress 7.0). */
private const ESCAPES = [
'\\\\' => '\\u005c',
'--' => '\\u002d\\u002d',
'<' => '\\u003c',
'>' => '\\u003e',
'&' => '\\u0026',
'\\"' => '\\u0022',
];
public static function document(Document $document): string
{
return self::parts($document->parts());
}
public static function block(Block $block): string
{
$open = $block->rawOpen() ?? self::opener($block);
if ($block->isVoid()) {
return $open;
}
return $open . self::parts($block->parts()) . ($block->rawClose() ?? self::closer($block));
}
/** @param array<string, mixed> $attrs */
public static function attributes(array $attrs): string
{
return strtr(Json::encode($attrs), self::ESCAPES);
}
private static function opener(Block $block): string
{
$attrs = $block->attrs() === [] ? '' : self::attributes($block->attrs()) . ' ';
return '<!-- wp:' . self::serializedName($block->name()) . ' ' . $attrs . ($block->isVoid() ? '/-->' : '-->');
}
private static function closer(Block $block): string
{
return '<!-- /wp:' . self::serializedName($block->name()) . ' -->';
}
private static function serializedName(string $name): string
{
return str_starts_with($name, 'core/') ? substr($name, 5) : $name;
}
/** @param list<string|Block> $parts */
private static function parts(array $parts): string
{
$out = '';
foreach ($parts as $part) {
$out .= $part instanceof Block ? self::block($part) : $part;
}
return $out;
}
}
```
- [ ] **Step 7: Tests und Lint (grün)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter BlockTree && composer lint`
Expected: PASS (u. a. `testParseThenSerializeIsByteIdentical` für alle 5 Fixtures, je auch als CRLF); phpcs ohne Meldungen.
- [ ] **Step 8: Commit**
```bash
cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
git add src/Integrations/Divi/BlockTree/ tests/Unit/Integrations/Divi/BlockTree/ tests/Fixtures/divi/ tests/Integration/divi-convert-fixture.php tests/Integration/divi-export-page.php
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi BlockTree – byte-exact parser/serializer with round-trip fixtures
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
```
---
### Task 3: Pfad-IDs, Attributpfade und Outline
Spec §5.3: `lit/divi-outline` liefert Pfad-IDs (`s0`, `s0.r1`, `s0.r1.c0.m2`), Modultyp, Admin-Label, Textauszug und Preset-Namen. `NodeIndex` vergibt die IDs (Buchstaben je Rolle, gezählt je Elternknoten; `divi/placeholder` transparent; opake und fremde Blöcke werden nicht betreten). `AttrMap` adressiert Divi-Attribute über Punktpfade; der Wert eines Pfads ist eine Breakpoint-Map (`desktop`/`tablet`/`phone`/… → `value`/`hover`/`sticky`). `flatten()` ist die Vollform von `lit-divi-node-get` (Spec §5.4). Alles reines PHP.
**Files:**
- Create: `src/Integrations/Divi/BlockTree/{NodeIndex,AttrMap,NodeSummary,Outline}.php`
- Test: `tests/Unit/Integrations/Divi/BlockTree/NodeIndexTest.php`, `tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php`
**Interfaces:**
- Consumes: `Document`, `Block`, `Container`, `Parser` (Task 2).
- Produces:
- `NodeIndex`: `ID_PATTERN = '/^[srcmgx]\d+(\.[srcmgx]\d+)*$/'`; `static build(Document $d): self`; `static letter(Block $b): string`; `root(): Container` (erster `divi/placeholder`, sonst das Dokument); `find(string $id): ?Block`; `idOf(Block $b): ?string`; `parentOf(Block $b): ?Container`; `parentId(Block $b): ?string`; `entries(): list<array{id: string, block: Block, depth: int}>` (Dokumentreihenfolge).
- `AttrMap`: `BREAKPOINTS = ['desktop','tablet','phone','tabletWide','phoneWide','widescreen','ultraWide']`; `META_KEYS = ['builderVersion','modulePreset','groupPreset','globalModule','globalParent','localAttrs']`; `static flatten(array $attrs): array<string, mixed>` (Pfad → Breakpoint-Map, sortiert); `static isBreakpointMap(mixed $v): bool`; `static get(array $attrs, string $path): mixed`; `static desktop(array $attrs, string $path): mixed` (Wert `desktop.value`); `static set(array $attrs, string $path, mixed $v): array`; `static remove(array $attrs, string $path): array` (leere Eltern werden entfernt).
- `NodeSummary`: `static label(Block): string`, `static excerpt(Block, int $max = 60): string`, `static presetIds(Block): list<string>`, `static info(Block): string` (z. B. `Spalten 8_24+16_24`, `fullwidth`), `static columnWidth(Block $column): ?string`, `static textOf(mixed $value): string` (HTML entfernt, `$variable(…)$` als `{name}`).
- `Outline`: `__construct(array<string,string> $presetNames = [])`; `rows(NodeIndex $index, ?string $rootId = null, ?int $maxDepth = null): list<array{id, type, label, excerpt, info, presets: list<string>, children: int, editable: bool, depth: int}>`; `text(array $rows): string`.
- [ ] **Step 1: Failing Tests**
`tests/Unit/Integrations/Divi/BlockTree/NodeIndexTest.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use PHPUnit\Framework\TestCase;
final class NodeIndexTest extends TestCase
{
public function testAssignsPathIdsPerParentAndLetter(): void
{
$index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-basic.html')));
self::assertSame(
[
's0', 's0.r0', 's0.r0.c0', 's0.r0.c0.m0', 's0.r0.c1', 's0.r0.c1.m0', 's0.r0.c1.m1', 's0.r0.c1.m2',
's1', 's1.r0', 's1.r0.c0', 's1.r0.c0.m0', 's1.r0.c1', 's1.r0.c1.m0', 's1.r0.c1.m0.m0',
's1.r0.c1.m0.m1',
],
array_column($index->entries(), 'id')
);
self::assertSame('divi/button', $index->find('s0.r0.c1.m2')?->name());
self::assertSame('s0.r0.c1', $index->parentId($index->find('s0.r0.c1.m2')));
self::assertNull($index->parentId($index->find('s1')));
self::assertSame('divi/placeholder', $index->root() instanceof \LIT\MCP\Integrations\Divi\BlockTree\Block
? $index->root()->name() : '');
self::assertSame($index->root(), $index->parentOf($index->find('s0')));
self::assertNull($index->find('s9'));
}
public function testGlobalAndForeignBlocksAreLeavesWithOwnLetters(): void
{
$index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-mixed.html')));
self::assertSame(
['x0', 's0', 's0.c0', 's0.c0.m0', 's0.c1', 's0.c1.r0', 's0.c1.r0.c0', 's0.c1.r0.c0.m0', 's0.c1.r0.c1',
's1', 's1.g0', 's1.g1', 's1.g2'],
array_column($index->entries(), 'id')
);
self::assertSame(1, preg_match(NodeIndex::ID_PATTERN, 's1.g2'));
self::assertSame(0, preg_match(NodeIndex::ID_PATTERN, 's1..g2'));
}
public function testDocumentWithoutPlaceholderIsItsOwnRoot(): void
{
$document = (new Parser())->parse('<!-- wp:divi/section /-->');
$index = NodeIndex::build($document);
self::assertSame($document, $index->root());
self::assertSame('s0', $index->entries()[0]['id']);
}
private function fixture(string $name): string
{
return (string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/' . $name);
}
}
```
`tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
use LIT\MCP\Integrations\Divi\BlockTree\Outline;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use PHPUnit\Framework\TestCase;
use stdClass;
final class OutlineTest extends TestCase
{
public function testRowsAndTextOfAPage(): void
{
$index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-basic.html')));
$outline = new Outline(['a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' => 'Hero-Titel']);
$rows = $outline->rows($index);
self::assertSame(
['id' => 's0.r0.c1.m0', 'type' => 'heading', 'label' => '', 'excerpt' => 'Willkommen bei Müller & Söhne',
'info' => '', 'presets' => ['Hero-Titel'], 'children' => 0, 'editable' => true, 'depth' => 3],
$rows[5]
);
self::assertSame('Spalten 8_24+16_24', $rows[1]['info']);
self::assertSame('Seit 1987 zuverlässig – jetzt anfragen.', $rows[6]['excerpt']);
self::assertSame(
's0 section "Hero"' . "\n"
. ' s0.r0 row "Hero – Zeile" [Spalten 8_24+16_24]' . "\n"
. ' s0.r0.c0 column' . "\n"
. ' s0.r0.c0.m0 image "Hero – Bild" – Unser Team vor dem Büro',
implode("\n", array_slice(explode("\n", $outline->text($rows)), 0, 4))
);
}
public function testSubtreeAndDepthLimit(): void
{
$index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-basic.html')));
$outline = new Outline();
$rows = $outline->rows($index, 's1.r0.c1', 1);
self::assertSame(['s1.r0.c1', 's1.r0.c1.m0'], array_column($rows, 'id'));
self::assertSame([0, 1], array_column($rows, 'depth'));
self::assertSame(2, $rows[1]['children']);
}
public function testReadOnlyNodesAreMarked(): void
{
$index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-mixed.html')));
$text = (new Outline())->text((new Outline())->rows($index));
self::assertStringContainsString('x0 core/paragraph (nur lesbar)', $text);
self::assertStringContainsString('s1.g1 row "Globale Zeile" (nur lesbar)', $text);
self::assertStringContainsString('s0 section [specialty]', $text);
self::assertStringContainsString('s0.c1.r0 row-inner [Spalten 1_2+1_2]', $text);
}
public function testSummaryHelpers(): void
{
self::assertSame('{post_title}', NodeSummary::textOf(
'$variable({"type":"content","value":{"name":"post_title","settings":{}}})$'
));
self::assertSame('Mehr', NodeSummary::textOf(['linkUrl' => '/x', 'text' => 'Mehr']));
self::assertSame('', NodeSummary::textOf(new stdClass()));
}
public function testAttrMapFlattenGetSetRemove(): void
{
$attrs = [
'builderVersion' => '5.2.1',
'title' => ['innerContent' => ['desktop' => ['value' => 'Hallo']], 'decoration' => ['font' => [
'font' => ['desktop' => ['value' => ['headingLevel' => 'h2']], 'phone' => ['value' => ['size' => '20px']]],
]]],
'module' => ['decoration' => ['spacing' => ['desktop' => ['value' => new stdClass()]]]],
];
self::assertSame(
['module.decoration.spacing', 'title.decoration.font.font', 'title.innerContent'],
array_keys(AttrMap::flatten($attrs))
);
self::assertSame('Hallo', AttrMap::desktop($attrs, 'title.innerContent'));
self::assertTrue(AttrMap::isBreakpointMap(['desktop' => [], 'phone' => []]));
self::assertFalse(AttrMap::isBreakpointMap(['desktop' => [], 'font' => []]));
$changed = AttrMap::set($attrs, 'module.decoration.spacing.desktop.value.padding', '10px');
self::assertSame('10px', AttrMap::get($changed, 'module.decoration.spacing.desktop.value.padding'));
$removed = AttrMap::remove($attrs, 'title.decoration.font.font');
self::assertArrayNotHasKey('decoration', $removed['title']);
self::assertSame($attrs, AttrMap::remove($attrs, 'nope.path'));
}
private function fixture(string $name): string
{
return (string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/' . $name);
}
}
```
- [ ] **Step 2: Test laufen lassen (rot)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'NodeIndexTest|OutlineTest'`
Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\BlockTree\NodeIndex" not found`.
- [ ] **Step 3: Implementierung**
`src/Integrations/Divi/BlockTree/NodeIndex.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\BlockTree;
/**
* Path IDs of a Document (spec §5.3): s0, s0.r1, s0.r1.c0.m2. One letter per role –
* s section, r row / row-inner, c column / column-inner, m module (incl. group and child
* items), g global module or global layout, x non-Divi block – numbered per parent and letter.
* divi/placeholder wrappers are transparent (their children count as top level). Opaque and
* non-Divi blocks get an ID but are not descended into. IDs are only valid for the tree_rev
* they were read with; build a new index after every change.
*/
final class NodeIndex
{
public const ID_PATTERN = '/^[srcmgx]\d+(\.[srcmgx]\d+)*$/';
/** @var array<string, Block> */
private array $byId = [];
/** @var array<int, string> spl_object_id(block) => id */
private array $ids = [];
/** @var array<int, Container> spl_object_id(block) => container holding it */
private array $parents = [];
/** @var list<array{id: string, block: Block, depth: int}> */
private array $entries = [];
private function __construct(private Container $root)
{
}
public static function build(Document $document): self
{
$root = $document;
foreach ($document->children() as $block) {
if ($block->name() === 'divi/placeholder') {
$root = $block;
break;
}
}
$index = new self($root);
$counters = [];
$index->walk($document, '', 0, $counters);
return $index;
}
public static function letter(Block $block): string
{
if ($block->isGlobal()) {
return 'g';
}
return match ($block->name()) {
'divi/section' => 's',
'divi/row', 'divi/row-inner' => 'r',
'divi/column', 'divi/column-inner' => 'c',
default => $block->isDivi() ? 'm' : 'x',
};
}
/** Container that receives new top-level sections: the first divi/placeholder, else the document. */
public function root(): Container
{
return $this->root;
}
public function find(string $id): ?Block
{
return $this->byId[$id] ?? null;
}
public function idOf(Block $block): ?string
{
return $this->ids[spl_object_id($block)] ?? null;
}
public function parentOf(Block $block): ?Container
{
return $this->parents[spl_object_id($block)] ?? null;
}
/** ID of the parent node, null for top-level nodes (their container is the root or a placeholder). */
public function parentId(Block $block): ?string
{
$parent = $this->parentOf($block);
return $parent instanceof Block ? $this->idOf($parent) : null;
}
/** @return list<array{id: string, block: Block, depth: int}> In document order. */
public function entries(): array
{
return $this->entries;
}
/** @param array<string, int> $counters */
private function walk(Container $container, string $prefix, int $depth, array &$counters): void
{
foreach ($container->children() as $block) {
if ($prefix === '' && $block->name() === 'divi/placeholder') {
$this->walk($block, '', $depth, $counters);
continue;
}
$letter = self::letter($block);
$number = $counters[$letter] ?? 0;
$counters[$letter] = $number + 1;
$id = ($prefix === '' ? '' : $prefix . '.') . $letter . $number;
$this->byId[$id] = $block;
$this->ids[spl_object_id($block)] = $id;
$this->parents[spl_object_id($block)] = $container;
$this->entries[] = ['id' => $id, 'block' => $block, 'depth' => $depth];
if ($block->isDivi() && ! $block->isOpaque()) {
$childCounters = [];
$this->walk($block, $id, $depth + 1, $childCounters);
}
}
}
}
```
`src/Integrations/Divi/BlockTree/AttrMap.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\BlockTree;
use stdClass;
/**
* Divi 5 attributes addressed by dotted paths ("title.decoration.font.font"). The value at
* a path is a breakpoint map: {"desktop": {"value": …, "hover": …}, "phone": {"value": …}}.
* flatten() lists every such path – the full form lit-divi-node-get returns and
* lit-divi-update accepts (spec §5.4).
*/
final class AttrMap
{
public const BREAKPOINTS = ['desktop', 'tablet', 'phone', 'tabletWide', 'phoneWide', 'widescreen', 'ultraWide'];
/** Top-level keys that are no setting groups: versions, presets, global references. */
public const META_KEYS = [
'builderVersion', 'modulePreset', 'groupPreset', 'globalModule', 'globalParent', 'localAttrs',
];
/**
* @param array<string, mixed> $attrs
* @return array<string, mixed> Path => breakpoint map (or the raw value of an odd leaf).
*/
public static function flatten(array $attrs): array
{
$flat = [];
foreach ($attrs as $key => $value) {
if (! in_array($key, self::META_KEYS, true)) {
self::collect((string) $key, $value, $flat);
}
}
ksort($flat);
return $flat;
}
public static function isBreakpointMap(mixed $value): bool
{
if (! is_array($value) || $value === [] || array_is_list($value)) {
return false;
}
return array_diff(array_map('strval', array_keys($value)), self::BREAKPOINTS) === [];
}
/** @param array<string, mixed> $attrs */
public static function get(array $attrs, string $path): mixed
{
$value = $attrs;
foreach (explode('.', $path) as $segment) {
if (! is_array($value) || ! array_key_exists($segment, $value)) {
return null;
}
$value = $value[$segment];
}
return $value;
}
/**
* Desktop value of a path (state "value"), e.g. the text of "title.innerContent".
*
* @param array<string, mixed> $attrs
*/
public static function desktop(array $attrs, string $path): mixed
{
$map = self::get($attrs, $path);
return is_array($map) && is_array($map['desktop'] ?? null) ? ($map['desktop']['value'] ?? null) : null;
}
/**
* @param array<string, mixed> $attrs
* @return array<string, mixed>
*/
public static function set(array $attrs, string $path, mixed $value): array
{
$segments = explode('.', $path);
$key = (string) array_shift($segments);
if ($segments === []) {
$attrs[$key] = $value;
return $attrs;
}
$child = $attrs[$key] ?? [];
$child = $child instanceof stdClass ? (array) $child : (is_array($child) ? $child : []);
$attrs[$key] = self::set($child, implode('.', $segments), $value);
return $attrs;
}
/**
* Removes the value at $path and every parent that becomes empty.
*
* @param array<string, mixed> $attrs
* @return array<string, mixed>
*/
public static function remove(array $attrs, string $path): array
{
$segments = explode('.', $path);
$key = (string) array_shift($segments);
if (! array_key_exists($key, $attrs)) {
return $attrs;
}
if ($segments === []) {
unset($attrs[$key]);
return $attrs;
}
if (! is_array($attrs[$key])) {
return $attrs;
}
$child = self::remove($attrs[$key], implode('.', $segments));
if ($child === []) {
unset($attrs[$key]);
} else {
$attrs[$key] = $child;
}
return $attrs;
}
/** @param array<string, mixed> $flat */
private static function collect(string $path, mixed $value, array &$flat): void
{
if (self::isBreakpointMap($value) || ! is_array($value) || $value === [] || array_is_list($value)) {
$flat[$path] = $value;
return;
}
foreach ($value as $key => $item) {
self::collect($path . '.' . $key, $item, $flat);
}
}
}
```
`src/Integrations/Divi/BlockTree/NodeSummary.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\BlockTree;
/**
* Short human-readable facts about a node for the outline: admin label, first text,
* preset IDs, column layout. Pure PHP.
*/
final class NodeSummary
{
/** innerContent groups read first for the excerpt (then alphabetical). */
private const PRIORITY = ['title', 'name', 'author', 'content', 'button', 'number'];
/** Keys of object values that carry readable text, in this order. */
private const TEXT_KEYS = ['text', 'title', 'label', 'alt', 'address', 'linkUrl', 'url', 'src'];
public static function label(Block $block): string
{
$label = AttrMap::desktop($block->attrs(), 'module.meta.adminLabel');
return is_string($label) ? $label : '';
}
public static function excerpt(Block $block, int $max = 60): string
{
$paths = array_filter(
array_keys(AttrMap::flatten($block->attrs())),
static fn (string $path): bool => str_ends_with($path, '.innerContent')
);
usort($paths, static function (string $a, string $b): int {
return [self::rank($a), $a] <=> [self::rank($b), $b];
});
foreach ($paths as $path) {
$text = self::textOf(AttrMap::desktop($block->attrs(), $path));
if ($text !== '') {
return self::shorten($text, $max);
}
}
return '';
}
/** @return list<string> */
public static function presetIds(Block $block): array
{
$presets = $block->attrs()['modulePreset'] ?? [];
if (is_string($presets)) {
$presets = [$presets];
}
return is_array($presets)
? array_values(array_filter($presets, static fn (mixed $id): bool => is_string($id) && $id !== ''))
: [];
}
/** Layout facts: section type, column widths of a row (e.g. "8_24+16_24"). */
public static function info(Block $block): string
{
if ($block->name() === 'divi/section') {
$type = AttrMap::desktop($block->attrs(), 'module.advanced.type');
return is_string($type) && $type !== 'regular' ? $type : '';
}
if (in_array($block->name(), ['divi/row', 'divi/row-inner'], true)) {
$structure = AttrMap::desktop($block->attrs(), 'module.advanced.columnStructure');
$fallback = is_string($structure) ? explode(',', $structure) : [];
$widths = [];
foreach ($block->children() as $position => $column) {
$widths[] = self::columnWidth($column) ?? ($fallback[$position] ?? '24_24');
}
return $widths === [] ? '' : 'Spalten ' . implode('+', $widths);
}
return '';
}
/** flexType (Divi 5 flex layout) or legacy column type; null when the column sets neither. */
public static function columnWidth(Block $column): ?string
{
$sizing = AttrMap::desktop($column->attrs(), 'module.decoration.sizing');
if (is_array($sizing) && is_string($sizing['flexType'] ?? null)) {
return $sizing['flexType'];
}
$type = AttrMap::desktop($column->attrs(), 'module.advanced.type');
return is_string($type) ? $type : null;
}
/** Readable text of a value: HTML stripped, dynamic content shown as {name}. */
public static function textOf(mixed $value): string
{
if (is_array($value)) {
foreach (self::TEXT_KEYS as $key) {
$text = self::textOf($value[$key] ?? null);
if ($text !== '') {
return $text;
}
}
return '';
}
if (! is_string($value) && ! is_int($value) && ! is_float($value)) {
return '';
}
$value = (string) $value;
if (preg_match('/^\$variable\((.*)\)\$$/s', $value, $match) === 1) {
$data = json_decode($match[1], true);
$name = is_array($data) && is_string($data['value']['name'] ?? null) ? $data['value']['name'] : 'dynamisch';
return '{' . $name . '}';
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags -- pure PHP (spec §4.4).
$text = html_entity_decode(strip_tags($value), ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim((string) preg_replace('/\s+/u', ' ', $text));
}
private static function rank(string $path): int
{
$index = array_search(explode('.', $path)[0], self::PRIORITY, true);
return $index === false ? count(self::PRIORITY) : (int) $index;
}
private static function shorten(string $text, int $max): string
{
return mb_strlen($text) > $max ? rtrim(mb_substr($text, 0, $max - 1)) . '…' : $text;
}
}
```
`src/Integrations/Divi/BlockTree/Outline.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\BlockTree;
/**
* The page tree as rows and as compact text (lit-divi-outline, spec §5.3/§5.7; also the
* "outline" of write results). Preset IDs are shown with their names when known.
*/
final class Outline
{
/** @param array<string, string> $presetNames Preset ID => name. */
public function __construct(private array $presetNames = [])
{
}
/**
* @return list<array{id: string, type: string, label: string, excerpt: string, info: string,
* presets: list<string>, children: int, editable: bool, depth: int}>
*/
public function rows(NodeIndex $index, ?string $rootId = null, ?int $maxDepth = null): array
{
$rows = [];
$baseDepth = null;
foreach ($index->entries() as $entry) {
$id = $entry['id'];
if ($rootId !== null && $id !== $rootId && ! str_starts_with($id, $rootId . '.')) {
continue;
}
$baseDepth ??= $entry['depth'];
$depth = $entry['depth'] - $baseDepth;
if ($maxDepth !== null && $depth > $maxDepth) {
continue;
}
$rows[] = $this->row($entry['block'], $id, $depth);
}
return $rows;
}
/** @param list<array<string, mixed>> $rows Output of rows(). */
public function text(array $rows): string
{
$lines = [];
foreach ($rows as $row) {
$line = str_repeat(' ', (int) $row['depth']) . $row['id'] . ' ' . $row['type'];
$line .= $row['label'] !== '' ? ' "' . $row['label'] . '"' : '';
$line .= $row['info'] !== '' ? ' [' . $row['info'] . ']' : '';
$line .= $row['presets'] !== [] ? ' {Preset: ' . implode(', ', $row['presets']) . '}' : '';
$line .= $row['excerpt'] !== '' ? ' – ' . $row['excerpt'] : '';
$line .= $row['editable'] ? '' : ' (nur lesbar)';
$lines[] = $line;
}
return implode("\n", $lines);
}
/**
* @return array{id: string, type: string, label: string, excerpt: string, info: string,
* presets: list<string>, children: int, editable: bool, depth: int}
*/
private function row(Block $block, string $id, int $depth): array
{
$editable = $block->isDivi() && ! $block->isOpaque();
return [
'id' => $id,
'type' => $block->isDivi() ? $block->shortName() : $block->name(),
'label' => NodeSummary::label($block),
'excerpt' => NodeSummary::excerpt($block),
'info' => $editable ? NodeSummary::info($block) : '',
'presets' => array_map(
fn (string $presetId): string => $this->presetNames[$presetId] ?? $presetId,
NodeSummary::presetIds($block)
),
'children' => $editable ? count($block->children()) : 0,
'editable' => $editable,
'depth' => $depth,
];
}
}
```
- [ ] **Step 4: Tests und Lint (grün)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter BlockTree && composer lint`
Expected: PASS, phpcs ohne Meldungen.
- [ ] **Step 5: Commit**
```bash
cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
git add src/Integrations/Divi/BlockTree/ tests/Unit/Integrations/Divi/BlockTree/
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi path IDs, attribute paths and outline
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
```
---
### Task 4: Modul-Katalog, Attributpfade, Kurzform und `lit/divi-catalog`
Spec §5.4/§5.7: Module und Attributpfade kommen live aus `ModuleRegistration::get_all_core_modules_metadata()` (100 Module, Schlüssel ohne `divi/`, `name` mit) und `ModuleRegistration::get_default_attrs($name, 'default')`. Gültige Pfade = Gruppen aus den `settings` jedes Attributs (`<attr>.innerContent`, `<attr>.<decoration|advanced>.<gruppe>`), alle `attrName`-Verweise darin, alle Pfade der Default-Attribute und `module.meta.adminLabel`; Schrift- und Textgruppen liegen tiefer (`title.decoration.font.font`, `content.decoration.bodyFont.body.font`, `module.advanced.text.text`). Die Kurzform (~20 Module + Kind-Module) ist kuratiert und stammt aus Divis Conversion-Outlines (`…/module-library/src/components/<modul>/conversion-outline.json`); ein DDEV-Skript prüft sie gegen den Live-Katalog (live verifiziert: alle 29 Typen stimmen; `social-media-follow` hat das Kind `social-media-follow-network`). Mit diesem Task entsteht die `DiviIntegration` (zunächst nur mit dem Katalog).
**Files:**
- Create: `src/Integrations/Divi/Schema/{AttrPathIndex,ModuleCatalog,ShortformMap,DiviModules,DiviCatalogAbility}.php`, `src/Integrations/Divi/DiviIntegration.php`
- Create: `tests/Integration/divi-metadata-fixture.php`, `tests/Integration/divi-shortform-check.php`, (generiert) `tests/Fixtures/divi/metadata-sample.json`
- Modify: `src/Plugin.php` (Integration registrieren), `docs/guides/overview.md` („Direkt sichtbar:“)
- Test: `tests/Unit/Integrations/Divi/Schema/ModuleCatalogTest.php`, `tests/Unit/Integrations/Divi/Schema/DiviCatalogAbilityTest.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php`
**Interfaces:**
- Consumes: `AttrMap::isBreakpointMap()`, `AttrMap::META_KEYS`, `AttrMap::desktop()` (Task 3); `Errors`, `JsonMap`, `Ability`, `Integration`.
- Produces:
- `AttrPathIndex::NESTED` (Gruppen mit tieferen Pfaden); `static paths(array $attributes, array $defaults): list<string>` (sortiert); `static suggest(string $path, list<string> $known, int $max = 5): list<string>`.
- `ModuleCatalog`: `__construct(array $metadata, ?callable $defaults = null)` (`callable(string $fullName): array`); `static normalize(string $type): string` (`"Button"` → `"divi/button"`); `has(string $name): bool`; `names(): list<string>`; `title(string): string`; `category(string): string` (`module`/`structure`/`child-module`); `childrenOf(string): list<string>` (childrenName + childModuleName); `paths(string): list<string>`; `isKnownPath(string $name, string $path): bool`; `suggestions(string $name, string $path): list<string>`; `summary(): list<array{name, title, category, children}>`.
- `ShortformMap` (Kurzname ohne `divi/`): `static types(): list<string>`; `static has(string): bool`; `static fields(string): array<string, array{path: string, sub: ?string, kind: string, description: string, keys?: array, values?: list<string>, with?: array}>`; `static childType(string): ?string`; `static primary(string): ?string`; `static values(string $type, array $attrs): array<string, mixed>` (aktuelle Desktop-Werte ohne Medienfelder). Arten: `text, html, code, url, media, enum, onoff, level, number, ids, color`.
- `DiviModules` (nicht final): `static isAvailable(): bool`; `catalog(): ModuleCatalog` (einmal je Anfrage); `builderVersion(): string` (`ET_BUILDER_PRODUCT_VERSION`, Fallback `5.2.1`).
- `DiviCatalogAbility(DiviModules $modules)` – `lit/divi-catalog`, direkt, readonly; `CODE_NOTE`; Input `{module?}`; Output `{modules?: list, module?: {name, title, category, children, item_type, shortform, paths}, notes}`.
- `DiviIntegration(ContentSchema $schema)` – `id()` = `divi`, `isAvailable()` = `DiviModules::isAvailable()`.
- [ ] **Step 1: Echte Modul-Metadaten als Fixture erzeugen (DDEV)**
`tests/Integration/divi-metadata-fixture.php`:
```php
<?php
/**
* Writes tests/Fixtures/divi/metadata-sample.json: Divi's own module metadata (name, title,
* category, children, attribute settings) and default attributes for the modules the unit
* tests use, so ModuleCatalog is tested against real Divi data without WordPress.
* Usage (DDEV, WordPress root): wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-metadata-fixture.php
* No `declare(strict_types=1);` – wp eval-file runs the file through eval().
*/
defined('WP_CLI') && WP_CLI || exit;
use ET\Builder\Packages\ModuleLibrary\ModuleRegistration;
if (! class_exists(ModuleRegistration::class)) {
WP_CLI::error('Divi 5 is not active.');
}
$wanted = [
'section', 'row', 'column', 'text', 'heading', 'button', 'image', 'blurb', 'code', 'accordion',
'accordion-item', 'fullwidth-header', 'group',
];
$all = ModuleRegistration::get_all_core_modules_metadata();
$metadata = [];
$defaults = [];
foreach ($wanted as $key) {
$module = $all[$key] ?? null;
if (! is_array($module)) {
WP_CLI::error('Missing module ' . $key);
}
$attributes = [];
foreach ((array) ($module['attributes'] ?? []) as $attrName => $definition) {
$attributes[$attrName] = ['settings' => $definition['settings'] ?? []];
}
$metadata[] = array_filter([
'name' => $module['name'],
'title' => $module['title'] ?? '',
'category' => $module['category'] ?? '',
'childrenName' => $module['childrenName'] ?? null,
'childModuleName' => $module['childModuleName'] ?? null,
'attributes' => $attributes,
], static fn ($value): bool => $value !== null);
$defaults[$module['name']] = ModuleRegistration::get_default_attrs($module['name'], 'default');
}
$file = dirname(__DIR__) . '/Fixtures/divi/metadata-sample.json';
$json = wp_json_encode(
['divi' => ET_BUILDER_PRODUCT_VERSION, 'metadata' => $metadata, 'defaults' => $defaults],
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);
if (! is_string($json) || file_put_contents($file, $json . "\n") === false) {
WP_CLI::error('Cannot write ' . $file);
}
WP_CLI::success(sprintf('metadata-sample.json: %d modules, %d bytes', count($metadata), strlen($json)));
```
Run:
```bash
cd /home/janniclas/Projekte/WordPress
timeout 300 ddev exec wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-metadata-fixture.php
```
Expected: `Success: metadata-sample.json: 13 modules, … bytes` (ca. 190 kB). Die Datei nicht von Hand ändern.
- [ ] **Step 2: Failing Tests**
`tests/Unit/Integrations/Divi/Schema/ModuleCatalogTest.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Schema;
use LIT\MCP\Integrations\Divi\Schema\AttrPathIndex;
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
use PHPUnit\Framework\TestCase;
final class ModuleCatalogTest extends TestCase
{
/** Real Divi 5.2.1 metadata (tests/Integration/divi-metadata-fixture.php). */
public static function sampleCatalog(): ModuleCatalog
{
$sample = json_decode(
(string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/metadata-sample.json'),
true
);
return new ModuleCatalog(
$sample['metadata'],
static fn (string $name): array => (array) ($sample['defaults'][$name] ?? [])
);
}
public function testKnowsModulesTitlesCategoriesAndChildren(): void
{
$catalog = self::sampleCatalog();
self::assertTrue($catalog->has('divi/button'));
self::assertFalse($catalog->has('button'));
self::assertSame('divi/button', ModuleCatalog::normalize(' Button '));
self::assertSame('divi/accordion-item', ModuleCatalog::normalize('divi/accordion-item'));
self::assertSame('structure', $catalog->category('divi/section'));
self::assertSame(['divi/accordion-item'], $catalog->childrenOf('divi/accordion'));
self::assertSame(['divi/column'], $catalog->childrenOf('divi/row'));
self::assertContains(
['name' => 'divi/heading', 'title' => 'Heading', 'category' => 'module', 'children' => []],
$catalog->summary()
);
}
public function testDerivesAttributePathsFromSettingsAndDefaults(): void
{
$catalog = self::sampleCatalog();
foreach (
[
'button.innerContent', 'button.decoration.button', 'button.decoration.background',
'module.advanced.alignment', 'module.decoration.spacing', 'module.meta.adminLabel',
'module.advanced.text.text',
] as $path
) {
self::assertTrue($catalog->isKnownPath('divi/button', $path), $path);
}
self::assertTrue($catalog->isKnownPath('divi/heading', 'title.decoration.font.font'));
self::assertFalse($catalog->isKnownPath('divi/heading', 'title.decoration.font'));
self::assertTrue($catalog->isKnownPath('divi/text', 'content.decoration.bodyFont.body.font'));
self::assertTrue($catalog->isKnownPath('divi/accordion-item', 'module.advanced.open'));
self::assertTrue($catalog->isKnownPath('divi/column', 'module.decoration.sizing'));
self::assertFalse($catalog->isKnownPath('divi/button', 'button.innerContent.text'));
self::assertSame('title.decoration.font.font', $catalog->suggestions('divi/heading', 'title.decoration.fnt.font')[0]);
}
public function testPathIndexFromHandwrittenMetadata(): void
{
$paths = AttrPathIndex::paths(
[
'title' => ['settings' => [
'innerContent' => ['groupType' => 'group-item', 'item' => ['attrName' => 'title.innerContent']],
'decoration' => ['font' => [], 'groupType' => 'x'],
]],
'module' => ['settings' => [
'meta' => ['meta' => []],
'advanced' => ['text' => ['item' => ['attrName' => 'module.advanced.alignment']]],
]],
],
['module' => ['advanced' => ['open' => ['desktop' => ['value' => 'off']]]], 'builderVersion' => '5']
);
self::assertSame(
[
'module.advanced.alignment', 'module.advanced.open', 'module.advanced.text.text',
'module.advanced.text.textShadow', 'module.meta.adminLabel', 'title.decoration.font.font',
'title.decoration.font.textShadow', 'title.innerContent',
],
$paths
);
}
public function testEveryShortformPathIsKnownForTheSampledModules(): void
{
$catalog = self::sampleCatalog();
foreach (['text', 'heading', 'button', 'image', 'blurb', 'code', 'accordion-item'] as $type) {
foreach (ShortformMap::fields($type) as $field => $spec) {
self::assertTrue(
$catalog->isKnownPath('divi/' . $type, $spec['path']),
sprintf('%s.%s → %s', $type, $field, $spec['path'])
);
}
}
}
public function testShortformTableIsConsistent(): void
{
$kinds = ['text', 'html', 'code', 'url', 'media', 'enum', 'onoff', 'level', 'number', 'ids', 'color'];
foreach (ShortformMap::types() as $type) {
$fields = ShortformMap::fields($type);
$primary = ShortformMap::primary($type);
self::assertTrue($primary === null || isset($fields[$primary]), $type . ' primary');
$child = ShortformMap::childType($type);
self::assertTrue($child === null || ShortformMap::has($child), $type . ' child');
foreach ($fields as $field => $spec) {
self::assertContains($spec['kind'], $kinds, $type . '.' . $field);
self::assertSame(1, preg_match('/^[a-zA-Z]+(\.[a-zA-Z]+)+$/', $spec['path']), $type . '.' . $field);
self::assertSame($spec['kind'] === 'media', isset($spec['keys']), $type . '.' . $field);
self::assertSame($spec['kind'] === 'enum', isset($spec['values']), $type . '.' . $field);
}
}
self::assertSame(
['text' => 'Termin', 'url' => '/kontakt'],
ShortformMap::values('button', ['button' => ['innerContent' => ['desktop' => ['value' => [
'text' => 'Termin', 'linkUrl' => '/kontakt',
]]]]])
);
}
}
```
`tests/Unit/Integrations/Divi/Schema/DiviCatalogAbilityTest.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Schema;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class DiviCatalogAbilityTest extends TestCase
{
private function ability(): DiviCatalogAbility
{
$modules = Mockery::mock(DiviModules::class);
$modules->allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog());
return new DiviCatalogAbility($modules);
}
public function testListsModulesWithShortformFlag(): void
{
$result = $this->ability()->execute([]);
$button = array_values(array_filter($result['modules'], static fn (array $m): bool => $m['name'] === 'divi/button'));
self::assertSame(
[['name' => 'divi/button', 'title' => 'Button', 'category' => 'module', 'children' => [], 'shortform' => true]],
$button
);
$header = array_values(array_filter(
$result['modules'],
static fn (array $m): bool => $m['name'] === 'divi/fullwidth-header'
));
self::assertFalse($header[0]['shortform']);
self::assertSame(DiviCatalogAbility::CODE_NOTE, $result['notes'][0]);
}
public function testDetailOfOneModule(): void
{
$result = $this->ability()->execute(['module' => 'Accordion']);
self::assertSame('divi/accordion', $result['module']['name']);
self::assertSame(['divi/accordion-item'], $result['module']['children']);
self::assertSame('accordion-item', $result['module']['item_type']);
self::assertContains('title.decoration.font.font', $result['module']['paths']);
$button = $this->ability()->execute(['module' => 'divi/button'])['module'];
self::assertSame('linkUrl', $button['shortform']['url']['sub']);
}
public function testUnknownModuleAndPermission(): void
{
self::assertSame('lit_not_found', $this->ability()->execute(['module' => 'slider-pro'])->get_error_code());
Functions\when('current_user_can')->justReturn(false);
self::assertSame('lit_forbidden', $this->ability()->permission([])->get_error_code());
self::assertTrue($this->ability()->annotations()['readonly']);
self::assertTrue($this->ability()->isDirect());
}
}
```
`tests/Unit/Integrations/Divi/DiviIntegrationTest.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Integrations\Divi\DiviIntegration;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class DiviIntegrationTest extends TestCase
{
/** Grows with every task that adds Divi abilities – keep in the order of abilities(). */
private const EXPECTED = [
'lit/divi-catalog',
];
public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void
{
$integration = new DiviIntegration(Mockery::mock(ContentSchema::class));
self::assertSame('divi', $integration->id());
self::assertFalse($integration->isAvailable(), 'no Divi classes in unit tests');
self::assertSame(
self::EXPECTED,
array_map(static fn (Ability $ability): string => $ability->name(), $integration->abilities())
);
foreach ($integration->abilities() as $ability) {
self::assertSame('object', $ability->inputSchema()['type'], $ability->name());
self::assertFalse($ability->inputSchema()['additionalProperties'], $ability->name());
self::assertNotNull($ability->outputSchema(), $ability->name());
}
}
}
```
- [ ] **Step 3: Test laufen lassen (rot)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'ModuleCatalogTest|DiviCatalogAbilityTest|DiviIntegrationTest'`
Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\Schema\ModuleCatalog" not found`.
- [ ] **Step 4: Implementierung**
`src/Integrations/Divi/Schema/AttrPathIndex.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Schema;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
/**
* Known attribute paths of one module, derived from Divi's module metadata (settings of
* every attribute group, attrName references) and its default attributes (spec §5.4:
* "Attributpfade werden gegen den Katalog validiert"). Font and text groups store their
* values one or two levels deeper; those paths are expanded (title.decoration.font.font).
*/
final class AttrPathIndex
{
/** "category.group" => sub paths holding the breakpoint maps. */
public const NESTED = [
'decoration.font' => ['font', 'textShadow'],
'decoration.bodyFont' => [
'body.font', 'body.textShadow', 'link.font', 'link.textShadow', 'ul.font', 'ul.textShadow', 'ul.list',
'ol.font', 'ol.textShadow', 'ol.list', 'quote.font', 'quote.textShadow', 'quote.border',
],
'decoration.headingFont' => [
'h1.font', 'h1.textShadow', 'h2.font', 'h2.textShadow', 'h3.font', 'h3.textShadow',
'h4.font', 'h4.textShadow', 'h5.font', 'h5.textShadow', 'h6.font', 'h6.textShadow',
],
'advanced.text' => ['text', 'textShadow'],
];
/** Keys inside a settings category that describe the UI, not an attribute group. */
private const UI_KEYS = ['groupType', 'item', 'items', 'component', 'groups', 'priority', 'render', 'panel'];
/**
* @param array<string, mixed> $attributes Metadata "attributes" of one module.
* @param array<string, mixed> $defaults Its default attributes.
* @return list<string> Sorted.
*/
public static function paths(array $attributes, array $defaults): array
{
$paths = ['module.meta.adminLabel' => true];
foreach ($attributes as $attrName => $definition) {
$settings = is_array($definition['settings'] ?? null) ? $definition['settings'] : [];
foreach ($settings as $category => $groups) {
if ($category === 'innerContent') {
$paths[$attrName . '.innerContent'] = true;
continue;
}
if ($category === 'meta' || ! is_array($groups)) {
continue;
}
foreach (array_keys($groups) as $group) {
if (is_string($group) && ! in_array($group, self::UI_KEYS, true)) {
self::add($paths, $attrName . '.' . $category . '.' . $group);
}
}
}
self::attrNames($settings, $paths);
}
self::fromDefaults($defaults, '', $paths);
$list = array_map('strval', array_keys($paths));
sort($list);
return $list;
}
/**
* Up to $max known paths closest to $path (same attribute group first).
*
* @param list<string> $known
* @return list<string>
*/
public static function suggest(string $path, array $known, int $max = 5): array
{
$first = explode('.', $path)[0];
$scored = [];
foreach ($known as $candidate) {
$penalty = explode('.', $candidate)[0] === $first ? 0 : 10;
$scored[$candidate] = levenshtein($path, $candidate) + $penalty;
}
asort($scored);
return array_slice(array_map('strval', array_keys($scored)), 0, $max);
}
/** @param array<string, bool> $paths */
private static function add(array &$paths, string $path): void
{
$segments = explode('.', $path);
$nested = count($segments) === 3 ? (self::NESTED[$segments[1] . '.' . $segments[2]] ?? null) : null;
if ($nested === null) {
$paths[$path] = true;
return;
}
foreach ($nested as $sub) {
$paths[$path . '.' . $sub] = true;
}
}
/**
* @param array<array-key, mixed> $settings
* @param array<string, bool> $paths
*/
private static function attrNames(array $settings, array &$paths): void
{
foreach ($settings as $key => $value) {
if ($key === 'attrName' && is_string($value) && preg_match('/^[A-Za-z0-9]+(\.[A-Za-z0-9]+)+$/', $value)) {
self::add($paths, $value);
} elseif (is_array($value)) {
self::attrNames($value, $paths);
}
}
}
/**
* @param array<array-key, mixed> $attrs
* @param array<string, bool> $paths
*/
private static function fromDefaults(array $attrs, string $prefix, array &$paths): void
{
foreach ($attrs as $key => $value) {
if ($prefix === '' && in_array($key, AttrMap::META_KEYS, true)) {
continue;
}
$path = $prefix === '' ? (string) $key : $prefix . '.' . $key;
if (AttrMap::isBreakpointMap($value)) {
$paths[$path] = true;
} elseif (is_array($value)) {
self::fromDefaults($value, $path, $paths);
}
}
}
}
```
`src/Integrations/Divi/Schema/ModuleCatalog.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Schema;
/**
* The modules this Divi installation knows (spec §5.7 lit-divi-catalog), built from
* ModuleRegistration::get_all_core_modules_metadata() – always live, never a copy.
* Pure PHP: the metadata array and a defaults callback are passed in.
*/
final class ModuleCatalog
{
/** @var array<string, array<string, mixed>> Full name => metadata. */
private array $modules = [];
/** @var array<string, list<string>> */
private array $paths = [];
/** @var callable(string): array<string, mixed> */
private $defaults;
/**
* @param array<array-key, mixed> $metadata Divi's metadata list (keys are ignored).
* @param (callable(string): array<string, mixed>)|null $defaults Default attributes per full module name.
*/
public function __construct(array $metadata, ?callable $defaults = null)
{
foreach ($metadata as $module) {
if (is_array($module) && is_string($module['name'] ?? null)) {
$this->modules[$module['name']] = $module;
}
}
ksort($this->modules);
$this->defaults = $defaults ?? static fn (string $name): array => [];
}
/** "button", "Button" or "divi/button" → "divi/button". */
public static function normalize(string $type): string
{
$type = strtolower(trim($type));
return str_contains($type, '/') ? $type : 'divi/' . $type;
}
public function has(string $name): bool
{
return isset($this->modules[$name]);
}
/** @return list<string> */
public function names(): array
{
return array_map('strval', array_keys($this->modules));
}
public function title(string $name): string
{
$title = $this->modules[$name]['title'] ?? $name;
return is_string($title) ? $title : $name;
}
/** "module", "structure" or "child-module". */
public function category(string $name): string
{
$category = $this->modules[$name]['category'] ?? 'module';
return is_string($category) ? $category : 'module';
}
/** @return list<string> Modules allowed as children (childrenName and childModuleName). */
public function childrenOf(string $name): array
{
$module = $this->modules[$name] ?? [];
$children = is_array($module['childrenName'] ?? null) ? $module['childrenName'] : [];
if (is_string($module['childModuleName'] ?? null) && $module['childModuleName'] !== '') {
$children[] = $module['childModuleName'];
}
return array_values(array_unique(array_filter($children, 'is_string')));
}
/** @return list<string> */
public function paths(string $name): array
{
if (! isset($this->paths[$name])) {
$attributes = $this->modules[$name]['attributes'] ?? [];
$defaults = $this->has($name) ? ($this->defaults)($name) : [];
$this->paths[$name] = AttrPathIndex::paths(
is_array($attributes) ? $attributes : [],
is_array($defaults) ? $defaults : []
);
}
return $this->paths[$name];
}
public function isKnownPath(string $name, string $path): bool
{
return in_array($path, $this->paths($name), true);
}
/** @return list<string> */
public function suggestions(string $name, string $path): array
{
return AttrPathIndex::suggest($path, $this->paths($name));
}
/** @return list<array{name: string, title: string, category: string, children: list<string>}> */
public function summary(): array
{
$rows = [];
foreach ($this->names() as $name) {
$rows[] = [
'name' => $name,
'title' => $this->title($name),
'category' => $this->category($name),
'children' => $this->childrenOf($name),
];
}
return $rows;
}
}
```
`src/Integrations/Divi/Schema/ShortformMap.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Schema;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
/**
* Curated short form (spec §5.4) for the ~20 everyday modules: field => where Divi 5 keeps
* the value. Paths come from Divi's conversion outlines (…/module-library/src/components/
* <module>/conversion-outline.json, "*" = breakpoint/state) and are checked against the live
* catalog by tests/Integration/divi-shortform-check.php.
*
* A field is ['path' => attribute path, 'sub' => key inside the desktop value or null,
* 'kind' => value kind, 'description' => German help text], plus 'values' for enums,
* 'keys' ([url, id, alt]) for media and 'with' (fixed extra sub values) where needed.
* Kinds: text, html, code, url, media, enum, onoff, level, number, ids, color.
*/
final class ShortformMap
{
/** @var array<string, array{primary: ?string, child: ?string, fields: array<string, array<string, mixed>>}>|null */
private static ?array $modules = null;
/** @return list<string> Short module names with a short form. */
public static function types(): array
{
return array_keys(self::modules());
}
/** $type is the short name ("button"). */
public static function has(string $type): bool
{
return isset(self::modules()[$type]);
}
/** @return array<string, array<string, mixed>> */
public static function fields(string $type): array
{
return self::modules()[$type]['fields'] ?? [];
}
/** Short name of the item type a parent holds in "items" (accordion → accordion-item). */
public static function childType(string $type): ?string
{
return self::modules()[$type]['child'] ?? null;
}
/** Field whose emptiness makes the module "empty" (spec §5.5), null if none. */
public static function primary(string $type): ?string
{
return self::modules()[$type]['primary'] ?? null;
}
/**
* Current desktop values of the short-form fields (media fields are write-only and left out).
*
* @param array<string, mixed> $attrs
* @return array<string, mixed>
*/
public static function values(string $type, array $attrs): array
{
$values = [];
foreach (self::fields($type) as $field => $spec) {
if ($spec['kind'] === 'media') {
continue;
}
$value = AttrMap::desktop($attrs, (string) $spec['path']);
if ($spec['sub'] !== null) {
$value = is_array($value) ? ($value[$spec['sub']] ?? null) : null;
}
if ($value !== null) {
$values[$field] = $value;
}
}
return $values;
}
/**
* @param array<string, mixed> $extra keys, values or with.
* @return array<string, mixed>
*/
private static function field(
string $path,
?string $sub,
string $kind,
string $description,
array $extra = []
): array {
return array_merge(['path' => $path, 'sub' => $sub, 'kind' => $kind, 'description' => $description], $extra);
}
/**
* @param array<string, array<string, mixed>> $fields
* @return array{primary: ?string, child: ?string, fields: array<string, array<string, mixed>>}
*/
private static function module(?string $primary, array $fields, ?string $child = null): array
{
return ['primary' => $primary, 'child' => $child, 'fields' => $fields];
}
/** @return array<string, array{primary: ?string, child: ?string, fields: array<string, array<string, mixed>>}> */
private static function modules(): array
{
if (self::$modules !== null) {
return self::$modules;
}
$title = self::field('title.innerContent', null, 'text', 'Titel');
$content = self::field('content.innerContent', null, 'html', 'Inhalt (HTML erlaubt)');
$open = self::field('module.advanced.open', null, 'onoff', 'geöffnet (true/false)');
$level = self::field('title.decoration.font.font', 'headingLevel', 'level', 'Überschriftenebene h1–h6');
$buttonText = self::field('button.innerContent', 'text', 'text', 'Button-Beschriftung');
$buttonUrl = self::field('button.innerContent', 'linkUrl', 'url', 'Button-Linkziel');
$media = static fn (string $path, array $keys, string $description, array $with = []): array => self::field(
$path,
null,
'media',
$description,
$with === [] ? ['keys' => $keys] : ['keys' => $keys, 'with' => $with]
);
self::$modules = [
'text' => self::module('content', ['content' => $content]),
'heading' => self::module('text', [
'text' => self::field('title.innerContent', null, 'text', 'Überschrift'),
'level' => $level,
]),
'button' => self::module('text', [
'text' => self::field('button.innerContent', 'text', 'text', 'Beschriftung'),
'url' => self::field('button.innerContent', 'linkUrl', 'url', 'Linkziel'),
'new_window' => self::field('button.innerContent', 'linkTarget', 'onoff', 'in neuem Fenster öffnen'),
]),
'image' => self::module('src', [
'image_id' => $media(
'image.innerContent',
['src', 'id', 'alt'],
'Bild aus der Mediathek (Anhang-ID); setzt URL, ID und Alt-Text'
),
'src' => self::field('image.innerContent', 'src', 'url', 'Bild-URL'),
'alt' => self::field('image.innerContent', 'alt', 'text', 'Alt-Text'),
'title' => self::field('image.innerContent', 'titleText', 'text', 'Titel'),
'url' => self::field('image.innerContent', 'linkUrl', 'url', 'Linkziel'),
]),
'blurb' => self::module('title', [
'title' => self::field('title.innerContent', 'text', 'text', 'Titel'),
'url' => self::field('title.innerContent', 'url', 'url', 'Link des Titels'),
'content' => $content,
'image_id' => $media(
'imageIcon.innerContent',
['src', null, 'alt'],
'Bild aus der Mediathek (Anhang-ID)',
['useIcon' => 'off']
),
'src' => self::field('imageIcon.innerContent', 'src', 'url', 'Bild-URL', [
'with' => ['useIcon' => 'off'],
]),
'alt' => self::field('imageIcon.innerContent', 'alt', 'text', 'Alt-Text'),
'level' => $level,
]),
'cta' => self::module('title', [
'title' => $title,
'content' => $content,
'button_text' => $buttonText,
'button_url' => $buttonUrl,
]),
'icon' => self::module(null, [
'url' => self::field('icon.innerContent', 'url', 'url', 'Linkziel'),
'title' => self::field('icon.innerContent', 'title', 'text', 'Titel'),
]),
'divider' => self::module(null, [
'show' => self::field('divider.advanced.line', 'show', 'onoff', 'Linie anzeigen'),
'color' => self::field('divider.advanced.line', 'color', 'color', 'Farbe, z. B. {{color:primary}}'),
]),
'testimonial' => self::module('content', [
'author' => self::field('author.innerContent', null, 'text', 'Name'),
'job_title' => self::field('jobTitle.innerContent', null, 'text', 'Funktion'),
'company' => self::field('company.innerContent', 'text', 'text', 'Firma'),
'company_url' => self::field('company.innerContent', 'linkUrl', 'url', 'Link der Firma'),
'content' => $content,
'portrait_id' => $media('portrait.innerContent', ['url', null, null], 'Porträt aus der Mediathek'),
'portrait_url' => self::field('portrait.innerContent', 'url', 'url', 'Porträt-URL'),
]),
'team-member' => self::module('name', [
'name' => self::field('name.innerContent', null, 'text', 'Name'),
'position' => self::field('position.innerContent', null, 'text', 'Position'),
'content' => $content,
'image_id' => $media('image.innerContent', ['url', null, null], 'Foto aus der Mediathek'),
'image_url' => self::field('image.innerContent', 'url', 'url', 'Foto-URL'),
]),
'accordion' => self::module(null, [], 'accordion-item'),
'accordion-item' => self::module('title', ['title' => $title, 'content' => $content, 'open' => $open]),
'tabs' => self::module(null, [], 'tab'),
'tab' => self::module('title', ['title' => $title, 'content' => $content]),
'toggle' => self::module('title', ['title' => $title, 'content' => $content, 'open' => $open]),
'gallery' => self::module('image_ids', [
'image_ids' => self::field('image.advanced.galleryIds', null, 'ids', 'Anhang-IDs, z. B. [12, 33]'),
]),
'slider' => self::module(null, [], 'slide'),
'slide' => self::module('title', [
'title' => $title,
'content' => $content,
'button_text' => $buttonText,
'button_url' => $buttonUrl,
'image_id' => $media('image.innerContent', ['src', null, 'alt'], 'Bild aus der Mediathek'),
'src' => self::field('image.innerContent', 'src', 'url', 'Bild-URL'),
'alt' => self::field('image.innerContent', 'alt', 'text', 'Alt-Text'),
]),
'contact-form' => self::module(null, [
'title' => $title,
'email' => self::field('email.advanced.receiver', null, 'text', 'Empfänger (leer = Admin-E-Mail)'),
'success_message' => self::field('module.advanced.successMessage', null, 'text', 'Text nach Absenden'),
'submit_text' => self::field('button.innerContent', 'text', 'text', 'Beschriftung Absenden'),
], 'contact-field'),
'contact-field' => self::module('title', [
'title' => self::field('fieldItem.innerContent', null, 'text', 'Feldname'),
'id' => self::field('fieldItem.advanced.id', null, 'text', 'Feld-ID'),
'type' => self::field('fieldItem.advanced.type', null, 'enum', 'Feldart (text = mehrzeilig)', [
'values' => ['input', 'email', 'text', 'checkbox', 'radio', 'select'],
]),
'required' => self::field('fieldItem.advanced.required', null, 'onoff', 'Pflichtfeld'),
]),
'map' => self::module('address', [
'address' => self::field('map.innerContent', 'address', 'text', 'Adresse'),
'lat' => self::field('map.innerContent', 'lat', 'number', 'Breitengrad'),
'lng' => self::field('map.innerContent', 'lng', 'number', 'Längengrad'),
'zoom' => self::field('map.innerContent', 'zoom', 'number', 'Zoomstufe'),
], 'map-pin'),
'map-pin' => self::module('title', [
'title' => $title,
'content' => $content,
'address' => self::field('pin.innerContent', 'address', 'text', 'Adresse'),
'lat' => self::field('pin.innerContent', 'lat', 'number', 'Breitengrad'),
'lng' => self::field('pin.innerContent', 'lng', 'number', 'Längengrad'),
]),
'video' => self::module('src', [
'src' => self::field('video.innerContent', 'src', 'url', 'Video-URL (YouTube, MP4)'),
'thumbnail' => self::field('thumbnail.innerContent', 'src', 'url', 'Vorschaubild-URL'),
]),
'number-counter' => self::module('number', [
'number' => self::field('number.innerContent', null, 'text', 'Zahl'),
'title' => $title,
'percent' => self::field('number.advanced.enablePercentSign', null, 'onoff', 'Prozentzeichen anzeigen'),
]),
'social-media-follow' => self::module(null, [], 'social-media-follow-network'),
'social-media-follow-network' => self::module('url', [
'network' => self::field(
'socialNetwork.innerContent',
'title',
'text',
'Netzwerk, z. B. facebook, instagram, linkedin, youtube'
),
'url' => self::field('socialNetwork.innerContent', 'link', 'url', 'Profil-URL'),
'label' => self::field('socialNetwork.innerContent', 'label', 'text', 'Beschriftung'),
]),
'icon-list' => self::module(null, [], 'icon-list-item'),
'icon-list-item' => self::module('text', [
'text' => self::field('content.innerContent', null, 'text', 'Text'),
]),
'code' => self::module('content', [
'content' => self::field('content.innerContent', null, 'code', 'HTML/Code – nur ohne passendes Modul'),
]),
];
return self::$modules;
}
}
```
`src/Integrations/Divi/Schema/DiviModules.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Schema;
use ET\Builder\Packages\ModuleLibrary\ModuleRegistration;
/**
* Reads Divi's module metadata once per request (Divi 5 only). Not final so abilities can be
* unit-tested with a mock.
*/
class DiviModules
{
private ?ModuleCatalog $catalog = null;
public static function isAvailable(): bool
{
return class_exists(ModuleRegistration::class);
}
public function catalog(): ModuleCatalog
{
if ($this->catalog === null) {
$metadata = ModuleRegistration::get_all_core_modules_metadata();
$this->catalog = new ModuleCatalog(
is_array($metadata) ? $metadata : [],
static function (string $name): array {
$defaults = ModuleRegistration::get_default_attrs($name, 'default');
return is_array($defaults) ? $defaults : [];
}
);
}
return $this->catalog;
}
/** Divi version written into builderVersion of new blocks. */
public function builderVersion(): string
{
return defined('ET_BUILDER_PRODUCT_VERSION') ? (string) ET_BUILDER_PRODUCT_VERSION : '5.2.1';
}
}
```
`src/Integrations/Divi/Schema/DiviCatalogAbility.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Schema;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\JsonMap;
use WP_Error;
/**
* lit/divi-catalog: the modules of this Divi installation with their short-form fields;
* with "module" the details of one module incl. all known attribute paths (spec §5.7).
*/
final class DiviCatalogAbility implements Ability
{
public const CODE_NOTE = 'Code-Modul (code) nur verwenden, wenn kein natives Modul passt – es ist im '
. 'Divi-Builder nicht visuell bearbeitbar.';
public function __construct(private DiviModules $modules)
{
}
public function name(): string
{
return 'lit/divi-catalog';
}
public function label(): string
{
return 'Divi-Modulkatalog';
}
public function description(): string
{
return 'Listet die Divi-Module dieser Website (Name, Titel, Art, erlaubte Kind-Module) und welche eine '
. 'Kurzform haben. Mit module: Kurzform-Felder und alle gültigen Attributpfade des Moduls für die '
. 'Vollform.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'module' => ['type' => 'string', 'description' => 'Modul, z. B. "button" oder "divi/button".'],
],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return [
'type' => 'object',
'properties' => [
'modules' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'name' => ['type' => 'string'],
'title' => ['type' => 'string'],
'category' => ['type' => 'string'],
'children' => ['type' => 'array', 'items' => ['type' => 'string']],
'shortform' => ['type' => 'boolean'],
],
'required' => ['name', 'title', 'category', 'children', 'shortform'],
],
],
'module' => [
'type' => 'object',
'properties' => [
'name' => ['type' => 'string'],
'title' => ['type' => 'string'],
'category' => ['type' => 'string'],
'children' => ['type' => 'array', 'items' => ['type' => 'string']],
'item_type' => ['type' => ['string', 'null']],
'shortform' => ['type' => 'object'],
'paths' => ['type' => 'array', 'items' => ['type' => 'string']],
],
'required' => ['name', 'title', 'category', 'children', 'shortform', 'paths'],
],
'notes' => ['type' => 'array', 'items' => ['type' => 'string']],
],
'required' => ['notes'],
];
}
public function annotations(): array
{
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return true;
}
public function permission(array $input): bool|WP_Error
{
return current_user_can('edit_posts')
? true
: Errors::forbidden('Keine Berechtigung, Inhalte zu bearbeiten.');
}
public function execute(array $input): mixed
{
$catalog = $this->modules->catalog();
$notes = [self::CODE_NOTE, 'Details zur Vollform: lit-guide mit topic "divi-attrs".'];
if (! is_string($input['module'] ?? null) || $input['module'] === '') {
$modules = [];
foreach ($catalog->summary() as $module) {
$modules[] = $module + ['shortform' => ShortformMap::has(substr($module['name'], strlen('divi/')))];
}
return ['modules' => $modules, 'notes' => $notes];
}
$name = ModuleCatalog::normalize($input['module']);
if (! $catalog->has($name)) {
return Errors::notFound(
sprintf('Modul "%s" gibt es auf dieser Website nicht.', $input['module']),
'Verfügbare Module mit lit-divi-catalog ohne module abrufen.'
);
}
$short = substr($name, strlen('divi/'));
$item = ShortformMap::childType($short);
return [
'module' => [
'name' => $name,
'title' => $catalog->title($name),
'category' => $catalog->category($name),
'children' => $catalog->childrenOf($name),
'item_type' => $item,
'shortform' => JsonMap::of(ShortformMap::fields($short)),
'paths' => $catalog->paths($name),
],
'notes' => $notes,
];
}
}
```
`src/Integrations/Divi/DiviIntegration.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi;
use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
/**
* Divi 5 pages (spec §5, Etappe 2). Only available when the Divi 5 builder is loaded
* (spec §2 principle 4); Divi 4 is never edited (spec §3.4).
*/
final class DiviIntegration implements Integration
{
private DiviModules $modules;
public function __construct(private ContentSchema $schema)
{
$this->modules = new DiviModules();
}
public function id(): string
{
return 'divi';
}
public function isAvailable(): bool
{
return DiviModules::isAvailable();
}
public function abilities(): array
{
return [
new DiviCatalogAbility($this->modules),
];
}
}
```
In `src/Plugin.php` ersetzen:
```php
use LIT\MCP\Integrations\Core\CoreIntegration;
```
durch:
```php
use LIT\MCP\Integrations\Core\CoreIntegration;
use LIT\MCP\Integrations\Divi\DiviIntegration;
```
In `src/Plugin.php` ersetzen:
```php
new MediaIntegration($schema, $changeLog, $limits)
);
```
durch:
```php
new MediaIntegration($schema, $changeLog, $limits),
new DiviIntegration($schema)
);
```
In `docs/guides/overview.md` ersetzen:
```markdown
Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`,
`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`,
`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`.
```
durch:
```markdown
Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`,
`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`,
`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-divi-catalog`.
```
- [ ] **Step 5: Tests und Lint (grün)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test && composer lint`
Expected: alle Tests PASS (inkl. `ToolNamingTest::testOverviewListsExactlyTheDirectTools`), phpcs ohne Meldungen.
- [ ] **Step 6: Kurzform gegen den Live-Katalog prüfen (DDEV)**
`tests/Integration/divi-shortform-check.php`:
```php
<?php
/**
* Checks the curated short form against the live Divi catalog: every module exists, every
* field path is a known attribute path, every item type is an allowed child.
* Usage (DDEV, WordPress root): wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-shortform-check.php
* No `declare(strict_types=1);` – wp eval-file runs the file through eval().
*/
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\ShortformMap;
if (! DiviModules::isAvailable()) {
WP_CLI::error('Divi 5 is not active.');
}
$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)));
}
}
if ($problems !== []) {
WP_CLI::error("Short form does not match the catalog:\n" . implode("\n", $problems));
}
WP_CLI::success(sprintf(
'%d short-form modules match the catalog of %d modules (%.0f ms).',
count(ShortformMap::types()),
count($catalog->names()),
(microtime(true) - $started) * 1000
));
```
Run:
```bash
cd /home/janniclas/Projekte/WordPress
timeout 300 ddev exec wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-shortform-check.php
```
Expected: `Success: 29 short-form modules match the catalog of 100 modules (… ms).` Meldet das Skript einen unbekannten Pfad, nicht die Prüfung lockern, sondern den Pfad in `ShortformMap` anhand von `conversion-outline.json` und `lit-divi-catalog` korrigieren.
- [ ] **Step 7: Commit**
```bash
cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
git add src/Integrations/Divi/Schema/ src/Integrations/Divi/DiviIntegration.php src/Plugin.php docs/guides/overview.md tests/Unit/Integrations/Divi/Schema/ tests/Unit/Integrations/Divi/DiviIntegrationTest.php tests/Integration/divi-metadata-fixture.php tests/Integration/divi-shortform-check.php tests/Fixtures/divi/metadata-sample.json
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi module catalog, attribute paths, short form and lit/divi-catalog
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
```
---
### Task 5: Designsystem lesen – `lit/divi-design-system`
Spec §2 Nr. 2, §5.4, §5.7: Farben, Variablen, Presets, Theme-Schriften und -Größen immer live aus Divi. Quellen (Divi 5.2.1, in DDEV geprüft): `GlobalData::get_global_colors()` (`gcid-…` → `{color, label, status}`; die fünf Customizer-Farben `gcid-primary-color`, `gcid-secondary-color`, `gcid-heading-color`, `gcid-body-color`, `gcid-link-color`), `GlobalData::get_global_variables()` (Typ → `gvid-…`; **jeder Typ kommt als `stdClass`**; die Theme-Schriften stecken unter `fonts` als `--et_global_heading_font`/`--et_global_body_font`), `GlobalPreset::get_data()` (`{module|group: {name: {default, items: {id: {name, …}}}}}`), Option `et_divi` (Größen mit Divi-Defaults). Jeder Eintrag bekommt den Platzhalter, den neue Inhalte ab Etappe 2b verwenden.
**Files:**
- Create: `src/Integrations/Divi/Design/{DesignSystem,DesignSystemReader,DiviDesignSystemAbility}.php`
- Modify: `src/Integrations/Divi/DiviIntegration.php`, `docs/guides/overview.md`
- Test: `tests/Unit/Integrations/Divi/Design/DesignSystemTest.php`, `tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` (Liste ergänzen)
**Interfaces:**
- Consumes: `Errors`, `Ability`; `DiviIntegration` (Task 4).
- Produces:
- `DesignSystem` (rein): `COLOR_ALIASES`, `FONT_VARIABLES`, `SIZE_OPTIONS`; `static fromDivi(array $colors, array $variables, array $presets, array $options): self`; `colors(): list<array{id, label, value}>`; `variables(): list<array{id, type, label, value}>`; `presets(): list<array{kind: 'module'|'group', module, id, name, default: bool}>`; `fonts(): array{heading: string, body: string}`; `sizes(): array<string, string>`; `colorId(string $aliasLabelOrId): ?string`; `variable(string $labelOrId): ?array`; `modulePresetId(string $fullModule, string $nameOrId): ?string`; `modulePresetNames(string $fullModule): list<string>`; `presetNames(): array<string, string>`; `static colorPlaceholder(string $id, string $label): string`.
- `DesignSystemReader` (nicht final): `read(): DesignSystem` (einmal je Anfrage).
- `DiviDesignSystemAbility(DesignSystemReader $reader)` – `lit/divi-design-system`, direkt, readonly; Output `{colors[+placeholder], variables[+placeholder], fonts, sizes, presets, notes}`.
- [ ] **Step 1: Failing Tests**
`tests/Unit/Integrations/Divi/Design/DesignSystemTest.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
use PHPUnit\Framework\TestCase;
final class DesignSystemTest extends TestCase
{
/** Shapes as GlobalData / GlobalPreset return them (Divi 5.2.1, verified in DDEV). */
public static function sample(): DesignSystem
{
return DesignSystem::fromDivi(
[
'gcid-primary-color' => ['color' => '#2ea3f2', 'label' => 'Primärfarbe', 'status' => 'active'],
'gcid-body-color' => ['color' => '#666666', 'label' => 'Fließtextfarbe', 'status' => 'active'],
'gcid-a1b2' => ['color' => '#0b3d59', 'label' => 'Nachtblau', 'status' => 'active'],
'gcid-old' => ['color' => '#ff0000', 'label' => 'Alt', 'status' => 'inactive'],
],
[
'numbers' => ['gvid-r1' => ['id' => 'gvid-r1', 'label' => 'Radius', 'value' => '8px', 'status' => 'active']],
'strings' => ['gvid-s1' => ['id' => 'gvid-s1', 'label' => 'Telefon', 'value' => '0221 123', 'status' => 'active']],
// Divi returns every variable type as stdClass (verified in DDEV).
'fonts' => (object) [
'--et_global_heading_font' => ['id' => '--et_global_heading_font', 'label' => 'Überschrift', 'value' => 'Open Sans', 'status' => 'active'],
'--et_global_body_font' => ['id' => '--et_global_body_font', 'label' => 'Fließtext', 'value' => 'Lato', 'status' => 'active'],
],
'links' => new \stdClass(),
],
[
'module' => ['divi/button' => ['default' => 'p-1', 'items' => [
'p-1' => ['id' => 'p-1', 'name' => 'Standard', 'moduleName' => 'divi/button'],
'p-2' => ['id' => 'p-2', 'name' => 'CTA primär', 'moduleName' => 'divi/button'],
]]],
'group' => ['divi/font' => ['default' => '', 'items' => [
'g-1' => ['id' => 'g-1', 'name' => 'Fließtext groß', 'groupName' => 'divi/font'],
]]],
],
['body_font_size' => '16']
);
}
public function testReadsColorsVariablesFontsSizesAndPresets(): void
{
$design = self::sample();
self::assertSame(
['id' => 'gcid-a1b2', 'label' => 'Nachtblau', 'value' => '#0b3d59'],
$design->colors()[2]
);
self::assertCount(3, $design->colors());
self::assertSame(['heading' => 'Open Sans', 'body' => 'Lato'], $design->fonts());
self::assertSame(
[['id' => 'gvid-r1', 'type' => 'numbers', 'label' => 'Radius', 'value' => '8px'],
['id' => 'gvid-s1', 'type' => 'strings', 'label' => 'Telefon', 'value' => '0221 123']],
$design->variables()
);
self::assertSame('16', $design->sizes()['body_font_size']);
self::assertSame('30', $design->sizes()['body_header_size']);
self::assertSame(
['kind' => 'module', 'module' => 'divi/button', 'id' => 'p-1', 'name' => 'Standard', 'default' => true],
$design->presets()[0]
);
self::assertSame(['p-1' => 'Standard', 'p-2' => 'CTA primär', 'g-1' => 'Fließtext groß'], $design->presetNames());
}
public function testLooksUpByAliasLabelOrId(): void
{
$design = self::sample();
self::assertSame('gcid-primary-color', $design->colorId('primary'));
self::assertSame('gcid-a1b2', $design->colorId('nachtblau'));
self::assertSame('gcid-a1b2', $design->colorId('gcid-a1b2'));
self::assertNull($design->colorId('secondary'));
self::assertNull($design->colorId('Alt'));
self::assertSame('gvid-s1', $design->variable('telefon')['id'] ?? null);
self::assertSame('p-2', $design->modulePresetId('divi/button', 'cta PRIMÄR'));
self::assertNull($design->modulePresetId('divi/text', 'Standard'));
self::assertSame(['Standard', 'CTA primär'], $design->modulePresetNames('divi/button'));
self::assertSame('{{color:primary}}', DesignSystem::colorPlaceholder('gcid-primary-color', 'Primärfarbe'));
self::assertSame('{{color:Nachtblau}}', DesignSystem::colorPlaceholder('gcid-a1b2', 'Nachtblau'));
}
public function testToleratesEmptyOrOddInput(): void
{
$design = DesignSystem::fromDivi([], ['fonts' => 'x'], ['module' => 'y'], []);
self::assertSame([], $design->colors());
self::assertSame(['heading' => '', 'body' => ''], $design->fonts());
self::assertSame([], $design->presets());
self::assertSame('14', $design->sizes()['body_font_size']);
}
}
```
`tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php`:
```php
<?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\Design\DiviDesignSystemAbility;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class DiviDesignSystemAbilityTest extends TestCase
{
public function testReturnsDesignSystemWithPlaceholders(): void
{
$reader = Mockery::mock(DesignSystemReader::class);
$reader->allows('read')->andReturn(DesignSystemTest::sample());
Functions\when('current_user_can')->justReturn(true);
$ability = new DiviDesignSystemAbility($reader);
self::assertTrue($ability->permission([]));
$result = $ability->execute([]);
self::assertSame('{{color:primary}}', $result['colors'][0]['placeholder']);
self::assertSame('{{color:Nachtblau}}', $result['colors'][2]['placeholder']);
self::assertSame('{{var:Telefon}}', $result['variables'][1]['placeholder']);
self::assertSame(['heading' => 'Open Sans', 'body' => 'Lato'], $result['fonts']);
self::assertSame('CTA primär', $result['presets'][1]['name']);
self::assertStringContainsString('{{font:heading}}', $result['notes'][0]);
self::assertTrue($ability->isDirect());
self::assertSame(['colors', 'variables', 'fonts', 'sizes', 'presets', 'notes'], $ability->outputSchema()['required']);
}
public function testNeedsEditPosts(): void
{
Functions\when('current_user_can')->justReturn(false);
$result = (new DiviDesignSystemAbility(Mockery::mock(DesignSystemReader::class)))->permission([]);
self::assertSame('lit_forbidden', $result->get_error_code());
}
}
```
In `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` ersetzen:
```php
'lit/divi-catalog',
];
```
durch:
```php
'lit/divi-catalog',
'lit/divi-design-system',
];
```
- [ ] **Step 2: Test laufen lassen (rot)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'DesignSystemTest|DiviDesignSystemAbilityTest|DiviIntegrationTest'`
Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\Design\DesignSystem" not found`.
- [ ] **Step 3: Implementierung**
`src/Integrations/Divi/Design/DesignSystem.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
/**
* The site's Divi design system as read live from Divi (spec §2 principle 2, §5.7): global
* colors, global variables, theme fonts and sizes, module and group presets. Pure PHP –
* fromDivi() takes the raw arrays Divi returns. Placeholders (spec §5.4) resolve against it.
*/
final class DesignSystem
{
/** Aliases of the five customizer colors. */
public const COLOR_ALIASES = [
'primary' => 'gcid-primary-color',
'secondary' => 'gcid-secondary-color',
'heading' => 'gcid-heading-color',
'body' => 'gcid-body-color',
'link' => 'gcid-link-color',
];
/** Theme fonts as Divi stores them among the global font variables. */
public const FONT_VARIABLES = ['heading' => '--et_global_heading_font', 'body' => '--et_global_body_font'];
/** Theme option => Divi's default, read from the et_divi option. */
public const SIZE_OPTIONS = [
'body_font_size' => '14',
'body_header_size' => '30',
'body_font_height' => '1.7',
'body_header_height' => '1',
'content_width' => '1080',
'gutter_width' => '3',
];
/**
* @param list<array{id: string, label: string, value: string}> $colors
* @param list<array{id: string, type: string, label: string, value: string}> $variables
* @param list<array{kind: string, module: string, id: string, name: string, default: bool}> $presets
* @param array{heading: string, body: string} $fonts
* @param array<string, string> $sizes
*/
public function __construct(
private array $colors,
private array $variables,
private array $presets,
private array $fonts,
private array $sizes
) {
}
/**
* @param array<array-key, mixed> $colors GlobalData::get_global_colors()
* @param array<array-key, mixed> $variables GlobalData::get_global_variables()
* @param array<array-key, mixed> $presets GlobalPreset::get_data()
* @param array<array-key, mixed> $options get_option('et_divi')
*/
public static function fromDivi(array $colors, array $variables, array $presets, array $options): self
{
// Divi returns some groups as stdClass (e.g. every variable type) – work on plain arrays.
$colors = (array) self::plain($colors);
$variables = (array) self::plain($variables);
$presets = (array) self::plain($presets);
$colorList = [];
foreach ($colors as $id => $color) {
if (is_array($color) && ($color['status'] ?? 'active') === 'active' && is_string($color['color'] ?? null)) {
$colorList[] = [
'id' => (string) $id,
'label' => is_string($color['label'] ?? null) ? $color['label'] : (string) $id,
'value' => $color['color'],
];
}
}
$variableList = [];
$fonts = ['heading' => '', 'body' => ''];
foreach ($variables as $type => $items) {
foreach (is_array($items) ? $items : [] as $id => $item) {
if (! is_array($item) || ($item['status'] ?? 'active') !== 'active') {
continue;
}
$value = is_scalar($item['value'] ?? null) ? (string) $item['value'] : '';
$font = array_search((string) $id, self::FONT_VARIABLES, true);
if ($font !== false) {
$fonts[$font] = $value;
continue;
}
$variableList[] = [
'id' => (string) $id,
'type' => (string) $type,
'label' => is_string($item['label'] ?? null) ? $item['label'] : (string) $id,
'value' => $value,
];
}
}
$sizes = [];
foreach (self::SIZE_OPTIONS as $option => $default) {
$value = $options[$option] ?? null;
$sizes[$option] = is_scalar($value) && (string) $value !== '' ? (string) $value : $default;
}
return new self($colorList, $variableList, self::presetList($presets), $fonts, $sizes);
}
/** @return list<array{id: string, label: string, value: string}> */
public function colors(): array
{
return $this->colors;
}
/** @return list<array{id: string, type: string, label: string, value: string}> */
public function variables(): array
{
return $this->variables;
}
/** @return list<array{kind: string, module: string, id: string, name: string, default: bool}> */
public function presets(): array
{
return $this->presets;
}
/** @return array{heading: string, body: string} */
public function fonts(): array
{
return $this->fonts;
}
/** @return array<string, string> */
public function sizes(): array
{
return $this->sizes;
}
/** gcid of an alias (primary …), an ID or a label (case-insensitive); null when unknown. */
public function colorId(string $name): ?string
{
$key = mb_strtolower(trim($name));
$alias = self::COLOR_ALIASES[$key] ?? null;
foreach ($this->colors as $color) {
if ($color['id'] === $alias || $color['id'] === trim($name) || mb_strtolower($color['label']) === $key) {
return $color['id'];
}
}
return null;
}
/** @return array{id: string, type: string, label: string, value: string}|null By label or ID. */
public function variable(string $name): ?array
{
$key = mb_strtolower(trim($name));
foreach ($this->variables as $variable) {
if ($variable['id'] === trim($name) || mb_strtolower($variable['label']) === $key) {
return $variable;
}
}
return null;
}
/** Module preset ID by name or ID for a full module name ("divi/button"); null when unknown. */
public function modulePresetId(string $module, string $name): ?string
{
$key = mb_strtolower(trim($name));
foreach ($this->presets as $preset) {
if (
$preset['kind'] === 'module'
&& $preset['module'] === $module
&& ($preset['id'] === trim($name) || mb_strtolower($preset['name']) === $key)
) {
return $preset['id'];
}
}
return null;
}
/** @return list<string> Names of the module presets of $module. */
public function modulePresetNames(string $module): array
{
$names = [];
foreach ($this->presets as $preset) {
if ($preset['kind'] === 'module' && $preset['module'] === $module) {
$names[] = $preset['name'];
}
}
return $names;
}
/** @return array<string, string> Preset ID => name (all presets). */
public function presetNames(): array
{
$names = [];
foreach ($this->presets as $preset) {
$names[$preset['id']] = $preset['name'];
}
return $names;
}
/** Placeholder that stands for a color in new content (spec §5.4). */
public static function colorPlaceholder(string $id, string $label): string
{
$alias = array_search($id, self::COLOR_ALIASES, true);
return '{{color:' . ($alias !== false ? $alias : $label) . '}}';
}
private static function plain(mixed $value): mixed
{
if (is_object($value)) {
$value = get_object_vars($value);
}
return is_array($value) ? array_map([self::class, 'plain'], $value) : $value;
}
/**
* @param array<array-key, mixed> $data GlobalPreset::get_data():
* {module|group: {name: {default, items: {id: item}}}}
* @return list<array{kind: string, module: string, id: string, name: string, default: bool}>
*/
private static function presetList(array $data): array
{
$list = [];
foreach (['module', 'group'] as $kind) {
foreach (is_array($data[$kind] ?? null) ? $data[$kind] : [] as $module => $record) {
$default = is_array($record) && is_string($record['default'] ?? null) ? $record['default'] : '';
$items = is_array($record) && is_array($record['items'] ?? null) ? $record['items'] : [];
foreach ($items as $id => $item) {
$list[] = [
'kind' => $kind,
'module' => (string) $module,
'id' => (string) $id,
'name' => is_array($item) && is_string($item['name'] ?? null) ? $item['name'] : (string) $id,
'default' => (string) $id === $default,
];
}
}
}
return $list;
}
}
```
`src/Integrations/Divi/Design/DesignSystemReader.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use ET\Builder\Packages\GlobalData\GlobalData;
use ET\Builder\Packages\GlobalData\GlobalPreset;
/**
* 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.
*/
class DesignSystemReader
{
private ?DesignSystem $designSystem = null;
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', []);
$this->designSystem = DesignSystem::fromDivi(
is_array($colors) ? $colors : [],
is_array($variables) ? $variables : [],
is_array($presets) ? $presets : [],
is_array($options) ? $options : []
);
}
return $this->designSystem;
}
}
```
`src/Integrations/Divi/Design/DiviDesignSystemAbility.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use WP_Error;
/**
* lit/divi-design-system: global colors, variables, theme fonts/sizes and presets with the
* placeholder to use for each (spec §5.4, §5.7). Read live from Divi; writing follows in
* Etappe 4.
*/
final class DiviDesignSystemAbility implements Ability
{
public function __construct(private DesignSystemReader $reader)
{
}
public function name(): string
{
return 'lit/divi-design-system';
}
public function label(): string
{
return 'Divi-Designsystem';
}
public function description(): string
{
return 'Liest das Designsystem der Website aus Divi: globale Farben, globale Variablen, Theme-Schriften und '
. '-Größen, Modul- und Gruppen-Presets – jeweils mit dem Platzhalter ({{color:…}}, {{var:…}}, '
. '{{font:…}}, preset), den neue Inhalte statt fester Werte verwenden sollen.';
}
public function inputSchema(): array
{
return ['type' => 'object', 'properties' => [], 'additionalProperties' => false];
}
public function outputSchema(): ?array
{
$entry = static fn (array $properties): array => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => $properties,
'required' => array_keys($properties),
],
];
return [
'type' => 'object',
'properties' => [
'colors' => $entry([
'id' => ['type' => 'string'],
'label' => ['type' => 'string'],
'value' => ['type' => 'string'],
'placeholder' => ['type' => 'string'],
]),
'variables' => $entry([
'id' => ['type' => 'string'],
'type' => ['type' => 'string'],
'label' => ['type' => 'string'],
'value' => ['type' => 'string'],
'placeholder' => ['type' => 'string'],
]),
'fonts' => [
'type' => 'object',
'properties' => ['heading' => ['type' => 'string'], 'body' => ['type' => 'string']],
'required' => ['heading', 'body'],
],
'sizes' => ['type' => 'object'],
'presets' => $entry([
'kind' => ['type' => 'string'],
'module' => ['type' => 'string'],
'id' => ['type' => 'string'],
'name' => ['type' => 'string'],
'default' => ['type' => 'boolean'],
]),
'notes' => ['type' => 'array', 'items' => ['type' => 'string']],
],
'required' => ['colors', 'variables', 'fonts', 'sizes', 'presets', 'notes'],
];
}
public function annotations(): array
{
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return true;
}
public function permission(array $input): bool|WP_Error
{
return current_user_can('edit_posts')
? true
: Errors::forbidden('Keine Berechtigung, Inhalte zu bearbeiten.');
}
public function execute(array $input): mixed
{
$design = $this->reader->read();
return [
'colors' => array_map(
static fn (array $color): array => $color + [
'placeholder' => DesignSystem::colorPlaceholder($color['id'], $color['label']),
],
$design->colors()
),
'variables' => array_map(
static fn (array $variable): array => $variable
+ ['placeholder' => '{{var:' . $variable['label'] . '}}'],
$design->variables()
),
'fonts' => $design->fonts(),
'sizes' => $design->sizes(),
'presets' => $design->presets(),
'notes' => [
'Schriften: {{font:heading}} bzw. {{font:body}}; Presets in der Kurzform über "preset": "<Name>".',
'Feste Farben oder Schriften nur verwenden, wenn der Nutzer es ausdrücklich möchte.',
],
];
}
}
```
In `src/Integrations/Divi/DiviIntegration.php` ersetzen:
```php
use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility;
```
durch:
```php
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility;
use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility;
```
In `src/Integrations/Divi/DiviIntegration.php` ersetzen:
```php
private DiviModules $modules;
public function __construct(private ContentSchema $schema)
{
$this->modules = new DiviModules();
}
```
durch:
```php
private DiviModules $modules;
private DesignSystemReader $design;
public function __construct(private ContentSchema $schema)
{
$this->modules = new DiviModules();
$this->design = new DesignSystemReader();
}
```
In `src/Integrations/Divi/DiviIntegration.php` ersetzen:
```php
new DiviCatalogAbility($this->modules),
];
```
durch:
```php
new DiviCatalogAbility($this->modules),
new DiviDesignSystemAbility($this->design),
];
```
In `docs/guides/overview.md` ersetzen:
```markdown
Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`,
`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`,
`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-divi-catalog`.
```
durch:
```markdown
Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`,
`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`,
`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-divi-catalog`, `lit-divi-design-system`.
```
- [ ] **Step 4: Tests und Lint (grün)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test && composer lint`
Expected: PASS, phpcs ohne Meldungen.
- [ ] **Step 5: Commit**
```bash
cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
git add src/Integrations/Divi/Design/ src/Integrations/Divi/DiviIntegration.php docs/guides/overview.md tests/Unit/Integrations/Divi/Design/ tests/Unit/Integrations/Divi/DiviIntegrationTest.php
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: read the Divi design system live – lit/divi-design-system
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
```
---
### Task 6: Seiten laden, `lit/divi-outline` und `lit/divi-node-get`
Spec §5.3, §5.7: Der Seitenbaum mit Pfad-IDs, Admin-Labels, Textauszügen, Layout-Info und Preset-Namen, dazu `tree_rev` (Hash von `post_content`) und `rev` (`Rev::ofPost`, Ruling P16). `DiviPageLoader` prüft Rechte und Status über `PostGuard::editable()` (Lesen darf auch Papierkorb-Einträge, Schreiben nicht), lehnt Divi-4-Inhalt und beschädigtes Markup mit `unsupported` ab (Detail ins PHP-Error-Log) und bietet `writable()` für Etappe 2b (Divi-Builder für den Typ aktiv, Divi-Layout oder leer, kein verpasster Plantermin). `lit-divi-node-get` liefert die Vollform (`AttrMap::flatten()`), die aktuellen Kurzform-Werte, Presets mit Namen und die IDs der Kinder.
**Files:**
- Create: `src/Integrations/Divi/Pages/{DiviPage,DiviPageLoader,DiviOutlineAbility,DiviNodeGetAbility}.php`
- Modify: `src/Integrations/Divi/DiviIntegration.php` (ganze Datei ersetzen), `docs/guides/overview.md`
- Test: `tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` (Liste ersetzen)
**Interfaces:**
- Consumes: `Parser`, `MalformedMarkup`, `NodeIndex`, `Outline`, `AttrMap`, `NodeSummary` (Tasks 2–3); `ShortformMap` (Task 4); `DesignSystemReader`, `DesignSystemTest::sample()` (Task 5); `PostGuard::editable()`, `PostGuard::statusStable()`, `ContentSchema::diviEnabled()`, `Rev::of()`, `Rev::ofPost()`, `Errors`, `JsonMap`.
- Produces:
- `DiviPage`: `readonly WP_Post $post`, `readonly Document $document`, `readonly NodeIndex $index`; `treeRev(): string`; `isDiviLayout(): bool` (leer oder enthält `<!-- wp:divi/`); `editUrl(): string` (Permalink + `et_fb=1`).
- `DiviPageLoader` (nicht final): `OUTLINE_HINT`; `__construct(ContentSchema $schema, Parser $parser = new Parser())`; `load(mixed $postId, bool $forWrite): DiviPage|WP_Error`; `writable(DiviPage $page): ?WP_Error`; `static unknownNode(string $id, WP_Post $post): WP_Error`.
- `DiviOutlineAbility(DiviPageLoader, DesignSystemReader)` – `lit/divi-outline`, direkt, readonly; Input `{post_id, id?, depth?}`; Output `{post_id, title, status, tree_rev, rev, outline, nodes, edit_url, hints}`.
- `DiviNodeGetAbility(DiviPageLoader, DesignSystemReader)` – `lit/divi-node-get`, direkt, readonly; Input `{post_id, id}`; Output `{post_id, tree_rev, id, parent, type, label, editable, attrs, shortform, presets: list<{id, name}>, builder_version, children}`.
- Test-Helfer (von 2b genutzt): `DiviPageLoaderTest::fixture(string): string`, `::givenPost(string $content, array $fields = []): WP_Post` (Post-ID 7, Typ `page`, Titel `Start`), `::schema(bool $diviEnabled = true): ContentSchema`.
- [ ] **Step 1: Failing Tests**
`tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Error;
use WP_Post;
final class DiviPageLoaderTest extends TestCase
{
public static function fixture(string $name): string
{
return (string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/' . $name);
}
/** Stubs everything PostGuard::editable() needs for a post with $content. */
public static function givenPost(string $content, array $fields = []): WP_Post
{
$post = new WP_Post(array_merge(
['ID' => 7, 'post_type' => 'page', 'post_status' => 'draft', 'post_title' => 'Start', 'post_content' => $content],
$fields
));
Functions\when('get_post')->justReturn($post);
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_permalink')->justReturn('https://kunde.test/?page_id=7');
Functions\when('add_query_arg')->alias(static fn (string $key, string $value, string $url): string => $url . '&' . $key . '=' . $value);
return $post;
}
public static function schema(bool $diviEnabled = true): ContentSchema
{
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->andReturn(true);
$schema->allows('diviEnabled')->andReturn($diviEnabled);
return $schema;
}
public function testLoadsTreeIndexAndTreeRev(): void
{
$content = self::fixture('synthetic-basic.html');
self::givenPost($content);
$page = (new DiviPageLoader(self::schema()))->load(7, true);
self::assertInstanceOf(DiviPage::class, $page);
self::assertSame(substr(hash('sha256', $content), 0, 12), $page->treeRev());
self::assertSame('divi/button', $page->index->find('s0.r0.c1.m2')?->name());
self::assertTrue($page->isDiviLayout());
self::assertSame('https://kunde.test/?page_id=7&et_fb=1', $page->editUrl());
self::assertNull((new DiviPageLoader(self::schema()))->writable($page));
}
public function testRefusesDivi4Content(): void
{
self::givenPost(self::fixture('divi4-shortcodes.html'));
$result = (new DiviPageLoader(self::schema()))->load(7, false);
self::assertInstanceOf(WP_Error::class, $result);
self::assertSame('lit_unsupported', $result->get_error_code());
self::assertStringContainsString('Divi-4', $result->get_error_message());
}
public function testRefusesMalformedMarkupAndLogsIt(): void
{
self::givenPost('<!-- wp:divi/section --><!-- wp:divi/row -->');
// 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-log');
$previous = ini_set('error_log', $log);
try {
$result = (new DiviPageLoader(self::schema()))->load(7, false);
} finally {
ini_set('error_log', (string) $previous);
}
self::assertSame('lit_unsupported', $result->get_error_code());
self::assertStringContainsString('kein gültiges Block-Markup', $result->get_error_message());
self::assertStringContainsString('divi parse of post 7 failed', (string) file_get_contents($log));
unlink($log);
}
public function testTrashedEntriesCanBeReadButNotWritten(): void
{
self::givenPost(self::fixture('synthetic-basic.html'), ['post_status' => 'trash']);
$loader = new DiviPageLoader(self::schema());
self::assertInstanceOf(DiviPage::class, $loader->load(7, false));
self::assertSame('lit_validation_failed', $loader->load(7, true)->get_error_code());
}
public function testWritableChecks(): void
{
$loader = new DiviPageLoader(self::schema(false));
self::givenPost('');
$empty = $loader->load(7, true);
self::assertSame('lit_unsupported', $loader->writable($empty)->get_error_code());
$loader = new DiviPageLoader(self::schema());
self::givenPost("<!-- wp:paragraph -->\n<p>Gutenberg</p>\n<!-- /wp:paragraph -->");
$gutenberg = $loader->load(7, true);
self::assertFalse($gutenberg->isDiviLayout());
self::assertStringContainsString('kein Divi-Layout', $loader->writable($gutenberg)->get_error_message());
self::givenPost('', ['post_status' => 'future', 'post_date_gmt' => '2020-01-01 00:00:00']);
$missed = $loader->load(7, true);
self::assertTrue($missed->isDiviLayout());
self::assertSame('lit_validation_failed', $loader->writable($missed)->get_error_code());
}
}
```
`tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Pages\DiviNodeGetAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviOutlineAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use stdClass;
final class DiviReadAbilitiesTest extends TestCase
{
private function design(): DesignSystemReader
{
$reader = Mockery::mock(DesignSystemReader::class);
$reader->allows('read')->andReturn(DesignSystemTest::sample());
return $reader;
}
public function testOutlineListsNodesWithTreeRevAndPostRev(): void
{
$content = DiviPageLoaderTest::fixture('synthetic-basic.html');
DiviPageLoaderTest::givenPost($content);
$ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design());
self::assertTrue($ability->permission(['post_id' => 7]));
$result = $ability->execute(['post_id' => 7]);
self::assertSame(substr(hash('sha256', $content), 0, 12), $result['tree_rev']);
self::assertSame(12, strlen($result['rev']));
self::assertNotSame($result['tree_rev'], $result['rev']);
self::assertCount(16, $result['nodes']);
self::assertStringStartsWith('s0 section "Hero"', $result['outline']);
self::assertSame([], $result['hints']);
self::assertTrue($ability->isDirect());
self::assertTrue($ability->annotations()['readonly']);
}
public function testOutlineOfASubtreeAndUnknownNode(): void
{
DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html'));
$ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design());
$result = $ability->execute(['post_id' => 7, 'id' => 's1', 'depth' => 1]);
self::assertSame(['s1', 's1.r0'], array_column($result['nodes'], 'id'));
$missing = $ability->execute(['post_id' => 7, 'id' => 's7']);
self::assertSame('lit_not_found', $missing->get_error_code());
self::assertStringContainsString('tree_rev', $missing->get_error_message());
}
public function testOutlineHintsForEmptyAndReadOnlyContent(): void
{
DiviPageLoaderTest::givenPost('');
$ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design());
self::assertSame(['Die Seite hat noch keinen Divi-Inhalt.'], $ability->execute(['post_id' => 7])['hints']);
DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-mixed.html'));
$hints = $ability->execute(['post_id' => 7])['hints'];
self::assertStringContainsString('nur lesbar', $hints[0]);
}
public function testNodeGetReturnsFullFormShortformAndPresets(): void
{
DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html'));
$ability = new DiviNodeGetAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design());
$heading = $ability->execute(['post_id' => 7, 'id' => 's0.r0.c1.m0']);
self::assertSame('divi/heading', $heading['type']);
self::assertSame('s0.r0.c1', $heading['parent']);
self::assertSame(
['desktop' => ['value' => ['headingLevel' => 'h1', 'color' => '#1a1a1a']], 'phone' => ['value' => ['size' => '28px']]],
$heading['attrs']['title.decoration.font.font']
);
self::assertSame(['text' => 'Willkommen bei Müller & Söhne', 'level' => 'h1'], $heading['shortform']);
self::assertSame([['id' => 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', 'name' => '']], $heading['presets']);
self::assertSame('5.2.1', $heading['builder_version']);
$accordion = $ability->execute(['post_id' => 7, 'id' => 's1.r0.c1.m0']);
self::assertSame(['s1.r0.c1.m0.m0', 's1.r0.c1.m0.m1'], $accordion['children']);
self::assertInstanceOf(stdClass::class, $accordion['shortform']);
}
public function testNodeGetValidatesTheId(): void
{
DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html'));
$ability = new DiviNodeGetAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design());
self::assertSame('lit_validation_failed', $ability->execute(['post_id' => 7, 'id' => 'hero'])->get_error_code());
self::assertSame('lit_not_found', $ability->execute(['post_id' => 7, 'id' => 's5.r0'])->get_error_code());
}
}
```
In `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` ersetzen:
```php
private const EXPECTED = [
'lit/divi-catalog',
'lit/divi-design-system',
];
```
durch:
```php
private const EXPECTED = [
'lit/divi-outline',
'lit/divi-node-get',
'lit/divi-catalog',
'lit/divi-design-system',
];
```
- [ ] **Step 2: Test laufen lassen (rot)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'DiviPageLoaderTest|DiviReadAbilitiesTest|DiviIntegrationTest'`
Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\Pages\DiviPageLoader" not found`.
- [ ] **Step 3: Implementierung**
`src/Integrations/Divi/Pages/DiviPage.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Integrations\Divi\BlockTree\Document;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use WP_Post;
/**
* An entry loaded for the Divi tools: the post, its parsed tree, the path index and the
* tree_rev (hash of post_content – the optimistic lock of the Divi tools, ruling P16; the
* post-level lock stays `rev` = Rev::ofPost()).
*/
final class DiviPage
{
public function __construct(
public readonly WP_Post $post,
public readonly Document $document,
public readonly NodeIndex $index
) {
}
public function treeRev(): string
{
return Rev::of((string) $this->post->post_content);
}
/** Whether post_content is a Divi 5 layout (or empty, so a first section can be added). */
public function isDiviLayout(): bool
{
$content = (string) $this->post->post_content;
return trim($content) === '' || str_contains($content, '<!-- wp:divi/');
}
public function editUrl(): string
{
return (string) add_query_arg('et_fb', '1', (string) get_permalink($this->post));
}
}
```
`src/Integrations/Divi/Pages/DiviPageLoader.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
/**
* Loads an entry for the Divi tools: rights and status via PostGuard (spec §4.5), then the
* block tree. Divi 4 shortcode content and unbalanced markup are refused with a hint –
* spec §3.4: Divi 4 content is kept, never edited. Not final so abilities can be tested
* with a mock.
*/
class DiviPageLoader
{
public const OUTLINE_HINT = 'Aktuellen Stand mit lit/divi-outline (direkt: lit-divi-outline) neu lesen und die '
. 'Änderung mit der neuen tree_rev wiederholen.';
public function __construct(private ContentSchema $schema, private Parser $parser = new Parser())
{
}
/** Readers pass $forWrite = false (trashed entries may be read, like lit-content-get). */
public function load(mixed $postId, bool $forWrite): DiviPage|WP_Error
{
$post = PostGuard::editable($postId, $this->schema, 'post_id', ! $forWrite);
if ($post instanceof WP_Error) {
return $post;
}
$content = (string) $post->post_content;
if (! str_contains($content, '<!-- wp:divi/') && str_contains($content, '[et_pb_')) {
return Errors::unsupported(
sprintf(
'Eintrag %d enthält Divi-4-Inhalt (Shortcodes); die Divi-Werkzeuge bearbeiten nur Divi 5.',
$post->ID
),
'Die Seite einmal im Divi-Builder öffnen und speichern – Divi 5 wandelt sie dabei um. Bis dahin '
. 'bleibt der Inhalt unverändert.'
);
}
try {
$document = $this->parser->parse($content);
} catch (MalformedMarkup $e) {
$message = sprintf('[wp-lit-mcp] divi parse of post %d failed: %s', $post->ID, $e->getMessage());
error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
return Errors::unsupported(
sprintf(
'Der Inhalt von Eintrag %d ist kein gültiges Block-Markup; nichts wurde gelesen oder geändert.',
$post->ID
),
'Die Seite im Divi-Builder öffnen und speichern (Divi repariert dabei die Struktur) oder den Nutzer '
. 'fragen, ob eine frühere Revision (lit/revisions-list) wiederhergestellt werden soll.'
);
}
return new DiviPage($post, $document, NodeIndex::build($document));
}
/**
* Additional checks for writing: the type must use the Divi builder, the content must be a
* Divi 5 layout (or empty) and saving must not publish a missed schedule (spec §2).
*/
public function writable(DiviPage $page): ?WP_Error
{
$post = $page->post;
if (! $this->schema->diviEnabled((string) $post->post_type)) {
return Errors::unsupported(
sprintf('Für den Inhaltstyp "%s" ist der Divi-Builder nicht aktiviert.', $post->post_type),
'In Divi → Theme-Optionen → Builder den Builder für diesen Inhaltstyp aktivieren (Administrator).'
);
}
if (! $page->isDiviLayout()) {
return Errors::unsupported(
sprintf('Eintrag %d ist kein Divi-Layout (Block-Editor- oder klassischer Inhalt).', $post->ID),
'Den Inhalt mit lit/content-update (direkt: lit-content-update) bearbeiten, oder den Nutzer fragen, ob '
. 'die Seite im Divi-Builder neu aufgebaut werden soll.'
);
}
return PostGuard::statusStable($post);
}
public static function unknownNode(string $id, WP_Post $post): WP_Error
{
return Errors::notFound(
sprintf('Knoten "%s" gibt es in Eintrag %d nicht.', $id, $post->ID),
self::OUTLINE_HINT
);
}
}
```
`src/Integrations/Divi/Pages/DiviOutlineAbility.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Integrations\Divi\BlockTree\Outline;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use WP_Error;
/**
* lit/divi-outline: the page tree with path IDs, types, admin labels, text excerpts and
* preset names plus tree_rev (spec §5.3, §5.7). Every Divi write needs this tree_rev.
*/
final class DiviOutlineAbility implements Ability
{
public function __construct(private DiviPageLoader $loader, private DesignSystemReader $design)
{
}
public function name(): string
{
return 'lit/divi-outline';
}
public function label(): string
{
return 'Divi-Seitenbaum';
}
public function description(): string
{
return 'Liest den Aufbau einer Divi-Seite: Knoten mit Pfad-ID (s0, s0.r1, s0.r1.c0.m2), Modultyp, '
. 'Admin-Label, Textauszug und Preset. Liefert tree_rev – Pflicht für jede Divi-Änderung. '
. 'Optional nur ein Teilbaum (id) oder begrenzte Tiefe (depth).';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'post_id' => ['type' => 'integer', 'minimum' => 1],
'id' => ['type' => 'string', 'description' => 'Nur diesen Teilbaum, z. B. "s1".'],
'depth' => [
'type' => 'integer',
'minimum' => 0,
'maximum' => 10,
'description' => 'Maximale Tiefe unterhalb des Startknotens (0 = nur der Knoten).',
],
],
'required' => ['post_id'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return [
'type' => 'object',
'properties' => [
'post_id' => ['type' => 'integer'],
'title' => ['type' => 'string'],
'status' => ['type' => 'string'],
'tree_rev' => ['type' => 'string'],
'rev' => ['type' => 'string'],
'outline' => ['type' => 'string'],
'nodes' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'string'],
'type' => ['type' => 'string'],
'label' => ['type' => 'string'],
'excerpt' => ['type' => 'string'],
'info' => ['type' => 'string'],
'presets' => ['type' => 'array', 'items' => ['type' => 'string']],
'children' => ['type' => 'integer'],
'editable' => ['type' => 'boolean'],
'depth' => ['type' => 'integer'],
],
'required' => ['id', 'type', 'label', 'excerpt', 'editable', 'depth'],
],
],
'edit_url' => ['type' => 'string'],
'hints' => ['type' => 'array', 'items' => ['type' => 'string']],
],
'required' => ['post_id', 'tree_rev', 'rev', 'outline', 'nodes', 'hints'],
];
}
public function annotations(): array
{
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return true;
}
public function permission(array $input): bool|WP_Error
{
$page = $this->loader->load($input['post_id'] ?? null, false);
return $page instanceof WP_Error ? $page : true;
}
public function execute(array $input): mixed
{
$page = $this->loader->load($input['post_id'] ?? null, false);
if ($page instanceof WP_Error) {
return $page;
}
$rootId = isset($input['id']) && is_string($input['id']) && $input['id'] !== '' ? $input['id'] : null;
if ($rootId !== null && $page->index->find($rootId) === null) {
return DiviPageLoader::unknownNode($rootId, $page->post);
}
$outline = new Outline($this->design->read()->presetNames());
$rows = $outline->rows($page->index, $rootId, isset($input['depth']) ? (int) $input['depth'] : null);
return [
'post_id' => (int) $page->post->ID,
'title' => (string) $page->post->post_title,
'status' => (string) $page->post->post_status,
'tree_rev' => $page->treeRev(),
'rev' => Rev::ofPost($page->post),
'outline' => $outline->text($rows),
'nodes' => $rows,
'edit_url' => $page->editUrl(),
'hints' => $this->hints($page, $rows),
];
}
/**
* @param list<array<string, mixed>> $rows
* @return list<string>
*/
private function hints(DiviPage $page, array $rows): array
{
$hints = [];
if ($rows === []) {
$hints[] = $page->isDiviLayout()
? 'Die Seite hat noch keinen Divi-Inhalt.'
: 'Der Inhalt ist kein Divi-Layout (Block-Editor oder klassisch); lit-content-get zeigt ihn.';
}
if (in_array(false, array_column($rows, 'editable'), true)) {
$hints[] = 'Knoten mit "nur lesbar" (globale Module, fremde Blöcke) ändern die Divi-Werkzeuge nicht; '
. 'ihren Inhalt nur im Divi-Builder bearbeiten.';
}
return $hints;
}
}
```
`src/Integrations/Divi/Pages/DiviNodeGetAbility.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\JsonMap;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
use WP_Error;
/**
* lit/divi-node-get: all attributes of one node in the full form (spec §5.4, §5.7) –
* attribute path => breakpoint map – plus the current short-form values.
*/
final class DiviNodeGetAbility implements Ability
{
public function __construct(private DiviPageLoader $loader, private DesignSystemReader $design)
{
}
public function name(): string
{
return 'lit/divi-node-get';
}
public function label(): string
{
return 'Divi-Knoten lesen';
}
public function description(): string
{
return 'Liest einen Knoten der Divi-Seite vollständig: attrs als Attributpfad → Werte je Breakpoint '
. '(Vollform, z. B. "title.decoration.font.font": {"desktop": {"value": {…}}}), die Kurzform-Werte, '
. 'Presets und die IDs der Kinder. Pfad-IDs aus lit-divi-outline.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'post_id' => ['type' => 'integer', 'minimum' => 1],
'id' => ['type' => 'string', 'description' => 'Pfad-ID aus lit-divi-outline, z. B. "s0.r0.c1.m2".'],
],
'required' => ['post_id', 'id'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return [
'type' => 'object',
'properties' => [
'post_id' => ['type' => 'integer'],
'tree_rev' => ['type' => 'string'],
'id' => ['type' => 'string'],
'parent' => ['type' => ['string', 'null']],
'type' => ['type' => 'string'],
'label' => ['type' => 'string'],
'editable' => ['type' => 'boolean'],
'attrs' => ['type' => 'object'],
'shortform' => ['type' => 'object'],
'presets' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => ['id' => ['type' => 'string'], 'name' => ['type' => 'string']],
'required' => ['id', 'name'],
],
],
'builder_version' => ['type' => ['string', 'null']],
'children' => ['type' => 'array', 'items' => ['type' => 'string']],
],
'required' => ['post_id', 'tree_rev', 'id', 'type', 'editable', 'attrs', 'shortform', 'children'],
];
}
public function annotations(): array
{
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return true;
}
public function permission(array $input): bool|WP_Error
{
$page = $this->loader->load($input['post_id'] ?? null, false);
return $page instanceof WP_Error ? $page : true;
}
public function execute(array $input): mixed
{
$page = $this->loader->load($input['post_id'] ?? null, false);
if ($page instanceof WP_Error) {
return $page;
}
$id = is_string($input['id'] ?? null) ? $input['id'] : '';
if (preg_match(NodeIndex::ID_PATTERN, $id) !== 1) {
return Errors::invalidInput('id ist keine Pfad-ID.', ['id' => 'z. B. "s0.r1.c0.m2"']);
}
$block = $page->index->find($id);
if ($block === null) {
return DiviPageLoader::unknownNode($id, $page->post);
}
$names = $this->design->read()->presetNames();
$editable = $block->isDivi() && ! $block->isOpaque();
$version = $block->attrs()['builderVersion'] ?? null;
return [
'post_id' => (int) $page->post->ID,
'tree_rev' => $page->treeRev(),
'id' => $id,
'parent' => $page->index->parentId($block),
'type' => $block->name(),
'label' => NodeSummary::label($block),
'editable' => $editable,
'attrs' => JsonMap::of(AttrMap::flatten($block->attrs())),
'shortform' => JsonMap::of(ShortformMap::values($block->shortName(), $block->attrs())),
'presets' => array_map(
static fn (string $presetId): array => ['id' => $presetId, 'name' => $names[$presetId] ?? ''],
NodeSummary::presetIds($block)
),
'builder_version' => is_string($version) ? $version : null,
'children' => $editable
? array_values(array_filter(array_map([$page->index, 'idOf'], $block->children())))
: [],
];
}
}
```
`src/Integrations/Divi/DiviIntegration.php` (ganze Datei ersetzen):
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviNodeGetAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviOutlineAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
/**
* Divi 5 pages (spec §5, Etappe 2). Only available when the Divi 5 builder is loaded
* (spec §2 principle 4); Divi 4 is never edited (spec §3.4).
*/
final class DiviIntegration implements Integration
{
private DiviModules $modules;
private DesignSystemReader $design;
public function __construct(private ContentSchema $schema)
{
$this->modules = new DiviModules();
$this->design = new DesignSystemReader();
}
public function id(): string
{
return 'divi';
}
public function isAvailable(): bool
{
return DiviModules::isAvailable();
}
public function abilities(): array
{
$loader = new DiviPageLoader($this->schema);
return [
new DiviOutlineAbility($loader, $this->design),
new DiviNodeGetAbility($loader, $this->design),
new DiviCatalogAbility($this->modules),
new DiviDesignSystemAbility($this->design),
];
}
}
```
In `docs/guides/overview.md` ersetzen:
```markdown
Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`,
`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`,
`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-divi-catalog`, `lit-divi-design-system`.
```
durch:
```markdown
Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`,
`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`,
`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-divi-outline`,
`lit-divi-node-get`, `lit-divi-catalog`, `lit-divi-design-system`.
```
- [ ] **Step 4: Tests und Lint (grün)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test && composer lint`
Expected: PASS, phpcs ohne Meldungen.
- [ ] **Step 5: Commit**
```bash
cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
git add src/Integrations/Divi/Pages/ src/Integrations/Divi/DiviIntegration.php docs/guides/overview.md tests/Unit/Integrations/Divi/Pages/ tests/Unit/Integrations/Divi/DiviIntegrationTest.php
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: lit/divi-outline and lit/divi-node-get with tree_rev
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
```
---
### Task 7: `lit/page-render-summary` – gerenderte Seite als strukturierter Auszug
Spec §5.7/§8.1: Was ein Besucher im Inhaltsbereich sieht – Überschriften, Texte, Links, Bilder mit Alt-Text – plus die offensichtlichen Probleme (Sprünge in der Überschriftenhierarchie, mehrere `h1`, Bilder ohne Alt-Text, Links ohne Ziel). Gerendert wird im Prozess über `apply_filters('the_content', …)` mit gesetztem globalen `$post` (Divi 5 rendert seine Blöcke dort – in DDEV verifiziert, inkl. `{{post:title}}`-Dynamic-Content); ausgegebene Bytes werden mitgenommen, `$post` wird immer zurückgesetzt. Header, Footer und Theme-Builder-Vorlagen gehören nicht dazu (D5). Das Tool ist Teil der WordPress-Integration und funktioniert für jeden verwalteten Eintrag.
**Files:**
- Create: `src/Integrations/WordPress/Render/{RenderSummary,PageRenderer,PageRenderSummaryAbility}.php`
- Modify: `src/Integrations/WordPress/WordPressIntegration.php`, `docs/guides/overview.md`
- Test: `tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php`, `tests/Unit/Integrations/WordPress/Render/PageRendererTest.php`, `tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php` (Liste ergänzen)
**Interfaces:**
- Consumes: `PostGuard::editable()`, `ContentSchema`, `Ability`.
- Produces:
- `RenderSummary` (rein, ext-dom): `static fromHtml(string $html, int $max = 50): array{headings: list<{level, text}>, texts: list<string>, links: list<{text, href}>, images: list<{src, alt: ?string}>, warnings: list<string>}`.
- `PageRenderer` (nicht final): `render(WP_Post $post): string`.
- `PageRenderSummaryAbility(ContentSchema $schema, PageRenderer $renderer)` – `lit/page-render-summary`, direkt, readonly; Input `{post_id}`; Output `{post_id, url, headings, texts, links, images, warnings, notes}`.
- [ ] **Step 1: Failing Tests**
`tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Render;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\WordPress\Render\PageRenderer;
use LIT\MCP\Integrations\WordPress\Render\PageRenderSummaryAbility;
use LIT\MCP\Integrations\WordPress\Render\RenderSummary;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class PageRenderSummaryAbilityTest extends TestCase
{
private const HTML = '<div class="et_pb_section"><h1>Willkommen</h1><h3>Leistungen</h3>'
. '<p>Seit 1987 <a href="/kontakt">in Köln</a>.</p><ul><li>Beratung</li></ul>'
. '<img src="https://k.test/a.jpg" alt="Team"><img src="https://k.test/b.jpg"><a href="#">Mehr</a></div>';
public function testSummarizesHtml(): void
{
$summary = RenderSummary::fromHtml(self::HTML);
self::assertSame([['level' => 1, 'text' => 'Willkommen'], ['level' => 3, 'text' => 'Leistungen']], $summary['headings']);
self::assertSame(['Seit 1987 in Köln.', 'Beratung'], $summary['texts']);
self::assertSame(['text' => 'in Köln', 'href' => '/kontakt'], $summary['links'][0]);
self::assertSame(
[['src' => 'https://k.test/a.jpg', 'alt' => 'Team'], ['src' => 'https://k.test/b.jpg', 'alt' => null]],
$summary['images']
);
self::assertSame(
[
'Sprung in der Überschriftenhierarchie: h1 → h3 ("Leistungen").',
'Bild ohne Alt-Text: https://k.test/b.jpg',
'Link ohne Ziel: "Mehr".',
],
$summary['warnings']
);
self::assertSame([], RenderSummary::fromHtml('')['headings']);
self::assertCount(2, RenderSummary::fromHtml(str_repeat('<p>x</p>', 5), 2)['texts']);
}
public function testRendersTheEntryAndAddsNotes(): void
{
$post = new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_password' => 'geheim']);
Functions\when('get_post')->justReturn($post);
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_permalink')->justReturn('https://k.test/start/');
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->andReturn(true);
$renderer = Mockery::mock(PageRenderer::class);
$renderer->expects('render')->with($post)->andReturn(self::HTML);
$ability = new PageRenderSummaryAbility($schema, $renderer);
self::assertTrue($ability->permission(['post_id' => 7]));
$result = $ability->execute(['post_id' => 7]);
self::assertSame(7, $result['post_id']);
self::assertSame('https://k.test/start/', $result['url']);
self::assertCount(2, $result['headings']);
self::assertCount(2, $result['notes']);
self::assertTrue($ability->isDirect());
self::assertTrue($ability->annotations()['readonly']);
}
}
```
`tests/Unit/Integrations/WordPress/Render/PageRendererTest.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Render;
use Brain\Monkey\Filters;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\WordPress\Render\PageRenderer;
use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException;
use WP_Post;
final class PageRendererTest extends TestCase
{
protected function tearDown(): void
{
unset($GLOBALS['post']);
parent::tearDown();
}
public function testRendersTheContentFilterAndRestoresTheGlobalPost(): void
{
$previous = new WP_Post(['ID' => 1]);
$GLOBALS['post'] = $previous;
$post = new WP_Post(['ID' => 7, 'post_content' => '<!-- wp:divi/section /-->']);
$setup = [];
Functions\when('setup_postdata')->alias(static function (WP_Post $p) use (&$setup): bool {
$setup[] = $p->ID;
return true;
});
Filters\expectApplied('the_content')->once()->with('<!-- wp:divi/section /-->')->andReturnUsing(
static function () use ($post): string {
echo '<p>echoed</p>';
return $GLOBALS['post'] === $post ? '<div class="et_pb_section"></div>' : 'wrong post';
}
);
$html = (new PageRenderer())->render($post);
self::assertSame('<p>echoed</p><div class="et_pb_section"></div>', $html);
self::assertSame($previous, $GLOBALS['post']);
self::assertSame([7, 1], $setup);
}
public function testRestoresEvenWhenRenderingThrows(): void
{
Functions\when('setup_postdata')->justReturn(true);
Functions\expect('wp_reset_postdata')->once();
Filters\expectApplied('the_content')->once()->andReturnUsing(static function (): string {
throw new RuntimeException('module crashed');
});
try {
(new PageRenderer())->render(new WP_Post(['ID' => 7]));
self::fail('exception expected');
} catch (RuntimeException) {
self::assertArrayHasKey('post', $GLOBALS);
self::assertNull($GLOBALS['post']);
}
}
}
```
In `tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php` ersetzen:
```php
'lit/wp-settings-update',
];
```
durch:
```php
'lit/wp-settings-update',
'lit/page-render-summary',
];
```
- [ ] **Step 2: Test laufen lassen (rot)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'PageRenderSummaryAbilityTest|PageRendererTest|WordPressIntegrationTest'`
Expected: FAIL – `Class "LIT\MCP\Integrations\WordPress\Render\RenderSummary" not found`.
- [ ] **Step 3: Implementierung**
`src/Integrations/WordPress/Render/RenderSummary.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Render;
use DOMDocument;
use DOMElement;
use DOMXPath;
/**
* Structured excerpt of rendered HTML (spec §5.7 lit/page-render-summary): headings, text
* blocks, links, images with alt text and the obvious problems. Pure PHP (ext-dom).
*/
final class RenderSummary
{
private const TEXT_LIMIT = 300;
/**
* @return array{headings: list<array{level: int, text: string}>, texts: list<string>,
* links: list<array{text: string, href: string}>, images: list<array{src: string, alt: ?string}>,
* warnings: list<string>}
*/
public static function fromHtml(string $html, int $max = 50): array
{
$dom = new DOMDocument();
$previous = libxml_use_internal_errors(true);
$dom->loadHTML(
'<?xml encoding="UTF-8"><html><body>' . $html . '</body></html>',
LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING
);
libxml_clear_errors();
libxml_use_internal_errors($previous);
$xpath = new DOMXPath($dom);
$headings = [];
foreach (self::elements($xpath, '//h1|//h2|//h3|//h4|//h5|//h6', $max) as $element) {
$headings[] = ['level' => (int) substr($element->nodeName, 1), 'text' => self::text($element)];
}
$texts = [];
foreach (self::elements($xpath, '//p|//li|//blockquote', $max) as $element) {
$text = self::text($element);
if ($text !== '') {
$texts[] = mb_strlen($text) > self::TEXT_LIMIT
? mb_substr($text, 0, self::TEXT_LIMIT - 1) . '…'
: $text;
}
}
$links = [];
foreach (self::elements($xpath, '//a[@href]', $max) as $element) {
$links[] = ['text' => self::text($element), 'href' => $element->getAttribute('href')];
}
$images = [];
foreach (self::elements($xpath, '//img', $max) as $element) {
$images[] = [
'src' => $element->getAttribute('src'),
'alt' => $element->hasAttribute('alt') ? $element->getAttribute('alt') : null,
];
}
return [
'headings' => $headings,
'texts' => $texts,
'links' => $links,
'images' => $images,
'warnings' => self::warnings($headings, $images, $links),
];
}
/**
* @param list<array{level: int, text: string}> $headings
* @param list<array{src: string, alt: ?string}> $images
* @param list<array{text: string, href: string}> $links
* @return list<string>
*/
private static function warnings(array $headings, array $images, array $links): array
{
$warnings = [];
$previous = null;
foreach ($headings as $heading) {
if ($previous !== null && $heading['level'] > $previous + 1) {
$warnings[] = sprintf(
'Sprung in der Überschriftenhierarchie: h%d → h%d ("%s").',
$previous,
$heading['level'],
$heading['text']
);
}
$previous = $heading['level'];
}
$h1 = count(array_filter($headings, static fn (array $heading): bool => $heading['level'] === 1));
if ($h1 > 1) {
$warnings[] = sprintf('%d Überschriften der Ebene h1 im Inhalt.', $h1);
}
foreach ($images as $image) {
if ($image['alt'] === null || trim($image['alt']) === '') {
$warnings[] = 'Bild ohne Alt-Text: ' . $image['src'];
}
}
foreach ($links as $link) {
if ($link['href'] === '' || $link['href'] === '#') {
$warnings[] = sprintf('Link ohne Ziel: "%s".', $link['text']);
}
}
return $warnings;
}
/** @return list<DOMElement> */
private static function elements(DOMXPath $xpath, string $query, int $max): array
{
$nodes = $xpath->query($query);
$elements = [];
foreach ($nodes === false ? [] : $nodes as $node) {
if ($node instanceof DOMElement && count($elements) < $max) {
$elements[] = $node;
}
}
return $elements;
}
private static function text(DOMElement $element): string
{
return trim((string) preg_replace('/\s+/u', ' ', $element->textContent));
}
}
```
`src/Integrations/WordPress/Render/PageRenderer.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Render;
use WP_Post;
/**
* Renders the content area of an entry in-process, like the front end does for the_content
* (Divi 5 renders its blocks there – verified in DDEV). Header, footer and Theme Builder
* layouts are not part of it. Not final so abilities can be unit-tested with a mock.
*/
class PageRenderer
{
public function render(WP_Post $post): string
{
$previous = $GLOBALS['post'] ?? null;
$GLOBALS['post'] = $post;
setup_postdata($post);
ob_start();
try {
$html = (string) apply_filters('the_content', (string) $post->post_content);
} finally {
$echoed = (string) ob_get_clean();
$GLOBALS['post'] = $previous;
if ($previous instanceof WP_Post) {
setup_postdata($previous);
} else {
wp_reset_postdata();
}
}
return $echoed . $html;
}
}
```
`src/Integrations/WordPress/Render/PageRenderSummaryAbility.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Render;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
/**
* lit/page-render-summary: what a visitor sees in the content area – headings, texts, links,
* images with alt text and obvious problems (spec §5.7, §8.1). Works for every managed entry,
* Divi or not; the content is rendered in-process, header/footer/Theme Builder are not included.
*/
final class PageRenderSummaryAbility implements Ability
{
public function __construct(private ContentSchema $schema, private PageRenderer $renderer)
{
}
public function name(): string
{
return 'lit/page-render-summary';
}
public function label(): string
{
return 'Gerenderte Seite (Auszug)';
}
public function description(): string
{
return 'Rendert den Inhaltsbereich eines Eintrags wie im Frontend und liefert einen strukturierten Auszug: '
. 'Überschriften, Texte, Links, Bilder mit Alt-Text und Warnungen (Hierarchie-Sprünge, fehlende '
. 'Alt-Texte). Header, Footer und Theme-Builder-Vorlagen sind nicht enthalten. Gut zum Prüfen nach '
. 'Änderungen.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => ['post_id' => ['type' => 'integer', 'minimum' => 1]],
'required' => ['post_id'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return [
'type' => 'object',
'properties' => [
'post_id' => ['type' => 'integer'],
'url' => ['type' => 'string'],
'headings' => ['type' => 'array', 'items' => ['type' => 'object']],
'texts' => ['type' => 'array', 'items' => ['type' => 'string']],
'links' => ['type' => 'array', 'items' => ['type' => 'object']],
'images' => ['type' => 'array', 'items' => ['type' => 'object']],
'warnings' => ['type' => 'array', 'items' => ['type' => 'string']],
'notes' => ['type' => 'array', 'items' => ['type' => 'string']],
],
'required' => ['post_id', 'headings', 'texts', 'links', 'images', 'warnings', 'notes'],
];
}
public function annotations(): array
{
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return true;
}
public function permission(array $input): bool|WP_Error
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema, 'post_id', true);
return $post instanceof WP_Error ? $post : true;
}
public function execute(array $input): mixed
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema, 'post_id', true);
if ($post instanceof WP_Error) {
return $post;
}
$notes = ['Nur der Inhaltsbereich; Header, Footer und Theme-Builder-Vorlagen fehlen.'];
if ((string) $post->post_password !== '') {
$notes[] = 'Der Eintrag ist passwortgeschützt; Besucher sehen zuerst das Passwortformular.';
}
return array_merge(
['post_id' => (int) $post->ID, 'url' => (string) get_permalink($post)],
RenderSummary::fromHtml($this->renderer->render($post)),
['notes' => $notes]
);
}
}
```
In `src/Integrations/WordPress/WordPressIntegration.php` ersetzen:
```php
use LIT\MCP\Integrations\WordPress\Menus\MenuWriter;
```
durch:
```php
use LIT\MCP\Integrations\WordPress\Menus\MenuWriter;
use LIT\MCP\Integrations\WordPress\Render\PageRenderer;
use LIT\MCP\Integrations\WordPress\Render\PageRenderSummaryAbility;
```
In `src/Integrations/WordPress/WordPressIntegration.php` ersetzen:
```php
* WordPress basics (spec §6): content, status, copies, revisions, menus, settings.
```
durch:
```php
* WordPress basics (spec §6): content, status, copies, revisions, menus, settings, and the
* rendered-content summary lit/page-render-summary (spec §5.7 – useful for every entry).
```
In `src/Integrations/WordPress/WordPressIntegration.php` ersetzen:
```php
new WpSettingsUpdateAbility($whitelist, $this->log),
];
```
durch:
```php
new WpSettingsUpdateAbility($whitelist, $this->log),
new PageRenderSummaryAbility($this->schema, new PageRenderer()),
];
```
In `docs/guides/overview.md` ersetzen:
```markdown
Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`,
`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`,
`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-divi-outline`,
`lit-divi-node-get`, `lit-divi-catalog`, `lit-divi-design-system`.
```
durch:
```markdown
Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`,
`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`,
`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-page-render-summary`,
`lit-divi-outline`, `lit-divi-node-get`, `lit-divi-catalog`, `lit-divi-design-system`.
```
- [ ] **Step 4: Tests und Lint (grün)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test && composer lint`
Expected: PASS, phpcs ohne Meldungen.
- [ ] **Step 5: Commit**
```bash
cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
git add src/Integrations/WordPress/Render/ src/Integrations/WordPress/WordPressIntegration.php docs/guides/overview.md tests/Unit/Integrations/WordPress/Render/ tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: lit/page-render-summary – rendered content as structured excerpt
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
```
---
### Task 8: Hinweise, Guides, Serveranweisungen, Ende-zu-Ende-Test und Abnahme
Die bisherigen Divi-Hinweise („Divi-Werkzeuge folgen in einer späteren Version…“) zeigen jetzt auf die Lese-Tools – aber nur, wenn die Divi-Integration verfügbar ist; ohne Divi 5 sagt der Hinweis das ehrlich (Review Focus 4). `PostPresenter::DIVI_HINT` wird zur Methode `diviHint()`, weil der Text von der Verfügbarkeit abhängt. Die Serveranweisungen bekommen eine Divi-Regel (Spec §9.1), wenn `divi` aktiv ist. Drei Guides (Spec §9.2: `divi-structure`, `divi-attrs`, `divi-design-system`; `divi-shortform` und `divi-placeholders` folgen mit den Schreib-Tools in 2b). Ein Ende-zu-Ende-Skript prüft alle Lese-Tools über den OAuth-Endpunkt gegen echtes Divi.
**Files:**
- Modify: `src/Integrations/WordPress/Content/PostPresenter.php`, `src/Integrations/WordPress/Content/ContentUpdateAbility.php`, `src/Integrations/WordPress/Revisions/RevisionsListAbility.php`, `src/Guides/InstructionsBuilder.php`, `docs/guides/overview.md`, `docs/guides/content-types.md`
- Create: `docs/guides/divi-structure.md`, `docs/guides/divi-attrs.md`, `docs/guides/divi-design-system.md`, `tests/Integration/divi-read-smoke.sh`
- Test: `tests/Unit/Integrations/WordPress/Content/PostPresenterTest.php`, `tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php`, `tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php`, `tests/Unit/Guides/InstructionsBuilderTest.php`
**Interfaces:**
- Consumes: `DiviModules::isAvailable()` (Task 4); die Lese-Tools (Tasks 4–7); `tests/Integration/lib.sh` (`connect`, `rpc`, `tool`, `error_code`, `fail`, `pass`).
- Produces:
- `PostPresenter::DIVI_NOTE` (Text geändert), `PostPresenter::DIVI_INACTIVE_NOTE`, `static diviNote(?bool $toolsAvailable = null): string`, `static diviHint(?bool $toolsAvailable = null): string`; **entfernt:** `PostPresenter::DIVI_HINT` (einzige Nutzer: `PostPresenter::full()`, `RevisionsListAbility`, deren Tests).
- Serveranweisungen mit Zeile `- Divi-Seiten: vor Änderungen lit-divi-outline und lit-divi-design-system lesen; …`, wenn eine verfügbare Integration die ID `divi` hat.
- [ ] **Step 1: Failing Tests**
In `tests/Unit/Integrations/WordPress/Content/PostPresenterTest.php` (in `testDiviContentIsWithheldAndPointsToTheDiviOutline`) ersetzen:
```php
self::assertStringNotContainsString('lit-divi', $full['hints'][0]);
self::assertStringContainsString('Divi-Werkzeuge folgen in einer späteren Version', $full['hints'][0]);
```
durch:
```php
self::assertSame([PostPresenter::diviHint(false)], $full['hints']);
```
In `tests/Unit/Integrations/WordPress/Content/PostPresenterTest.php` (neuer Test davor) ersetzen:
```php
public function testPasswordAndProtectedReadonlyMetaFieldsAreOmitted(): void
```
durch:
```php
public function testDiviHintNamesTheDiviToolsOnlyWhenDiviIsActive(): void
{
self::assertStringContainsString('lit-divi-outline', PostPresenter::diviHint(true));
self::assertStringContainsString('lit-content-update', PostPresenter::diviHint(true));
self::assertStringNotContainsString('lit-divi', PostPresenter::diviHint(false));
self::assertStringContainsString(PostPresenter::DIVI_INACTIVE_NOTE, PostPresenter::diviHint());
}
public function testPasswordAndProtectedReadonlyMetaFieldsAreOmitted(): void
```
In `tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php` (in `testRawContentOnDiviPagesIsUnsupported`) ersetzen:
```php
self::assertStringNotContainsString('lit-divi', $result->get_error_message());
self::assertStringContainsString('Divi-Werkzeuge folgen in einer späteren Version; bis dahin im Divi-Builder '
. 'bearbeiten.', $result->get_error_data()['hint']);
```
durch:
```php
self::assertStringContainsString(PostPresenter::DIVI_INACTIVE_NOTE, $result->get_error_data()['hint']);
```
In `tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php` (Import) ersetzen:
```php
use LIT\MCP\Integrations\WordPress\Content\ContentUpdateAbility;
```
durch:
```php
use LIT\MCP\Integrations\WordPress\Content\ContentUpdateAbility;
use LIT\MCP\Integrations\WordPress\Content\PostPresenter;
```
In `tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php` ersetzen:
```php
self::assertSame([PostPresenter::DIVI_HINT], $result['hints']);
```
durch:
```php
self::assertSame([PostPresenter::diviHint()], $result['hints']);
```
In `tests/Unit/Guides/InstructionsBuilderTest.php` (neuer Test davor) ersetzen:
```php
public function testListsPostTypesWhenAProviderIsGiven(): void
```
durch:
```php
public function testAddsTheDiviRuleOnlyWhenDiviIsActive(): void
{
$divi = Mockery::mock(Integration::class);
$divi->allows('id')->andReturn('divi');
$divi->allows('isAvailable')->andReturn(true);
$divi->allows('abilities')->andReturn([$this->ability('lit/divi-outline', true)]);
$core = $this->coreWith($this->ability('lit/guide', true));
$with = (new InstructionsBuilder(new IntegrationRegistry($core, $divi)))->build();
$without = (new InstructionsBuilder(new IntegrationRegistry($core)))->build();
self::assertStringContainsString('lit-divi-outline und lit-divi-design-system lesen', $with);
self::assertStringNotContainsString('lit-divi-outline und', $without);
self::assertLessThan(2000, strlen($with));
}
public function testListsPostTypesWhenAProviderIsGiven(): void
```
- [ ] **Step 2: Test laufen lassen (rot)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'PostPresenterTest|ContentUpdateAbilityTest|RevisionsListAbilityTest|InstructionsBuilderTest'`
Expected: FAIL – `Call to undefined method …PostPresenter::diviHint()` bzw. fehlende Divi-Regel.
- [ ] **Step 3: Implementierung – Hinweise**
In `src/Integrations/WordPress/Content/PostPresenter.php` ersetzen:
```php
use LIT\MCP\Abilities\Rev;
```
durch:
```php
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
```
In `src/Integrations/WordPress/Content/PostPresenter.php` ersetzen:
```php
public const DIVI_NOTE = 'Divi-Werkzeuge folgen in einer späteren Version; bis dahin im Divi-Builder bearbeiten.';
public const DIVI_HINT = 'Dieser Inhalt ist mit Divi gebaut; content wird deshalb nicht ausgegeben. '
. self::DIVI_NOTE . ' Titel, Auszug, Felder und Begriffe sind über lit/content-update '
. '(direkt: lit-content-update) änderbar.';
```
durch:
```php
/** How Divi content is read and changed with the Divi tools. */
public const DIVI_NOTE = 'Den Aufbau mit lit/divi-outline (direkt: lit-divi-outline) lesen, einzelne Knoten mit '
. 'lit/divi-node-get (direkt: lit-divi-node-get); ändern vorerst im Divi-Builder.';
/** Divi content on a site where the Divi 5 builder is not loaded (no Divi tools registered). */
public const DIVI_INACTIVE_NOTE = 'Der Divi-5-Builder ist auf dieser Website nicht aktiv, daher gibt es keine '
. 'Divi-Werkzeuge; Divi-Inhalte nur im Divi-Builder bearbeiten.';
/** $toolsAvailable defaults to whether the Divi integration is available (Divi 5 loaded). */
public static function diviNote(?bool $toolsAvailable = null): string
{
return ($toolsAvailable ?? DiviModules::isAvailable()) ? self::DIVI_NOTE : self::DIVI_INACTIVE_NOTE;
}
public static function diviHint(?bool $toolsAvailable = null): string
{
return 'Dieser Inhalt ist mit Divi gebaut; content wird deshalb nicht ausgegeben. '
. self::diviNote($toolsAvailable) . ' Titel, Auszug, Felder und Begriffe sind über lit/content-update '
. '(direkt: lit-content-update) änderbar.';
}
```
In `src/Integrations/WordPress/Content/PostPresenter.php` ersetzen:
```php
'hints' => $divi ? [self::DIVI_HINT] : [],
```
durch:
```php
'hints' => $divi ? [self::diviHint()] : [],
```
In `src/Integrations/WordPress/Content/ContentUpdateAbility.php` ersetzen:
```php
PostPresenter::DIVI_NOTE . ' Titel, Auszug, Felder und Begriffe hier ohne content ändern.'
```
durch:
```php
PostPresenter::diviNote() . ' Titel, Auszug, Felder und Begriffe hier ohne content ändern.'
```
In `src/Integrations/WordPress/Revisions/RevisionsListAbility.php` ersetzen:
```php
* PostPresenter::DIVI_HINT once when any item is flagged.
```
durch:
```php
* PostPresenter::diviHint() once when any item is flagged.
```
In `src/Integrations/WordPress/Revisions/RevisionsListAbility.php` ersetzen:
```php
'hints' => in_array(true, array_column($items, 'divi'), true) ? [PostPresenter::DIVI_HINT] : [],
```
durch:
```php
'hints' => in_array(true, array_column($items, 'divi'), true) ? [PostPresenter::diviHint()] : [],
```
In `src/Guides/InstructionsBuilder.php` ersetzen:
```php
return implode("\n", array_merge($lines, [
'Regeln:',
```
durch:
```php
$divi = str_contains(', ' . $active . ',', ', divi,')
? ['- Divi-Seiten: vor Änderungen lit-divi-outline und lit-divi-design-system lesen; Aufbau: lit-guide '
. 'topic "divi-structure".']
: [];
return implode("\n", array_merge($lines, [
'Regeln:',
```
In `src/Guides/InstructionsBuilder.php` ersetzen:
```php
'- Fehler enthalten einen hint – ihm folgen, statt blind zu wiederholen.',
]));
```
durch:
```php
'- Fehler enthalten einen hint – ihm folgen, statt blind zu wiederholen.',
], $divi));
```
- [ ] **Step 4: Implementierung – Guides**
`docs/guides/divi-structure.md`:
```markdown
# Divi-Seiten: Aufbau lesen
Divi 5 speichert eine Seite als Block-Markup: Abschnitte (`section`) enthalten Zeilen (`row`), Zeilen
Spalten (`column`), Spalten Module (`text`, `heading`, `button`, …). Manche Module haben Kind-Module
(`accordion` → `accordion-item`). Die Divi-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist.
## Seitenbaum
- `lit-divi-outline` mit `post_id` liefert jeden Knoten mit **Pfad-ID**, Modultyp, Admin-Label, Textauszug,
Layout-Info (z. B. `Spalten 8_24+16_24`) und Preset-Namen – als kompakten Text (`outline`) und als Liste
(`nodes`). Mit `id` nur ein Teilbaum, mit `depth` begrenzte Tiefe.
- Pfad-IDs: `s0` = erster Abschnitt, `s0.r1` = zweite Zeile darin, `s0.r1.c0` = erste Spalte,
`s0.r1.c0.m2` = drittes Modul, `…m2.m0` = erstes Kind-Modul. `g` = globales Modul, `x` = fremder Block
(z. B. aus dem Block-Editor). Gezählt wird je Buchstabe innerhalb des Elternknotens.
- `lit-divi-node-get` mit `post_id` und `id` liefert alle Einstellungen eines Knotens (siehe `divi-attrs`),
die aktuellen Kurzform-Werte (`shortform`) und die IDs der Kinder.
## Stand und Sperre
- `tree_rev` ist der Stand des Seiteninhalts. Pfad-IDs gelten nur für diesen Stand – ändert jemand die Seite
(auch im Divi-Builder), verschieben sich die IDs. Divi-Änderungen verlangen deshalb immer die `tree_rev`
des letzten Lesevorgangs.
- `rev` ist davon getrennt: der Stand des ganzen Eintrags (Titel, Auszug, Status …) für `lit-content-update`.
## Was nicht bearbeitet wird
- Knoten mit „nur lesbar“ (globale Module `g…`, fremde Blöcke `x…`) ändern die Divi-Werkzeuge nicht.
- Divi-4-Inhalt (Shortcodes `[et_pb_…]`) wird nicht gelesen oder geändert: Seite einmal im Divi-Builder
öffnen und speichern, Divi 5 wandelt sie dabei um.
- Ist das Markup beschädigt, lehnen die Werkzeuge ab (`unsupported`); die Seite im Divi-Builder öffnen und
speichern.
## Ergebnis prüfen
`lit-page-render-summary` rendert den Inhaltsbereich wie im Frontend und listet Überschriften, Texte, Links
und Bilder samt Warnungen (Sprünge in der Überschriftenhierarchie, fehlende Alt-Texte, Links ohne Ziel).
Header, Footer und Theme-Builder-Vorlagen sind nicht enthalten.
```
`docs/guides/divi-attrs.md`:
````markdown
# Divi-Einstellungen (Vollform)
Jedes Divi-5-Modul speichert seine Einstellungen unter **Attributpfaden**, z. B.
- `title.innerContent` – Text der Überschrift,
- `title.decoration.font.font` – Schrift der Überschrift (Farbe, Größe, Ebene `headingLevel`),
- `button.innerContent` – Beschriftung und Link eines Buttons (`text`, `linkUrl`, `linkTarget`),
- `module.decoration.background` – Hintergrund, `module.decoration.spacing` – Innen-/Außenabstand,
- `module.meta.adminLabel` – Admin-Label im Divi-Builder.
Der Wert eines Pfads ist eine Zuordnung **Breakpoint → Zustand → Wert**:
```json
{ "desktop": { "value": { "color": "#1a1a1a", "headingLevel": "h2" }, "hover": { "color": "#000000" } },
"phone": { "value": { "size": "28px" } } }
```
Breakpoints: `desktop`, `tablet`, `phone` (je nach Website auch `tabletWide`, `phoneWide`, `widescreen`,
`ultraWide`); Zustände: `value`, `hover`, `sticky`.
## Pfade finden
- `lit-divi-catalog` mit `module` (z. B. `"heading"`) listet alle gültigen Pfade dieses Moduls auf dieser
Website (live aus Divi) und die Kurzform-Felder mit ihrem Pfad.
- `lit-divi-node-get` zeigt die gesetzten Werte eines vorhandenen Knotens in genau dieser Form (`attrs`).
- Schrift-Gruppen liegen eine Ebene tiefer: `…decoration.font.font`, `…decoration.bodyFont.body.font`,
`…decoration.headingFont.h2.font`, `module.advanced.text.text`.
- Werte wie `$variable({…})$` sind Verweise auf globale Farben, Variablen oder dynamische Inhalte – nicht
durch feste Werte ersetzen (siehe `divi-design-system`).
````
`docs/guides/divi-design-system.md`:
```markdown
# Divi-Designsystem
`lit-divi-design-system` liest das Designsystem der Website live aus Divi:
- **Globale Farben** (`colors`): ID (`gcid-…`), Name, Wert und der Platzhalter für neue Inhalte, z. B.
`{{color:primary}}` für die Primärfarbe oder `{{color:Nachtblau}}` für eine eigene globale Farbe. Die fünf
Theme-Farben heißen `primary`, `secondary`, `heading`, `body` und `link`.
- **Globale Variablen** (`variables`): Zahlen, Texte, Bilder, Links (`gvid-…`), Platzhalter `{{var:<Name>}}`.
- **Theme-Schriften** (`fonts`): Überschrift und Fließtext, Platzhalter `{{font:heading}}` / `{{font:body}}`.
- **Größen** (`sizes`): Schriftgrößen, Inhaltsbreite und Spaltenabstand aus den Theme-Optionen.
- **Presets** (`presets`): Modul-Presets je Modul (`kind: module`, z. B. alle Button-Presets) und
Gruppen-Presets (`kind: group`); `default: true` markiert das Standard-Preset eines Moduls.
Regeln:
1. Vor dem Gestalten einer Seite das Designsystem lesen.
2. Globale Farben, Variablen und Presets vor festen Werten verwenden; feste Farben oder Schriften nur, wenn
der Nutzer es ausdrücklich möchte.
3. Gibt es für einen Zweck noch keine Farbe, Variable oder kein Preset, dem Nutzer vorschlagen, es im
Divi-Builder anzulegen, statt feste Werte zu verteilen.
```
In `docs/guides/overview.md` ersetzen:
```markdown
Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows` oder `media`.
```
durch:
```markdown
Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist.
Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows`, `media`, `divi-structure`,
`divi-attrs` oder `divi-design-system`.
```
In `docs/guides/content-types.md` ersetzen:
```markdown
Bei Divi-Seiten fehlt `content`; Divi-Werkzeuge folgen in einer späteren Version, bis dahin werden
Divi-Inhalte im Divi-Builder bearbeitet (Titel, Auszug, Felder und Begriffe gehen weiter über
`lit-content-update`).
```
durch:
```markdown
Bei Divi-Seiten fehlt `content`; den Aufbau liest `lit-divi-outline` (siehe `divi-structure`), geändert
wird er vorerst im Divi-Builder (Titel, Auszug, Felder und Begriffe gehen weiter über
`lit-content-update`).
```
In `docs/guides/content-types.md` ersetzen:
```markdown
2. Kopie mit `lit-content-update` (Divi-Inhalte bis zu den Divi-Werkzeugen im Divi-Builder) bearbeiten und dem Nutzer die Vorschau zeigen.
```
durch:
```markdown
2. Kopie mit `lit-content-update` (Divi-Inhalte vorerst im Divi-Builder) bearbeiten und dem Nutzer die
Vorschau zeigen; `lit-page-render-summary` fasst den gerenderten Inhalt zusammen.
```
- [ ] **Step 5: Tests und Lint (grün)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test && composer lint`
Expected: alle Tests PASS (inkl. `ToolNamingTest`), phpcs ohne Meldungen.
- [ ] **Step 6: Ende-zu-Ende-Test anlegen und ausführen**
`tests/Integration/divi-read-smoke.sh`:
```bash
#!/usr/bin/env bash
# End-to-end checks of the Etappe-2a Divi read tools through the OAuth-protected endpoint.
# Runs INSIDE the DDEV web container (Divi 5 must be the active theme):
# cd /home/janniclas/Projekte/WordPress && timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-read-smoke.sh
# Creates draft pages from tests/Fixtures/divi and deletes them 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)
FIXTURES=$PLUGIN/tests/Fixtures/divi
CREATED_POSTS=()
cleanup() {
for id in "${CREATED_POSTS[@]}"; do
wp post delete "$id" --force >/dev/null 2>&1 || true
done
cleanup_app_passwords_for "$ADMIN_LOGIN"
}
trap cleanup EXIT
# Creates a draft page from a fixture file as the admin (no kses re-serialization) and prints its ID.
create_page() { # $1 = fixture file, $2 = title
LIT_FILE="$FIXTURES/$1" LIT_TITLE="$2" wp --user="$ADMIN_LOGIN" eval '
$id = wp_insert_post(["post_type" => "page", "post_status" => "draft", "post_title" => getenv("LIT_TITLE"),
"post_content" => wp_slash((string) file_get_contents(getenv("LIT_FILE")))]);
update_post_meta($id, "_et_pb_use_builder", "on");
update_post_meta($id, "_et_pb_use_divi_5", "on");
echo $id;'
}
content_hash() { # $1 = post ID -> tree_rev of the stored content
wp eval "echo substr(hash('sha256', (string) get_post($1)->post_content), 0, 12);"
}
[ "$(wp eval 'echo get_template();')" = "Divi" ] || fail "Divi must be the active theme"
wp eval-file "$PLUGIN/tests/Integration/divi-shortform-check.php" >/dev/null || fail "short form does not match the Divi catalog"
pass "short form matches the live Divi catalog"
PAGE_ID=$(create_page converted-landing.html "LIT Divi Lesen")
CREATED_POSTS+=("$PAGE_ID")
D4_ID=$(create_page divi4-shortcodes.html "LIT Divi 4")
CREATED_POSTS+=("$D4_ID")
BEFORE=$(content_hash "$PAGE_ID")
connect "$ADMIN_LOGIN"
# 1. Direct read tools with readOnlyHint
LIST=$(rpc tools/list '{}')
for t in lit-divi-outline lit-divi-node-get lit-divi-catalog lit-divi-design-system lit-page-render-summary; do
echo "$LIST" | jq -e --arg t "$t" '.result.tools[] | select(.name == $t) | .annotations.readOnlyHint == true' >/dev/null \
|| fail "tools/list lacks $t with readOnlyHint"
done
pass "tools/list: Divi read tools are direct and read-only"
# 2. Outline with tree_rev = hash of post_content (ruling P16) and path IDs
OUTLINE=$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}")
[ "$(echo "$OUTLINE" | jq -r '.tree_rev')" = "$BEFORE" ] || fail "tree_rev is not the hash of post_content: $OUTLINE"
echo "$OUTLINE" | jq -e '.rev != .tree_rev' >/dev/null || fail "rev and tree_rev must differ: $OUTLINE"
echo "$OUTLINE" | jq -e '.nodes[] | select(.id == "s1.r1.c0.m2.m0" and .type == "accordion-item")' >/dev/null \
|| fail "outline lacks the accordion item: $OUTLINE"
echo "$OUTLINE" | jq -r '.outline' | head -1 | grep -q '^s0 section "Hero"' || fail "outline text: $OUTLINE"
pass "lit-divi-outline: path IDs, tree_rev, rev"
# 3. Node in full form plus short-form values
NODE=$(tool lit-divi-node-get "{\"post_id\":$PAGE_ID,\"id\":\"s0.r0.c0.m0\"}")
echo "$NODE" | jq -e '.type == "divi/heading" and .shortform.text == "Handwerk mit Herz"' >/dev/null || fail "node-get: $NODE"
echo "$NODE" | jq -e '.attrs["title.innerContent"].desktop.value == "Handwerk mit Herz"' >/dev/null || fail "node-get attrs: $NODE"
MISSING=$(tool lit-divi-node-get "{\"post_id\":$PAGE_ID,\"id\":\"s9\"}")
[ "$(error_code "$MISSING")" = "not_found" ] || fail "unknown node: $MISSING"
pass "lit-divi-node-get: full form, short form, unknown node"
# 4. Catalog and design system live from Divi
CATALOG=$(tool lit-divi-catalog '{}')
echo "$CATALOG" | jq -e '(.modules | length) >= 90' >/dev/null || fail "catalog too small: $CATALOG"
BUTTON=$(tool lit-divi-catalog '{"module":"button"}')
echo "$BUTTON" | jq -e '.module.paths | index("button.innerContent") != null' >/dev/null || fail "catalog button: $BUTTON"
DESIGN=$(tool lit-divi-design-system '{}')
echo "$DESIGN" | jq -e '.colors[] | select(.id == "gcid-primary-color" and .placeholder == "{{color:primary}}")' >/dev/null \
|| fail "design system: $DESIGN"
pass "lit-divi-catalog and lit-divi-design-system read live data"
# 5. Rendered summary
SUMMARY=$(tool lit-page-render-summary "{\"post_id\":$PAGE_ID}")
echo "$SUMMARY" | jq -e '.headings[0].text == "Handwerk mit Herz"' >/dev/null || fail "render summary: $SUMMARY"
echo "$SUMMARY" | jq -e '[.texts[] | select(contains("Seit 1987"))] | length == 1' >/dev/null || fail "render texts: $SUMMARY"
pass "lit-page-render-summary renders Divi modules"
# 6. Hints and refusals
GET=$(tool lit-content-get "{\"post_id\":$PAGE_ID}")
echo "$GET" | jq -e '.content == null and (.hints[0] | contains("lit-divi-outline"))' >/dev/null || fail "content-get hint: $GET"
D4=$(tool lit-divi-outline "{\"post_id\":$D4_ID}")
[ "$(error_code "$D4")" = "unsupported" ] || fail "Divi 4 page must be unsupported: $D4"
pass "content-get points to the Divi tools; Divi 4 content is refused"
# 7. Reading changed nothing
[ "$(content_hash "$PAGE_ID")" = "$BEFORE" ] || fail "reading changed post_content"
pass "post_content unchanged after reading"
echo "ALL CHECKS PASSED"
```
Run:
```bash
cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && chmod +x tests/Integration/divi-read-smoke.sh
cd /home/janniclas/Projekte/WordPress
timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-read-smoke.sh
timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/mcp-smoke.sh
timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/wp-basis-smoke.sh
timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/media-admin-smoke.sh
```
Expected: alle vier Skripte enden mit `ALL CHECKS PASSED` (bekannte WARN-Zeilen aus Etappe 1 zu `/.well-known/` bzw. Internetzugang sind zulässig).
- [ ] **Step 7: Commit**
```bash
cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
git add src/Integrations/WordPress/Content/PostPresenter.php src/Integrations/WordPress/Content/ContentUpdateAbility.php src/Integrations/WordPress/Revisions/RevisionsListAbility.php src/Guides/InstructionsBuilder.php docs/guides tests/Integration/divi-read-smoke.sh tests/Unit/Integrations/WordPress/Content/PostPresenterTest.php tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php tests/Unit/Guides/InstructionsBuilderTest.php
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi hints point to the read tools; guides divi-structure/attrs/design-system; test: Divi read end-to-end
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
```
- [ ] **Step 8: Manuelle Schritte (Jan-Niclas)**
Der ausführende Agent bereitet vor und meldet; diese Punkte erledigt Jan-Niclas selbst:
1. **Builder-Fixtures:** 2–3 Seiten im Divi Visual Builder bauen – mit globalem Modul (aus der Divi Library), Modul-Preset, globaler Farbe und Variable, Dynamic Content (Beitragstitel, ein Meta-Feld), Spezialabschnitt und verschachtelter Zeile – und je Seite exportieren:
```bash
cd /home/janniclas/Projekte/WordPress
timeout 300 ddev exec wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-export-page.php <post_id> <name>
cd wp-content/plugins/WP-LIT-MCP && composer test
```
Erwartung: `Success: builder-<name>.html …`, danach grün. Schlägt der Roundtrip fehl, ist das ein Fehler in `Parser`/`Serializer` (nie die Fixture anpassen). Fixtures ohne personenbezogene Daten mit `git add tests/Fixtures/divi/builder-*.html` und einer Commit-Nachricht `test: Divi builder fixtures` einchecken.
2. **claude.ai:** „Zeig mir den Aufbau der Startseite“ → `lit-divi-outline`; „Welche Farben und Presets hat die Website?“ → `lit-divi-design-system`; „Was sieht ein Besucher auf der Seite?“ → `lit-page-render-summary`. Die Antworten stimmen mit dem Divi-Builder überein.
Ergebnisse als kurze Notiz an den Ausführenden zurückmelden; Abweichungen werden als Folgeaufgaben erfasst.
---
## Self-Review (durchgeführt beim Schreiben)
- **Spec-Abdeckung (Teil 2a):** §5.2 BlockTree (Parser/Serializer, Byte-Invariante, Sonderfall globale Module, reines PHP, `$variable`-Escaping) → Tasks 2–3; §5.3 Pfad-IDs, Outline, `tree_rev` (P16) → Tasks 1, 3, 6; §5.4 Katalog-Validierung der Attributpfade, Breakpoints, Kurzform-Tabelle → Task 4 (Schreiben in 2b); §5.7 `lit/divi-outline`, `lit/divi-node-get`, `lit/divi-catalog`, `lit/divi-design-system`, `lit/page-render-summary` → Tasks 4–7; §9.1 Divi-Regel in den Serveranweisungen, §9.2 Guides `divi-structure`, `divi-attrs`, `divi-design-system` → Task 8; §11.3 Schemas/Annotations → alle Ability-Tasks; §13 Fixtures (echte Builder-Seiten, Roundtrip) → Task 2 + manueller Schritt 8.1. Offen für 2b: Kurz-/Vollform schreiben, Platzhalter, Insert/Update/Move/Delete/Duplicate/Replace, Warnungen §5.5, Speicher-Pipeline §5.6, `content-create` mit Divi (§6.2), Guides `divi-shortform`, `divi-placeholders`. Nach Etappe 3 verschoben (D6): `lit/divi-dynamic-sources`, Warnung „ähnliche Abschnitte“.
- **Gefundene Spec-Lücken:** §5.3 nannte `rev` für die Divi-Sperre (Konflikt mit P16) → Task 1; §5.7 ließ offen, wo `page-render-summary` rendert und ob es Divi braucht → D5; §5.6 übersah, dass kses für Benutzer ohne `unfiltered_html` alle Blöcke neu serialisiert → D7 (Umsetzung 2b); `social-media-follow` heißt im Katalog-Kind `social-media-follow-network`, Kontaktfeld „volle Breite“ ist in 5.2 kein Kurzform-Pfad mehr (Flex) → in `ShortformMap` berücksichtigt.
- **Placeholder-Scan:** keine „TBD/TODO/später“-Schritte; generierte Dateien (`converted-*.html`, `metadata-sample.json`) haben je einen exakten Befehl und eine Erwartung.
- **Typ-Konsistenz geprüft:** `NodeIndex::find/idOf/parentOf/parentId/root/entries`, `AttrMap::flatten/desktop/set/remove`, `ModuleCatalog::normalize/has/childrenOf/isKnownPath/suggestions/paths`, `ShortformMap::fields/childType/primary/values`, `DesignSystem::presetNames/colorPlaceholder`, `DiviPageLoader::load/writable/unknownNode`, `DiviPage::treeRev/editUrl/isDiviLayout` stimmen zwischen Interfaces-Blöcken, Code und Tests überein; die Test-Helfer `DiviPageLoaderTest::fixture/givenPost/schema`, `DesignSystemTest::sample`, `ModuleCatalogTest::sampleCatalog` nutzt Plan 2b unverändert.
- **Review Focus:** alle fünf Punkte haben ihren Test im genannten Task; der Code wurde in einer Kopie des Plugins mit PHP 8.5/PHPUnit 9.6 ausgeführt (681 Tests grün, phpcs sauber) und die Lese-Tools direkt gegen Divi 5.2.1 in DDEV geprüft.