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