Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
278 KiB
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-MCPanlegen oder ändern. Integrationsskripte räumen Seiten, Benutzer, Rollen und die Optionlit_mcp_allowed_roleswieder 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 lintundcomposer testnach jedem Task sauber. src/Integrations/Divi/Compose/*ist reines PHP ohne WordPress (Spec §4.4); WordPress-Funktionen nur inDivi/Pages/(Adapter, Abilities) und über die SchnittstellenValueSanitizerundMediaResolver. Compose-Tests erben vonPHPUnit\Framework\TestCaseund nutzenComposeFixture(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 mitFunctions\expect()prüfen;error_log()perini_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 inDiviSaver, 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 mitunfiltered_html), alle anderen Strings →text()(sanitize_text_field);$variable(…)$-Verweise bleiben unverändert. tree_revist Pflicht für jede Divi-Änderung (Ruling P16); Abweichung →rev_mismatch, nichts geschrieben. Antworten liefern die neuetree_revundrev(Rev::ofPost).- Alles oder nichts: erst vollständig validieren, dann einmal speichern; Fehler →
validation_failedbzw.invalid_attr_pathmitfields(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 überPostChangeResult; scheitert das Speichern, wird der vorherige Inhalt zurückgeschrieben (WriteFailed, sonstpartial_write+ChangeLog::recordPartial). Status bleibt unverändert; verpasste Plantermine blockieren (PostGuard::statusStable). - Admin-Labels: ohne
labelautomatisch „ – “ (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 mitconfirm: true;lit/divi-replace-textnur mitdry_run: falseundtargetsaus dem Probelauf.
- Byte-Invariante: nur geänderte oder neue Blöcke bekommen neue Delimiter; alles andere bleibt byte-identisch – auch für Benutzer ohne
- 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 Toolslit-<name>, indirektelit/<name>; jeder Task mit neuem Tool aktualisiertdocs/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 imComposer. - Commits:
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit …mit den TrailernCo-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>undClaude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7.
Review Focus
- 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 ohneunfiltered_html). - Parallel im Divi-Builder gespeichert oder versehentlich
revstatttree_revgesendet:rev_mismatch, nichts geschrieben, Hinweis auflit-divi-outline→ Tests in Task 5 (DiviWriterTest::testPageChecksTheTreeRevLock) und Task 6 (testStaleTreeRevAndInvalidNodesWriteNothing). - 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). - 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). - Speichern scheitert mittendrin (Hook wirft, Datenbank weg) oder die Seite änderte sich zwischen Probelauf und Ersetzen: vorheriger Inhalt zurück bzw.
partial_writeim 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-HelferDesignSystemTest::sample(),ModuleCatalogTest::sampleCatalog(). -
Produces:
Placeholders(DesignSystem $design):POST(Maptitle→post_title…);static isVariable(string $v): bool;resolve(string $v): string(wirftInvalidArgumentExceptionmit 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, wirftInvalidArgumentException).ComposeFixture(Test-Helfer, wächst in Tasks 2–3):sanitizer(): ValueSanitizer(text =strip_tags+trim, html entfernt<script>, code unverändert, url lehntjavascript: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;$parentnull = 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-Version5.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 mitunfiltered_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>}(wirftWriteFailed).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 mittree_rev; wirftWriteFailed).
-
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(Codelit_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?}; ohneconfirm→lit_confirm_requiredmitnodes.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?}; OutputToolResult+dry_run,matches,skipped,next_cursorbzw.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 & 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 & 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); Eingabedivi(1–30 Objekte); Ergebnis beidivizusätzlichtree_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(nenntlit-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“):
- 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). - Zurück zu Claude: Nach dem Speichern im Builder eine Änderung per claude.ai verlangen –
rev_mismatcherscheint nur, wenn Claude eine altetree_revnutzt; nach erneutem Lesen gelingt die Änderung, im Builder gemachte Einstellungen bleiben erhalten. - Rückgängig: Einen Abschnitt löschen lassen (Rückfrage erscheint), danach über
lit/revision-restoremit der gemeldetenrevision_idzurückholen. - 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.
- Builder-Fixtures (aus 2a, falls noch offen): Seiten mit globalem Modul, Presets, Variablen und Dynamic Content mit
tests/Integration/divi-export-page.phpexportieren;composer testbleibt grün; danach auf einer Kopie einer solchen Seitelit-divi-updatean 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_pathmit Vorschlägen), Breakpoints und Hover/Sticky, Kombination Kurz + Voll, Struktur Abschnitt → Zeilen (columns) → Spalten → Module, Vollbreite übersection_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.7lit/divi-insert,lit/divi-update,lit/divi-move,lit/divi-delete,lit/divi-duplicate-node,lit/divi-replace-text→ Tasks 6–8; §6.2lit/content-createmit Divi-Kurzform → Task 9; §8.1ToolResultmitchanged,outline,rev,tree_rev,warnings,revision_id,log_id,preview_url,edit_url(&et_fb=1) → Task 5; §8.2 Codesrev_mismatch,invalid_attr_path,validation_failed,confirm_required,unsupported, alles oder nichts, Stapel je Eintrag → Tasks 5–8; §9.1/§9.2 Regeln und Guidesdivi-shortform,divi-placeholders→ Task 10; §10 Bereinigung (wp_kses_post,sanitize_text_field) undconfirm: 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_htmlalle Blöcke → D7 (DiviSaver, Readme, E2E-Abschnitt 7); Spaltenformat von Divi 5.2 (Flex,flexType) nicht in der Spec → D8;divi-replace-textohne Sperre je Seite → D10 (targetsmittree_rev); Platzhalter mitten im Text unklar → D9 (nur ganzer Wert);content-createmit 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ückgabeblocks|attrs,errors,invalid_paths),TreeEditor::position(Arraycontainer/parent/indexoder String),DiviChange::changed/removed/entries/touched/warnings,DiviWriter::page/save/inputProperties/positionProperties/sectionLabel,DiviSaver::save(Rückgaberevision_id,warnings),DiviComposition::composer/attrWriter/rules/editor/warnings,ContentComposer::available/compose/afterInsert,Errors::invalidAttrPathstimmen 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 testgrü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).