From 703fecb6d49747b8c4925fd8410b91f58dc049fd Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 11:07:49 +0200 Subject: [PATCH] docs: implementation plans Etappe 2a (Divi lesen) and 2b (Divi schreiben) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- .../plans/2026-09-26-etappe-2a-divi-lesen.md | 5738 ++++++++++++++ .../2026-09-26-etappe-2b-divi-schreiben.md | 6654 +++++++++++++++++ 2 files changed, 12392 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-26-etappe-2a-divi-lesen.md create mode 100644 docs/superpowers/plans/2026-09-26-etappe-2b-divi-schreiben.md diff --git a/docs/superpowers/plans/2026-09-26-etappe-2a-divi-lesen.md b/docs/superpowers/plans/2026-09-26-etappe-2a-divi-lesen.md new file mode 100644 index 0000000..53428cc --- /dev/null +++ b/docs/superpowers/plans/2026-09-26-etappe-2a-divi-lesen.md @@ -0,0 +1,5738 @@ +# Etappe 2a – Divi-Seiten lesen: BlockTree, Katalog, Designsystem, Seitenbaum – Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Claude liest Divi-5-Seiten als Baum mit Pfad-IDs und `tree_rev`, jeden Knoten in Vollform samt Kurzform-Werten, den Modul-Katalog und das Designsystem der Website live aus Divi sowie eine Zusammenfassung der gerenderten Seite – als sichere, byte-exakte Grundlage für die Schreibwerkzeuge in Etappe 2b. + +**Architecture:** Neue Integration `DiviIntegration` (nur mit geladenem Divi-5-Builder). Reines PHP ohne WordPress: `BlockTree/` (eigener Tokenizer mit dem Delimiter-Muster von `WP_Block_Parser`, der jedes Byte des Originals behält; Serializer wie `serialize_block_attributes()`; Pfad-Index; Outline), `Schema/` (Modul-Katalog und Attributpfade aus `ModuleRegistration`-Metadaten, kuratierte Kurzform), `Design/DesignSystem`. Dünne WordPress/Divi-Adapter (`DiviModules`, `DesignSystemReader`, `DiviPageLoader`) und vier direkte Lese-Tools; `lit/page-render-summary` gehört zur WordPress-Integration (funktioniert für jeden Eintrag). Die Divi-Hinweise in `lit-content-get`, `lit-content-update` und `lit/revisions-list` zeigen auf die neuen Werkzeuge. + +**Tech Stack:** PHP ≥ 8.1, WordPress 7.0 (Abilities API), Divi 5.2.1 (`ET\Builder\Packages\ModuleLibrary\ModuleRegistration`, `ET\Builder\Packages\GlobalData\GlobalData`, `ET\Builder\Packages\GlobalData\GlobalPreset`), `wordpress/mcp-adapter` 0.5, `wp-media/mcp-oauth` 1.1, PHPUnit 9.6 + Brain Monkey + Mockery, ext-dom, phpcs (PSR-12, WordPress.Security, PHPCompatibilityWP), DDEV. + +**Spec:** `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` (insb. §2, §4.3, §4.4, §5.1–5.4, §5.7, §8.1, §9, §11.3, §12 Etappe 2, §13) + +**Voraussetzung:** Etappe 1 (1a + 1b) ist umgesetzt (Branch `etappe-2`, Stand `ef44593`): `Errors`, `ToolResult`, `Rev` (`of`, `ofPost`), `JsonMap`, `Cursor`, `ChangeLog`, `PostGuard`, `PostPresenter`, `ContentSchema` (inkl. `diviEnabled()`), `ExposurePolicy`, `InstructionsBuilder`, `GuideRepository`, `tests/Integration/lib.sh`, `ToolNamingTest`. Danach folgt Plan `docs/superpowers/plans/2026-09-26-etappe-2b-divi-schreiben.md`. + +## Global Constraints + +- Dateien ausschließlich im Plugin-Verzeichnis `/home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP` anlegen oder ändern (auch Fixtures). Theme und andere Plugins nur lesen; Seiten, Benutzer und Rollen, die Integrationsskripte anlegen, löschen sie wieder. +- PHP ≥ 8.1 ohne 8.2+-Features (keine `readonly`-Klassen, keine DNF-Typen, keine typisierten Klassenkonstanten); WordPress ≥ 6.9 (getestet 7.0); Divi 5.2.1. +- Namespace `LIT\MCP\` → `src/`, PSR-4, eine Klasse pro Datei, PSR-12, höchstens 120 Zeichen pro Zeile; `composer lint` und `composer test` nach jedem Task sauber. +- Reines PHP ohne WordPress-Funktionen (Spec §4.4): `src/Integrations/Divi/BlockTree/*`, `src/Integrations/Divi/Schema/{AttrPathIndex,ModuleCatalog,ShortformMap}.php`, `src/Integrations/Divi/Design/DesignSystem.php`, `src/Integrations/WordPress/Render/RenderSummary.php`. Deren Tests erben von `PHPUnit\Framework\TestCase`. Wo phpcs dort WordPress-Alternativen verlangt (`json_encode`, `strip_tags`), steht `// phpcs:ignore -- pure PHP (spec §4.4).` +- Unit-Tests der WordPress-Adapter und Abilities über `LIT\MCP\Tests\Unit\TestCase` (Brain Monkey + Mockery); die dort per `Functions\stubs()` vorbelegten Funktionen (`wp_json_encode`, `is_wp_error`, `absint`, `wp_slash`, `wp_unslash`, `sanitize_text_field`) nie mit `Functions\expect()` prüfen. `error_log()` lässt sich nicht stubben – per `ini_set('error_log', )` umleiten. +- Integrationstests im DDEV-Container: `cd /home/janniclas/Projekte/WordPress && timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/.sh`; Hilfsskripte mit `timeout 300 ddev exec wp eval-file …` (ohne `declare(strict_types=1);`, da `wp eval-file` über `eval()` läuft). +- **Byte-Invariante (Spec §5.2, §1.3 Nr. 3):** Parse → Serialize ergibt für jede Fixture byte-identischen Inhalt (auch mit CRLF). Unveränderte Knoten werden nie neu erzeugt; unbalanciertes Markup wird abgelehnt (`MalformedMarkup`), nie repariert. +- **Sperren (Ruling P16):** `rev` bleibt die Sperre des ganzen Eintrags (`Rev::ofPost`); die Divi-Werkzeuge liefern zusätzlich `tree_rev` = `Rev::of(post_content)`. Ab Etappe 2b verlangt jede Divi-Änderung `tree_rev`. +- Ability-Namen `lit/divi-` bzw. `lit/page-render-summary`; in Guides, Beschreibungen und Hinweisen direkte Tools als `lit-`, indirekte als `lit/` (`ToolNamingTest`); jeder Task, der ein direktes Tool hinzufügt, ergänzt im selben Task die Zeile „Direkt sichtbar:“ in `docs/guides/overview.md` (Ruling Q2). +- Lese-Tools: Annotations `readonly: true`, `destructive: false`, `idempotent: true`; `inputSchema` mit `additionalProperties: false`, `outputSchema` immer gesetzt (JSON Schema 2020-12 im von `rest_validate_value_from_schema()` geprüften Umfang – kein `$ref`). +- Fachliche Fehler nur über `Errors` (`lit_*`, JSON mit `hint`); Divi-4-Inhalt und beschädigtes Markup → `unsupported` mit Hinweis. +- Divi-Daten nur live über Divis APIs (`ModuleRegistration`, `GlobalData`, `GlobalPreset`, Option `et_divi`) und nur für die Dauer der Anfrage gehalten (Spec §2 Nr. 2); die Divi-Integration ist nur verfügbar, wenn `ModuleRegistration` existiert (Spec §2 Nr. 4). +- Commits: `git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit …` mit den Trailern `Co-Authored-By: Claude Opus 5.5 (1M context) ` und `Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7`. + +## Review Focus + +1. **Echte Builder-Seiten mit Leerraum und Zeilenumbrüchen zwischen Blöcken, CRLF, Block-Editor-Blöcken, globalen Modulen (`globalModule`, `divi/global-layout` mit und ohne Inhalt) und nicht dekodierbaren Attributen:** Roundtrip byte-identisch, solche Knoten erscheinen „nur lesbar“ und werden nicht betreten → Tests in Task 2 (`ParserTest`: jede Fixture auch als CRLF, Opazität) und Task 3 (`OutlineTest::testReadOnlyNodesAreMarked`). +2. **Divi-4-Seite (Shortcodes) oder beschädigtes Markup:** klarer `unsupported`-Fehler mit Hinweis, Fehlerdetail im PHP-Error-Log, nichts wird geraten → Test in Task 6 (`DiviPageLoaderTest`). +3. **Divi liefert Gruppen als `stdClass` (globale Variablen) oder leere Presets:** Designsystem bleibt vollständig lesbar (live in DDEV gefunden: alle Variablentypen kommen als `stdClass`) → Test in Task 5 (`DesignSystemTest::sample()` mit `stdClass`-Gruppen). +4. **Divi nicht aktiv (anderes Theme oder Divi 4):** keine Divi-Tools registriert, Hinweise nennen keine Werkzeuge, die es nicht gibt → Tests in Task 4 (`DiviIntegrationTest`) und Task 8 (`PostPresenterTest::testDiviHintNamesTheDiviToolsOnlyWhenDiviIsActive`). +5. **Divi-Update mit geänderten Modul-Metadaten:** ein Kurzform-Pfad existiert nicht mehr → das Prüfskript schlägt laut fehl, statt dass später falsche Attribute geschrieben werden → Task 4 (`divi-shortform-check.php`, auch im Smoke-Test von Task 8) und `ModuleCatalogTest` gegen echte 5.2.1-Metadaten. + +--- + +## File Structure + +``` +docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md (geändert) tree_rev, Tool-Budget, Entscheidungen Etappe 2 +docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md Entscheidungen D1–D12 der Planung +src/ + Plugin.php (geändert) DiviIntegration registrieren + Guides/InstructionsBuilder.php (geändert) Divi-Regel, wenn Divi aktiv + Integrations/ + Divi/ + DiviIntegration.php id "divi", nur mit Divi 5; Lese-Tools + BlockTree/ reines PHP + Json.php JSON der Attribute ({} bleibt {}) + Container.php Teile (Blöcke + Text) eines Dokuments/Blocks + Block.php Block mit Original-Delimitern, opak/void + Document.php geparster post_content + MalformedMarkup.php unbalanciertes Markup + Parser.php Tokenizer (WP_Block_Parser-Muster), byte-exakt + Serializer.php wie serialize_block_attributes() + NodeIndex.php Pfad-IDs s0.r1.c0.m2, Eltern, Wurzel + AttrMap.php Attributpfade, Breakpoint-Maps, flatten/get/set/remove + NodeSummary.php Admin-Label, Textauszug, Presets, Spaltenbreiten + Outline.php Baum als Zeilen und Text + Schema/ + AttrPathIndex.php gültige Attributpfade aus Metadaten + Defaults (rein) + ModuleCatalog.php Module, Titel, Kategorien, Kinder, Pfade (rein) + ShortformMap.php kuratierte Kurzform für ~20 Module (rein) + DiviModules.php Adapter ModuleRegistration (+ builderVersion) + DiviCatalogAbility.php lit/divi-catalog + Design/ + DesignSystem.php Farben, Variablen, Schriften, Größen, Presets (rein) + DesignSystemReader.php Adapter GlobalData/GlobalPreset/et_divi + DiviDesignSystemAbility.php lit/divi-design-system + Pages/ + DiviPage.php Post + Dokument + Index + tree_rev + edit_url + DiviPageLoader.php Rechte (PostGuard), Divi-4/Markup-Prüfung, writable() + DiviOutlineAbility.php lit/divi-outline + DiviNodeGetAbility.php lit/divi-node-get + WordPress/ + WordPressIntegration.php (geändert) lit/page-render-summary + Render/ + RenderSummary.php HTML → Überschriften, Texte, Links, Bilder, Warnungen (rein) + PageRenderer.php the_content im Prozess rendern, globals zurücksetzen + PageRenderSummaryAbility.php lit/page-render-summary + Content/PostPresenter.php (geändert) diviNote()/diviHint() statt DIVI_NOTE/DIVI_HINT + Content/ContentUpdateAbility.php (geändert) Hinweis auf die Divi-Werkzeuge + Revisions/RevisionsListAbility.php (geändert) diviHint() +docs/guides/ + overview.md (geändert) direkte Tools, Themen + content-types.md (geändert) Divi-Hinweis + divi-structure.md Aufbau, Pfad-IDs, tree_rev, nur lesbar, render summary + divi-attrs.md Vollform: Pfade, Breakpoints, Zustände + divi-design-system.md Farben, Variablen, Schriften, Presets, Regeln +tests/ + Fixtures/divi/ + synthetic-basic.html handgeschriebene Divi-5-Seite (Presets, $variable, \u-Escapes) + synthetic-mixed.html Block-Editor, Leerraum, specialty, globale Module, {} / [] / "0" + divi4-shortcodes.html Divi-4-Inhalt + source/landing.shortcode Divi-4-Quelle für die Konvertierung + source/features.shortcode Divi-4-Quelle für die Konvertierung + converted-landing.html (generiert) von Divis eigener Konvertierung + converted-features.html (generiert) von Divis eigener Konvertierung + metadata-sample.json (generiert) echte Modul-Metadaten + Defaults (13 Module) + builder-*.html (später, manuell) im Visual Builder gebaute Seiten + Integration/ + divi-convert-fixture.php Divi-4-Shortcodes → Divi-5-Fixture (Divis Conversion) + divi-export-page.php Builder-Seite byte-exakt als Fixture exportieren + divi-metadata-fixture.php metadata-sample.json erzeugen + divi-shortform-check.php Kurzform gegen den Live-Katalog prüfen + divi-read-smoke.sh Ende-zu-Ende-Test der Lese-Tools + Unit/Integrations/Divi/… Tests je Klasse (siehe Tasks) + Unit/Integrations/WordPress/Render/… RenderSummary, PageRenderer, Ability +``` + +--- +### Task 1: Spec-Nachtrag und Entscheidungen der Etappe 2 + +Ruling P16 verlangt, die Spec anzupassen: `rev` ist die Sperre des ganzen Eintrags, die Divi-Werkzeuge sperren mit `tree_rev`. Dazu kommen die bei der Planung getroffenen Entscheidungen (Tool-Budget, kses beim Speichern, Verschiebungen nach Etappe 3). Reiner Doku-Task; Prüfung per `grep`. + +**Files:** +- Modify: `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` (§4.3, §5.2, §5.3, §5.5, §5.6, §5.7, §8.1, §9.1, §12, §15) +- Create: `docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md` + +**Interfaces:** +- Consumes: – +- Produces: verbindliche Begriffe für alle folgenden Tasks: `tree_rev` (Hash von `post_content`, Sperre der Divi-Werkzeuge), `rev` (`Rev::ofPost`), Pfad-ID-Buchstaben `s r c m g x`, direkte Tools nach Etappe 2 (21). + +- [ ] **Step 1: §4.3 – Tool-Budget ergänzen** + +In `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` ersetzen: +```markdown +- **Alle übrigen Abilities:** über die Meta-Tools des Adapters (`discover-abilities`, `get-ability-info`, `execute-ability`). +``` +durch: +```markdown +- **Stand nach Etappe 2 (Entscheidung D2, 2026-09-26):** 21 direkte Tools – das Standard-Set oben ohne `lit/library-insert` (kommt mit Etappe 3), dazu `lit/site-status` (leeres Eingabeschema, Anker der Verbindungstests) und `lit/media-download` (eingebettete Ressource, funktioniert nur direkt). `lit/divi-duplicate-node` und `lit/divi-replace-text` sind indirekt. Mit Etappe 3 sind es 22; das Budget wird dort erneut geprüft (Kosten entstehen vor allem durch große Eingabeschemas, nicht durch die Zahl). +- **Alle übrigen Abilities:** über die Meta-Tools des Adapters (`discover-abilities`, `get-ability-info`, `execute-ability`). +``` + +- [ ] **Step 2: §5.2 – Umsetzung des BlockTree festhalten** + +In `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` ersetzen: +```markdown +- Attributwerte mit `$variable(...)$` werden beim Serialisieren wie von Divi erwartet escaped (`"` → `"`). +``` +durch: +```markdown +- Attributwerte mit `$variable(...)$` werden beim Serialisieren wie von Divi erwartet escaped (`"` → `"`). +- Umsetzung (D3): eigener Tokenizer mit dem Delimiter-Muster von `WP_Block_Parser` statt `parse_blocks()` – ohne WordPress testbar, jedes Byte zwischen den Delimitern bleibt erhalten. Knoten mit `globalModule`, `divi/global-layout` oder nicht dekodierbaren Attributen sind undurchsichtig: sie werden nicht betreten, nur als Ganzes verschoben, dupliziert oder gelöscht (Bearbeitung ab Etappe 3). Unbalanciertes Markup wird abgelehnt, nie repariert. +``` + +- [ ] **Step 3: §5.3 – `tree_rev` statt `rev` für die Divi-Sperre (Ruling P16)** + +In `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` ersetzen: +```markdown +- Jede Antwort enthält `rev` = Hash des aktuellen `post_content`. +- Jedes Schreib-Tool verlangt `rev`. Weicht er ab, wird abgelehnt (`rev_mismatch`), nichts geschrieben; Claude liest neu. Pfad-IDs gelten immer relativ zu einem `rev`. +``` +durch: +```markdown +- Jede Antwort enthält `tree_rev` = Hash des aktuellen `post_content` (Sperre der Divi-Werkzeuge) und `rev` = Sperre des ganzen Eintrags (`Rev::ofPost`, dieselbe wie bei `lit/content-get` und `lit/content-update`; Ruling P16). +- Jedes Divi-Schreib-Tool verlangt `tree_rev`. Weicht sie ab, wird abgelehnt (`rev_mismatch`), nichts geschrieben; Claude liest neu. Pfad-IDs gelten immer relativ zu einer `tree_rev`. Jede Schreib-Antwort liefert die neue `tree_rev` und die neuen Pfad-IDs der geänderten Knoten. +- Buchstaben der Pfad-IDs: `s` Abschnitt, `r` Zeile (auch `row-inner`), `c` Spalte (auch `column-inner`), `m` Modul (auch Kind-Module und Gruppen), `g` globales Modul bzw. globales Layout, `x` fremder Block (z. B. Block-Editor); gezählt wird je Buchstabe innerhalb des Elternknotens, `divi/placeholder` ist transparent. +``` + +- [ ] **Step 4: §5.5, §5.6, §5.7 – Verschiebungen und Speicherdetails** + +In `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` ersetzen: +```markdown +- ähnlichen, bereits existierenden Abschnitten (Hinweis auf `lit/library-find-similar`). +``` +durch: +```markdown +- ähnlichen, bereits existierenden Abschnitten (Hinweis auf `lit/library-find-similar`) – ab Etappe 3 zusammen mit `lit/library-find-similar` (D6). +``` +In `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` ersetzen: +```markdown +3. Serialisieren, `wp_slash()`, `wp_update_post()` – Status unverändert, WordPress legt eine Revision an. +``` +durch: +```markdown +3. Serialisieren, `wp_slash()`, `wp_update_post()` – Status unverändert, vorher Wiederherstellungspunkt (Revision). Für Benutzer ohne `unfiltered_html` wird kses für genau diesen Aufruf abgeschaltet: `filter_block_content()` würde sonst jeden unveränderten Block neu serialisieren (Byte-Identität, globale Module; in DDEV nachgewiesen). Alle Werte, die LIT MCP schreibt, sind vorher mit `wp_kses_post` / `sanitize_text_field` / `esc_url_raw` bereinigt (D7). +``` +In `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` ersetzen: +```markdown +| `lit/divi-dynamic-sources` | lesen | verfügbare Dynamic-Content-Quellen für einen Post-Typ (über `DynamicContentOptions::get_options` mit einem Beispiel-Post des Typs) | +| `lit/page-render-summary` | lesen | gerenderte Seite als strukturierter Auszug (Überschriften, Texte, Links, Bilder+Alt) | +``` +durch: +```markdown +| `lit/divi-dynamic-sources` | lesen | verfügbare Dynamic-Content-Quellen für einen Post-Typ (über `DynamicContentOptions::get_options` mit einem Beispiel-Post des Typs) – Etappe 3, mit dem Theme Builder (D6) | +| `lit/page-render-summary` | lesen | gerenderte Seite als strukturierter Auszug (Überschriften, Texte, Links, Bilder+Alt); Inhaltsbereich, im Prozess über `the_content` gerendert (ohne Header/Footer/Theme Builder); Teil der WordPress-Integration, funktioniert auch ohne Divi (D5) | +``` +In `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` ersetzen: +```markdown +| `lit/divi-replace-text` | schreiben | Text seitenübergreifend ersetzen, `dry_run` (Standard `true`) | +``` +durch: +```markdown +| `lit/divi-replace-text` | schreiben | Text seitenübergreifend (je Inhaltstyp) ersetzen, `dry_run` (Standard `true`) liefert Treffer je Seite mit `tree_rev`; ausgeführt wird nur auf diesen `targets` mit `confirm: true`, jede Seite mit eigener Revision (D10) | +``` + +- [ ] **Step 5: §8.1, §9.1, §12, §15** + +In `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` (ToolResult-Beispiel) ersetzen: +```markdown + "rev": "a1b2c3", +``` +durch: +```markdown + "rev": "a1b2c3", "tree_rev": "d4e5f6", +``` +In `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` ersetzen: +```markdown +Status nie ändern ohne ausdrücklichen Wunsch; immer `rev` mitgeben, +``` +durch: +```markdown +Status nie ändern ohne ausdrücklichen Wunsch; immer `rev` (bei Divi-Änderungen `tree_rev`) mitgeben, +``` +In `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` ersetzen: +```markdown +| **2 – Divi-Seiten** | BlockTree, Katalog, Kurz-/Vollform, Platzhalter, Outline/Insert/Update/Move/Delete/Duplicate/Replace, Warnungen, `page-render-summary`, Divi-Guides | Divi-Seiten mit Claude bauen und pflegen | +``` +durch: +```markdown +| **2 – Divi-Seiten** | BlockTree, Katalog, Kurz-/Vollform, Platzhalter, Outline/Insert/Update/Move/Delete/Duplicate/Replace, Warnungen, `page-render-summary`, Divi-Guides (umgesetzt in 2a: lesen, 2b: schreiben) | Divi-Seiten mit Claude bauen und pflegen | +``` +In `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` ersetzen: +```markdown +- Etappe 2: Exakte Kurzform-Felder je Modul (aus Modul-Metadaten abgeleitet, im Guide dokumentiert). +``` +durch: +```markdown +- Etappe 2: erledigt – Kurzform-Felder aus Divis Conversion-Outlines abgeleitet (`ShortformMap`), gegen den Live-Katalog geprüft (`tests/Integration/divi-shortform-check.php`), dokumentiert im Guide `divi-shortform`. +``` + +- [ ] **Step 6: Entscheidungen festhalten** + +`docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md`: +```markdown +# Etappe 2 – Entscheidungen der Planung + +Getroffen bei der Planung von Etappe 2a/2b, 2026-09-26. Kosten, falls falsch, jeweils am Ende. + +- D1 (Ruling P16): `rev` = `Rev::ofPost` (ganzer Eintrag); Divi-Sperre = `tree_rev` = `Rev::of(post_content)`. Divi-Lese-Tools liefern beide, Divi-Schreib-Tools verlangen `tree_rev` und liefern beide neu. – Kosten: keine. +- D2: Direkte Tools nach Etappe 2 = 21 (Spec §4.3 ohne `lit/library-insert`, plus `lit/site-status`, `lit/media-download`). `lit/site-status` bleibt direkt (leeres Schema, Anker von mcp-smoke.sh), `lit/media-download` muss direkt bleiben. Indirekt: `lit/divi-duplicate-node`, `lit/divi-replace-text`. – Kosten: etwas mehr Kontext je Unterhaltung; in Etappe 3 neu bewerten. +- D3: BlockTree mit eigenem Tokenizer (Muster von `WP_Block_Parser`), opake Knoten für globale Module/Layouts und undekodierbare Attribute, unbalanciertes Markup → Ablehnung. – Kosten: globale Module erst ab Etappe 3 bearbeitbar. +- D4: `ExposurePolicy::DIRECT_ONLY` bleibt eine Liste; Etappe 2 bringt keine Bild-/Ressourcen-Tools, ein `Ability::requiresDirect()` wäre YAGNI. – Kosten: bei weiteren Bild-Tools die Liste ergänzen. +- D5: `lit/page-render-summary` gehört zur WordPress-Integration (funktioniert für jeden Eintrag) und rendert den Inhaltsbereich im Prozess (`the_content`), ohne HTTP-Loopback. – Kosten: Header/Footer/Theme Builder fehlen im Auszug. +- D6: `lit/divi-dynamic-sources` und die Warnung „ähnliche Abschnitte“ kommen mit Etappe 3 (Theme Builder, `lit/library-find-similar`). – Kosten: bis dahin keine Quellenliste; `{{field:…}}` nimmt jeden Meta-Schlüssel. +- D7: Divi-Speichern ohne kses für Benutzer ohne `unfiltered_html` (sonst re-serialisiert WordPress jeden Block); alle geschriebenen Werte vorher bereinigt; Code-Modul ungefiltert nur mit `unfiltered_html`. – Kosten: jeder neue Schreibpfad muss bereinigen (Review-Pflicht). +- D8: Spalten neuer Zeilen über `module.decoration.sizing.desktop.value.flexType` (`"1_3,2_3"` → `8_24`, `16_24`; Fünftel und 1/7, 1/9, 1/10, 1/11 nativ), wie Divi 5.2 (Flex-Layout ist Standard). – Kosten: falls der Builder anders speichert, nur Composer anpassen. +- D9: Platzhalter stehen allein im Wert; Überschriften der Kurzform ohne `level` werden `h2`. – Kosten: eingebettete dynamische Texte nur über die Vollform. +- D10: `lit/divi-replace-text` je Inhaltstyp; Probelauf liefert `tree_rev` je Seite, Ausführung nur auf diesen `targets` mit `confirm: true`; ersetzt nur sichtbaren Text (`…innerContent`, Schlüssel text/title/label/alt/titleText), nie URLs oder Stile. – Kosten: Text mit HTML-Entitäten muss so gesucht werden, wie er gespeichert ist. +- D11: `lit/content-create` mit `divi` speichert über `wp_insert_post` mit WordPress' üblicher Filterung (nur neuer, selbst erzeugter Inhalt – nichts Unberührtes, das byte-identisch bleiben müsste). – Kosten: für Benutzer ohne `unfiltered_html` kann WordPress Entitäten normalisieren; `tree_rev` in der Antwort beschreibt den gespeicherten Stand. +- D12: Fixtures: handgeschriebene Grenzfälle (`synthetic-*.html`), mit Divis eigener Konvertierung erzeugte Seiten (`converted-*.html`), echte Modul-Metadaten (`metadata-sample.json`); im Visual Builder gebaute Seiten exportiert Jan-Niclas später mit `tests/Integration/divi-export-page.php` als `builder-*.html` – sie laufen automatisch im Roundtrip-Test mit. +``` + +- [ ] **Step 7: Prüfen** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && grep -c 'tree_rev' docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md && grep -c '^- D' docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md` +Expected: erste Zahl ≥ 6, zweite Zahl `12`. + +- [ ] **Step 8: Commit** + +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP +git add docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "docs: spec – tree_rev lock (P16), tool budget and decisions for Etappe 2 + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- + +### Task 2: BlockTree – byte-exakter Parser und Serializer, Roundtrip-Fixtures + +Spec §5.2: Parse → Serialize ohne Änderung ergibt byte-identischen Inhalt; fremde Blöcke bleiben unberührt; reines PHP. Der Parser nutzt das Delimiter-Muster von `WP_Block_Parser::next_token()` (WordPress 7.0), merkt sich aber die Original-Bytes jedes Delimiters und jeden Text dazwischen. Der Serializer erzeugt nur für geänderte Blöcke neue Delimiter – genau wie `serialize_block_attributes()` (inkl. `\u0022` in `$variable(…)$`). Attribute werden so dekodiert, dass `{}` und Objekte mit Schlüsseln `"0"`, `"1"` Objekte bleiben (Divi zerbricht an `[]`, siehe Divis `EmptyArrayCorruptionMigration`). Blöcke mit `globalModule`, `divi/global-layout` oder nicht dekodierbaren Attributen sind opak (D3). + +Fixtures: handgeschriebene Grenzfälle, zwei mit Divis eigener Konvertierung erzeugte Seiten (`Conversion::maybeConvertContent()` – derselbe Code wie der Divi-5-Migrator, lokal verifiziert) und ein Exportskript für echte Builder-Seiten, die Jan-Niclas später ergänzt. Jede `*.html` in `tests/Fixtures/divi/` läuft automatisch im Roundtrip-Test (auch mit CRLF). + +**Files:** +- Create: `src/Integrations/Divi/BlockTree/{Json,Container,Block,Document,MalformedMarkup,Parser,Serializer}.php` +- Create: `tests/Fixtures/divi/{synthetic-basic,synthetic-mixed,divi4-shortcodes}.html`, `tests/Fixtures/divi/source/{landing,features}.shortcode` +- Create (generiert in Step 3): `tests/Fixtures/divi/converted-landing.html`, `tests/Fixtures/divi/converted-features.html` +- Create: `tests/Integration/divi-convert-fixture.php`, `tests/Integration/divi-export-page.php` +- Test: `tests/Unit/Integrations/Divi/BlockTree/ParserTest.php`, `tests/Unit/Integrations/Divi/BlockTree/SerializerTest.php` + +**Interfaces:** +- Consumes: – +- Produces (alle `LIT\MCP\Integrations\Divi\BlockTree\`): + - `Json::decodeObject(string $json): ?array` (null, wenn kein JSON-Objekt); `Json::fromJson(mixed $value): mixed`; `Json::encode(mixed $value): string` (`JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE`, wirft `JsonException`). + - `abstract class Container`: `parts(): list`, `children(): list`, `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 $children = []): self` (neu, als Paar geschrieben), `closeWith(string $rawClose)`, `name(): string` (`divi/section`, `core/paragraph`), `shortName(): string` (ohne `divi/`), `attrs(): array`, `setAttrs(array $attrs): void` (verwirft den Original-Opener), `rawOpen(): ?string`, `rawClose(): ?string`, `isVoid(): bool`, `isOpaque(): bool`, `isDivi(): bool`, `isGlobal(): bool`, `copy(): self` (tiefe Kopie). + - `final class Document extends Container`. + - `final class MalformedMarkup extends RuntimeException`. + - `final class Parser`: `parse(string $content): Document` (wirft `MalformedMarkup`). + - `final class Serializer`: `static document(Document $d): string`, `static block(Block $b): string`, `static attributes(array $attrs): string` (= `serialize_block_attributes()`). + +- [ ] **Step 1: Handgeschriebene Fixtures anlegen** + +Die Dateien exakt so anlegen. `synthetic-basic.html` und `divi4-shortcodes.html` **ohne** abschließenden Zeilenumbruch speichern (z. B. `printf '%s' '…' > datei`), `synthetic-mixed.html` mit Zeilenumbrüchen (LF) wie gezeigt. Die `\u0022`/`\u003c`-Folgen sind wörtlich (Backslash, `u`, vier Hex-Ziffern) – so speichert WordPress Attribute. + +`tests/Fixtures/divi/synthetic-basic.html`: +```html + +``` + +`tests/Fixtures/divi/synthetic-mixed.html`: +```html + +

Einleitung aus dem Block-Editor mit Link.

+ + + + + + + + + +Freier Text am Ende [et_pb_legacy] +``` + +`tests/Fixtures/divi/divi4-shortcodes.html`: +```html +[et_pb_section fb_built="1" _builder_version="4.27.4"][et_pb_row _builder_version="4.27.4"][et_pb_column type="4_4"][et_pb_text]

Divi 4

[/et_pb_text][/et_pb_column][/et_pb_row][/et_pb_section] +``` + +`tests/Fixtures/divi/source/landing.shortcode`: +```text +[et_pb_section fb_built="1" admin_label="Hero" _builder_version="4.27.4" background_color="#0b3d59"][et_pb_row column_structure="1_2,1_2" _builder_version="4.27.4"][et_pb_column type="1_2" _builder_version="4.27.4"][et_pb_heading title="Handwerk mit Herz" title_level="h1" _builder_version="4.27.4"][/et_pb_heading][et_pb_text _builder_version="4.27.4"]

Seit 1987 in Köln – "fair" & pünktlich.

[/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"]

Persönlich vor Ort.

[/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"]

Mit 3D-Ansicht.

[/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"]

Termingerecht.

[/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"]

Wir melden uns binnen 24 Stunden.

[/et_pb_cta][et_pb_testimonial author="Anna B." job_title="Bauherrin" company_name="Privat" _builder_version="4.27.4"]

Top Arbeit!

[/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"]

Kommt drauf an.

[/et_pb_accordion_item][et_pb_accordion_item title="Wie schnell?" open="off" _builder_version="4.27.4"]

2 Wochen.

[/et_pb_accordion_item][/et_pb_accordion][et_pb_contact_form email="info@example.test" title="Kontakt" _builder_version="4.27.4"][et_pb_contact_field field_id="Name" field_title="Name" _builder_version="4.27.4"][/et_pb_contact_field][et_pb_contact_field field_id="Email" field_title="E-Mail" field_type="email" _builder_version="4.27.4"][/et_pb_contact_field][et_pb_contact_field field_id="Message" field_title="Nachricht" field_type="text" fullwidth_field="on" _builder_version="4.27.4"][/et_pb_contact_field][/et_pb_contact_form][/et_pb_column][/et_pb_row][/et_pb_section] +``` + +`tests/Fixtures/divi/source/features.shortcode`: +```text +[et_pb_section fb_built="1" admin_label="Details" _builder_version="4.27.4"][et_pb_row column_structure="2_3,1_3" _builder_version="4.27.4"][et_pb_column type="2_3" _builder_version="4.27.4"][et_pb_tabs _builder_version="4.27.4"][et_pb_tab title="Ablauf" _builder_version="4.27.4"]

Erst reden, dann bauen.

[/et_pb_tab][et_pb_tab title="Preise" _builder_version="4.27.4"]

Ab 99 €.

[/et_pb_tab][/et_pb_tabs][et_pb_toggle title="Garantie" open="off" _builder_version="4.27.4"]

5 Jahre.

[/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"]

Unsere neue Halle.

[/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"]
Code
[/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"]

Meister seit 2001.

[/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 + + * 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 '); +} +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, '' . $converted . ''; +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 +.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 + * : 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 = get_post($postId); +if (! $post instanceof WP_Post) { + WP_CLI::error('No post ' . $postId); +} + +$content = (string) $post->post_content; +if (! str_contains($content, '\n

x

\n\n\ntext"); + + self::assertSame('core/paragraph', $document->children()[0]->name()); + self::assertSame(["\n

x

\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(''); + 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 */ + public static function malformed(): array + { + return [ + 'closer without opener' => [''], + 'unclosed block' => [''], + 'crossed blocks' => [''], + ]; + } + + /** @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 + $variable]) + ); + self::assertSame( + '{"t":"\u003cp\u003eA \u0026 B\u003c/p\u003e \u002d\u002d \u005c/ ä","e":{},"l":[]}', + Serializer::attributes(['t' => '

A & B

-- \\/ ä', 'e' => new stdClass(), 'l' => []]) + ); + } + + public function testRegeneratesOnlyTheChangedOpener(): void + { + $content = ''; + $document = (new Parser())->parse($content); + $text = $document->children()[0]->children()[0]; + + $text->setAttrs(['x' => 'z']); + + self::assertSame( + '', + Serializer::document($document) + ); + } + + public function testNewBlocksAreWrittenAsPairs(): void + { + $block = Block::create('divi/row', ['builderVersion' => '5.2.1'], [Block::create('divi/column')]); + + self::assertSame( + '' + . '', + Serializer::block($block) + ); + self::assertSame('', Serializer::block(Block::create('core/paragraph'))); + } + + public function testVoidBlockGettingAChildBecomesAPair(): void + { + $document = (new Parser())->parse(''); + $column = $document->children()[0]; + + $column->insertChild(0, Block::create('divi/text')); + + self::assertSame( + '', + Serializer::document($document) + ); + } + + public function testInsertAndRemoveKeepSurroundingText(): void + { + $document = (new Parser())->parse("A\nBC"); + [$text, $code] = $document->children(); + $new = Block::create('divi/button'); + + $document->insertChild(1, $new); + self::assertSame( + "A\nBC", + 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", Serializer::document($empty)); + } + + public function testCopyIsDeepAndSerializesIdentically(): void + { + $content = ''; + $row = (new Parser())->parse($content)->children()[0]; + $copy = $row->copy(); + + $copy->children()[0]->setAttrs(['b' => 2]); + + self::assertSame($content, Serializer::block($row)); + self::assertSame('', 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 +|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 + */ + protected array $parts = []; + + /** @return list */ + public function parts(): array + { + return $this->parts; + } + + /** @return list */ + 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 + $attrs + */ + private function __construct( + private string $name, + private array $attrs, + private ?string $rawOpen, + private ?string $rawClose, + private bool $void, + private bool $opaque + ) { + } + + /** @param array $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 $attrs + * @param list $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 */ + public function attrs(): array + { + return $this->attrs; + } + + /** @param array $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 +\/)?wp:(?P[a-z][a-z0-9_-]*\/)?(?P[a-z][a-z0-9_-]*)\s+' + . '(?P{(?:(?:[^}]+|}+(?=})|(?!}\s+\/?-->).)*+)?}\s+)?(?P\/)?-->/s'; + + /** @throws MalformedMarkup */ + public function parse(string $content): Document + { + $document = new Document(); + /** @var list $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 $match */ + private static function name(array $match): string + { + $namespace = self::captured($match, 'namespace') ? $match['namespace'][0] : 'core/'; + + return $namespace . $match['name'][0]; + } + + /** @param array $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 + '\\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 $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 '' : '-->'); + } + + private static function closer(Block $block): string + { + return ''; + } + + private static function serializedName(string $name): string + { + return str_starts_with($name, 'core/') ? substr($name, 5) : $name; + } + + /** @param list $parts */ + private static function parts(array $parts): string + { + $out = ''; + foreach ($parts as $part) { + $out .= $part instanceof Block ? self::block($part) : $part; + } + + return $out; + } +} +``` + +- [ ] **Step 7: Tests und Lint (grün)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter BlockTree && composer lint` +Expected: PASS (u. a. `testParseThenSerializeIsByteIdentical` für alle 5 Fixtures, je auch als CRLF); phpcs ohne Meldungen. + +- [ ] **Step 8: Commit** + +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP +git add src/Integrations/Divi/BlockTree/ tests/Unit/Integrations/Divi/BlockTree/ tests/Fixtures/divi/ tests/Integration/divi-convert-fixture.php tests/Integration/divi-export-page.php +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi BlockTree – byte-exact parser/serializer with round-trip fixtures + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- + +### Task 3: Pfad-IDs, Attributpfade und Outline + +Spec §5.3: `lit/divi-outline` liefert Pfad-IDs (`s0`, `s0.r1`, `s0.r1.c0.m2`), Modultyp, Admin-Label, Textauszug und Preset-Namen. `NodeIndex` vergibt die IDs (Buchstaben je Rolle, gezählt je Elternknoten; `divi/placeholder` transparent; opake und fremde Blöcke werden nicht betreten). `AttrMap` adressiert Divi-Attribute über Punktpfade; der Wert eines Pfads ist eine Breakpoint-Map (`desktop`/`tablet`/`phone`/… → `value`/`hover`/`sticky`). `flatten()` ist die Vollform von `lit-divi-node-get` (Spec §5.4). Alles reines PHP. + +**Files:** +- Create: `src/Integrations/Divi/BlockTree/{NodeIndex,AttrMap,NodeSummary,Outline}.php` +- Test: `tests/Unit/Integrations/Divi/BlockTree/NodeIndexTest.php`, `tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php` + +**Interfaces:** +- Consumes: `Document`, `Block`, `Container`, `Parser` (Task 2). +- Produces: + - `NodeIndex`: `ID_PATTERN = '/^[srcmgx]\d+(\.[srcmgx]\d+)*$/'`; `static build(Document $d): self`; `static letter(Block $b): string`; `root(): Container` (erster `divi/placeholder`, sonst das Dokument); `find(string $id): ?Block`; `idOf(Block $b): ?string`; `parentOf(Block $b): ?Container`; `parentId(Block $b): ?string`; `entries(): list` (Dokumentreihenfolge). + - `AttrMap`: `BREAKPOINTS = ['desktop','tablet','phone','tabletWide','phoneWide','widescreen','ultraWide']`; `META_KEYS = ['builderVersion','modulePreset','groupPreset','globalModule','globalParent','localAttrs']`; `static flatten(array $attrs): array` (Pfad → Breakpoint-Map, sortiert); `static isBreakpointMap(mixed $v): bool`; `static get(array $attrs, string $path): mixed`; `static desktop(array $attrs, string $path): mixed` (Wert `desktop.value`); `static set(array $attrs, string $path, mixed $v): array`; `static remove(array $attrs, string $path): array` (leere Eltern werden entfernt). + - `NodeSummary`: `static label(Block): string`, `static excerpt(Block, int $max = 60): string`, `static presetIds(Block): list`, `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 $presetNames = [])`; `rows(NodeIndex $index, ?string $rootId = null, ?int $maxDepth = null): list, children: int, editable: bool, depth: int}>`; `text(array $rows): string`. + +- [ ] **Step 1: Failing Tests** + +`tests/Unit/Integrations/Divi/BlockTree/NodeIndexTest.php`: +```php +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(''); + $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 +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 + */ + private array $byId = []; + + /** @var array spl_object_id(block) => id */ + private array $ids = []; + + /** @var array spl_object_id(block) => container holding it */ + private array $parents = []; + + /** @var list */ + 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 In document order. */ + public function entries(): array + { + return $this->entries; + } + + /** @param array $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 + $attrs + * @return array 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 $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 $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 $attrs + * @return array + */ + 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 $attrs + * @return array + */ + 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 $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 +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 */ + 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 + $presetNames Preset ID => name. */ + public function __construct(private array $presetNames = []) + { + } + + /** + * @return list, 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> $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, children: int, editable: bool, depth: int} + */ + private function row(Block $block, string $id, int $depth): array + { + $editable = $block->isDivi() && ! $block->isOpaque(); + + return [ + 'id' => $id, + 'type' => $block->isDivi() ? $block->shortName() : $block->name(), + 'label' => NodeSummary::label($block), + 'excerpt' => NodeSummary::excerpt($block), + 'info' => $editable ? NodeSummary::info($block) : '', + 'presets' => array_map( + fn (string $presetId): string => $this->presetNames[$presetId] ?? $presetId, + NodeSummary::presetIds($block) + ), + 'children' => $editable ? count($block->children()) : 0, + 'editable' => $editable, + 'depth' => $depth, + ]; + } +} +``` + +- [ ] **Step 4: Tests und Lint (grün)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter BlockTree && composer lint` +Expected: PASS, phpcs ohne Meldungen. + +- [ ] **Step 5: Commit** + +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP +git add src/Integrations/Divi/BlockTree/ tests/Unit/Integrations/Divi/BlockTree/ +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi path IDs, attribute paths and outline + +Co-Authored-By: Claude Opus 5.5 (1M context) +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 (`.innerContent`, `..`), 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//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` (sortiert); `static suggest(string $path, list $known, int $max = 5): list`. + - `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`; `title(string): string`; `category(string): string` (`module`/`structure`/`child-module`); `childrenOf(string): list` (childrenName + childModuleName); `paths(string): list`; `isKnownPath(string $name, string $path): bool`; `suggestions(string $name, string $path): list`; `summary(): list`. + - `ShortformMap` (Kurzname ohne `divi/`): `static types(): list`; `static has(string): bool`; `static fields(string): array, with?: array}>`; `static childType(string): ?string`; `static primary(string): ?string`; `static values(string $type, array $attrs): array` (aktuelle Desktop-Werte ohne Medienfelder). Arten: `text, html, code, url, media, enum, onoff, level, number, ids, color`. + - `DiviModules` (nicht final): `static isAvailable(): bool`; `catalog(): ModuleCatalog` (einmal je Anfrage); `builderVersion(): string` (`ET_BUILDER_PRODUCT_VERSION`, Fallback `5.2.1`). + - `DiviCatalogAbility(DiviModules $modules)` – `lit/divi-catalog`, direkt, readonly; `CODE_NOTE`; Input `{module?}`; Output `{modules?: list, module?: {name, title, category, children, item_type, shortform, paths}, notes}`. + - `DiviIntegration(ContentSchema $schema)` – `id()` = `divi`, `isAvailable()` = `DiviModules::isAvailable()`. + +- [ ] **Step 1: Echte Modul-Metadaten als Fixture erzeugen (DDEV)** + +`tests/Integration/divi-metadata-fixture.php`: +```php + $definition) { + $attributes[$attrName] = ['settings' => $definition['settings'] ?? []]; + } + + $metadata[] = array_filter([ + 'name' => $module['name'], + 'title' => $module['title'] ?? '', + 'category' => $module['category'] ?? '', + 'childrenName' => $module['childrenName'] ?? null, + 'childModuleName' => $module['childModuleName'] ?? null, + 'attributes' => $attributes, + ], static fn ($value): bool => $value !== null); + $defaults[$module['name']] = ModuleRegistration::get_default_attrs($module['name'], 'default'); +} + +$file = dirname(__DIR__) . '/Fixtures/divi/metadata-sample.json'; +$json = wp_json_encode( + ['divi' => ET_BUILDER_PRODUCT_VERSION, 'metadata' => $metadata, 'defaults' => $defaults], + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE +); +if (! is_string($json) || file_put_contents($file, $json . "\n") === false) { + WP_CLI::error('Cannot write ' . $file); +} +WP_CLI::success(sprintf('metadata-sample.json: %d modules, %d bytes', count($metadata), strlen($json))); +``` + +Run: +```bash +cd /home/janniclas/Projekte/WordPress +timeout 300 ddev exec wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-metadata-fixture.php +``` +Expected: `Success: metadata-sample.json: 13 modules, … bytes` (ca. 190 kB). Die Datei nicht von Hand ändern. + +- [ ] **Step 2: Failing Tests** + +`tests/Unit/Integrations/Divi/Schema/ModuleCatalogTest.php`: +```php + (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 +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 +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 + 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 $attributes Metadata "attributes" of one module. + * @param array $defaults Its default attributes. + * @return list 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 $known + * @return list + */ + 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 $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 $settings + * @param array $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 $attrs + * @param array $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 +> Full name => metadata. */ + private array $modules = []; + + /** @var array> */ + private array $paths = []; + + /** @var callable(string): array */ + private $defaults; + + /** + * @param array $metadata Divi's metadata list (keys are ignored). + * @param (callable(string): array)|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 */ + 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 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 */ + 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 */ + public function suggestions(string $name, string $path): array + { + return AttrPathIndex::suggest($path, $this->paths($name)); + } + + /** @return list}> */ + 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 + where Divi 5 keeps + * the value. Paths come from Divi's conversion outlines (…/module-library/src/components/ + * /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>}>|null */ + private static ?array $modules = null; + + /** @return list 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> */ + 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 $attrs + * @return array + */ + 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 $extra keys, values or with. + * @return array + */ + 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> $fields + * @return array{primary: ?string, child: ?string, fields: array>} + */ + private static function module(?string $primary, array $fields, ?string $child = null): array + { + return ['primary' => $primary, 'child' => $child, 'fields' => $fields]; + } + + /** @return array>}> */ + 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 +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 + '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 +modules = new DiviModules(); + } + + public function id(): string + { + return 'divi'; + } + + public function isAvailable(): bool + { + return DiviModules::isAvailable(); + } + + public function abilities(): array + { + return [ + new DiviCatalogAbility($this->modules), + ]; + } +} +``` + +In `src/Plugin.php` ersetzen: +```php +use LIT\MCP\Integrations\Core\CoreIntegration; +``` +durch: +```php +use LIT\MCP\Integrations\Core\CoreIntegration; +use LIT\MCP\Integrations\Divi\DiviIntegration; +``` + +In `src/Plugin.php` ersetzen: +```php + new MediaIntegration($schema, $changeLog, $limits) + ); +``` +durch: +```php + new MediaIntegration($schema, $changeLog, $limits), + new DiviIntegration($schema) + ); +``` + +In `docs/guides/overview.md` ersetzen: +```markdown +Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`, +`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`, +`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`. +``` +durch: +```markdown +Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`, +`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`, +`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-divi-catalog`. +``` + +- [ ] **Step 5: Tests und Lint (grün)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test && composer lint` +Expected: alle Tests PASS (inkl. `ToolNamingTest::testOverviewListsExactlyTheDirectTools`), phpcs ohne Meldungen. + +- [ ] **Step 6: Kurzform gegen den Live-Katalog prüfen (DDEV)** + +`tests/Integration/divi-shortform-check.php`: +```php +catalog(); +$problems = []; +foreach (ShortformMap::types() as $type) { + $name = ModuleCatalog::normalize($type); + if (! $catalog->has($name)) { + $problems[] = $name . ': unknown module'; + continue; + } + + foreach (ShortformMap::fields($type) as $field => $spec) { + if (! $catalog->isKnownPath($name, (string) $spec['path'])) { + $problems[] = sprintf( + '%s.%s: unknown path %s (closest: %s)', + $type, + $field, + $spec['path'], + implode(', ', $catalog->suggestions($name, (string) $spec['path'])) + ); + } + } + + $child = ShortformMap::childType($type); + if ($child !== null && ! in_array(ModuleCatalog::normalize($child), $catalog->childrenOf($name), true)) { + $problems[] = sprintf('%s: %s is no allowed child (%s)', $type, $child, implode(', ', $catalog->childrenOf($name))); + } +} + +if ($problems !== []) { + WP_CLI::error("Short form does not match the catalog:\n" . implode("\n", $problems)); +} + +WP_CLI::success(sprintf( + '%d short-form modules match the catalog of %d modules (%.0f ms).', + count(ShortformMap::types()), + count($catalog->names()), + (microtime(true) - $started) * 1000 +)); +``` + +Run: +```bash +cd /home/janniclas/Projekte/WordPress +timeout 300 ddev exec wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-shortform-check.php +``` +Expected: `Success: 29 short-form modules match the catalog of 100 modules (… ms).` Meldet das Skript einen unbekannten Pfad, nicht die Prüfung lockern, sondern den Pfad in `ShortformMap` anhand von `conversion-outline.json` und `lit-divi-catalog` korrigieren. + +- [ ] **Step 7: Commit** + +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP +git add src/Integrations/Divi/Schema/ src/Integrations/Divi/DiviIntegration.php src/Plugin.php docs/guides/overview.md tests/Unit/Integrations/Divi/Schema/ tests/Unit/Integrations/Divi/DiviIntegrationTest.php tests/Integration/divi-metadata-fixture.php tests/Integration/divi-shortform-check.php tests/Fixtures/divi/metadata-sample.json +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi module catalog, attribute paths, short form and lit/divi-catalog + +Co-Authored-By: Claude Opus 5.5 (1M context) +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`; `variables(): list`; `presets(): list`; `fonts(): array{heading: string, body: string}`; `sizes(): array`; `colorId(string $aliasLabelOrId): ?string`; `variable(string $labelOrId): ?array`; `modulePresetId(string $fullModule, string $nameOrId): ?string`; `modulePresetNames(string $fullModule): list`; `presetNames(): array`; `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 + ['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 +allows('read')->andReturn(DesignSystemTest::sample()); + Functions\when('current_user_can')->justReturn(true); + $ability = new DiviDesignSystemAbility($reader); + + self::assertTrue($ability->permission([])); + $result = $ability->execute([]); + + self::assertSame('{{color:primary}}', $result['colors'][0]['placeholder']); + self::assertSame('{{color:Nachtblau}}', $result['colors'][2]['placeholder']); + self::assertSame('{{var:Telefon}}', $result['variables'][1]['placeholder']); + self::assertSame(['heading' => 'Open Sans', 'body' => 'Lato'], $result['fonts']); + self::assertSame('CTA primär', $result['presets'][1]['name']); + self::assertStringContainsString('{{font:heading}}', $result['notes'][0]); + self::assertTrue($ability->isDirect()); + self::assertSame(['colors', 'variables', 'fonts', 'sizes', 'presets', 'notes'], $ability->outputSchema()['required']); + } + + public function testNeedsEditPosts(): void + { + Functions\when('current_user_can')->justReturn(false); + + $result = (new DiviDesignSystemAbility(Mockery::mock(DesignSystemReader::class)))->permission([]); + + self::assertSame('lit_forbidden', $result->get_error_code()); + } +} +``` + +In `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` ersetzen: +```php + 'lit/divi-catalog', + ]; +``` +durch: +```php + 'lit/divi-catalog', + 'lit/divi-design-system', + ]; +``` + +- [ ] **Step 2: Test laufen lassen (rot)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'DesignSystemTest|DiviDesignSystemAbilityTest|DiviIntegrationTest'` +Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\Design\DesignSystem" not found`. + +- [ ] **Step 3: Implementierung** + +`src/Integrations/Divi/Design/DesignSystem.php`: +```php + '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 $colors + * @param list $variables + * @param list $presets + * @param array{heading: string, body: string} $fonts + * @param array $sizes + */ + public function __construct( + private array $colors, + private array $variables, + private array $presets, + private array $fonts, + private array $sizes + ) { + } + + /** + * @param array $colors GlobalData::get_global_colors() + * @param array $variables GlobalData::get_global_variables() + * @param array $presets GlobalPreset::get_data() + * @param array $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 */ + public function colors(): array + { + return $this->colors; + } + + /** @return list */ + public function variables(): array + { + return $this->variables; + } + + /** @return list */ + public function presets(): array + { + return $this->presets; + } + + /** @return array{heading: string, body: string} */ + public function fonts(): array + { + return $this->fonts; + } + + /** @return array */ + 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 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 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 $data GlobalPreset::get_data(): + * {module|group: {name: {default, items: {id: item}}}} + * @return list + */ + 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 +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 + '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": "".', + 'Feste Farben oder Schriften nur verwenden, wenn der Nutzer es ausdrücklich möchte.', + ], + ]; + } +} +``` + +In `src/Integrations/Divi/DiviIntegration.php` ersetzen: +```php +use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility; +``` +durch: +```php +use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; +use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility; +use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility; +``` + +In `src/Integrations/Divi/DiviIntegration.php` ersetzen: +```php + private DiviModules $modules; + + public function __construct(private ContentSchema $schema) + { + $this->modules = new DiviModules(); + } +``` +durch: +```php + private DiviModules $modules; + private DesignSystemReader $design; + + public function __construct(private ContentSchema $schema) + { + $this->modules = new DiviModules(); + $this->design = new DesignSystemReader(); + } +``` + +In `src/Integrations/Divi/DiviIntegration.php` ersetzen: +```php + new DiviCatalogAbility($this->modules), + ]; +``` +durch: +```php + new DiviCatalogAbility($this->modules), + new DiviDesignSystemAbility($this->design), + ]; +``` + +In `docs/guides/overview.md` ersetzen: +```markdown +Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`, +`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`, +`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-divi-catalog`. +``` +durch: +```markdown +Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`, +`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`, +`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-divi-catalog`, `lit-divi-design-system`. +``` + +- [ ] **Step 4: Tests und Lint (grün)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test && composer lint` +Expected: PASS, phpcs ohne Meldungen. + +- [ ] **Step 5: Commit** + +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP +git add src/Integrations/Divi/Design/ src/Integrations/Divi/DiviIntegration.php docs/guides/overview.md tests/Unit/Integrations/Divi/Design/ tests/Unit/Integrations/Divi/DiviIntegrationTest.php +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: read the Divi design system live – lit/divi-design-system + +Co-Authored-By: Claude Opus 5.5 (1M context) +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 `'); + // 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("\n

Gutenberg

\n"); + $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 +allows('read')->andReturn(DesignSystemTest::sample()); + + return $reader; + } + + public function testOutlineListsNodesWithTreeRevAndPostRev(): void + { + $content = DiviPageLoaderTest::fixture('synthetic-basic.html'); + DiviPageLoaderTest::givenPost($content); + $ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); + + self::assertTrue($ability->permission(['post_id' => 7])); + $result = $ability->execute(['post_id' => 7]); + + self::assertSame(substr(hash('sha256', $content), 0, 12), $result['tree_rev']); + self::assertSame(12, strlen($result['rev'])); + self::assertNotSame($result['tree_rev'], $result['rev']); + self::assertCount(16, $result['nodes']); + self::assertStringStartsWith('s0 section "Hero"', $result['outline']); + self::assertSame([], $result['hints']); + self::assertTrue($ability->isDirect()); + self::assertTrue($ability->annotations()['readonly']); + } + + public function testOutlineOfASubtreeAndUnknownNode(): void + { + DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html')); + $ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); + + $result = $ability->execute(['post_id' => 7, 'id' => 's1', 'depth' => 1]); + self::assertSame(['s1', 's1.r0'], array_column($result['nodes'], 'id')); + + $missing = $ability->execute(['post_id' => 7, 'id' => 's7']); + self::assertSame('lit_not_found', $missing->get_error_code()); + self::assertStringContainsString('tree_rev', $missing->get_error_message()); + } + + public function testOutlineHintsForEmptyAndReadOnlyContent(): void + { + DiviPageLoaderTest::givenPost(''); + $ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); + self::assertSame(['Die Seite hat noch keinen Divi-Inhalt.'], $ability->execute(['post_id' => 7])['hints']); + + DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-mixed.html')); + $hints = $ability->execute(['post_id' => 7])['hints']; + self::assertStringContainsString('nur lesbar', $hints[0]); + } + + public function testNodeGetReturnsFullFormShortformAndPresets(): void + { + DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html')); + $ability = new DiviNodeGetAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); + + $heading = $ability->execute(['post_id' => 7, 'id' => 's0.r0.c1.m0']); + + self::assertSame('divi/heading', $heading['type']); + self::assertSame('s0.r0.c1', $heading['parent']); + self::assertSame( + ['desktop' => ['value' => ['headingLevel' => 'h1', 'color' => '#1a1a1a']], 'phone' => ['value' => ['size' => '28px']]], + $heading['attrs']['title.decoration.font.font'] + ); + self::assertSame(['text' => 'Willkommen bei Müller & Söhne', 'level' => 'h1'], $heading['shortform']); + self::assertSame([['id' => 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', 'name' => '']], $heading['presets']); + self::assertSame('5.2.1', $heading['builder_version']); + + $accordion = $ability->execute(['post_id' => 7, 'id' => 's1.r0.c1.m0']); + self::assertSame(['s1.r0.c1.m0.m0', 's1.r0.c1.m0.m1'], $accordion['children']); + self::assertInstanceOf(stdClass::class, $accordion['shortform']); + } + + public function testNodeGetValidatesTheId(): void + { + DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html')); + $ability = new DiviNodeGetAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); + + self::assertSame('lit_validation_failed', $ability->execute(['post_id' => 7, 'id' => 'hero'])->get_error_code()); + self::assertSame('lit_not_found', $ability->execute(['post_id' => 7, 'id' => 's5.r0'])->get_error_code()); + } +} +``` + +In `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` ersetzen: +```php + private const EXPECTED = [ + 'lit/divi-catalog', + 'lit/divi-design-system', + ]; +``` +durch: +```php + private const EXPECTED = [ + 'lit/divi-outline', + 'lit/divi-node-get', + 'lit/divi-catalog', + 'lit/divi-design-system', + ]; +``` + +- [ ] **Step 2: Test laufen lassen (rot)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'DiviPageLoaderTest|DiviReadAbilitiesTest|DiviIntegrationTest'` +Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\Pages\DiviPageLoader" not found`. + +- [ ] **Step 3: Implementierung** + +`src/Integrations/Divi/Pages/DiviPage.php`: +```php +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, '']); + $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('')->andReturnUsing( + static function () use ($post): string { + echo '

echoed

'; + + return $GLOBALS['post'] === $post ? '
' : 'wrong post'; + } + ); + + $html = (new PageRenderer())->render($post); + + self::assertSame('

echoed

', $html); + self::assertSame($previous, $GLOBALS['post']); + self::assertSame([7, 1], $setup); + } + + public function testRestoresEvenWhenRenderingThrows(): void + { + Functions\when('setup_postdata')->justReturn(true); + Functions\expect('wp_reset_postdata')->once(); + Filters\expectApplied('the_content')->once()->andReturnUsing(static function (): string { + throw new RuntimeException('module crashed'); + }); + + try { + (new PageRenderer())->render(new WP_Post(['ID' => 7])); + self::fail('exception expected'); + } catch (RuntimeException) { + self::assertArrayHasKey('post', $GLOBALS); + self::assertNull($GLOBALS['post']); + } + } +} +``` + +In `tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php` ersetzen: +```php + 'lit/wp-settings-update', + ]; +``` +durch: +```php + 'lit/wp-settings-update', + 'lit/page-render-summary', + ]; +``` + +- [ ] **Step 2: Test laufen lassen (rot)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'PageRenderSummaryAbilityTest|PageRendererTest|WordPressIntegrationTest'` +Expected: FAIL – `Class "LIT\MCP\Integrations\WordPress\Render\RenderSummary" not found`. + +- [ ] **Step 3: Implementierung** + +`src/Integrations/WordPress/Render/RenderSummary.php`: +```php +, texts: list, + * links: list, images: list, + * warnings: list} + */ + public static function fromHtml(string $html, int $max = 50): array + { + $dom = new DOMDocument(); + $previous = libxml_use_internal_errors(true); + $dom->loadHTML( + '' . $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 $headings + * @param list $images + * @param list $links + * @return list + */ + 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 */ + 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 +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 + 'object', + 'properties' => ['post_id' => ['type' => 'integer', 'minimum' => 1]], + 'required' => ['post_id'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'post_id' => ['type' => 'integer'], + 'url' => ['type' => 'string'], + 'headings' => ['type' => 'array', 'items' => ['type' => 'object']], + 'texts' => ['type' => 'array', 'items' => ['type' => 'string']], + 'links' => ['type' => 'array', 'items' => ['type' => 'object']], + 'images' => ['type' => 'array', 'items' => ['type' => 'object']], + 'warnings' => ['type' => 'array', 'items' => ['type' => 'string']], + 'notes' => ['type' => 'array', 'items' => ['type' => 'string']], + ], + 'required' => ['post_id', 'headings', 'texts', 'links', 'images', 'warnings', 'notes'], + ]; + } + + public function annotations(): array + { + return ['readonly' => true, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return true; + } + + public function permission(array $input): bool|WP_Error + { + $post = PostGuard::editable($input['post_id'] ?? null, $this->schema, 'post_id', true); + + return $post instanceof WP_Error ? $post : true; + } + + public function execute(array $input): mixed + { + $post = PostGuard::editable($input['post_id'] ?? null, $this->schema, 'post_id', true); + if ($post instanceof WP_Error) { + return $post; + } + + $notes = ['Nur der Inhaltsbereich; Header, Footer und Theme-Builder-Vorlagen fehlen.']; + if ((string) $post->post_password !== '') { + $notes[] = 'Der Eintrag ist passwortgeschützt; Besucher sehen zuerst das Passwortformular.'; + } + + return array_merge( + ['post_id' => (int) $post->ID, 'url' => (string) get_permalink($post)], + RenderSummary::fromHtml($this->renderer->render($post)), + ['notes' => $notes] + ); + } +} +``` + +In `src/Integrations/WordPress/WordPressIntegration.php` ersetzen: +```php +use LIT\MCP\Integrations\WordPress\Menus\MenuWriter; +``` +durch: +```php +use LIT\MCP\Integrations\WordPress\Menus\MenuWriter; +use LIT\MCP\Integrations\WordPress\Render\PageRenderer; +use LIT\MCP\Integrations\WordPress\Render\PageRenderSummaryAbility; +``` + +In `src/Integrations/WordPress/WordPressIntegration.php` ersetzen: +```php + * WordPress basics (spec §6): content, status, copies, revisions, menus, settings. +``` +durch: +```php + * WordPress basics (spec §6): content, status, copies, revisions, menus, settings, and the + * rendered-content summary lit/page-render-summary (spec §5.7 – useful for every entry). +``` + +In `src/Integrations/WordPress/WordPressIntegration.php` ersetzen: +```php + new WpSettingsUpdateAbility($whitelist, $this->log), + ]; +``` +durch: +```php + new WpSettingsUpdateAbility($whitelist, $this->log), + new PageRenderSummaryAbility($this->schema, new PageRenderer()), + ]; +``` + +In `docs/guides/overview.md` ersetzen: +```markdown +Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`, +`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`, +`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-divi-outline`, +`lit-divi-node-get`, `lit-divi-catalog`, `lit-divi-design-system`. +``` +durch: +```markdown +Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`, +`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`, +`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-page-render-summary`, +`lit-divi-outline`, `lit-divi-node-get`, `lit-divi-catalog`, `lit-divi-design-system`. +``` + +- [ ] **Step 4: Tests und Lint (grün)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test && composer lint` +Expected: PASS, phpcs ohne Meldungen. + +- [ ] **Step 5: Commit** + +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP +git add src/Integrations/WordPress/Render/ src/Integrations/WordPress/WordPressIntegration.php docs/guides/overview.md tests/Unit/Integrations/WordPress/Render/ tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: lit/page-render-summary – rendered content as structured excerpt + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- + +### Task 8: Hinweise, Guides, Serveranweisungen, Ende-zu-Ende-Test und Abnahme + +Die bisherigen Divi-Hinweise („Divi-Werkzeuge folgen in einer späteren Version…“) zeigen jetzt auf die Lese-Tools – aber nur, wenn die Divi-Integration verfügbar ist; ohne Divi 5 sagt der Hinweis das ehrlich (Review Focus 4). `PostPresenter::DIVI_HINT` wird zur Methode `diviHint()`, weil der Text von der Verfügbarkeit abhängt. Die Serveranweisungen bekommen eine Divi-Regel (Spec §9.1), wenn `divi` aktiv ist. Drei Guides (Spec §9.2: `divi-structure`, `divi-attrs`, `divi-design-system`; `divi-shortform` und `divi-placeholders` folgen mit den Schreib-Tools in 2b). Ein Ende-zu-Ende-Skript prüft alle Lese-Tools über den OAuth-Endpunkt gegen echtes Divi. + +**Files:** +- Modify: `src/Integrations/WordPress/Content/PostPresenter.php`, `src/Integrations/WordPress/Content/ContentUpdateAbility.php`, `src/Integrations/WordPress/Revisions/RevisionsListAbility.php`, `src/Guides/InstructionsBuilder.php`, `docs/guides/overview.md`, `docs/guides/content-types.md` +- Create: `docs/guides/divi-structure.md`, `docs/guides/divi-attrs.md`, `docs/guides/divi-design-system.md`, `tests/Integration/divi-read-smoke.sh` +- Test: `tests/Unit/Integrations/WordPress/Content/PostPresenterTest.php`, `tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php`, `tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php`, `tests/Unit/Guides/InstructionsBuilderTest.php` + +**Interfaces:** +- Consumes: `DiviModules::isAvailable()` (Task 4); die Lese-Tools (Tasks 4–7); `tests/Integration/lib.sh` (`connect`, `rpc`, `tool`, `error_code`, `fail`, `pass`). +- Produces: + - `PostPresenter::DIVI_NOTE` (Text geändert), `PostPresenter::DIVI_INACTIVE_NOTE`, `static diviNote(?bool $toolsAvailable = null): string`, `static diviHint(?bool $toolsAvailable = null): string`; **entfernt:** `PostPresenter::DIVI_HINT` (einzige Nutzer: `PostPresenter::full()`, `RevisionsListAbility`, deren Tests). + - Serveranweisungen mit Zeile `- Divi-Seiten: vor Änderungen lit-divi-outline und lit-divi-design-system lesen; …`, wenn eine verfügbare Integration die ID `divi` hat. + +- [ ] **Step 1: Failing Tests** + +In `tests/Unit/Integrations/WordPress/Content/PostPresenterTest.php` (in `testDiviContentIsWithheldAndPointsToTheDiviOutline`) ersetzen: +```php + self::assertStringNotContainsString('lit-divi', $full['hints'][0]); + self::assertStringContainsString('Divi-Werkzeuge folgen in einer späteren Version', $full['hints'][0]); +``` +durch: +```php + self::assertSame([PostPresenter::diviHint(false)], $full['hints']); +``` + +In `tests/Unit/Integrations/WordPress/Content/PostPresenterTest.php` (neuer Test davor) ersetzen: +```php + public function testPasswordAndProtectedReadonlyMetaFieldsAreOmitted(): void +``` +durch: +```php + public function testDiviHintNamesTheDiviToolsOnlyWhenDiviIsActive(): void + { + self::assertStringContainsString('lit-divi-outline', PostPresenter::diviHint(true)); + self::assertStringContainsString('lit-content-update', PostPresenter::diviHint(true)); + self::assertStringNotContainsString('lit-divi', PostPresenter::diviHint(false)); + self::assertStringContainsString(PostPresenter::DIVI_INACTIVE_NOTE, PostPresenter::diviHint()); + } + + public function testPasswordAndProtectedReadonlyMetaFieldsAreOmitted(): void +``` + +In `tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php` (in `testRawContentOnDiviPagesIsUnsupported`) ersetzen: +```php + self::assertStringNotContainsString('lit-divi', $result->get_error_message()); + self::assertStringContainsString('Divi-Werkzeuge folgen in einer späteren Version; bis dahin im Divi-Builder ' + . 'bearbeiten.', $result->get_error_data()['hint']); +``` +durch: +```php + self::assertStringContainsString(PostPresenter::DIVI_INACTIVE_NOTE, $result->get_error_data()['hint']); +``` + +In `tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php` (Import) ersetzen: +```php +use LIT\MCP\Integrations\WordPress\Content\ContentUpdateAbility; +``` +durch: +```php +use LIT\MCP\Integrations\WordPress\Content\ContentUpdateAbility; +use LIT\MCP\Integrations\WordPress\Content\PostPresenter; +``` + +In `tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php` ersetzen: +```php + self::assertSame([PostPresenter::DIVI_HINT], $result['hints']); +``` +durch: +```php + self::assertSame([PostPresenter::diviHint()], $result['hints']); +``` + +In `tests/Unit/Guides/InstructionsBuilderTest.php` (neuer Test davor) ersetzen: +```php + public function testListsPostTypesWhenAProviderIsGiven(): void +``` +durch: +```php + public function testAddsTheDiviRuleOnlyWhenDiviIsActive(): void + { + $divi = Mockery::mock(Integration::class); + $divi->allows('id')->andReturn('divi'); + $divi->allows('isAvailable')->andReturn(true); + $divi->allows('abilities')->andReturn([$this->ability('lit/divi-outline', true)]); + $core = $this->coreWith($this->ability('lit/guide', true)); + + $with = (new InstructionsBuilder(new IntegrationRegistry($core, $divi)))->build(); + $without = (new InstructionsBuilder(new IntegrationRegistry($core)))->build(); + + self::assertStringContainsString('lit-divi-outline und lit-divi-design-system lesen', $with); + self::assertStringNotContainsString('lit-divi-outline und', $without); + self::assertLessThan(2000, strlen($with)); + } + + public function testListsPostTypesWhenAProviderIsGiven(): void +``` + +- [ ] **Step 2: Test laufen lassen (rot)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'PostPresenterTest|ContentUpdateAbilityTest|RevisionsListAbilityTest|InstructionsBuilderTest'` +Expected: FAIL – `Call to undefined method …PostPresenter::diviHint()` bzw. fehlende Divi-Regel. + +- [ ] **Step 3: Implementierung – Hinweise** + +In `src/Integrations/WordPress/Content/PostPresenter.php` ersetzen: +```php +use LIT\MCP\Abilities\Rev; +``` +durch: +```php +use LIT\MCP\Abilities\Rev; +use LIT\MCP\Integrations\Divi\Schema\DiviModules; +``` + +In `src/Integrations/WordPress/Content/PostPresenter.php` ersetzen: +```php + public const DIVI_NOTE = 'Divi-Werkzeuge folgen in einer späteren Version; bis dahin im Divi-Builder bearbeiten.'; + + public const DIVI_HINT = 'Dieser Inhalt ist mit Divi gebaut; content wird deshalb nicht ausgegeben. ' + . self::DIVI_NOTE . ' Titel, Auszug, Felder und Begriffe sind über lit/content-update ' + . '(direkt: lit-content-update) änderbar.'; +``` +durch: +```php + /** How Divi content is read and changed with the Divi tools. */ + public const DIVI_NOTE = 'Den Aufbau mit lit/divi-outline (direkt: lit-divi-outline) lesen, einzelne Knoten mit ' + . 'lit/divi-node-get (direkt: lit-divi-node-get); ändern vorerst im Divi-Builder.'; + + /** Divi content on a site where the Divi 5 builder is not loaded (no Divi tools registered). */ + public const DIVI_INACTIVE_NOTE = 'Der Divi-5-Builder ist auf dieser Website nicht aktiv, daher gibt es keine ' + . 'Divi-Werkzeuge; Divi-Inhalte nur im Divi-Builder bearbeiten.'; + + /** $toolsAvailable defaults to whether the Divi integration is available (Divi 5 loaded). */ + public static function diviNote(?bool $toolsAvailable = null): string + { + return ($toolsAvailable ?? DiviModules::isAvailable()) ? self::DIVI_NOTE : self::DIVI_INACTIVE_NOTE; + } + + public static function diviHint(?bool $toolsAvailable = null): string + { + return 'Dieser Inhalt ist mit Divi gebaut; content wird deshalb nicht ausgegeben. ' + . self::diviNote($toolsAvailable) . ' Titel, Auszug, Felder und Begriffe sind über lit/content-update ' + . '(direkt: lit-content-update) änderbar.'; + } +``` + +In `src/Integrations/WordPress/Content/PostPresenter.php` ersetzen: +```php + 'hints' => $divi ? [self::DIVI_HINT] : [], +``` +durch: +```php + 'hints' => $divi ? [self::diviHint()] : [], +``` + +In `src/Integrations/WordPress/Content/ContentUpdateAbility.php` ersetzen: +```php + PostPresenter::DIVI_NOTE . ' Titel, Auszug, Felder und Begriffe hier ohne content ändern.' +``` +durch: +```php + PostPresenter::diviNote() . ' Titel, Auszug, Felder und Begriffe hier ohne content ändern.' +``` + +In `src/Integrations/WordPress/Revisions/RevisionsListAbility.php` ersetzen: +```php + * PostPresenter::DIVI_HINT once when any item is flagged. +``` +durch: +```php + * PostPresenter::diviHint() once when any item is flagged. +``` + +In `src/Integrations/WordPress/Revisions/RevisionsListAbility.php` ersetzen: +```php + 'hints' => in_array(true, array_column($items, 'divi'), true) ? [PostPresenter::DIVI_HINT] : [], +``` +durch: +```php + 'hints' => in_array(true, array_column($items, 'divi'), true) ? [PostPresenter::diviHint()] : [], +``` + +In `src/Guides/InstructionsBuilder.php` ersetzen: +```php + return implode("\n", array_merge($lines, [ + 'Regeln:', +``` +durch: +```php + $divi = str_contains(', ' . $active . ',', ', divi,') + ? ['- Divi-Seiten: vor Änderungen lit-divi-outline und lit-divi-design-system lesen; Aufbau: lit-guide ' + . 'topic "divi-structure".'] + : []; + + return implode("\n", array_merge($lines, [ + 'Regeln:', +``` + +In `src/Guides/InstructionsBuilder.php` ersetzen: +```php + '- Fehler enthalten einen hint – ihm folgen, statt blind zu wiederholen.', + ])); +``` +durch: +```php + '- Fehler enthalten einen hint – ihm folgen, statt blind zu wiederholen.', + ], $divi)); +``` + +- [ ] **Step 4: Implementierung – Guides** + +`docs/guides/divi-structure.md`: +```markdown +# Divi-Seiten: Aufbau lesen + +Divi 5 speichert eine Seite als Block-Markup: Abschnitte (`section`) enthalten Zeilen (`row`), Zeilen +Spalten (`column`), Spalten Module (`text`, `heading`, `button`, …). Manche Module haben Kind-Module +(`accordion` → `accordion-item`). Die Divi-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist. + +## Seitenbaum + +- `lit-divi-outline` mit `post_id` liefert jeden Knoten mit **Pfad-ID**, Modultyp, Admin-Label, Textauszug, + Layout-Info (z. B. `Spalten 8_24+16_24`) und Preset-Namen – als kompakten Text (`outline`) und als Liste + (`nodes`). Mit `id` nur ein Teilbaum, mit `depth` begrenzte Tiefe. +- Pfad-IDs: `s0` = erster Abschnitt, `s0.r1` = zweite Zeile darin, `s0.r1.c0` = erste Spalte, + `s0.r1.c0.m2` = drittes Modul, `…m2.m0` = erstes Kind-Modul. `g` = globales Modul, `x` = fremder Block + (z. B. aus dem Block-Editor). Gezählt wird je Buchstabe innerhalb des Elternknotens. +- `lit-divi-node-get` mit `post_id` und `id` liefert alle Einstellungen eines Knotens (siehe `divi-attrs`), + die aktuellen Kurzform-Werte (`shortform`) und die IDs der Kinder. + +## Stand und Sperre + +- `tree_rev` ist der Stand des Seiteninhalts. Pfad-IDs gelten nur für diesen Stand – ändert jemand die Seite + (auch im Divi-Builder), verschieben sich die IDs. Divi-Änderungen verlangen deshalb immer die `tree_rev` + des letzten Lesevorgangs. +- `rev` ist davon getrennt: der Stand des ganzen Eintrags (Titel, Auszug, Status …) für `lit-content-update`. + +## Was nicht bearbeitet wird + +- Knoten mit „nur lesbar“ (globale Module `g…`, fremde Blöcke `x…`) ändern die Divi-Werkzeuge nicht. +- Divi-4-Inhalt (Shortcodes `[et_pb_…]`) wird nicht gelesen oder geändert: Seite einmal im Divi-Builder + öffnen und speichern, Divi 5 wandelt sie dabei um. +- Ist das Markup beschädigt, lehnen die Werkzeuge ab (`unsupported`); die Seite im Divi-Builder öffnen und + speichern. + +## Ergebnis prüfen + +`lit-page-render-summary` rendert den Inhaltsbereich wie im Frontend und listet Überschriften, Texte, Links +und Bilder samt Warnungen (Sprünge in der Überschriftenhierarchie, fehlende Alt-Texte, Links ohne Ziel). +Header, Footer und Theme-Builder-Vorlagen sind nicht enthalten. +``` + +`docs/guides/divi-attrs.md`: +````markdown +# Divi-Einstellungen (Vollform) + +Jedes Divi-5-Modul speichert seine Einstellungen unter **Attributpfaden**, z. B. + +- `title.innerContent` – Text der Überschrift, +- `title.decoration.font.font` – Schrift der Überschrift (Farbe, Größe, Ebene `headingLevel`), +- `button.innerContent` – Beschriftung und Link eines Buttons (`text`, `linkUrl`, `linkTarget`), +- `module.decoration.background` – Hintergrund, `module.decoration.spacing` – Innen-/Außenabstand, +- `module.meta.adminLabel` – Admin-Label im Divi-Builder. + +Der Wert eines Pfads ist eine Zuordnung **Breakpoint → Zustand → Wert**: + +```json +{ "desktop": { "value": { "color": "#1a1a1a", "headingLevel": "h2" }, "hover": { "color": "#000000" } }, + "phone": { "value": { "size": "28px" } } } +``` + +Breakpoints: `desktop`, `tablet`, `phone` (je nach Website auch `tabletWide`, `phoneWide`, `widescreen`, +`ultraWide`); Zustände: `value`, `hover`, `sticky`. + +## Pfade finden + +- `lit-divi-catalog` mit `module` (z. B. `"heading"`) listet alle gültigen Pfade dieses Moduls auf dieser + Website (live aus Divi) und die Kurzform-Felder mit ihrem Pfad. +- `lit-divi-node-get` zeigt die gesetzten Werte eines vorhandenen Knotens in genau dieser Form (`attrs`). +- Schrift-Gruppen liegen eine Ebene tiefer: `…decoration.font.font`, `…decoration.bodyFont.body.font`, + `…decoration.headingFont.h2.font`, `module.advanced.text.text`. +- Werte wie `$variable({…})$` sind Verweise auf globale Farben, Variablen oder dynamische Inhalte – nicht + durch feste Werte ersetzen (siehe `divi-design-system`). +```` + +`docs/guides/divi-design-system.md`: +```markdown +# Divi-Designsystem + +`lit-divi-design-system` liest das Designsystem der Website live aus Divi: + +- **Globale Farben** (`colors`): ID (`gcid-…`), Name, Wert und der Platzhalter für neue Inhalte, z. B. + `{{color:primary}}` für die Primärfarbe oder `{{color:Nachtblau}}` für eine eigene globale Farbe. Die fünf + Theme-Farben heißen `primary`, `secondary`, `heading`, `body` und `link`. +- **Globale Variablen** (`variables`): Zahlen, Texte, Bilder, Links (`gvid-…`), Platzhalter `{{var:}}`. +- **Theme-Schriften** (`fonts`): Überschrift und Fließtext, Platzhalter `{{font:heading}}` / `{{font:body}}`. +- **Größen** (`sizes`): Schriftgrößen, Inhaltsbreite und Spaltenabstand aus den Theme-Optionen. +- **Presets** (`presets`): Modul-Presets je Modul (`kind: module`, z. B. alle Button-Presets) und + Gruppen-Presets (`kind: group`); `default: true` markiert das Standard-Preset eines Moduls. + +Regeln: + +1. Vor dem Gestalten einer Seite das Designsystem lesen. +2. Globale Farben, Variablen und Presets vor festen Werten verwenden; feste Farben oder Schriften nur, wenn + der Nutzer es ausdrücklich möchte. +3. Gibt es für einen Zweck noch keine Farbe, Variable oder kein Preset, dem Nutzer vorschlagen, es im + Divi-Builder anzulegen, statt feste Werte zu verteilen. +``` + +In `docs/guides/overview.md` ersetzen: +```markdown +Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows` oder `media`. +``` +durch: +```markdown +Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist. + +Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows`, `media`, `divi-structure`, +`divi-attrs` oder `divi-design-system`. +``` + +In `docs/guides/content-types.md` ersetzen: +```markdown + Bei Divi-Seiten fehlt `content`; Divi-Werkzeuge folgen in einer späteren Version, bis dahin werden + Divi-Inhalte im Divi-Builder bearbeitet (Titel, Auszug, Felder und Begriffe gehen weiter über + `lit-content-update`). +``` +durch: +```markdown + Bei Divi-Seiten fehlt `content`; den Aufbau liest `lit-divi-outline` (siehe `divi-structure`), geändert + wird er vorerst im Divi-Builder (Titel, Auszug, Felder und Begriffe gehen weiter über + `lit-content-update`). +``` + +In `docs/guides/content-types.md` ersetzen: +```markdown +2. Kopie mit `lit-content-update` (Divi-Inhalte bis zu den Divi-Werkzeugen im Divi-Builder) bearbeiten und dem Nutzer die Vorschau zeigen. +``` +durch: +```markdown +2. Kopie mit `lit-content-update` (Divi-Inhalte vorerst im Divi-Builder) bearbeiten und dem Nutzer die + Vorschau zeigen; `lit-page-render-summary` fasst den gerenderten Inhalt zusammen. +``` + +- [ ] **Step 5: Tests und Lint (grün)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test && composer lint` +Expected: alle Tests PASS (inkl. `ToolNamingTest`), phpcs ohne Meldungen. + +- [ ] **Step 6: Ende-zu-Ende-Test anlegen und ausführen** + +`tests/Integration/divi-read-smoke.sh`: +```bash +#!/usr/bin/env bash +# End-to-end checks of the Etappe-2a Divi read tools through the OAuth-protected endpoint. +# Runs INSIDE the DDEV web container (Divi 5 must be the active theme): +# cd /home/janniclas/Projekte/WordPress && timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-read-smoke.sh +# Creates draft pages from tests/Fixtures/divi and deletes them again. +set -euo pipefail +cd /var/www/html +# shellcheck source=lib.sh +source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh + +ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1) +FIXTURES=$PLUGIN/tests/Fixtures/divi +CREATED_POSTS=() + +cleanup() { + for id in "${CREATED_POSTS[@]}"; do + wp post delete "$id" --force >/dev/null 2>&1 || true + done + cleanup_app_passwords_for "$ADMIN_LOGIN" +} +trap cleanup EXIT + +# Creates a draft page from a fixture file as the admin (no kses re-serialization) and prints its ID. +create_page() { # $1 = fixture file, $2 = title + LIT_FILE="$FIXTURES/$1" LIT_TITLE="$2" wp --user="$ADMIN_LOGIN" eval ' +$id = wp_insert_post(["post_type" => "page", "post_status" => "draft", "post_title" => getenv("LIT_TITLE"), + "post_content" => wp_slash((string) file_get_contents(getenv("LIT_FILE")))]); +update_post_meta($id, "_et_pb_use_builder", "on"); +update_post_meta($id, "_et_pb_use_divi_5", "on"); +echo $id;' +} + +content_hash() { # $1 = post ID -> tree_rev of the stored content + wp eval "echo substr(hash('sha256', (string) get_post($1)->post_content), 0, 12);" +} + +[ "$(wp eval 'echo get_template();')" = "Divi" ] || fail "Divi must be the active theme" +wp eval-file "$PLUGIN/tests/Integration/divi-shortform-check.php" >/dev/null || fail "short form does not match the Divi catalog" +pass "short form matches the live Divi catalog" + +PAGE_ID=$(create_page converted-landing.html "LIT Divi Lesen") +CREATED_POSTS+=("$PAGE_ID") +D4_ID=$(create_page divi4-shortcodes.html "LIT Divi 4") +CREATED_POSTS+=("$D4_ID") +BEFORE=$(content_hash "$PAGE_ID") +connect "$ADMIN_LOGIN" + +# 1. Direct read tools with readOnlyHint +LIST=$(rpc tools/list '{}') +for t in lit-divi-outline lit-divi-node-get lit-divi-catalog lit-divi-design-system lit-page-render-summary; do + echo "$LIST" | jq -e --arg t "$t" '.result.tools[] | select(.name == $t) | .annotations.readOnlyHint == true' >/dev/null \ + || fail "tools/list lacks $t with readOnlyHint" +done +pass "tools/list: Divi read tools are direct and read-only" + +# 2. Outline with tree_rev = hash of post_content (ruling P16) and path IDs +OUTLINE=$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}") +[ "$(echo "$OUTLINE" | jq -r '.tree_rev')" = "$BEFORE" ] || fail "tree_rev is not the hash of post_content: $OUTLINE" +echo "$OUTLINE" | jq -e '.rev != .tree_rev' >/dev/null || fail "rev and tree_rev must differ: $OUTLINE" +echo "$OUTLINE" | jq -e '.nodes[] | select(.id == "s1.r1.c0.m2.m0" and .type == "accordion-item")' >/dev/null \ + || fail "outline lacks the accordion item: $OUTLINE" +echo "$OUTLINE" | jq -r '.outline' | head -1 | grep -q '^s0 section "Hero"' || fail "outline text: $OUTLINE" +pass "lit-divi-outline: path IDs, tree_rev, rev" + +# 3. Node in full form plus short-form values +NODE=$(tool lit-divi-node-get "{\"post_id\":$PAGE_ID,\"id\":\"s0.r0.c0.m0\"}") +echo "$NODE" | jq -e '.type == "divi/heading" and .shortform.text == "Handwerk mit Herz"' >/dev/null || fail "node-get: $NODE" +echo "$NODE" | jq -e '.attrs["title.innerContent"].desktop.value == "Handwerk mit Herz"' >/dev/null || fail "node-get attrs: $NODE" +MISSING=$(tool lit-divi-node-get "{\"post_id\":$PAGE_ID,\"id\":\"s9\"}") +[ "$(error_code "$MISSING")" = "not_found" ] || fail "unknown node: $MISSING" +pass "lit-divi-node-get: full form, short form, unknown node" + +# 4. Catalog and design system live from Divi +CATALOG=$(tool lit-divi-catalog '{}') +echo "$CATALOG" | jq -e '(.modules | length) >= 90' >/dev/null || fail "catalog too small: $CATALOG" +BUTTON=$(tool lit-divi-catalog '{"module":"button"}') +echo "$BUTTON" | jq -e '.module.paths | index("button.innerContent") != null' >/dev/null || fail "catalog button: $BUTTON" +DESIGN=$(tool lit-divi-design-system '{}') +echo "$DESIGN" | jq -e '.colors[] | select(.id == "gcid-primary-color" and .placeholder == "{{color:primary}}")' >/dev/null \ + || fail "design system: $DESIGN" +pass "lit-divi-catalog and lit-divi-design-system read live data" + +# 5. Rendered summary +SUMMARY=$(tool lit-page-render-summary "{\"post_id\":$PAGE_ID}") +echo "$SUMMARY" | jq -e '.headings[0].text == "Handwerk mit Herz"' >/dev/null || fail "render summary: $SUMMARY" +echo "$SUMMARY" | jq -e '[.texts[] | select(contains("Seit 1987"))] | length == 1' >/dev/null || fail "render texts: $SUMMARY" +pass "lit-page-render-summary renders Divi modules" + +# 6. Hints and refusals +GET=$(tool lit-content-get "{\"post_id\":$PAGE_ID}") +echo "$GET" | jq -e '.content == null and (.hints[0] | contains("lit-divi-outline"))' >/dev/null || fail "content-get hint: $GET" +D4=$(tool lit-divi-outline "{\"post_id\":$D4_ID}") +[ "$(error_code "$D4")" = "unsupported" ] || fail "Divi 4 page must be unsupported: $D4" +pass "content-get points to the Divi tools; Divi 4 content is refused" + +# 7. Reading changed nothing +[ "$(content_hash "$PAGE_ID")" = "$BEFORE" ] || fail "reading changed post_content" +pass "post_content unchanged after reading" + +echo "ALL CHECKS PASSED" +``` + +Run: +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && chmod +x tests/Integration/divi-read-smoke.sh +cd /home/janniclas/Projekte/WordPress +timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-read-smoke.sh +timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/mcp-smoke.sh +timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/wp-basis-smoke.sh +timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/media-admin-smoke.sh +``` +Expected: alle vier Skripte enden mit `ALL CHECKS PASSED` (bekannte WARN-Zeilen aus Etappe 1 zu `/.well-known/` bzw. Internetzugang sind zulässig). + +- [ ] **Step 7: Commit** + +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP +git add src/Integrations/WordPress/Content/PostPresenter.php src/Integrations/WordPress/Content/ContentUpdateAbility.php src/Integrations/WordPress/Revisions/RevisionsListAbility.php src/Guides/InstructionsBuilder.php docs/guides tests/Integration/divi-read-smoke.sh tests/Unit/Integrations/WordPress/Content/PostPresenterTest.php tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php tests/Unit/Guides/InstructionsBuilderTest.php +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi hints point to the read tools; guides divi-structure/attrs/design-system; test: Divi read end-to-end + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +- [ ] **Step 8: Manuelle Schritte (Jan-Niclas)** + +Der ausführende Agent bereitet vor und meldet; diese Punkte erledigt Jan-Niclas selbst: +1. **Builder-Fixtures:** 2–3 Seiten im Divi Visual Builder bauen – mit globalem Modul (aus der Divi Library), Modul-Preset, globaler Farbe und Variable, Dynamic Content (Beitragstitel, ein Meta-Feld), Spezialabschnitt und verschachtelter Zeile – und je Seite exportieren: + ```bash + cd /home/janniclas/Projekte/WordPress + timeout 300 ddev exec wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-export-page.php + cd wp-content/plugins/WP-LIT-MCP && composer test + ``` + Erwartung: `Success: builder-.html …`, danach grün. Schlägt der Roundtrip fehl, ist das ein Fehler in `Parser`/`Serializer` (nie die Fixture anpassen). Fixtures ohne personenbezogene Daten mit `git add tests/Fixtures/divi/builder-*.html` und einer Commit-Nachricht `test: Divi builder fixtures` einchecken. +2. **claude.ai:** „Zeig mir den Aufbau der Startseite“ → `lit-divi-outline`; „Welche Farben und Presets hat die Website?“ → `lit-divi-design-system`; „Was sieht ein Besucher auf der Seite?“ → `lit-page-render-summary`. Die Antworten stimmen mit dem Divi-Builder überein. + +Ergebnisse als kurze Notiz an den Ausführenden zurückmelden; Abweichungen werden als Folgeaufgaben erfasst. + +--- + +## Self-Review (durchgeführt beim Schreiben) + +- **Spec-Abdeckung (Teil 2a):** §5.2 BlockTree (Parser/Serializer, Byte-Invariante, Sonderfall globale Module, reines PHP, `$variable`-Escaping) → Tasks 2–3; §5.3 Pfad-IDs, Outline, `tree_rev` (P16) → Tasks 1, 3, 6; §5.4 Katalog-Validierung der Attributpfade, Breakpoints, Kurzform-Tabelle → Task 4 (Schreiben in 2b); §5.7 `lit/divi-outline`, `lit/divi-node-get`, `lit/divi-catalog`, `lit/divi-design-system`, `lit/page-render-summary` → Tasks 4–7; §9.1 Divi-Regel in den Serveranweisungen, §9.2 Guides `divi-structure`, `divi-attrs`, `divi-design-system` → Task 8; §11.3 Schemas/Annotations → alle Ability-Tasks; §13 Fixtures (echte Builder-Seiten, Roundtrip) → Task 2 + manueller Schritt 8.1. Offen für 2b: Kurz-/Vollform schreiben, Platzhalter, Insert/Update/Move/Delete/Duplicate/Replace, Warnungen §5.5, Speicher-Pipeline §5.6, `content-create` mit Divi (§6.2), Guides `divi-shortform`, `divi-placeholders`. Nach Etappe 3 verschoben (D6): `lit/divi-dynamic-sources`, Warnung „ähnliche Abschnitte“. +- **Gefundene Spec-Lücken:** §5.3 nannte `rev` für die Divi-Sperre (Konflikt mit P16) → Task 1; §5.7 ließ offen, wo `page-render-summary` rendert und ob es Divi braucht → D5; §5.6 übersah, dass kses für Benutzer ohne `unfiltered_html` alle Blöcke neu serialisiert → D7 (Umsetzung 2b); `social-media-follow` heißt im Katalog-Kind `social-media-follow-network`, Kontaktfeld „volle Breite“ ist in 5.2 kein Kurzform-Pfad mehr (Flex) → in `ShortformMap` berücksichtigt. +- **Placeholder-Scan:** keine „TBD/TODO/später“-Schritte; generierte Dateien (`converted-*.html`, `metadata-sample.json`) haben je einen exakten Befehl und eine Erwartung. +- **Typ-Konsistenz geprüft:** `NodeIndex::find/idOf/parentOf/parentId/root/entries`, `AttrMap::flatten/desktop/set/remove`, `ModuleCatalog::normalize/has/childrenOf/isKnownPath/suggestions/paths`, `ShortformMap::fields/childType/primary/values`, `DesignSystem::presetNames/colorPlaceholder`, `DiviPageLoader::load/writable/unknownNode`, `DiviPage::treeRev/editUrl/isDiviLayout` stimmen zwischen Interfaces-Blöcken, Code und Tests überein; die Test-Helfer `DiviPageLoaderTest::fixture/givenPost/schema`, `DesignSystemTest::sample`, `ModuleCatalogTest::sampleCatalog` nutzt Plan 2b unverändert. +- **Review Focus:** alle fünf Punkte haben ihren Test im genannten Task; der Code wurde in einer Kopie des Plugins mit PHP 8.5/PHPUnit 9.6 ausgeführt (681 Tests grün, phpcs sauber) und die Lese-Tools direkt gegen Divi 5.2.1 in DDEV geprüft. diff --git a/docs/superpowers/plans/2026-09-26-etappe-2b-divi-schreiben.md b/docs/superpowers/plans/2026-09-26-etappe-2b-divi-schreiben.md new file mode 100644 index 0000000..20a9217 --- /dev/null +++ b/docs/superpowers/plans/2026-09-26-etappe-2b-divi-schreiben.md @@ -0,0 +1,6654 @@ +# Etappe 2b – Divi-Seiten schreiben: Kurz-/Vollform, Platzhalter, Einfügen bis Ersetzen – 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 baut und pflegt Divi-5-Seiten: Abschnitte, Zeilen und Module in Kurz- oder Vollform einfügen, ändern, verschieben, duplizieren und löschen, Text seitenübergreifend ersetzen und neue Seiten direkt mit Divi-Inhalt anlegen – mit Platzhaltern für das Designsystem, Qualitätswarnungen, `tree_rev`-Sperre, Revision, Protokoll, Cache-Leerung und byte-identischem Rest der Seite. + +**Architecture:** Reines PHP in `Divi/Compose/`: `Placeholders` (Spec-§5.4-Platzhalter → `$variable(…)$`), `AttrWriter` (Vollform, Pfadprüfung gegen den Katalog), `ShortformWriter` (Kurzform nach `ShortformMap`), `StructureRules` + `Composer` (Knotenbeschreibung → neue Blöcke, Spalten über `flexType`, Presets, Admin-Labels), `TreeEditor` (Position, Einfügen, Verschieben, Entfernen, Duplizieren), `Warnings` (§5.5), `TextReplacer`. WordPress-Seite in `Divi/Pages/`: `DiviWriter` (Sperre, Speichern, Protokoll, `ToolResult`) mit `DiviSaver` (Speicher-Pipeline §5.6 inkl. kses-Umgehung, D7), `DiviComposition` (verdrahtet Katalog, Designsystem, `WpValueSanitizer`, `WpMediaResolver`), sechs Schreib-Abilities und `DiviContentComposer` für `lit/content-create`. + +**Tech Stack:** PHP ≥ 8.1, WordPress 7.0 (Abilities API, `wp_update_post`, kses), Divi 5.2.1 (`ET_Core_PageResource::remove_static_resources`), `wordpress/mcp-adapter` 0.5, `wp-media/mcp-oauth` 1.1, PHPUnit 9.6 + Brain Monkey + Mockery, phpcs, DDEV. + +**Spec:** `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` (insb. §2, §5.4–5.7, §6.2, §8.1–8.2, §9, §10, §11.3, §12 Etappe 2) und die Entscheidungen `docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md` (D1–D12) + +**Voraussetzung:** Plan `docs/superpowers/plans/2026-09-26-etappe-2a-divi-lesen.md` ist vollständig umgesetzt (BlockTree, `NodeIndex`, `AttrMap`, `NodeSummary`, `Outline`, `ModuleCatalog`, `ShortformMap`, `DiviModules`, `DesignSystem`, `DesignSystemReader`, `DiviPage`, `DiviPageLoader` inkl. `writable()`, `DiviIntegration`, die Test-Helfer `DiviPageLoaderTest::fixture/givenPost/schema`, `DesignSystemTest::sample`, `ModuleCatalogTest::sampleCatalog`, Fixtures in `tests/Fixtures/divi/`). + +## Global Constraints + +- Dateien ausschließlich im Plugin-Verzeichnis `/home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP` anlegen oder ändern. Integrationsskripte räumen Seiten, Benutzer, Rollen und die Option `lit_mcp_allowed_roles` wieder auf. +- PHP ≥ 8.1 ohne 8.2+-Features; WordPress ≥ 6.9 (getestet 7.0); Divi 5.2.1. +- Namespace `LIT\MCP\` → `src/`, PSR-4, eine Klasse pro Datei, PSR-12, höchstens 120 Zeichen pro Zeile; `composer lint` und `composer test` nach jedem Task sauber. +- `src/Integrations/Divi/Compose/*` ist reines PHP ohne WordPress (Spec §4.4); WordPress-Funktionen nur in `Divi/Pages/` (Adapter, Abilities) und über die Schnittstellen `ValueSanitizer` und `MediaResolver`. Compose-Tests erben von `PHPUnit\Framework\TestCase` und nutzen `ComposeFixture` (echter 5.2.1-Katalog, Beispiel-Designsystem, vorhersagbare Fakes). +- Unit-Tests der Abilities über `LIT\MCP\Tests\Unit\TestCase` (Brain Monkey + Mockery); vorbelegte Stubs (`wp_json_encode`, `is_wp_error`, `absint`, `wp_slash`, `wp_unslash`, `sanitize_text_field`) nie mit `Functions\expect()` prüfen; `error_log()` per `ini_set('error_log', …)` umleiten. +- Integrationstests im DDEV-Container: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/.sh`. +- **Sicherheitsregeln Spec §5 (verbindlich für jeden Schreibpfad):** + - Byte-Invariante: nur geänderte oder neue Blöcke bekommen neue Delimiter; alles andere bleibt byte-identisch – auch für Benutzer ohne `unfiltered_html` (kses-Umgehung nur in `DiviSaver`, D7). + - Jeder Wert, den LIT MCP in Divi-Attribute schreibt, läuft durch `ValueSanitizer`: `…innerContent` → `html()` (`wp_kses_post`), URL-Schlüssel (`…url`, `…src`, `…link`) → `url()` (`esc_url_raw`), Code-Modul → `code()` (ungefiltert nur mit `unfiltered_html`), alle anderen Strings → `text()` (`sanitize_text_field`); `$variable(…)$`-Verweise bleiben unverändert. + - `tree_rev` ist Pflicht für jede Divi-Änderung (Ruling P16); Abweichung → `rev_mismatch`, nichts geschrieben. Antworten liefern die neue `tree_rev` und `rev` (`Rev::ofPost`). + - Alles oder nichts: erst vollständig validieren, dann einmal speichern; Fehler → `validation_failed` bzw. `invalid_attr_path` mit `fields` (Eingabepfad → Problem). + - Vor dem Speichern Wiederherstellungspunkt (`RevisionPoint::capture`), danach Divi-Flags `_et_pb_use_divi_5`/`_et_pb_use_builder` = `on`, `ET_Core_PageResource::remove_static_resources($id, 'all', true)`, Protokoll über `PostChangeResult`; scheitert das Speichern, wird der vorherige Inhalt zurückgeschrieben (`WriteFailed`, sonst `partial_write` + `ChangeLog::recordPartial`). Status bleibt unverändert; verpasste Plantermine blockieren (`PostGuard::statusStable`). + - Admin-Labels: ohne `label` automatisch „ – “ (Spec §5.5); Code-Modul erlaubt, aber mit Warnung; Platzhalter nach Spec §5.4 (allein im Wert, D9); Warnungen nach §5.5 (ähnliche Abschnitte ab Etappe 3, D6). + - Destruktiv (`lit/divi-delete`, `lit/divi-replace-text`) nur mit `confirm: true`; `lit/divi-replace-text` nur mit `dry_run: false` und `targets` aus dem Probelauf. +- Ability-Namen und Annotations: `lit/divi-insert` (direkt; idempotent false), `lit/divi-update` (direkt; idempotent true), `lit/divi-move` (direkt), `lit/divi-delete` (direkt; destructive true), `lit/divi-duplicate-node` (indirekt), `lit/divi-replace-text` (indirekt; destructive true). Output: `ToolResult::schema(['tree_rev' => …], ['tree_rev'])`. Texte nennen direkte Tools `lit-`, indirekte `lit/`; jeder Task mit neuem Tool aktualisiert `docs/guides/overview.md` (Ruling Q2). +- JSON Schema im von `rest_validate_value_from_schema()` geprüften Umfang (kein `$ref`): Knotenbeschreibungen sind `{"type": "object"}`-Einträge, geprüft wird im `Composer`. +- Commits: `git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit …` mit den Trailern `Co-Authored-By: Claude Opus 5.5 (1M context) ` und `Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7`. + +## Review Focus + +1. **Redakteur ohne `unfiltered_html` (z. B. auf Multisite auch Administratoren) ändert eine Divi-Seite:** Ohne Gegenmaßnahme würde WordPress' kses jeden unveränderten Block neu serialisieren (in DDEV nachgewiesen). Erwartet: unberührte Blöcke byte-identisch, neue Werte trotzdem bereinigt (kein `#is', '', $value); + } + + public function code(string $value): string + { + return $value; + } + + public function url(string $value): string + { + return str_starts_with(strtolower(trim($value)), 'javascript:') ? '' : trim($value); + } + }; + } + + public static function media(): MediaResolver + { + return new class implements MediaResolver { + public function image(int $id): ?array + { + return match ($id) { + 33 => ['url' => 'https://kunde.test/wp-content/uploads/team.jpg', 'alt' => 'Unser Team'], + 12 => ['url' => 'https://kunde.test/wp-content/uploads/halle.jpg', 'alt' => ''], + default => null, + }; + } + }; + } + + public static function placeholders(): Placeholders + { + return new Placeholders(DesignSystemTest::sample()); + } + + public static function attrWriter(): AttrWriter + { + return new AttrWriter(ModuleCatalogTest::sampleCatalog(), self::placeholders(), self::sanitizer()); + } +} +``` + +`tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php`: +```php + */ + public static function placeholders(): array + { + return [ + 'color alias' => ['{{color:primary}}', '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$'], + 'color label' => ['{{ color : Nachtblau }}', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'], + 'variable' => ['{{var:Telefon}}', '$variable({"type":"content","value":{"name":"gvid-s1","settings":{}}})$'], + 'font' => ['{{font:heading}}', '$variable({"type":"content","value":{"name":"--et_global_heading_font","settings":{}}})$'], + 'post title' => [ + '{{post:title}}', + '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$', + ], + 'featured image' => ['{{post:featured_image}}', '$variable({"type":"content","value":{"name":"post_featured_image","settings":{}}})$'], + 'custom field' => [ + '{{field:telefon_mobil}}', + '$variable({"type":"content","value":{"name":"custom_meta_telefon_mobil","settings":{"before":"","after":""}}})$', + ], + 'plain text' => ['Hallo Welt', 'Hallo Welt'], + ]; + } + + /** @dataProvider placeholders */ + public function testResolvesToDiviVariables(string $input, string $expected): void + { + $resolved = ComposeFixture::placeholders()->resolve($input); + + self::assertSame($expected, $resolved); + self::assertSame($expected !== $input, Placeholders::isVariable($resolved)); + } + + /** @return array */ + public static function invalid(): array + { + return [ + 'unknown color' => ['{{color:Lila}}', 'Vorhanden: primary, body, Primärfarbe, Fließtextfarbe, Nachtblau.'], + 'unknown variable' => ['{{var:Fax}}', 'Vorhanden: Radius, Telefon'], + 'unknown font' => ['{{font:mono}}', 'heading, body'], + 'unknown post value' => ['{{post:views}}', 'title, excerpt'], + 'bad field key' => ['{{field:a b}}', 'Ungültiger Feldschlüssel'], + 'embedded' => ['Seite: {{post:title}}', 'allein im Wert'], + ]; + } + + /** @dataProvider invalid */ + public function testRejectsUnknownNamesWithTheExistingOnes(string $input, string $message): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + ComposeFixture::placeholders()->resolve($input); + } + + public function testResolvesDeepValues(): void + { + self::assertSame( + ['color' => '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$', 'size' => '20px'], + ComposeFixture::placeholders()->resolveDeep(['color' => '{{color:primary}}', 'size' => '20px']) + ); + self::assertFalse(Placeholders::isVariable('$variable(kaputt)$')); + } +} +``` + +`tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php`: +```php + [ + 'innerContent' => ['desktop' => ['value' => 'Alt']], + 'decoration' => ['font' => ['font' => [ + 'desktop' => ['value' => ['headingLevel' => 'h2', 'color' => '#111111']], + 'phone' => ['value' => ['size' => '22px']], + ]]], + ], + 'builderVersion' => '5.2.1', + ]; + + public function testMergesShortAndRawBreakpointsAndKeepsTheRest(): void + { + $result = ComposeFixture::attrWriter()->apply('divi/heading', self::HEADING, [ + 'title.decoration.font.font' => [ + 'desktop' => ['value' => ['headingLevel' => 'h3'], 'hover' => ['color' => '{{color:primary}}']], + 'tablet' => ['size' => '26px'], + ], + 'title.innerContent' => ['desktop' => 'Neu'], + ], 'attrs'); + + self::assertSame([], $result['errors']); + self::assertSame( + [ + 'desktop' => [ + 'value' => ['headingLevel' => 'h3'], + 'hover' => ['color' => '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$'], + ], + 'phone' => ['value' => ['size' => '22px']], + 'tablet' => ['value' => ['size' => '26px']], + ], + $result['attrs']['title']['decoration']['font']['font'] + ); + self::assertSame('Neu', $result['attrs']['title']['innerContent']['desktop']['value']); + self::assertSame('5.2.1', $result['attrs']['builderVersion']); + } + + public function testNullRemovesPathsAndStatesAndTopLevelHoverMeansDesktop(): void + { + $writer = ComposeFixture::attrWriter(); + + $removed = $writer->apply('divi/heading', self::HEADING, ['title.decoration.font.font' => null], 'attrs'); + self::assertArrayNotHasKey('decoration', $removed['attrs']['title']); + + $phoneGone = $writer->apply('divi/heading', self::HEADING, [ + 'title.decoration.font.font' => ['phone' => ['value' => null], 'hover' => ['color' => '#000000']], + ], 'attrs'); + self::assertSame( + ['desktop' => ['value' => ['headingLevel' => 'h2', 'color' => '#111111'], 'hover' => ['color' => '#000000']]], + $phoneGone['attrs']['title']['decoration']['font']['font'] + ); + } + + public function testUnknownPathsGetSuggestions(): void + { + $result = ComposeFixture::attrWriter()->apply('divi/heading', self::HEADING, [ + 'title.decoration.font' => ['desktop' => ['color' => '#fff']], + 'title.innerContent' => ['mobile' => 'x'], + ], 'changes[0].attrs'); + + self::assertSame(['title.decoration.font'], $result['invalid_paths']); + self::assertStringContainsString( + 'title.decoration.font.font', + $result['errors']['changes[0].attrs.title.decoration.font'] + ); + self::assertStringContainsString('unbekannter Breakpoint "mobile"', $result['errors']['changes[0].attrs.title.innerContent']); + self::assertSame(self::HEADING, $result['attrs']); + } + + public function testSanitizesByPlace(): void + { + $writer = ComposeFixture::attrWriter(); + + self::assertSame('', $writer->cleanValue('divi/button', 'button.innerContent', 'javascript:alert(1)', 'linkUrl')); + self::assertSame('

x

', $writer->cleanValue('divi/text', 'content.innerContent', '

x

')); + self::assertSame('', $writer->cleanValue('divi/code', 'content.innerContent', '')); + self::assertSame('10px', $writer->cleanValue('divi/text', 'module.decoration.spacing', '10px')); + self::assertSame( + '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$', + $writer->cleanValue('divi/text', 'module.decoration.background', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$') + ); + self::assertSame(['desktop' => ['value' => 'x']], AttrWriter::normalize(['desktop' => 'x'])); + } +} +``` + +- [ ] **Step 2: Test laufen lassen (rot)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'PlaceholdersTest|AttrWriterTest'` +Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\Compose\Placeholders" not found`. + +- [ ] **Step 3: Implementierung** + +`src/Integrations/Divi/Compose/Placeholders.php`: +```php +}}, {{var:}}, {{font:heading|body}}, {{post:title|…}}, + * {{field:}}. A placeholder must be the whole value; unknown names fail with the + * list of existing ones. Pure PHP. + */ +final class Placeholders +{ + /** {{post:}} => Divi dynamic content option. */ + public const POST = [ + 'title' => 'post_title', + 'excerpt' => 'post_excerpt', + 'date' => 'post_date', + 'modified' => 'post_modified_date', + 'author' => 'post_author', + 'url' => 'post_link_url', + 'featured_image' => 'post_featured_image', + 'categories' => 'post_categories', + 'tags' => 'post_tags', + ]; + + /** Dynamic content that is text and takes before/after settings. */ + private const TEXT_SETTINGS = [ + 'post_title', 'post_excerpt', 'post_date', 'post_modified_date', 'post_author', 'post_categories', 'post_tags', + ]; + + private const PATTERN = '/^\{\{\s*(color|var|font|post|field)\s*:\s*([^{}]+?)\s*\}\}$/'; + + public function __construct(private DesignSystem $design) + { + } + + /** Whether $value is a Divi variable reference ($variable({…})$), e.g. from an earlier read. */ + public static function isVariable(string $value): bool + { + return preg_match('/^\$variable\((\{.*\})\)\$$/s', $value, $match) === 1 + && is_array(json_decode($match[1], true)); + } + + /** + * Resolves $value when it is a placeholder; other strings are returned unchanged. + * + * @throws InvalidArgumentException With a German message for the client. + */ + public function resolve(string $value): string + { + if (preg_match(self::PATTERN, trim($value), $match) !== 1) { + if (str_contains($value, '{{')) { + throw new InvalidArgumentException( + 'Platzhalter müssen allein im Wert stehen, z. B. "{{color:primary}}" (bekannt: color, var, font, ' + . 'post, field).' + ); + } + + return $value; + } + + [, $kind, $name] = $match; + + return match ($kind) { + 'color' => $this->color($name), + 'var' => $this->variable($name), + 'font' => $this->font($name), + 'post' => $this->post($name), + default => $this->field($name), + }; + } + + /** + * Applies resolve() to every string inside $value (full-form values are nested). + * + * @throws InvalidArgumentException + */ + public function resolveDeep(mixed $value): mixed + { + if (is_string($value)) { + return $this->resolve($value); + } + + if (is_array($value)) { + return array_map([$this, 'resolveDeep'], $value); + } + + return $value; + } + + private function color(string $name): string + { + $id = $this->design->colorId($name); + if ($id === null) { + $ids = array_column($this->design->colors(), 'id'); + $known = array_merge( + array_keys(array_intersect(DesignSystem::COLOR_ALIASES, $ids)), + array_column($this->design->colors(), 'label') + ); + throw new InvalidArgumentException(sprintf( + 'Unbekannte globale Farbe "%s". Vorhanden: %s.', + $name, + implode(', ', array_unique($known)) + )); + } + + return self::variableString('color', $id, new stdClass()); + } + + private function variable(string $name): string + { + $variable = $this->design->variable($name); + if ($variable === null) { + $labels = array_column($this->design->variables(), 'label'); + throw new InvalidArgumentException(sprintf( + 'Unbekannte globale Variable "%s". Vorhanden: %s.', + $name, + $labels === [] ? '(keine)' : implode(', ', $labels) + )); + } + + $type = $variable['type'] === 'colors' ? 'color' : 'content'; + + return self::variableString($type, $variable['id'], new stdClass()); + } + + private function font(string $name): string + { + $id = DesignSystem::FONT_VARIABLES[strtolower($name)] ?? null; + if ($id === null) { + throw new InvalidArgumentException( + sprintf('Unbekannte Theme-Schrift "%s". Vorhanden: heading, body.', $name) + ); + } + + return self::variableString('content', $id, new stdClass()); + } + + private function post(string $name): string + { + $option = self::POST[strtolower($name)] ?? null; + if ($option === null) { + throw new InvalidArgumentException(sprintf( + 'Unbekannter Beitragswert "%s". Vorhanden: %s.', + $name, + implode(', ', array_keys(self::POST)) + )); + } + + $settings = in_array($option, self::TEXT_SETTINGS, true) ? ['before' => '', 'after' => ''] : new stdClass(); + + return self::variableString('content', $option, $settings); + } + + private function field(string $name): string + { + if (preg_match('/^[A-Za-z0-9_-]+$/', $name) !== 1) { + throw new InvalidArgumentException(sprintf('Ungültiger Feldschlüssel "%s".', $name)); + } + + return self::variableString('content', 'custom_meta_' . $name, ['before' => '', 'after' => '']); + } + + /** @param array|stdClass $settings */ + private static function variableString(string $type, string $name, array|stdClass $settings): string + { + $payload = ['type' => $type, 'value' => ['name' => $name, 'settings' => $settings]]; + + return '$variable(' . Json::encode($payload) . ')$'; + } +} +``` + +`src/Integrations/Divi/Compose/ValueSanitizer.php`: +```php + breakpoint map. Paths are checked + * against the catalog (invalid_attr_path with suggestions), placeholders resolved, strings + * sanitized. A breakpoint may be given short ({"desktop": "#fff"}) or raw + * ({"desktop": {"value": "#fff", "hover": "#000"}}); top-level "hover"/"sticky" mean desktop. + * Given breakpoints/states replace the existing ones, others stay; null removes. Pure PHP. + */ +final class AttrWriter +{ + private const STATES = ['value', 'hover', 'sticky']; + + /** innerContent sub keys that hold URLs. */ + private const URL_KEY = '/(url|src|link)$/i'; + + private const CODE_MODULES = ['divi/code', 'divi/fullwidth-code']; + + public function __construct( + private ModuleCatalog $catalog, + private Placeholders $placeholders, + private ValueSanitizer $sanitizer + ) { + } + + /** + * @param array $attrs Current attributes of the block. + * @param array $changes Path => breakpoint map, or null to remove the path. + * @return array{attrs: array, errors: array, invalid_paths: list} + */ + public function apply(string $module, array $attrs, array $changes, string $field): array + { + $errors = []; + $invalid = []; + foreach ($changes as $path => $map) { + $path = (string) $path; + $key = $field . '.' . $path; + if (! $this->catalog->isKnownPath($module, $path)) { + $suggestions = $this->catalog->suggestions($module, $path); + $errors[$key] = 'unbekannter Attributpfad' + . ($suggestions === [] ? '' : '; ähnlich: ' . implode(', ', $suggestions)); + $invalid[] = $path; + continue; + } + + if ($map === null) { + $attrs = AttrMap::remove($attrs, $path); + continue; + } + + try { + $normalized = $this->clean($module, $path, $this->placeholders->resolveDeep(self::normalize($map))); + } catch (InvalidArgumentException $e) { + $errors[$key] = $e->getMessage(); + continue; + } + + $merged = self::merge(AttrMap::get($attrs, $path), $normalized); + $attrs = $merged === [] ? AttrMap::remove($attrs, $path) : AttrMap::set($attrs, $path, $merged); + } + + return ['attrs' => $attrs, 'errors' => $errors, 'invalid_paths' => $invalid]; + } + + /** + * Cleans one value written to $path (used by the short form too). + * + * @throws InvalidArgumentException + */ + public function cleanValue(string $module, string $path, mixed $value, ?string $subKey = null): mixed + { + if (is_array($value)) { + $clean = []; + foreach ($value as $key => $item) { + $clean[$key] = $this->cleanValue($module, $path, $item, is_string($key) ? $key : $subKey); + } + + return $clean; + } + + if (! is_string($value) || Placeholders::isVariable($value)) { + return $value; + } + + if (! str_ends_with($path, '.innerContent')) { + return $this->sanitizer->text($value); + } + + if ($subKey !== null && preg_match(self::URL_KEY, $subKey) === 1) { + return $this->sanitizer->url($value); + } + + return in_array($module, self::CODE_MODULES, true) + ? $this->sanitizer->code($value) + : $this->sanitizer->html($value); + } + + /** + * @return array> Breakpoint => state => value. + * @throws InvalidArgumentException + */ + public static function normalize(mixed $map): array + { + $shape = 'erwartet {"desktop": Wert, "tablet": Wert, "phone": Wert, "hover": Wert} ' + . 'oder {"desktop": {"value": …, "hover": …}}'; + if (! is_array($map) || $map === [] || array_is_list($map)) { + throw new InvalidArgumentException($shape); + } + + $normalized = []; + foreach ($map as $key => $value) { + $key = (string) $key; + if ($key === 'hover' || $key === 'sticky') { + $normalized['desktop'][$key] = $value; + } elseif (in_array($key, AttrMap::BREAKPOINTS, true)) { + $isRaw = is_array($value) && $value !== [] && ! array_is_list($value) + && array_diff(array_map('strval', array_keys($value)), self::STATES) === []; + foreach ($isRaw ? $value : ['value' => $value] as $state => $stateValue) { + $normalized[$key][(string) $state] = $stateValue; + } + } else { + throw new InvalidArgumentException(sprintf('unbekannter Breakpoint "%s"; %s', $key, $shape)); + } + } + + return $normalized; + } + + /** + * @param array> $normalized + * @return array> + * @throws InvalidArgumentException + */ + private function clean(string $module, string $path, array $normalized): array + { + foreach ($normalized as $breakpoint => $states) { + foreach ($states as $state => $value) { + $normalized[$breakpoint][$state] = $this->cleanValue($module, $path, $value); + } + } + + return $normalized; + } + + /** + * @param array> $changes + * @return array + */ + private static function merge(mixed $existing, array $changes): array + { + $map = is_array($existing) && AttrMap::isBreakpointMap($existing) ? $existing : []; + foreach ($changes as $breakpoint => $states) { + $current = is_array($map[$breakpoint] ?? null) ? $map[$breakpoint] : []; + foreach ($states as $state => $value) { + if ($value === null) { + unset($current[$state]); + } else { + $current[$state] = $value; + } + } + + if ($current === []) { + unset($map[$breakpoint]); + } else { + $map[$breakpoint] = $current; + } + } + + return $map; + } +} +``` + +- [ ] **Step 4: Tests und Lint (grün)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'PlaceholdersTest|AttrWriterTest' && composer lint` +Expected: PASS, phpcs ohne Meldungen. + +- [ ] **Step 5: Commit** + +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP +git add src/Integrations/Divi/Compose/ tests/Unit/Integrations/Divi/Compose/ +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi placeholders and full-form attribute writer + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- + +### Task 2: Kurzform schreiben – `ShortformWriter` + +Spec §5.4: Kurzform-Felder nach `ShortformMap` (2a) – Wert je Art prüfen (`onoff` aus true/false, `level` h1–h6, `number`, `enum`, `ids` als „12,33“, `color` nur Farbe oder Platzhalter, `url` ohne `javascript:`), Platzhalter auflösen (nicht beim Code-Modul), bereinigen und unter `.desktop.value[.]` setzen; `null` entfernt; `with` setzt feste Zusatzwerte (Blurb-Bild → `useIcon: off`). Medienfelder (`image_id`, `portrait_id`, `image_ids`) holen URL, ID und Alt-Text über `MediaResolver` – ein ausdrücklich gesetztes `alt` gewinnt. + +**Files:** +- Create: `src/Integrations/Divi/Compose/ShortformWriter.php` +- Modify: `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` (`shortform()`) +- Test: `tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php` + +**Interfaces:** +- Consumes: `ShortformMap::fields()` (2a), `AttrMap::set/remove` (2a), `Placeholders`, `ValueSanitizer`, `MediaResolver`, `AttrWriter::cleanValue()` (Task 1). +- Produces: `ShortformWriter(Placeholders, ValueSanitizer, MediaResolver, AttrWriter)`: `apply(string $type, array $attrs, array $values, string $field): array{attrs, errors}` (`$type` = Kurzname, Fehlerschlüssel `"."`); `ComposeFixture::shortform(): ShortformWriter`. + +- [ ] **Step 1: Failing Tests** + +In `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` ersetzen: +```php +use LIT\MCP\Integrations\Divi\Compose\Placeholders; +``` +durch: +```php +use LIT\MCP\Integrations\Divi\Compose\Placeholders; +use LIT\MCP\Integrations\Divi\Compose\ShortformWriter; +``` + +In `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` (Methode ergänzen) ersetzen: +```php + public static function attrWriter(): AttrWriter + { + return new AttrWriter(ModuleCatalogTest::sampleCatalog(), self::placeholders(), self::sanitizer()); + } +``` +durch: +```php + public static function attrWriter(): AttrWriter + { + return new AttrWriter(ModuleCatalogTest::sampleCatalog(), self::placeholders(), self::sanitizer()); + } + + public static function shortform(): ShortformWriter + { + return new ShortformWriter(self::placeholders(), self::sanitizer(), self::media(), self::attrWriter()); + } +``` + +`tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php`: +```php +apply('button', [], [ + 'text' => 'Termin buchen', + 'url' => '/kontakt', + 'new_window' => true, + ], 'nodes[0]'); + + self::assertSame([], $result['errors']); + self::assertSame( + ['button' => ['innerContent' => ['desktop' => ['value' => [ + 'text' => 'Termin buchen', 'linkUrl' => '/kontakt', 'linkTarget' => 'on', + ]]]]], + $result['attrs'] + ); + } + + public function testMediaSetsUrlIdAndAltUnlessAltIsGiven(): void + { + $writer = ComposeFixture::shortform(); + + $image = $writer->apply('image', [], ['image_id' => 33], 'n'); + self::assertSame( + ['src' => 'https://kunde.test/wp-content/uploads/team.jpg', 'id' => '33', 'alt' => 'Unser Team'], + $image['attrs']['image']['innerContent']['desktop']['value'] + ); + + $own = $writer->apply('image', [], ['image_id' => 33, 'alt' => 'Team 2026'], 'n'); + self::assertSame('Team 2026', $own['attrs']['image']['innerContent']['desktop']['value']['alt']); + + $blurb = $writer->apply('blurb', [], ['image_id' => 12, 'title' => 'Halle'], 'n'); + self::assertSame( + ['src' => 'https://kunde.test/wp-content/uploads/halle.jpg', 'useIcon' => 'off'], + $blurb['attrs']['imageIcon']['innerContent']['desktop']['value'] + ); + + $gallery = $writer->apply('gallery', [], ['image_ids' => [12, 33]], 'n'); + self::assertSame('12,33', $gallery['attrs']['image']['advanced']['galleryIds']['desktop']['value']); + } + + public function testValidatesKindsAndNames(): void + { + $result = ComposeFixture::shortform()->apply('heading', [], [ + 'level' => 'h7', + 'text' => '{{color:Lila}}', + 'size' => 12, + ], 'nodes[1]'); + + self::assertSame('h1 bis h6 erwartet', $result['errors']['nodes[1].level']); + self::assertStringContainsString('Unbekannte globale Farbe', $result['errors']['nodes[1].text']); + self::assertSame('unbekanntes Feld; erlaubt: text, level', $result['errors']['nodes[1].size']); + + $more = ComposeFixture::shortform()->apply('contact-field', [], ['type' => 'date', 'required' => 'ja'], 'f'); + self::assertStringContainsString('erlaubt: input, email', $more['errors']['f.type']); + self::assertSame('true oder false erwartet', $more['errors']['f.required']); + + $media = ComposeFixture::shortform()->apply('image', [], ['image_id' => 999, 'url' => 'javascript:x'], 'm'); + self::assertStringContainsString('kein Bild aus der Mediathek', $media['errors']['m.image_id']); + self::assertSame('ungültige URL', $media['errors']['m.url']); + + $none = ComposeFixture::shortform()->apply('accordion', [], ['title' => 'x'], 'a'); + self::assertStringContainsString('keine Kurzform', $none['errors']['a.title']); + } + + public function testPlaceholdersAndNullRemoval(): void + { + $writer = ComposeFixture::shortform(); + $attrs = $writer->apply('heading', [], ['text' => '{{post:title}}', 'level' => 'h2'], 'n')['attrs']; + + self::assertSame( + '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$', + $attrs['title']['innerContent']['desktop']['value'] + ); + + $removed = $writer->apply('heading', $attrs, ['level' => null], 'n')['attrs']; + self::assertArrayNotHasKey('decoration', $removed['title'], 'empty parents are pruned'); + + $divider = $writer->apply('divider', [], ['color' => '{{color:Nachtblau}}'], 'n')['attrs']; + self::assertStringContainsString('gcid-a1b2', $divider['divider']['advanced']['line']['desktop']['value']['color']); + self::assertSame( + 'Farbe erwartet, z. B. {{color:primary}} oder #1a1a1a', + $writer->apply('divider', [], ['color' => 'blau;}'], 'n')['errors']['n.color'] + ); + } +} +``` + +- [ ] **Step 2: Test laufen lassen (rot)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter ShortformWriterTest` +Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\Compose\ShortformWriter" not found`. + +- [ ] **Step 3: Implementierung** + +`src/Integrations/Divi/Compose/ShortformWriter.php`: +```php +.desktop.value[.]". + * null removes a field. Pure PHP. + */ +final class ShortformWriter +{ + private const COLOR = '/^(#[0-9a-fA-F]{3,8}|rgba?\([0-9.,%\s]+\)|hsla?\([0-9.,%\s]+\)|transparent)$/'; + + public function __construct( + private Placeholders $placeholders, + private ValueSanitizer $sanitizer, + private MediaResolver $media, + private AttrWriter $attrs + ) { + } + + /** + * @param string $type Short module name ("button"). + * @param array $attrs Current attributes. + * @param array $values Field => value. + * @return array{attrs: array, errors: array} + */ + public function apply(string $type, array $attrs, array $values, string $field): array + { + $fields = ShortformMap::fields($type); + $errors = []; + foreach ($values as $name => $value) { + $name = (string) $name; + $spec = $fields[$name] ?? null; + if ($spec === null) { + $errors[$field . '.' . $name] = $fields === [] + ? 'dieses Modul hat keine Kurzform; Einstellungen über attrs (Vollform) setzen' + : 'unbekanntes Feld; erlaubt: ' . implode(', ', array_keys($fields)); + continue; + } + + try { + $attrs = $this->set('divi/' . $type, $attrs, $spec, $value, $values); + } catch (InvalidArgumentException $e) { + $errors[$field . '.' . $name] = $e->getMessage(); + } + } + + return ['attrs' => $attrs, 'errors' => $errors]; + } + + /** + * @param array $attrs + * @param array $spec + * @param array $all All values of this call (media must not overwrite an explicit alt). + * @return array + * @throws InvalidArgumentException + */ + private function set(string $module, array $attrs, array $spec, mixed $value, array $all): array + { + $base = $spec['path'] . '.desktop.value'; + if ($value === null) { + return AttrMap::remove($attrs, $spec['sub'] === null ? $spec['path'] : $base . '.' . $spec['sub']); + } + + if ($spec['kind'] === 'media') { + $attrs = $this->media($attrs, $base, $spec['keys'], $value, ! array_key_exists('alt', $all)); + } else { + $clean = $this->value($module, $spec, $value); + $attrs = AttrMap::set($attrs, $spec['sub'] === null ? $base : $base . '.' . $spec['sub'], $clean); + } + + foreach ($spec['with'] ?? [] as $sub => $fixed) { + $attrs = AttrMap::set($attrs, $base . '.' . $sub, $fixed); + } + + return $attrs; + } + + /** + * @param array $attrs + * @param array{0: string, 1: ?string, 2: ?string} $keys URL key, ID key, alt key. + * @return array + * @throws InvalidArgumentException + */ + private function media(array $attrs, string $base, array $keys, mixed $value, bool $withAlt): array + { + $id = is_int($value) ? $value : (is_string($value) && ctype_digit($value) ? (int) $value : 0); + $image = $id > 0 ? $this->media->image($id) : null; + if ($image === null) { + throw new InvalidArgumentException( + 'kein Bild aus der Mediathek; Anhang-ID z. B. mit lit-media-search suchen' + ); + } + + [$urlKey, $idKey, $altKey] = $keys; + $attrs = AttrMap::set($attrs, $base . '.' . $urlKey, $this->sanitizer->url($image['url'])); + if ($idKey !== null) { + $attrs = AttrMap::set($attrs, $base . '.' . $idKey, (string) $id); + } + + if ($altKey !== null && $withAlt && $image['alt'] !== '') { + $attrs = AttrMap::set($attrs, $base . '.' . $altKey, $this->sanitizer->text($image['alt'])); + } + + return $attrs; + } + + /** + * @param array $spec + * @throws InvalidArgumentException + */ + private function value(string $module, array $spec, mixed $value): mixed + { + switch ($spec['kind']) { + case 'onoff': + if (is_bool($value)) { + return $value ? 'on' : 'off'; + } + if ($value === 'on' || $value === 'off') { + return $value; + } + throw new InvalidArgumentException('true oder false erwartet'); + case 'level': + if (is_string($value) && preg_match('/^h[1-6]$/', $value) === 1) { + return $value; + } + throw new InvalidArgumentException('h1 bis h6 erwartet'); + case 'number': + if (is_int($value) || is_float($value) || (is_string($value) && is_numeric($value))) { + return $value + 0; + } + throw new InvalidArgumentException('Zahl erwartet'); + case 'enum': + if (is_string($value) && in_array($value, $spec['values'], true)) { + return $value; + } + throw new InvalidArgumentException('erlaubt: ' . implode(', ', $spec['values'])); + case 'ids': + return $this->ids($value); + } + + if (! is_string($value) && ! is_int($value) && ! is_float($value)) { + throw new InvalidArgumentException('Text erwartet'); + } + + $value = (string) $value; + if ($spec['kind'] !== 'code') { + $value = $this->placeholders->resolve($value); + if (Placeholders::isVariable($value)) { + return $value; + } + } + + if ($spec['kind'] === 'color') { + if (preg_match(self::COLOR, trim($value)) !== 1) { + throw new InvalidArgumentException('Farbe erwartet, z. B. {{color:primary}} oder #1a1a1a'); + } + + return $this->sanitizer->text($value); + } + + if ($spec['kind'] === 'url') { + $url = $this->sanitizer->url($value); + if ($url === '' && trim($value) !== '') { + throw new InvalidArgumentException('ungültige URL'); + } + + return $url; + } + + return match ($spec['kind']) { + 'html', 'code' => $this->attrs->cleanValue($module, (string) $spec['path'], $value), + default => $this->sanitizer->text($value), + }; + } + + /** @throws InvalidArgumentException */ + private function ids(mixed $value): string + { + if (! is_array($value) || $value === [] || ! array_is_list($value)) { + throw new InvalidArgumentException('Liste von Anhang-IDs erwartet, z. B. [12, 33]'); + } + + $ids = []; + foreach ($value as $id) { + $id = is_int($id) ? $id : (is_string($id) && ctype_digit($id) ? (int) $id : 0); + if ($id <= 0 || $this->media->image($id) === null) { + throw new InvalidArgumentException(sprintf('%s ist kein Bild aus der Mediathek', (string) $id)); + } + $ids[] = $id; + } + + return implode(',', $ids); + } +} +``` + +- [ ] **Step 4: Tests und Lint (grün)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter Compose && composer lint` +Expected: PASS, phpcs ohne Meldungen. + +- [ ] **Step 5: Commit** + +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP +git add src/Integrations/Divi/Compose/ShortformWriter.php tests/Unit/Integrations/Divi/Compose/ +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi short-form writer + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- + +### Task 3: Aus Beschreibungen Blöcke bauen – `StructureRules` und `Composer` + +Spec §5.4 „Struktur“ und §5.5: Abschnitt → Zeilen (`"columns": "1_3,2_3"`) → Spalten → Module; Vollbreite über `section_type: "fullwidth"`; Kind-Module in `items`; `preset` per Name (Modul-Presets aus dem Designsystem); fehlendes `label` → „ – “; `heading` ohne `level` → `h2` (D9); jeder neue Block bekommt `builderVersion` (live `ET_BUILDER_PRODUCT_VERSION`). Spaltenbreiten über `module.decoration.sizing.desktop.value.flexType` wie Divi 5.2 (D8; `a_b` mit 24 % b = 0 → `(a·24/b)_24`, sonst Fünftel und 1/7, 1/9, 1/10, 1/11; Summe = 1). `StructureRules` prüft, was wohin darf (Seite → Abschnitte, regulärer Abschnitt → Zeilen, Vollbreite → `fullwidth-*`, Spalte/Gruppe → Module, Elternmodul → seine Kind-Module; Spalten nur mit ihrer Zeile; Spezialabschnitte und nur lesbare Knoten nicht). Alle Fehler werden mit Eingabepfad gesammelt. `update()` wendet `set`/`attrs`/`preset`/`label` auf einen vorhandenen Block an (für `lit-divi-update`). + +**Files:** +- Create: `src/Integrations/Divi/Compose/{StructureRules,Composer}.php` +- Modify: `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` (`rules()`, `composer()`) +- Test: `tests/Unit/Integrations/Divi/Compose/ComposerTest.php` + +**Interfaces:** +- Consumes: `Block::create()/insertChild()/name()/attrs()/isDivi()/isOpaque()`, `AttrMap::set/desktop` (2a), `ModuleCatalog::has/category/childrenOf/title/normalize` (2a), `ShortformMap::fields()` (2a), `DesignSystem::modulePresetId/modulePresetNames` (2a), `ShortformWriter`, `AttrWriter` (Tasks 1–2). +- Produces: + - `StructureRules(ModuleCatalog)`: `violation(?Block $parent, string $child): ?string` (null = erlaubt; `$parent` null = oberste Ebene); `isPlainModule(string $name): bool`. + - `Composer(ModuleCatalog, StructureRules, ShortformWriter, AttrWriter, DesignSystem, string $builderVersion)`: `compose(array $specs, ?Block $parent, string $field, string $context = ''): array{blocks: list, errors: array, invalid_paths: list}` (Fehlerschlüssel z. B. `nodes[0].rows[1].modules[0][2].text`); `update(Block $block, array $change, string $field): array{attrs, errors, invalid_paths}`; `autoLabel(string $name, string $context): string`. + - `ComposeFixture::rules()`, `ComposeFixture::composer()` (Builder-Version `5.2.1`). + +- [ ] **Step 1: Failing Tests** + +In `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` ersetzen: +```php +use LIT\MCP\Integrations\Divi\Compose\AttrWriter; +``` +durch: +```php +use LIT\MCP\Integrations\Divi\Compose\AttrWriter; +use LIT\MCP\Integrations\Divi\Compose\Composer; +``` + +In `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` ersetzen: +```php +use LIT\MCP\Integrations\Divi\Compose\ShortformWriter; +``` +durch: +```php +use LIT\MCP\Integrations\Divi\Compose\ShortformWriter; +use LIT\MCP\Integrations\Divi\Compose\StructureRules; +``` + +In `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` (Methoden ergänzen) ersetzen: +```php + public static function shortform(): ShortformWriter + { + return new ShortformWriter(self::placeholders(), self::sanitizer(), self::media(), self::attrWriter()); + } +} +``` +durch: +```php + public static function shortform(): ShortformWriter + { + return new ShortformWriter(self::placeholders(), self::sanitizer(), self::media(), self::attrWriter()); + } + + public static function rules(): StructureRules + { + return new StructureRules(ModuleCatalogTest::sampleCatalog()); + } + + public static function composer(): Composer + { + return new Composer( + ModuleCatalogTest::sampleCatalog(), + self::rules(), + self::shortform(), + self::attrWriter(), + DesignSystemTest::sample(), + '5.2.1' + ); + } +} +``` + +`tests/Unit/Integrations/Divi/Compose/ComposerTest.php`: +```php +compose([[ + 'type' => 'section', + 'label' => 'Hero', + 'background_color' => '{{color:Nachtblau}}', + 'rows' => [[ + 'columns' => '1_3,2_3', + 'modules' => [ + [['type' => 'image', 'image_id' => 33]], + [ + ['type' => 'heading', 'text' => 'Willkommen'], + ['type' => 'button', 'text' => 'Termin buchen', 'url' => '/kontakt', 'preset' => 'CTA primär'], + ], + ], + ]], + ]], null, 'nodes'); + + self::assertSame([], $result['errors']); + self::assertStringStartsWith( + '', + Serializer::block($result['blocks'][0]) + ); + + $section = $result['blocks'][0]; + [$heading, $button] = $section->children()[0]->children()[1]->children(); + self::assertSame('16_24', NodeSummary::columnWidth($section->children()[0]->children()[1])); + self::assertSame('h2', $heading->attrs()['title']['decoration']['font']['font']['desktop']['value']['headingLevel']); + self::assertSame('Hero – Heading', NodeSummary::label($heading)); + self::assertSame(['p-2'], $button->attrs()['modulePreset']); + + // What the composer writes, the parser reads back unchanged. + $markup = Serializer::block($section); + self::assertSame($markup, Serializer::document((new Parser())->parse($markup))); + self::assertFalse((new Parser())->parse($markup)->children()[0]->isOpaque()); + } + + public function testShortcutsItemsFullwidthAndFullForm(): void + { + $result = ComposeFixture::composer()->compose([ + ['type' => 'section', 'modules' => [ + ['type' => 'accordion', 'items' => [['title' => 'Frage', 'content' => '

Antwort

', 'open' => true]]], + ['type' => 'text', 'content' => '

x

', 'label' => 'Einleitung', + 'attrs' => ['module.decoration.spacing' => ['desktop' => ['padding' => ['top' => '20px']]]]], + ]], + ['type' => 'section', 'section_type' => 'fullwidth', 'modules' => [ + ['type' => 'fullwidth-header', 'attrs' => ['title.innerContent' => ['desktop' => 'Großer Titel']]], + ]], + ], null, 'nodes'); + + self::assertSame([], $result['errors']); + [$regular, $fullwidth] = $result['blocks']; + $column = $regular->children()[0]->children()[0]; + self::assertSame('24_24', NodeSummary::columnWidth($column)); + [$accordion, $text] = $column->children(); + self::assertSame('divi/accordion-item', $accordion->children()[0]->name()); + self::assertSame('on', $accordion->children()[0]->attrs()['module']['advanced']['open']['desktop']['value']); + self::assertSame('Einleitung', NodeSummary::label($text)); + self::assertSame(['top' => '20px'], $text->attrs()['module']['decoration']['spacing']['desktop']['value']['padding']); + self::assertSame('fullwidth', $fullwidth->attrs()['module']['advanced']['type']['desktop']['value']); + self::assertSame('divi/fullwidth-header', $fullwidth->children()[0]->name()); + } + + public function testCollectsEveryErrorWithItsInputPath(): void + { + $result = ComposeFixture::composer()->compose([ + ['type' => 'button', 'text' => 'x'], + ['type' => 'section', 'rows' => [ + ['columns' => '1_2,1_3', 'modules' => []], + ['columns' => '1_2,1_2', 'modules' => [[['type' => 'slider-pro']], [['type' => 'accordion-item']]]], + ]], + ['type' => 'section', 'preset' => 'Gibt es nicht', 'foo' => 1], + ['type' => 'section', 'modules' => [['type' => 'heading', 'attrs' => ['title.fnt' => ['desktop' => 'x']]]]], + ], null, 'nodes'); + + self::assertSame('auf oberster Ebene sind nur Abschnitte (section) erlaubt', $result['errors']['nodes[0].type']); + self::assertStringContainsString('volle Zeile', $result['errors']['nodes[1].rows[0].columns']); + self::assertStringContainsString('unbekanntes Modul "divi/slider-pro"', $result['errors']['nodes[1].rows[1].modules[0][0].type']); + self::assertStringContainsString('passt nicht in eine Spalte', $result['errors']['nodes[1].rows[1].modules[1][0].type']); + self::assertStringContainsString('unbekanntes Preset', $result['errors']['nodes[2].preset']); + self::assertStringContainsString('unbekanntes Feld', $result['errors']['nodes[2].foo']); + self::assertSame(['title.fnt'], $result['invalid_paths']); + self::assertArrayHasKey('nodes[3].modules[0].modules[0][0].attrs.title.fnt', $result['errors']); + } + + public function testColumnWidths(): void + { + $composer = ComposeFixture::composer(); + $widths = static function (string $columns) use ($composer): array { + $result = $composer->compose([['type' => 'section', 'rows' => [['columns' => $columns]]]], null, 'n'); + + return [ + array_map([NodeSummary::class, 'columnWidth'], $result['blocks'][0]->children()[0]->children()), + $result['errors'], + ]; + }; + + self::assertSame([['12_24', '12_24'], []], $widths('1_2,1_2')); + self::assertSame([['6_24', '18_24'], []], $widths('1_4, 3_4')); + self::assertSame([['2_5', '3_5'], []], $widths('2_5,3_5')); + self::assertSame(['24_24'], $widths('1_7,5_7')[0]); + self::assertArrayHasKey('n[0].rows[0].columns', $widths('1_7,5_7')[1]); + self::assertArrayHasKey('n[0].rows[0].columns', $widths('abc')[1]); + } + + public function testUpdateChangesShortFormPresetAndLabel(): void + { + $block = Block::create('divi/button', ['button' => ['innerContent' => ['desktop' => ['value' => ['text' => 'Alt']]]]]); + + $result = ComposeFixture::composer()->update($block, [ + 'set' => ['text' => 'Neu', 'url' => '/neu'], + 'preset' => 'Standard', + 'label' => 'Hero – CTA', + ], 'changes[0]'); + + self::assertSame([], $result['errors']); + self::assertSame(['text' => 'Neu', 'linkUrl' => '/neu'], $result['attrs']['button']['innerContent']['desktop']['value']); + self::assertSame(['p-1'], $result['attrs']['modulePreset']); + self::assertSame('Hero – CTA', $result['attrs']['module']['meta']['adminLabel']['desktop']['value']); + + $cleared = ComposeFixture::composer()->update( + Block::create('divi/button', $result['attrs']), + ['preset' => null], + 'changes[0]' + ); + self::assertArrayNotHasKey('modulePreset', $cleared['attrs']); + } +} +``` + +- [ ] **Step 2: Test laufen lassen (rot)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter ComposerTest` +Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\Compose\StructureRules" not found`. + +- [ ] **Step 3: Implementierung** + +`src/Integrations/Divi/Compose/StructureRules.php`: +```php +catalog->has($child)) { + return sprintf('unbekanntes Modul "%s" (siehe lit-divi-catalog)', $child); + } + + if ($parent === null) { + return $child === 'divi/section' ? null : 'auf oberster Ebene sind nur Abschnitte (section) erlaubt'; + } + + if (! $parent->isDivi() || $parent->isOpaque()) { + return 'der Zielknoten ist nur lesbar (globales Modul oder fremder Block)'; + } + + $parentName = $parent->name(); + if ($parentName === 'divi/section') { + return $this->inSection($parent, $child); + } + + if (in_array($parentName, self::ROWS, true)) { + return 'Spalten entstehen mit der Zeile ("columns"); eine neue Zeile mit der gewünschten Aufteilung ' + . 'einfügen'; + } + + if (in_array($parentName, self::COLUMNS, true) || $parentName === 'divi/group') { + return $this->isPlainModule($child) || $child === 'divi/group' + ? null + : sprintf('%s passt nicht in eine Spalte', $child); + } + + $children = $this->catalog->childrenOf($parentName); + if ($children === []) { + return sprintf('%s kann keine Kind-Module enthalten', $parentName); + } + + return in_array($child, $children, true) + ? null + : sprintf('%s erlaubt nur %s als Kind', $parentName, implode(', ', $children)); + } + + public function isPlainModule(string $name): bool + { + return $this->catalog->category($name) === 'module' && ! str_starts_with($name, 'divi/fullwidth-'); + } + + private function inSection(Block $section, string $child): ?string + { + $type = AttrMap::desktop($section->attrs(), 'module.advanced.type'); + if ($type === 'specialty') { + return 'Spezialabschnitte (specialty) nur im Divi-Builder umbauen; Module in ihre Spalten einfügen'; + } + + if ($type === 'fullwidth') { + return str_starts_with($child, 'divi/fullwidth-') + ? null + : 'in einen Vollbreite-Abschnitt passen nur fullwidth-Module'; + } + + return $child === 'divi/row' ? null : 'ein Abschnitt enthält Zeilen (row)'; + } +} +``` + +`src/Integrations/Divi/Compose/Composer.php`: +```php + – " (spec §5.5). + * Every error is collected with its input path; blocks are only usable when there are none. + * Pure PHP. + */ +final class Composer +{ + private const COMMON = ['type', 'label', 'preset', 'attrs']; + private const SECTION = ['section_type', 'background_color', 'rows', 'modules']; + private const ROW = ['columns', 'modules']; + + /** Column fractions Divi's flex layout knows besides multiples of 1/24. */ + private const SPECIAL_WIDTHS = ['1_5', '2_5', '3_5', '4_5', '1_7', '1_9', '1_10', '1_11']; + + /** @var array */ + private array $errors = []; + + /** @var list */ + private array $invalidPaths = []; + + public function __construct( + private ModuleCatalog $catalog, + private StructureRules $rules, + private ShortformWriter $shortform, + private AttrWriter $attrWriter, + private DesignSystem $design, + private string $builderVersion + ) { + } + + /** + * @param list $specs Node descriptions. + * @param Block|null $parent Block the nodes go into (null = top level of the page). + * @param string $field Input path of $specs for error keys, e.g. "nodes". + * @param string $context Admin label of the surrounding section ("" if none). + * @return array{blocks: list, errors: array, invalid_paths: list} + */ + public function compose(array $specs, ?Block $parent, string $field, string $context = ''): array + { + $this->errors = []; + $this->invalidPaths = []; + $blocks = []; + foreach (array_values($specs) as $position => $spec) { + $block = $this->node($spec, $parent, sprintf('%s[%d]', $field, $position), $context); + if ($block !== null) { + $blocks[] = $block; + } + } + + return ['blocks' => $blocks, 'errors' => $this->errors, 'invalid_paths' => $this->invalidPaths]; + } + + /** + * Applies short-form fields, full-form attrs, preset and label to an existing block + * (lit-divi-update); errors like compose(). + * + * @param array $change set, attrs, preset, label + * @return array{attrs: array, errors: array, invalid_paths: list} + */ + public function update(Block $block, array $change, string $field): array + { + $this->errors = []; + $this->invalidPaths = []; + $attrs = $block->attrs(); + $short = $block->shortName(); + + if (isset($change['set'])) { + if (! is_array($change['set']) || array_is_list($change['set'])) { + $this->errors[$field . '.set'] = 'Objekt mit Kurzform-Feldern erwartet'; + } else { + $result = $this->shortform->apply($short, $attrs, $change['set'], $field . '.set'); + $attrs = $result['attrs']; + $this->errors += $result['errors']; + } + } + + $attrs = $this->common($block->name(), $attrs, $change, $field, null); + + return ['attrs' => $attrs, 'errors' => $this->errors, 'invalid_paths' => $this->invalidPaths]; + } + + /** Admin label for a new module: " – " or the title alone. */ + public function autoLabel(string $name, string $context): string + { + $title = $this->catalog->title($name); + + return $context === '' ? $title : $context . ' – ' . $title; + } + + private function node(mixed $spec, ?Block $parent, string $path, string $context): ?Block + { + if (! is_array($spec) || array_is_list($spec)) { + $this->errors[$path] = 'Objekt mit "type" erwartet'; + + return null; + } + + $type = $this->typeOf($spec, $parent); + if ($type === null) { + $this->errors[$path . '.type'] = 'type fehlt'; + + return null; + } + + $violation = $this->rules->violation($parent, $type); + if ($violation !== null) { + $this->errors[$path . '.type'] = $violation; + + return null; + } + + return match ($type) { + 'divi/section' => $this->section($spec, $path), + 'divi/row' => $this->row($spec, $path, $context), + default => $this->module($type, $spec, $path, $context), + }; + } + + /** @param array $spec */ + private function typeOf(array $spec, ?Block $parent): ?string + { + if (is_string($spec['type'] ?? null) && $spec['type'] !== '') { + return ModuleCatalog::normalize($spec['type']); + } + + $children = $parent === null ? [] : $this->catalog->childrenOf($parent->name()); + + return count($children) === 1 ? $children[0] : null; + } + + /** @param array $spec */ + private function section(array $spec, string $path): Block + { + $this->unknownKeys($spec, array_merge(self::COMMON, self::SECTION), $path); + $label = is_string($spec['label'] ?? null) ? trim($spec['label']) : ''; + $attrs = ['builderVersion' => $this->builderVersion]; + if (($spec['section_type'] ?? 'regular') === 'fullwidth') { + $attrs = AttrMap::set($attrs, 'module.advanced.type', ['desktop' => ['value' => 'fullwidth']]); + } elseif (($spec['section_type'] ?? 'regular') !== 'regular') { + $this->errors[$path . '.section_type'] = 'regular oder fullwidth erwartet (specialty nur im Divi-Builder)'; + } + + if (isset($spec['background_color'])) { + $attrs = $this->backgroundColor($attrs, $spec['background_color'], $path . '.background_color'); + } + + $section = Block::create('divi/section', $this->common('divi/section', $attrs, $spec, $path, $label)); + if (isset($spec['rows']) && isset($spec['modules']) && ($spec['section_type'] ?? 'regular') !== 'fullwidth') { + $this->errors[$path] = 'entweder rows oder modules angeben'; + + return $section; + } + + if (($spec['section_type'] ?? 'regular') === 'fullwidth') { + $this->children($section, $spec['modules'] ?? [], $path . '.modules', $label); + } elseif (isset($spec['modules'])) { + $this->children($section, [['type' => 'row', 'modules' => $spec['modules']]], $path . '.modules', $label); + } else { + $this->children($section, $spec['rows'] ?? [], $path . '.rows', $label); + } + + return $section; + } + + /** @param array $spec */ + private function row(array $spec, string $path, string $context): Block + { + $this->unknownKeys($spec, array_merge(self::COMMON, self::ROW), $path); + $widths = $this->widths($spec['columns'] ?? '4_4', $path . '.columns'); + $label = $context === '' ? '' : $context . ' – Zeile'; + $attrs = $this->common('divi/row', ['builderVersion' => $this->builderVersion], $spec, $path, $label); + $row = Block::create('divi/row', $attrs); + + $modules = $spec['modules'] ?? []; + if (! is_array($modules) || ! array_is_list($modules)) { + $this->errors[$path . '.modules'] = 'Liste erwartet (je Spalte eine Liste von Modulen)'; + $modules = []; + } + + // One column: a flat list of modules is accepted. + $first = $modules[0] ?? null; + if (count($widths) === 1 && is_array($first) && ! array_is_list($first)) { + $modules = [$modules]; + } + + if (count($modules) > count($widths)) { + $this->errors[$path . '.modules'] = sprintf( + '%d Spalten, aber %d Modul-Listen', + count($widths), + count($modules) + ); + } + + foreach ($widths as $position => $width) { + $column = Block::create('divi/column', AttrMap::set( + ['builderVersion' => $this->builderVersion], + 'module.decoration.sizing', + ['desktop' => ['value' => ['flexType' => $width]]] + )); + $row->insertChild($position, $column); + $list = $modules[$position] ?? []; + $listPath = sprintf('%s.modules[%d]', $path, $position); + $this->children($column, is_array($list) ? $list : [], $listPath, $context); + } + + return $row; + } + + /** @param array $spec */ + private function module(string $name, array $spec, string $path, string $context): Block + { + $short = substr($name, strlen('divi/')); + $fields = array_keys(ShortformMap::fields($short)); + $hasItems = $this->catalog->childrenOf($name) !== []; + $allowed = array_merge( + self::COMMON, + $fields, + $hasItems ? ['items'] : [], + $name === 'divi/group' ? ['modules'] : [] + ); + $this->unknownKeys($spec, $allowed, $path); + + $values = array_intersect_key($spec, array_flip($fields)); + if ($name === 'divi/heading' && ! array_key_exists('level', $values)) { + $values['level'] = 'h2'; + } + + $attrs = ['builderVersion' => $this->builderVersion]; + if ($values !== []) { + $result = $this->shortform->apply($short, $attrs, $values, $path); + $attrs = $result['attrs']; + $this->errors += $result['errors']; + } + + $label = is_string($spec['label'] ?? null) ? trim($spec['label']) : $this->autoLabel($name, $context); + $block = Block::create($name, $this->common($name, $attrs, $spec, $path, $label)); + + if ($hasItems) { + $this->children($block, $spec['items'] ?? [], $path . '.items', $context); + } elseif ($name === 'divi/group') { + $this->children($block, $spec['modules'] ?? [], $path . '.modules', $context); + } + + return $block; + } + + /** + * Preset, full-form attrs and admin label – shared by new and updated blocks. + * + * @param array $attrs + * @param array $spec + * @return array + */ + private function common(string $name, array $attrs, array $spec, string $path, ?string $label): array + { + if (array_key_exists('preset', $spec)) { + $attrs = $this->preset($name, $attrs, $spec['preset'], $path . '.preset'); + } + + if (isset($spec['attrs'])) { + if (! is_array($spec['attrs']) || array_is_list($spec['attrs'])) { + $this->errors[$path . '.attrs'] = 'Objekt "Attributpfad": {Breakpoint: Wert} erwartet'; + } else { + $result = $this->attrWriter->apply($name, $attrs, $spec['attrs'], $path . '.attrs'); + $attrs = $result['attrs']; + $this->errors += $result['errors']; + array_push($this->invalidPaths, ...$result['invalid_paths']); + } + } + + $label = is_string($spec['label'] ?? null) ? trim($spec['label']) : $label; + if ($label !== null && $label !== '') { + $attrs = AttrMap::set($attrs, 'module.meta.adminLabel', ['desktop' => ['value' => $this->clean($label)]]); + } + + return $attrs; + } + + /** + * @param array $attrs + * @return array + */ + private function preset(string $name, array $attrs, mixed $preset, string $path): array + { + if ($preset === null || $preset === '') { + unset($attrs['modulePreset']); + + return $attrs; + } + + $id = is_string($preset) ? $this->design->modulePresetId($name, $preset) : null; + if ($id === null) { + $names = $this->design->modulePresetNames($name); + $this->errors[$path] = sprintf( + 'unbekanntes Preset; vorhanden für %s: %s', + $name, + $names === [] ? '(keine)' : implode(', ', $names) + ); + + return $attrs; + } + + $attrs['modulePreset'] = [$id]; + + return $attrs; + } + + /** + * @param array $attrs + * @return array + */ + private function backgroundColor(array $attrs, mixed $color, string $path): array + { + $result = $this->attrWriter->apply( + 'divi/section', + $attrs, + ['module.decoration.background' => ['desktop' => ['color' => $color]]], + $path + ); + foreach ($result['errors'] as $message) { + $this->errors[$path] = $message; + } + + return $result['attrs']; + } + + /** @param mixed $specs */ + private function children(Block $parent, mixed $specs, string $path, string $context): void + { + if (! is_array($specs) || ($specs !== [] && ! array_is_list($specs))) { + $this->errors[$path] = 'Liste erwartet'; + + return; + } + + foreach ($specs as $position => $spec) { + $child = $this->node($spec, $parent, sprintf('%s[%d]', $path, $position), $context); + if ($child !== null) { + $parent->insertChild(PHP_INT_MAX, $child); + } + } + } + + /** + * "1_3,2_3" → flexTypes ["8_24", "16_24"]; the fractions must add up to one row. + * + * @return list + */ + private function widths(mixed $columns, string $path): array + { + $parts = is_string($columns) ? array_map('trim', explode(',', $columns)) : []; + $widths = []; + $sum = 0.0; + foreach ($parts as $part) { + $valid = preg_match('/^(\d{1,2})_(\d{1,2})$/', $part, $match) === 1 + && (int) $match[1] >= 1 + && (int) $match[1] <= (int) $match[2]; + if (! $valid) { + $this->errors[$path] = 'Spaltenaufteilung wie "1_2,1_2" oder "1_3,2_3" erwartet'; + + return ['24_24']; + } + + [$numerator, $denominator] = [(int) $match[1], (int) $match[2]]; + if (24 % $denominator === 0) { + $widths[] = ($numerator * 24 / $denominator) . '_24'; + } elseif (in_array($part, self::SPECIAL_WIDTHS, true)) { + $widths[] = $part; + } else { + $this->errors[$path] = sprintf('Spaltenbreite %s gibt es in Divi nicht', $part); + + return ['24_24']; + } + $sum += $numerator / $denominator; + } + + if ($widths === [] || abs($sum - 1.0) > 0.01) { + $this->errors[$path] = 'die Spaltenbreiten müssen zusammen eine volle Zeile ergeben (z. B. "1_3,2_3")'; + + return ['24_24']; + } + + return $widths; + } + + /** + * @param array $spec + * @param list $allowed + */ + private function unknownKeys(array $spec, array $allowed, string $path): void + { + foreach (array_diff(array_map('strval', array_keys($spec)), $allowed) as $key) { + $this->errors[$path . '.' . $key] = 'unbekanntes Feld; erlaubt: ' . implode(', ', $allowed); + } + } + + private function clean(string $label): string + { + try { + return (string) $this->attrWriter->cleanValue('divi/section', 'module.meta.adminLabel', $label); + } catch (InvalidArgumentException) { + return $label; + } + } +} +``` + +- [ ] **Step 4: Tests und Lint (grün)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter Compose && composer lint` +Expected: PASS (u. a. die exakte Markup-Erwartung in `testBuildsASectionFromTheShortForm` und dass der Parser das erzeugte Markup unverändert zurückgibt), phpcs ohne Meldungen. + +- [ ] **Step 5: Commit** + +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP +git add src/Integrations/Divi/Compose/StructureRules.php src/Integrations/Divi/Compose/Composer.php tests/Unit/Integrations/Divi/Compose/ +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi composer – short/full form to blocks with structure rules, presets and labels + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- + +### Task 4: Baum ändern und prüfen – `TreeEditor` und `Warnings` + +Spec §5.7: Knoten an `parent` (optional `index`), `before` oder `after` einfügen, verschieben (auch zwischen Zeilen und Abschnitten), entfernen, duplizieren – immer gegen `StructureRules`; ein Knoten kann nicht in seinen eigenen Teilbaum wandern; Verschieben und Löschen erhalten jedes Byte der übrigen Seite (Text zwischen Blöcken bleibt stehen). Spec §5.5: Warnungen nur zu neu erzeugten oder geänderten Knoten (und deren Kindern) – feste Farbe, obwohl eine globale Farbe passt (RGB-Abstand ≤ 24), feste Schrift = Theme-Schrift, Bild ohne Alt-Text (`image`, `blurb`, `slide`), leere Module (Kurzform-Hauptfeld leer, Elternmodul ohne Einträge, leere Struktur), Sprung in der Überschriftenhierarchie (Heading-Standard h1, Blurb h4, CTA h2, `

`–`

` im Text-Modul), Code-Modul. „Ähnliche Abschnitte“ folgt in Etappe 3 (D6). + +**Files:** +- Create: `src/Integrations/Divi/Compose/{TreeEditor,Warnings}.php` +- Test: `tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php`, `tests/Unit/Integrations/Divi/Compose/WarningsTest.php` + +**Interfaces:** +- Consumes: `NodeIndex::find/parentOf/root/idOf/entries`, `Container::indexOf/insertChild/removeChild`, `Block::copy()` (2a); `StructureRules` (Task 3); `DesignSystem::colors/fonts/colorPlaceholder`, `ShortformMap::childType/primary/values`, `NodeSummary::textOf`, `AttrMap::desktop`, `Placeholders::isVariable`. +- Produces: + - `TreeEditor(StructureRules)`: `position(NodeIndex $index, array $input): array{container: Container, parent: ?Block, index: int}|string` (Fehlertext); `insert(array $position, list $blocks): ?string`; `move(NodeIndex $index, Block $node, array $position): ?string`; `remove(NodeIndex $index, Block $node): void`; `duplicate(NodeIndex $index, Block $node): Block` (Kopie direkt dahinter). + - `Warnings(DesignSystem)`: `collect(NodeIndex $index, list $touched): list` (Format `": "`, IDs nach der Änderung). + +- [ ] **Step 1: Failing Tests** + +`tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php`: +```php +parse((string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/' . $name)); + } + + public function testResolvesPositions(): void + { + $document = $this->document(); + $index = NodeIndex::build($document); + $editor = new TreeEditor(ComposeFixture::rules()); + + $end = $editor->position($index, []); + self::assertSame($index->root(), $end['container']); + self::assertNull($end['parent']); + + $inColumn = $editor->position($index, ['parent' => 's0.r0.c1', 'index' => 1]); + self::assertSame($index->find('s0.r0.c1'), $inColumn['parent']); + self::assertSame(1, $inColumn['index']); + + $after = $editor->position($index, ['after' => 's0.r0.c1.m0']); + self::assertSame($index->find('s0.r0.c1'), $after['container']); + self::assertSame(1, $after['index']); + + $beforeSection = $editor->position($index, ['before' => 's1']); + self::assertNull($beforeSection['parent']); + self::assertSame(1, $beforeSection['index']); + + self::assertSame('nur eines von parent, before oder after angeben', $editor->position($index, ['parent' => 's0', 'after' => 's1'])); + self::assertSame('Knoten "s9" gibt es nicht', $editor->position($index, ['before' => 's9'])); + } + + public function testInsertChecksTheStructureRules(): void + { + $document = $this->document(); + $index = NodeIndex::build($document); + $editor = new TreeEditor(ComposeFixture::rules()); + $button = Block::create('divi/button'); + + self::assertSame( + 'auf oberster Ebene sind nur Abschnitte (section) erlaubt', + $editor->insert($editor->position($index, []), [$button]) + ); + self::assertStringContainsString('Zeilen', (string) $editor->insert($editor->position($index, ['parent' => 's0']), [$button])); + self::assertStringContainsString('Spalten entstehen mit der Zeile', (string) $editor->insert($editor->position($index, ['parent' => 's0.r0']), [$button])); + self::assertStringContainsString('accordion-item', (string) $editor->insert($editor->position($index, ['parent' => 's1.r0.c1.m0']), [$button])); + + self::assertNull($editor->insert($editor->position($index, ['after' => 's0.r0.c1.m0']), [$button])); + self::assertSame('divi/button', NodeIndex::build($document)->find('s0.r0.c1.m1')?->name()); + } + + public function testMoveWithinAndAcrossParents(): void + { + $document = $this->document(); + $original = Serializer::document($document); + $index = NodeIndex::build($document); + $editor = new TreeEditor(ComposeFixture::rules()); + $button = $index->find('s0.r0.c1.m2'); + + self::assertNull($editor->move($index, $button, $editor->position($index, ['before' => 's0.r0.c1.m0']))); + $index = NodeIndex::build($document); + self::assertSame($button, $index->find('s0.r0.c1.m0')); + + self::assertNull($editor->move($index, $button, $editor->position($index, ['parent' => 's1.r0.c0']))); + $index = NodeIndex::build($document); + self::assertSame($button, $index->find('s1.r0.c0.m1')); + self::assertStringContainsString('Termin buchen', Serializer::document($document)); + self::assertSame(strlen($original), strlen(Serializer::document($document)), 'moving keeps every byte'); + + $section = $index->find('s0'); + self::assertSame( + 'ein Knoten kann nicht in sich selbst verschoben werden', + $editor->move($index, $section, $editor->position($index, ['parent' => 's0.r0.c0'])) + ); + self::assertNull($editor->move($index, $section, $editor->position($index, []))); + self::assertSame($section, NodeIndex::build($document)->find('s1')); + } + + public function testRemoveAndDuplicate(): void + { + $document = $this->document(); + $index = NodeIndex::build($document); + $editor = new TreeEditor(ComposeFixture::rules()); + + $copy = $editor->duplicate($index, $index->find('s1.r0.c1.m0')); + $index = NodeIndex::build($document); + self::assertSame($copy, $index->find('s1.r0.c1.m1')); + self::assertSame(Serializer::block($index->find('s1.r0.c1.m0')), Serializer::block($copy)); + + $editor->remove($index, $index->find('s0')); + self::assertSame('divi/section', NodeIndex::build($document)->find('s0')?->name()); + self::assertNull(NodeIndex::build($document)->find('s1')); + } + + public function testReadOnlyTargetsAndSpecialtySections(): void + { + $document = $this->document('synthetic-mixed.html'); + $index = NodeIndex::build($document); + $editor = new TreeEditor(ComposeFixture::rules()); + $text = Block::create('divi/text'); + + self::assertStringContainsString('nur lesbar', (string) $editor->insert($editor->position($index, ['parent' => 's1.g1']), [$text])); + self::assertStringContainsString('Spezialabschnitte', (string) $editor->insert($editor->position($index, ['parent' => 's0']), [Block::create('divi/row')])); + self::assertNull($editor->insert($editor->position($index, ['parent' => 's0.c0']), [$text])); + } +} +``` + +`tests/Unit/Integrations/Divi/Compose/WarningsTest.php`: +```php +compose([ + ['type' => 'section', 'label' => 'Test', 'modules' => [ + ['type' => 'heading', 'text' => 'Titel', 'level' => 'h1'], + ['type' => 'heading', 'text' => 'Zu tief', 'level' => 'h4'], + ['type' => 'image', 'src' => 'https://kunde.test/a.jpg'], + ['type' => 'text', 'content' => ''], + ['type' => 'code', 'content' => '
x
'], + ['type' => 'accordion'], + ['type' => 'button', 'text' => 'Los', 'attrs' => [ + 'button.decoration.background' => ['desktop' => ['color' => '#2ea3f3']], + 'button.decoration.font.font' => ['desktop' => ['family' => 'Open Sans', 'color' => '#abcdef']], + ]], + ]], + ], null, 'nodes'); + self::assertSame([], $composed['errors']); + $document = new Document(); + foreach ($composed['blocks'] as $block) { + $document->append($block); + } + + $warnings = (new Warnings(DesignSystemTest::sample()))->collect(NodeIndex::build($document), $composed['blocks']); + + self::assertSame( + [ + 's0.r0.c0.m2: Bild ohne Alt-Text.', + 's0.r0.c0.m3: text ist leer (content fehlt).', + 's0.r0.c0.m4: Code-Modul verwendet – im Divi-Builder nicht visuell bearbeitbar; nur nutzen, wenn kein ' + . 'natives Modul passt.', + 's0.r0.c0.m5: accordion ohne Einträge.', + 's0.r0.c0.m6: feste Farbe #2ea3f3 – globale Farbe "Primärfarbe" ({{color:primary}}) passt.', + 's0.r0.c0.m6: feste Schrift Open Sans – {{font:heading}} verwenden.', + 's0.r0.c0.m1: Sprung in der Überschriftenhierarchie (h4 nach h1).', + ], + $warnings + ); + + self::assertSame([], (new Warnings(DesignSystemTest::sample()))->collect(NodeIndex::build($document), [])); + } +} +``` + +- [ ] **Step 2: Test laufen lassen (rot)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'TreeEditorTest|WarningsTest'` +Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\Compose\TreeEditor" not found`. + +- [ ] **Step 3: Implementierung** + +`src/Integrations/Divi/Compose/TreeEditor.php`: +```php + $input + * @return array{container: Container, parent: ?Block, index: int}|string Position or German error. + */ + public function position(NodeIndex $index, array $input): array|string + { + $given = array_filter( + [ + 'parent' => $input['parent'] ?? null, + 'before' => $input['before'] ?? null, + 'after' => $input['after'] ?? null, + ], + static fn (mixed $value): bool => is_string($value) && $value !== '' + ); + if (count($given) > 1) { + return 'nur eines von parent, before oder after angeben'; + } + + $at = isset($input['index']) && is_int($input['index']) ? max(0, $input['index']) : PHP_INT_MAX; + if ($given === []) { + return ['container' => $index->root(), 'parent' => null, 'index' => $at]; + } + + $key = (string) array_key_first($given); + $node = $index->find((string) $given[$key]); + if ($node === null) { + return sprintf('Knoten "%s" gibt es nicht', (string) $given[$key]); + } + + if ($key === 'parent') { + return ['container' => $node, 'parent' => $node, 'index' => $at]; + } + + $container = $index->parentOf($node); + if ($container === null) { + return sprintf('Knoten "%s" hat keinen Elternknoten', (string) $given[$key]); + } + + $sibling = (int) $container->indexOf($node); + + return [ + 'container' => $container, + 'parent' => $container instanceof Block && $container->name() !== 'divi/placeholder' ? $container : null, + 'index' => $key === 'before' ? $sibling : $sibling + 1, + ]; + } + + /** + * @param array{container: Container, parent: ?Block, index: int} $position + * @param list $blocks + */ + public function insert(array $position, array $blocks): ?string + { + foreach ($blocks as $block) { + $violation = $this->rules->violation($position['parent'], $block->name()); + if ($violation !== null) { + return $violation; + } + } + + foreach (array_values($blocks) as $offset => $block) { + $at = $position['index'] === PHP_INT_MAX ? PHP_INT_MAX : $position['index'] + $offset; + $position['container']->insertChild($at, $block); + } + + return null; + } + + /** @param array{container: Container, parent: ?Block, index: int} $position */ + public function move(NodeIndex $index, Block $node, array $position): ?string + { + for ($ancestor = $position['parent']; $ancestor !== null; $ancestor = $this->parentBlock($index, $ancestor)) { + if ($ancestor === $node) { + return 'ein Knoten kann nicht in sich selbst verschoben werden'; + } + } + + $violation = $this->rules->violation($position['parent'], $node->name()); + if ($violation !== null) { + return $violation; + } + + $from = $index->parentOf($node); + if ($from === null) { + return 'der Knoten hat keinen Elternknoten'; + } + + $old = $from->removeChild($node); + $target = $position['index']; + if ($from === $position['container'] && $target !== PHP_INT_MAX && $old < $target) { + --$target; + } + $position['container']->insertChild($target, $node); + + return null; + } + + public function remove(NodeIndex $index, Block $node): void + { + $index->parentOf($node)?->removeChild($node); + } + + /** Inserts a copy right after $node and returns it. */ + public function duplicate(NodeIndex $index, Block $node): Block + { + $copy = $node->copy(); + $container = $index->parentOf($node); + if ($container !== null) { + $container->insertChild((int) $container->indexOf($node) + 1, $copy); + } + + return $copy; + } + + private function parentBlock(NodeIndex $index, Block $block): ?Block + { + $parent = $index->parentOf($block); + + return $parent instanceof Block && $parent->name() !== 'divi/placeholder' ? $parent : null; + } +} +``` + +`src/Integrations/Divi/Compose/Warnings.php`: +```php + 1, 'divi/blurb' => 4, 'divi/cta' => 2]; + + private const IMAGE_SOURCES = [ + 'divi/image' => ['image.innerContent', 'src', 'alt'], + 'divi/blurb' => ['imageIcon.innerContent', 'src', 'alt'], + 'divi/slide' => ['image.innerContent', 'src', 'alt'], + ]; + + public function __construct(private DesignSystem $design) + { + } + + /** + * @param list $touched Created or changed blocks (their descendants count too). + * @return list + */ + public function collect(NodeIndex $index, array $touched): array + { + $blocks = []; + foreach ($touched as $block) { + foreach ($this->withDescendants($block) as $node) { + $blocks[spl_object_id($node)] = $node; + } + } + + $warnings = []; + foreach ($blocks as $block) { + $id = $index->idOf($block) ?? '?'; + array_push( + $warnings, + ...$this->code($block, $id), + ...$this->image($block, $id), + ...$this->empty($block, $id), + ...$this->fixedValues($block, $id) + ); + } + + return array_values(array_unique(array_merge($warnings, $this->headings($index, $blocks)))); + } + + /** @return list */ + private function withDescendants(Block $block): array + { + $all = [$block]; + if (! $block->isOpaque()) { + foreach ($block->children() as $child) { + array_push($all, ...$this->withDescendants($child)); + } + } + + return $all; + } + + /** @return list */ + private function code(Block $block, string $id): array + { + return in_array($block->name(), ['divi/code', 'divi/fullwidth-code'], true) + ? [sprintf('%s: Code-Modul verwendet – im Divi-Builder nicht visuell bearbeitbar; nur nutzen, wenn kein ' + . 'natives Modul passt.', $id)] + : []; + } + + /** @return list */ + private function image(Block $block, string $id): array + { + $spec = self::IMAGE_SOURCES[$block->name()] ?? null; + if ($spec === null) { + return []; + } + + $value = AttrMap::desktop($block->attrs(), $spec[0]); + $src = is_array($value) ? ($value[$spec[1]] ?? '') : ''; + $alt = is_array($value) ? ($value[$spec[2]] ?? '') : ''; + + return is_string($src) && $src !== '' && (! is_string($alt) || trim($alt) === '') + ? [sprintf('%s: Bild ohne Alt-Text.', $id)] + : []; + } + + /** @return list */ + private function empty(Block $block, string $id): array + { + $name = $block->name(); + if (in_array($name, ['divi/section', 'divi/row', 'divi/column', 'divi/group'], true)) { + return $block->children() === [] ? [sprintf('%s: leer (%s ohne Inhalt).', $id, $block->shortName())] : []; + } + + $type = $block->shortName(); + if (ShortformMap::childType($type) !== null && $block->children() === []) { + return [sprintf('%s: %s ohne Einträge.', $id, $type)]; + } + + $primary = ShortformMap::primary($type); + if ($primary === null || ShortformMap::childType($type) !== null) { + return []; + } + + $values = ShortformMap::values($type, $block->attrs()); + $value = $values[$primary] ?? ''; + + return NodeSummary::textOf($value) === '' ? [sprintf('%s: %s ist leer (%s fehlt).', $id, $type, $primary)] : []; + } + + /** @return list */ + private function fixedValues(Block $block, string $id): array + { + $warnings = []; + $fonts = array_filter($this->design->fonts()); + foreach ($this->leaves($block->attrs()) as [$key, $value]) { + if (preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', trim($value)) === 1) { + $match = $this->similarColor($value); + if ($match !== null) { + $warnings[] = sprintf( + '%s: feste Farbe %s – globale Farbe "%s" (%s) passt.', + $id, + $value, + $match['label'], + DesignSystem::colorPlaceholder($match['id'], $match['label']) + ); + } + } elseif ($key === 'family') { + $font = array_search(mb_strtolower(trim($value)), array_map('mb_strtolower', $fonts), true); + if ($font !== false) { + $warnings[] = sprintf('%s: feste Schrift %s – {{font:%s}} verwenden.', $id, $value, $font); + } + } + } + + return $warnings; + } + + /** + * Heading jumps (h2 → h4) in page order where the later heading belongs to a touched block. + * + * @param array $touched + * @return list + */ + private function headings(NodeIndex $index, array $touched): array + { + $warnings = []; + $previous = null; + foreach ($index->entries() as $entry) { + $block = $entry['block']; + foreach ($this->levels($block) as $level) { + if ($previous !== null && $level > $previous + 1 && isset($touched[spl_object_id($block)])) { + $warnings[] = sprintf( + '%s: Sprung in der Überschriftenhierarchie (h%d nach h%d).', + $entry['id'], + $level, + $previous + ); + } + $previous = $level; + } + } + + return $warnings; + } + + /** @return list Heading levels a block renders, in order. */ + private function levels(Block $block): array + { + $default = self::HEADING_DEFAULTS[$block->name()] ?? null; + if ($default !== null) { + $font = AttrMap::desktop($block->attrs(), 'title.decoration.font.font'); + $level = is_array($font) && is_string($font['headingLevel'] ?? null) + ? $font['headingLevel'] + : 'h' . $default; + + return [(int) substr($level, 1)]; + } + + if ($block->name() === 'divi/text') { + $html = AttrMap::desktop($block->attrs(), 'content.innerContent'); + preg_match_all('/design->colors() as $color) { + $other = self::rgb($color['value']); + if ($rgb !== null && $other !== null && self::distance($rgb, $other) <= self::SIMILAR) { + return $color; + } + } + + return null; + } + + /** + * @param array $values + * @return list [last key, value] of every string leaf (variables excluded). + */ + private function leaves(array $values): array + { + $leaves = []; + foreach ($values as $name => $value) { + if (is_array($value)) { + array_push($leaves, ...$this->leaves($value)); + } elseif (is_string($value) && ! Placeholders::isVariable($value)) { + $leaves[] = [(string) $name, $value]; + } + } + + return $leaves; + } + + /** @return array{int, int, int}|null */ + private static function rgb(string $hex): ?array + { + $hex = ltrim(trim($hex), '#'); + if (preg_match('/^([0-9a-f]{3}|[0-9a-f]{6})$/i', $hex) !== 1) { + return null; + } + + if (strlen($hex) === 3) { + $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; + } + + return [(int) hexdec(substr($hex, 0, 2)), (int) hexdec(substr($hex, 2, 2)), (int) hexdec(substr($hex, 4, 2))]; + } + + /** + * @param array{int, int, int} $a + * @param array{int, int, int} $b + */ + private static function distance(array $a, array $b): float + { + return sqrt(($a[0] - $b[0]) ** 2 + ($a[1] - $b[1]) ** 2 + ($a[2] - $b[2]) ** 2); + } +} +``` + +- [ ] **Step 4: Tests und Lint (grün)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter Compose && composer lint` +Expected: PASS, phpcs ohne Meldungen. + +- [ ] **Step 5: Commit** + +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP +git add src/Integrations/Divi/Compose/TreeEditor.php src/Integrations/Divi/Compose/Warnings.php tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php tests/Unit/Integrations/Divi/Compose/WarningsTest.php +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi tree editor and quality warnings + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- + +### Task 5: Speicher-Pipeline – `DiviSaver`, `DiviWriter`, `DiviComposition` + +Spec §5.6 und D7: `DiviSaver` legt den Wiederherstellungspunkt an (`RevisionPoint::capture`), speichert mit `wp_slash()` über `wp_update_post()` (Status unverändert) und schaltet dabei **nur für Benutzer ohne `unfiltered_html`** kses aus (`kses_remove_filters()` … `kses_init_filters()` im `finally`) – sonst würde `filter_block_content()` jeden unberührten Block neu serialisieren. Danach Divi-Flags und Divi-CSS-Cache (Fehler dort nur als Warnung über `FollowUp`), und eine Warnung, falls ein Filter den gespeicherten Inhalt verändert hat. Wirft ein Hook, wird der vorherige Inhalt zurückgeschrieben (`WriteFailed` rolled back; misslingt auch das: `partial_write`). `DiviWriter` ist der gemeinsame Rahmen aller Divi-Schreib-Tools: `page()` prüft Rechte, `writable()` und die `tree_rev`-Sperre (Ruling P16); `save()` serialisiert, speichert (nichts, wenn sich nichts ändert), ruft `lit_mcp_after_save`, protokolliert und baut das `ToolResult` (geänderte Knoten mit neuen IDs, Outline der betroffenen Abschnitte, Warnungen, `revision_id`, `rev`, `tree_rev`, `preview_url`, `edit_url` mit `et_fb=1`). `DiviComposition` verdrahtet die Compose-Klassen einmal je Anfrage mit Live-Katalog, Designsystem, `WpValueSanitizer` und `WpMediaResolver`. + +**Files:** +- Create: `src/Integrations/Divi/Pages/{WpValueSanitizer,WpMediaResolver,DiviComposition,DiviChange,DiviSaver,DiviWriter}.php` +- Test: `tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php` + +**Interfaces:** +- Consumes: `RevisionPoint::capture`, `PostGuard::missedSchedule`, `FollowUp::run`, `Errors::revMismatch/notSavedKeeping`, `WriteFailed`, `ToolResult`, `Rev::of/ofPost`, `PostChangeResult` (`afterSave`, `reread`, `log`, `result`), `ChangeLog::recordPartial` (Etappe 1); `DiviPageLoader::load/writable/OUTLINE_HINT`, `DiviPage`, `NodeIndex`, `NodeSummary::label`, `Outline`, `Serializer::document`, `DesignSystemReader`, `DiviModules` (2a); Compose-Klassen (Tasks 1–4). +- Produces: + - `WpValueSanitizer implements ValueSanitizer` (`sanitize_text_field`, `wp_kses_post`, Code ungefiltert nur mit `unfiltered_html`, `esc_url_raw`); `WpMediaResolver implements MediaResolver` (nur Bilder, die der Benutzer lesen darf). + - `DiviComposition` (nicht final): `__construct(DiviModules, DesignSystemReader, ValueSanitizer = new WpValueSanitizer(), MediaResolver = new WpMediaResolver())`; `composer(): Composer`; `attrWriter(): AttrWriter`; `rules(): StructureRules`; `editor(): TreeEditor`; `warnings(): Warnings`. + - `DiviChange(string $summary)`: `changed(Block $b, string $action): self` (inserted/updated/moved/duplicated; alles außer moved zählt für Warnungen); `removed(string $idBefore, Block $b): self`; `warning(string): self`; `entries()`, `touched()`, `warnings()`; `readonly string $summary`. + - `DiviSaver` (nicht final): `REASON`, `CHANGED_ON_SAVE`; `save(WP_Post $post, string $content): array{revision_id: ?int, warnings: list}` (wirft `WriteFailed`). + - `DiviWriter` (nicht final): `__construct(DiviPageLoader, DiviSaver, ChangeLog, DesignSystemReader, DiviComposition)`; `static inputProperties(): array` (`post_id`, `tree_rev`); `static positionProperties(): array` (`parent`, `before`, `after`, `index`); `page(array $input): DiviPage|WP_Error`; `static sectionLabel(NodeIndex $index, ?Block $block): string`; `save(string $ability, DiviPage $page, DiviChange $change, array $input): array` (ToolResult-Array mit `tree_rev`; wirft `WriteFailed`). + +- [ ] **Step 1: Failing Tests** + +`tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php`: +```php + */ + private array $calls = []; + + protected function setUp(): void + { + parent::setUp(); + $this->post = new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_content' => 'ALT']); + Functions\when('wp_save_post_revision')->justReturn(811); + Functions\when('wp_get_post_revisions')->justReturn([811 => new WP_Post(['ID' => 811])]); + Functions\when('update_post_meta')->alias(function (int $id, string $key, string $value): bool { + $this->calls[] = "meta:$key=$value"; + + return true; + }); + Functions\when('kses_remove_filters')->alias(function (): void { + $this->calls[] = 'kses off'; + }); + Functions\when('kses_init_filters')->alias(function (): void { + $this->calls[] = 'kses on'; + }); + } + + public function testSavesWithoutKsesForFilteredUsersAndSetsDiviFlags(): void + { + Functions\when('current_user_can')->justReturn(false); + Functions\when('has_filter')->justReturn(10); + Functions\expect('wp_update_post')->once()->with(['ID' => 7, 'post_content' => 'NEU'], true) + ->andReturnUsing(function (): int { + $this->calls[] = 'update'; + + return 7; + }); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_content' => 'NEU'])); + + $saved = (new DiviSaver())->save($this->post, 'NEU'); + + self::assertSame(['revision_id' => 811, 'warnings' => []], $saved); + self::assertSame( + ['kses off', 'update', 'kses on', 'meta:_et_pb_use_divi_5=on', 'meta:_et_pb_use_builder=on'], + $this->calls + ); + } + + public function testKeepsKsesUntouchedForUnfilteredUsersAndWarnsWhenAFilterChangedTheContent(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('wp_update_post')->justReturn(7); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_content' => 'NEU, gefiltert'])); + + $saved = (new DiviSaver())->save($this->post, 'NEU'); + + self::assertNotContains('kses off', $this->calls); + self::assertSame([DiviSaver::CHANGED_ON_SAVE], $saved['warnings']); + } + + public function testRefusedUpdateIsACleanFailure(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('wp_update_post')->justReturn(new WP_Error('db_update_error', 'DB kaputt')); + + try { + (new DiviSaver())->save($this->post, 'NEU'); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + self::assertSame(811, $e->revisionId); + self::assertStringContainsString('Revision 811 bleibt erhalten', $e->toError()->get_error_message()); + } + } + + public function testAThrowingHookRestoresThePreviousContent(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_status' => 'draft'])); + $written = []; + Functions\when('wp_update_post')->alias(static function (array $data) use (&$written): int { + $written[] = $data['post_content']; + if ($data['post_content'] === 'NEU') { + throw new RuntimeException('save_post hook failed'); + } + + return 7; + }); + + try { + (new DiviSaver())->save($this->post, 'NEU'); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + self::assertSame(['NEU', 'ALT'], $written); + } + } + + public function testFailedRestoreIsAPartialWrite(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_status' => 'draft'])); + Functions\when('wp_update_post')->alias(static function (): int { + throw new RuntimeException('database gone'); + }); + + try { + (new DiviSaver())->save($this->post, 'NEU'); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertSame('lit_partial_write', $e->toError()->get_error_code()); + } + } +} +``` + +`tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php`: +```php +allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog()); + $modules->allows('builderVersion')->andReturn('5.2.1'); + $design = Mockery::mock(DesignSystemReader::class); + $design->allows('read')->andReturn(DesignSystemTest::sample()); + + return new DiviWriter( + new DiviPageLoader(DiviPageLoaderTest::schema()), + $saver, + $log, + $design, + new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media()) + ); + } + + public function testPageChecksTheTreeRevLock(): void + { + $content = DiviPageLoaderTest::fixture('synthetic-basic.html'); + DiviPageLoaderTest::givenPost($content); + $writer = $this->writer(Mockery::mock(DiviSaver::class), Mockery::mock(ChangeLog::class)); + + self::assertInstanceOf(DiviPage::class, $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)])); + self::assertSame('lit_rev_mismatch', $writer->page(['post_id' => 7])->get_error_code()); + $stale = $writer->page(['post_id' => 7, 'tree_rev' => Rev::ofPost(DiviPageLoaderTest::givenPost($content))]); + self::assertSame('lit_rev_mismatch', $stale->get_error_code(), 'the post rev is no tree_rev'); + } + + public function testSectionLabelOfANode(): void + { + DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html')); + $page = (new DiviPageLoader(DiviPageLoaderTest::schema()))->load(7, true); + + self::assertSame('Hero', DiviWriter::sectionLabel($page->index, $page->index->find('s0.r0.c1'))); + self::assertSame('', DiviWriter::sectionLabel($page->index, null)); + } + + public function testUnchangedTreeIsNotSaved(): void + { + $content = DiviPageLoaderTest::fixture('synthetic-basic.html'); + DiviPageLoaderTest::givenPost($content); + $saver = Mockery::mock(DiviSaver::class); + $saver->expects('save')->never(); + $writer = $this->writer($saver, Mockery::mock(ChangeLog::class)); + $page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]); + + $result = $writer->save('lit/divi-update', $page, new DiviChange('nichts'), []); + + self::assertSame(Rev::of($content), $result['tree_rev']); + self::assertStringStartsWith('Keine Änderung', $result['summary']); + } + + public function testFailedRollbackIsLoggedAsPartial(): void + { + $content = DiviPageLoaderTest::fixture('synthetic-basic.html'); + DiviPageLoaderTest::givenPost($content); + $saver = Mockery::mock(DiviSaver::class); + $saver->allows('save')->andThrow(new WriteFailed(DiviSaver::REASON, false, 7, 811)); + $log = Mockery::mock(ChangeLog::class); + $log->expects('recordPartial')->with('lit/divi-delete', 'page', '7', 'Divi "Start": Änderung nur teilweise zurückgenommen', 811); + $writer = $this->writer($saver, $log); + $page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]); + $page->document->children()[0]->removeChild($page->index->find('s1')); + + $this->expectException(WriteFailed::class); + $writer->save('lit/divi-delete', $page, new DiviChange('s1 gelöscht'), []); + } +} +``` + +- [ ] **Step 2: Test laufen lassen (rot)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'DiviSaverTest|DiviWriterTest'` +Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\Pages\DiviSaver" not found`. + +- [ ] **Step 3: Implementierung** + +`src/Integrations/Divi/Pages/WpValueSanitizer.php`: +```php + $url, 'alt' => (string) get_post_meta($id, '_wp_attachment_image_alt', true)]; + } +} +``` + +`src/Integrations/Divi/Pages/DiviComposition.php`: +```php +composer === null) { + $placeholders = new Placeholders($this->design->read()); + $this->composer = new Composer( + $this->modules->catalog(), + $this->rules(), + new ShortformWriter($placeholders, $this->sanitizer, $this->media, $this->attrWriter()), + $this->attrWriter(), + $this->design->read(), + $this->modules->builderVersion() + ); + } + + return $this->composer; + } + + public function attrWriter(): AttrWriter + { + return $this->attrWriter ??= new AttrWriter( + $this->modules->catalog(), + new Placeholders($this->design->read()), + $this->sanitizer + ); + } + + public function rules(): StructureRules + { + return new StructureRules($this->modules->catalog()); + } + + public function editor(): TreeEditor + { + return new TreeEditor($this->rules()); + } + + public function warnings(): Warnings + { + return new Warnings($this->design->read()); + } +} +``` + +`src/Integrations/Divi/Pages/DiviChange.php`: +```php + */ + private array $changed = []; + + /** @var list */ + private array $touched = []; + + /** @var list */ + private array $warnings = []; + + public function __construct(public readonly string $summary) + { + } + + /** A node that still exists after the change (inserted, updated, moved, duplicated). */ + public function changed(Block $block, string $action): self + { + $this->changed[] = [ + 'block' => $block, + 'id' => null, + 'type' => $block->name(), + 'action' => $action, + 'label' => NodeSummary::label($block), + ]; + if ($action !== 'moved') { + $this->touched[] = $block; + } + + return $this; + } + + /** A node that is gone; $id is its ID before the change. */ + public function removed(string $id, Block $block): self + { + $this->changed[] = [ + 'block' => null, + 'id' => $id, + 'type' => $block->name(), + 'action' => 'deleted', + 'label' => NodeSummary::label($block), + ]; + + return $this; + } + + public function warning(string $warning): self + { + $this->warnings[] = $warning; + + return $this; + } + + /** @return list */ + public function entries(): array + { + return $this->changed; + } + + /** @return list */ + public function touched(): array + { + return $this->touched; + } + + /** @return list */ + public function warnings(): array + { + return $this->warnings; + } +} +``` + +`src/Integrations/Divi/Pages/DiviSaver.php`: +```php +} + * @throws WriteFailed + */ + public function save(WP_Post $post, string $content): array + { + $postId = (int) $post->ID; + $revisionId = RevisionPoint::capture($postId); + + try { + $result = $this->update($postId, $content); + } catch (Throwable $e) { + $this->restore($postId, (string) $post->post_content, $revisionId, $e); + } + + if ($result instanceof WP_Error || $result === 0) { + $message = $result instanceof WP_Error ? $result->get_error_message() : 'returned 0'; + throw new WriteFailed( + self::REASON, + true, + $postId, + $revisionId, + null, + sprintf('wp_update_post(%d) refused: %s', $postId, $message), + rolledBackState: self::keeping($revisionId) + ); + } + + $warnings = []; + FollowUp::run( + static function () use ($postId): void { + update_post_meta($postId, '_et_pb_use_divi_5', 'on'); + update_post_meta($postId, '_et_pb_use_builder', 'on'); + }, + 'divi builder flags', + 'Die Divi-Kennzeichen der Seite konnten nicht gesetzt werden; der Inhalt ist gespeichert – die Seite ' + . 'einmal im Divi-Builder öffnen und speichern.', + $warnings + ); + FollowUp::run( + static function () use ($postId): void { + if (class_exists('ET_Core_PageResource')) { + \ET_Core_PageResource::remove_static_resources($postId, 'all', true); + } + }, + 'divi static css', + 'Der CSS-Cache von Divi konnte nicht geleert werden; die Änderung ist gespeichert, erscheint aber evtl. ' + . 'erst nach „Statische CSS-Dateien leeren“ (Divi → Theme-Optionen → Builder).', + $warnings + ); + + $stored = get_post($postId); + if ($stored instanceof WP_Post && (string) $stored->post_content !== $content) { + $warnings[] = self::CHANGED_ON_SAVE; + } + + return ['revision_id' => $revisionId, 'warnings' => $warnings]; + } + + private function update(int $postId, string $content): int|WP_Error + { + $kses = ! current_user_can('unfiltered_html') + && has_filter('content_save_pre', 'wp_filter_post_kses') !== false; + if ($kses) { + kses_remove_filters(); + } + + try { + return wp_update_post(wp_slash(['ID' => $postId, 'post_content' => $content]), true); + } finally { + if ($kses) { + kses_init_filters(); + } + } + } + + /** @throws WriteFailed Always. */ + private function restore(int $postId, string $before, ?int $revisionId, Throwable $cause): never + { + $detail = sprintf('Saving Divi content of post %d failed: %s', $postId, $cause->getMessage()); + try { + $current = get_post($postId); + if ($current instanceof WP_Post && PostGuard::missedSchedule($current)) { + throw new RuntimeException('restoring would publish a missed schedule'); + } + + $restored = $this->update($postId, $before); + if ($restored instanceof WP_Error || $restored === 0) { + throw new RuntimeException('previous content could not be written back'); + } + } catch (Throwable $e) { + $detail .= '; rollback: ' . $e->getMessage(); + throw new WriteFailed(self::REASON, false, $postId, $revisionId, $cause, $detail); + } + + throw new WriteFailed( + self::REASON, + true, + $postId, + $revisionId, + $cause, + $detail, + rolledBackState: self::keeping($revisionId) + ); + } + + /** write_failed must not claim "nothing was saved" while the restore-point revision stays. */ + private static function keeping(?int $revisionId): ?string + { + return $revisionId === null ? null : Errors::notSavedKeeping(sprintf('Revision %d', $revisionId)); + } +} +``` + +`src/Integrations/Divi/Pages/DiviWriter.php`: +```php +> post_id and tree_rev of every Divi write. */ + public static function inputProperties(): array + { + return [ + 'post_id' => ['type' => 'integer', 'minimum' => 1], + 'tree_rev' => [ + 'type' => 'string', + 'description' => 'tree_rev aus lit-divi-outline bzw. der letzten Divi-Änderung (Pflicht).', + ], + ]; + } + + /** @return array> Target position of insert and move. */ + public static function positionProperties(): array + { + return [ + 'parent' => [ + 'type' => 'string', + 'description' => 'Pfad-ID des Elternknotens (z. B. Spalte "s0.r0.c1"); ohne parent/before/after: ' + . 'Ende der Seite.', + ], + 'before' => ['type' => 'string', 'description' => 'Pfad-ID: vor diesem Knoten einfügen.'], + 'after' => ['type' => 'string', 'description' => 'Pfad-ID: nach diesem Knoten einfügen.'], + 'index' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Position unter parent (0 = zuerst).'], + ]; + } + + /** + * The page to change, after rights, type, layout and the tree_rev lock were checked. + * + * @param array $input + */ + public function page(array $input): DiviPage|WP_Error + { + $page = $this->loader->load($input['post_id'] ?? null, true); + if ($page instanceof WP_Error) { + return $page; + } + + $problem = $this->loader->writable($page); + if ($problem !== null) { + return $problem; + } + + if (! is_string($input['tree_rev'] ?? null) || $input['tree_rev'] !== $page->treeRev()) { + return Errors::revMismatch( + 'Die Divi-Seite wurde seit dem letzten Lesen geändert (tree_rev passt nicht); nichts wurde ' + . 'geschrieben.', + DiviPageLoader::OUTLINE_HINT + ); + } + + return $page; + } + + /** Section label around $block, for automatic admin labels ("" when none). */ + public static function sectionLabel(NodeIndex $index, ?Block $block): string + { + for ($node = $block; $node !== null; $node = self::parentBlock($index, $node)) { + if ($node->name() === 'divi/section') { + return NodeSummary::label($node); + } + } + + return ''; + } + + /** + * Saves the changed tree of $page and returns the ToolResult array. + * + * @param array $input + * @return array + * @throws WriteFailed + */ + public function save(string $ability, DiviPage $page, DiviChange $change, array $input): array + { + $post = $page->post; + $content = Serializer::document($page->document); + if ($content === (string) $post->post_content) { + return ToolResult::ok('Keine Änderung – die Seite ist bereits so.') + ->rev(Rev::ofPost($post)) + ->with('tree_rev', $page->treeRev()) + ->warnings($change->warnings()) + ->toArray(); + } + + $index = NodeIndex::build($page->document); + $quality = $this->composition->warnings()->collect($index, $change->touched()); + $warnings = array_merge($change->warnings(), $quality); + + try { + $saved = $this->saver->save($post, $content); + } catch (WriteFailed $e) { + if (! $e->rolledBack) { + $this->log->recordPartial( + $ability, + (string) $post->post_type, + (string) $post->ID, + sprintf('Divi "%s": Änderung nur teilweise zurückgenommen', $post->post_title), + $e->revisionId + ); + } + throw $e; + } + + $result = (new PostChangeResult( + $ability, + $this->log, + (int) $post->ID, + 'die Änderung', + array_merge($warnings, $saved['warnings']) + ))->afterSave($input); + $updated = $result->reread($post) ?? $post; + $summary = sprintf('Divi "%s": %s', $post->post_title, $change->summary); + $result->log((string) $post->post_type, $summary, $saved['revision_id']); + + $status = sprintf('%s (Status unverändert: %s).', $change->summary, $updated->post_status); + $tool = $result->result($status, $updated) + ->revisionId($saved['revision_id']) + ->outline($this->outline($index, $change)) + ->with('tree_rev', Rev::of((string) $updated->post_content)) + ->urls((string) get_preview_post_link($updated), $page->editUrl()); + foreach ($change->entries() as $entry) { + $id = $entry['id'] ?? ($entry['block'] !== null ? $index->idOf($entry['block']) : null); + $tool->changed($id ?? '?', $entry['type'], $entry['action'], $entry['label']); + } + + return $tool->toArray(); + } + + /** Outline of the sections that contain changed nodes. */ + private function outline(NodeIndex $index, DiviChange $change): ?string + { + $sections = []; + foreach ($change->entries() as $entry) { + $id = $entry['block'] !== null ? $index->idOf($entry['block']) : null; + if ($id !== null) { + $sections[explode('.', $id)[0]] = true; + } + } + + if ($sections === []) { + return null; + } + + $outline = new Outline($this->design->read()->presetNames()); + $texts = []; + foreach (array_keys($sections) as $sectionId) { + $texts[] = $outline->text($outline->rows($index, (string) $sectionId)); + } + + return implode("\n", $texts); + } + + private static function parentBlock(NodeIndex $index, Block $block): ?Block + { + $parent = $index->parentOf($block); + + return $parent instanceof Block && $parent->name() !== 'divi/placeholder' ? $parent : null; + } +} +``` + +- [ ] **Step 4: Tests und Lint (grün)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test && composer lint` +Expected: PASS, phpcs ohne Meldungen. + +- [ ] **Step 5: Commit** + +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP +git add src/Integrations/Divi/Pages/WpValueSanitizer.php src/Integrations/Divi/Pages/WpMediaResolver.php src/Integrations/Divi/Pages/DiviComposition.php src/Integrations/Divi/Pages/DiviChange.php src/Integrations/Divi/Pages/DiviSaver.php src/Integrations/Divi/Pages/DiviWriter.php tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi save pipeline – tree_rev lock, kses-safe save, revision, flags, cache, ToolResult + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- + +### Task 6: `lit/divi-insert` und `lit/divi-update` + +Spec §5.7: Einfügen von Knoten in Kurz- und/oder Vollform – Module, Zeilen oder ganze Abschnitte – an `parent`/`before`/`after` (ohne Position ans Seitenende); Ändern mehrerer vorhandener Knoten in einem Aufruf (`changes` mit `set`, `attrs`, `preset`, `label`), alles oder nichts. Unbekannte Attributpfade → neuer Fehlercode `invalid_attr_path` (Spec §8.2) mit `fields`; nur lesbare Knoten (globale Module, fremde Blöcke) lassen sich nicht ändern. Die Divi-Integration bekommt dafür das Änderungsprotokoll. + +**Files:** +- Create: `src/Integrations/Divi/Pages/{DiviInsertAbility,DiviUpdateAbility}.php` +- Modify: `src/Abilities/Errors.php` (`invalidAttrPath()`), `src/Integrations/Divi/DiviIntegration.php` (ganze Datei ersetzen), `src/Plugin.php`, `docs/guides/overview.md` +- Test: `tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` + +**Interfaces:** +- Consumes: `DiviWriter::page/save/inputProperties/positionProperties/sectionLabel`, `DiviChange`, `DiviComposition::composer/editor` (Task 5); `Composer::compose/update`, `TreeEditor::position/insert` (Tasks 3–4); `ToolResult::schema`. +- Produces: + - `Errors::invalidAttrPath(array $fields): WP_Error` (Code `lit_invalid_attr_path`, Status 400). + - `DiviInsertAbility(DiviWriter, DiviComposition)` – `lit/divi-insert`, direkt; Input `{post_id, tree_rev, nodes (1–20 Objekte), parent?, before?, after?, index?}`. + - `DiviUpdateAbility(DiviWriter, DiviComposition)` – `lit/divi-update`, direkt, idempotent; Input `{post_id, tree_rev, changes: [{id, set?, attrs?, preset?, label?}] (1–50)}`. + - `DiviIntegration(ContentSchema $schema, ChangeLog $log)` (neue Signatur). + +- [ ] **Step 1: Failing Tests** + +`tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php`: +```php + Contents handed to DiviSaver::save(). */ + private array $saved = []; + + private DiviWriter $writer; + private DiviComposition $composition; + + protected function setUp(): void + { + parent::setUp(); + $this->original = DiviPageLoaderTest::fixture('synthetic-basic.html'); + $this->post = DiviPageLoaderTest::givenPost($this->original); + Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true'); + + $modules = Mockery::mock(DiviModules::class); + $modules->allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog()); + $modules->allows('builderVersion')->andReturn('5.2.1'); + $design = Mockery::mock(DesignSystemReader::class); + $design->allows('read')->andReturn(DesignSystemTest::sample()); + $this->composition = new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media()); + + $saver = Mockery::mock(DiviSaver::class); + $saver->allows('save')->andReturnUsing(function (WP_Post $post, string $content): array { + $this->saved[] = $content; + $post->post_content = $content; + + return ['revision_id' => 811, 'warnings' => []]; + }); + $log = Mockery::mock(ChangeLog::class); + $log->allows('record')->andReturn(4711); + + $this->writer = new DiviWriter( + new DiviPageLoader(DiviPageLoaderTest::schema()), + $saver, + $log, + $design, + $this->composition + ); + } + + private function treeRev(): string + { + return Rev::of($this->original); + } + + public function testInsertComposesAndSavesWithTreeRevAndPostRev(): void + { + $ability = new DiviInsertAbility($this->writer, $this->composition); + + $result = $ability->execute([ + 'post_id' => 7, + 'tree_rev' => $this->treeRev(), + 'after' => 's0.r0.c1.m1', + 'nodes' => [['type' => 'text', 'content' => '

Neu

']], + ]); + + self::assertTrue($result['ok'], (string) json_encode($result)); + self::assertCount(1, $this->saved); + self::assertSame([['id' => 's0.r0.c1.m2', 'type' => 'divi/text', 'action' => 'inserted', 'label' => 'Hero – Text']], $result['changed']); + self::assertSame(Rev::of($this->saved[0]), $result['tree_rev']); + self::assertSame(Rev::ofPost($this->post), $result['rev']); + self::assertSame(811, $result['revision_id']); + self::assertSame(4711, $result['log_id']); + self::assertStringStartsWith('s0 section "Hero"', (string) $result['outline']); + self::assertSame('https://kunde.test/?page_id=7&et_fb=1', $result['edit_url']); + + // Byte identity: only the new block was added, every other byte is unchanged. + $inserted = ''; + self::assertSame(str_replace('' . $inserted . ''; + private const INPUT = ['post_type' => 'page', 'title' => 'Start', 'divi' => [['type' => 'section']]]; + + private function schema(): ContentSchema + { + Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['create_posts' => 'edit_pages']]); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_current_user_id')->justReturn(3); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + + return $schema; + } + + private function writer(): PostWriter + { + $writer = Mockery::mock(PostWriter::class); + $writer->allows('prepare')->andReturn(['fields' => [], 'terms' => [], 'warnings' => []]); + $writer->allows('postData')->andReturn(['post_title' => 'Start']); + $writer->allows('deniedMeta')->andReturn([]); + $writer->allows('writeExtras'); + $writer->allows('deleteCreatedTerms'); + + return $writer; + } + + private function composer(bool $available = true): ContentComposer + { + $composer = Mockery::mock(ContentComposer::class); + $composer->allows('available')->andReturn($available); + $composer->allows('compose')->with('page', [['type' => 'section']]) + ->andReturn(['content' => self::MARKUP, 'warnings' => ['s0: leer (section ohne Inhalt).']]); + + return $composer; + } + + public function testCreatesADiviDraftWithTreeRev(): void + { + $composer = $this->composer(); + $composer->expects('afterInsert')->with(42); + Functions\expect('wp_insert_post')->once()->with([ + 'post_title' => 'Start', + 'post_type' => 'page', + 'post_status' => 'draft', + 'post_author' => 3, + 'post_content' => self::MARKUP, + ], true)->andReturn(42); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 42, 'post_title' => 'Start', 'post_type' => 'page', 'post_content' => self::MARKUP])); + Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=42&preview=true'); + Functions\when('get_edit_post_link')->justReturn('https://kunde.test/wp-admin/post.php?post=42&action=edit'); + $log = Mockery::mock(ChangeLog::class); + $log->allows('record')->andReturn(9); + + $result = (new ContentCreateAbility($this->schema(), $this->writer(), $log, $composer))->execute(self::INPUT); + + self::assertTrue($result['ok']); + self::assertSame(Rev::of(self::MARKUP), $result['tree_rev']); + self::assertSame(['s0: leer (section ohne Inhalt).'], $result['warnings']); + } + + public function testDiviAndContentExcludeEachOtherAndNeedDivi(): void + { + Functions\expect('wp_insert_post')->never(); + + $both = (new ContentCreateAbility($this->schema(), $this->writer(), Mockery::mock(ChangeLog::class), $this->composer())) + ->execute(self::INPUT + ['content' => '

x

']); + self::assertSame('lit_validation_failed', $both->get_error_code()); + + $inactive = (new ContentCreateAbility($this->schema(), $this->writer(), Mockery::mock(ChangeLog::class), $this->composer(false))) + ->execute(self::INPUT); + self::assertSame('lit_unsupported', $inactive->get_error_code()); + + $none = (new ContentCreateAbility($this->schema(), $this->writer(), Mockery::mock(ChangeLog::class)))->execute(self::INPUT); + self::assertSame('lit_unsupported', $none->get_error_code()); + + $composer = Mockery::mock(ContentComposer::class); + $composer->allows('available')->andReturn(true); + $composer->allows('compose')->andReturn(Errors::invalidInput('Divi-Inhalt ist ungültig; nichts wurde angelegt.')); + $invalid = (new ContentCreateAbility($this->schema(), $this->writer(), Mockery::mock(ChangeLog::class), $composer)) + ->execute(self::INPUT); + self::assertSame('lit_validation_failed', $invalid->get_error_code()); + } + + public function testFailingBuilderFlagsDiscardTheDraft(): void + { + $composer = $this->composer(); + $composer->allows('afterInsert')->andThrow(new RuntimeException('meta failed')); + Functions\when('wp_insert_post')->justReturn(42); + Functions\expect('wp_delete_post')->once()->with(42, true)->andReturn(new WP_Post(['ID' => 42])); + + try { + (new ContentCreateAbility($this->schema(), $this->writer(), Mockery::mock(ChangeLog::class), $composer)) + ->execute(self::INPUT); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + } + } +} +``` + +`tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php`: +```php +allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog()); + $modules->allows('builderVersion')->andReturn('5.2.1'); + $design = Mockery::mock(DesignSystemReader::class); + $design->allows('read')->andReturn(DesignSystemTest::sample()); + + return new DiviContentComposer( + DiviPageLoaderTest::schema($diviEnabled), + new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media()) + ); + } + + public function testComposesAPlaceholderWrappedLayout(): void + { + $result = $this->composer()->compose('page', [ + ['type' => 'section', 'label' => 'Hero', 'modules' => [['type' => 'heading', 'text' => 'Willkommen']]], + ['type' => 'section', 'modules' => [['type' => 'image', 'src' => 'https://kunde.test/a.jpg']]], + ]); + + self::assertStringStartsWith('', $result['content']); + $document = (new Parser())->parse($result['content']); + self::assertCount(2, $document->children()[0]->children()); + self::assertSame(['s1.r0.c0.m0: Bild ohne Alt-Text.'], $result['warnings']); + } + + public function testRefusesTypesWithoutBuilderAndInvalidNodes(): void + { + self::assertSame('lit_validation_failed', $this->composer(false)->compose('post', [['type' => 'section']])->get_error_code()); + + $invalid = $this->composer()->compose('page', [['type' => 'section', 'modules' => [['type' => 'button', 'attrs' => ['x.y' => ['desktop' => 1]]]]]]); + self::assertSame('lit_invalid_attr_path', $invalid->get_error_code()); + self::assertArrayHasKey('divi[0].modules[0].modules[0][0].attrs.x.y', $invalid->get_error_data()['fields']); + } + + public function testAfterInsertSetsTheBuilderFlags(): void + { + $flags = []; + Functions\when('update_post_meta')->alias(static function (int $id, string $key, string $value) use (&$flags): int { + $flags[$key] = $value; + + return 1; + }); + + $this->composer()->afterInsert(42); + self::assertSame(['_et_pb_use_divi_5' => 'on', '_et_pb_use_builder' => 'on'], $flags); + + Functions\when('update_post_meta')->justReturn(false); + $this->expectException(RuntimeException::class); + $this->composer()->afterInsert(42); + } +} +``` + +- [ ] **Step 2: Test laufen lassen (rot)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'ContentCreateDiviTest|DiviContentComposerTest'` +Expected: FAIL – `Interface "LIT\MCP\Integrations\WordPress\Content\ContentComposer" not found`. + +- [ ] **Step 3: Implementierung** + +`src/Integrations/WordPress/Content/ContentComposer.php`: +```php + $sections + * @return array{content: string, warnings: list}|WP_Error + */ + public function compose(string $postType, array $sections): array|WP_Error; + + /** + * Runs after the new draft exists (e.g. builder flags). Throwing makes lit/content-create + * delete the draft again (all or nothing). + */ + public function afterInsert(int $postId): void; +} +``` + +`src/Integrations/Divi/Pages/DiviContentComposer.php`: +```php +schema->diviEnabled($postType)) { + return Errors::invalidInput( + 'Für diesen Inhaltstyp ist der Divi-Builder nicht aktiviert; nichts wurde angelegt.', + ['divi' => 'Divi-Builder für "' . $postType . '" nicht aktiv'] + ); + } + + $composed = $this->composition->composer()->compose(array_values($sections), null, 'divi'); + if ($composed['errors'] !== []) { + return $composed['invalid_paths'] !== [] + ? Errors::invalidAttrPath($composed['errors']) + : Errors::invalidInput('Divi-Inhalt ist ungültig; nichts wurde angelegt.', $composed['errors']); + } + + $document = new Document(); + $document->append(Block::create('divi/placeholder', [], $composed['blocks'])); + + return [ + 'content' => Serializer::document($document), + 'warnings' => $this->composition->warnings()->collect(NodeIndex::build($document), $composed['blocks']), + ]; + } + + public function afterInsert(int $postId): void + { + if ( + update_post_meta($postId, '_et_pb_use_divi_5', 'on') === false + || update_post_meta($postId, '_et_pb_use_builder', 'on') === false + ) { + throw new RuntimeException(sprintf('Divi builder flags of post %d could not be set.', $postId)); + } + } +} +``` + +In `src/Integrations/WordPress/Content/ContentCreateAbility.php` ersetzen: +```php +use LIT\MCP\Abilities\Errors; +``` +durch: +```php +use LIT\MCP\Abilities\Errors; +use LIT\MCP\Abilities\Rev; +``` + +In `src/Integrations/WordPress/Content/ContentCreateAbility.php` ersetzen: +```php + private PostWriter $writer, + private ChangeLog $log + ) { + } +``` +durch: +```php + private PostWriter $writer, + private ChangeLog $log, + private ?ContentComposer $divi = null + ) { + } +``` + +In `src/Integrations/WordPress/Content/ContentCreateAbility.php` ersetzen: +```php + . 'in einem Schritt; bei einem Fehler wird nichts angelegt.'; +``` +durch: +```php + . 'in einem Schritt; bei einem Fehler wird nichts angelegt. Divi-Seiten direkt mit Inhalt: divi = Liste ' + . 'von Abschnitten in Kurzform (lit-guide topic "divi-shortform"), statt content.'; +``` + +In `src/Integrations/WordPress/Content/ContentCreateAbility.php` ersetzen: +```php + 'title' => ['type' => 'string', 'minLength' => 1], + ], PostWriter::inputProperties()), +``` +durch: +```php + 'title' => ['type' => 'string', 'minLength' => 1], + 'divi' => [ + 'type' => 'array', + 'minItems' => 1, + 'maxItems' => 30, + 'items' => ['type' => 'object'], + 'description' => 'Divi-Layout in Kurzform: Abschnitte wie bei lit-divi-insert (nicht zusammen mit ' + . 'content).', + ], + ], PostWriter::inputProperties()), +``` + +In `src/Integrations/WordPress/Content/ContentCreateAbility.php` ersetzen: +```php + 'post_author' => get_current_user_id(), + ]); +``` +durch: +```php + 'post_author' => get_current_user_id(), + ]); + $warnings = $prepared['warnings']; + if (array_key_exists('divi', $input)) { + $divi = $this->composeDivi($postType, $input); + if ($divi instanceof WP_Error) { + return $divi; + } + $data['post_content'] = $divi['content']; + $warnings = array_merge($warnings, $divi['warnings']); + } +``` + +In `src/Integrations/WordPress/Content/ContentCreateAbility.php` ersetzen: +```php + try { + $this->writer->writeExtras($postId, $postType, $prepared, $input); + } catch (Throwable $e) { + $this->discard($postId, $postType, $e); + } + + return $this->result($postId, $postType, $input, $prepared['warnings']); + } +``` +durch: +```php + try { + $this->writer->writeExtras($postId, $postType, $prepared, $input); + if (array_key_exists('divi', $input) && $this->divi !== null) { + $this->divi->afterInsert($postId); + } + } catch (Throwable $e) { + $this->discard($postId, $postType, $e); + } + + return $this->result($postId, $postType, $input, $warnings); + } + + /** + * Divi layout from the short form (spec §6.2) – validated completely before the insert. + * + * @param array $input + * @return array{content: string, warnings: list}|WP_Error + */ + private function composeDivi(string $postType, array $input): array|WP_Error + { + if ($this->divi === null || ! $this->divi->available()) { + return Errors::unsupported( + 'Divi-Inhalt ist auf dieser Website nicht möglich (Divi 5 ist nicht aktiv); nichts wurde angelegt.', + 'Ohne divi anlegen und den Inhalt über content oder im Editor setzen.' + ); + } + + if (array_key_exists('content', $input)) { + return Errors::invalidInput( + 'content und divi schließen sich aus; nichts wurde angelegt.', + ['content' => 'bei Divi-Inhalt weglassen'] + ); + } + + return $this->divi->compose($postType, is_array($input['divi']) ? array_values($input['divi']) : []); + } +``` + +In `src/Integrations/WordPress/Content/ContentCreateAbility.php` ersetzen: +```php + return $change->result(sprintf('"%s" als Entwurf angelegt (ID %d).', $title, $postId), $post) + ->changed((string) $postId, $postType, 'created', $title) + ->urls((string) get_preview_post_link($postId), (string) get_edit_post_link($postId, 'raw')) + ->toArray(); +``` +durch: +```php + $result = $change->result(sprintf('"%s" als Entwurf angelegt (ID %d).', $title, $postId), $post) + ->changed((string) $postId, $postType, 'created', $title) + ->urls((string) get_preview_post_link($postId), (string) get_edit_post_link($postId, 'raw')); + if (array_key_exists('divi', $input) && $post instanceof WP_Post) { + // The Divi tools lock on tree_rev (ruling P16): the next lit-divi-* call can use it right away. + $result->with('tree_rev', Rev::of((string) $post->post_content)); + } + + return $result->toArray(); +``` + +In `src/Integrations/WordPress/WordPressIntegration.php` ersetzen: +```php +use LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility; +``` +durch: +```php +use LIT\MCP\Integrations\WordPress\Content\ContentComposer; +use LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility; +``` + +In `src/Integrations/WordPress/WordPressIntegration.php` ersetzen: +```php + public function __construct(private ContentSchema $schema, private ChangeLog $log) + { + } +``` +durch: +```php + public function __construct( + private ContentSchema $schema, + private ChangeLog $log, + private ?ContentComposer $divi = null + ) { + } +``` + +In `src/Integrations/WordPress/WordPressIntegration.php` ersetzen: +```php + new ContentCreateAbility($this->schema, $writer, $this->log), +``` +durch: +```php + new ContentCreateAbility($this->schema, $writer, $this->log, $this->divi), +``` + +In `src/Integrations/Divi/DiviIntegration.php` ersetzen: +```php +use LIT\MCP\Integrations\Divi\Pages\DiviComposition; +``` +durch: +```php +use LIT\MCP\Integrations\Divi\Pages\DiviComposition; +use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; +``` + +In `src/Integrations/Divi/DiviIntegration.php` ersetzen: +```php +use LIT\MCP\Integrations\Integration; +``` +durch: +```php +use LIT\MCP\Integrations\Integration; +use LIT\MCP\Integrations\WordPress\Content\ContentComposer; +``` + +In `src/Integrations/Divi/DiviIntegration.php` ersetzen: +```php + * 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). + */ +``` +durch: +```php + * 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). contentComposer() gives + * lit/content-create the Divi short form (spec §6.2). + */ +``` + +In `src/Integrations/Divi/DiviIntegration.php` ersetzen: +```php + public function abilities(): array + { + $loader +``` +durch: +```php + public function contentComposer(): ContentComposer + { + return new DiviContentComposer($this->schema, $this->composition); + } + + public function abilities(): array + { + $loader +``` + +In `src/Plugin.php` ersetzen: +```php + $schema = self::contentSchema(); + $registry = new IntegrationRegistry( + new CoreIntegration(self::pluginDir() . '/docs/guides'), + new WordPressIntegration($schema, $changeLog), + new MediaIntegration($schema, $changeLog, $limits), + new DiviIntegration($schema, $changeLog) + ); +``` +durch: +```php + $schema = self::contentSchema(); + $divi = new DiviIntegration($schema, $changeLog); + $registry = new IntegrationRegistry( + new CoreIntegration(self::pluginDir() . '/docs/guides'), + new WordPressIntegration($schema, $changeLog, $divi->contentComposer()), + new MediaIntegration($schema, $changeLog, $limits), + $divi + ); +``` + +- [ ] **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 (auch die bestehenden `ContentCreateAbilityTest`-Fälle ohne `divi`), phpcs ohne Meldungen. + +- [ ] **Step 5: Commit** + +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP +git add src/Integrations/WordPress/Content/ContentComposer.php src/Integrations/Divi/Pages/DiviContentComposer.php src/Integrations/WordPress/Content/ContentCreateAbility.php src/Integrations/WordPress/WordPressIntegration.php src/Integrations/Divi/DiviIntegration.php src/Plugin.php tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: lit/content-create with Divi short form + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- + +### Task 10: Hinweise, Guides, Version 0.3.0, Ende-zu-Ende-Test und Abnahme + +Die Hinweise nennen jetzt die Schreib-Tools (`PostPresenter::DIVI_NOTE`, Outline-Hinweise, Katalog-Hinweis), die Serveranweisungen bekommen die `tree_rev`-Regel (Spec §9.1), zwei neue Guides (Spec §9.2: `divi-shortform`, `divi-placeholders`) und Ergänzungen in `divi-structure`, `divi-attrs`, `overview`, `workflows`, `content-types`. Version 0.3.0 mit Readme (Auswirkungen auf die Website: kses-Umgehung beim Divi-Speichern und Divi-Cache; Rückgängig über Revisionen). Ein Ende-zu-Ende-Skript prüft alle Schreib-Tools gegen echtes Divi über den OAuth-Endpunkt – einschließlich eines Benutzers ohne `unfiltered_html`. + +**Files:** +- Modify: `src/Integrations/WordPress/Content/PostPresenter.php`, `src/Integrations/Divi/Pages/DiviOutlineAbility.php`, `src/Integrations/Divi/Schema/DiviCatalogAbility.php`, `src/Guides/InstructionsBuilder.php`, `src/Plugin.php`, `wp-lit-mcp.php`, `readme.txt`, `docs/guides/{divi-structure,divi-attrs,overview,workflows,content-types}.md` +- Create: `docs/guides/divi-shortform.md`, `docs/guides/divi-placeholders.md`, `tests/Integration/divi-write-smoke.sh` +- Test: `tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php`, `tests/Unit/Guides/InstructionsBuilderTest.php` + +**Interfaces:** +- Consumes: alle Schreib-Tools (Tasks 6–9); `tests/Integration/lib.sh` (`connect`, `rpc`, `tool`, `ability`, `error_code`, `cleanup_app_passwords_for`). +- Produces: `PostPresenter::DIVI_NOTE` (nennt `lit-divi-insert`, `lit-divi-update`, `lit-divi-move`, `lit-divi-delete`); Serveranweisungen mit Zeile `- Divi-Änderungen verlangen die tree_rev …`; `Plugin::VERSION = '0.3.0'`. + +- [ ] **Step 1: Failing Tests** + +In `tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php` ersetzen: +```php + $ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); + self::assertSame(['Die Seite hat noch keinen Divi-Inhalt.'], $ability->execute(['post_id' => 7])['hints']); +``` +durch: +```php + $ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); + self::assertStringContainsString('lit-divi-insert', $ability->execute(['post_id' => 7])['hints'][0]); +``` + +In `tests/Unit/Guides/InstructionsBuilderTest.php` ersetzen: +```php + self::assertStringContainsString('lit-divi-outline und lit-divi-design-system lesen', $with); + self::assertStringNotContainsString('lit-divi-outline und', $without); +``` +durch: +```php + self::assertStringContainsString('lit-divi-outline und lit-divi-design-system lesen', $with); + self::assertStringContainsString('tree_rev', $with); + self::assertStringNotContainsString('lit-divi-outline und', $without); +``` + +- [ ] **Step 2: Test laufen lassen (rot)** + +Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'DiviReadAbilitiesTest|InstructionsBuilderTest'` +Expected: FAIL – Hinweis ohne `lit-divi-insert` bzw. Serveranweisungen ohne `tree_rev`. + +- [ ] **Step 3: Implementierung – Hinweise, Anweisungen, Version** + +In `src/Integrations/WordPress/Content/PostPresenter.php` ersetzen: +```php + /** How Divi content is read and changed with the Divi tools. */ + public const DIVI_NOTE = 'Den Aufbau mit lit/divi-outline (direkt: lit-divi-outline) lesen, einzelne Knoten mit ' + . 'lit/divi-node-get (direkt: lit-divi-node-get); ändern vorerst im Divi-Builder.'; +``` +durch: +```php + /** How Divi content is read and changed with the Divi tools. */ + public const DIVI_NOTE = 'Den Aufbau mit lit/divi-outline (direkt: lit-divi-outline) lesen und mit ' + . 'lit/divi-insert, lit/divi-update, lit/divi-move bzw. lit/divi-delete (direkt: lit-divi-insert, ' + . 'lit-divi-update, lit-divi-move, lit-divi-delete) ändern.'; +``` + +In `src/Integrations/Divi/Pages/DiviOutlineAbility.php` ersetzen: +```php + $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.'; +``` +durch: +```php + $hints[] = $page->isDiviLayout() + ? 'Die Seite hat noch keinen Divi-Inhalt: mit lit-divi-insert (ohne parent) den ersten Abschnitt ' + . 'einfügen.' + : 'Der Inhalt ist kein Divi-Layout (Block-Editor oder klassisch); lit-content-get zeigt ihn.'; +``` + +In `src/Integrations/Divi/Pages/DiviOutlineAbility.php` ersetzen: +```php + 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.'; + } +``` +durch: +```php + if (in_array(false, array_column($rows, 'editable'), true)) { + $hints[] = 'Knoten mit "nur lesbar" (globale Module, fremde Blöcke) lassen sich nur als Ganzes ' + . 'verschieben, duplizieren oder löschen; ihren Inhalt im Divi-Builder bearbeiten.'; + } +``` + +In `src/Integrations/Divi/Schema/DiviCatalogAbility.php` ersetzen: +```php + $catalog = $this->modules->catalog(); + $notes = [self::CODE_NOTE, 'Details zur Vollform: lit-guide mit topic "divi-attrs".']; +``` +durch: +```php + $catalog = $this->modules->catalog(); + $notes = [self::CODE_NOTE, 'Details und Beispiele: lit-guide mit topic "divi-shortform" bzw. "divi-attrs".']; +``` + +In `src/Guides/InstructionsBuilder.php` ersetzen: +```php + $divi = str_contains(', ' . $active . ',', ', divi,') + ? ['- Divi-Seiten: vor Änderungen lit-divi-outline und lit-divi-design-system lesen; Aufbau: lit-guide ' + . 'topic "divi-structure".'] + : []; +``` +durch: +```php + $divi = str_contains(', ' . $active . ',', ', divi,') + ? [ + '- Divi-Seiten: vor Änderungen lit-divi-outline und lit-divi-design-system lesen; Aufbau: lit-guide ' + . 'topic "divi-structure".', + '- Divi-Änderungen verlangen die tree_rev aus lit-divi-outline bzw. der letzten Divi-Antwort ' + . '(nicht rev).', + ] + : []; +``` + +In `src/Plugin.php` ersetzen: +```php +{ + public const VERSION = '0.2.0'; +``` +durch: +```php +{ + public const VERSION = '0.3.0'; +``` + +In `wp-lit-mcp.php` ersetzen: +```php + * Description: MCP-Server für WordPress, Divi 5 und Pods – mit OAuth für claude.ai. + * Version: 0.2.0 + * Requires at least: 6.9 +``` +durch: +```php + * Description: MCP-Server für WordPress, Divi 5 und Pods – mit OAuth für claude.ai. + * Version: 0.3.0 + * Requires at least: 6.9 +``` + +In `readme.txt` ersetzen: +```text +Requires PHP: 8.1 +Stable tag: 0.2.0 +License: GPL-2.0-or-later +``` +durch: +```text +Requires PHP: 8.1 +Stable tag: 0.3.0 +License: GPL-2.0-or-later +``` + +In `readme.txt` ersetzen: +```text + Transient lit_mcp_endpoint_check. +* Uploads über LIT MCP: nur Bilder (jpg, png, gif, webp, avif), PDF, MP4, MP3; Größenlimit einstellbar +``` +durch: +```text + Transient lit_mcp_endpoint_check. +* Divi-Änderungen über LIT MCP speichern den Seiteninhalt für Benutzer ohne die Berechtigung + unfiltered_html ohne die kses-Filterung von WordPress, weil diese sonst jeden unveränderten Divi-Block neu + schreiben würde. Alle Werte, die LIT MCP dabei schreibt, werden vorher bereinigt (wp_kses_post, + sanitize_text_field, esc_url_raw); Inhalte des Code-Moduls bleiben nur mit unfiltered_html ungefiltert. + Nach jeder Divi-Änderung wird der statische CSS-Cache von Divi für die Seite geleert. +* Uploads über LIT MCP: nur Bilder (jpg, png, gif, webp, avif), PDF, MP4, MP3; Größenlimit einstellbar +``` + +In `readme.txt` ersetzen: +```text + lit/revision-restore). Felder und Begriffe sind in Revisionen nicht enthalten. +* Original durch eine Kopie ersetzen (lit/post-replace-with-copy): vorher legt das Werkzeug eine Revision +``` +durch: +```text + lit/revision-restore). Felder und Begriffe sind in Revisionen nicht enthalten. +* Divi-Seiten: jede Divi-Änderung legt vorher eine Revision an (revision_id in der Antwort); zurück über + lit/revision-restore. Beim seitenübergreifenden Ersetzen erhält jede geänderte Seite eine eigene Revision. +* Original durch eine Kopie ersetzen (lit/post-replace-with-copy): vorher legt das Werkzeug eine Revision +``` + +In `readme.txt` ersetzen: +```text + += 0.2.0 = +``` +durch: +```text + += 0.3.0 = +* Divi-Seiten: Seitenbaum mit Pfad-IDs und tree_rev, Knoten in Voll- und Kurzform, Modulkatalog, + Designsystem (Farben, Variablen, Schriften, Presets), gerenderte Zusammenfassung; Abschnitte, Zeilen und + Module einfügen, ändern, verschieben, duplizieren und löschen (Kurzform, Vollform, Platzhalter wie + {{color:primary}}), Text seitenübergreifend ersetzen mit Probelauf, Qualitätswarnungen, neue Seiten + direkt mit Divi-Inhalt. + += 0.2.0 = +``` + +- [ ] **Step 4: Implementierung – Guides** + +`docs/guides/divi-shortform.md`: +````markdown +# Divi-Kurzform + +Neue Divi-Inhalte beschreibt man als JSON-Knoten. `lit-divi-insert` (in `nodes`) und `lit-content-create` (in +`divi`) nehmen dieselbe Form. Jeder Knoten hat einen `type`; dazu Kurzform-Felder, optional `label` +(Admin-Label), `preset` (Name eines Modul-Presets aus `lit-divi-design-system`) und `attrs` (Vollform, siehe +`divi-attrs`). Kurz- und Vollform lassen sich kombinieren; `attrs` gewinnt. + +## Aufbau + +```json +{ "type": "section", "label": "Hero", "background_color": "{{color:primary}}", + "rows": [ + { "columns": "1_3,2_3", + "modules": [ + [ { "type": "image", "image_id": 33 } ], + [ { "type": "heading", "text": "Willkommen", "level": "h1" }, + { "type": "text", "content": "

Seit 1987 in Köln.

" }, + { "type": "button", "text": "Termin buchen", "url": "/kontakt", "preset": "CTA primär" } ] + ] } + ] } +``` + +- **Abschnitt** (`section`): `rows` (Zeilen) oder – für eine einspaltige Zeile – direkt `modules`; + `section_type: "fullwidth"` für Vollbreite-Abschnitte (dann nur `fullwidth-…`-Module in `modules`); + `background_color`. Spezialabschnitte (specialty) nur im Divi-Builder bauen. +- **Zeile** (`row`): `columns` = Breiten als Brüche, die zusammen 1 ergeben (`"4_4"`, `"1_2,1_2"`, + `"1_3,2_3"`, `"1_4,1_4,1_2"`, auch Fünftel); `modules` = je Spalte eine Liste (bei einer Spalte reicht eine + einfache Liste). +- **Module** mit Kind-Modulen (Akkordeon, Tabs, Slider, Kontaktformular, Karte, Social-Media-Follow, + Icon-Liste) bekommen ihre Einträge in `items`; der Eintragstyp ergibt sich von selbst. +- `label` fehlt → Admin-Label „ – “, z. B. „Hero – Button“. Abschnitten deshalb ein Label + geben. +- `heading` ohne `level` wird `h2` (eine Seite hat meist schon eine `h1` im Titel). + +## Kurzform-Felder + +| Modul | Felder | +|---|---| +| `text` | `content` (HTML) | +| `heading` | `text`, `level` (`h1`–`h6`) | +| `button` | `text`, `url`, `new_window` (true/false) | +| `image` | `image_id` (Mediathek, setzt URL/ID/Alt), `src`, `alt`, `title`, `url` | +| `blurb` | `title`, `url`, `content`, `image_id`, `src`, `alt`, `level` | +| `cta` | `title`, `content`, `button_text`, `button_url` | +| `icon` | `url`, `title` | +| `divider` | `show`, `color` | +| `testimonial` | `author`, `job_title`, `company`, `company_url`, `content`, `portrait_id`, `portrait_url` | +| `team-member` | `name`, `position`, `content`, `image_id`, `image_url` | +| `accordion` → `accordion-item` | Eintrag: `title`, `content`, `open` | +| `tabs` → `tab` | Eintrag: `title`, `content` | +| `toggle` | `title`, `content`, `open` | +| `gallery` | `image_ids` (Liste von Anhang-IDs) | +| `slider` → `slide` | Eintrag: `title`, `content`, `button_text`, `button_url`, `image_id`, `src`, `alt` | +| `contact-form` → `contact-field` | Formular: `title`, `email`, `success_message`, `submit_text`; Feld: `title`, `id`, `type` (`input`, `email`, `text`, `checkbox`, `radio`, `select`), `required` | +| `map` → `map-pin` | Karte: `address`, `lat`, `lng`, `zoom`; Pin: `title`, `content`, `address`, `lat`, `lng` | +| `video` | `src`, `thumbnail` | +| `number-counter` | `number`, `title`, `percent` | +| `social-media-follow` → `social-media-follow-network` | Eintrag: `network` (z. B. `facebook`), `url`, `label` | +| `icon-list` → `icon-list-item` | Eintrag: `text` | +| `code` | `content` – nur, wenn kein natives Modul passt (Warnung) | + +Alle anderen Module (siehe `lit-divi-catalog`) gehen über `attrs`. Die Kurzform-Felder eines Moduls samt Pfad +zeigt `lit-divi-catalog` mit `module`. + +## Einfügen, ändern, verschieben, löschen + +- `lit-divi-insert`: `nodes` + Position – `parent` (Pfad-ID, z. B. Spalte `s0.r0.c1`; optional `index`), + `before` oder `after`; ohne Position ans Seitenende. Oben auf der Seite sind nur Abschnitte erlaubt, in + Abschnitten Zeilen, in Spalten Module. +- `lit-divi-update`: `changes` = `[{"id": "s0.r0.c1.m2", "set": {"text": "Jetzt buchen"}, "preset": "CTA + primär", "label": "Hero – Button"}]` – mehrere Knoten in einem Aufruf; `null` entfernt einen Wert. +- `lit-divi-move`: `id` + Position (auch in andere Zeilen oder Abschnitte). +- `lit-divi-delete`: `ids`, erst nach Rückfrage mit `confirm: true`. +- `lit/divi-duplicate-node`: Kopie direkt hinter dem Original. +- Jede Antwort enthält die neue `tree_rev` (für die nächste Änderung), `changed` mit den neuen Pfad-IDs, + den betroffenen Abschnitt als `outline`, `warnings` (Qualität, siehe unten) und `revision_id`. + +Fehler: `validation_failed` bzw. `invalid_attr_path` mit `fields` (Eingabepfad → Problem, z. B. +`nodes[0].rows[0].columns`); dann wurde nichts gespeichert. + +## Warnungen + +Neue oder geänderte Knoten werden geprüft: feste Farben/Schriften, obwohl eine passende globale existiert; +Bilder ohne Alt-Text; leere Module; Sprünge in der Überschriftenhierarchie; Code-Modul. Warnungen dem Nutzer +nennen und möglichst gleich beheben. +```` + +`docs/guides/divi-placeholders.md`: +````markdown +# Platzhalter für Divi-Inhalte + +Platzhalter stehen **allein** im Wert (nicht mitten im Text) und werden beim Speichern in Divis eigene +Verweise übersetzt. So bleibt die Seite mit dem Designsystem verbunden: ändert sich die Primärfarbe, ändert +sich auch der Button. + +| Platzhalter | Bedeutung | +|---|---| +| `{{color:primary}}`, `secondary`, `heading`, `body`, `link` | die fünf Theme-Farben | +| `{{color:}}` | eigene globale Farbe (Name wie in `lit-divi-design-system`) | +| `{{var:}}` | globale Variable (Zahl, Text, Bild, Link) | +| `{{font:heading}}`, `{{font:body}}` | Theme-Schrift für Überschriften bzw. Fließtext | +| `{{post:title}}`, `excerpt`, `date`, `modified`, `author`, `url`, `featured_image`, `categories`, `tags` | dynamischer Inhalt des angezeigten Beitrags | +| `{{field:}}` | dynamischer Inhalt aus einem Feld (Pods/Meta), z. B. `{{field:telefon}}` | +| `"preset": ""` | Modul-Preset (kein Platzhalter, sondern eigenes Feld) | + +Beispiele: + +```json +{ "type": "button", "text": "Anrufen", "url": "{{field:telefon_link}}", + "attrs": { "button.decoration.background": { "desktop": { "color": "{{color:primary}}" }, + "hover": { "color": "{{color:Nachtblau}}" } } } } +``` + +```json +{ "type": "heading", "text": "{{post:title}}", "level": "h1" } +``` + +Unbekannte Namen lehnt das Werkzeug ab und nennt die vorhandenen. In `lit-divi-node-get` erscheinen die +übersetzten Verweise als `$variable({…})$` – diese Werte unverändert lassen oder wieder durch einen +Platzhalter ersetzen. +```` + +In `docs/guides/divi-structure.md` ersetzen: +```markdown + +## Was nicht bearbeitet wird +``` +durch: +```markdown + +## Ändern + +Neue Inhalte und Änderungen: siehe `divi-shortform` (Kurzform, `lit-divi-insert`, `lit-divi-update`, +`lit-divi-move`, `lit-divi-delete`), `divi-attrs` (Vollform) und `divi-placeholders`. Ablauf: + +1. `lit-divi-outline` lesen (liefert `tree_rev`), `lit-divi-design-system` für Farben, Variablen, Presets. +2. Änderung mit dieser `tree_rev` senden. Die Antwort enthält die neue `tree_rev` und die neuen Pfad-IDs – + damit direkt weiterarbeiten, ohne erneut zu lesen. +3. `rev_mismatch`: jemand hat die Seite inzwischen geändert (z. B. im Divi-Builder) → neu lesen. +4. Zum Schluss `lit-page-render-summary` prüfen und dem Nutzer die Vorschau (`preview_url`) bzw. den + Divi-Builder (`edit_url`) nennen. Der Status bleibt unverändert; veröffentlichte Seiten werden live + geändert (vorher entsteht eine Revision, `revision_id`). + +## Was nicht bearbeitet wird +``` + +In `docs/guides/divi-structure.md` ersetzen: +```markdown + +- 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 +``` +durch: +```markdown + +- Knoten mit „nur lesbar“ (globale Module `g…`, fremde Blöcke `x…`) lassen sich nur als Ganzes verschieben, + duplizieren oder löschen; ihr Inhalt wird im Divi-Builder bearbeitet (globale Module folgen später). +- Divi-4-Inhalt (Shortcodes `[et_pb_…]`) wird nicht gelesen oder geändert: Seite einmal im Divi-Builder +``` + +In `docs/guides/divi-attrs.md` ersetzen: +```markdown + +## Pfade finden +``` +durch: +````markdown + +## Setzen + +In `attrs` (bei `lit-divi-insert` je Knoten, bei `lit-divi-update` je Änderung) steht Pfad → Wert je Breakpoint. +Kurz: `{"desktop": "#1a1a1a", "phone": "…"}` bzw. bei Objektwerten `{"desktop": {"size": "28px"}}`; Hover und +Sticky: `{"hover": {…}}` bzw. `{"desktop": {"value": {…}, "hover": {…}}}`. Nur die genannten Breakpoints +werden ersetzt, der Rest bleibt; `null` entfernt einen Pfad, Breakpoint oder Zustand: + +```json +{ "id": "s0.r0.c1.m0", + "attrs": { "title.decoration.font.font": { "desktop": { "color": "{{color:heading}}" }, + "phone": { "size": "28px" } }, + "module.decoration.spacing": null } } +``` + +Unbekannte Pfade → Fehler `invalid_attr_path` mit Vorschlägen unter `fields`. Texte werden bereinigt +(HTML nur, wo Divi Rich-Text erwartet; Links ohne `javascript:`). + +## Pfade finden +```` + +In `docs/guides/overview.md` ersetzen: +```markdown +Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows`, `media`, `divi-structure`, +`divi-attrs` oder `divi-design-system`. +``` +durch: +```markdown +Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows`, `media`, `divi-structure`, +`divi-shortform`, `divi-attrs`, `divi-placeholders` oder `divi-design-system`. +``` + +In `docs/guides/overview.md` ersetzen: +```markdown +- Erfolg: `ok`, `summary`, `changed`, `warnings`, `rev`, `revision_id` (Revision mit dem Stand vorher, nur bei + Tools, die Inhalt überschreiben, sonst `null`), `log_id`, URLs. +- Fehler: JSON mit `code` (z. B. `validation_failed`, `rev_mismatch`, `write_failed`, `partial_write`), +``` +durch: +```markdown +- Erfolg: `ok`, `summary`, `changed`, `warnings`, `rev`, `revision_id` (Revision mit dem Stand vorher, nur bei + Tools, die Inhalt überschreiben, sonst `null`), `log_id`, URLs. Divi-Werkzeuge zusätzlich `tree_rev` (Stand + des Seiteninhalts, Pflicht für die nächste Divi-Änderung) und `outline` (betroffener Abschnitt). +- Fehler: JSON mit `code` (z. B. `validation_failed`, `rev_mismatch`, `write_failed`, `partial_write`), +``` + +In `docs/guides/workflows.md` ersetzen: +```markdown +6. Veröffentlichen erst, wenn der Nutzer es ausdrücklich möchte. +``` +durch: +```markdown +6. Veröffentlichen erst, wenn der Nutzer es ausdrücklich möchte. + +## Divi-Seite bauen + +1. `lit-divi-design-system` lesen (Farben, Schriften, Presets) und `lit-divi-catalog` für die Module. +2. Seite als Entwurf mit Inhalt anlegen: `lit-content-create` mit `divi` (Abschnitte in Kurzform, siehe + `divi-shortform`) – oder eine leere Seite anlegen und Abschnitte mit `lit-divi-insert` einfügen. +3. Weitere Abschnitte/Module mit `lit-divi-insert`, Anpassungen mit `lit-divi-update`; immer die `tree_rev` + der letzten Antwort mitgeben. +4. `lit-page-render-summary` prüfen, `warnings` beheben, dem Nutzer `preview_url` und `edit_url` (Divi-Builder) + nennen. Veröffentlichen nur auf Wunsch (`lit-post-publish`). + +## Text auf vielen Seiten ersetzen + +`lit/divi-replace-text` zuerst mit `dry_run: true` (Standard): zeigt Treffer je Seite mit `tree_rev`. Nach +Rückfrage mit `dry_run: false`, `targets` (post_id + tree_rev aus dem Probelauf) und `confirm: true` +ausführen; jede Seite bekommt eine eigene Revision, `results` meldet das Ergebnis je Seite. +``` + +In `docs/guides/content-types.md` ersetzen: +```markdown +- `lit-content-get`: alle Daten eines Eintrags inkl. Feldern, Begriffen, Beitragsbild und `rev`. + 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`). +``` +durch: +```markdown +- `lit-content-get`: alle Daten eines Eintrags inkl. Feldern, Begriffen, Beitragsbild und `rev`. + Bei Divi-Seiten fehlt `content`; den Aufbau liest `lit-divi-outline`, geändert wird er mit den + Divi-Werkzeugen (siehe `divi-structure`); Titel, Auszug, Felder und Begriffe gehen weiter über + `lit-content-update`. +``` + +In `docs/guides/content-types.md` ersetzen: +```markdown +- `lit-content-create` legt immer einen **Entwurf** an. Felder werden gegen das Schema geprüft; ein Fehler + bricht den ganzen Aufruf ab (`fields.` im Fehler nennt die Ursache). +- `lit-content-update` ändert nur die übergebenen Werte, der Status bleibt. `rev` aus `lit-content-get` ist +``` +durch: +```markdown +- `lit-content-create` legt immer einen **Entwurf** an. Felder werden gegen das Schema geprüft; ein Fehler + bricht den ganzen Aufruf ab (`fields.` im Fehler nennt die Ursache). Divi-Seiten gleich mit + Inhalt: `divi` = Liste von Abschnitten in Kurzform (siehe `divi-shortform`) statt `content`; die Antwort + enthält dann die `tree_rev` für weitere Divi-Änderungen. +- `lit-content-update` ändert nur die übergebenen Werte, der Status bleibt. `rev` aus `lit-content-get` ist +``` + +In `docs/guides/content-types.md` ersetzen: +```markdown +1. `lit/post-duplicate` → Entwurfskopie; die Antwort enthält die `rev` der Kopie für `lit-content-update`. +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. +3. `lit/post-replace-with-copy` übernimmt die Kopie ins Original (ID, URL, Status und Menüeinträge bleiben). +``` +durch: +```markdown +1. `lit/post-duplicate` → Entwurfskopie; die Antwort enthält die `rev` der Kopie für `lit-content-update`. +2. Kopie mit `lit-content-update` bzw. den Divi-Werkzeugen bearbeiten und dem Nutzer die Vorschau zeigen; + `lit-page-render-summary` fasst den gerenderten Inhalt zusammen. +3. `lit/post-replace-with-copy` übernimmt die Kopie ins Original (ID, URL, Status und Menüeinträge bleiben). +``` + +- [ ] **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`: in den Guides nur `lit-divi-insert` usw. für direkte und `lit/divi-duplicate-node`, `lit/divi-replace-text` für indirekte Tools), phpcs ohne Meldungen. + +- [ ] **Step 6: Ende-zu-Ende-Test anlegen und alle Integrationstests ausführen** + +`tests/Integration/divi-write-smoke.sh`: +```bash +#!/usr/bin/env bash +# End-to-end checks of the Etappe-2b Divi write 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 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-write-smoke.sh +# Creates draft pages, a temporary role/user without unfiltered_html, and removes everything 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 +ORIGINAL_ROLES=$(wp option get lit_mcp_allowed_roles --format=json 2>/dev/null || echo '["administrator"]') +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" + cleanup_app_passwords_for lit-smoke-editor + wp user delete lit-smoke-editor --yes >/dev/null 2>&1 || true + wp role delete lit_smoke_editor >/dev/null 2>&1 || true + wp option update lit_mcp_allowed_roles "$ORIGINAL_ROLES" --format=json >/dev/null 2>&1 || true +} +trap cleanup EXIT + +create_page() { # $1 = fixture file, $2 = title -> prints the ID + 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_of() { # $1 = post ID + wp post get "$1" --field=post_content +} + +# Whether the stored content still contains the exact bytes of the fixture section "Leistungen". +keeps_leistungen() { # $1 = post ID + LIT_ID="$1" LIT_FILE="$FIXTURES/converted-landing.html" wp eval ' +$fixture = (string) file_get_contents(getenv("LIT_FILE")); +$start = strpos($fixture, "") - $start); +echo str_contains((string) get_post((int) getenv("LIT_ID"))->post_content, $section) ? "yes" : "no";' +} + +[ "$(wp eval 'echo get_template();')" = "Divi" ] || fail "Divi must be the active theme" +PAGE_ID=$(create_page converted-landing.html "LIT Divi Schreiben") +CREATED_POSTS+=("$PAGE_ID") +connect "$ADMIN_LOGIN" + +# 1. Direct write tools with correct annotations +LIST=$(rpc tools/list '{}') +for t in lit-divi-insert lit-divi-update lit-divi-move lit-divi-delete; do + echo "$LIST" | jq -e --arg t "$t" '.result.tools[] | select(.name == $t)' >/dev/null || fail "tools/list lacks $t" +done +echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-divi-delete") | .annotations.destructiveHint == true' >/dev/null \ + || fail "lit-divi-delete lacks destructiveHint" +echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-divi-replace-text")' >/dev/null \ + && fail "lit-divi-replace-text must be indirect" +pass "tools/list: Divi write tools direct, replace-text indirect" + +# 2. Insert a whole section in short form with placeholders +TREE_REV=$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}" | jq -r '.tree_rev') +INSERT=$(tool lit-divi-insert "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev, before: "s1", + nodes: [{type: "section", label: "Neu", background_color: "{{color:primary}}", rows: [{columns: "1_3,2_3", modules: [ + [{type: "heading", text: "{{post:title}}", level: "h2"}], + [{type: "text", content: "

Hallo Welt

"}, + {type: "button", text: "Mehr erfahren", url: "/mehr", label: "Neu – CTA"}]]}]}]}')") +echo "$INSERT" | jq -e '.ok == true and .changed[0].id == "s1" and .revision_id != null and .log_id != null' >/dev/null \ + || fail "divi-insert: $INSERT" +TREE_REV=$(echo "$INSERT" | jq -r '.tree_rev') +content_of "$PAGE_ID" | grep -q 'u003cscript' && fail "script tag was stored" +[ "$(keeps_leistungen "$PAGE_ID")" = "yes" ] || fail "insert changed the bytes of an untouched section" +SUMMARY=$(tool lit-page-render-summary "{\"post_id\":$PAGE_ID}") +echo "$SUMMARY" | jq -e '.headings[] | select(.level == 2 and .text == "LIT Divi Schreiben")' >/dev/null \ + || fail "{{post:title}} not rendered: $SUMMARY" +pass "lit-divi-insert: section with placeholders, sanitized, untouched bytes kept, rendered" + +# 3. Lock and all-or-nothing +STALE=$(tool lit-divi-update "{\"post_id\":$PAGE_ID,\"tree_rev\":\"000000000000\",\"changes\":[{\"id\":\"s0\",\"label\":\"x\"}]}") +[ "$(error_code "$STALE")" = "rev_mismatch" ] || fail "stale tree_rev accepted: $STALE" +BAD=$(tool lit-divi-update "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev, + changes: [{id: "s1.r0.c1.m1", set: {text: "Jetzt"}}, {id: "s1.r0.c0.m0", attrs: {"title.decoration.fnt": {desktop: "x"}}}]}')") +[ "$(error_code "$BAD")" = "invalid_attr_path" ] || fail "invalid path accepted: $BAD" +[ "$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}" | jq -r '.tree_rev')" = "$TREE_REV" ] || fail "failed update wrote something" +pass "tree_rev lock and invalid_attr_path leave the page unchanged" + +# 4. Update, move, delete +UPDATE=$(tool lit-divi-update "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev, + changes: [{id: "s1.r0.c1.m1", set: {text: "Jetzt anfragen"}, attrs: {"button.decoration.background": {desktop: {color: "{{color:secondary}}"}}}}]}')") +echo "$UPDATE" | jq -e '.ok == true and .changed[0].action == "updated"' >/dev/null || fail "divi-update: $UPDATE" +TREE_REV=$(echo "$UPDATE" | jq -r '.tree_rev') +MOVE=$(tool lit-divi-move "{\"post_id\":$PAGE_ID,\"tree_rev\":\"$TREE_REV\",\"id\":\"s1.r0.c1.m1\",\"parent\":\"s1.r0.c0\"}") +echo "$MOVE" | jq -e '.changed[0].id == "s1.r0.c0.m1"' >/dev/null || fail "divi-move: $MOVE" +TREE_REV=$(echo "$MOVE" | jq -r '.tree_rev') +ASK=$(tool lit-divi-delete "{\"post_id\":$PAGE_ID,\"tree_rev\":\"$TREE_REV\",\"ids\":[\"s1.r0.c0.m1\"]}") +[ "$(error_code "$ASK")" = "confirm_required" ] || fail "delete without confirm: $ASK" +DELETE=$(tool lit-divi-delete "{\"post_id\":$PAGE_ID,\"tree_rev\":\"$TREE_REV\",\"ids\":[\"s1.r0.c0.m1\"],\"confirm\":true}") +echo "$DELETE" | jq -e '.changed[0].action == "deleted"' >/dev/null || fail "divi-delete: $DELETE" +TREE_REV=$(echo "$DELETE" | jq -r '.tree_rev') +[ "$(keeps_leistungen "$PAGE_ID")" = "yes" ] || fail "update/move/delete changed an untouched section" +pass "lit-divi-update, lit-divi-move, lit-divi-delete" + +# 5. Duplicate and replace text (indirect) +DUP=$(ability lit/divi-duplicate-node "{\"post_id\":$PAGE_ID,\"tree_rev\":\"$TREE_REV\",\"id\":\"s1\"}") +echo "$DUP" | jq -e '.changed[0].id == "s2" and (.changed[0].label | endswith("(Kopie)"))' >/dev/null || fail "duplicate: $DUP" +DRY=$(ability lit/divi-replace-text '{"search":"Köln","replace":"Bonn","limit":100}') +TARGET=$(echo "$DRY" | jq -c --argjson id "$PAGE_ID" '[.matches[] | select(.post_id == $id) | {post_id, tree_rev}]') +[ "$TARGET" != "[]" ] || fail "dry run found nothing: $DRY" +content_of "$PAGE_ID" | grep -q 'Bonn' && fail "dry run changed content" +RUN=$(ability lit/divi-replace-text "$(jq -cn --argjson t "$TARGET" '{search: "Köln", replace: "Bonn", dry_run: false, confirm: true, targets: $t}')") +echo "$RUN" | jq -e '.results[0].ok == true and .results[0].count >= 1' >/dev/null || fail "replace-text: $RUN" +content_of "$PAGE_ID" | grep -q 'Bonn' || fail "replace-text wrote nothing" +pass "lit/divi-duplicate-node and lit/divi-replace-text (dry run, confirmed run)" + +# 6. New Divi page in one call +CREATED=$(tool lit-content-create '{"post_type":"page","title":"LIT Divi Neu","divi":[{"type":"section","label":"Start","modules":[{"type":"heading","text":"Neu hier"},{"type":"text","content":"

Text

"}]}]}') +NEW_ID=$(echo "$CREATED" | jq -r '.changed[0].id // empty') +[ -n "$NEW_ID" ] || fail "content-create with divi: $CREATED" +CREATED_POSTS+=("$NEW_ID") +echo "$CREATED" | jq -e '.tree_rev != null' >/dev/null || fail "content-create lacks tree_rev: $CREATED" +[ "$(wp post meta get "$NEW_ID" _et_pb_use_divi_5)" = "on" ] || fail "Divi flag missing" +[ "$(wp post get "$NEW_ID" --field=post_status)" = "draft" ] || fail "new Divi page is not a draft" +tool lit-page-render-summary "{\"post_id\":$NEW_ID}" | jq -e '.headings[0].text == "Neu hier"' >/dev/null || fail "new page not rendered" +pass "lit-content-create with Divi short form" + +# 7. A user without unfiltered_html: untouched bytes still identical, new values still sanitized +wp role create lit_smoke_editor "LIT Smoke Editor" --clone=editor >/dev/null +wp cap remove lit_smoke_editor unfiltered_html >/dev/null +wp option update lit_mcp_allowed_roles '["administrator","lit_smoke_editor"]' --format=json >/dev/null +wp user create lit-smoke-editor lit-smoke-editor@example.invalid --role=lit_smoke_editor >/dev/null +connect lit-smoke-editor +TREE_REV=$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}" | jq -r '.tree_rev') +EDIT=$(tool lit-divi-update "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev, + changes: [{id: "s0.r0.c0.m1", set: {content: "

Neu vom Redakteur

"}}]}')") +echo "$EDIT" | jq -e '.ok == true' >/dev/null || fail "editor update: $EDIT" +content_of "$PAGE_ID" | grep -q 'u003cscript' && fail "editor could store a script" +[ "$(keeps_leistungen "$PAGE_ID")" = "no" ] && fail "kses re-serialized untouched blocks of an editor save" +pass "user without unfiltered_html: byte-identical untouched content, sanitized values" + +echo "ALL CHECKS PASSED" +``` + +Run: +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && chmod +x tests/Integration/divi-write-smoke.sh +cd /home/janniclas/Projekte/WordPress +timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-write-smoke.sh +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 fünf Skripte enden mit `ALL CHECKS PASSED` (bekannte WARN-Zeilen aus Etappe 1 sind zulässig). Schlägt Abschnitt 7 von `divi-write-smoke.sh` fehl („kses re-serialized untouched blocks“), ist die kses-Umgehung in `DiviSaver` wirkungslos – nicht den Test lockern. + +- [ ] **Step 7: Commit** + +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP +git add src/Integrations/WordPress/Content/PostPresenter.php src/Integrations/Divi/Pages/DiviOutlineAbility.php src/Integrations/Divi/Schema/DiviCatalogAbility.php src/Guides/InstructionsBuilder.php src/Plugin.php wp-lit-mcp.php readme.txt docs/guides tests/Integration/divi-write-smoke.sh tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php tests/Unit/Guides/InstructionsBuilderTest.php +git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "docs: Divi guides shortform/placeholders, hints name the write tools; test: Divi write end-to-end; version 0.3.0 + +Co-Authored-By: Claude Opus 5.5 (1M context) +Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +- [ ] **Step 8: Manuelle Abnahme (Jan-Niclas, Browser und claude.ai)** + +Der ausführende Agent bereitet vor und meldet; diese Punkte prüft Jan-Niclas selbst (Spec §1.3 Nr. 2, §13 „Bearbeitbarkeit im Visual Builder“): +1. **Visual Builder:** In claude.ai eine Testseite bauen lassen („Lege eine Entwurfsseite ‚Abnahme Divi‘ an mit Hero (Überschrift, Text, Button mit Preset), einer Zeile 1/3 + 2/3 mit Bild und Blurb, einem Akkordeon und einem Kontaktformular“). Die Seite im Divi Visual Builder öffnen (`edit_url`): alle Module erscheinen als native Module mit Admin-Labels „ – “, lassen sich anklicken, ändern und speichern; die Spaltenbreiten stimmen (1/3 + 2/3); Farben aus `{{color:…}}` sind im Builder als globale Farbe verknüpft (Farbwähler zeigt die globale Farbe). +2. **Zurück zu Claude:** Nach dem Speichern im Builder eine Änderung per claude.ai verlangen – `rev_mismatch` erscheint nur, wenn Claude eine alte `tree_rev` nutzt; nach erneutem Lesen gelingt die Änderung, im Builder gemachte Einstellungen bleiben erhalten. +3. **Rückgängig:** Einen Abschnitt löschen lassen (Rückfrage erscheint), danach über `lit/revision-restore` mit der gemeldeten `revision_id` zurückholen. +4. **Ersetzen:** „Ersetze auf allen Seiten ‚Termin‘ durch ‚Beratungstermin‘“ – Claude zeigt erst den Probelauf und fragt nach, dann ersetzt es; im Protokoll-Tab erscheint je Seite ein Eintrag. +5. **Builder-Fixtures (aus 2a, falls noch offen):** Seiten mit globalem Modul, Presets, Variablen und Dynamic Content mit `tests/Integration/divi-export-page.php` exportieren; `composer test` bleibt grün; danach auf einer Kopie einer solchen Seite `lit-divi-update` an einem normalen Modul ausführen – das globale Modul bleibt byte-identisch. + +Ergebnisse als kurze Notiz an den Ausführenden zurückmelden; Abweichungen (z. B. anders gespeicherte Spaltenbreiten, D8) werden als Folgeaufgaben erfasst. + +--- + +## Self-Review (durchgeführt beim Schreiben) + +- **Spec-Abdeckung (Teil 2b):** §5.4 Kurzform (~20 Module + Kind-Module), Vollform mit Pfadprüfung (`invalid_attr_path` mit Vorschlägen), Breakpoints und Hover/Sticky, Kombination Kurz + Voll, Struktur Abschnitt → Zeilen (`columns`) → Spalten → Module, Vollbreite über `section_type`, Platzhalter-Tabelle vollständig inkl. `preset` → Tasks 1–3; §5.5 Warnungen (außer „ähnliche Abschnitte“, D6) und automatische Admin-Labels → Tasks 3–4; §5.6 Speicher-Pipeline Schritte 1–6 → Task 5; §5.7 `lit/divi-insert`, `lit/divi-update`, `lit/divi-move`, `lit/divi-delete`, `lit/divi-duplicate-node`, `lit/divi-replace-text` → Tasks 6–8; §6.2 `lit/content-create` mit Divi-Kurzform → Task 9; §8.1 `ToolResult` mit `changed`, `outline`, `rev`, `tree_rev`, `warnings`, `revision_id`, `log_id`, `preview_url`, `edit_url` (`&et_fb=1`) → Task 5; §8.2 Codes `rev_mismatch`, `invalid_attr_path`, `validation_failed`, `confirm_required`, `unsupported`, alles oder nichts, Stapel je Eintrag → Tasks 5–8; §9.1/§9.2 Regeln und Guides `divi-shortform`, `divi-placeholders` → Task 10; §10 Bereinigung (`wp_kses_post`, `sanitize_text_field`) und `confirm: true` → Tasks 1, 5, 7, 8; §11.3 Annotations → Tasks 6–8. Etappe 3: Library, globale Module bearbeiten, Theme Builder, `lit/divi-dynamic-sources`, ähnliche Abschnitte (D6). +- **Gefundene Spec-Lücken und Auflösung:** kses re-serialisiert für Benutzer ohne `unfiltered_html` alle Blöcke → D7 (`DiviSaver`, Readme, E2E-Abschnitt 7); Spaltenformat von Divi 5.2 (Flex, `flexType`) nicht in der Spec → D8; `divi-replace-text` ohne Sperre je Seite → D10 (`targets` mit `tree_rev`); Platzhalter mitten im Text unklar → D9 (nur ganzer Wert); `content-create` mit Divi und kses → D11. +- **Placeholder-Scan:** alle Code-Schritte enthalten vollständigen Code bzw. exakte Ersetzungen (alt → neu); keine „TBD“, keine „wie in Task N“. +- **Typ-Konsistenz geprüft:** `Composer::compose/update` (Rückgabe `blocks|attrs`, `errors`, `invalid_paths`), `TreeEditor::position` (Array `container/parent/index` oder String), `DiviChange::changed/removed/entries/touched/warnings`, `DiviWriter::page/save/inputProperties/positionProperties/sectionLabel`, `DiviSaver::save` (Rückgabe `revision_id`, `warnings`), `DiviComposition::composer/attrWriter/rules/editor/warnings`, `ContentComposer::available/compose/afterInsert`, `Errors::invalidAttrPath` stimmen zwischen Interfaces-Blöcken, Code und Tests überein. +- **Review Focus:** alle fünf Punkte haben ihren Test im genannten Task. Der Code wurde in einer Kopie des Plugins ausgeführt: nach jedem Task `composer test` grün (bis 742 Tests) und phpcs sauber; die Schreib-Pipeline wurde direkt gegen Divi 5.2.1 in DDEV geprüft (Einfügen, Ändern, Verschieben, Löschen, Probelauf, `{{post:title}}` gerendert, unberührte Bytes identisch auch mit aktivem kses).