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

278 KiB
Raw Blame History

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 „ – “ (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

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

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

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

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

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

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

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
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:

use LIT\MCP\Integrations\Divi\Compose\Placeholders;

durch:

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:

    public static function attrWriter(): AttrWriter
    {
        return new AttrWriter(ModuleCatalogTest::sampleCatalog(), self::placeholders(), self::sanitizer());
    }

durch:

    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

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

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
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 → „ – “; 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:

use LIT\MCP\Integrations\Divi\Compose\AttrWriter;

durch:

use LIT\MCP\Integrations\Divi\Compose\AttrWriter;
use LIT\MCP\Integrations\Divi\Compose\Composer;

In tests/Unit/Integrations/Divi/Compose/ComposeFixture.php ersetzen:

use LIT\MCP\Integrations\Divi\Compose\ShortformWriter;

durch:

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:

    public static function shortform(): ShortformWriter
    {
        return new ShortformWriter(self::placeholders(), self::sanitizer(), self::media(), self::attrWriter());
    }
}

durch:

    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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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:

        'lit/divi-design-system',
    ];

durch:

        'lit/divi-design-system',
        'lit/divi-insert',
        'lit/divi-update',
    ];

In tests/Unit/Integrations/Divi/DiviIntegrationTest.php ersetzen:

        $integration = new DiviIntegration(Mockery::mock(ContentSchema::class));

durch:

        $integration = new DiviIntegration(Mockery::mock(ContentSchema::class), Mockery::mock(ChangeLog::class));

In tests/Unit/Integrations/Divi/DiviIntegrationTest.php ersetzen:

use LIT\MCP\Abilities\Ability;

durch:

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:

    public static function unsupported(string $message, string $hint): WP_Error
    {
        return self::make('unsupported', $message, $hint, 400);
    }

durch:

    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

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

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

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:

            new DiviIntegration($schema)

durch:

            new DiviIntegration($schema, $changeLog)

In docs/guides/overview.md ersetzen:

`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:

`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
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:

use LIT\MCP\Integrations\Divi\Pages\DiviComposition;

durch:

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:

use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility;

durch:

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:

    public function testReadOnlyNodesCannotBeUpdated(): void

durch:

    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:

        'lit/divi-update',
    ];

durch:

        '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

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

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

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:

use LIT\MCP\Integrations\Divi\Pages\DiviComposition;

durch:

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:

use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility;

durch:

use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviMoveAbility;

In src/Integrations/Divi/DiviIntegration.php ersetzen:

            new DiviUpdateAbility($writer, $this->composition),
        ];

durch:

            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:

`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:

`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:

`lit/media-update`, `lit/media-delete`).

durch:

`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
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

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

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:

        'lit/divi-duplicate-node',
    ];

durch:

        '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

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

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:

use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;

durch:

use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\Divi\Pages\DiviReplaceTextAbility;

In src/Integrations/Divi/DiviIntegration.php ersetzen:

            new DiviDuplicateNodeAbility($writer, $this->composition),
        ];

durch:

            new DiviDuplicateNodeAbility($writer, $this->composition),
            new DiviReplaceTextAbility($this->schema, $loader, $writer, $this->composition),
        ];

In docs/guides/overview.md ersetzen:

`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`).

durch:

`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
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

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

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

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

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:

use LIT\MCP\Abilities\Errors;

durch:

use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rev;

In src/Integrations/WordPress/Content/ContentCreateAbility.php ersetzen:

        private PostWriter $writer,
        private ChangeLog $log
    ) {
    }

durch:

        private PostWriter $writer,
        private ChangeLog $log,
        private ?ContentComposer $divi = null
    ) {
    }

In src/Integrations/WordPress/Content/ContentCreateAbility.php ersetzen:

            . 'in einem Schritt; bei einem Fehler wird nichts angelegt.';

durch:

            . '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:

                'title' => ['type' => 'string', 'minLength' => 1],
            ], PostWriter::inputProperties()),

durch:

                '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:

            'post_author' => get_current_user_id(),
        ]);

durch:

            '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:

        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:

        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:

        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:

        $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:

use LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility;

durch:

use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility;

In src/Integrations/WordPress/WordPressIntegration.php ersetzen:

    public function __construct(private ContentSchema $schema, private ChangeLog $log)
    {
    }

durch:

    public function __construct(
        private ContentSchema $schema,
        private ChangeLog $log,
        private ?ContentComposer $divi = null
    ) {
    }

In src/Integrations/WordPress/WordPressIntegration.php ersetzen:

            new ContentCreateAbility($this->schema, $writer, $this->log),

durch:

            new ContentCreateAbility($this->schema, $writer, $this->log, $this->divi),

In src/Integrations/Divi/DiviIntegration.php ersetzen:

use LIT\MCP\Integrations\Divi\Pages\DiviComposition;

durch:

use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;

In src/Integrations/Divi/DiviIntegration.php ersetzen:

use LIT\MCP\Integrations\Integration;

durch:

use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;

In src/Integrations/Divi/DiviIntegration.php ersetzen:

 * 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:

 * 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:

    public function abilities(): array
    {
        $loader

durch:

    public function contentComposer(): ContentComposer
    {
        return new DiviContentComposer($this->schema, $this->composition);
    }

    public function abilities(): array
    {
        $loader

In src/Plugin.php ersetzen:

        $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:

        $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
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:

        $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:

        $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:

        self::assertStringContainsString('lit-divi-outline und lit-divi-design-system lesen', $with);
        self::assertStringNotContainsString('lit-divi-outline und', $without);

durch:

        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:

    /** 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:

    /** 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:

            $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:

            $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:

        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:

        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:

        $catalog = $this->modules->catalog();
        $notes = [self::CODE_NOTE, 'Details zur Vollform: lit-guide mit topic "divi-attrs".'];

durch:

        $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:

        $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:

        $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:

{
    public const VERSION = '0.2.0';

durch:

{
    public const VERSION = '0.3.0';

In wp-lit-mcp.php ersetzen:

 * 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:

 * 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:

Requires PHP: 8.1
Stable tag: 0.2.0
License: GPL-2.0-or-later

durch:

Requires PHP: 8.1
Stable tag: 0.3.0
License: GPL-2.0-or-later

In readme.txt ersetzen:

  Transient lit_mcp_endpoint_check.
* Uploads über LIT MCP: nur Bilder (jpg, png, gif, webp, avif), PDF, MP4, MP3; Größenlimit einstellbar

durch:

  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:

  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:

  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:


= 0.2.0 =

durch:


= 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:

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

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


## Was nicht bearbeitet wird

durch:


## Ä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:


- 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:


- 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:


## Pfade finden

durch:


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

Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows`, `media`, `divi-structure`,
`divi-attrs` oder `divi-design-system`.

durch:

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:

- 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:

- 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:

6. Veröffentlichen erst, wenn der Nutzer es ausdrücklich möchte.

durch:

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:

- `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:

- `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:

- `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:

- `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:

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:

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:

#!/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:

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
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 „ – “, 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).