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