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

6655 lines
278 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Etappe 2b – Divi-Seiten schreiben: Kurz-/Vollform, Platzhalter, Einfügen bis Ersetzen – Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Claude baut und pflegt Divi-5-Seiten: Abschnitte, Zeilen und Module in Kurz- oder Vollform einfügen, ändern, verschieben, duplizieren und löschen, Text seitenübergreifend ersetzen und neue Seiten direkt mit Divi-Inhalt anlegen – mit Platzhaltern für das Designsystem, Qualitätswarnungen, `tree_rev`-Sperre, Revision, Protokoll, Cache-Leerung und byte-identischem Rest der Seite.
**Architecture:** Reines PHP in `Divi/Compose/`: `Placeholders` (Spec-§5.4-Platzhalter → `$variable(…)$`), `AttrWriter` (Vollform, Pfadprüfung gegen den Katalog), `ShortformWriter` (Kurzform nach `ShortformMap`), `StructureRules` + `Composer` (Knotenbeschreibung → neue Blöcke, Spalten über `flexType`, Presets, Admin-Labels), `TreeEditor` (Position, Einfügen, Verschieben, Entfernen, Duplizieren), `Warnings` (§5.5), `TextReplacer`. WordPress-Seite in `Divi/Pages/`: `DiviWriter` (Sperre, Speichern, Protokoll, `ToolResult`) mit `DiviSaver` (Speicher-Pipeline §5.6 inkl. kses-Umgehung, D7), `DiviComposition` (verdrahtet Katalog, Designsystem, `WpValueSanitizer`, `WpMediaResolver`), sechs Schreib-Abilities und `DiviContentComposer` für `lit/content-create`.
**Tech Stack:** PHP ≥ 8.1, WordPress 7.0 (Abilities API, `wp_update_post`, kses), Divi 5.2.1 (`ET_Core_PageResource::remove_static_resources`), `wordpress/mcp-adapter` 0.5, `wp-media/mcp-oauth` 1.1, PHPUnit 9.6 + Brain Monkey + Mockery, phpcs, DDEV.
**Spec:** `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` (insb. §2, §5.4–5.7, §6.2, §8.1–8.2, §9, §10, §11.3, §12 Etappe 2) und die Entscheidungen `docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md` (D1–D12)
**Voraussetzung:** Plan `docs/superpowers/plans/2026-09-26-etappe-2a-divi-lesen.md` ist vollständig umgesetzt (BlockTree, `NodeIndex`, `AttrMap`, `NodeSummary`, `Outline`, `ModuleCatalog`, `ShortformMap`, `DiviModules`, `DesignSystem`, `DesignSystemReader`, `DiviPage`, `DiviPageLoader` inkl. `writable()`, `DiviIntegration`, die Test-Helfer `DiviPageLoaderTest::fixture/givenPost/schema`, `DesignSystemTest::sample`, `ModuleCatalogTest::sampleCatalog`, Fixtures in `tests/Fixtures/divi/`).
## Global Constraints
- Dateien ausschließlich im Plugin-Verzeichnis `/home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP` anlegen oder ändern. Integrationsskripte räumen Seiten, Benutzer, Rollen und die Option `lit_mcp_allowed_roles` wieder auf.
- PHP ≥ 8.1 ohne 8.2+-Features; WordPress ≥ 6.9 (getestet 7.0); Divi 5.2.1.
- Namespace `LIT\MCP\` → `src/`, PSR-4, eine Klasse pro Datei, PSR-12, höchstens 120 Zeichen pro Zeile; `composer lint` und `composer test` nach jedem Task sauber.
- `src/Integrations/Divi/Compose/*` ist reines PHP ohne WordPress (Spec §4.4); WordPress-Funktionen nur in `Divi/Pages/` (Adapter, Abilities) und über die Schnittstellen `ValueSanitizer` und `MediaResolver`. Compose-Tests erben von `PHPUnit\Framework\TestCase` und nutzen `ComposeFixture` (echter 5.2.1-Katalog, Beispiel-Designsystem, vorhersagbare Fakes).
- Unit-Tests der Abilities über `LIT\MCP\Tests\Unit\TestCase` (Brain Monkey + Mockery); vorbelegte Stubs (`wp_json_encode`, `is_wp_error`, `absint`, `wp_slash`, `wp_unslash`, `sanitize_text_field`) nie mit `Functions\expect()` prüfen; `error_log()` per `ini_set('error_log', …)` umleiten.
- Integrationstests im DDEV-Container: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/<skript>.sh`.
- **Sicherheitsregeln Spec §5 (verbindlich für jeden Schreibpfad):**
- Byte-Invariante: nur geänderte oder neue Blöcke bekommen neue Delimiter; alles andere bleibt byte-identisch – auch für Benutzer ohne `unfiltered_html` (kses-Umgehung nur in `DiviSaver`, D7).
- Jeder Wert, den LIT MCP in Divi-Attribute schreibt, läuft durch `ValueSanitizer`: `…innerContent` → `html()` (`wp_kses_post`), URL-Schlüssel (`…url`, `…src`, `…link`) → `url()` (`esc_url_raw`), Code-Modul → `code()` (ungefiltert nur mit `unfiltered_html`), alle anderen Strings → `text()` (`sanitize_text_field`); `$variable(…)$`-Verweise bleiben unverändert.
- `tree_rev` ist Pflicht für jede Divi-Änderung (Ruling P16); Abweichung → `rev_mismatch`, nichts geschrieben. Antworten liefern die neue `tree_rev` und `rev` (`Rev::ofPost`).
- Alles oder nichts: erst vollständig validieren, dann einmal speichern; Fehler → `validation_failed` bzw. `invalid_attr_path` mit `fields` (Eingabepfad → Problem).
- Vor dem Speichern Wiederherstellungspunkt (`RevisionPoint::capture`), danach Divi-Flags `_et_pb_use_divi_5`/`_et_pb_use_builder` = `on`, `ET_Core_PageResource::remove_static_resources($id, 'all', true)`, Protokoll über `PostChangeResult`; scheitert das Speichern, wird der vorherige Inhalt zurückgeschrieben (`WriteFailed`, sonst `partial_write` + `ChangeLog::recordPartial`). Status bleibt unverändert; verpasste Plantermine blockieren (`PostGuard::statusStable`).
- Admin-Labels: ohne `label` automatisch „<Abschnitt> – <Modul>“ (Spec §5.5); Code-Modul erlaubt, aber mit Warnung; Platzhalter nach Spec §5.4 (allein im Wert, D9); Warnungen nach §5.5 (ähnliche Abschnitte ab Etappe 3, D6).
- Destruktiv (`lit/divi-delete`, `lit/divi-replace-text`) nur mit `confirm: true`; `lit/divi-replace-text` nur mit `dry_run: false` und `targets` aus dem Probelauf.
- Ability-Namen und Annotations: `lit/divi-insert` (direkt; idempotent false), `lit/divi-update` (direkt; idempotent true), `lit/divi-move` (direkt), `lit/divi-delete` (direkt; destructive true), `lit/divi-duplicate-node` (indirekt), `lit/divi-replace-text` (indirekt; destructive true). Output: `ToolResult::schema(['tree_rev' => …], ['tree_rev'])`. Texte nennen direkte Tools `lit-<name>`, indirekte `lit/<name>`; jeder Task mit neuem Tool aktualisiert `docs/guides/overview.md` (Ruling Q2).
- JSON Schema im von `rest_validate_value_from_schema()` geprüften Umfang (kein `$ref`): Knotenbeschreibungen sind `{"type": "object"}`-Einträge, geprüft wird im `Composer`.
- Commits: `git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit …` mit den Trailern `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>` und `Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7`.
## Review Focus
1. **Redakteur ohne `unfiltered_html` (z. B. auf Multisite auch Administratoren) ändert eine Divi-Seite:** Ohne Gegenmaßnahme würde WordPress' kses jeden unveränderten Block neu serialisieren (in DDEV nachgewiesen). Erwartet: unberührte Blöcke byte-identisch, neue Werte trotzdem bereinigt (kein `<script>`) → Tests in Task 5 (`DiviSaverTest::testSavesWithoutKsesForFilteredUsersAndSetsDiviFlags`) und Task 10 (`divi-write-smoke.sh`, Abschnitt 7 mit einer Rolle ohne `unfiltered_html`).
2. **Parallel im Divi-Builder gespeichert oder versehentlich `rev` statt `tree_rev` gesendet:** `rev_mismatch`, nichts geschrieben, Hinweis auf `lit-divi-outline` → Tests in Task 5 (`DiviWriterTest::testPageChecksTheTreeRevLock`) und Task 6 (`testStaleTreeRevAndInvalidNodesWriteNothing`).
3. **Fehler tief in einem großen Abschnitt (Spaltenbrüche ergeben nicht 1, unbekanntes Modul/Preset/Feld/Attributpfad, falscher Platz):** alle Fehler auf einmal mit Eingabepfad, nichts gespeichert → Test in Task 3 (`ComposerTest::testCollectsEveryErrorWithItsInputPath`).
4. **Strukturverstöße:** Modul auf oberster Ebene, Spalte einfügen, Knoten in den eigenen Teilbaum verschieben, in einen Spezialabschnitt oder ein globales Modul einfügen → verständlich abgelehnt → Tests in Task 4 (`TreeEditorTest`).
5. **Speichern scheitert mittendrin (Hook wirft, Datenbank weg) oder die Seite änderte sich zwischen Probelauf und Ersetzen:** vorheriger Inhalt zurück bzw. `partial_write` im Protokoll; beim Ersetzen wird die veraltete Seite gemeldet, die anderen laufen → Tests in Task 5 (`DiviSaverTest`, `DiviWriterTest::testFailedRollbackIsLoggedAsPartial`) und Task 8 (`testReplacesPerPageAndReportsStaleOnes`).
---
## File Structure
```
src/
Abilities/Errors.php (geändert) invalidAttrPath()
Plugin.php (geändert) DiviIntegration mit ChangeLog, ContentComposer
Guides/InstructionsBuilder.php (geändert) tree_rev-Regel
Integrations/
Divi/
DiviIntegration.php (geändert) Schreib-Tools, contentComposer()
Compose/ reines PHP
ValueSanitizer.php Schnittstelle: text/html/code/url
MediaResolver.php Schnittstelle: Bild aus der Mediathek
Placeholders.php {{color:…}} u. a. → $variable(…)$
AttrWriter.php Vollform: Pfade prüfen, Breakpoints mischen, bereinigen
ShortformWriter.php Kurzform-Felder schreiben
StructureRules.php was wohin darf
Composer.php Knotenbeschreibung → Blöcke; update()
TreeEditor.php Position, einfügen, verschieben, entfernen, duplizieren
Warnings.php Qualitätswarnungen §5.5
TextReplacer.php sichtbaren Text ersetzen
Pages/
WpValueSanitizer.php wp_kses_post / sanitize_text_field / esc_url_raw
WpMediaResolver.php Anhang → URL + Alt (nur lesbare Bilder)
DiviComposition.php verdrahtet Compose mit Katalog, Designsystem, WordPress
DiviChange.php Ergebnis einer Baumänderung
DiviSaver.php Speicher-Pipeline §5.6 (kses, Flags, Cache, Rollback)
DiviWriter.php Sperre, Speichern, Protokoll, ToolResult
DiviInsertAbility.php lit/divi-insert
DiviUpdateAbility.php lit/divi-update
DiviMoveAbility.php lit/divi-move
DiviDeleteAbility.php lit/divi-delete
DiviDuplicateNodeAbility.php lit/divi-duplicate-node
DiviReplaceTextAbility.php lit/divi-replace-text
DiviContentComposer.php Divi-Kurzform für lit/content-create
DiviOutlineAbility.php (geändert) Hinweise
Schema/DiviCatalogAbility.php (geändert) Hinweis auf divi-shortform
WordPress/
Content/ContentComposer.php Schnittstelle für Inhalte aus Beschreibungen
Content/ContentCreateAbility.php (geändert) Eingabe divi
Content/PostPresenter.php (geändert) DIVI_NOTE nennt die Schreib-Tools
WordPressIntegration.php (geändert) optionaler ContentComposer
docs/guides/ divi-shortform.md, divi-placeholders.md (neu); overview, divi-structure, divi-attrs,
workflows, content-types (geändert)
readme.txt, wp-lit-mcp.php (geändert) Version 0.3.0, Auswirkungen, Rückgängig
tests/
Unit/Integrations/Divi/Compose/… ComposeFixture + Tests je Klasse
Unit/Integrations/Divi/Pages/… DiviSaver, DiviWriter, Schreib-Abilities, ContentComposer
Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php
Integration/divi-write-smoke.sh Ende-zu-Ende-Test der Schreib-Tools
```
---
### Task 1: Platzhalter und Vollform – `Placeholders`, `ValueSanitizer`, `MediaResolver`, `AttrWriter`
Spec §5.4: Platzhalter werden in Divis `$variable(…)$`-Syntax übersetzt – Farben `{"type":"color","value":{"name":"gcid-…","settings":{}}}`, Variablen und Theme-Schriften `{"type":"content",…"gvid-…"}` bzw. `--et_global_heading_font`, Beitragswerte `post_title` u. a. mit `{"before":"","after":""}`, Felder `custom_meta_<key>` (Formate aus Divis Code und Defaults, live gerendert). Unbekannte Namen → Fehler mit Liste der vorhandenen; Platzhalter stehen allein im Wert (D9). Die Vollform: Attributpfad → Werte je Breakpoint; kurz (`{"desktop": "#fff"}`) oder roh (`{"desktop": {"value": …, "hover": …}}`), `hover`/`sticky` oben = Desktop; genannte Breakpoints/Zustände ersetzen, der Rest bleibt; `null` entfernt. Pfade gegen den Katalog (`invalid_attr_path` mit Vorschlägen). Bereinigt wird nach Ort (siehe Global Constraints).
**Files:**
- Create: `src/Integrations/Divi/Compose/{Placeholders,ValueSanitizer,MediaResolver,AttrWriter}.php`
- Test: `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php`, `tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php`, `tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php`
**Interfaces:**
- Consumes: `DesignSystem::colorId/variable/colors/variables`, `DesignSystem::COLOR_ALIASES/FONT_VARIABLES` (2a), `Json::encode`, `AttrMap::BREAKPOINTS/isBreakpointMap/get/set/remove` (2a), `ModuleCatalog::isKnownPath/suggestions` (2a); Test-Helfer `DesignSystemTest::sample()`, `ModuleCatalogTest::sampleCatalog()`.
- Produces:
- `Placeholders(DesignSystem $design)`: `POST` (Map `title` → `post_title` …); `static isVariable(string $v): bool`; `resolve(string $v): string` (wirft `InvalidArgumentException` mit deutscher Meldung); `resolveDeep(mixed $v): mixed`.
- `interface ValueSanitizer { text(string): string; html(string): string; code(string): string; url(string): string; }`
- `interface MediaResolver { image(int $id): ?array{url: string, alt: string}; }`
- `AttrWriter(ModuleCatalog, Placeholders, ValueSanitizer)`: `apply(string $module, array $attrs, array $changes, string $field): array{attrs, errors: array<string,string>, invalid_paths: list<string>}` (Fehlerschlüssel `"<field>.<pfad>"`); `cleanValue(string $module, string $path, mixed $value, ?string $subKey = null): mixed`; `static normalize(mixed $map): array` (Breakpoint → Zustand → Wert, wirft `InvalidArgumentException`).
- `ComposeFixture` (Test-Helfer, wächst in Tasks 2–3): `sanitizer(): ValueSanitizer` (text = `strip_tags`+`trim`, html entfernt `<script>`, code unverändert, url lehnt `javascript:` ab), `media(): MediaResolver` (33 → team.jpg/„Unser Team“, 12 → halle.jpg/ohne Alt), `placeholders()`, `attrWriter()`.
- [ ] **Step 1: Failing Tests**
`tests/Unit/Integrations/Divi/Compose/ComposeFixture.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
use LIT\MCP\Integrations\Divi\Compose\AttrWriter;
use LIT\MCP\Integrations\Divi\Compose\MediaResolver;
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer;
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest;
/**
* Compose classes wired with the real Divi 5.2.1 sample catalog, the sample design system and
* predictable fakes for sanitizing and media (no WordPress).
*/
final class ComposeFixture
{
public static function sanitizer(): ValueSanitizer
{
return new class implements ValueSanitizer {
public function text(string $value): string
{
return trim(strip_tags($value));
}
public function html(string $value): string
{
return (string) preg_replace('#<script\b[^>]*>.*?</script>#is', '', $value);
}
public function code(string $value): string
{
return $value;
}
public function url(string $value): string
{
return str_starts_with(strtolower(trim($value)), 'javascript:') ? '' : trim($value);
}
};
}
public static function media(): MediaResolver
{
return new class implements MediaResolver {
public function image(int $id): ?array
{
return match ($id) {
33 => ['url' => 'https://kunde.test/wp-content/uploads/team.jpg', 'alt' => 'Unser Team'],
12 => ['url' => 'https://kunde.test/wp-content/uploads/halle.jpg', 'alt' => ''],
default => null,
};
}
};
}
public static function placeholders(): Placeholders
{
return new Placeholders(DesignSystemTest::sample());
}
public static function attrWriter(): AttrWriter
{
return new AttrWriter(ModuleCatalogTest::sampleCatalog(), self::placeholders(), self::sanitizer());
}
}
```
`tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
use PHPUnit\Framework\TestCase;
final class PlaceholdersTest extends TestCase
{
/** @return array<string, array{string, string}> */
public static function placeholders(): array
{
return [
'color alias' => ['{{color:primary}}', '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$'],
'color label' => ['{{ color : Nachtblau }}', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'],
'variable' => ['{{var:Telefon}}', '$variable({"type":"content","value":{"name":"gvid-s1","settings":{}}})$'],
'font' => ['{{font:heading}}', '$variable({"type":"content","value":{"name":"--et_global_heading_font","settings":{}}})$'],
'post title' => [
'{{post:title}}',
'$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$',
],
'featured image' => ['{{post:featured_image}}', '$variable({"type":"content","value":{"name":"post_featured_image","settings":{}}})$'],
'custom field' => [
'{{field:telefon_mobil}}',
'$variable({"type":"content","value":{"name":"custom_meta_telefon_mobil","settings":{"before":"","after":""}}})$',
],
'plain text' => ['Hallo Welt', 'Hallo Welt'],
];
}
/** @dataProvider placeholders */
public function testResolvesToDiviVariables(string $input, string $expected): void
{
$resolved = ComposeFixture::placeholders()->resolve($input);
self::assertSame($expected, $resolved);
self::assertSame($expected !== $input, Placeholders::isVariable($resolved));
}
/** @return array<string, array{string, string}> */
public static function invalid(): array
{
return [
'unknown color' => ['{{color:Lila}}', 'Vorhanden: primary, body, Primärfarbe, Fließtextfarbe, Nachtblau.'],
'unknown variable' => ['{{var:Fax}}', 'Vorhanden: Radius, Telefon'],
'unknown font' => ['{{font:mono}}', 'heading, body'],
'unknown post value' => ['{{post:views}}', 'title, excerpt'],
'bad field key' => ['{{field:a b}}', 'Ungültiger Feldschlüssel'],
'embedded' => ['Seite: {{post:title}}', 'allein im Wert'],
];
}
/** @dataProvider invalid */
public function testRejectsUnknownNamesWithTheExistingOnes(string $input, string $message): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage($message);
ComposeFixture::placeholders()->resolve($input);
}
public function testResolvesDeepValues(): void
{
self::assertSame(
['color' => '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$', 'size' => '20px'],
ComposeFixture::placeholders()->resolveDeep(['color' => '{{color:primary}}', 'size' => '20px'])
);
self::assertFalse(Placeholders::isVariable('$variable(kaputt)$'));
}
}
```
`tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
use LIT\MCP\Integrations\Divi\Compose\AttrWriter;
use PHPUnit\Framework\TestCase;
final class AttrWriterTest extends TestCase
{
private const HEADING = [
'title' => [
'innerContent' => ['desktop' => ['value' => 'Alt']],
'decoration' => ['font' => ['font' => [
'desktop' => ['value' => ['headingLevel' => 'h2', 'color' => '#111111']],
'phone' => ['value' => ['size' => '22px']],
]]],
],
'builderVersion' => '5.2.1',
];
public function testMergesShortAndRawBreakpointsAndKeepsTheRest(): void
{
$result = ComposeFixture::attrWriter()->apply('divi/heading', self::HEADING, [
'title.decoration.font.font' => [
'desktop' => ['value' => ['headingLevel' => 'h3'], 'hover' => ['color' => '{{color:primary}}']],
'tablet' => ['size' => '26px'],
],
'title.innerContent' => ['desktop' => '<b>Neu</b><script>x()</script>'],
], 'attrs');
self::assertSame([], $result['errors']);
self::assertSame(
[
'desktop' => [
'value' => ['headingLevel' => 'h3'],
'hover' => ['color' => '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$'],
],
'phone' => ['value' => ['size' => '22px']],
'tablet' => ['value' => ['size' => '26px']],
],
$result['attrs']['title']['decoration']['font']['font']
);
self::assertSame('<b>Neu</b>', $result['attrs']['title']['innerContent']['desktop']['value']);
self::assertSame('5.2.1', $result['attrs']['builderVersion']);
}
public function testNullRemovesPathsAndStatesAndTopLevelHoverMeansDesktop(): void
{
$writer = ComposeFixture::attrWriter();
$removed = $writer->apply('divi/heading', self::HEADING, ['title.decoration.font.font' => null], 'attrs');
self::assertArrayNotHasKey('decoration', $removed['attrs']['title']);
$phoneGone = $writer->apply('divi/heading', self::HEADING, [
'title.decoration.font.font' => ['phone' => ['value' => null], 'hover' => ['color' => '#000000']],
], 'attrs');
self::assertSame(
['desktop' => ['value' => ['headingLevel' => 'h2', 'color' => '#111111'], 'hover' => ['color' => '#000000']]],
$phoneGone['attrs']['title']['decoration']['font']['font']
);
}
public function testUnknownPathsGetSuggestions(): void
{
$result = ComposeFixture::attrWriter()->apply('divi/heading', self::HEADING, [
'title.decoration.font' => ['desktop' => ['color' => '#fff']],
'title.innerContent' => ['mobile' => 'x'],
], 'changes[0].attrs');
self::assertSame(['title.decoration.font'], $result['invalid_paths']);
self::assertStringContainsString(
'title.decoration.font.font',
$result['errors']['changes[0].attrs.title.decoration.font']
);
self::assertStringContainsString('unbekannter Breakpoint "mobile"', $result['errors']['changes[0].attrs.title.innerContent']);
self::assertSame(self::HEADING, $result['attrs']);
}
public function testSanitizesByPlace(): void
{
$writer = ComposeFixture::attrWriter();
self::assertSame('', $writer->cleanValue('divi/button', 'button.innerContent', 'javascript:alert(1)', 'linkUrl'));
self::assertSame('<p>x</p>', $writer->cleanValue('divi/text', 'content.innerContent', '<p>x</p><script>y</script>'));
self::assertSame('<script>ok()</script>', $writer->cleanValue('divi/code', 'content.innerContent', '<script>ok()</script>'));
self::assertSame('10px', $writer->cleanValue('divi/text', 'module.decoration.spacing', '<b>10px</b>'));
self::assertSame(
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$',
$writer->cleanValue('divi/text', 'module.decoration.background', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$')
);
self::assertSame(['desktop' => ['value' => 'x']], AttrWriter::normalize(['desktop' => 'x']));
}
}
```
- [ ] **Step 2: Test laufen lassen (rot)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'PlaceholdersTest|AttrWriterTest'`
Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\Compose\Placeholders" not found`.
- [ ] **Step 3: Implementierung**
`src/Integrations/Divi/Compose/Placeholders.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Compose;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\BlockTree\Json;
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
use stdClass;
/**
* Translates the placeholders of spec §5.4 into Divi's $variable(…)$ syntax:
* {{color:primary|<Name>}}, {{var:<Name>}}, {{font:heading|body}}, {{post:title|…}},
* {{field:<meta_key>}}. A placeholder must be the whole value; unknown names fail with the
* list of existing ones. Pure PHP.
*/
final class Placeholders
{
/** {{post:<key>}} => Divi dynamic content option. */
public const POST = [
'title' => 'post_title',
'excerpt' => 'post_excerpt',
'date' => 'post_date',
'modified' => 'post_modified_date',
'author' => 'post_author',
'url' => 'post_link_url',
'featured_image' => 'post_featured_image',
'categories' => 'post_categories',
'tags' => 'post_tags',
];
/** Dynamic content that is text and takes before/after settings. */
private const TEXT_SETTINGS = [
'post_title', 'post_excerpt', 'post_date', 'post_modified_date', 'post_author', 'post_categories', 'post_tags',
];
private const PATTERN = '/^\{\{\s*(color|var|font|post|field)\s*:\s*([^{}]+?)\s*\}\}$/';
public function __construct(private DesignSystem $design)
{
}
/** Whether $value is a Divi variable reference ($variable({…})$), e.g. from an earlier read. */
public static function isVariable(string $value): bool
{
return preg_match('/^\$variable\((\{.*\})\)\$$/s', $value, $match) === 1
&& is_array(json_decode($match[1], true));
}
/**
* Resolves $value when it is a placeholder; other strings are returned unchanged.
*
* @throws InvalidArgumentException With a German message for the client.
*/
public function resolve(string $value): string
{
if (preg_match(self::PATTERN, trim($value), $match) !== 1) {
if (str_contains($value, '{{')) {
throw new InvalidArgumentException(
'Platzhalter müssen allein im Wert stehen, z. B. "{{color:primary}}" (bekannt: color, var, font, '
. 'post, field).'
);
}
return $value;
}
[, $kind, $name] = $match;
return match ($kind) {
'color' => $this->color($name),
'var' => $this->variable($name),
'font' => $this->font($name),
'post' => $this->post($name),
default => $this->field($name),
};
}
/**
* Applies resolve() to every string inside $value (full-form values are nested).
*
* @throws InvalidArgumentException
*/
public function resolveDeep(mixed $value): mixed
{
if (is_string($value)) {
return $this->resolve($value);
}
if (is_array($value)) {
return array_map([$this, 'resolveDeep'], $value);
}
return $value;
}
private function color(string $name): string
{
$id = $this->design->colorId($name);
if ($id === null) {
$ids = array_column($this->design->colors(), 'id');
$known = array_merge(
array_keys(array_intersect(DesignSystem::COLOR_ALIASES, $ids)),
array_column($this->design->colors(), 'label')
);
throw new InvalidArgumentException(sprintf(
'Unbekannte globale Farbe "%s". Vorhanden: %s.',
$name,
implode(', ', array_unique($known))
));
}
return self::variableString('color', $id, new stdClass());
}
private function variable(string $name): string
{
$variable = $this->design->variable($name);
if ($variable === null) {
$labels = array_column($this->design->variables(), 'label');
throw new InvalidArgumentException(sprintf(
'Unbekannte globale Variable "%s". Vorhanden: %s.',
$name,
$labels === [] ? '(keine)' : implode(', ', $labels)
));
}
$type = $variable['type'] === 'colors' ? 'color' : 'content';
return self::variableString($type, $variable['id'], new stdClass());
}
private function font(string $name): string
{
$id = DesignSystem::FONT_VARIABLES[strtolower($name)] ?? null;
if ($id === null) {
throw new InvalidArgumentException(
sprintf('Unbekannte Theme-Schrift "%s". Vorhanden: heading, body.', $name)
);
}
return self::variableString('content', $id, new stdClass());
}
private function post(string $name): string
{
$option = self::POST[strtolower($name)] ?? null;
if ($option === null) {
throw new InvalidArgumentException(sprintf(
'Unbekannter Beitragswert "%s". Vorhanden: %s.',
$name,
implode(', ', array_keys(self::POST))
));
}
$settings = in_array($option, self::TEXT_SETTINGS, true) ? ['before' => '', 'after' => ''] : new stdClass();
return self::variableString('content', $option, $settings);
}
private function field(string $name): string
{
if (preg_match('/^[A-Za-z0-9_-]+$/', $name) !== 1) {
throw new InvalidArgumentException(sprintf('Ungültiger Feldschlüssel "%s".', $name));
}
return self::variableString('content', 'custom_meta_' . $name, ['before' => '', 'after' => '']);
}
/** @param array<string, string>|stdClass $settings */
private static function variableString(string $type, string $name, array|stdClass $settings): string
{
$payload = ['type' => $type, 'value' => ['name' => $name, 'settings' => $settings]];
return '$variable(' . Json::encode($payload) . ')$';
}
}
```
`src/Integrations/Divi/Compose/ValueSanitizer.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Compose;
/**
* Cleans values before they go into Divi attributes (spec §10: wp_kses_post for text
* modules, sanitize_text_field for simple fields). The pure Compose classes only know this
* interface; WpValueSanitizer is the WordPress implementation.
*/
interface ValueSanitizer
{
/** Single-line text (labels, titles, button text, colors, sizes). */
public function text(string $value): string;
/** Rich text of text modules. */
public function html(string $value): string;
/** Code module content: unfiltered only for users with unfiltered_html. */
public function code(string $value): string;
/** Link or media URL; '' when not acceptable. */
public function url(string $value): string;
}
```
`src/Integrations/Divi/Compose/MediaResolver.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Compose;
/**
* Looks up media library images for short-form fields like image_id (spec §6.3: Divi image
* modules reference images by attachment ID plus URL). WpMediaResolver is the WordPress side.
*/
interface MediaResolver
{
/** @return array{url: string, alt: string}|null Null when $id is no image the user may read. */
public function image(int $id): ?array;
}
```
`src/Integrations/Divi/Compose/AttrWriter.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Compose;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
/**
* Applies full-form changes (spec §5.4): attribute path => breakpoint map. Paths are checked
* against the catalog (invalid_attr_path with suggestions), placeholders resolved, strings
* sanitized. A breakpoint may be given short ({"desktop": "#fff"}) or raw
* ({"desktop": {"value": "#fff", "hover": "#000"}}); top-level "hover"/"sticky" mean desktop.
* Given breakpoints/states replace the existing ones, others stay; null removes. Pure PHP.
*/
final class AttrWriter
{
private const STATES = ['value', 'hover', 'sticky'];
/** innerContent sub keys that hold URLs. */
private const URL_KEY = '/(url|src|link)$/i';
private const CODE_MODULES = ['divi/code', 'divi/fullwidth-code'];
public function __construct(
private ModuleCatalog $catalog,
private Placeholders $placeholders,
private ValueSanitizer $sanitizer
) {
}
/**
* @param array<string, mixed> $attrs Current attributes of the block.
* @param array<array-key, mixed> $changes Path => breakpoint map, or null to remove the path.
* @return array{attrs: array<string, mixed>, errors: array<string, string>, invalid_paths: list<string>}
*/
public function apply(string $module, array $attrs, array $changes, string $field): array
{
$errors = [];
$invalid = [];
foreach ($changes as $path => $map) {
$path = (string) $path;
$key = $field . '.' . $path;
if (! $this->catalog->isKnownPath($module, $path)) {
$suggestions = $this->catalog->suggestions($module, $path);
$errors[$key] = 'unbekannter Attributpfad'
. ($suggestions === [] ? '' : '; ähnlich: ' . implode(', ', $suggestions));
$invalid[] = $path;
continue;
}
if ($map === null) {
$attrs = AttrMap::remove($attrs, $path);
continue;
}
try {
$normalized = $this->clean($module, $path, $this->placeholders->resolveDeep(self::normalize($map)));
} catch (InvalidArgumentException $e) {
$errors[$key] = $e->getMessage();
continue;
}
$merged = self::merge(AttrMap::get($attrs, $path), $normalized);
$attrs = $merged === [] ? AttrMap::remove($attrs, $path) : AttrMap::set($attrs, $path, $merged);
}
return ['attrs' => $attrs, 'errors' => $errors, 'invalid_paths' => $invalid];
}
/**
* Cleans one value written to $path (used by the short form too).
*
* @throws InvalidArgumentException
*/
public function cleanValue(string $module, string $path, mixed $value, ?string $subKey = null): mixed
{
if (is_array($value)) {
$clean = [];
foreach ($value as $key => $item) {
$clean[$key] = $this->cleanValue($module, $path, $item, is_string($key) ? $key : $subKey);
}
return $clean;
}
if (! is_string($value) || Placeholders::isVariable($value)) {
return $value;
}
if (! str_ends_with($path, '.innerContent')) {
return $this->sanitizer->text($value);
}
if ($subKey !== null && preg_match(self::URL_KEY, $subKey) === 1) {
return $this->sanitizer->url($value);
}
return in_array($module, self::CODE_MODULES, true)
? $this->sanitizer->code($value)
: $this->sanitizer->html($value);
}
/**
* @return array<string, array<string, mixed>> Breakpoint => state => value.
* @throws InvalidArgumentException
*/
public static function normalize(mixed $map): array
{
$shape = 'erwartet {"desktop": Wert, "tablet": Wert, "phone": Wert, "hover": Wert} '
. 'oder {"desktop": {"value": …, "hover": …}}';
if (! is_array($map) || $map === [] || array_is_list($map)) {
throw new InvalidArgumentException($shape);
}
$normalized = [];
foreach ($map as $key => $value) {
$key = (string) $key;
if ($key === 'hover' || $key === 'sticky') {
$normalized['desktop'][$key] = $value;
} elseif (in_array($key, AttrMap::BREAKPOINTS, true)) {
$isRaw = is_array($value) && $value !== [] && ! array_is_list($value)
&& array_diff(array_map('strval', array_keys($value)), self::STATES) === [];
foreach ($isRaw ? $value : ['value' => $value] as $state => $stateValue) {
$normalized[$key][(string) $state] = $stateValue;
}
} else {
throw new InvalidArgumentException(sprintf('unbekannter Breakpoint "%s"; %s', $key, $shape));
}
}
return $normalized;
}
/**
* @param array<string, array<string, mixed>> $normalized
* @return array<string, array<string, mixed>>
* @throws InvalidArgumentException
*/
private function clean(string $module, string $path, array $normalized): array
{
foreach ($normalized as $breakpoint => $states) {
foreach ($states as $state => $value) {
$normalized[$breakpoint][$state] = $this->cleanValue($module, $path, $value);
}
}
return $normalized;
}
/**
* @param array<string, array<string, mixed>> $changes
* @return array<string, mixed>
*/
private static function merge(mixed $existing, array $changes): array
{
$map = is_array($existing) && AttrMap::isBreakpointMap($existing) ? $existing : [];
foreach ($changes as $breakpoint => $states) {
$current = is_array($map[$breakpoint] ?? null) ? $map[$breakpoint] : [];
foreach ($states as $state => $value) {
if ($value === null) {
unset($current[$state]);
} else {
$current[$state] = $value;
}
}
if ($current === []) {
unset($map[$breakpoint]);
} else {
$map[$breakpoint] = $current;
}
}
return $map;
}
}
```
- [ ] **Step 4: Tests und Lint (grün)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'PlaceholdersTest|AttrWriterTest' && composer lint`
Expected: PASS, phpcs ohne Meldungen.
- [ ] **Step 5: Commit**
```bash
cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
git add src/Integrations/Divi/Compose/ tests/Unit/Integrations/Divi/Compose/
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi placeholders and full-form attribute writer
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
```
---
### Task 2: Kurzform schreiben – `ShortformWriter`
Spec §5.4: Kurzform-Felder nach `ShortformMap` (2a) – Wert je Art prüfen (`onoff` aus true/false, `level` h1–h6, `number`, `enum`, `ids` als „12,33“, `color` nur Farbe oder Platzhalter, `url` ohne `javascript:`), Platzhalter auflösen (nicht beim Code-Modul), bereinigen und unter `<pfad>.desktop.value[.<sub>]` setzen; `null` entfernt; `with` setzt feste Zusatzwerte (Blurb-Bild → `useIcon: off`). Medienfelder (`image_id`, `portrait_id`, `image_ids`) holen URL, ID und Alt-Text über `MediaResolver` – ein ausdrücklich gesetztes `alt` gewinnt.
**Files:**
- Create: `src/Integrations/Divi/Compose/ShortformWriter.php`
- Modify: `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` (`shortform()`)
- Test: `tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php`
**Interfaces:**
- Consumes: `ShortformMap::fields()` (2a), `AttrMap::set/remove` (2a), `Placeholders`, `ValueSanitizer`, `MediaResolver`, `AttrWriter::cleanValue()` (Task 1).
- Produces: `ShortformWriter(Placeholders, ValueSanitizer, MediaResolver, AttrWriter)`: `apply(string $type, array $attrs, array $values, string $field): array{attrs, errors}` (`$type` = Kurzname, Fehlerschlüssel `"<field>.<feld>"`); `ComposeFixture::shortform(): ShortformWriter`.
- [ ] **Step 1: Failing Tests**
In `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` ersetzen:
```php
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
```
durch:
```php
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
use LIT\MCP\Integrations\Divi\Compose\ShortformWriter;
```
In `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` (Methode ergänzen) ersetzen:
```php
public static function attrWriter(): AttrWriter
{
return new AttrWriter(ModuleCatalogTest::sampleCatalog(), self::placeholders(), self::sanitizer());
}
```
durch:
```php
public static function attrWriter(): AttrWriter
{
return new AttrWriter(ModuleCatalogTest::sampleCatalog(), self::placeholders(), self::sanitizer());
}
public static function shortform(): ShortformWriter
{
return new ShortformWriter(self::placeholders(), self::sanitizer(), self::media(), self::attrWriter());
}
```
`tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
use PHPUnit\Framework\TestCase;
final class ShortformWriterTest extends TestCase
{
public function testWritesFieldsAtTheirPaths(): void
{
$result = ComposeFixture::shortform()->apply('button', [], [
'text' => 'Termin <b>buchen</b>',
'url' => '/kontakt',
'new_window' => true,
], 'nodes[0]');
self::assertSame([], $result['errors']);
self::assertSame(
['button' => ['innerContent' => ['desktop' => ['value' => [
'text' => 'Termin buchen', 'linkUrl' => '/kontakt', 'linkTarget' => 'on',
]]]]],
$result['attrs']
);
}
public function testMediaSetsUrlIdAndAltUnlessAltIsGiven(): void
{
$writer = ComposeFixture::shortform();
$image = $writer->apply('image', [], ['image_id' => 33], 'n');
self::assertSame(
['src' => 'https://kunde.test/wp-content/uploads/team.jpg', 'id' => '33', 'alt' => 'Unser Team'],
$image['attrs']['image']['innerContent']['desktop']['value']
);
$own = $writer->apply('image', [], ['image_id' => 33, 'alt' => 'Team 2026'], 'n');
self::assertSame('Team 2026', $own['attrs']['image']['innerContent']['desktop']['value']['alt']);
$blurb = $writer->apply('blurb', [], ['image_id' => 12, 'title' => 'Halle'], 'n');
self::assertSame(
['src' => 'https://kunde.test/wp-content/uploads/halle.jpg', 'useIcon' => 'off'],
$blurb['attrs']['imageIcon']['innerContent']['desktop']['value']
);
$gallery = $writer->apply('gallery', [], ['image_ids' => [12, 33]], 'n');
self::assertSame('12,33', $gallery['attrs']['image']['advanced']['galleryIds']['desktop']['value']);
}
public function testValidatesKindsAndNames(): void
{
$result = ComposeFixture::shortform()->apply('heading', [], [
'level' => 'h7',
'text' => '{{color:Lila}}',
'size' => 12,
], 'nodes[1]');
self::assertSame('h1 bis h6 erwartet', $result['errors']['nodes[1].level']);
self::assertStringContainsString('Unbekannte globale Farbe', $result['errors']['nodes[1].text']);
self::assertSame('unbekanntes Feld; erlaubt: text, level', $result['errors']['nodes[1].size']);
$more = ComposeFixture::shortform()->apply('contact-field', [], ['type' => 'date', 'required' => 'ja'], 'f');
self::assertStringContainsString('erlaubt: input, email', $more['errors']['f.type']);
self::assertSame('true oder false erwartet', $more['errors']['f.required']);
$media = ComposeFixture::shortform()->apply('image', [], ['image_id' => 999, 'url' => 'javascript:x'], 'm');
self::assertStringContainsString('kein Bild aus der Mediathek', $media['errors']['m.image_id']);
self::assertSame('ungültige URL', $media['errors']['m.url']);
$none = ComposeFixture::shortform()->apply('accordion', [], ['title' => 'x'], 'a');
self::assertStringContainsString('keine Kurzform', $none['errors']['a.title']);
}
public function testPlaceholdersAndNullRemoval(): void
{
$writer = ComposeFixture::shortform();
$attrs = $writer->apply('heading', [], ['text' => '{{post:title}}', 'level' => 'h2'], 'n')['attrs'];
self::assertSame(
'$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$',
$attrs['title']['innerContent']['desktop']['value']
);
$removed = $writer->apply('heading', $attrs, ['level' => null], 'n')['attrs'];
self::assertArrayNotHasKey('decoration', $removed['title'], 'empty parents are pruned');
$divider = $writer->apply('divider', [], ['color' => '{{color:Nachtblau}}'], 'n')['attrs'];
self::assertStringContainsString('gcid-a1b2', $divider['divider']['advanced']['line']['desktop']['value']['color']);
self::assertSame(
'Farbe erwartet, z. B. {{color:primary}} oder #1a1a1a',
$writer->apply('divider', [], ['color' => 'blau;}'], 'n')['errors']['n.color']
);
}
}
```
- [ ] **Step 2: Test laufen lassen (rot)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter ShortformWriterTest`
Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\Compose\ShortformWriter" not found`.
- [ ] **Step 3: Implementierung**
`src/Integrations/Divi/Compose/ShortformWriter.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Compose;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
/**
* Writes short-form fields (spec §5.4, ShortformMap) into Divi attributes: validates the value
* per kind, resolves placeholders, sanitizes and sets it at "<path>.desktop.value[.<sub>]".
* null removes a field. Pure PHP.
*/
final class ShortformWriter
{
private const COLOR = '/^(#[0-9a-fA-F]{3,8}|rgba?\([0-9.,%\s]+\)|hsla?\([0-9.,%\s]+\)|transparent)$/';
public function __construct(
private Placeholders $placeholders,
private ValueSanitizer $sanitizer,
private MediaResolver $media,
private AttrWriter $attrs
) {
}
/**
* @param string $type Short module name ("button").
* @param array<string, mixed> $attrs Current attributes.
* @param array<string, mixed> $values Field => value.
* @return array{attrs: array<string, mixed>, errors: array<string, string>}
*/
public function apply(string $type, array $attrs, array $values, string $field): array
{
$fields = ShortformMap::fields($type);
$errors = [];
foreach ($values as $name => $value) {
$name = (string) $name;
$spec = $fields[$name] ?? null;
if ($spec === null) {
$errors[$field . '.' . $name] = $fields === []
? 'dieses Modul hat keine Kurzform; Einstellungen über attrs (Vollform) setzen'
: 'unbekanntes Feld; erlaubt: ' . implode(', ', array_keys($fields));
continue;
}
try {
$attrs = $this->set('divi/' . $type, $attrs, $spec, $value, $values);
} catch (InvalidArgumentException $e) {
$errors[$field . '.' . $name] = $e->getMessage();
}
}
return ['attrs' => $attrs, 'errors' => $errors];
}
/**
* @param array<string, mixed> $attrs
* @param array<string, mixed> $spec
* @param array<string, mixed> $all All values of this call (media must not overwrite an explicit alt).
* @return array<string, mixed>
* @throws InvalidArgumentException
*/
private function set(string $module, array $attrs, array $spec, mixed $value, array $all): array
{
$base = $spec['path'] . '.desktop.value';
if ($value === null) {
return AttrMap::remove($attrs, $spec['sub'] === null ? $spec['path'] : $base . '.' . $spec['sub']);
}
if ($spec['kind'] === 'media') {
$attrs = $this->media($attrs, $base, $spec['keys'], $value, ! array_key_exists('alt', $all));
} else {
$clean = $this->value($module, $spec, $value);
$attrs = AttrMap::set($attrs, $spec['sub'] === null ? $base : $base . '.' . $spec['sub'], $clean);
}
foreach ($spec['with'] ?? [] as $sub => $fixed) {
$attrs = AttrMap::set($attrs, $base . '.' . $sub, $fixed);
}
return $attrs;
}
/**
* @param array<string, mixed> $attrs
* @param array{0: string, 1: ?string, 2: ?string} $keys URL key, ID key, alt key.
* @return array<string, mixed>
* @throws InvalidArgumentException
*/
private function media(array $attrs, string $base, array $keys, mixed $value, bool $withAlt): array
{
$id = is_int($value) ? $value : (is_string($value) && ctype_digit($value) ? (int) $value : 0);
$image = $id > 0 ? $this->media->image($id) : null;
if ($image === null) {
throw new InvalidArgumentException(
'kein Bild aus der Mediathek; Anhang-ID z. B. mit lit-media-search suchen'
);
}
[$urlKey, $idKey, $altKey] = $keys;
$attrs = AttrMap::set($attrs, $base . '.' . $urlKey, $this->sanitizer->url($image['url']));
if ($idKey !== null) {
$attrs = AttrMap::set($attrs, $base . '.' . $idKey, (string) $id);
}
if ($altKey !== null && $withAlt && $image['alt'] !== '') {
$attrs = AttrMap::set($attrs, $base . '.' . $altKey, $this->sanitizer->text($image['alt']));
}
return $attrs;
}
/**
* @param array<string, mixed> $spec
* @throws InvalidArgumentException
*/
private function value(string $module, array $spec, mixed $value): mixed
{
switch ($spec['kind']) {
case 'onoff':
if (is_bool($value)) {
return $value ? 'on' : 'off';
}
if ($value === 'on' || $value === 'off') {
return $value;
}
throw new InvalidArgumentException('true oder false erwartet');
case 'level':
if (is_string($value) && preg_match('/^h[1-6]$/', $value) === 1) {
return $value;
}
throw new InvalidArgumentException('h1 bis h6 erwartet');
case 'number':
if (is_int($value) || is_float($value) || (is_string($value) && is_numeric($value))) {
return $value + 0;
}
throw new InvalidArgumentException('Zahl erwartet');
case 'enum':
if (is_string($value) && in_array($value, $spec['values'], true)) {
return $value;
}
throw new InvalidArgumentException('erlaubt: ' . implode(', ', $spec['values']));
case 'ids':
return $this->ids($value);
}
if (! is_string($value) && ! is_int($value) && ! is_float($value)) {
throw new InvalidArgumentException('Text erwartet');
}
$value = (string) $value;
if ($spec['kind'] !== 'code') {
$value = $this->placeholders->resolve($value);
if (Placeholders::isVariable($value)) {
return $value;
}
}
if ($spec['kind'] === 'color') {
if (preg_match(self::COLOR, trim($value)) !== 1) {
throw new InvalidArgumentException('Farbe erwartet, z. B. {{color:primary}} oder #1a1a1a');
}
return $this->sanitizer->text($value);
}
if ($spec['kind'] === 'url') {
$url = $this->sanitizer->url($value);
if ($url === '' && trim($value) !== '') {
throw new InvalidArgumentException('ungültige URL');
}
return $url;
}
return match ($spec['kind']) {
'html', 'code' => $this->attrs->cleanValue($module, (string) $spec['path'], $value),
default => $this->sanitizer->text($value),
};
}
/** @throws InvalidArgumentException */
private function ids(mixed $value): string
{
if (! is_array($value) || $value === [] || ! array_is_list($value)) {
throw new InvalidArgumentException('Liste von Anhang-IDs erwartet, z. B. [12, 33]');
}
$ids = [];
foreach ($value as $id) {
$id = is_int($id) ? $id : (is_string($id) && ctype_digit($id) ? (int) $id : 0);
if ($id <= 0 || $this->media->image($id) === null) {
throw new InvalidArgumentException(sprintf('%s ist kein Bild aus der Mediathek', (string) $id));
}
$ids[] = $id;
}
return implode(',', $ids);
}
}
```
- [ ] **Step 4: Tests und Lint (grün)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter Compose && composer lint`
Expected: PASS, phpcs ohne Meldungen.
- [ ] **Step 5: Commit**
```bash
cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
git add src/Integrations/Divi/Compose/ShortformWriter.php tests/Unit/Integrations/Divi/Compose/
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi short-form writer
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
```
---
### Task 3: Aus Beschreibungen Blöcke bauen – `StructureRules` und `Composer`
Spec §5.4 „Struktur“ und §5.5: Abschnitt → Zeilen (`"columns": "1_3,2_3"`) → Spalten → Module; Vollbreite über `section_type: "fullwidth"`; Kind-Module in `items`; `preset` per Name (Modul-Presets aus dem Designsystem); fehlendes `label` → „<Abschnitt> – <Modultitel>“; `heading` ohne `level` → `h2` (D9); jeder neue Block bekommt `builderVersion` (live `ET_BUILDER_PRODUCT_VERSION`). Spaltenbreiten über `module.decoration.sizing.desktop.value.flexType` wie Divi 5.2 (D8; `a_b` mit 24 % b = 0 → `(a·24/b)_24`, sonst Fünftel und 1/7, 1/9, 1/10, 1/11; Summe = 1). `StructureRules` prüft, was wohin darf (Seite → Abschnitte, regulärer Abschnitt → Zeilen, Vollbreite → `fullwidth-*`, Spalte/Gruppe → Module, Elternmodul → seine Kind-Module; Spalten nur mit ihrer Zeile; Spezialabschnitte und nur lesbare Knoten nicht). Alle Fehler werden mit Eingabepfad gesammelt. `update()` wendet `set`/`attrs`/`preset`/`label` auf einen vorhandenen Block an (für `lit-divi-update`).
**Files:**
- Create: `src/Integrations/Divi/Compose/{StructureRules,Composer}.php`
- Modify: `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` (`rules()`, `composer()`)
- Test: `tests/Unit/Integrations/Divi/Compose/ComposerTest.php`
**Interfaces:**
- Consumes: `Block::create()/insertChild()/name()/attrs()/isDivi()/isOpaque()`, `AttrMap::set/desktop` (2a), `ModuleCatalog::has/category/childrenOf/title/normalize` (2a), `ShortformMap::fields()` (2a), `DesignSystem::modulePresetId/modulePresetNames` (2a), `ShortformWriter`, `AttrWriter` (Tasks 1–2).
- Produces:
- `StructureRules(ModuleCatalog)`: `violation(?Block $parent, string $child): ?string` (null = erlaubt; `$parent` null = oberste Ebene); `isPlainModule(string $name): bool`.
- `Composer(ModuleCatalog, StructureRules, ShortformWriter, AttrWriter, DesignSystem, string $builderVersion)`: `compose(array $specs, ?Block $parent, string $field, string $context = ''): array{blocks: list<Block>, errors: array<string,string>, invalid_paths: list<string>}` (Fehlerschlüssel z. B. `nodes[0].rows[1].modules[0][2].text`); `update(Block $block, array $change, string $field): array{attrs, errors, invalid_paths}`; `autoLabel(string $name, string $context): string`.
- `ComposeFixture::rules()`, `ComposeFixture::composer()` (Builder-Version `5.2.1`).
- [ ] **Step 1: Failing Tests**
In `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` ersetzen:
```php
use LIT\MCP\Integrations\Divi\Compose\AttrWriter;
```
durch:
```php
use LIT\MCP\Integrations\Divi\Compose\AttrWriter;
use LIT\MCP\Integrations\Divi\Compose\Composer;
```
In `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` ersetzen:
```php
use LIT\MCP\Integrations\Divi\Compose\ShortformWriter;
```
durch:
```php
use LIT\MCP\Integrations\Divi\Compose\ShortformWriter;
use LIT\MCP\Integrations\Divi\Compose\StructureRules;
```
In `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` (Methoden ergänzen) ersetzen:
```php
public static function shortform(): ShortformWriter
{
return new ShortformWriter(self::placeholders(), self::sanitizer(), self::media(), self::attrWriter());
}
}
```
durch:
```php
public static function shortform(): ShortformWriter
{
return new ShortformWriter(self::placeholders(), self::sanitizer(), self::media(), self::attrWriter());
}
public static function rules(): StructureRules
{
return new StructureRules(ModuleCatalogTest::sampleCatalog());
}
public static function composer(): Composer
{
return new Composer(
ModuleCatalogTest::sampleCatalog(),
self::rules(),
self::shortform(),
self::attrWriter(),
DesignSystemTest::sample(),
'5.2.1'
);
}
}
```
`tests/Unit/Integrations/Divi/Compose/ComposerTest.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use PHPUnit\Framework\TestCase;
final class ComposerTest extends TestCase
{
public function testBuildsASectionFromTheShortForm(): void
{
$result = ComposeFixture::composer()->compose([[
'type' => 'section',
'label' => 'Hero',
'background_color' => '{{color:Nachtblau}}',
'rows' => [[
'columns' => '1_3,2_3',
'modules' => [
[['type' => 'image', 'image_id' => 33]],
[
['type' => 'heading', 'text' => 'Willkommen'],
['type' => 'button', 'text' => 'Termin buchen', 'url' => '/kontakt', 'preset' => 'CTA primär'],
],
],
]],
]], null, 'nodes');
self::assertSame([], $result['errors']);
self::assertStringStartsWith(
'<!-- wp:divi/section {"builderVersion":"5.2.1","module":{"decoration":{"background":{"desktop":{"value":'
. '{"color":"$variable({\u0022type\u0022:\u0022color\u0022,\u0022value\u0022:{\u0022name\u0022:'
. '\u0022gcid-a1b2\u0022,\u0022settings\u0022:{}}})$"}}}},"meta":{"adminLabel":{"desktop":{"value":'
. '"Hero"}}}}} --><!-- wp:divi/row {"builderVersion":"5.2.1","module":{"meta":{"adminLabel":'
. '{"desktop":{"value":"Hero – Zeile"}}}}} --><!-- wp:divi/column {"builderVersion":"5.2.1","module":'
. '{"decoration":{"sizing":{"desktop":{"value":{"flexType":"8_24"}}}}}} --><!-- wp:divi/image '
. '{"builderVersion":"5.2.1","image":{"innerContent":{"desktop":{"value":{"src":'
. '"https://kunde.test/wp-content/uploads/team.jpg","id":"33","alt":"Unser Team"}}}},"module":{"meta":'
. '{"adminLabel":{"desktop":{"value":"Hero – Image"}}}}} --><!-- /wp:divi/image -->',
Serializer::block($result['blocks'][0])
);
$section = $result['blocks'][0];
[$heading, $button] = $section->children()[0]->children()[1]->children();
self::assertSame('16_24', NodeSummary::columnWidth($section->children()[0]->children()[1]));
self::assertSame('h2', $heading->attrs()['title']['decoration']['font']['font']['desktop']['value']['headingLevel']);
self::assertSame('Hero – Heading', NodeSummary::label($heading));
self::assertSame(['p-2'], $button->attrs()['modulePreset']);
// What the composer writes, the parser reads back unchanged.
$markup = Serializer::block($section);
self::assertSame($markup, Serializer::document((new Parser())->parse($markup)));
self::assertFalse((new Parser())->parse($markup)->children()[0]->isOpaque());
}
public function testShortcutsItemsFullwidthAndFullForm(): void
{
$result = ComposeFixture::composer()->compose([
['type' => 'section', 'modules' => [
['type' => 'accordion', 'items' => [['title' => 'Frage', 'content' => '<p>Antwort</p>', 'open' => true]]],
['type' => 'text', 'content' => '<p>x</p>', 'label' => 'Einleitung',
'attrs' => ['module.decoration.spacing' => ['desktop' => ['padding' => ['top' => '20px']]]]],
]],
['type' => 'section', 'section_type' => 'fullwidth', 'modules' => [
['type' => 'fullwidth-header', 'attrs' => ['title.innerContent' => ['desktop' => 'Großer Titel']]],
]],
], null, 'nodes');
self::assertSame([], $result['errors']);
[$regular, $fullwidth] = $result['blocks'];
$column = $regular->children()[0]->children()[0];
self::assertSame('24_24', NodeSummary::columnWidth($column));
[$accordion, $text] = $column->children();
self::assertSame('divi/accordion-item', $accordion->children()[0]->name());
self::assertSame('on', $accordion->children()[0]->attrs()['module']['advanced']['open']['desktop']['value']);
self::assertSame('Einleitung', NodeSummary::label($text));
self::assertSame(['top' => '20px'], $text->attrs()['module']['decoration']['spacing']['desktop']['value']['padding']);
self::assertSame('fullwidth', $fullwidth->attrs()['module']['advanced']['type']['desktop']['value']);
self::assertSame('divi/fullwidth-header', $fullwidth->children()[0]->name());
}
public function testCollectsEveryErrorWithItsInputPath(): void
{
$result = ComposeFixture::composer()->compose([
['type' => 'button', 'text' => 'x'],
['type' => 'section', 'rows' => [
['columns' => '1_2,1_3', 'modules' => []],
['columns' => '1_2,1_2', 'modules' => [[['type' => 'slider-pro']], [['type' => 'accordion-item']]]],
]],
['type' => 'section', 'preset' => 'Gibt es nicht', 'foo' => 1],
['type' => 'section', 'modules' => [['type' => 'heading', 'attrs' => ['title.fnt' => ['desktop' => 'x']]]]],
], null, 'nodes');
self::assertSame('auf oberster Ebene sind nur Abschnitte (section) erlaubt', $result['errors']['nodes[0].type']);
self::assertStringContainsString('volle Zeile', $result['errors']['nodes[1].rows[0].columns']);
self::assertStringContainsString('unbekanntes Modul "divi/slider-pro"', $result['errors']['nodes[1].rows[1].modules[0][0].type']);
self::assertStringContainsString('passt nicht in eine Spalte', $result['errors']['nodes[1].rows[1].modules[1][0].type']);
self::assertStringContainsString('unbekanntes Preset', $result['errors']['nodes[2].preset']);
self::assertStringContainsString('unbekanntes Feld', $result['errors']['nodes[2].foo']);
self::assertSame(['title.fnt'], $result['invalid_paths']);
self::assertArrayHasKey('nodes[3].modules[0].modules[0][0].attrs.title.fnt', $result['errors']);
}
public function testColumnWidths(): void
{
$composer = ComposeFixture::composer();
$widths = static function (string $columns) use ($composer): array {
$result = $composer->compose([['type' => 'section', 'rows' => [['columns' => $columns]]]], null, 'n');
return [
array_map([NodeSummary::class, 'columnWidth'], $result['blocks'][0]->children()[0]->children()),
$result['errors'],
];
};
self::assertSame([['12_24', '12_24'], []], $widths('1_2,1_2'));
self::assertSame([['6_24', '18_24'], []], $widths('1_4, 3_4'));
self::assertSame([['2_5', '3_5'], []], $widths('2_5,3_5'));
self::assertSame(['24_24'], $widths('1_7,5_7')[0]);
self::assertArrayHasKey('n[0].rows[0].columns', $widths('1_7,5_7')[1]);
self::assertArrayHasKey('n[0].rows[0].columns', $widths('abc')[1]);
}
public function testUpdateChangesShortFormPresetAndLabel(): void
{
$block = Block::create('divi/button', ['button' => ['innerContent' => ['desktop' => ['value' => ['text' => 'Alt']]]]]);
$result = ComposeFixture::composer()->update($block, [
'set' => ['text' => 'Neu', 'url' => '/neu'],
'preset' => 'Standard',
'label' => 'Hero – CTA',
], 'changes[0]');
self::assertSame([], $result['errors']);
self::assertSame(['text' => 'Neu', 'linkUrl' => '/neu'], $result['attrs']['button']['innerContent']['desktop']['value']);
self::assertSame(['p-1'], $result['attrs']['modulePreset']);
self::assertSame('Hero – CTA', $result['attrs']['module']['meta']['adminLabel']['desktop']['value']);
$cleared = ComposeFixture::composer()->update(
Block::create('divi/button', $result['attrs']),
['preset' => null],
'changes[0]'
);
self::assertArrayNotHasKey('modulePreset', $cleared['attrs']);
}
}
```
- [ ] **Step 2: Test laufen lassen (rot)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter ComposerTest`
Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\Compose\StructureRules" not found`.
- [ ] **Step 3: Implementierung**
`src/Integrations/Divi/Compose/StructureRules.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Compose;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
/**
* Which node may sit where (spec §5.4 "Struktur"): page → sections; regular section → rows;
* fullwidth section → fullwidth modules; column/group → modules; parent module → its child
* modules (catalog). Columns only come with their row; specialty sections and read-only
* nodes are not rebuilt. Pure PHP.
*/
final class StructureRules
{
private const COLUMNS = ['divi/column', 'divi/column-inner'];
private const ROWS = ['divi/row', 'divi/row-inner'];
public function __construct(private ModuleCatalog $catalog)
{
}
/** Null when $child may be placed into $parent (null = top level of the page), else the German reason. */
public function violation(?Block $parent, string $child): ?string
{
if (! $this->catalog->has($child)) {
return sprintf('unbekanntes Modul "%s" (siehe lit-divi-catalog)', $child);
}
if ($parent === null) {
return $child === 'divi/section' ? null : 'auf oberster Ebene sind nur Abschnitte (section) erlaubt';
}
if (! $parent->isDivi() || $parent->isOpaque()) {
return 'der Zielknoten ist nur lesbar (globales Modul oder fremder Block)';
}
$parentName = $parent->name();
if ($parentName === 'divi/section') {
return $this->inSection($parent, $child);
}
if (in_array($parentName, self::ROWS, true)) {
return 'Spalten entstehen mit der Zeile ("columns"); eine neue Zeile mit der gewünschten Aufteilung '
. 'einfügen';
}
if (in_array($parentName, self::COLUMNS, true) || $parentName === 'divi/group') {
return $this->isPlainModule($child) || $child === 'divi/group'
? null
: sprintf('%s passt nicht in eine Spalte', $child);
}
$children = $this->catalog->childrenOf($parentName);
if ($children === []) {
return sprintf('%s kann keine Kind-Module enthalten', $parentName);
}
return in_array($child, $children, true)
? null
: sprintf('%s erlaubt nur %s als Kind', $parentName, implode(', ', $children));
}
public function isPlainModule(string $name): bool
{
return $this->catalog->category($name) === 'module' && ! str_starts_with($name, 'divi/fullwidth-');
}
private function inSection(Block $section, string $child): ?string
{
$type = AttrMap::desktop($section->attrs(), 'module.advanced.type');
if ($type === 'specialty') {
return 'Spezialabschnitte (specialty) nur im Divi-Builder umbauen; Module in ihre Spalten einfügen';
}
if ($type === 'fullwidth') {
return str_starts_with($child, 'divi/fullwidth-')
? null
: 'in einen Vollbreite-Abschnitt passen nur fullwidth-Module';
}
return $child === 'divi/row' ? null : 'ein Abschnitt enthält Zeilen (row)';
}
}
```
`src/Integrations/Divi/Compose/Composer.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Compose;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
/**
* Builds new Divi blocks from node descriptions (spec §5.4): short form, full form ("attrs")
* or both; sections with rows ("columns": "1_3,2_3") and modules; parent modules with
* "items"; presets by name; automatic admin labels "<Abschnitt> – <Modul>" (spec §5.5).
* Every error is collected with its input path; blocks are only usable when there are none.
* Pure PHP.
*/
final class Composer
{
private const COMMON = ['type', 'label', 'preset', 'attrs'];
private const SECTION = ['section_type', 'background_color', 'rows', 'modules'];
private const ROW = ['columns', 'modules'];
/** Column fractions Divi's flex layout knows besides multiples of 1/24. */
private const SPECIAL_WIDTHS = ['1_5', '2_5', '3_5', '4_5', '1_7', '1_9', '1_10', '1_11'];
/** @var array<string, string> */
private array $errors = [];
/** @var list<string> */
private array $invalidPaths = [];
public function __construct(
private ModuleCatalog $catalog,
private StructureRules $rules,
private ShortformWriter $shortform,
private AttrWriter $attrWriter,
private DesignSystem $design,
private string $builderVersion
) {
}
/**
* @param list<mixed> $specs Node descriptions.
* @param Block|null $parent Block the nodes go into (null = top level of the page).
* @param string $field Input path of $specs for error keys, e.g. "nodes".
* @param string $context Admin label of the surrounding section ("" if none).
* @return array{blocks: list<Block>, errors: array<string, string>, invalid_paths: list<string>}
*/
public function compose(array $specs, ?Block $parent, string $field, string $context = ''): array
{
$this->errors = [];
$this->invalidPaths = [];
$blocks = [];
foreach (array_values($specs) as $position => $spec) {
$block = $this->node($spec, $parent, sprintf('%s[%d]', $field, $position), $context);
if ($block !== null) {
$blocks[] = $block;
}
}
return ['blocks' => $blocks, 'errors' => $this->errors, 'invalid_paths' => $this->invalidPaths];
}
/**
* Applies short-form fields, full-form attrs, preset and label to an existing block
* (lit-divi-update); errors like compose().
*
* @param array<string, mixed> $change set, attrs, preset, label
* @return array{attrs: array<string, mixed>, errors: array<string, string>, invalid_paths: list<string>}
*/
public function update(Block $block, array $change, string $field): array
{
$this->errors = [];
$this->invalidPaths = [];
$attrs = $block->attrs();
$short = $block->shortName();
if (isset($change['set'])) {
if (! is_array($change['set']) || array_is_list($change['set'])) {
$this->errors[$field . '.set'] = 'Objekt mit Kurzform-Feldern erwartet';
} else {
$result = $this->shortform->apply($short, $attrs, $change['set'], $field . '.set');
$attrs = $result['attrs'];
$this->errors += $result['errors'];
}
}
$attrs = $this->common($block->name(), $attrs, $change, $field, null);
return ['attrs' => $attrs, 'errors' => $this->errors, 'invalid_paths' => $this->invalidPaths];
}
/** Admin label for a new module: "<Abschnitt> – <Modultitel>" or the title alone. */
public function autoLabel(string $name, string $context): string
{
$title = $this->catalog->title($name);
return $context === '' ? $title : $context . ' – ' . $title;
}
private function node(mixed $spec, ?Block $parent, string $path, string $context): ?Block
{
if (! is_array($spec) || array_is_list($spec)) {
$this->errors[$path] = 'Objekt mit "type" erwartet';
return null;
}
$type = $this->typeOf($spec, $parent);
if ($type === null) {
$this->errors[$path . '.type'] = 'type fehlt';
return null;
}
$violation = $this->rules->violation($parent, $type);
if ($violation !== null) {
$this->errors[$path . '.type'] = $violation;
return null;
}
return match ($type) {
'divi/section' => $this->section($spec, $path),
'divi/row' => $this->row($spec, $path, $context),
default => $this->module($type, $spec, $path, $context),
};
}
/** @param array<string, mixed> $spec */
private function typeOf(array $spec, ?Block $parent): ?string
{
if (is_string($spec['type'] ?? null) && $spec['type'] !== '') {
return ModuleCatalog::normalize($spec['type']);
}
$children = $parent === null ? [] : $this->catalog->childrenOf($parent->name());
return count($children) === 1 ? $children[0] : null;
}
/** @param array<string, mixed> $spec */
private function section(array $spec, string $path): Block
{
$this->unknownKeys($spec, array_merge(self::COMMON, self::SECTION), $path);
$label = is_string($spec['label'] ?? null) ? trim($spec['label']) : '';
$attrs = ['builderVersion' => $this->builderVersion];
if (($spec['section_type'] ?? 'regular') === 'fullwidth') {
$attrs = AttrMap::set($attrs, 'module.advanced.type', ['desktop' => ['value' => 'fullwidth']]);
} elseif (($spec['section_type'] ?? 'regular') !== 'regular') {
$this->errors[$path . '.section_type'] = 'regular oder fullwidth erwartet (specialty nur im Divi-Builder)';
}
if (isset($spec['background_color'])) {
$attrs = $this->backgroundColor($attrs, $spec['background_color'], $path . '.background_color');
}
$section = Block::create('divi/section', $this->common('divi/section', $attrs, $spec, $path, $label));
if (isset($spec['rows']) && isset($spec['modules']) && ($spec['section_type'] ?? 'regular') !== 'fullwidth') {
$this->errors[$path] = 'entweder rows oder modules angeben';
return $section;
}
if (($spec['section_type'] ?? 'regular') === 'fullwidth') {
$this->children($section, $spec['modules'] ?? [], $path . '.modules', $label);
} elseif (isset($spec['modules'])) {
$this->children($section, [['type' => 'row', 'modules' => $spec['modules']]], $path . '.modules', $label);
} else {
$this->children($section, $spec['rows'] ?? [], $path . '.rows', $label);
}
return $section;
}
/** @param array<string, mixed> $spec */
private function row(array $spec, string $path, string $context): Block
{
$this->unknownKeys($spec, array_merge(self::COMMON, self::ROW), $path);
$widths = $this->widths($spec['columns'] ?? '4_4', $path . '.columns');
$label = $context === '' ? '' : $context . ' – Zeile';
$attrs = $this->common('divi/row', ['builderVersion' => $this->builderVersion], $spec, $path, $label);
$row = Block::create('divi/row', $attrs);
$modules = $spec['modules'] ?? [];
if (! is_array($modules) || ! array_is_list($modules)) {
$this->errors[$path . '.modules'] = 'Liste erwartet (je Spalte eine Liste von Modulen)';
$modules = [];
}
// One column: a flat list of modules is accepted.
$first = $modules[0] ?? null;
if (count($widths) === 1 && is_array($first) && ! array_is_list($first)) {
$modules = [$modules];
}
if (count($modules) > count($widths)) {
$this->errors[$path . '.modules'] = sprintf(
'%d Spalten, aber %d Modul-Listen',
count($widths),
count($modules)
);
}
foreach ($widths as $position => $width) {
$column = Block::create('divi/column', AttrMap::set(
['builderVersion' => $this->builderVersion],
'module.decoration.sizing',
['desktop' => ['value' => ['flexType' => $width]]]
));
$row->insertChild($position, $column);
$list = $modules[$position] ?? [];
$listPath = sprintf('%s.modules[%d]', $path, $position);
$this->children($column, is_array($list) ? $list : [], $listPath, $context);
}
return $row;
}
/** @param array<string, mixed> $spec */
private function module(string $name, array $spec, string $path, string $context): Block
{
$short = substr($name, strlen('divi/'));
$fields = array_keys(ShortformMap::fields($short));
$hasItems = $this->catalog->childrenOf($name) !== [];
$allowed = array_merge(
self::COMMON,
$fields,
$hasItems ? ['items'] : [],
$name === 'divi/group' ? ['modules'] : []
);
$this->unknownKeys($spec, $allowed, $path);
$values = array_intersect_key($spec, array_flip($fields));
if ($name === 'divi/heading' && ! array_key_exists('level', $values)) {
$values['level'] = 'h2';
}
$attrs = ['builderVersion' => $this->builderVersion];
if ($values !== []) {
$result = $this->shortform->apply($short, $attrs, $values, $path);
$attrs = $result['attrs'];
$this->errors += $result['errors'];
}
$label = is_string($spec['label'] ?? null) ? trim($spec['label']) : $this->autoLabel($name, $context);
$block = Block::create($name, $this->common($name, $attrs, $spec, $path, $label));
if ($hasItems) {
$this->children($block, $spec['items'] ?? [], $path . '.items', $context);
} elseif ($name === 'divi/group') {
$this->children($block, $spec['modules'] ?? [], $path . '.modules', $context);
}
return $block;
}
/**
* Preset, full-form attrs and admin label – shared by new and updated blocks.
*
* @param array<string, mixed> $attrs
* @param array<string, mixed> $spec
* @return array<string, mixed>
*/
private function common(string $name, array $attrs, array $spec, string $path, ?string $label): array
{
if (array_key_exists('preset', $spec)) {
$attrs = $this->preset($name, $attrs, $spec['preset'], $path . '.preset');
}
if (isset($spec['attrs'])) {
if (! is_array($spec['attrs']) || array_is_list($spec['attrs'])) {
$this->errors[$path . '.attrs'] = 'Objekt "Attributpfad": {Breakpoint: Wert} erwartet';
} else {
$result = $this->attrWriter->apply($name, $attrs, $spec['attrs'], $path . '.attrs');
$attrs = $result['attrs'];
$this->errors += $result['errors'];
array_push($this->invalidPaths, ...$result['invalid_paths']);
}
}
$label = is_string($spec['label'] ?? null) ? trim($spec['label']) : $label;
if ($label !== null && $label !== '') {
$attrs = AttrMap::set($attrs, 'module.meta.adminLabel', ['desktop' => ['value' => $this->clean($label)]]);
}
return $attrs;
}
/**
* @param array<string, mixed> $attrs
* @return array<string, mixed>
*/
private function preset(string $name, array $attrs, mixed $preset, string $path): array
{
if ($preset === null || $preset === '') {
unset($attrs['modulePreset']);
return $attrs;
}
$id = is_string($preset) ? $this->design->modulePresetId($name, $preset) : null;
if ($id === null) {
$names = $this->design->modulePresetNames($name);
$this->errors[$path] = sprintf(
'unbekanntes Preset; vorhanden für %s: %s',
$name,
$names === [] ? '(keine)' : implode(', ', $names)
);
return $attrs;
}
$attrs['modulePreset'] = [$id];
return $attrs;
}
/**
* @param array<string, mixed> $attrs
* @return array<string, mixed>
*/
private function backgroundColor(array $attrs, mixed $color, string $path): array
{
$result = $this->attrWriter->apply(
'divi/section',
$attrs,
['module.decoration.background' => ['desktop' => ['color' => $color]]],
$path
);
foreach ($result['errors'] as $message) {
$this->errors[$path] = $message;
}
return $result['attrs'];
}
/** @param mixed $specs */
private function children(Block $parent, mixed $specs, string $path, string $context): void
{
if (! is_array($specs) || ($specs !== [] && ! array_is_list($specs))) {
$this->errors[$path] = 'Liste erwartet';
return;
}
foreach ($specs as $position => $spec) {
$child = $this->node($spec, $parent, sprintf('%s[%d]', $path, $position), $context);
if ($child !== null) {
$parent->insertChild(PHP_INT_MAX, $child);
}
}
}
/**
* "1_3,2_3" → flexTypes ["8_24", "16_24"]; the fractions must add up to one row.
*
* @return list<string>
*/
private function widths(mixed $columns, string $path): array
{
$parts = is_string($columns) ? array_map('trim', explode(',', $columns)) : [];
$widths = [];
$sum = 0.0;
foreach ($parts as $part) {
$valid = preg_match('/^(\d{1,2})_(\d{1,2})$/', $part, $match) === 1
&& (int) $match[1] >= 1
&& (int) $match[1] <= (int) $match[2];
if (! $valid) {
$this->errors[$path] = 'Spaltenaufteilung wie "1_2,1_2" oder "1_3,2_3" erwartet';
return ['24_24'];
}
[$numerator, $denominator] = [(int) $match[1], (int) $match[2]];
if (24 % $denominator === 0) {
$widths[] = ($numerator * 24 / $denominator) . '_24';
} elseif (in_array($part, self::SPECIAL_WIDTHS, true)) {
$widths[] = $part;
} else {
$this->errors[$path] = sprintf('Spaltenbreite %s gibt es in Divi nicht', $part);
return ['24_24'];
}
$sum += $numerator / $denominator;
}
if ($widths === [] || abs($sum - 1.0) > 0.01) {
$this->errors[$path] = 'die Spaltenbreiten müssen zusammen eine volle Zeile ergeben (z. B. "1_3,2_3")';
return ['24_24'];
}
return $widths;
}
/**
* @param array<string, mixed> $spec
* @param list<string> $allowed
*/
private function unknownKeys(array $spec, array $allowed, string $path): void
{
foreach (array_diff(array_map('strval', array_keys($spec)), $allowed) as $key) {
$this->errors[$path . '.' . $key] = 'unbekanntes Feld; erlaubt: ' . implode(', ', $allowed);
}
}
private function clean(string $label): string
{
try {
return (string) $this->attrWriter->cleanValue('divi/section', 'module.meta.adminLabel', $label);
} catch (InvalidArgumentException) {
return $label;
}
}
}
```
- [ ] **Step 4: Tests und Lint (grün)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter Compose && composer lint`
Expected: PASS (u. a. die exakte Markup-Erwartung in `testBuildsASectionFromTheShortForm` und dass der Parser das erzeugte Markup unverändert zurückgibt), phpcs ohne Meldungen.
- [ ] **Step 5: Commit**
```bash
cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
git add src/Integrations/Divi/Compose/StructureRules.php src/Integrations/Divi/Compose/Composer.php tests/Unit/Integrations/Divi/Compose/
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi composer – short/full form to blocks with structure rules, presets and labels
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
```
---
### Task 4: Baum ändern und prüfen – `TreeEditor` und `Warnings`
Spec §5.7: Knoten an `parent` (optional `index`), `before` oder `after` einfügen, verschieben (auch zwischen Zeilen und Abschnitten), entfernen, duplizieren – immer gegen `StructureRules`; ein Knoten kann nicht in seinen eigenen Teilbaum wandern; Verschieben und Löschen erhalten jedes Byte der übrigen Seite (Text zwischen Blöcken bleibt stehen). Spec §5.5: Warnungen nur zu neu erzeugten oder geänderten Knoten (und deren Kindern) – feste Farbe, obwohl eine globale Farbe passt (RGB-Abstand ≤ 24), feste Schrift = Theme-Schrift, Bild ohne Alt-Text (`image`, `blurb`, `slide`), leere Module (Kurzform-Hauptfeld leer, Elternmodul ohne Einträge, leere Struktur), Sprung in der Überschriftenhierarchie (Heading-Standard h1, Blurb h4, CTA h2, `<h1>`–`<h6>` im Text-Modul), Code-Modul. „Ähnliche Abschnitte“ folgt in Etappe 3 (D6).
**Files:**
- Create: `src/Integrations/Divi/Compose/{TreeEditor,Warnings}.php`
- Test: `tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php`, `tests/Unit/Integrations/Divi/Compose/WarningsTest.php`
**Interfaces:**
- Consumes: `NodeIndex::find/parentOf/root/idOf/entries`, `Container::indexOf/insertChild/removeChild`, `Block::copy()` (2a); `StructureRules` (Task 3); `DesignSystem::colors/fonts/colorPlaceholder`, `ShortformMap::childType/primary/values`, `NodeSummary::textOf`, `AttrMap::desktop`, `Placeholders::isVariable`.
- Produces:
- `TreeEditor(StructureRules)`: `position(NodeIndex $index, array $input): array{container: Container, parent: ?Block, index: int}|string` (Fehlertext); `insert(array $position, list<Block> $blocks): ?string`; `move(NodeIndex $index, Block $node, array $position): ?string`; `remove(NodeIndex $index, Block $node): void`; `duplicate(NodeIndex $index, Block $node): Block` (Kopie direkt dahinter).
- `Warnings(DesignSystem)`: `collect(NodeIndex $index, list<Block> $touched): list<string>` (Format `"<id>: <Text>"`, IDs nach der Änderung).
- [ ] **Step 1: Failing Tests**
`tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\Document;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Compose\TreeEditor;
use PHPUnit\Framework\TestCase;
final class TreeEditorTest extends TestCase
{
private function document(string $name = 'synthetic-basic.html'): Document
{
return (new Parser())->parse((string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/' . $name));
}
public function testResolvesPositions(): void
{
$document = $this->document();
$index = NodeIndex::build($document);
$editor = new TreeEditor(ComposeFixture::rules());
$end = $editor->position($index, []);
self::assertSame($index->root(), $end['container']);
self::assertNull($end['parent']);
$inColumn = $editor->position($index, ['parent' => 's0.r0.c1', 'index' => 1]);
self::assertSame($index->find('s0.r0.c1'), $inColumn['parent']);
self::assertSame(1, $inColumn['index']);
$after = $editor->position($index, ['after' => 's0.r0.c1.m0']);
self::assertSame($index->find('s0.r0.c1'), $after['container']);
self::assertSame(1, $after['index']);
$beforeSection = $editor->position($index, ['before' => 's1']);
self::assertNull($beforeSection['parent']);
self::assertSame(1, $beforeSection['index']);
self::assertSame('nur eines von parent, before oder after angeben', $editor->position($index, ['parent' => 's0', 'after' => 's1']));
self::assertSame('Knoten "s9" gibt es nicht', $editor->position($index, ['before' => 's9']));
}
public function testInsertChecksTheStructureRules(): void
{
$document = $this->document();
$index = NodeIndex::build($document);
$editor = new TreeEditor(ComposeFixture::rules());
$button = Block::create('divi/button');
self::assertSame(
'auf oberster Ebene sind nur Abschnitte (section) erlaubt',
$editor->insert($editor->position($index, []), [$button])
);
self::assertStringContainsString('Zeilen', (string) $editor->insert($editor->position($index, ['parent' => 's0']), [$button]));
self::assertStringContainsString('Spalten entstehen mit der Zeile', (string) $editor->insert($editor->position($index, ['parent' => 's0.r0']), [$button]));
self::assertStringContainsString('accordion-item', (string) $editor->insert($editor->position($index, ['parent' => 's1.r0.c1.m0']), [$button]));
self::assertNull($editor->insert($editor->position($index, ['after' => 's0.r0.c1.m0']), [$button]));
self::assertSame('divi/button', NodeIndex::build($document)->find('s0.r0.c1.m1')?->name());
}
public function testMoveWithinAndAcrossParents(): void
{
$document = $this->document();
$original = Serializer::document($document);
$index = NodeIndex::build($document);
$editor = new TreeEditor(ComposeFixture::rules());
$button = $index->find('s0.r0.c1.m2');
self::assertNull($editor->move($index, $button, $editor->position($index, ['before' => 's0.r0.c1.m0'])));
$index = NodeIndex::build($document);
self::assertSame($button, $index->find('s0.r0.c1.m0'));
self::assertNull($editor->move($index, $button, $editor->position($index, ['parent' => 's1.r0.c0'])));
$index = NodeIndex::build($document);
self::assertSame($button, $index->find('s1.r0.c0.m1'));
self::assertStringContainsString('Termin buchen', Serializer::document($document));
self::assertSame(strlen($original), strlen(Serializer::document($document)), 'moving keeps every byte');
$section = $index->find('s0');
self::assertSame(
'ein Knoten kann nicht in sich selbst verschoben werden',
$editor->move($index, $section, $editor->position($index, ['parent' => 's0.r0.c0']))
);
self::assertNull($editor->move($index, $section, $editor->position($index, [])));
self::assertSame($section, NodeIndex::build($document)->find('s1'));
}
public function testRemoveAndDuplicate(): void
{
$document = $this->document();
$index = NodeIndex::build($document);
$editor = new TreeEditor(ComposeFixture::rules());
$copy = $editor->duplicate($index, $index->find('s1.r0.c1.m0'));
$index = NodeIndex::build($document);
self::assertSame($copy, $index->find('s1.r0.c1.m1'));
self::assertSame(Serializer::block($index->find('s1.r0.c1.m0')), Serializer::block($copy));
$editor->remove($index, $index->find('s0'));
self::assertSame('divi/section', NodeIndex::build($document)->find('s0')?->name());
self::assertNull(NodeIndex::build($document)->find('s1'));
}
public function testReadOnlyTargetsAndSpecialtySections(): void
{
$document = $this->document('synthetic-mixed.html');
$index = NodeIndex::build($document);
$editor = new TreeEditor(ComposeFixture::rules());
$text = Block::create('divi/text');
self::assertStringContainsString('nur lesbar', (string) $editor->insert($editor->position($index, ['parent' => 's1.g1']), [$text]));
self::assertStringContainsString('Spezialabschnitte', (string) $editor->insert($editor->position($index, ['parent' => 's0']), [Block::create('divi/row')]));
self::assertNull($editor->insert($editor->position($index, ['parent' => 's0.c0']), [$text]));
}
}
```
`tests/Unit/Integrations/Divi/Compose/WarningsTest.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
use LIT\MCP\Integrations\Divi\BlockTree\Document;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\Compose\Warnings;
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
use PHPUnit\Framework\TestCase;
final class WarningsTest extends TestCase
{
public function testWarnsAboutTheTouchedNodesOnly(): void
{
$composed = ComposeFixture::composer()->compose([
['type' => 'section', 'label' => 'Test', 'modules' => [
['type' => 'heading', 'text' => 'Titel', 'level' => 'h1'],
['type' => 'heading', 'text' => 'Zu tief', 'level' => 'h4'],
['type' => 'image', 'src' => 'https://kunde.test/a.jpg'],
['type' => 'text', 'content' => ''],
['type' => 'code', 'content' => '<div>x</div>'],
['type' => 'accordion'],
['type' => 'button', 'text' => 'Los', 'attrs' => [
'button.decoration.background' => ['desktop' => ['color' => '#2ea3f3']],
'button.decoration.font.font' => ['desktop' => ['family' => 'Open Sans', 'color' => '#abcdef']],
]],
]],
], null, 'nodes');
self::assertSame([], $composed['errors']);
$document = new Document();
foreach ($composed['blocks'] as $block) {
$document->append($block);
}
$warnings = (new Warnings(DesignSystemTest::sample()))->collect(NodeIndex::build($document), $composed['blocks']);
self::assertSame(
[
's0.r0.c0.m2: Bild ohne Alt-Text.',
's0.r0.c0.m3: text ist leer (content fehlt).',
's0.r0.c0.m4: Code-Modul verwendet – im Divi-Builder nicht visuell bearbeitbar; nur nutzen, wenn kein '
. 'natives Modul passt.',
's0.r0.c0.m5: accordion ohne Einträge.',
's0.r0.c0.m6: feste Farbe #2ea3f3 – globale Farbe "Primärfarbe" ({{color:primary}}) passt.',
's0.r0.c0.m6: feste Schrift Open Sans – {{font:heading}} verwenden.',
's0.r0.c0.m1: Sprung in der Überschriftenhierarchie (h4 nach h1).',
],
$warnings
);
self::assertSame([], (new Warnings(DesignSystemTest::sample()))->collect(NodeIndex::build($document), []));
}
}
```
- [ ] **Step 2: Test laufen lassen (rot)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'TreeEditorTest|WarningsTest'`
Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\Compose\TreeEditor" not found`.
- [ ] **Step 3: Implementierung**
`src/Integrations/Divi/Compose/TreeEditor.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Compose;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\Container;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
/**
* Structural changes on a parsed page (spec §5.7): where to insert (parent / before / after,
* optional index), move, remove, duplicate – always checked against StructureRules. Works on
* the tree only; saving is DiviWriter's job. Pure PHP.
*/
final class TreeEditor
{
public function __construct(private StructureRules $rules)
{
}
/**
* Resolves a target position: exactly one of parent, before, after; index only with parent.
* No position at all means "end of the page".
*
* @param array<string, mixed> $input
* @return array{container: Container, parent: ?Block, index: int}|string Position or German error.
*/
public function position(NodeIndex $index, array $input): array|string
{
$given = array_filter(
[
'parent' => $input['parent'] ?? null,
'before' => $input['before'] ?? null,
'after' => $input['after'] ?? null,
],
static fn (mixed $value): bool => is_string($value) && $value !== ''
);
if (count($given) > 1) {
return 'nur eines von parent, before oder after angeben';
}
$at = isset($input['index']) && is_int($input['index']) ? max(0, $input['index']) : PHP_INT_MAX;
if ($given === []) {
return ['container' => $index->root(), 'parent' => null, 'index' => $at];
}
$key = (string) array_key_first($given);
$node = $index->find((string) $given[$key]);
if ($node === null) {
return sprintf('Knoten "%s" gibt es nicht', (string) $given[$key]);
}
if ($key === 'parent') {
return ['container' => $node, 'parent' => $node, 'index' => $at];
}
$container = $index->parentOf($node);
if ($container === null) {
return sprintf('Knoten "%s" hat keinen Elternknoten', (string) $given[$key]);
}
$sibling = (int) $container->indexOf($node);
return [
'container' => $container,
'parent' => $container instanceof Block && $container->name() !== 'divi/placeholder' ? $container : null,
'index' => $key === 'before' ? $sibling : $sibling + 1,
];
}
/**
* @param array{container: Container, parent: ?Block, index: int} $position
* @param list<Block> $blocks
*/
public function insert(array $position, array $blocks): ?string
{
foreach ($blocks as $block) {
$violation = $this->rules->violation($position['parent'], $block->name());
if ($violation !== null) {
return $violation;
}
}
foreach (array_values($blocks) as $offset => $block) {
$at = $position['index'] === PHP_INT_MAX ? PHP_INT_MAX : $position['index'] + $offset;
$position['container']->insertChild($at, $block);
}
return null;
}
/** @param array{container: Container, parent: ?Block, index: int} $position */
public function move(NodeIndex $index, Block $node, array $position): ?string
{
for ($ancestor = $position['parent']; $ancestor !== null; $ancestor = $this->parentBlock($index, $ancestor)) {
if ($ancestor === $node) {
return 'ein Knoten kann nicht in sich selbst verschoben werden';
}
}
$violation = $this->rules->violation($position['parent'], $node->name());
if ($violation !== null) {
return $violation;
}
$from = $index->parentOf($node);
if ($from === null) {
return 'der Knoten hat keinen Elternknoten';
}
$old = $from->removeChild($node);
$target = $position['index'];
if ($from === $position['container'] && $target !== PHP_INT_MAX && $old < $target) {
--$target;
}
$position['container']->insertChild($target, $node);
return null;
}
public function remove(NodeIndex $index, Block $node): void
{
$index->parentOf($node)?->removeChild($node);
}
/** Inserts a copy right after $node and returns it. */
public function duplicate(NodeIndex $index, Block $node): Block
{
$copy = $node->copy();
$container = $index->parentOf($node);
if ($container !== null) {
$container->insertChild((int) $container->indexOf($node) + 1, $copy);
}
return $copy;
}
private function parentBlock(NodeIndex $index, Block $block): ?Block
{
$parent = $index->parentOf($block);
return $parent instanceof Block && $parent->name() !== 'divi/placeholder' ? $parent : null;
}
}
```
`src/Integrations/Divi/Compose/Warnings.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Compose;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
/**
* Quality warnings of a Divi write (spec §5.5) – only about the nodes this call created or
* changed (and their children): fixed colors/fonts where a global one fits, images without
* alt text, empty modules, heading jumps, the code module. Similar existing sections
* (lit/library-find-similar) follow with the Library in Etappe 3. Pure PHP.
*/
final class Warnings
{
/** RGB distance up to which a fixed color counts as "the same" as a global color. */
private const SIMILAR = 24.0;
/** Default heading level per module (Divi's default render attributes). */
private const HEADING_DEFAULTS = ['divi/heading' => 1, 'divi/blurb' => 4, 'divi/cta' => 2];
private const IMAGE_SOURCES = [
'divi/image' => ['image.innerContent', 'src', 'alt'],
'divi/blurb' => ['imageIcon.innerContent', 'src', 'alt'],
'divi/slide' => ['image.innerContent', 'src', 'alt'],
];
public function __construct(private DesignSystem $design)
{
}
/**
* @param list<Block> $touched Created or changed blocks (their descendants count too).
* @return list<string>
*/
public function collect(NodeIndex $index, array $touched): array
{
$blocks = [];
foreach ($touched as $block) {
foreach ($this->withDescendants($block) as $node) {
$blocks[spl_object_id($node)] = $node;
}
}
$warnings = [];
foreach ($blocks as $block) {
$id = $index->idOf($block) ?? '?';
array_push(
$warnings,
...$this->code($block, $id),
...$this->image($block, $id),
...$this->empty($block, $id),
...$this->fixedValues($block, $id)
);
}
return array_values(array_unique(array_merge($warnings, $this->headings($index, $blocks))));
}
/** @return list<Block> */
private function withDescendants(Block $block): array
{
$all = [$block];
if (! $block->isOpaque()) {
foreach ($block->children() as $child) {
array_push($all, ...$this->withDescendants($child));
}
}
return $all;
}
/** @return list<string> */
private function code(Block $block, string $id): array
{
return in_array($block->name(), ['divi/code', 'divi/fullwidth-code'], true)
? [sprintf('%s: Code-Modul verwendet – im Divi-Builder nicht visuell bearbeitbar; nur nutzen, wenn kein '
. 'natives Modul passt.', $id)]
: [];
}
/** @return list<string> */
private function image(Block $block, string $id): array
{
$spec = self::IMAGE_SOURCES[$block->name()] ?? null;
if ($spec === null) {
return [];
}
$value = AttrMap::desktop($block->attrs(), $spec[0]);
$src = is_array($value) ? ($value[$spec[1]] ?? '') : '';
$alt = is_array($value) ? ($value[$spec[2]] ?? '') : '';
return is_string($src) && $src !== '' && (! is_string($alt) || trim($alt) === '')
? [sprintf('%s: Bild ohne Alt-Text.', $id)]
: [];
}
/** @return list<string> */
private function empty(Block $block, string $id): array
{
$name = $block->name();
if (in_array($name, ['divi/section', 'divi/row', 'divi/column', 'divi/group'], true)) {
return $block->children() === [] ? [sprintf('%s: leer (%s ohne Inhalt).', $id, $block->shortName())] : [];
}
$type = $block->shortName();
if (ShortformMap::childType($type) !== null && $block->children() === []) {
return [sprintf('%s: %s ohne Einträge.', $id, $type)];
}
$primary = ShortformMap::primary($type);
if ($primary === null || ShortformMap::childType($type) !== null) {
return [];
}
$values = ShortformMap::values($type, $block->attrs());
$value = $values[$primary] ?? '';
return NodeSummary::textOf($value) === '' ? [sprintf('%s: %s ist leer (%s fehlt).', $id, $type, $primary)] : [];
}
/** @return list<string> */
private function fixedValues(Block $block, string $id): array
{
$warnings = [];
$fonts = array_filter($this->design->fonts());
foreach ($this->leaves($block->attrs()) as [$key, $value]) {
if (preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', trim($value)) === 1) {
$match = $this->similarColor($value);
if ($match !== null) {
$warnings[] = sprintf(
'%s: feste Farbe %s – globale Farbe "%s" (%s) passt.',
$id,
$value,
$match['label'],
DesignSystem::colorPlaceholder($match['id'], $match['label'])
);
}
} elseif ($key === 'family') {
$font = array_search(mb_strtolower(trim($value)), array_map('mb_strtolower', $fonts), true);
if ($font !== false) {
$warnings[] = sprintf('%s: feste Schrift %s – {{font:%s}} verwenden.', $id, $value, $font);
}
}
}
return $warnings;
}
/**
* Heading jumps (h2 → h4) in page order where the later heading belongs to a touched block.
*
* @param array<int, Block> $touched
* @return list<string>
*/
private function headings(NodeIndex $index, array $touched): array
{
$warnings = [];
$previous = null;
foreach ($index->entries() as $entry) {
$block = $entry['block'];
foreach ($this->levels($block) as $level) {
if ($previous !== null && $level > $previous + 1 && isset($touched[spl_object_id($block)])) {
$warnings[] = sprintf(
'%s: Sprung in der Überschriftenhierarchie (h%d nach h%d).',
$entry['id'],
$level,
$previous
);
}
$previous = $level;
}
}
return $warnings;
}
/** @return list<int> Heading levels a block renders, in order. */
private function levels(Block $block): array
{
$default = self::HEADING_DEFAULTS[$block->name()] ?? null;
if ($default !== null) {
$font = AttrMap::desktop($block->attrs(), 'title.decoration.font.font');
$level = is_array($font) && is_string($font['headingLevel'] ?? null)
? $font['headingLevel']
: 'h' . $default;
return [(int) substr($level, 1)];
}
if ($block->name() === 'divi/text') {
$html = AttrMap::desktop($block->attrs(), 'content.innerContent');
preg_match_all('/<h([1-6])\b/i', is_string($html) ? $html : '', $matches);
return array_map('intval', $matches[1]);
}
return [];
}
/** @return array{id: string, label: string, value: string}|null */
private function similarColor(string $hex): ?array
{
$rgb = self::rgb($hex);
foreach ($this->design->colors() as $color) {
$other = self::rgb($color['value']);
if ($rgb !== null && $other !== null && self::distance($rgb, $other) <= self::SIMILAR) {
return $color;
}
}
return null;
}
/**
* @param array<array-key, mixed> $values
* @return list<array{0: string, 1: string}> [last key, value] of every string leaf (variables excluded).
*/
private function leaves(array $values): array
{
$leaves = [];
foreach ($values as $name => $value) {
if (is_array($value)) {
array_push($leaves, ...$this->leaves($value));
} elseif (is_string($value) && ! Placeholders::isVariable($value)) {
$leaves[] = [(string) $name, $value];
}
}
return $leaves;
}
/** @return array{int, int, int}|null */
private static function rgb(string $hex): ?array
{
$hex = ltrim(trim($hex), '#');
if (preg_match('/^([0-9a-f]{3}|[0-9a-f]{6})$/i', $hex) !== 1) {
return null;
}
if (strlen($hex) === 3) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
return [(int) hexdec(substr($hex, 0, 2)), (int) hexdec(substr($hex, 2, 2)), (int) hexdec(substr($hex, 4, 2))];
}
/**
* @param array{int, int, int} $a
* @param array{int, int, int} $b
*/
private static function distance(array $a, array $b): float
{
return sqrt(($a[0] - $b[0]) ** 2 + ($a[1] - $b[1]) ** 2 + ($a[2] - $b[2]) ** 2);
}
}
```
- [ ] **Step 4: Tests und Lint (grün)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter Compose && composer lint`
Expected: PASS, phpcs ohne Meldungen.
- [ ] **Step 5: Commit**
```bash
cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
git add src/Integrations/Divi/Compose/TreeEditor.php src/Integrations/Divi/Compose/Warnings.php tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php tests/Unit/Integrations/Divi/Compose/WarningsTest.php
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi tree editor and quality warnings
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
```
---
### Task 5: Speicher-Pipeline – `DiviSaver`, `DiviWriter`, `DiviComposition`
Spec §5.6 und D7: `DiviSaver` legt den Wiederherstellungspunkt an (`RevisionPoint::capture`), speichert mit `wp_slash()` über `wp_update_post()` (Status unverändert) und schaltet dabei **nur für Benutzer ohne `unfiltered_html`** kses aus (`kses_remove_filters()` … `kses_init_filters()` im `finally`) – sonst würde `filter_block_content()` jeden unberührten Block neu serialisieren. Danach Divi-Flags und Divi-CSS-Cache (Fehler dort nur als Warnung über `FollowUp`), und eine Warnung, falls ein Filter den gespeicherten Inhalt verändert hat. Wirft ein Hook, wird der vorherige Inhalt zurückgeschrieben (`WriteFailed` rolled back; misslingt auch das: `partial_write`). `DiviWriter` ist der gemeinsame Rahmen aller Divi-Schreib-Tools: `page()` prüft Rechte, `writable()` und die `tree_rev`-Sperre (Ruling P16); `save()` serialisiert, speichert (nichts, wenn sich nichts ändert), ruft `lit_mcp_after_save`, protokolliert und baut das `ToolResult` (geänderte Knoten mit neuen IDs, Outline der betroffenen Abschnitte, Warnungen, `revision_id`, `rev`, `tree_rev`, `preview_url`, `edit_url` mit `et_fb=1`). `DiviComposition` verdrahtet die Compose-Klassen einmal je Anfrage mit Live-Katalog, Designsystem, `WpValueSanitizer` und `WpMediaResolver`.
**Files:**
- Create: `src/Integrations/Divi/Pages/{WpValueSanitizer,WpMediaResolver,DiviComposition,DiviChange,DiviSaver,DiviWriter}.php`
- Test: `tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php`
**Interfaces:**
- Consumes: `RevisionPoint::capture`, `PostGuard::missedSchedule`, `FollowUp::run`, `Errors::revMismatch/notSavedKeeping`, `WriteFailed`, `ToolResult`, `Rev::of/ofPost`, `PostChangeResult` (`afterSave`, `reread`, `log`, `result`), `ChangeLog::recordPartial` (Etappe 1); `DiviPageLoader::load/writable/OUTLINE_HINT`, `DiviPage`, `NodeIndex`, `NodeSummary::label`, `Outline`, `Serializer::document`, `DesignSystemReader`, `DiviModules` (2a); Compose-Klassen (Tasks 1–4).
- Produces:
- `WpValueSanitizer implements ValueSanitizer` (`sanitize_text_field`, `wp_kses_post`, Code ungefiltert nur mit `unfiltered_html`, `esc_url_raw`); `WpMediaResolver implements MediaResolver` (nur Bilder, die der Benutzer lesen darf).
- `DiviComposition` (nicht final): `__construct(DiviModules, DesignSystemReader, ValueSanitizer = new WpValueSanitizer(), MediaResolver = new WpMediaResolver())`; `composer(): Composer`; `attrWriter(): AttrWriter`; `rules(): StructureRules`; `editor(): TreeEditor`; `warnings(): Warnings`.
- `DiviChange(string $summary)`: `changed(Block $b, string $action): self` (inserted/updated/moved/duplicated; alles außer moved zählt für Warnungen); `removed(string $idBefore, Block $b): self`; `warning(string): self`; `entries()`, `touched()`, `warnings()`; `readonly string $summary`.
- `DiviSaver` (nicht final): `REASON`, `CHANGED_ON_SAVE`; `save(WP_Post $post, string $content): array{revision_id: ?int, warnings: list<string>}` (wirft `WriteFailed`).
- `DiviWriter` (nicht final): `__construct(DiviPageLoader, DiviSaver, ChangeLog, DesignSystemReader, DiviComposition)`; `static inputProperties(): array` (`post_id`, `tree_rev`); `static positionProperties(): array` (`parent`, `before`, `after`, `index`); `page(array $input): DiviPage|WP_Error`; `static sectionLabel(NodeIndex $index, ?Block $block): string`; `save(string $ability, DiviPage $page, DiviChange $change, array $input): array` (ToolResult-Array mit `tree_rev`; wirft `WriteFailed`).
- [ ] **Step 1: Failing Tests**
`tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException;
use WP_Error;
use WP_Post;
final class DiviSaverTest extends TestCase
{
private WP_Post $post;
/** @var list<string> */
private array $calls = [];
protected function setUp(): void
{
parent::setUp();
$this->post = new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_content' => 'ALT']);
Functions\when('wp_save_post_revision')->justReturn(811);
Functions\when('wp_get_post_revisions')->justReturn([811 => new WP_Post(['ID' => 811])]);
Functions\when('update_post_meta')->alias(function (int $id, string $key, string $value): bool {
$this->calls[] = "meta:$key=$value";
return true;
});
Functions\when('kses_remove_filters')->alias(function (): void {
$this->calls[] = 'kses off';
});
Functions\when('kses_init_filters')->alias(function (): void {
$this->calls[] = 'kses on';
});
}
public function testSavesWithoutKsesForFilteredUsersAndSetsDiviFlags(): void
{
Functions\when('current_user_can')->justReturn(false);
Functions\when('has_filter')->justReturn(10);
Functions\expect('wp_update_post')->once()->with(['ID' => 7, 'post_content' => 'NEU'], true)
->andReturnUsing(function (): int {
$this->calls[] = 'update';
return 7;
});
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_content' => 'NEU']));
$saved = (new DiviSaver())->save($this->post, 'NEU');
self::assertSame(['revision_id' => 811, 'warnings' => []], $saved);
self::assertSame(
['kses off', 'update', 'kses on', 'meta:_et_pb_use_divi_5=on', 'meta:_et_pb_use_builder=on'],
$this->calls
);
}
public function testKeepsKsesUntouchedForUnfilteredUsersAndWarnsWhenAFilterChangedTheContent(): void
{
Functions\when('current_user_can')->justReturn(true);
Functions\when('wp_update_post')->justReturn(7);
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_content' => 'NEU, gefiltert']));
$saved = (new DiviSaver())->save($this->post, 'NEU');
self::assertNotContains('kses off', $this->calls);
self::assertSame([DiviSaver::CHANGED_ON_SAVE], $saved['warnings']);
}
public function testRefusedUpdateIsACleanFailure(): void
{
Functions\when('current_user_can')->justReturn(true);
Functions\when('wp_update_post')->justReturn(new WP_Error('db_update_error', 'DB kaputt'));
try {
(new DiviSaver())->save($this->post, 'NEU');
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
self::assertSame(811, $e->revisionId);
self::assertStringContainsString('Revision 811 bleibt erhalten', $e->toError()->get_error_message());
}
}
public function testAThrowingHookRestoresThePreviousContent(): void
{
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_status' => 'draft']));
$written = [];
Functions\when('wp_update_post')->alias(static function (array $data) use (&$written): int {
$written[] = $data['post_content'];
if ($data['post_content'] === 'NEU') {
throw new RuntimeException('save_post hook failed');
}
return 7;
});
try {
(new DiviSaver())->save($this->post, 'NEU');
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
self::assertSame(['NEU', 'ALT'], $written);
}
}
public function testFailedRestoreIsAPartialWrite(): void
{
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_status' => 'draft']));
Functions\when('wp_update_post')->alias(static function (): int {
throw new RuntimeException('database gone');
});
try {
(new DiviSaver())->save($this->post, 'NEU');
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
self::assertFalse($e->rolledBack);
self::assertSame('lit_partial_write', $e->toError()->get_error_code());
}
}
}
```
`tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Pages\DiviChange;
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Tests\Unit\Integrations\Divi\Compose\ComposeFixture;
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class DiviWriterTest extends TestCase
{
private function writer(DiviSaver $saver, ChangeLog $log): DiviWriter
{
$modules = Mockery::mock(DiviModules::class);
$modules->allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog());
$modules->allows('builderVersion')->andReturn('5.2.1');
$design = Mockery::mock(DesignSystemReader::class);
$design->allows('read')->andReturn(DesignSystemTest::sample());
return new DiviWriter(
new DiviPageLoader(DiviPageLoaderTest::schema()),
$saver,
$log,
$design,
new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media())
);
}
public function testPageChecksTheTreeRevLock(): void
{
$content = DiviPageLoaderTest::fixture('synthetic-basic.html');
DiviPageLoaderTest::givenPost($content);
$writer = $this->writer(Mockery::mock(DiviSaver::class), Mockery::mock(ChangeLog::class));
self::assertInstanceOf(DiviPage::class, $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]));
self::assertSame('lit_rev_mismatch', $writer->page(['post_id' => 7])->get_error_code());
$stale = $writer->page(['post_id' => 7, 'tree_rev' => Rev::ofPost(DiviPageLoaderTest::givenPost($content))]);
self::assertSame('lit_rev_mismatch', $stale->get_error_code(), 'the post rev is no tree_rev');
}
public function testSectionLabelOfANode(): void
{
DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html'));
$page = (new DiviPageLoader(DiviPageLoaderTest::schema()))->load(7, true);
self::assertSame('Hero', DiviWriter::sectionLabel($page->index, $page->index->find('s0.r0.c1')));
self::assertSame('', DiviWriter::sectionLabel($page->index, null));
}
public function testUnchangedTreeIsNotSaved(): void
{
$content = DiviPageLoaderTest::fixture('synthetic-basic.html');
DiviPageLoaderTest::givenPost($content);
$saver = Mockery::mock(DiviSaver::class);
$saver->expects('save')->never();
$writer = $this->writer($saver, Mockery::mock(ChangeLog::class));
$page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]);
$result = $writer->save('lit/divi-update', $page, new DiviChange('nichts'), []);
self::assertSame(Rev::of($content), $result['tree_rev']);
self::assertStringStartsWith('Keine Änderung', $result['summary']);
}
public function testFailedRollbackIsLoggedAsPartial(): void
{
$content = DiviPageLoaderTest::fixture('synthetic-basic.html');
DiviPageLoaderTest::givenPost($content);
$saver = Mockery::mock(DiviSaver::class);
$saver->allows('save')->andThrow(new WriteFailed(DiviSaver::REASON, false, 7, 811));
$log = Mockery::mock(ChangeLog::class);
$log->expects('recordPartial')->with('lit/divi-delete', 'page', '7', 'Divi "Start": Änderung nur teilweise zurückgenommen', 811);
$writer = $this->writer($saver, $log);
$page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]);
$page->document->children()[0]->removeChild($page->index->find('s1'));
$this->expectException(WriteFailed::class);
$writer->save('lit/divi-delete', $page, new DiviChange('s1 gelöscht'), []);
}
}
```
- [ ] **Step 2: Test laufen lassen (rot)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'DiviSaverTest|DiviWriterTest'`
Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\Pages\DiviSaver" not found`.
- [ ] **Step 3: Implementierung**
`src/Integrations/Divi/Pages/WpValueSanitizer.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer;
/**
* WordPress sanitizing of Divi values (spec §10). Because DiviSaver bypasses kses for the
* page as a whole (kses would re-serialize every untouched block), every value this plugin
* writes goes through here instead.
*/
final class WpValueSanitizer implements ValueSanitizer
{
public function text(string $value): string
{
return sanitize_text_field($value);
}
public function html(string $value): string
{
return wp_kses_post($value);
}
public function code(string $value): string
{
return current_user_can('unfiltered_html') ? $value : wp_kses_post($value);
}
public function url(string $value): string
{
return esc_url_raw(trim($value));
}
}
```
`src/Integrations/Divi/Pages/WpMediaResolver.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Integrations\Divi\Compose\MediaResolver;
/**
* Media library images for short-form fields (image_id, portrait_id, image_ids): only images
* the current user may read.
*/
final class WpMediaResolver implements MediaResolver
{
public function image(int $id): ?array
{
if ($id <= 0 || ! wp_attachment_is_image($id) || ! current_user_can('read_post', $id)) {
return null;
}
$url = wp_get_attachment_url($id);
if (! is_string($url) || $url === '') {
return null;
}
return ['url' => $url, 'alt' => (string) get_post_meta($id, '_wp_attachment_image_alt', true)];
}
}
```
`src/Integrations/Divi/Pages/DiviComposition.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Integrations\Divi\Compose\AttrWriter;
use LIT\MCP\Integrations\Divi\Compose\Composer;
use LIT\MCP\Integrations\Divi\Compose\MediaResolver;
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
use LIT\MCP\Integrations\Divi\Compose\ShortformWriter;
use LIT\MCP\Integrations\Divi\Compose\StructureRules;
use LIT\MCP\Integrations\Divi\Compose\TreeEditor;
use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer;
use LIT\MCP\Integrations\Divi\Compose\Warnings;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
/**
* Wires the pure Compose classes with the live catalog, design system and the WordPress
* sanitizer/media lookup – once per request. Not final so abilities can be tested with a mock.
*/
class DiviComposition
{
private ?Composer $composer = null;
private ?AttrWriter $attrWriter = null;
public function __construct(
private DiviModules $modules,
private DesignSystemReader $design,
private ValueSanitizer $sanitizer = new WpValueSanitizer(),
private MediaResolver $media = new WpMediaResolver()
) {
}
public function composer(): Composer
{
if ($this->composer === null) {
$placeholders = new Placeholders($this->design->read());
$this->composer = new Composer(
$this->modules->catalog(),
$this->rules(),
new ShortformWriter($placeholders, $this->sanitizer, $this->media, $this->attrWriter()),
$this->attrWriter(),
$this->design->read(),
$this->modules->builderVersion()
);
}
return $this->composer;
}
public function attrWriter(): AttrWriter
{
return $this->attrWriter ??= new AttrWriter(
$this->modules->catalog(),
new Placeholders($this->design->read()),
$this->sanitizer
);
}
public function rules(): StructureRules
{
return new StructureRules($this->modules->catalog());
}
public function editor(): TreeEditor
{
return new TreeEditor($this->rules());
}
public function warnings(): Warnings
{
return new Warnings($this->design->read());
}
}
```
`src/Integrations/Divi/Pages/DiviChange.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
/**
* What a Divi write did to the tree: summary, changed nodes (for ToolResult::changed – IDs of
* surviving nodes are resolved after the change), touched nodes (for the quality warnings)
* and warnings of its own.
*/
final class DiviChange
{
/** @var list<array{block: ?Block, id: ?string, type: string, action: string, label: string}> */
private array $changed = [];
/** @var list<Block> */
private array $touched = [];
/** @var list<string> */
private array $warnings = [];
public function __construct(public readonly string $summary)
{
}
/** A node that still exists after the change (inserted, updated, moved, duplicated). */
public function changed(Block $block, string $action): self
{
$this->changed[] = [
'block' => $block,
'id' => null,
'type' => $block->name(),
'action' => $action,
'label' => NodeSummary::label($block),
];
if ($action !== 'moved') {
$this->touched[] = $block;
}
return $this;
}
/** A node that is gone; $id is its ID before the change. */
public function removed(string $id, Block $block): self
{
$this->changed[] = [
'block' => null,
'id' => $id,
'type' => $block->name(),
'action' => 'deleted',
'label' => NodeSummary::label($block),
];
return $this;
}
public function warning(string $warning): self
{
$this->warnings[] = $warning;
return $this;
}
/** @return list<array{block: ?Block, id: ?string, type: string, action: string, label: string}> */
public function entries(): array
{
return $this->changed;
}
/** @return list<Block> */
public function touched(): array
{
return $this->touched;
}
/** @return list<string> */
public function warnings(): array
{
return $this->warnings;
}
}
```
`src/Integrations/Divi/Pages/DiviSaver.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\FollowUp;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\RevisionPoint;
use RuntimeException;
use Throwable;
use WP_Error;
use WP_Post;
/**
* Save pipeline of spec §5.6, steps 3–5: restore point, wp_update_post() with wp_slash() (the
* status stays), Divi 5 flags, Divi's static CSS cache. For users without unfiltered_html
* WordPress' kses would run filter_block_content(), which re-parses and re-serializes EVERY
* block – untouched content would no longer be byte-identical (spec §1.3, §5.2) and global
* modules could break (parse_blocks() mishandles them). kses is therefore switched off for
* exactly this call; every value the Divi tools write is sanitized by WpValueSanitizer before.
* If saving throws, the previous content is written back (WriteFailed: rolled back or not).
* Not final so abilities can be tested with a mock.
*/
class DiviSaver
{
public const REASON = 'Die Divi-Seite konnte nicht gespeichert werden.';
public const CHANGED_ON_SAVE = 'WordPress oder ein Plugin hat den Inhalt beim Speichern verändert; tree_rev '
. 'beschreibt den gespeicherten Stand – vor weiteren Änderungen lit-divi-outline lesen.';
/**
* @return array{revision_id: ?int, warnings: list<string>}
* @throws WriteFailed
*/
public function save(WP_Post $post, string $content): array
{
$postId = (int) $post->ID;
$revisionId = RevisionPoint::capture($postId);
try {
$result = $this->update($postId, $content);
} catch (Throwable $e) {
$this->restore($postId, (string) $post->post_content, $revisionId, $e);
}
if ($result instanceof WP_Error || $result === 0) {
$message = $result instanceof WP_Error ? $result->get_error_message() : 'returned 0';
throw new WriteFailed(
self::REASON,
true,
$postId,
$revisionId,
null,
sprintf('wp_update_post(%d) refused: %s', $postId, $message),
rolledBackState: self::keeping($revisionId)
);
}
$warnings = [];
FollowUp::run(
static function () use ($postId): void {
update_post_meta($postId, '_et_pb_use_divi_5', 'on');
update_post_meta($postId, '_et_pb_use_builder', 'on');
},
'divi builder flags',
'Die Divi-Kennzeichen der Seite konnten nicht gesetzt werden; der Inhalt ist gespeichert – die Seite '
. 'einmal im Divi-Builder öffnen und speichern.',
$warnings
);
FollowUp::run(
static function () use ($postId): void {
if (class_exists('ET_Core_PageResource')) {
\ET_Core_PageResource::remove_static_resources($postId, 'all', true);
}
},
'divi static css',
'Der CSS-Cache von Divi konnte nicht geleert werden; die Änderung ist gespeichert, erscheint aber evtl. '
. 'erst nach „Statische CSS-Dateien leeren“ (Divi → Theme-Optionen → Builder).',
$warnings
);
$stored = get_post($postId);
if ($stored instanceof WP_Post && (string) $stored->post_content !== $content) {
$warnings[] = self::CHANGED_ON_SAVE;
}
return ['revision_id' => $revisionId, 'warnings' => $warnings];
}
private function update(int $postId, string $content): int|WP_Error
{
$kses = ! current_user_can('unfiltered_html')
&& has_filter('content_save_pre', 'wp_filter_post_kses') !== false;
if ($kses) {
kses_remove_filters();
}
try {
return wp_update_post(wp_slash(['ID' => $postId, 'post_content' => $content]), true);
} finally {
if ($kses) {
kses_init_filters();
}
}
}
/** @throws WriteFailed Always. */
private function restore(int $postId, string $before, ?int $revisionId, Throwable $cause): never
{
$detail = sprintf('Saving Divi content of post %d failed: %s', $postId, $cause->getMessage());
try {
$current = get_post($postId);
if ($current instanceof WP_Post && PostGuard::missedSchedule($current)) {
throw new RuntimeException('restoring would publish a missed schedule');
}
$restored = $this->update($postId, $before);
if ($restored instanceof WP_Error || $restored === 0) {
throw new RuntimeException('previous content could not be written back');
}
} catch (Throwable $e) {
$detail .= '; rollback: ' . $e->getMessage();
throw new WriteFailed(self::REASON, false, $postId, $revisionId, $cause, $detail);
}
throw new WriteFailed(
self::REASON,
true,
$postId,
$revisionId,
$cause,
$detail,
rolledBackState: self::keeping($revisionId)
);
}
/** write_failed must not claim "nothing was saved" while the restore-point revision stays. */
private static function keeping(?int $revisionId): ?string
{
return $revisionId === null ? null : Errors::notSavedKeeping(sprintf('Revision %d', $revisionId));
}
}
```
`src/Integrations/Divi/Pages/DiviWriter.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
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\Serializer;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\WordPress\PostChangeResult;
use WP_Error;
/**
* The shared frame of every Divi write (spec §5.6): load with rights and tree_rev lock
* (ruling P16), let the ability change the tree, serialize, save via DiviSaver, then change
* log, lit_mcp_after_save and the ToolResult with changed nodes, affected outline, quality
* warnings (spec §5.5), revision_id, the new rev and tree_rev. Not final so abilities can be
* tested with a mock.
*/
class DiviWriter
{
public function __construct(
private DiviPageLoader $loader,
private DiviSaver $saver,
private ChangeLog $log,
private DesignSystemReader $design,
private DiviComposition $composition
) {
}
/** @return array<string, array<string, mixed>> post_id and tree_rev of every Divi write. */
public static function inputProperties(): array
{
return [
'post_id' => ['type' => 'integer', 'minimum' => 1],
'tree_rev' => [
'type' => 'string',
'description' => 'tree_rev aus lit-divi-outline bzw. der letzten Divi-Änderung (Pflicht).',
],
];
}
/** @return array<string, array<string, mixed>> Target position of insert and move. */
public static function positionProperties(): array
{
return [
'parent' => [
'type' => 'string',
'description' => 'Pfad-ID des Elternknotens (z. B. Spalte "s0.r0.c1"); ohne parent/before/after: '
. 'Ende der Seite.',
],
'before' => ['type' => 'string', 'description' => 'Pfad-ID: vor diesem Knoten einfügen.'],
'after' => ['type' => 'string', 'description' => 'Pfad-ID: nach diesem Knoten einfügen.'],
'index' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Position unter parent (0 = zuerst).'],
];
}
/**
* The page to change, after rights, type, layout and the tree_rev lock were checked.
*
* @param array<string, mixed> $input
*/
public function page(array $input): DiviPage|WP_Error
{
$page = $this->loader->load($input['post_id'] ?? null, true);
if ($page instanceof WP_Error) {
return $page;
}
$problem = $this->loader->writable($page);
if ($problem !== null) {
return $problem;
}
if (! is_string($input['tree_rev'] ?? null) || $input['tree_rev'] !== $page->treeRev()) {
return Errors::revMismatch(
'Die Divi-Seite wurde seit dem letzten Lesen geändert (tree_rev passt nicht); nichts wurde '
. 'geschrieben.',
DiviPageLoader::OUTLINE_HINT
);
}
return $page;
}
/** Section label around $block, for automatic admin labels ("" when none). */
public static function sectionLabel(NodeIndex $index, ?Block $block): string
{
for ($node = $block; $node !== null; $node = self::parentBlock($index, $node)) {
if ($node->name() === 'divi/section') {
return NodeSummary::label($node);
}
}
return '';
}
/**
* Saves the changed tree of $page and returns the ToolResult array.
*
* @param array<string, mixed> $input
* @return array<string, mixed>
* @throws WriteFailed
*/
public function save(string $ability, DiviPage $page, DiviChange $change, array $input): array
{
$post = $page->post;
$content = Serializer::document($page->document);
if ($content === (string) $post->post_content) {
return ToolResult::ok('Keine Änderung – die Seite ist bereits so.')
->rev(Rev::ofPost($post))
->with('tree_rev', $page->treeRev())
->warnings($change->warnings())
->toArray();
}
$index = NodeIndex::build($page->document);
$quality = $this->composition->warnings()->collect($index, $change->touched());
$warnings = array_merge($change->warnings(), $quality);
try {
$saved = $this->saver->save($post, $content);
} catch (WriteFailed $e) {
if (! $e->rolledBack) {
$this->log->recordPartial(
$ability,
(string) $post->post_type,
(string) $post->ID,
sprintf('Divi "%s": Änderung nur teilweise zurückgenommen', $post->post_title),
$e->revisionId
);
}
throw $e;
}
$result = (new PostChangeResult(
$ability,
$this->log,
(int) $post->ID,
'die Änderung',
array_merge($warnings, $saved['warnings'])
))->afterSave($input);
$updated = $result->reread($post) ?? $post;
$summary = sprintf('Divi "%s": %s', $post->post_title, $change->summary);
$result->log((string) $post->post_type, $summary, $saved['revision_id']);
$status = sprintf('%s (Status unverändert: %s).', $change->summary, $updated->post_status);
$tool = $result->result($status, $updated)
->revisionId($saved['revision_id'])
->outline($this->outline($index, $change))
->with('tree_rev', Rev::of((string) $updated->post_content))
->urls((string) get_preview_post_link($updated), $page->editUrl());
foreach ($change->entries() as $entry) {
$id = $entry['id'] ?? ($entry['block'] !== null ? $index->idOf($entry['block']) : null);
$tool->changed($id ?? '?', $entry['type'], $entry['action'], $entry['label']);
}
return $tool->toArray();
}
/** Outline of the sections that contain changed nodes. */
private function outline(NodeIndex $index, DiviChange $change): ?string
{
$sections = [];
foreach ($change->entries() as $entry) {
$id = $entry['block'] !== null ? $index->idOf($entry['block']) : null;
if ($id !== null) {
$sections[explode('.', $id)[0]] = true;
}
}
if ($sections === []) {
return null;
}
$outline = new Outline($this->design->read()->presetNames());
$texts = [];
foreach (array_keys($sections) as $sectionId) {
$texts[] = $outline->text($outline->rows($index, (string) $sectionId));
}
return implode("\n", $texts);
}
private static function parentBlock(NodeIndex $index, Block $block): ?Block
{
$parent = $index->parentOf($block);
return $parent instanceof Block && $parent->name() !== 'divi/placeholder' ? $parent : null;
}
}
```
- [ ] **Step 4: Tests und Lint (grün)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test && composer lint`
Expected: PASS, phpcs ohne Meldungen.
- [ ] **Step 5: Commit**
```bash
cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
git add src/Integrations/Divi/Pages/WpValueSanitizer.php src/Integrations/Divi/Pages/WpMediaResolver.php src/Integrations/Divi/Pages/DiviComposition.php src/Integrations/Divi/Pages/DiviChange.php src/Integrations/Divi/Pages/DiviSaver.php src/Integrations/Divi/Pages/DiviWriter.php tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi save pipeline – tree_rev lock, kses-safe save, revision, flags, cache, ToolResult
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
```
---
### Task 6: `lit/divi-insert` und `lit/divi-update`
Spec §5.7: Einfügen von Knoten in Kurz- und/oder Vollform – Module, Zeilen oder ganze Abschnitte – an `parent`/`before`/`after` (ohne Position ans Seitenende); Ändern mehrerer vorhandener Knoten in einem Aufruf (`changes` mit `set`, `attrs`, `preset`, `label`), alles oder nichts. Unbekannte Attributpfade → neuer Fehlercode `invalid_attr_path` (Spec §8.2) mit `fields`; nur lesbare Knoten (globale Module, fremde Blöcke) lassen sich nicht ändern. Die Divi-Integration bekommt dafür das Änderungsprotokoll.
**Files:**
- Create: `src/Integrations/Divi/Pages/{DiviInsertAbility,DiviUpdateAbility}.php`
- Modify: `src/Abilities/Errors.php` (`invalidAttrPath()`), `src/Integrations/Divi/DiviIntegration.php` (ganze Datei ersetzen), `src/Plugin.php`, `docs/guides/overview.md`
- Test: `tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php`
**Interfaces:**
- Consumes: `DiviWriter::page/save/inputProperties/positionProperties/sectionLabel`, `DiviChange`, `DiviComposition::composer/editor` (Task 5); `Composer::compose/update`, `TreeEditor::position/insert` (Tasks 3–4); `ToolResult::schema`.
- Produces:
- `Errors::invalidAttrPath(array $fields): WP_Error` (Code `lit_invalid_attr_path`, Status 400).
- `DiviInsertAbility(DiviWriter, DiviComposition)` – `lit/divi-insert`, direkt; Input `{post_id, tree_rev, nodes (1–20 Objekte), parent?, before?, after?, index?}`.
- `DiviUpdateAbility(DiviWriter, DiviComposition)` – `lit/divi-update`, direkt, idempotent; Input `{post_id, tree_rev, changes: [{id, set?, attrs?, preset?, label?}] (1–50)}`.
- `DiviIntegration(ContentSchema $schema, ChangeLog $log)` (neue Signatur).
- [ ] **Step 1: Failing Tests**
`tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Tests\Unit\Integrations\Divi\Compose\ComposeFixture;
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class DiviWriteAbilitiesTest extends TestCase
{
private WP_Post $post;
private string $original = '';
/** @var list<string> Contents handed to DiviSaver::save(). */
private array $saved = [];
private DiviWriter $writer;
private DiviComposition $composition;
protected function setUp(): void
{
parent::setUp();
$this->original = DiviPageLoaderTest::fixture('synthetic-basic.html');
$this->post = DiviPageLoaderTest::givenPost($this->original);
Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true');
$modules = Mockery::mock(DiviModules::class);
$modules->allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog());
$modules->allows('builderVersion')->andReturn('5.2.1');
$design = Mockery::mock(DesignSystemReader::class);
$design->allows('read')->andReturn(DesignSystemTest::sample());
$this->composition = new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media());
$saver = Mockery::mock(DiviSaver::class);
$saver->allows('save')->andReturnUsing(function (WP_Post $post, string $content): array {
$this->saved[] = $content;
$post->post_content = $content;
return ['revision_id' => 811, 'warnings' => []];
});
$log = Mockery::mock(ChangeLog::class);
$log->allows('record')->andReturn(4711);
$this->writer = new DiviWriter(
new DiviPageLoader(DiviPageLoaderTest::schema()),
$saver,
$log,
$design,
$this->composition
);
}
private function treeRev(): string
{
return Rev::of($this->original);
}
public function testInsertComposesAndSavesWithTreeRevAndPostRev(): void
{
$ability = new DiviInsertAbility($this->writer, $this->composition);
$result = $ability->execute([
'post_id' => 7,
'tree_rev' => $this->treeRev(),
'after' => 's0.r0.c1.m1',
'nodes' => [['type' => 'text', 'content' => '<p>Neu</p>']],
]);
self::assertTrue($result['ok'], (string) json_encode($result));
self::assertCount(1, $this->saved);
self::assertSame([['id' => 's0.r0.c1.m2', 'type' => 'divi/text', 'action' => 'inserted', 'label' => 'Hero – Text']], $result['changed']);
self::assertSame(Rev::of($this->saved[0]), $result['tree_rev']);
self::assertSame(Rev::ofPost($this->post), $result['rev']);
self::assertSame(811, $result['revision_id']);
self::assertSame(4711, $result['log_id']);
self::assertStringStartsWith('s0 section "Hero"', (string) $result['outline']);
self::assertSame('https://kunde.test/?page_id=7&et_fb=1', $result['edit_url']);
// Byte identity: only the new block was added, every other byte is unchanged.
$inserted = '<!-- wp:divi/text {"builderVersion":"5.2.1","content":{"innerContent":{"desktop":{"value":'
. '"\u003cp\u003eNeu\u003c/p\u003e"}}},"module":{"meta":{"adminLabel":{"desktop":{"value":"Hero – Text"}}}}} '
. '--><!-- /wp:divi/text -->';
self::assertSame(str_replace('<!-- /wp:divi/text --><!-- wp:divi/button', '<!-- /wp:divi/text -->' . $inserted . '<!-- wp:divi/button', $this->original), $this->saved[0]);
}
public function testStaleTreeRevAndInvalidNodesWriteNothing(): void
{
$ability = new DiviInsertAbility($this->writer, $this->composition);
$stale = $ability->execute(['post_id' => 7, 'tree_rev' => '000000000000', 'nodes' => [['type' => 'section']]]);
self::assertSame('lit_rev_mismatch', $stale->get_error_code());
self::assertStringContainsString('lit-divi-outline', $stale->get_error_data()['hint']);
$invalid = $ability->execute([
'post_id' => 7,
'tree_rev' => $this->treeRev(),
'parent' => 's0.r0.c0',
'nodes' => [['type' => 'heading', 'attrs' => ['title.fnt' => ['desktop' => 'x']]]],
]);
self::assertSame('lit_invalid_attr_path', $invalid->get_error_code());
$wrongPlace = $ability->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'nodes' => [['type' => 'button']]]);
self::assertSame('lit_validation_failed', $wrongPlace->get_error_code());
self::assertSame([], $this->saved);
}
public function testUpdateChangesSeveralNodesAllOrNothing(): void
{
$ability = new DiviUpdateAbility($this->writer, $this->composition);
$failed = $ability->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [
['id' => 's0.r0.c1.m2', 'set' => ['text' => 'Jetzt buchen']],
['id' => 's0.r0.c1.m0', 'set' => ['level' => 'h9']],
]]);
self::assertSame('lit_validation_failed', $failed->get_error_code());
self::assertSame([], $this->saved);
$result = $ability->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [
['id' => 's0.r0.c1.m2', 'set' => ['text' => 'Jetzt buchen']],
['id' => 's0.r0.c1.m0', 'attrs' => ['title.decoration.font.font' => ['phone' => null]]],
]]);
self::assertTrue($result['ok']);
self::assertSame(['s0.r0.c1.m2', 's0.r0.c1.m0'], array_column($result['changed'], 'id'));
$index = NodeIndex::build((new Parser())->parse($this->saved[0]));
self::assertSame('Jetzt buchen', $index->find('s0.r0.c1.m2')?->attrs()['button']['innerContent']['desktop']['value']['text']);
self::assertArrayNotHasKey('phone', $index->find('s0.r0.c1.m0')?->attrs()['title']['decoration']['font']['font']);
// Untouched section s1 keeps its exact bytes.
self::assertStringContainsString(substr($this->original, (int) strpos($this->original, '<!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Leistungen"'), -1), $this->saved[0]);
}
public function testUpdateWithoutRealChangeSavesNothing(): void
{
$result = (new DiviUpdateAbility($this->writer, $this->composition))->execute([
'post_id' => 7,
'tree_rev' => $this->treeRev(),
'changes' => [['id' => 's0.r0.c1.m2', 'set' => ['text' => 'Termin buchen']]],
]);
self::assertStringStartsWith('Keine Änderung', $result['summary']);
self::assertSame([], $this->saved);
}
public function testReadOnlyNodesCannotBeUpdated(): void
{
$this->post->post_content = DiviPageLoaderTest::fixture('synthetic-mixed.html');
$result = (new DiviUpdateAbility($this->writer, $this->composition))->execute([
'post_id' => 7,
'tree_rev' => Rev::of($this->post->post_content),
'changes' => [['id' => 's1.g1', 'label' => 'x']],
]);
self::assertStringContainsString('nur lesbar', $result->get_error_message());
}
}
```
In `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` ersetzen:
```php
'lit/divi-design-system',
];
```
durch:
```php
'lit/divi-design-system',
'lit/divi-insert',
'lit/divi-update',
];
```
In `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` ersetzen:
```php
$integration = new DiviIntegration(Mockery::mock(ContentSchema::class));
```
durch:
```php
$integration = new DiviIntegration(Mockery::mock(ContentSchema::class), Mockery::mock(ChangeLog::class));
```
In `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` ersetzen:
```php
use LIT\MCP\Abilities\Ability;
```
durch:
```php
use LIT\MCP\Abilities\Ability;
use LIT\MCP\ChangeLog\ChangeLog;
```
- [ ] **Step 2: Test laufen lassen (rot)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'DiviWriteAbilitiesTest|DiviIntegrationTest'`
Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility" not found`.
- [ ] **Step 3: Implementierung**
In `src/Abilities/Errors.php` ersetzen:
```php
public static function unsupported(string $message, string $hint): WP_Error
{
return self::make('unsupported', $message, $hint, 400);
}
```
durch:
```php
public static function unsupported(string $message, string $hint): WP_Error
{
return self::make('unsupported', $message, $hint, 400);
}
/**
* Unknown Divi attribute paths (spec §5.4, §8.2). $fields names every problem of the call,
* the unknown paths with their closest known ones.
*
* @param array<string, string> $fields Input path => problem.
*/
public static function invalidAttrPath(array $fields): WP_Error
{
return self::make(
'invalid_attr_path',
'Unbekannte Attributpfade; nichts wurde gespeichert.',
'Vorschläge unter fields prüfen; alle gültigen Pfade eines Moduls liefert lit/divi-catalog '
. '(direkt: lit-divi-catalog) mit module.',
400,
['fields' => $fields]
);
}
```
`src/Integrations/Divi/Pages/DiviInsertAbility.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\ToolResult;
use WP_Error;
/**
* lit/divi-insert: inserts nodes in short and/or full form – modules, rows or whole sections –
* at parent / before / after (spec §5.7). All or nothing; placeholders and presets are
* resolved, admin labels set automatically (spec §5.4, §5.5).
*/
final class DiviInsertAbility implements Ability
{
public function __construct(private DiviWriter $writer, private DiviComposition $composition)
{
}
public function name(): string
{
return 'lit/divi-insert';
}
public function label(): string
{
return 'Divi-Knoten einfügen';
}
public function description(): string
{
return 'Fügt Divi-Knoten ein: Module, Zeilen oder ganze Abschnitte in Kurzform (z. B. {"type": "button", '
. '"text": "Termin buchen", "url": "/kontakt", "preset": "CTA primär"}) und/oder Vollform ("attrs"). '
. 'Position über parent, before oder after (Pfad-IDs aus lit-divi-outline), sonst ans Seitenende. '
. 'Platzhalter wie {{color:primary}} statt fester Werte. tree_rev ist Pflicht; alles oder nichts. '
. 'Format: lit-guide topic "divi-shortform".';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => array_merge(DiviWriter::inputProperties(), [
'nodes' => [
'type' => 'array',
'minItems' => 1,
'maxItems' => 20,
'items' => ['type' => 'object'],
'description' => 'Einzufügende Knoten in Reihenfolge; Abschnitte mit rows/modules, Zeilen mit '
. 'columns und modules, Module mit Kurzform-Feldern, items (Kind-Module) und attrs.',
],
], DiviWriter::positionProperties()),
'required' => ['post_id', 'tree_rev', 'nodes'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema(['tree_rev' => ['type' => 'string']], ['tree_rev']);
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => false, 'idempotent' => false];
}
public function isDirect(): bool
{
return true;
}
public function permission(array $input): bool|WP_Error
{
$page = $this->writer->page($input);
return $page instanceof WP_Error ? $page : true;
}
public function execute(array $input): mixed
{
$page = $this->writer->page($input);
if ($page instanceof WP_Error) {
return $page;
}
$editor = $this->composition->editor();
$position = $editor->position($page->index, $input);
if (is_string($position)) {
return Errors::invalidInput('Ungültige Position; nichts wurde gespeichert.', ['position' => $position]);
}
$context = DiviWriter::sectionLabel($page->index, $position['parent']);
$nodes = is_array($input['nodes'] ?? null) ? array_values($input['nodes']) : [];
$composed = $this->composition->composer()->compose($nodes, $position['parent'], 'nodes', $context);
if ($composed['errors'] !== []) {
return $composed['invalid_paths'] !== []
? Errors::invalidAttrPath($composed['errors'])
: Errors::invalidInput('Knoten sind ungültig; nichts wurde gespeichert.', $composed['errors']);
}
$violation = $editor->insert($position, $composed['blocks']);
if ($violation !== null) {
return Errors::invalidInput(
'Knoten passen nicht an diese Stelle; nichts wurde gespeichert.',
['nodes' => $violation]
);
}
$change = new DiviChange(sprintf('%d Divi-Knoten eingefügt', count($composed['blocks'])));
foreach ($composed['blocks'] as $block) {
$change->changed($block, 'inserted');
}
return $this->writer->save($this->name(), $page, $change, $input);
}
}
```
`src/Integrations/Divi/Pages/DiviUpdateAbility.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\ToolResult;
use WP_Error;
/**
* lit/divi-update: sets or removes values of existing nodes – short-form fields ("set"), full
* form ("attrs", null removes a path or breakpoint), preset and admin label (spec §5.7). Several
* nodes per call, all or nothing.
*/
final class DiviUpdateAbility implements Ability
{
public function __construct(private DiviWriter $writer, private DiviComposition $composition)
{
}
public function name(): string
{
return 'lit/divi-update';
}
public function label(): string
{
return 'Divi-Knoten ändern';
}
public function description(): string
{
return 'Ändert vorhandene Divi-Knoten: changes = Liste von {id, set: {Kurzform-Felder}, attrs: {Attributpfad: '
. '{Breakpoint: Wert}}, preset, label}. null entfernt einen Wert. Nicht genannte Einstellungen bleiben '
. 'unverändert. tree_rev aus lit-divi-outline ist Pflicht; alles oder nichts.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => array_merge(DiviWriter::inputProperties(), [
'changes' => [
'type' => 'array',
'minItems' => 1,
'maxItems' => 50,
'items' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'string'],
'set' => ['type' => 'object', 'description' => 'Kurzform-Felder, z. B. {"text": "Neu"}.'],
'attrs' => [
'type' => 'object',
'description' => 'Vollform: Attributpfad → Breakpoint-Werte.',
],
'preset' => ['type' => ['string', 'null'], 'description' => 'Preset-Name; null entfernt.'],
'label' => ['type' => 'string', 'description' => 'Admin-Label im Divi-Builder.'],
],
'required' => ['id'],
'additionalProperties' => false,
],
],
]),
'required' => ['post_id', 'tree_rev', 'changes'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema(['tree_rev' => ['type' => 'string']], ['tree_rev']);
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return true;
}
public function permission(array $input): bool|WP_Error
{
$page = $this->writer->page($input);
return $page instanceof WP_Error ? $page : true;
}
public function execute(array $input): mixed
{
$page = $this->writer->page($input);
if ($page instanceof WP_Error) {
return $page;
}
$errors = [];
$invalidPaths = false;
$updates = [];
foreach (array_values(is_array($input['changes'] ?? null) ? $input['changes'] : []) as $position => $change) {
$field = sprintf('changes[%d]', $position);
$id = is_array($change) && is_string($change['id'] ?? null) ? $change['id'] : '';
$block = $page->index->find($id);
if ($block === null) {
$errors[$field . '.id'] = sprintf('Knoten "%s" gibt es nicht (lit-divi-outline neu lesen)', $id);
continue;
}
if (! $block->isDivi() || $block->isOpaque()) {
$errors[$field . '.id'] = 'nur lesbar (globales Modul oder fremder Block)';
continue;
}
$result = $this->composition->composer()->update($block, $change, $field);
$errors += $result['errors'];
$invalidPaths = $invalidPaths || $result['invalid_paths'] !== [];
$updates[] = [$block, $result['attrs']];
}
if ($errors !== []) {
return $invalidPaths
? Errors::invalidAttrPath($errors)
: Errors::invalidInput('Änderungen sind ungültig; nichts wurde gespeichert.', $errors);
}
$change = new DiviChange(sprintf('%d Divi-Knoten geändert', count($updates)));
foreach ($updates as [$block, $attrs]) {
if ($attrs !== $block->attrs()) {
$block->setAttrs($attrs);
$change->changed($block, 'updated');
}
}
return $this->writer->save($this->name(), $page, $change, $input);
}
}
```
`src/Integrations/Divi/DiviIntegration.php` (ganze Datei ersetzen):
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility;
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\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
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;
private DiviComposition $composition;
public function __construct(private ContentSchema $schema, private ChangeLog $log)
{
$this->modules = new DiviModules();
$this->design = new DesignSystemReader();
$this->composition = new DiviComposition($this->modules, $this->design);
}
public function id(): string
{
return 'divi';
}
public function isAvailable(): bool
{
return DiviModules::isAvailable();
}
public function abilities(): array
{
$loader = new DiviPageLoader($this->schema);
$writer = new DiviWriter($loader, new DiviSaver(), $this->log, $this->design, $this->composition);
return [
new DiviOutlineAbility($loader, $this->design),
new DiviNodeGetAbility($loader, $this->design),
new DiviCatalogAbility($this->modules),
new DiviDesignSystemAbility($this->design),
new DiviInsertAbility($writer, $this->composition),
new DiviUpdateAbility($writer, $this->composition),
];
}
}
```
In `src/Plugin.php` ersetzen:
```php
new DiviIntegration($schema)
```
durch:
```php
new DiviIntegration($schema, $changeLog)
```
In `docs/guides/overview.md` ersetzen:
```markdown
`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`.
```
durch:
```markdown
`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`, `lit-divi-insert`,
`lit-divi-update`.
```
- [ ] **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 (u. a. `testInsertComposesAndSavesWithTreeRevAndPostRev`: alle übrigen Bytes der Seite unverändert), phpcs ohne Meldungen.
- [ ] **Step 5: Commit**
```bash
cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
git add src/Abilities/Errors.php src/Integrations/Divi/Pages/DiviInsertAbility.php src/Integrations/Divi/Pages/DiviUpdateAbility.php src/Integrations/Divi/DiviIntegration.php src/Plugin.php docs/guides/overview.md tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php 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-insert and lit/divi-update
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
```
---
### Task 7: `lit/divi-move`, `lit/divi-delete`, `lit/divi-duplicate-node`
Spec §5.7: Verschieben (auch zwischen Zeilen und Abschnitten, gegen die Strukturregeln), Löschen (destruktiv: ohne `confirm: true` nennt die Antwort, was samt Inhalt entfernt würde; Knoten innerhalb eines ebenfalls gelöschten Knotens zählen nicht doppelt), Duplizieren direkt hinter das Original (Admin-Label der Kopie endet auf „(Kopie)“). Nur lesbare Knoten bewegen sich als Ganzes. Verschieben und Löschen der Kopie stellen den byte-identischen Ausgangszustand wieder her (im Test geprüft).
**Files:**
- Create: `src/Integrations/Divi/Pages/{DiviMoveAbility,DiviDeleteAbility,DiviDuplicateNodeAbility}.php`
- Modify: `src/Integrations/Divi/DiviIntegration.php`, `docs/guides/overview.md`
- Test: `tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php` (Test ergänzen), `tests/Unit/Integrations/Divi/DiviIntegrationTest.php`
**Interfaces:**
- Consumes: `DiviWriter`, `DiviChange::changed/removed`, `DiviComposition::editor` (Task 5); `TreeEditor::position/move/remove/duplicate` (Task 4); `DiviPageLoader::unknownNode`, `NodeSummary::label`, `AttrMap::set`; `Errors::confirmRequired`.
- Produces:
- `DiviMoveAbility(DiviWriter, DiviComposition)` – `lit/divi-move`, direkt; Input `{post_id, tree_rev, id, parent?, before?, after?, index?}`.
- `DiviDeleteAbility(DiviWriter, DiviComposition)` – `lit/divi-delete`, direkt, destruktiv; Input `{post_id, tree_rev, ids (1–20), confirm?}`; ohne `confirm` → `lit_confirm_required` mit `nodes`.
- `DiviDuplicateNodeAbility(DiviWriter, DiviComposition)` – `lit/divi-duplicate-node`, indirekt; Input `{post_id, tree_rev, id}`.
- [ ] **Step 1: Failing Tests**
In `tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php` ersetzen:
```php
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
```
durch:
```php
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviDuplicateNodeAbility;
```
In `tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php` ersetzen:
```php
use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility;
```
durch:
```php
use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviMoveAbility;
```
In `tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php` (neuer Test davor) ersetzen:
```php
public function testReadOnlyNodesCannotBeUpdated(): void
```
durch:
```php
public function testMoveDuplicateAndDelete(): void
{
$moved = (new DiviMoveAbility($this->writer, $this->composition))->execute([
'post_id' => 7, 'tree_rev' => $this->treeRev(), 'id' => 's0.r0.c1.m2', 'parent' => 's1.r0.c0',
]);
self::assertSame([['id' => 's1.r0.c0.m1', 'type' => 'divi/button', 'action' => 'moved', 'label' => 'Hero – Button']], $moved['changed']);
self::assertSame(strlen($this->original), strlen($this->saved[0]));
$this->original = $this->saved[0];
$duplicated = (new DiviDuplicateNodeAbility($this->writer, $this->composition))->execute([
'post_id' => 7, 'tree_rev' => Rev::of($this->original), 'id' => 's1',
]);
self::assertSame('s2', $duplicated['changed'][0]['id']);
self::assertSame('Leistungen (Kopie)', $duplicated['changed'][0]['label']);
$this->original = $this->saved[1];
$delete = new DiviDeleteAbility($this->writer, $this->composition);
$ask = $delete->execute(['post_id' => 7, 'tree_rev' => Rev::of($this->original), 'ids' => ['s2', 's2.r0']]);
self::assertSame('lit_confirm_required', $ask->get_error_code());
self::assertSame(
'1 Divi-Knoten würden samt Inhalt entfernt: s2 section "Leistungen (Kopie)" (1 Kind-Knoten).',
json_decode($ask->get_error_message(), true)['message']
);
$deleted = $delete->execute(['post_id' => 7, 'tree_rev' => Rev::of($this->original), 'ids' => ['s2'], 'confirm' => true]);
self::assertSame([['id' => 's2', 'type' => 'divi/section', 'action' => 'deleted', 'label' => 'Leistungen (Kopie)']], $deleted['changed']);
self::assertSame($this->saved[0], $this->saved[2], 'deleting the copy restores the exact bytes');
self::assertTrue($delete->annotations()['destructive']);
}
public function testReadOnlyNodesCannotBeUpdated(): void
```
In `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` ersetzen:
```php
'lit/divi-update',
];
```
durch:
```php
'lit/divi-update',
'lit/divi-move',
'lit/divi-delete',
'lit/divi-duplicate-node',
];
```
- [ ] **Step 2: Test laufen lassen (rot)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'DiviWriteAbilitiesTest|DiviIntegrationTest'`
Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\Pages\DiviMoveAbility" not found`.
- [ ] **Step 3: Implementierung**
`src/Integrations/Divi/Pages/DiviMoveAbility.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\ToolResult;
use WP_Error;
/**
* lit/divi-move: moves a node (with everything inside) to parent / before / after – also into
* another row or section, as long as the structure rules allow it (spec §5.7). Read-only
* nodes (global modules, foreign blocks) move as a whole, unchanged.
*/
final class DiviMoveAbility implements Ability
{
public function __construct(private DiviWriter $writer, private DiviComposition $composition)
{
}
public function name(): string
{
return 'lit/divi-move';
}
public function label(): string
{
return 'Divi-Knoten verschieben';
}
public function description(): string
{
return 'Verschiebt einen Divi-Knoten samt Inhalt an eine neue Stelle (parent, before oder after; auch in '
. 'andere Zeilen oder Abschnitte). tree_rev aus lit-divi-outline ist Pflicht.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => array_merge(
DiviWriter::inputProperties(),
['id' => ['type' => 'string', 'description' => 'Pfad-ID des zu verschiebenden Knotens.']],
DiviWriter::positionProperties()
),
'required' => ['post_id', 'tree_rev', 'id'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema(['tree_rev' => ['type' => 'string']], ['tree_rev']);
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => false, 'idempotent' => false];
}
public function isDirect(): bool
{
return true;
}
public function permission(array $input): bool|WP_Error
{
$page = $this->writer->page($input);
return $page instanceof WP_Error ? $page : true;
}
public function execute(array $input): mixed
{
$page = $this->writer->page($input);
if ($page instanceof WP_Error) {
return $page;
}
$id = is_string($input['id'] ?? null) ? $input['id'] : '';
$node = $page->index->find($id);
if ($node === null) {
return DiviPageLoader::unknownNode($id, $page->post);
}
$editor = $this->composition->editor();
$position = $editor->position($page->index, $input);
if (is_string($position)) {
return Errors::invalidInput('Ungültige Position; nichts wurde gespeichert.', ['position' => $position]);
}
$problem = $editor->move($page->index, $node, $position);
if ($problem !== null) {
return Errors::invalidInput(
'Verschieben nicht möglich; nichts wurde gespeichert.',
['position' => $problem]
);
}
$change = (new DiviChange(sprintf('%s verschoben', $id)))->changed($node, 'moved');
return $this->writer->save($this->name(), $page, $change, $input);
}
}
```
`src/Integrations/Divi/Pages/DiviDeleteAbility.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\ToolResult;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
use WP_Error;
/**
* lit/divi-delete: removes nodes with everything inside (spec §5.7, destructive). Needs
* confirm: true after asking the user; without it the answer lists what would be removed.
* The revision in the result allows undoing it (lit/revision-restore).
*/
final class DiviDeleteAbility implements Ability
{
public function __construct(private DiviWriter $writer, private DiviComposition $composition)
{
}
public function name(): string
{
return 'lit/divi-delete';
}
public function label(): string
{
return 'Divi-Knoten löschen';
}
public function description(): string
{
return 'Entfernt Divi-Knoten samt Inhalt. Erst nach Rückfrage beim Nutzer mit confirm: true; ohne confirm '
. 'nennt die Antwort, was entfernt würde. Rückgängig über die Revision in revision_id. tree_rev ist '
. 'Pflicht.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => array_merge(DiviWriter::inputProperties(), [
'ids' => ['type' => 'array', 'minItems' => 1, 'maxItems' => 20, 'items' => ['type' => 'string']],
'confirm' => ['type' => 'boolean', 'description' => 'true erst nach Rückfrage beim Nutzer.'],
]),
'required' => ['post_id', 'tree_rev', 'ids'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema(['tree_rev' => ['type' => 'string']], ['tree_rev']);
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => true, 'idempotent' => false];
}
public function isDirect(): bool
{
return true;
}
public function permission(array $input): bool|WP_Error
{
$page = $this->writer->page($input);
return $page instanceof WP_Error ? $page : true;
}
public function execute(array $input): mixed
{
$page = $this->writer->page($input);
if ($page instanceof WP_Error) {
return $page;
}
/** @var array<string, Block> $nodes */
$nodes = [];
foreach (is_array($input['ids'] ?? null) ? $input['ids'] : [] as $id) {
$node = is_string($id) ? $page->index->find($id) : null;
if ($node === null) {
return DiviPageLoader::unknownNode((string) $id, $page->post);
}
$nodes[$id] = $node;
}
// A node inside another node that is deleted anyway needs no own removal.
$nodes = array_filter(
$nodes,
static fn (Block $node, string $id): bool => array_filter(
array_keys($nodes),
static fn (string $other): bool => $other !== $id && str_starts_with($id, $other . '.')
) === [],
ARRAY_FILTER_USE_BOTH
);
if (($input['confirm'] ?? false) !== true) {
return Errors::confirmRequired(
sprintf('%d Divi-Knoten würden samt Inhalt entfernt: %s.', count($nodes), $this->describe($nodes)),
['nodes' => array_keys($nodes)]
);
}
$editor = $this->composition->editor();
$change = new DiviChange(sprintf('%d Divi-Knoten gelöscht', count($nodes)));
foreach ($nodes as $id => $node) {
$change->removed((string) $id, $node);
$editor->remove($page->index, $node);
}
return $this->writer->save($this->name(), $page, $change, $input);
}
/** @param array<string, Block> $nodes */
private function describe(array $nodes): string
{
$parts = [];
foreach ($nodes as $id => $node) {
$label = NodeSummary::label($node);
$parts[] = sprintf(
'%s %s%s (%d Kind-Knoten)',
$id,
$node->shortName(),
$label === '' ? '' : ' "' . $label . '"',
count($node->children())
);
}
return implode(', ', $parts);
}
}
```
`src/Integrations/Divi/Pages/DiviDuplicateNodeAbility.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
use WP_Error;
/**
* lit/divi-duplicate-node: copies a node with everything inside right behind the original
* (spec §5.7). The copy's admin label gets " (Kopie)"; read-only nodes are copied unchanged.
*/
final class DiviDuplicateNodeAbility implements Ability
{
public function __construct(private DiviWriter $writer, private DiviComposition $composition)
{
}
public function name(): string
{
return 'lit/divi-duplicate-node';
}
public function label(): string
{
return 'Divi-Knoten duplizieren';
}
public function description(): string
{
return 'Dupliziert einen Divi-Knoten samt Inhalt direkt hinter das Original; das Admin-Label der Kopie endet '
. 'auf „(Kopie)“. tree_rev aus lit-divi-outline ist Pflicht.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => array_merge(DiviWriter::inputProperties(), ['id' => ['type' => 'string']]),
'required' => ['post_id', 'tree_rev', 'id'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema(['tree_rev' => ['type' => 'string']], ['tree_rev']);
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => false, 'idempotent' => false];
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
$page = $this->writer->page($input);
return $page instanceof WP_Error ? $page : true;
}
public function execute(array $input): mixed
{
$page = $this->writer->page($input);
if ($page instanceof WP_Error) {
return $page;
}
$id = is_string($input['id'] ?? null) ? $input['id'] : '';
$node = $page->index->find($id);
if ($node === null) {
return DiviPageLoader::unknownNode($id, $page->post);
}
$copy = $this->composition->editor()->duplicate($page->index, $node);
if ($copy->isDivi() && ! $copy->isOpaque()) {
$label = NodeSummary::label($node);
$copy->setAttrs(AttrMap::set($copy->attrs(), 'module.meta.adminLabel', [
'desktop' => ['value' => ($label === '' ? $node->shortName() : $label) . ' (Kopie)'],
]));
}
$change = (new DiviChange(sprintf('%s dupliziert', $id)))->changed($copy, 'duplicated');
return $this->writer->save($this->name(), $page, $change, $input);
}
}
```
In `src/Integrations/Divi/DiviIntegration.php` ersetzen:
```php
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
```
durch:
```php
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviDuplicateNodeAbility;
```
In `src/Integrations/Divi/DiviIntegration.php` ersetzen:
```php
use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility;
```
durch:
```php
use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviMoveAbility;
```
In `src/Integrations/Divi/DiviIntegration.php` ersetzen:
```php
new DiviUpdateAbility($writer, $this->composition),
];
```
durch:
```php
new DiviUpdateAbility($writer, $this->composition),
new DiviMoveAbility($writer, $this->composition),
new DiviDeleteAbility($writer, $this->composition),
new DiviDuplicateNodeAbility($writer, $this->composition),
];
```
In `docs/guides/overview.md` ersetzen:
```markdown
`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`, `lit-divi-insert`,
`lit-divi-update`.
```
durch:
```markdown
`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`, `lit-divi-insert`,
`lit-divi-update`, `lit-divi-move`, `lit-divi-delete`.
```
In `docs/guides/overview.md` ersetzen:
```markdown
`lit/media-update`, `lit/media-delete`).
```
durch:
```markdown
`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`).
```
- [ ] **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/DiviMoveAbility.php src/Integrations/Divi/Pages/DiviDeleteAbility.php src/Integrations/Divi/Pages/DiviDuplicateNodeAbility.php src/Integrations/Divi/DiviIntegration.php docs/guides/overview.md tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php 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-move, lit/divi-delete, lit/divi-duplicate-node
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
```
---
### Task 8: Text seitenübergreifend ersetzen – `TextReplacer` und `lit/divi-replace-text`
Spec §5.7 und D10: Probelauf (Standard `dry_run: true`) sucht in den Divi-Seiten eines Inhaltstyps (Standard `page`; nur Einträge, die der Nutzer bearbeiten darf – ohne `edit_others_*` nur eigene, wie `PostGuard::ownPostsOnly`), Vorfilter per SQL auf den Suchtext wie gespeichert und in der JSON-escapten Form (`Serializer::attributes()`), dann exakt im Baum: nur sichtbarer Text (`…innerContent`, Objekt-Schlüssel `text`, `title`, `label`, `alt`, `titleText`), nie URLs, IDs, Stile oder `$variable(…)$`; Groß-/Kleinschreibung wahlweise, auch bei Umlauten. Der Probelauf liefert je Seite `tree_rev`, Anzahl und bis zu fünf Beispiele (vorher/nachher). Ausgeführt wird nur mit `dry_run: false`, `targets` (post_id + tree_rev aus dem Probelauf) und `confirm: true`; jede Seite wird einzeln über `DiviWriter::save()` gespeichert (eigene Revision, Protokoll), veraltete oder fehlerhafte Seiten landen mit Grund in `results` und als Warnung – die anderen laufen weiter (Spec §8.2: Stapel-Tools melden je Eintrag). Ersetzte Werte werden wie jeder andere Wert bereinigt.
**Files:**
- Create: `src/Integrations/Divi/Compose/TextReplacer.php`, `src/Integrations/Divi/Pages/DiviReplaceTextAbility.php`
- Modify: `src/Integrations/Divi/DiviIntegration.php`, `docs/guides/overview.md`
- Test: `tests/Unit/Integrations/Divi/Compose/TextReplacerTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php`
**Interfaces:**
- Consumes: `AttrMap::flatten/isBreakpointMap/set`, `NodeSummary::textOf`, `Placeholders::isVariable`, `Serializer::attributes` (2a/Task 1); `DiviPageLoader::load`, `DiviWriter::page/save`, `DiviChange`, `DiviComposition::attrWriter` (Task 5); `AttrWriter::cleanValue` (Task 1); `PostGuard::postType/ownPostsOnly`, `Cursor`, `ToolResult`, `WriteFailed`, `Errors`.
- Produces:
- `TextReplacer(string $search, string $replace, bool $caseSensitive = true)`: `apply(array $attrs, callable $clean): array{attrs, count: int, samples: list<{path, before, after}>}` mit `$clean(string $path, ?string $key, string $value): string`.
- `DiviReplaceTextAbility(ContentSchema, DiviPageLoader, DiviWriter, DiviComposition, ?\wpdb $db = null)` – `lit/divi-replace-text`, indirekt, destruktiv; Input `{search (≥ 2 Zeichen), replace, post_type?, case_sensitive?, dry_run?, targets?, confirm?, limit?, cursor?}`; Output `ToolResult` + `dry_run`, `matches`, `skipped`, `next_cursor` bzw. `results`.
- [ ] **Step 1: Failing Tests**
`tests/Unit/Integrations/Divi/Compose/TextReplacerTest.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
use LIT\MCP\Integrations\Divi\Compose\TextReplacer;
use PHPUnit\Framework\TestCase;
final class TextReplacerTest extends TestCase
{
private const ATTRS = [
'title' => ['innerContent' => ['desktop' => ['value' => 'Müller GmbH'], 'phone' => ['value' => 'Müller']]],
'content' => ['innerContent' => ['desktop' => ['value' => '<p>Seit 1987: Müller &amp; Söhne. Anruf bei MÜLLER.</p>']]],
'button' => ['innerContent' => ['desktop' => ['value' => ['text' => 'Müller anrufen', 'linkUrl' => '/mueller-kontakt']]]],
'module' => ['meta' => ['adminLabel' => ['desktop' => ['value' => 'Müller-Hero']]]],
'dyn' => ['innerContent' => ['desktop' => ['value' => '$variable({"type":"content","value":{"name":"Müller","settings":{}}})$']]],
];
public function testReplacesVisibleTextOnly(): void
{
$seen = [];
$result = (new TextReplacer('Müller', 'Schmidt'))->apply(self::ATTRS, static function (string $path, ?string $key, string $value) use (&$seen): string {
$seen[] = $path . '|' . ($key ?? '');
return $value;
});
self::assertSame(4, $result['count']);
self::assertSame('Schmidt GmbH', $result['attrs']['title']['innerContent']['desktop']['value']);
self::assertSame('Schmidt', $result['attrs']['title']['innerContent']['phone']['value']);
self::assertSame('<p>Seit 1987: Schmidt &amp; Söhne. Anruf bei MÜLLER.</p>', $result['attrs']['content']['innerContent']['desktop']['value']);
self::assertSame(['text' => 'Schmidt anrufen', 'linkUrl' => '/mueller-kontakt'], $result['attrs']['button']['innerContent']['desktop']['value']);
self::assertSame('Müller-Hero', $result['attrs']['module']['meta']['adminLabel']['desktop']['value']);
self::assertSame(self::ATTRS['dyn'], $result['attrs']['dyn']);
self::assertContains('button.innerContent|text', $seen);
self::assertSame(['path' => 'button.innerContent.text', 'before' => 'Müller anrufen', 'after' => 'Schmidt anrufen'], $result['samples'][0]);
}
public function testCaseInsensitiveIncludingUmlauts(): void
{
$result = (new TextReplacer('müller', 'Schmidt', false))->apply(self::ATTRS, static fn (string $p, ?string $k, string $v): string => $v);
self::assertSame(5, $result['count']);
self::assertStringContainsString('Anruf bei Schmidt.', $result['attrs']['content']['innerContent']['desktop']['value']);
}
public function testNothingFound(): void
{
$result = (new TextReplacer('Meier', 'X'))->apply(self::ATTRS, static fn (string $p, ?string $k, string $v): string => $v);
self::assertSame(0, $result['count']);
self::assertSame(self::ATTRS, $result['attrs']);
}
}
```
`tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\Divi\Pages\DiviReplaceTextAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Tests\Unit\Integrations\Divi\Compose\ComposeFixture;
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class DiviReplaceTextAbilityTest extends TestCase
{
private WP_Post $post;
/** @var list<string> */
private array $saved = [];
/** @var list<string> */
private array $queries = [];
private function ability(): DiviReplaceTextAbility
{
$this->post = DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html'));
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['edit_posts' => 'edit_pages', 'edit_others_posts' => 'edit_others_pages']]);
Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true');
$schema = DiviPageLoaderTest::schema();
$modules = Mockery::mock(DiviModules::class);
$modules->allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog());
$modules->allows('builderVersion')->andReturn('5.2.1');
$design = Mockery::mock(DesignSystemReader::class);
$design->allows('read')->andReturn(DesignSystemTest::sample());
$composition = new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media());
$saver = Mockery::mock(DiviSaver::class);
$saver->allows('save')->andReturnUsing(function (WP_Post $post, string $content): array {
$this->saved[] = $content;
$post->post_content = $content;
return ['revision_id' => 811, 'warnings' => []];
});
$log = Mockery::mock(ChangeLog::class);
$log->allows('record')->andReturn(4711);
$loader = new DiviPageLoader($schema);
$db = Mockery::mock(\wpdb::class)->makePartial();
$db->allows('get_col')->andReturnUsing(function (string $sql): array {
$this->queries[] = $sql;
return [7];
});
return new DiviReplaceTextAbility(
$schema,
$loader,
new DiviWriter($loader, $saver, $log, $design, $composition),
$composition,
$db
);
}
public function testDryRunListsHitsWithTreeRevAndChangesNothing(): void
{
$ability = $this->ability();
$result = $ability->execute(['search' => 'Termin', 'replace' => 'Beratung']);
self::assertTrue($result['dry_run']);
self::assertSame([], $this->saved);
self::assertSame(7, $result['matches'][0]['post_id']);
self::assertSame(Rev::of($this->post->post_content), $result['matches'][0]['tree_rev']);
self::assertSame(1, $result['matches'][0]['count']);
self::assertSame('s0.r0.c1.m2', $result['matches'][0]['samples'][0]['id']);
self::assertSame('Beratung buchen', $result['matches'][0]['samples'][0]['after']);
self::assertStringContainsString("post_type = 'page'", $this->queries[0]);
self::assertStringContainsString("LIKE '%<!-- wp:divi/%'", $this->queries[0]);
self::assertTrue($ability->annotations()['destructive']);
self::assertFalse($ability->isDirect());
}
public function testRealRunNeedsTargetsAndConfirm(): void
{
$ability = $this->ability();
self::assertSame('lit_validation_failed', $ability->execute(['search' => 'Termin', 'replace' => 'X', 'dry_run' => false])->get_error_code());
self::assertSame('lit_confirm_required', $ability->execute([
'search' => 'Termin', 'replace' => 'X', 'dry_run' => false, 'targets' => [['post_id' => 7, 'tree_rev' => 'abc']],
])->get_error_code());
self::assertSame([], $this->saved);
}
public function testReplacesPerPageAndReportsStaleOnes(): void
{
$ability = $this->ability();
$treeRev = Rev::of($this->post->post_content);
$result = $ability->execute([
'search' => 'termin',
'replace' => 'Rückruf',
'case_sensitive' => false,
'dry_run' => false,
'confirm' => true,
'targets' => [['post_id' => 7, 'tree_rev' => $treeRev], ['post_id' => 7, 'tree_rev' => $treeRev]],
]);
self::assertFalse($result['dry_run']);
self::assertCount(1, $this->saved);
self::assertStringContainsString('"text":"Rückruf buchen"', $this->saved[0]);
self::assertTrue($result['results'][0]['ok']);
self::assertSame(1, $result['results'][0]['count']);
self::assertSame(811, $result['results'][0]['revision_id']);
self::assertFalse($result['results'][1]['ok'], 'second target has a stale tree_rev now');
self::assertSame([['id' => '7', 'type' => 'page', 'action' => 'replaced', 'label' => 'Start']], $result['changed']);
self::assertStringContainsString('Eintrag 7:', $result['warnings'][0]);
}
}
```
In `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` ersetzen:
```php
'lit/divi-duplicate-node',
];
```
durch:
```php
'lit/divi-duplicate-node',
'lit/divi-replace-text',
];
```
- [ ] **Step 2: Test laufen lassen (rot)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'TextReplacerTest|DiviReplaceTextAbilityTest|DiviIntegrationTest'`
Expected: FAIL – `Class "LIT\MCP\Integrations\Divi\Compose\TextReplacer" not found`.
- [ ] **Step 3: Implementierung**
`src/Integrations/Divi/Compose/TextReplacer.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Compose;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
/**
* Replaces visible text in the attributes of one block (lit/divi-replace-text, spec §5.7):
* only in content (…innerContent) – plain values and the text keys of object values
* (text, title, label, alt, titleText) – never in URLs, IDs, styles or $variable(…)$
* references. Pure PHP.
*/
final class TextReplacer
{
private const TEXT_KEYS = ['text', 'title', 'label', 'alt', 'titleText'];
private const CONTEXT = 30;
public function __construct(private string $search, private string $replace, private bool $caseSensitive = true)
{
}
/**
* @param array<string, mixed> $attrs
* @param callable(string $path, ?string $key, string $value): string $clean Sanitizes a changed value.
* @return array{attrs: array<string, mixed>, count: int,
* samples: list<array{path: string, before: string, after: string}>}
*/
public function apply(array $attrs, callable $clean): array
{
$count = 0;
$samples = [];
foreach (AttrMap::flatten($attrs) as $path => $map) {
if (! str_ends_with($path, '.innerContent') || ! AttrMap::isBreakpointMap($map)) {
continue;
}
foreach ($map as $breakpoint => $states) {
foreach (is_array($states) ? $states : [] as $state => $value) {
$replaced = $this->value($value, $path, $clean, $count, $samples);
if ($replaced !== $value) {
$attrs = AttrMap::set($attrs, sprintf('%s.%s.%s', $path, $breakpoint, $state), $replaced);
}
}
}
}
return ['attrs' => $attrs, 'count' => $count, 'samples' => $samples];
}
/**
* @param callable(string, ?string, string): string $clean
* @param list<array{path: string, before: string, after: string}> $samples
*/
private function value(mixed $value, string $path, callable $clean, int &$count, array &$samples): mixed
{
if (is_string($value)) {
return $this->text($value, $path, null, $clean, $count, $samples);
}
if (is_array($value)) {
foreach (self::TEXT_KEYS as $key) {
if (is_string($value[$key] ?? null)) {
$value[$key] = $this->text($value[$key], $path, $key, $clean, $count, $samples);
}
}
}
return $value;
}
/**
* @param callable(string, ?string, string): string $clean
* @param list<array{path: string, before: string, after: string}> $samples
*/
private function text(
string $value,
string $path,
?string $key,
callable $clean,
int &$count,
array &$samples
): string {
if ($this->search === '' || Placeholders::isVariable($value)) {
return $value;
}
$pattern = '/' . preg_quote($this->search, '/') . '/u' . ($this->caseSensitive ? '' : 'i');
$found = (int) preg_match_all($pattern, $value);
if ($found === 0) {
return $value;
}
$replaced = (string) preg_replace_callback($pattern, fn (): string => $this->replace, $value);
$replaced = $clean($path, $key, $replaced);
$count += $found;
if (count($samples) < 5) {
$samples[] = [
'path' => $path . ($key === null ? '' : '.' . $key),
'before' => $this->excerpt(NodeSummary::textOf($value)),
'after' => $this->excerpt(NodeSummary::textOf($replaced), $this->replace),
];
}
return $replaced;
}
private function excerpt(string $text, ?string $needle = null): string
{
$needle ??= $this->search;
$position = $this->caseSensitive ? mb_strpos($text, $needle) : mb_stripos($text, $needle);
if ($position === false || $needle === '') {
return mb_substr($text, 0, 2 * self::CONTEXT);
}
$start = max(0, $position - self::CONTEXT);
return ($start > 0 ? '…' : '') . mb_substr($text, $start, mb_strlen($needle) + 2 * self::CONTEXT)
. ($start + mb_strlen($needle) + 2 * self::CONTEXT < mb_strlen($text) ? '…' : '');
}
}
```
`src/Integrations/Divi/Pages/DiviReplaceTextAbility.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Cursor;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Compose\TextReplacer;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
/**
* lit/divi-replace-text: replaces visible text across the Divi pages of one content type
* (spec §5.7). dry_run (default true) only lists the hits with their tree_rev; the real run
* takes exactly those pages as targets (post_id + tree_rev) and needs confirm: true. Each page
* is saved on its own (revision, change log); the result reports every page (spec §8.2).
*/
final class DiviReplaceTextAbility implements Ability
{
private const MAX_TARGETS = 20;
public function __construct(
private ContentSchema $schema,
private DiviPageLoader $loader,
private DiviWriter $writer,
private DiviComposition $composition,
private ?\wpdb $db = null
) {
}
public function name(): string
{
return 'lit/divi-replace-text';
}
public function label(): string
{
return 'Text auf Divi-Seiten ersetzen';
}
public function description(): string
{
return 'Ersetzt sichtbaren Text (Inhalte, Titel, Button-Texte, Alt-Texte – keine URLs oder Stile) auf den '
. 'Divi-Seiten eines Inhaltstyps. Standard ist dry_run: true – liefert die Treffer je Seite mit tree_rev. '
. 'Zum Ersetzen dry_run: false, targets = [{post_id, tree_rev}] aus dem Probelauf und confirm: true nach '
. 'Rückfrage beim Nutzer. Jede Seite erhält eine eigene Revision.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => array_merge([
'search' => ['type' => 'string', 'minLength' => 2],
'replace' => ['type' => 'string'],
'post_type' => ['type' => 'string', 'description' => 'Inhaltstyp, Standard "page".'],
'case_sensitive' => ['type' => 'boolean', 'description' => 'Standard true.'],
'dry_run' => ['type' => 'boolean', 'description' => 'Standard true: nur Treffer anzeigen.'],
'targets' => [
'type' => 'array',
'maxItems' => self::MAX_TARGETS,
'items' => [
'type' => 'object',
'properties' => [
'post_id' => ['type' => 'integer', 'minimum' => 1],
'tree_rev' => ['type' => 'string'],
],
'required' => ['post_id', 'tree_rev'],
'additionalProperties' => false,
],
],
'confirm' => ['type' => 'boolean'],
], Cursor::inputProperties()),
'required' => ['search', 'replace'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema([
'dry_run' => ['type' => 'boolean'],
'matches' => ['type' => 'array', 'items' => ['type' => 'object']],
'skipped' => ['type' => 'array', 'items' => ['type' => 'object']],
'next_cursor' => ['type' => ['string', 'null']],
'results' => ['type' => 'array', 'items' => ['type' => 'object']],
], ['dry_run']);
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => true, 'idempotent' => false];
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
$type = PostGuard::postType($input['post_type'] ?? 'page', $this->schema, 'edit_posts');
return $type instanceof WP_Error ? $type : true;
}
public function execute(array $input): mixed
{
$postType = is_string($input['post_type'] ?? null) ? $input['post_type'] : 'page';
$type = PostGuard::postType($postType, $this->schema, 'edit_posts');
if ($type instanceof WP_Error) {
return $type;
}
$replacer = new TextReplacer(
(string) ($input['search'] ?? ''),
(string) ($input['replace'] ?? ''),
($input['case_sensitive'] ?? true) !== false
);
return ($input['dry_run'] ?? true) !== false
? $this->dryRun($postType, $replacer, $input)
: $this->run($replacer, $input);
}
/** @param array<string, mixed> $input */
private function dryRun(string $postType, TextReplacer $replacer, array $input): array|WP_Error
{
$offset = Cursor::decode(is_string($input['cursor'] ?? null) ? $input['cursor'] : null);
if ($offset === null) {
return Cursor::invalid();
}
$limit = Cursor::limit($input['limit'] ?? null, 10);
$candidates = $this->candidates($postType, (string) $input['search'], $limit + 1, $offset);
$page = Cursor::page($candidates, $limit, $offset);
$matches = [];
$skipped = [];
$total = 0;
$unchanged = static fn (string $path, ?string $key, string $value): string => $value;
foreach ($page['items'] as $postId) {
$divi = $this->loader->load((int) $postId, true);
if ($divi instanceof WP_Error) {
$skipped[] = ['post_id' => (int) $postId, 'reason' => $divi->get_error_message()];
continue;
}
$count = 0;
$samples = [];
foreach ($divi->index->entries() as $entry) {
$block = $entry['block'];
if (! $block->isDivi() || $block->isOpaque()) {
continue;
}
$result = $replacer->apply($block->attrs(), $unchanged);
$count += $result['count'];
foreach ($result['samples'] as $sample) {
$samples[] = ['id' => $entry['id']] + $sample;
}
}
if ($count > 0) {
$total += $count;
$matches[] = [
'post_id' => (int) $divi->post->ID,
'title' => (string) $divi->post->post_title,
'status' => (string) $divi->post->post_status,
'tree_rev' => $divi->treeRev(),
'count' => $count,
'samples' => array_slice($samples, 0, 5),
];
}
}
return ToolResult::ok(sprintf(
'Probelauf: %d Treffer auf %d Seiten – nichts geändert.%s',
$total,
count($matches),
$page['next_cursor'] === null ? '' : ' Weitere Seiten mit next_cursor prüfen.'
))
->with('dry_run', true)
->with('matches', $matches)
->with('skipped', $skipped)
->with('next_cursor', $page['next_cursor'])
->toArray();
}
/** @param array<string, mixed> $input */
private function run(TextReplacer $replacer, array $input): array|WP_Error
{
$targets = is_array($input['targets'] ?? null) ? $input['targets'] : [];
if ($targets === []) {
return Errors::invalidInput(
'targets fehlt; nichts wurde geändert.',
['targets' => 'post_id und tree_rev je Seite aus dem Probelauf (dry_run: true)']
);
}
if (($input['confirm'] ?? false) !== true) {
return Errors::confirmRequired(sprintf(
'"%s" würde auf %d Seiten durch "%s" ersetzt.',
(string) $input['search'],
count($targets),
(string) $input['replace']
));
}
$results = array_map(fn (mixed $target): array => $this->replaceOn($replacer, (array) $target), $targets);
$replaced = array_filter($results, static fn (array $entry): bool => $entry['ok'] && $entry['count'] > 0);
$tool = ToolResult::ok(sprintf(
'Text auf %d von %d Seiten ersetzt (%d Stellen).',
count($replaced),
count($results),
array_sum(array_column($replaced, 'count'))
));
foreach ($results as $entry) {
if (! $entry['ok']) {
$tool->warning(sprintf('Eintrag %d: %s', $entry['post_id'], $entry['error']));
} elseif ($entry['count'] > 0) {
$tool->changed((string) $entry['post_id'], $entry['type'], 'replaced', $entry['title']);
}
}
return $tool->with('dry_run', false)->with('results', array_values($results))->toArray();
}
/**
* @param array<string, mixed> $target
* @return array<string, mixed>
*/
private function replaceOn(TextReplacer $replacer, array $target): array
{
$postId = (int) ($target['post_id'] ?? 0);
$input = ['post_id' => $postId, 'tree_rev' => $target['tree_rev'] ?? null];
$page = $this->writer->page($input);
if ($page instanceof WP_Error) {
return ['post_id' => $postId, 'ok' => false, 'error' => $page->get_error_message()];
}
$attrWriter = $this->composition->attrWriter();
$count = 0;
foreach ($page->index->entries() as $entry) {
$block = $entry['block'];
if (! $block->isDivi() || $block->isOpaque()) {
continue;
}
$clean = static fn (string $path, ?string $key, string $value): string
=> (string) $attrWriter->cleanValue($block->name(), $path, $value, $key);
$result = $replacer->apply($block->attrs(), $clean);
if ($result['count'] > 0) {
$block->setAttrs($result['attrs']);
$count += $result['count'];
}
}
$base = [
'post_id' => $postId,
'type' => (string) $page->post->post_type,
'title' => (string) $page->post->post_title,
];
if ($count === 0) {
return $base + ['ok' => true, 'count' => 0, 'tree_rev' => $page->treeRev()];
}
try {
$change = new DiviChange(sprintf('Text ersetzt (%d Stellen)', $count));
$saved = $this->writer->save($this->name(), $page, $change, $input);
} catch (WriteFailed $e) {
$message = sprintf('[wp-lit-mcp] %s on post %d failed: %s', $this->name(), $postId, $e);
error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
return $base + ['ok' => false, 'error' => $e->toError()->get_error_message()];
}
return $base + [
'ok' => true,
'count' => $count,
'tree_rev' => $saved['tree_rev'],
'revision_id' => $saved['revision_id'],
'log_id' => $saved['log_id'],
];
}
/** @return list<int> IDs of entries whose content contains the search text (as stored or JSON-escaped). */
private function candidates(string $postType, string $search, int $limit, int $offset): array
{
$db = $this->db();
$escaped = substr(Serializer::attributes(['s' => $search]), 6, -2);
$sql = "SELECT ID FROM {$db->posts} WHERE post_type = %s"
. " AND post_status NOT IN ('trash', 'auto-draft', 'inherit')"
. ' AND post_content LIKE %s AND (post_content LIKE %s OR post_content LIKE %s)';
$args = [
$postType,
'%' . $db->esc_like('<!-- wp:divi/') . '%',
'%' . $db->esc_like($search) . '%',
'%' . $db->esc_like($escaped) . '%',
];
$author = PostGuard::ownPostsOnly($postType);
if ($author !== null) {
$sql .= ' AND post_author = %d';
$args[] = $author;
}
$sql .= ' ORDER BY ID ASC LIMIT %d OFFSET %d';
array_push($args, $limit, $offset);
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $sql holds only our own table name and placeholders.
$ids = $db->get_col($db->prepare($sql, ...$args));
return array_map('intval', is_array($ids) ? $ids : []);
}
private function db(): \wpdb
{
if ($this->db === null) {
global $wpdb;
$this->db = $wpdb;
}
return $this->db;
}
}
```
In `src/Integrations/Divi/DiviIntegration.php` ersetzen:
```php
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
```
durch:
```php
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\Divi\Pages\DiviReplaceTextAbility;
```
In `src/Integrations/Divi/DiviIntegration.php` ersetzen:
```php
new DiviDuplicateNodeAbility($writer, $this->composition),
];
```
durch:
```php
new DiviDuplicateNodeAbility($writer, $this->composition),
new DiviReplaceTextAbility($this->schema, $loader, $writer, $this->composition),
];
```
In `docs/guides/overview.md` ersetzen:
```markdown
`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`).
```
durch:
```markdown
`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`).
```
- [ ] **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/Compose/TextReplacer.php src/Integrations/Divi/Pages/DiviReplaceTextAbility.php src/Integrations/Divi/DiviIntegration.php docs/guides/overview.md tests/Unit/Integrations/Divi/Compose/TextReplacerTest.php tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php 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-replace-text with dry run and per-page tree_rev targets
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
```
---
### Task 9: `lit/content-create` mit Divi-Kurzform
Spec §6.2 (aus Etappe 1a hierher verschoben): „optional Divi-Inhalt in Kurzform“. Neue Eingabe `divi` = Liste von Abschnitten in derselben Form wie bei `lit-divi-insert`; schließt `content` aus; nur wenn Divi 5 aktiv ist und der Divi-Builder für den Typ eingeschaltet ist. Alles wird vor dem Einfügen geprüft und als Divi-Layout (in `divi/placeholder` wie beim Visual Builder) erzeugt; nach dem Einfügen setzt der Composer die Divi-Flags – scheitert das, wird der neue Entwurf wieder gelöscht (bestehende Rollback-Logik von `lit/content-create`). Die Antwort enthält zusätzlich `tree_rev` und die Qualitätswarnungen. Damit die WordPress-Integration nicht von Divi abhängt, gibt es die Schnittstelle `ContentComposer`; `DiviIntegration::contentComposer()` liefert die Divi-Umsetzung. Gespeichert wird hier mit WordPress' üblicher Filterung (D11 – nur neuer, selbst erzeugter Inhalt).
**Files:**
- Create: `src/Integrations/WordPress/Content/ContentComposer.php`, `src/Integrations/Divi/Pages/DiviContentComposer.php`
- Modify: `src/Integrations/WordPress/Content/ContentCreateAbility.php`, `src/Integrations/WordPress/WordPressIntegration.php`, `src/Integrations/Divi/DiviIntegration.php`, `src/Plugin.php`
- Test: `tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php`
**Interfaces:**
- Consumes: `DiviComposition::composer/warnings` (Task 5), `Composer::compose` (Task 3), `ContentSchema::diviEnabled`, `DiviModules::isAvailable`, `Block::create`, `Document`, `Serializer::document`, `NodeIndex::build`, `Errors`, `Rev::of`.
- Produces:
- `interface ContentComposer { available(): bool; compose(string $postType, array $sections): array{content: string, warnings: list<string>}|WP_Error; afterInsert(int $postId): void; }`
- `DiviContentComposer(ContentSchema, DiviComposition) implements ContentComposer`.
- `ContentCreateAbility::__construct(ContentSchema, PostWriter, ChangeLog, ?ContentComposer $divi = null)`; Eingabe `divi` (1–30 Objekte); Ergebnis bei `divi` zusätzlich `tree_rev`.
- `WordPressIntegration::__construct(ContentSchema, ChangeLog, ?ContentComposer $divi = null)`; `DiviIntegration::contentComposer(): ContentComposer`.
- [ ] **Step 1: Failing Tests**
`tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Content;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility;
use LIT\MCP\Integrations\WordPress\Content\PostWriter;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
use WP_Post;
final class ContentCreateDiviTest extends TestCase
{
private const MARKUP = '<!-- wp:divi/placeholder --><!-- wp:divi/section --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->';
private const INPUT = ['post_type' => 'page', 'title' => 'Start', 'divi' => [['type' => 'section']]];
private function schema(): ContentSchema
{
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['create_posts' => 'edit_pages']]);
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_current_user_id')->justReturn(3);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->andReturn(true);
return $schema;
}
private function writer(): PostWriter
{
$writer = Mockery::mock(PostWriter::class);
$writer->allows('prepare')->andReturn(['fields' => [], 'terms' => [], 'warnings' => []]);
$writer->allows('postData')->andReturn(['post_title' => 'Start']);
$writer->allows('deniedMeta')->andReturn([]);
$writer->allows('writeExtras');
$writer->allows('deleteCreatedTerms');
return $writer;
}
private function composer(bool $available = true): ContentComposer
{
$composer = Mockery::mock(ContentComposer::class);
$composer->allows('available')->andReturn($available);
$composer->allows('compose')->with('page', [['type' => 'section']])
->andReturn(['content' => self::MARKUP, 'warnings' => ['s0: leer (section ohne Inhalt).']]);
return $composer;
}
public function testCreatesADiviDraftWithTreeRev(): void
{
$composer = $this->composer();
$composer->expects('afterInsert')->with(42);
Functions\expect('wp_insert_post')->once()->with([
'post_title' => 'Start',
'post_type' => 'page',
'post_status' => 'draft',
'post_author' => 3,
'post_content' => self::MARKUP,
], true)->andReturn(42);
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 42, 'post_title' => 'Start', 'post_type' => 'page', 'post_content' => self::MARKUP]));
Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=42&preview=true');
Functions\when('get_edit_post_link')->justReturn('https://kunde.test/wp-admin/post.php?post=42&action=edit');
$log = Mockery::mock(ChangeLog::class);
$log->allows('record')->andReturn(9);
$result = (new ContentCreateAbility($this->schema(), $this->writer(), $log, $composer))->execute(self::INPUT);
self::assertTrue($result['ok']);
self::assertSame(Rev::of(self::MARKUP), $result['tree_rev']);
self::assertSame(['s0: leer (section ohne Inhalt).'], $result['warnings']);
}
public function testDiviAndContentExcludeEachOtherAndNeedDivi(): void
{
Functions\expect('wp_insert_post')->never();
$both = (new ContentCreateAbility($this->schema(), $this->writer(), Mockery::mock(ChangeLog::class), $this->composer()))
->execute(self::INPUT + ['content' => '<p>x</p>']);
self::assertSame('lit_validation_failed', $both->get_error_code());
$inactive = (new ContentCreateAbility($this->schema(), $this->writer(), Mockery::mock(ChangeLog::class), $this->composer(false)))
->execute(self::INPUT);
self::assertSame('lit_unsupported', $inactive->get_error_code());
$none = (new ContentCreateAbility($this->schema(), $this->writer(), Mockery::mock(ChangeLog::class)))->execute(self::INPUT);
self::assertSame('lit_unsupported', $none->get_error_code());
$composer = Mockery::mock(ContentComposer::class);
$composer->allows('available')->andReturn(true);
$composer->allows('compose')->andReturn(Errors::invalidInput('Divi-Inhalt ist ungültig; nichts wurde angelegt.'));
$invalid = (new ContentCreateAbility($this->schema(), $this->writer(), Mockery::mock(ChangeLog::class), $composer))
->execute(self::INPUT);
self::assertSame('lit_validation_failed', $invalid->get_error_code());
}
public function testFailingBuilderFlagsDiscardTheDraft(): void
{
$composer = $this->composer();
$composer->allows('afterInsert')->andThrow(new RuntimeException('meta failed'));
Functions\when('wp_insert_post')->justReturn(42);
Functions\expect('wp_delete_post')->once()->with(42, true)->andReturn(new WP_Post(['ID' => 42]));
try {
(new ContentCreateAbility($this->schema(), $this->writer(), Mockery::mock(ChangeLog::class), $composer))
->execute(self::INPUT);
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
}
}
}
```
`tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Tests\Unit\Integrations\Divi\Compose\ComposeFixture;
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
final class DiviContentComposerTest extends TestCase
{
private function composer(bool $diviEnabled = true): DiviContentComposer
{
$modules = Mockery::mock(DiviModules::class);
$modules->allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog());
$modules->allows('builderVersion')->andReturn('5.2.1');
$design = Mockery::mock(DesignSystemReader::class);
$design->allows('read')->andReturn(DesignSystemTest::sample());
return new DiviContentComposer(
DiviPageLoaderTest::schema($diviEnabled),
new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media())
);
}
public function testComposesAPlaceholderWrappedLayout(): void
{
$result = $this->composer()->compose('page', [
['type' => 'section', 'label' => 'Hero', 'modules' => [['type' => 'heading', 'text' => 'Willkommen']]],
['type' => 'section', 'modules' => [['type' => 'image', 'src' => 'https://kunde.test/a.jpg']]],
]);
self::assertStringStartsWith('<!-- wp:divi/placeholder --><!-- wp:divi/section {"builderVersion":"5.2.1"', $result['content']);
self::assertStringEndsWith('<!-- /wp:divi/section --><!-- /wp:divi/placeholder -->', $result['content']);
$document = (new Parser())->parse($result['content']);
self::assertCount(2, $document->children()[0]->children());
self::assertSame(['s1.r0.c0.m0: Bild ohne Alt-Text.'], $result['warnings']);
}
public function testRefusesTypesWithoutBuilderAndInvalidNodes(): void
{
self::assertSame('lit_validation_failed', $this->composer(false)->compose('post', [['type' => 'section']])->get_error_code());
$invalid = $this->composer()->compose('page', [['type' => 'section', 'modules' => [['type' => 'button', 'attrs' => ['x.y' => ['desktop' => 1]]]]]]);
self::assertSame('lit_invalid_attr_path', $invalid->get_error_code());
self::assertArrayHasKey('divi[0].modules[0].modules[0][0].attrs.x.y', $invalid->get_error_data()['fields']);
}
public function testAfterInsertSetsTheBuilderFlags(): void
{
$flags = [];
Functions\when('update_post_meta')->alias(static function (int $id, string $key, string $value) use (&$flags): int {
$flags[$key] = $value;
return 1;
});
$this->composer()->afterInsert(42);
self::assertSame(['_et_pb_use_divi_5' => 'on', '_et_pb_use_builder' => 'on'], $flags);
Functions\when('update_post_meta')->justReturn(false);
$this->expectException(RuntimeException::class);
$this->composer()->afterInsert(42);
}
}
```
- [ ] **Step 2: Test laufen lassen (rot)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'ContentCreateDiviTest|DiviContentComposerTest'`
Expected: FAIL – `Interface "LIT\MCP\Integrations\WordPress\Content\ContentComposer" not found`.
- [ ] **Step 3: Implementierung**
`src/Integrations/WordPress/Content/ContentComposer.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Content;
use WP_Error;
/**
* Builds post_content for lit/content-create from a structured description (spec §6.2:
* "optional Divi-Inhalt in Kurzform"). Implemented by the Divi integration, so the WordPress
* integration does not depend on Divi.
*/
interface ContentComposer
{
/** Whether composing is possible on this site right now (e.g. Divi 5 active). */
public function available(): bool;
/**
* Validates and builds the content. Nothing is written.
*
* @param list<mixed> $sections
* @return array{content: string, warnings: list<string>}|WP_Error
*/
public function compose(string $postType, array $sections): array|WP_Error;
/**
* Runs after the new draft exists (e.g. builder flags). Throwing makes lit/content-create
* delete the draft again (all or nothing).
*/
public function afterInsert(int $postId): void;
}
```
`src/Integrations/Divi/Pages/DiviContentComposer.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\Document;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use RuntimeException;
use WP_Error;
/**
* Divi short form for lit/content-create (spec §6.2): the sections become a Divi 5 layout
* wrapped in divi/placeholder like the Visual Builder saves it; afterwards the builder flags
* are set.
*/
final class DiviContentComposer implements ContentComposer
{
public function __construct(private ContentSchema $schema, private DiviComposition $composition)
{
}
public function available(): bool
{
return DiviModules::isAvailable();
}
public function compose(string $postType, array $sections): array|WP_Error
{
if (! $this->schema->diviEnabled($postType)) {
return Errors::invalidInput(
'Für diesen Inhaltstyp ist der Divi-Builder nicht aktiviert; nichts wurde angelegt.',
['divi' => 'Divi-Builder für "' . $postType . '" nicht aktiv']
);
}
$composed = $this->composition->composer()->compose(array_values($sections), null, 'divi');
if ($composed['errors'] !== []) {
return $composed['invalid_paths'] !== []
? Errors::invalidAttrPath($composed['errors'])
: Errors::invalidInput('Divi-Inhalt ist ungültig; nichts wurde angelegt.', $composed['errors']);
}
$document = new Document();
$document->append(Block::create('divi/placeholder', [], $composed['blocks']));
return [
'content' => Serializer::document($document),
'warnings' => $this->composition->warnings()->collect(NodeIndex::build($document), $composed['blocks']),
];
}
public function afterInsert(int $postId): void
{
if (
update_post_meta($postId, '_et_pb_use_divi_5', 'on') === false
|| update_post_meta($postId, '_et_pb_use_builder', 'on') === false
) {
throw new RuntimeException(sprintf('Divi builder flags of post %d could not be set.', $postId));
}
}
}
```
In `src/Integrations/WordPress/Content/ContentCreateAbility.php` ersetzen:
```php
use LIT\MCP\Abilities\Errors;
```
durch:
```php
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rev;
```
In `src/Integrations/WordPress/Content/ContentCreateAbility.php` ersetzen:
```php
private PostWriter $writer,
private ChangeLog $log
) {
}
```
durch:
```php
private PostWriter $writer,
private ChangeLog $log,
private ?ContentComposer $divi = null
) {
}
```
In `src/Integrations/WordPress/Content/ContentCreateAbility.php` ersetzen:
```php
. 'in einem Schritt; bei einem Fehler wird nichts angelegt.';
```
durch:
```php
. 'in einem Schritt; bei einem Fehler wird nichts angelegt. Divi-Seiten direkt mit Inhalt: divi = Liste '
. 'von Abschnitten in Kurzform (lit-guide topic "divi-shortform"), statt content.';
```
In `src/Integrations/WordPress/Content/ContentCreateAbility.php` ersetzen:
```php
'title' => ['type' => 'string', 'minLength' => 1],
], PostWriter::inputProperties()),
```
durch:
```php
'title' => ['type' => 'string', 'minLength' => 1],
'divi' => [
'type' => 'array',
'minItems' => 1,
'maxItems' => 30,
'items' => ['type' => 'object'],
'description' => 'Divi-Layout in Kurzform: Abschnitte wie bei lit-divi-insert (nicht zusammen mit '
. 'content).',
],
], PostWriter::inputProperties()),
```
In `src/Integrations/WordPress/Content/ContentCreateAbility.php` ersetzen:
```php
'post_author' => get_current_user_id(),
]);
```
durch:
```php
'post_author' => get_current_user_id(),
]);
$warnings = $prepared['warnings'];
if (array_key_exists('divi', $input)) {
$divi = $this->composeDivi($postType, $input);
if ($divi instanceof WP_Error) {
return $divi;
}
$data['post_content'] = $divi['content'];
$warnings = array_merge($warnings, $divi['warnings']);
}
```
In `src/Integrations/WordPress/Content/ContentCreateAbility.php` ersetzen:
```php
try {
$this->writer->writeExtras($postId, $postType, $prepared, $input);
} catch (Throwable $e) {
$this->discard($postId, $postType, $e);
}
return $this->result($postId, $postType, $input, $prepared['warnings']);
}
```
durch:
```php
try {
$this->writer->writeExtras($postId, $postType, $prepared, $input);
if (array_key_exists('divi', $input) && $this->divi !== null) {
$this->divi->afterInsert($postId);
}
} catch (Throwable $e) {
$this->discard($postId, $postType, $e);
}
return $this->result($postId, $postType, $input, $warnings);
}
/**
* Divi layout from the short form (spec §6.2) – validated completely before the insert.
*
* @param array<string, mixed> $input
* @return array{content: string, warnings: list<string>}|WP_Error
*/
private function composeDivi(string $postType, array $input): array|WP_Error
{
if ($this->divi === null || ! $this->divi->available()) {
return Errors::unsupported(
'Divi-Inhalt ist auf dieser Website nicht möglich (Divi 5 ist nicht aktiv); nichts wurde angelegt.',
'Ohne divi anlegen und den Inhalt über content oder im Editor setzen.'
);
}
if (array_key_exists('content', $input)) {
return Errors::invalidInput(
'content und divi schließen sich aus; nichts wurde angelegt.',
['content' => 'bei Divi-Inhalt weglassen']
);
}
return $this->divi->compose($postType, is_array($input['divi']) ? array_values($input['divi']) : []);
}
```
In `src/Integrations/WordPress/Content/ContentCreateAbility.php` ersetzen:
```php
return $change->result(sprintf('"%s" als Entwurf angelegt (ID %d).', $title, $postId), $post)
->changed((string) $postId, $postType, 'created', $title)
->urls((string) get_preview_post_link($postId), (string) get_edit_post_link($postId, 'raw'))
->toArray();
```
durch:
```php
$result = $change->result(sprintf('"%s" als Entwurf angelegt (ID %d).', $title, $postId), $post)
->changed((string) $postId, $postType, 'created', $title)
->urls((string) get_preview_post_link($postId), (string) get_edit_post_link($postId, 'raw'));
if (array_key_exists('divi', $input) && $post instanceof WP_Post) {
// The Divi tools lock on tree_rev (ruling P16): the next lit-divi-* call can use it right away.
$result->with('tree_rev', Rev::of((string) $post->post_content));
}
return $result->toArray();
```
In `src/Integrations/WordPress/WordPressIntegration.php` ersetzen:
```php
use LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility;
```
durch:
```php
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility;
```
In `src/Integrations/WordPress/WordPressIntegration.php` ersetzen:
```php
public function __construct(private ContentSchema $schema, private ChangeLog $log)
{
}
```
durch:
```php
public function __construct(
private ContentSchema $schema,
private ChangeLog $log,
private ?ContentComposer $divi = null
) {
}
```
In `src/Integrations/WordPress/WordPressIntegration.php` ersetzen:
```php
new ContentCreateAbility($this->schema, $writer, $this->log),
```
durch:
```php
new ContentCreateAbility($this->schema, $writer, $this->log, $this->divi),
```
In `src/Integrations/Divi/DiviIntegration.php` ersetzen:
```php
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
```
durch:
```php
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
```
In `src/Integrations/Divi/DiviIntegration.php` ersetzen:
```php
use LIT\MCP\Integrations\Integration;
```
durch:
```php
use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
```
In `src/Integrations/Divi/DiviIntegration.php` ersetzen:
```php
* Divi 5 pages (spec §5, Etappe 2). Only available when the Divi 5 builder is loaded
* (spec §2 principle 4); Divi 4 is never edited (spec §3.4).
*/
```
durch:
```php
* Divi 5 pages (spec §5, Etappe 2). Only available when the Divi 5 builder is loaded
* (spec §2 principle 4); Divi 4 is never edited (spec §3.4). contentComposer() gives
* lit/content-create the Divi short form (spec §6.2).
*/
```
In `src/Integrations/Divi/DiviIntegration.php` ersetzen:
```php
public function abilities(): array
{
$loader
```
durch:
```php
public function contentComposer(): ContentComposer
{
return new DiviContentComposer($this->schema, $this->composition);
}
public function abilities(): array
{
$loader
```
In `src/Plugin.php` ersetzen:
```php
$schema = self::contentSchema();
$registry = new IntegrationRegistry(
new CoreIntegration(self::pluginDir() . '/docs/guides'),
new WordPressIntegration($schema, $changeLog),
new MediaIntegration($schema, $changeLog, $limits),
new DiviIntegration($schema, $changeLog)
);
```
durch:
```php
$schema = self::contentSchema();
$divi = new DiviIntegration($schema, $changeLog);
$registry = new IntegrationRegistry(
new CoreIntegration(self::pluginDir() . '/docs/guides'),
new WordPressIntegration($schema, $changeLog, $divi->contentComposer()),
new MediaIntegration($schema, $changeLog, $limits),
$divi
);
```
- [ ] **Step 4: Tests und Lint (grün)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test && composer lint`
Expected: PASS (auch die bestehenden `ContentCreateAbilityTest`-Fälle ohne `divi`), phpcs ohne Meldungen.
- [ ] **Step 5: Commit**
```bash
cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
git add src/Integrations/WordPress/Content/ContentComposer.php src/Integrations/Divi/Pages/DiviContentComposer.php src/Integrations/WordPress/Content/ContentCreateAbility.php src/Integrations/WordPress/WordPressIntegration.php src/Integrations/Divi/DiviIntegration.php src/Plugin.php tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: lit/content-create with Divi short form
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
```
---
### Task 10: Hinweise, Guides, Version 0.3.0, Ende-zu-Ende-Test und Abnahme
Die Hinweise nennen jetzt die Schreib-Tools (`PostPresenter::DIVI_NOTE`, Outline-Hinweise, Katalog-Hinweis), die Serveranweisungen bekommen die `tree_rev`-Regel (Spec §9.1), zwei neue Guides (Spec §9.2: `divi-shortform`, `divi-placeholders`) und Ergänzungen in `divi-structure`, `divi-attrs`, `overview`, `workflows`, `content-types`. Version 0.3.0 mit Readme (Auswirkungen auf die Website: kses-Umgehung beim Divi-Speichern und Divi-Cache; Rückgängig über Revisionen). Ein Ende-zu-Ende-Skript prüft alle Schreib-Tools gegen echtes Divi über den OAuth-Endpunkt – einschließlich eines Benutzers ohne `unfiltered_html`.
**Files:**
- Modify: `src/Integrations/WordPress/Content/PostPresenter.php`, `src/Integrations/Divi/Pages/DiviOutlineAbility.php`, `src/Integrations/Divi/Schema/DiviCatalogAbility.php`, `src/Guides/InstructionsBuilder.php`, `src/Plugin.php`, `wp-lit-mcp.php`, `readme.txt`, `docs/guides/{divi-structure,divi-attrs,overview,workflows,content-types}.md`
- Create: `docs/guides/divi-shortform.md`, `docs/guides/divi-placeholders.md`, `tests/Integration/divi-write-smoke.sh`
- Test: `tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php`, `tests/Unit/Guides/InstructionsBuilderTest.php`
**Interfaces:**
- Consumes: alle Schreib-Tools (Tasks 6–9); `tests/Integration/lib.sh` (`connect`, `rpc`, `tool`, `ability`, `error_code`, `cleanup_app_passwords_for`).
- Produces: `PostPresenter::DIVI_NOTE` (nennt `lit-divi-insert`, `lit-divi-update`, `lit-divi-move`, `lit-divi-delete`); Serveranweisungen mit Zeile `- Divi-Änderungen verlangen die tree_rev …`; `Plugin::VERSION = '0.3.0'`.
- [ ] **Step 1: Failing Tests**
In `tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php` ersetzen:
```php
$ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design());
self::assertSame(['Die Seite hat noch keinen Divi-Inhalt.'], $ability->execute(['post_id' => 7])['hints']);
```
durch:
```php
$ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design());
self::assertStringContainsString('lit-divi-insert', $ability->execute(['post_id' => 7])['hints'][0]);
```
In `tests/Unit/Guides/InstructionsBuilderTest.php` ersetzen:
```php
self::assertStringContainsString('lit-divi-outline und lit-divi-design-system lesen', $with);
self::assertStringNotContainsString('lit-divi-outline und', $without);
```
durch:
```php
self::assertStringContainsString('lit-divi-outline und lit-divi-design-system lesen', $with);
self::assertStringContainsString('tree_rev', $with);
self::assertStringNotContainsString('lit-divi-outline und', $without);
```
- [ ] **Step 2: Test laufen lassen (rot)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'DiviReadAbilitiesTest|InstructionsBuilderTest'`
Expected: FAIL – Hinweis ohne `lit-divi-insert` bzw. Serveranweisungen ohne `tree_rev`.
- [ ] **Step 3: Implementierung – Hinweise, Anweisungen, Version**
In `src/Integrations/WordPress/Content/PostPresenter.php` ersetzen:
```php
/** How Divi content is read and changed with the Divi tools. */
public const DIVI_NOTE = 'Den Aufbau mit lit/divi-outline (direkt: lit-divi-outline) lesen, einzelne Knoten mit '
. 'lit/divi-node-get (direkt: lit-divi-node-get); ändern vorerst im Divi-Builder.';
```
durch:
```php
/** How Divi content is read and changed with the Divi tools. */
public const DIVI_NOTE = 'Den Aufbau mit lit/divi-outline (direkt: lit-divi-outline) lesen und mit '
. 'lit/divi-insert, lit/divi-update, lit/divi-move bzw. lit/divi-delete (direkt: lit-divi-insert, '
. 'lit-divi-update, lit-divi-move, lit-divi-delete) ändern.';
```
In `src/Integrations/Divi/Pages/DiviOutlineAbility.php` ersetzen:
```php
$hints[] = $page->isDiviLayout()
? 'Die Seite hat noch keinen Divi-Inhalt.'
: 'Der Inhalt ist kein Divi-Layout (Block-Editor oder klassisch); lit-content-get zeigt ihn.';
```
durch:
```php
$hints[] = $page->isDiviLayout()
? 'Die Seite hat noch keinen Divi-Inhalt: mit lit-divi-insert (ohne parent) den ersten Abschnitt '
. 'einfügen.'
: 'Der Inhalt ist kein Divi-Layout (Block-Editor oder klassisch); lit-content-get zeigt ihn.';
```
In `src/Integrations/Divi/Pages/DiviOutlineAbility.php` ersetzen:
```php
if (in_array(false, array_column($rows, 'editable'), true)) {
$hints[] = 'Knoten mit "nur lesbar" (globale Module, fremde Blöcke) ändern die Divi-Werkzeuge nicht; '
. 'ihren Inhalt nur im Divi-Builder bearbeiten.';
}
```
durch:
```php
if (in_array(false, array_column($rows, 'editable'), true)) {
$hints[] = 'Knoten mit "nur lesbar" (globale Module, fremde Blöcke) lassen sich nur als Ganzes '
. 'verschieben, duplizieren oder löschen; ihren Inhalt im Divi-Builder bearbeiten.';
}
```
In `src/Integrations/Divi/Schema/DiviCatalogAbility.php` ersetzen:
```php
$catalog = $this->modules->catalog();
$notes = [self::CODE_NOTE, 'Details zur Vollform: lit-guide mit topic "divi-attrs".'];
```
durch:
```php
$catalog = $this->modules->catalog();
$notes = [self::CODE_NOTE, 'Details und Beispiele: lit-guide mit topic "divi-shortform" bzw. "divi-attrs".'];
```
In `src/Guides/InstructionsBuilder.php` ersetzen:
```php
$divi = str_contains(', ' . $active . ',', ', divi,')
? ['- Divi-Seiten: vor Änderungen lit-divi-outline und lit-divi-design-system lesen; Aufbau: lit-guide '
. 'topic "divi-structure".']
: [];
```
durch:
```php
$divi = str_contains(', ' . $active . ',', ', divi,')
? [
'- Divi-Seiten: vor Änderungen lit-divi-outline und lit-divi-design-system lesen; Aufbau: lit-guide '
. 'topic "divi-structure".',
'- Divi-Änderungen verlangen die tree_rev aus lit-divi-outline bzw. der letzten Divi-Antwort '
. '(nicht rev).',
]
: [];
```
In `src/Plugin.php` ersetzen:
```php
{
public const VERSION = '0.2.0';
```
durch:
```php
{
public const VERSION = '0.3.0';
```
In `wp-lit-mcp.php` ersetzen:
```php
* Description: MCP-Server für WordPress, Divi 5 und Pods – mit OAuth für claude.ai.
* Version: 0.2.0
* Requires at least: 6.9
```
durch:
```php
* Description: MCP-Server für WordPress, Divi 5 und Pods – mit OAuth für claude.ai.
* Version: 0.3.0
* Requires at least: 6.9
```
In `readme.txt` ersetzen:
```text
Requires PHP: 8.1
Stable tag: 0.2.0
License: GPL-2.0-or-later
```
durch:
```text
Requires PHP: 8.1
Stable tag: 0.3.0
License: GPL-2.0-or-later
```
In `readme.txt` ersetzen:
```text
Transient lit_mcp_endpoint_check.
* Uploads über LIT MCP: nur Bilder (jpg, png, gif, webp, avif), PDF, MP4, MP3; Größenlimit einstellbar
```
durch:
```text
Transient lit_mcp_endpoint_check.
* Divi-Änderungen über LIT MCP speichern den Seiteninhalt für Benutzer ohne die Berechtigung
unfiltered_html ohne die kses-Filterung von WordPress, weil diese sonst jeden unveränderten Divi-Block neu
schreiben würde. Alle Werte, die LIT MCP dabei schreibt, werden vorher bereinigt (wp_kses_post,
sanitize_text_field, esc_url_raw); Inhalte des Code-Moduls bleiben nur mit unfiltered_html ungefiltert.
Nach jeder Divi-Änderung wird der statische CSS-Cache von Divi für die Seite geleert.
* Uploads über LIT MCP: nur Bilder (jpg, png, gif, webp, avif), PDF, MP4, MP3; Größenlimit einstellbar
```
In `readme.txt` ersetzen:
```text
lit/revision-restore). Felder und Begriffe sind in Revisionen nicht enthalten.
* Original durch eine Kopie ersetzen (lit/post-replace-with-copy): vorher legt das Werkzeug eine Revision
```
durch:
```text
lit/revision-restore). Felder und Begriffe sind in Revisionen nicht enthalten.
* Divi-Seiten: jede Divi-Änderung legt vorher eine Revision an (revision_id in der Antwort); zurück über
lit/revision-restore. Beim seitenübergreifenden Ersetzen erhält jede geänderte Seite eine eigene Revision.
* Original durch eine Kopie ersetzen (lit/post-replace-with-copy): vorher legt das Werkzeug eine Revision
```
In `readme.txt` ersetzen:
```text
= 0.2.0 =
```
durch:
```text
= 0.3.0 =
* Divi-Seiten: Seitenbaum mit Pfad-IDs und tree_rev, Knoten in Voll- und Kurzform, Modulkatalog,
Designsystem (Farben, Variablen, Schriften, Presets), gerenderte Zusammenfassung; Abschnitte, Zeilen und
Module einfügen, ändern, verschieben, duplizieren und löschen (Kurzform, Vollform, Platzhalter wie
{{color:primary}}), Text seitenübergreifend ersetzen mit Probelauf, Qualitätswarnungen, neue Seiten
direkt mit Divi-Inhalt.
= 0.2.0 =
```
- [ ] **Step 4: Implementierung – Guides**
`docs/guides/divi-shortform.md`:
````markdown
# Divi-Kurzform
Neue Divi-Inhalte beschreibt man als JSON-Knoten. `lit-divi-insert` (in `nodes`) und `lit-content-create` (in
`divi`) nehmen dieselbe Form. Jeder Knoten hat einen `type`; dazu Kurzform-Felder, optional `label`
(Admin-Label), `preset` (Name eines Modul-Presets aus `lit-divi-design-system`) und `attrs` (Vollform, siehe
`divi-attrs`). Kurz- und Vollform lassen sich kombinieren; `attrs` gewinnt.
## Aufbau
```json
{ "type": "section", "label": "Hero", "background_color": "{{color:primary}}",
"rows": [
{ "columns": "1_3,2_3",
"modules": [
[ { "type": "image", "image_id": 33 } ],
[ { "type": "heading", "text": "Willkommen", "level": "h1" },
{ "type": "text", "content": "<p>Seit 1987 in Köln.</p>" },
{ "type": "button", "text": "Termin buchen", "url": "/kontakt", "preset": "CTA primär" } ]
] }
] }
```
- **Abschnitt** (`section`): `rows` (Zeilen) oder – für eine einspaltige Zeile – direkt `modules`;
`section_type: "fullwidth"` für Vollbreite-Abschnitte (dann nur `fullwidth-…`-Module in `modules`);
`background_color`. Spezialabschnitte (specialty) nur im Divi-Builder bauen.
- **Zeile** (`row`): `columns` = Breiten als Brüche, die zusammen 1 ergeben (`"4_4"`, `"1_2,1_2"`,
`"1_3,2_3"`, `"1_4,1_4,1_2"`, auch Fünftel); `modules` = je Spalte eine Liste (bei einer Spalte reicht eine
einfache Liste).
- **Module** mit Kind-Modulen (Akkordeon, Tabs, Slider, Kontaktformular, Karte, Social-Media-Follow,
Icon-Liste) bekommen ihre Einträge in `items`; der Eintragstyp ergibt sich von selbst.
- `label` fehlt → Admin-Label „<Abschnitt> – <Modul>“, z. B. „Hero – Button“. Abschnitten deshalb ein Label
geben.
- `heading` ohne `level` wird `h2` (eine Seite hat meist schon eine `h1` im Titel).
## Kurzform-Felder
| Modul | Felder |
|---|---|
| `text` | `content` (HTML) |
| `heading` | `text`, `level` (`h1`–`h6`) |
| `button` | `text`, `url`, `new_window` (true/false) |
| `image` | `image_id` (Mediathek, setzt URL/ID/Alt), `src`, `alt`, `title`, `url` |
| `blurb` | `title`, `url`, `content`, `image_id`, `src`, `alt`, `level` |
| `cta` | `title`, `content`, `button_text`, `button_url` |
| `icon` | `url`, `title` |
| `divider` | `show`, `color` |
| `testimonial` | `author`, `job_title`, `company`, `company_url`, `content`, `portrait_id`, `portrait_url` |
| `team-member` | `name`, `position`, `content`, `image_id`, `image_url` |
| `accordion` → `accordion-item` | Eintrag: `title`, `content`, `open` |
| `tabs` → `tab` | Eintrag: `title`, `content` |
| `toggle` | `title`, `content`, `open` |
| `gallery` | `image_ids` (Liste von Anhang-IDs) |
| `slider` → `slide` | Eintrag: `title`, `content`, `button_text`, `button_url`, `image_id`, `src`, `alt` |
| `contact-form` → `contact-field` | Formular: `title`, `email`, `success_message`, `submit_text`; Feld: `title`, `id`, `type` (`input`, `email`, `text`, `checkbox`, `radio`, `select`), `required` |
| `map` → `map-pin` | Karte: `address`, `lat`, `lng`, `zoom`; Pin: `title`, `content`, `address`, `lat`, `lng` |
| `video` | `src`, `thumbnail` |
| `number-counter` | `number`, `title`, `percent` |
| `social-media-follow` → `social-media-follow-network` | Eintrag: `network` (z. B. `facebook`), `url`, `label` |
| `icon-list` → `icon-list-item` | Eintrag: `text` |
| `code` | `content` – nur, wenn kein natives Modul passt (Warnung) |
Alle anderen Module (siehe `lit-divi-catalog`) gehen über `attrs`. Die Kurzform-Felder eines Moduls samt Pfad
zeigt `lit-divi-catalog` mit `module`.
## Einfügen, ändern, verschieben, löschen
- `lit-divi-insert`: `nodes` + Position – `parent` (Pfad-ID, z. B. Spalte `s0.r0.c1`; optional `index`),
`before` oder `after`; ohne Position ans Seitenende. Oben auf der Seite sind nur Abschnitte erlaubt, in
Abschnitten Zeilen, in Spalten Module.
- `lit-divi-update`: `changes` = `[{"id": "s0.r0.c1.m2", "set": {"text": "Jetzt buchen"}, "preset": "CTA
primär", "label": "Hero – Button"}]` – mehrere Knoten in einem Aufruf; `null` entfernt einen Wert.
- `lit-divi-move`: `id` + Position (auch in andere Zeilen oder Abschnitte).
- `lit-divi-delete`: `ids`, erst nach Rückfrage mit `confirm: true`.
- `lit/divi-duplicate-node`: Kopie direkt hinter dem Original.
- Jede Antwort enthält die neue `tree_rev` (für die nächste Änderung), `changed` mit den neuen Pfad-IDs,
den betroffenen Abschnitt als `outline`, `warnings` (Qualität, siehe unten) und `revision_id`.
Fehler: `validation_failed` bzw. `invalid_attr_path` mit `fields` (Eingabepfad → Problem, z. B.
`nodes[0].rows[0].columns`); dann wurde nichts gespeichert.
## Warnungen
Neue oder geänderte Knoten werden geprüft: feste Farben/Schriften, obwohl eine passende globale existiert;
Bilder ohne Alt-Text; leere Module; Sprünge in der Überschriftenhierarchie; Code-Modul. Warnungen dem Nutzer
nennen und möglichst gleich beheben.
````
`docs/guides/divi-placeholders.md`:
````markdown
# Platzhalter für Divi-Inhalte
Platzhalter stehen **allein** im Wert (nicht mitten im Text) und werden beim Speichern in Divis eigene
Verweise übersetzt. So bleibt die Seite mit dem Designsystem verbunden: ändert sich die Primärfarbe, ändert
sich auch der Button.
| Platzhalter | Bedeutung |
|---|---|
| `{{color:primary}}`, `secondary`, `heading`, `body`, `link` | die fünf Theme-Farben |
| `{{color:<Name>}}` | eigene globale Farbe (Name wie in `lit-divi-design-system`) |
| `{{var:<Name>}}` | globale Variable (Zahl, Text, Bild, Link) |
| `{{font:heading}}`, `{{font:body}}` | Theme-Schrift für Überschriften bzw. Fließtext |
| `{{post:title}}`, `excerpt`, `date`, `modified`, `author`, `url`, `featured_image`, `categories`, `tags` | dynamischer Inhalt des angezeigten Beitrags |
| `{{field:<meta_key>}}` | dynamischer Inhalt aus einem Feld (Pods/Meta), z. B. `{{field:telefon}}` |
| `"preset": "<Name>"` | Modul-Preset (kein Platzhalter, sondern eigenes Feld) |
Beispiele:
```json
{ "type": "button", "text": "Anrufen", "url": "{{field:telefon_link}}",
"attrs": { "button.decoration.background": { "desktop": { "color": "{{color:primary}}" },
"hover": { "color": "{{color:Nachtblau}}" } } } }
```
```json
{ "type": "heading", "text": "{{post:title}}", "level": "h1" }
```
Unbekannte Namen lehnt das Werkzeug ab und nennt die vorhandenen. In `lit-divi-node-get` erscheinen die
übersetzten Verweise als `$variable({…})$` – diese Werte unverändert lassen oder wieder durch einen
Platzhalter ersetzen.
````
In `docs/guides/divi-structure.md` ersetzen:
```markdown
## Was nicht bearbeitet wird
```
durch:
```markdown
## Ändern
Neue Inhalte und Änderungen: siehe `divi-shortform` (Kurzform, `lit-divi-insert`, `lit-divi-update`,
`lit-divi-move`, `lit-divi-delete`), `divi-attrs` (Vollform) und `divi-placeholders`. Ablauf:
1. `lit-divi-outline` lesen (liefert `tree_rev`), `lit-divi-design-system` für Farben, Variablen, Presets.
2. Änderung mit dieser `tree_rev` senden. Die Antwort enthält die neue `tree_rev` und die neuen Pfad-IDs –
damit direkt weiterarbeiten, ohne erneut zu lesen.
3. `rev_mismatch`: jemand hat die Seite inzwischen geändert (z. B. im Divi-Builder) → neu lesen.
4. Zum Schluss `lit-page-render-summary` prüfen und dem Nutzer die Vorschau (`preview_url`) bzw. den
Divi-Builder (`edit_url`) nennen. Der Status bleibt unverändert; veröffentlichte Seiten werden live
geändert (vorher entsteht eine Revision, `revision_id`).
## Was nicht bearbeitet wird
```
In `docs/guides/divi-structure.md` ersetzen:
```markdown
- Knoten mit „nur lesbar“ (globale Module `g…`, fremde Blöcke `x…`) ändern die Divi-Werkzeuge nicht.
- Divi-4-Inhalt (Shortcodes `[et_pb_…]`) wird nicht gelesen oder geändert: Seite einmal im Divi-Builder
```
durch:
```markdown
- Knoten mit „nur lesbar“ (globale Module `g…`, fremde Blöcke `x…`) lassen sich nur als Ganzes verschieben,
duplizieren oder löschen; ihr Inhalt wird im Divi-Builder bearbeitet (globale Module folgen später).
- Divi-4-Inhalt (Shortcodes `[et_pb_…]`) wird nicht gelesen oder geändert: Seite einmal im Divi-Builder
```
In `docs/guides/divi-attrs.md` ersetzen:
```markdown
## Pfade finden
```
durch:
````markdown
## Setzen
In `attrs` (bei `lit-divi-insert` je Knoten, bei `lit-divi-update` je Änderung) steht Pfad → Wert je Breakpoint.
Kurz: `{"desktop": "#1a1a1a", "phone": "…"}` bzw. bei Objektwerten `{"desktop": {"size": "28px"}}`; Hover und
Sticky: `{"hover": {…}}` bzw. `{"desktop": {"value": {…}, "hover": {…}}}`. Nur die genannten Breakpoints
werden ersetzt, der Rest bleibt; `null` entfernt einen Pfad, Breakpoint oder Zustand:
```json
{ "id": "s0.r0.c1.m0",
"attrs": { "title.decoration.font.font": { "desktop": { "color": "{{color:heading}}" },
"phone": { "size": "28px" } },
"module.decoration.spacing": null } }
```
Unbekannte Pfade → Fehler `invalid_attr_path` mit Vorschlägen unter `fields`. Texte werden bereinigt
(HTML nur, wo Divi Rich-Text erwartet; Links ohne `javascript:`).
## Pfade finden
````
In `docs/guides/overview.md` ersetzen:
```markdown
Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows`, `media`, `divi-structure`,
`divi-attrs` oder `divi-design-system`.
```
durch:
```markdown
Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows`, `media`, `divi-structure`,
`divi-shortform`, `divi-attrs`, `divi-placeholders` oder `divi-design-system`.
```
In `docs/guides/overview.md` ersetzen:
```markdown
- Erfolg: `ok`, `summary`, `changed`, `warnings`, `rev`, `revision_id` (Revision mit dem Stand vorher, nur bei
Tools, die Inhalt überschreiben, sonst `null`), `log_id`, URLs.
- Fehler: JSON mit `code` (z. B. `validation_failed`, `rev_mismatch`, `write_failed`, `partial_write`),
```
durch:
```markdown
- Erfolg: `ok`, `summary`, `changed`, `warnings`, `rev`, `revision_id` (Revision mit dem Stand vorher, nur bei
Tools, die Inhalt überschreiben, sonst `null`), `log_id`, URLs. Divi-Werkzeuge zusätzlich `tree_rev` (Stand
des Seiteninhalts, Pflicht für die nächste Divi-Änderung) und `outline` (betroffener Abschnitt).
- Fehler: JSON mit `code` (z. B. `validation_failed`, `rev_mismatch`, `write_failed`, `partial_write`),
```
In `docs/guides/workflows.md` ersetzen:
```markdown
6. Veröffentlichen erst, wenn der Nutzer es ausdrücklich möchte.
```
durch:
```markdown
6. Veröffentlichen erst, wenn der Nutzer es ausdrücklich möchte.
## Divi-Seite bauen
1. `lit-divi-design-system` lesen (Farben, Schriften, Presets) und `lit-divi-catalog` für die Module.
2. Seite als Entwurf mit Inhalt anlegen: `lit-content-create` mit `divi` (Abschnitte in Kurzform, siehe
`divi-shortform`) – oder eine leere Seite anlegen und Abschnitte mit `lit-divi-insert` einfügen.
3. Weitere Abschnitte/Module mit `lit-divi-insert`, Anpassungen mit `lit-divi-update`; immer die `tree_rev`
der letzten Antwort mitgeben.
4. `lit-page-render-summary` prüfen, `warnings` beheben, dem Nutzer `preview_url` und `edit_url` (Divi-Builder)
nennen. Veröffentlichen nur auf Wunsch (`lit-post-publish`).
## Text auf vielen Seiten ersetzen
`lit/divi-replace-text` zuerst mit `dry_run: true` (Standard): zeigt Treffer je Seite mit `tree_rev`. Nach
Rückfrage mit `dry_run: false`, `targets` (post_id + tree_rev aus dem Probelauf) und `confirm: true`
ausführen; jede Seite bekommt eine eigene Revision, `results` meldet das Ergebnis je Seite.
```
In `docs/guides/content-types.md` ersetzen:
```markdown
- `lit-content-get`: alle Daten eines Eintrags inkl. Feldern, Begriffen, Beitragsbild und `rev`.
Bei Divi-Seiten fehlt `content`; den Aufbau liest `lit-divi-outline` (siehe `divi-structure`), geändert
wird er vorerst im Divi-Builder (Titel, Auszug, Felder und Begriffe gehen weiter über
`lit-content-update`).
```
durch:
```markdown
- `lit-content-get`: alle Daten eines Eintrags inkl. Feldern, Begriffen, Beitragsbild und `rev`.
Bei Divi-Seiten fehlt `content`; den Aufbau liest `lit-divi-outline`, geändert wird er mit den
Divi-Werkzeugen (siehe `divi-structure`); Titel, Auszug, Felder und Begriffe gehen weiter über
`lit-content-update`.
```
In `docs/guides/content-types.md` ersetzen:
```markdown
- `lit-content-create` legt immer einen **Entwurf** an. Felder werden gegen das Schema geprüft; ein Fehler
bricht den ganzen Aufruf ab (`fields.<schlüssel>` im Fehler nennt die Ursache).
- `lit-content-update` ändert nur die übergebenen Werte, der Status bleibt. `rev` aus `lit-content-get` ist
```
durch:
```markdown
- `lit-content-create` legt immer einen **Entwurf** an. Felder werden gegen das Schema geprüft; ein Fehler
bricht den ganzen Aufruf ab (`fields.<schlüssel>` im Fehler nennt die Ursache). Divi-Seiten gleich mit
Inhalt: `divi` = Liste von Abschnitten in Kurzform (siehe `divi-shortform`) statt `content`; die Antwort
enthält dann die `tree_rev` für weitere Divi-Änderungen.
- `lit-content-update` ändert nur die übergebenen Werte, der Status bleibt. `rev` aus `lit-content-get` ist
```
In `docs/guides/content-types.md` ersetzen:
```markdown
1. `lit/post-duplicate` → Entwurfskopie; die Antwort enthält die `rev` der Kopie für `lit-content-update`.
2. Kopie mit `lit-content-update` (Divi-Inhalte vorerst im Divi-Builder) bearbeiten und dem Nutzer die
Vorschau zeigen; `lit-page-render-summary` fasst den gerenderten Inhalt zusammen.
3. `lit/post-replace-with-copy` übernimmt die Kopie ins Original (ID, URL, Status und Menüeinträge bleiben).
```
durch:
```markdown
1. `lit/post-duplicate` → Entwurfskopie; die Antwort enthält die `rev` der Kopie für `lit-content-update`.
2. Kopie mit `lit-content-update` bzw. den Divi-Werkzeugen bearbeiten und dem Nutzer die Vorschau zeigen;
`lit-page-render-summary` fasst den gerenderten Inhalt zusammen.
3. `lit/post-replace-with-copy` übernimmt die Kopie ins Original (ID, URL, Status und Menüeinträge bleiben).
```
- [ ] **Step 5: Tests und Lint (grün)**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test && composer lint`
Expected: alle Tests PASS (inkl. `ToolNamingTest`: in den Guides nur `lit-divi-insert` usw. für direkte und `lit/divi-duplicate-node`, `lit/divi-replace-text` für indirekte Tools), phpcs ohne Meldungen.
- [ ] **Step 6: Ende-zu-Ende-Test anlegen und alle Integrationstests ausführen**
`tests/Integration/divi-write-smoke.sh`:
```bash
#!/usr/bin/env bash
# End-to-end checks of the Etappe-2b Divi write tools through the OAuth-protected endpoint.
# Runs INSIDE the DDEV web container (Divi 5 must be the active theme):
# cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-write-smoke.sh
# Creates draft pages, a temporary role/user without unfiltered_html, and removes everything again.
set -euo pipefail
cd /var/www/html
# shellcheck source=lib.sh
source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh
ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1)
FIXTURES=$PLUGIN/tests/Fixtures/divi
ORIGINAL_ROLES=$(wp option get lit_mcp_allowed_roles --format=json 2>/dev/null || echo '["administrator"]')
CREATED_POSTS=()
cleanup() {
for id in "${CREATED_POSTS[@]}"; do
wp post delete "$id" --force >/dev/null 2>&1 || true
done
cleanup_app_passwords_for "$ADMIN_LOGIN"
cleanup_app_passwords_for lit-smoke-editor
wp user delete lit-smoke-editor --yes >/dev/null 2>&1 || true
wp role delete lit_smoke_editor >/dev/null 2>&1 || true
wp option update lit_mcp_allowed_roles "$ORIGINAL_ROLES" --format=json >/dev/null 2>&1 || true
}
trap cleanup EXIT
create_page() { # $1 = fixture file, $2 = title -> prints the ID
LIT_FILE="$FIXTURES/$1" LIT_TITLE="$2" wp --user="$ADMIN_LOGIN" eval '
$id = wp_insert_post(["post_type" => "page", "post_status" => "draft", "post_title" => getenv("LIT_TITLE"),
"post_content" => wp_slash((string) file_get_contents(getenv("LIT_FILE")))]);
update_post_meta($id, "_et_pb_use_builder", "on");
update_post_meta($id, "_et_pb_use_divi_5", "on");
echo $id;'
}
content_of() { # $1 = post ID
wp post get "$1" --field=post_content
}
# Whether the stored content still contains the exact bytes of the fixture section "Leistungen".
keeps_leistungen() { # $1 = post ID
LIT_ID="$1" LIT_FILE="$FIXTURES/converted-landing.html" wp eval '
$fixture = (string) file_get_contents(getenv("LIT_FILE"));
$start = strpos($fixture, "<!-- wp:divi/section {\"module\":{\"meta\":{\"adminLabel\":{\"desktop\":{\"value\":\"Leistungen\"");
$section = substr($fixture, $start, strrpos($fixture, "<!-- /wp:divi/placeholder -->") - $start);
echo str_contains((string) get_post((int) getenv("LIT_ID"))->post_content, $section) ? "yes" : "no";'
}
[ "$(wp eval 'echo get_template();')" = "Divi" ] || fail "Divi must be the active theme"
PAGE_ID=$(create_page converted-landing.html "LIT Divi Schreiben")
CREATED_POSTS+=("$PAGE_ID")
connect "$ADMIN_LOGIN"
# 1. Direct write tools with correct annotations
LIST=$(rpc tools/list '{}')
for t in lit-divi-insert lit-divi-update lit-divi-move lit-divi-delete; do
echo "$LIST" | jq -e --arg t "$t" '.result.tools[] | select(.name == $t)' >/dev/null || fail "tools/list lacks $t"
done
echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-divi-delete") | .annotations.destructiveHint == true' >/dev/null \
|| fail "lit-divi-delete lacks destructiveHint"
echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-divi-replace-text")' >/dev/null \
&& fail "lit-divi-replace-text must be indirect"
pass "tools/list: Divi write tools direct, replace-text indirect"
# 2. Insert a whole section in short form with placeholders
TREE_REV=$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}" | jq -r '.tree_rev')
INSERT=$(tool lit-divi-insert "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev, before: "s1",
nodes: [{type: "section", label: "Neu", background_color: "{{color:primary}}", rows: [{columns: "1_3,2_3", modules: [
[{type: "heading", text: "{{post:title}}", level: "h2"}],
[{type: "text", content: "<p>Hallo <strong>Welt</strong></p><script>alert(1)</script>"},
{type: "button", text: "Mehr erfahren", url: "/mehr", label: "Neu – CTA"}]]}]}]}')")
echo "$INSERT" | jq -e '.ok == true and .changed[0].id == "s1" and .revision_id != null and .log_id != null' >/dev/null \
|| fail "divi-insert: $INSERT"
TREE_REV=$(echo "$INSERT" | jq -r '.tree_rev')
content_of "$PAGE_ID" | grep -q 'u003cscript' && fail "script tag was stored"
[ "$(keeps_leistungen "$PAGE_ID")" = "yes" ] || fail "insert changed the bytes of an untouched section"
SUMMARY=$(tool lit-page-render-summary "{\"post_id\":$PAGE_ID}")
echo "$SUMMARY" | jq -e '.headings[] | select(.level == 2 and .text == "LIT Divi Schreiben")' >/dev/null \
|| fail "{{post:title}} not rendered: $SUMMARY"
pass "lit-divi-insert: section with placeholders, sanitized, untouched bytes kept, rendered"
# 3. Lock and all-or-nothing
STALE=$(tool lit-divi-update "{\"post_id\":$PAGE_ID,\"tree_rev\":\"000000000000\",\"changes\":[{\"id\":\"s0\",\"label\":\"x\"}]}")
[ "$(error_code "$STALE")" = "rev_mismatch" ] || fail "stale tree_rev accepted: $STALE"
BAD=$(tool lit-divi-update "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev,
changes: [{id: "s1.r0.c1.m1", set: {text: "Jetzt"}}, {id: "s1.r0.c0.m0", attrs: {"title.decoration.fnt": {desktop: "x"}}}]}')")
[ "$(error_code "$BAD")" = "invalid_attr_path" ] || fail "invalid path accepted: $BAD"
[ "$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}" | jq -r '.tree_rev')" = "$TREE_REV" ] || fail "failed update wrote something"
pass "tree_rev lock and invalid_attr_path leave the page unchanged"
# 4. Update, move, delete
UPDATE=$(tool lit-divi-update "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev,
changes: [{id: "s1.r0.c1.m1", set: {text: "Jetzt anfragen"}, attrs: {"button.decoration.background": {desktop: {color: "{{color:secondary}}"}}}}]}')")
echo "$UPDATE" | jq -e '.ok == true and .changed[0].action == "updated"' >/dev/null || fail "divi-update: $UPDATE"
TREE_REV=$(echo "$UPDATE" | jq -r '.tree_rev')
MOVE=$(tool lit-divi-move "{\"post_id\":$PAGE_ID,\"tree_rev\":\"$TREE_REV\",\"id\":\"s1.r0.c1.m1\",\"parent\":\"s1.r0.c0\"}")
echo "$MOVE" | jq -e '.changed[0].id == "s1.r0.c0.m1"' >/dev/null || fail "divi-move: $MOVE"
TREE_REV=$(echo "$MOVE" | jq -r '.tree_rev')
ASK=$(tool lit-divi-delete "{\"post_id\":$PAGE_ID,\"tree_rev\":\"$TREE_REV\",\"ids\":[\"s1.r0.c0.m1\"]}")
[ "$(error_code "$ASK")" = "confirm_required" ] || fail "delete without confirm: $ASK"
DELETE=$(tool lit-divi-delete "{\"post_id\":$PAGE_ID,\"tree_rev\":\"$TREE_REV\",\"ids\":[\"s1.r0.c0.m1\"],\"confirm\":true}")
echo "$DELETE" | jq -e '.changed[0].action == "deleted"' >/dev/null || fail "divi-delete: $DELETE"
TREE_REV=$(echo "$DELETE" | jq -r '.tree_rev')
[ "$(keeps_leistungen "$PAGE_ID")" = "yes" ] || fail "update/move/delete changed an untouched section"
pass "lit-divi-update, lit-divi-move, lit-divi-delete"
# 5. Duplicate and replace text (indirect)
DUP=$(ability lit/divi-duplicate-node "{\"post_id\":$PAGE_ID,\"tree_rev\":\"$TREE_REV\",\"id\":\"s1\"}")
echo "$DUP" | jq -e '.changed[0].id == "s2" and (.changed[0].label | endswith("(Kopie)"))' >/dev/null || fail "duplicate: $DUP"
DRY=$(ability lit/divi-replace-text '{"search":"Köln","replace":"Bonn","limit":100}')
TARGET=$(echo "$DRY" | jq -c --argjson id "$PAGE_ID" '[.matches[] | select(.post_id == $id) | {post_id, tree_rev}]')
[ "$TARGET" != "[]" ] || fail "dry run found nothing: $DRY"
content_of "$PAGE_ID" | grep -q 'Bonn' && fail "dry run changed content"
RUN=$(ability lit/divi-replace-text "$(jq -cn --argjson t "$TARGET" '{search: "Köln", replace: "Bonn", dry_run: false, confirm: true, targets: $t}')")
echo "$RUN" | jq -e '.results[0].ok == true and .results[0].count >= 1' >/dev/null || fail "replace-text: $RUN"
content_of "$PAGE_ID" | grep -q 'Bonn' || fail "replace-text wrote nothing"
pass "lit/divi-duplicate-node and lit/divi-replace-text (dry run, confirmed run)"
# 6. New Divi page in one call
CREATED=$(tool lit-content-create '{"post_type":"page","title":"LIT Divi Neu","divi":[{"type":"section","label":"Start","modules":[{"type":"heading","text":"Neu hier"},{"type":"text","content":"<p>Text</p>"}]}]}')
NEW_ID=$(echo "$CREATED" | jq -r '.changed[0].id // empty')
[ -n "$NEW_ID" ] || fail "content-create with divi: $CREATED"
CREATED_POSTS+=("$NEW_ID")
echo "$CREATED" | jq -e '.tree_rev != null' >/dev/null || fail "content-create lacks tree_rev: $CREATED"
[ "$(wp post meta get "$NEW_ID" _et_pb_use_divi_5)" = "on" ] || fail "Divi flag missing"
[ "$(wp post get "$NEW_ID" --field=post_status)" = "draft" ] || fail "new Divi page is not a draft"
tool lit-page-render-summary "{\"post_id\":$NEW_ID}" | jq -e '.headings[0].text == "Neu hier"' >/dev/null || fail "new page not rendered"
pass "lit-content-create with Divi short form"
# 7. A user without unfiltered_html: untouched bytes still identical, new values still sanitized
wp role create lit_smoke_editor "LIT Smoke Editor" --clone=editor >/dev/null
wp cap remove lit_smoke_editor unfiltered_html >/dev/null
wp option update lit_mcp_allowed_roles '["administrator","lit_smoke_editor"]' --format=json >/dev/null
wp user create lit-smoke-editor lit-smoke-editor@example.invalid --role=lit_smoke_editor >/dev/null
connect lit-smoke-editor
TREE_REV=$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}" | jq -r '.tree_rev')
EDIT=$(tool lit-divi-update "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev,
changes: [{id: "s0.r0.c0.m1", set: {content: "<p>Neu vom Redakteur</p><script>x()</script>"}}]}')")
echo "$EDIT" | jq -e '.ok == true' >/dev/null || fail "editor update: $EDIT"
content_of "$PAGE_ID" | grep -q 'u003cscript' && fail "editor could store a script"
[ "$(keeps_leistungen "$PAGE_ID")" = "no" ] && fail "kses re-serialized untouched blocks of an editor save"
pass "user without unfiltered_html: byte-identical untouched content, sanitized values"
echo "ALL CHECKS PASSED"
```
Run:
```bash
cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && chmod +x tests/Integration/divi-write-smoke.sh
cd /home/janniclas/Projekte/WordPress
timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-write-smoke.sh
timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-read-smoke.sh
timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/mcp-smoke.sh
timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/wp-basis-smoke.sh
timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/media-admin-smoke.sh
```
Expected: alle fünf Skripte enden mit `ALL CHECKS PASSED` (bekannte WARN-Zeilen aus Etappe 1 sind zulässig). Schlägt Abschnitt 7 von `divi-write-smoke.sh` fehl („kses re-serialized untouched blocks“), ist die kses-Umgehung in `DiviSaver` wirkungslos – nicht den Test lockern.
- [ ] **Step 7: Commit**
```bash
cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
git add src/Integrations/WordPress/Content/PostPresenter.php src/Integrations/Divi/Pages/DiviOutlineAbility.php src/Integrations/Divi/Schema/DiviCatalogAbility.php src/Guides/InstructionsBuilder.php src/Plugin.php wp-lit-mcp.php readme.txt docs/guides tests/Integration/divi-write-smoke.sh tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php tests/Unit/Guides/InstructionsBuilderTest.php
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "docs: Divi guides shortform/placeholders, hints name the write tools; test: Divi write end-to-end; version 0.3.0
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
```
- [ ] **Step 8: Manuelle Abnahme (Jan-Niclas, Browser und claude.ai)**
Der ausführende Agent bereitet vor und meldet; diese Punkte prüft Jan-Niclas selbst (Spec §1.3 Nr. 2, §13 „Bearbeitbarkeit im Visual Builder“):
1. **Visual Builder:** In claude.ai eine Testseite bauen lassen („Lege eine Entwurfsseite ‚Abnahme Divi‘ an mit Hero (Überschrift, Text, Button mit Preset), einer Zeile 1/3 + 2/3 mit Bild und Blurb, einem Akkordeon und einem Kontaktformular“). Die Seite im Divi Visual Builder öffnen (`edit_url`): alle Module erscheinen als native Module mit Admin-Labels „<Abschnitt> – <Modul>“, lassen sich anklicken, ändern und speichern; die Spaltenbreiten stimmen (1/3 + 2/3); Farben aus `{{color:…}}` sind im Builder als globale Farbe verknüpft (Farbwähler zeigt die globale Farbe).
2. **Zurück zu Claude:** Nach dem Speichern im Builder eine Änderung per claude.ai verlangen – `rev_mismatch` erscheint nur, wenn Claude eine alte `tree_rev` nutzt; nach erneutem Lesen gelingt die Änderung, im Builder gemachte Einstellungen bleiben erhalten.
3. **Rückgängig:** Einen Abschnitt löschen lassen (Rückfrage erscheint), danach über `lit/revision-restore` mit der gemeldeten `revision_id` zurückholen.
4. **Ersetzen:** „Ersetze auf allen Seiten ‚Termin‘ durch ‚Beratungstermin‘“ – Claude zeigt erst den Probelauf und fragt nach, dann ersetzt es; im Protokoll-Tab erscheint je Seite ein Eintrag.
5. **Builder-Fixtures (aus 2a, falls noch offen):** Seiten mit globalem Modul, Presets, Variablen und Dynamic Content mit `tests/Integration/divi-export-page.php` exportieren; `composer test` bleibt grün; danach auf einer Kopie einer solchen Seite `lit-divi-update` an einem normalen Modul ausführen – das globale Modul bleibt byte-identisch.
Ergebnisse als kurze Notiz an den Ausführenden zurückmelden; Abweichungen (z. B. anders gespeicherte Spaltenbreiten, D8) werden als Folgeaufgaben erfasst.
---
## Self-Review (durchgeführt beim Schreiben)
- **Spec-Abdeckung (Teil 2b):** §5.4 Kurzform (~20 Module + Kind-Module), Vollform mit Pfadprüfung (`invalid_attr_path` mit Vorschlägen), Breakpoints und Hover/Sticky, Kombination Kurz + Voll, Struktur Abschnitt → Zeilen (`columns`) → Spalten → Module, Vollbreite über `section_type`, Platzhalter-Tabelle vollständig inkl. `preset` → Tasks 1–3; §5.5 Warnungen (außer „ähnliche Abschnitte“, D6) und automatische Admin-Labels → Tasks 3–4; §5.6 Speicher-Pipeline Schritte 1–6 → Task 5; §5.7 `lit/divi-insert`, `lit/divi-update`, `lit/divi-move`, `lit/divi-delete`, `lit/divi-duplicate-node`, `lit/divi-replace-text` → Tasks 6–8; §6.2 `lit/content-create` mit Divi-Kurzform → Task 9; §8.1 `ToolResult` mit `changed`, `outline`, `rev`, `tree_rev`, `warnings`, `revision_id`, `log_id`, `preview_url`, `edit_url` (`&et_fb=1`) → Task 5; §8.2 Codes `rev_mismatch`, `invalid_attr_path`, `validation_failed`, `confirm_required`, `unsupported`, alles oder nichts, Stapel je Eintrag → Tasks 5–8; §9.1/§9.2 Regeln und Guides `divi-shortform`, `divi-placeholders` → Task 10; §10 Bereinigung (`wp_kses_post`, `sanitize_text_field`) und `confirm: true` → Tasks 1, 5, 7, 8; §11.3 Annotations → Tasks 6–8. Etappe 3: Library, globale Module bearbeiten, Theme Builder, `lit/divi-dynamic-sources`, ähnliche Abschnitte (D6).
- **Gefundene Spec-Lücken und Auflösung:** kses re-serialisiert für Benutzer ohne `unfiltered_html` alle Blöcke → D7 (`DiviSaver`, Readme, E2E-Abschnitt 7); Spaltenformat von Divi 5.2 (Flex, `flexType`) nicht in der Spec → D8; `divi-replace-text` ohne Sperre je Seite → D10 (`targets` mit `tree_rev`); Platzhalter mitten im Text unklar → D9 (nur ganzer Wert); `content-create` mit Divi und kses → D11.
- **Placeholder-Scan:** alle Code-Schritte enthalten vollständigen Code bzw. exakte Ersetzungen (alt → neu); keine „TBD“, keine „wie in Task N“.
- **Typ-Konsistenz geprüft:** `Composer::compose/update` (Rückgabe `blocks|attrs`, `errors`, `invalid_paths`), `TreeEditor::position` (Array `container/parent/index` oder String), `DiviChange::changed/removed/entries/touched/warnings`, `DiviWriter::page/save/inputProperties/positionProperties/sectionLabel`, `DiviSaver::save` (Rückgabe `revision_id`, `warnings`), `DiviComposition::composer/attrWriter/rules/editor/warnings`, `ContentComposer::available/compose/afterInsert`, `Errors::invalidAttrPath` stimmen zwischen Interfaces-Blöcken, Code und Tests überein.
- **Review Focus:** alle fünf Punkte haben ihren Test im genannten Task. Der Code wurde in einer Kopie des Plugins ausgeführt: nach jedem Task `composer test` grün (bis 742 Tests) und phpcs sauber; die Schreib-Pipeline wurde direkt gegen Divi 5.2.1 in DDEV geprüft (Einfügen, Ändern, Verschieben, Löschen, Probelauf, `{{post:title}}` gerendert, unberührte Bytes identisch auch mit aktivem kses).