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

249 KiB
Raw Blame History

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:

- **Alle übrigen Abilities:** über die Meta-Tools des Adapters (`discover-abilities`, `get-ability-info`, `execute-ability`).

durch:

- **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:

- Attributwerte mit `$variable(...)$` werden beim Serialisieren wie von Divi erwartet escaped (`"` → `"`).

durch:

- 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:

- 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:

- 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:

- ähnlichen, bereits existierenden Abschnitten (Hinweis auf `lit/library-find-similar`).

durch:

- ä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:

3. Serialisieren, `wp_slash()`, `wp_update_post()` – Status unverändert, WordPress legt eine Revision an.

durch:

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:

| `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:

| `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:

| `lit/divi-replace-text` | schreiben | Text seitenübergreifend ersetzen, `dry_run` (Standard `true`) |

durch:

| `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:

  "rev": "a1b2c3",

durch:

  "rev": "a1b2c3", "tree_rev": "d4e5f6",

In docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md ersetzen:

Status nie ändern ohne ausdrücklichen Wunsch; immer `rev` mitgeben,

durch:

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:

| **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:

| **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:

- Etappe 2: Exakte Kurzform-Felder je Modul (aus Modul-Metadaten abgeleitet, im Guide dokumentiert).

durch:

- 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:

# 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
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:

<!-- 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:

<!-- 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:

[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:

[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:

[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
/**
 * 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

/**
 * 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:

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

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

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

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

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

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

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

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

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

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
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

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

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

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

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

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

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
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

/**
 * 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:

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

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

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

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

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

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

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

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

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

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:

use LIT\MCP\Integrations\Core\CoreIntegration;

durch:

use LIT\MCP\Integrations\Core\CoreIntegration;
use LIT\MCP\Integrations\Divi\DiviIntegration;

In src/Plugin.php ersetzen:

            new MediaIntegration($schema, $changeLog, $limits)
        );

durch:

            new MediaIntegration($schema, $changeLog, $limits),
            new DiviIntegration($schema)
        );

In docs/guides/overview.md ersetzen:

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:

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

/**
 * 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:

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
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

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

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:

        'lit/divi-catalog',
    ];

durch:

        '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

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

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

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:

use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility;

durch:

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:

    private DiviModules $modules;

    public function __construct(private ContentSchema $schema)
    {
        $this->modules = new DiviModules();
    }

durch:

    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:

            new DiviCatalogAbility($this->modules),
        ];

durch:

            new DiviCatalogAbility($this->modules),
            new DiviDesignSystemAbility($this->design),
        ];

In docs/guides/overview.md ersetzen:

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:

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
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

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

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:

    private const EXPECTED = [
        'lit/divi-catalog',
        'lit/divi-design-system',
    ];

durch:

    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

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

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

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

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

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:

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:

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
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

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

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:

        'lit/wp-settings-update',
    ];

durch:

        '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

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

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

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:

use LIT\MCP\Integrations\WordPress\Menus\MenuWriter;

durch:

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:

 * WordPress basics (spec §6): content, status, copies, revisions, menus, settings.

durch:

 * 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:

            new WpSettingsUpdateAbility($whitelist, $this->log),
        ];

durch:

            new WpSettingsUpdateAbility($whitelist, $this->log),
            new PageRenderSummaryAbility($this->schema, new PageRenderer()),
        ];

In docs/guides/overview.md ersetzen:

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:

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
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:

        self::assertStringNotContainsString('lit-divi', $full['hints'][0]);
        self::assertStringContainsString('Divi-Werkzeuge folgen in einer späteren Version', $full['hints'][0]);

durch:

        self::assertSame([PostPresenter::diviHint(false)], $full['hints']);

In tests/Unit/Integrations/WordPress/Content/PostPresenterTest.php (neuer Test davor) ersetzen:

    public function testPasswordAndProtectedReadonlyMetaFieldsAreOmitted(): void

durch:

    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:

        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:

        self::assertStringContainsString(PostPresenter::DIVI_INACTIVE_NOTE, $result->get_error_data()['hint']);

In tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php (Import) ersetzen:

use LIT\MCP\Integrations\WordPress\Content\ContentUpdateAbility;

durch:

use LIT\MCP\Integrations\WordPress\Content\ContentUpdateAbility;
use LIT\MCP\Integrations\WordPress\Content\PostPresenter;

In tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php ersetzen:

        self::assertSame([PostPresenter::DIVI_HINT], $result['hints']);

durch:

        self::assertSame([PostPresenter::diviHint()], $result['hints']);

In tests/Unit/Guides/InstructionsBuilderTest.php (neuer Test davor) ersetzen:

    public function testListsPostTypesWhenAProviderIsGiven(): void

durch:

    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:

use LIT\MCP\Abilities\Rev;

durch:

use LIT\MCP\Abilities\Rev;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;

In src/Integrations/WordPress/Content/PostPresenter.php ersetzen:

    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:

    /** 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:

            'hints' => $divi ? [self::DIVI_HINT] : [],

durch:

            'hints' => $divi ? [self::diviHint()] : [],

In src/Integrations/WordPress/Content/ContentUpdateAbility.php ersetzen:

                PostPresenter::DIVI_NOTE . ' Titel, Auszug, Felder und Begriffe hier ohne content ändern.'

durch:

                PostPresenter::diviNote() . ' Titel, Auszug, Felder und Begriffe hier ohne content ändern.'

In src/Integrations/WordPress/Revisions/RevisionsListAbility.php ersetzen:

 * PostPresenter::DIVI_HINT once when any item is flagged.

durch:

 * PostPresenter::diviHint() once when any item is flagged.

In src/Integrations/WordPress/Revisions/RevisionsListAbility.php ersetzen:

            'hints' => in_array(true, array_column($items, 'divi'), true) ? [PostPresenter::DIVI_HINT] : [],

durch:

            'hints' => in_array(true, array_column($items, 'divi'), true) ? [PostPresenter::diviHint()] : [],

In src/Guides/InstructionsBuilder.php ersetzen:

        return implode("\n", array_merge($lines, [
            'Regeln:',

durch:

        $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:

            '- Fehler enthalten einen hint – ihm folgen, statt blind zu wiederholen.',
        ]));

durch:

            '- Fehler enthalten einen hint – ihm folgen, statt blind zu wiederholen.',
        ], $divi));
  • Step 4: Implementierung – Guides

docs/guides/divi-structure.md:

# 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:

# 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:

# 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:

Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows` oder `media`.

durch:

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:

  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:

  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:

2. Kopie mit `lit-content-update` (Divi-Inhalte bis zu den Divi-Werkzeugen im Divi-Builder) bearbeiten und dem Nutzer die Vorschau zeigen.

durch:

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:

#!/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:

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
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:
    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.