Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
249 KiB
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-MCPanlegen 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 lintundcomposer testnach 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 vonPHPUnit\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 perFunctions\stubs()vorbelegten Funktionen (wp_json_encode,is_wp_error,absint,wp_slash,wp_unslash,sanitize_text_field) nie mitFunctions\expect()prüfen.error_log()lässt sich nicht stubben – perini_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 mittimeout 300 ddev exec wp eval-file …(ohnedeclare(strict_types=1);, dawp eval-fileübereval()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):
revbleibt die Sperre des ganzen Eintrags (Rev::ofPost); die Divi-Werkzeuge liefern zusätzlichtree_rev=Rev::of(post_content). Ab Etappe 2b verlangt jede Divi-Änderungtree_rev. - Ability-Namen
lit/divi-<aktion>bzw.lit/page-render-summary; in Guides, Beschreibungen und Hinweisen direkte Tools alslit-<name>, indirekte alslit/<name>(ToolNamingTest); jeder Task, der ein direktes Tool hinzufügt, ergänzt im selben Task die Zeile „Direkt sichtbar:“ indocs/guides/overview.md(Ruling Q2). - Lese-Tools: Annotations
readonly: true,destructive: false,idempotent: true;inputSchemamitadditionalProperties: false,outputSchemaimmer gesetzt (JSON Schema 2020-12 im vonrest_validate_value_from_schema()geprüften Umfang – kein$ref). - Fachliche Fehler nur über
Errors(lit_*, JSON mithint); Divi-4-Inhalt und beschädigtes Markup →unsupportedmit Hinweis. - Divi-Daten nur live über Divis APIs (
ModuleRegistration,GlobalData,GlobalPreset, Optionet_divi) und nur für die Dauer der Anfrage gehalten (Spec §2 Nr. 2); die Divi-Integration ist nur verfügbar, wennModuleRegistrationexistiert (Spec §2 Nr. 4). - Commits:
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit …mit den TrailernCo-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>undClaude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7.
Review Focus
- Echte Builder-Seiten mit Leerraum und Zeilenumbrüchen zwischen Blöcken, CRLF, Block-Editor-Blöcken, globalen Modulen (
globalModule,divi/global-layoutmit 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). - 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). - Divi liefert Gruppen als
stdClass(globale Variablen) oder leere Presets: Designsystem bleibt vollständig lesbar (live in DDEV gefunden: alle Variablentypen kommen alsstdClass) → Test in Task 5 (DesignSystemTest::sample()mitstdClass-Gruppen). - 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). - 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) undModuleCatalogTestgegen 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 vonpost_content, Sperre der Divi-Werkzeuge),rev(Rev::ofPost), Pfad-ID-Buchstabens 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_revstattrevfü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, wirftJsonException).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(ohnedivi/),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(wirftMalformedMarkup).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" & 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="||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(ersterdivi/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(Wertdesktop.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 ohnedivi/):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, Fallback5.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, Typpage, TitelStart),::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 IDdivihat.
-
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:
- 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:
Erwartung:
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 testSuccess: builder-<name>.html …, danach grün. Schlägt der Roundtrip fehl, ist das ein Fehler inParser/Serializer(nie die Fixture anpassen). Fixtures ohne personenbezogene Daten mitgit add tests/Fixtures/divi/builder-*.htmlund einer Commit-Nachrichttest: Divi builder fixtureseinchecken. - 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.7lit/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 Guidesdivi-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-createmit Divi (§6.2), Guidesdivi-shortform,divi-placeholders. Nach Etappe 3 verschoben (D6):lit/divi-dynamic-sources, Warnung „ähnliche Abschnitte“. - Gefundene Spec-Lücken: §5.3 nannte
revfür die Divi-Sperre (Konflikt mit P16) → Task 1; §5.7 ließ offen, wopage-render-summaryrendert und ob es Divi braucht → D5; §5.6 übersah, dass kses für Benutzer ohneunfiltered_htmlalle Blöcke neu serialisiert → D7 (Umsetzung 2b);social-media-followheißt im Katalog-Kindsocial-media-follow-network, Kontaktfeld „volle Breite“ ist in 5.2 kein Kurzform-Pfad mehr (Flex) → inShortformMapberü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/isDiviLayoutstimmen zwischen Interfaces-Blöcken, Code und Tests überein; die Test-HelferDiviPageLoaderTest::fixture/givenPost/schema,DesignSystemTest::sample,ModuleCatalogTest::sampleCatalognutzt 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.