# Etappe 2b – Divi-Seiten schreiben: Kurz-/Vollform, Platzhalter, Einfügen bis Ersetzen – Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Claude baut und pflegt Divi-5-Seiten: Abschnitte, Zeilen und Module in Kurz- oder Vollform einfügen, ändern, verschieben, duplizieren und löschen, Text seitenübergreifend ersetzen und neue Seiten direkt mit Divi-Inhalt anlegen – mit Platzhaltern für das Designsystem, Qualitätswarnungen, `tree_rev`-Sperre, Revision, Protokoll, Cache-Leerung und byte-identischem Rest der Seite. **Architecture:** Reines PHP in `Divi/Compose/`: `Placeholders` (Spec-§5.4-Platzhalter → `$variable(…)$`), `AttrWriter` (Vollform, Pfadprüfung gegen den Katalog), `ShortformWriter` (Kurzform nach `ShortformMap`), `StructureRules` + `Composer` (Knotenbeschreibung → neue Blöcke, Spalten über `flexType`, Presets, Admin-Labels), `TreeEditor` (Position, Einfügen, Verschieben, Entfernen, Duplizieren), `Warnings` (§5.5), `TextReplacer`. WordPress-Seite in `Divi/Pages/`: `DiviWriter` (Sperre, Speichern, Protokoll, `ToolResult`) mit `DiviSaver` (Speicher-Pipeline §5.6 inkl. kses-Umgehung, D7), `DiviComposition` (verdrahtet Katalog, Designsystem, `WpValueSanitizer`, `WpMediaResolver`), sechs Schreib-Abilities und `DiviContentComposer` für `lit/content-create`. **Tech Stack:** PHP ≥ 8.1, WordPress 7.0 (Abilities API, `wp_update_post`, kses), Divi 5.2.1 (`ET_Core_PageResource::remove_static_resources`), `wordpress/mcp-adapter` 0.5, `wp-media/mcp-oauth` 1.1, PHPUnit 9.6 + Brain Monkey + Mockery, phpcs, DDEV. **Spec:** `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` (insb. §2, §5.4–5.7, §6.2, §8.1–8.2, §9, §10, §11.3, §12 Etappe 2) und die Entscheidungen `docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md` (D1–D12) **Voraussetzung:** Plan `docs/superpowers/plans/2026-09-26-etappe-2a-divi-lesen.md` ist vollständig umgesetzt (BlockTree, `NodeIndex`, `AttrMap`, `NodeSummary`, `Outline`, `ModuleCatalog`, `ShortformMap`, `DiviModules`, `DesignSystem`, `DesignSystemReader`, `DiviPage`, `DiviPageLoader` inkl. `writable()`, `DiviIntegration`, die Test-Helfer `DiviPageLoaderTest::fixture/givenPost/schema`, `DesignSystemTest::sample`, `ModuleCatalogTest::sampleCatalog`, Fixtures in `tests/Fixtures/divi/`). ## Global Constraints - Dateien ausschließlich im Plugin-Verzeichnis `/home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP` anlegen oder ändern. Integrationsskripte räumen Seiten, Benutzer, Rollen und die Option `lit_mcp_allowed_roles` wieder auf. - PHP ≥ 8.1 ohne 8.2+-Features; WordPress ≥ 6.9 (getestet 7.0); Divi 5.2.1. - Namespace `LIT\MCP\` → `src/`, PSR-4, eine Klasse pro Datei, PSR-12, höchstens 120 Zeichen pro Zeile; `composer lint` und `composer test` nach jedem Task sauber. - `src/Integrations/Divi/Compose/*` ist reines PHP ohne WordPress (Spec §4.4); WordPress-Funktionen nur in `Divi/Pages/` (Adapter, Abilities) und über die Schnittstellen `ValueSanitizer` und `MediaResolver`. Compose-Tests erben von `PHPUnit\Framework\TestCase` und nutzen `ComposeFixture` (echter 5.2.1-Katalog, Beispiel-Designsystem, vorhersagbare Fakes). - Unit-Tests der Abilities über `LIT\MCP\Tests\Unit\TestCase` (Brain Monkey + Mockery); vorbelegte Stubs (`wp_json_encode`, `is_wp_error`, `absint`, `wp_slash`, `wp_unslash`, `sanitize_text_field`) nie mit `Functions\expect()` prüfen; `error_log()` per `ini_set('error_log', …)` umleiten. - Integrationstests im DDEV-Container: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/.sh`. - **Sicherheitsregeln Spec §5 (verbindlich für jeden Schreibpfad):** - Byte-Invariante: nur geänderte oder neue Blöcke bekommen neue Delimiter; alles andere bleibt byte-identisch – auch für Benutzer ohne `unfiltered_html` (kses-Umgehung nur in `DiviSaver`, D7). - Jeder Wert, den LIT MCP in Divi-Attribute schreibt, läuft durch `ValueSanitizer`: `…innerContent` → `html()` (`wp_kses_post`), URL-Schlüssel (`…url`, `…src`, `…link`) → `url()` (`esc_url_raw`), Code-Modul → `code()` (ungefiltert nur mit `unfiltered_html`), alle anderen Strings → `text()` (`sanitize_text_field`); `$variable(…)$`-Verweise bleiben unverändert. - `tree_rev` ist Pflicht für jede Divi-Änderung (Ruling P16); Abweichung → `rev_mismatch`, nichts geschrieben. Antworten liefern die neue `tree_rev` und `rev` (`Rev::ofPost`). - Alles oder nichts: erst vollständig validieren, dann einmal speichern; Fehler → `validation_failed` bzw. `invalid_attr_path` mit `fields` (Eingabepfad → Problem). - Vor dem Speichern Wiederherstellungspunkt (`RevisionPoint::capture`), danach Divi-Flags `_et_pb_use_divi_5`/`_et_pb_use_builder` = `on`, `ET_Core_PageResource::remove_static_resources($id, 'all', true)`, Protokoll über `PostChangeResult`; scheitert das Speichern, wird der vorherige Inhalt zurückgeschrieben (`WriteFailed`, sonst `partial_write` + `ChangeLog::recordPartial`). Status bleibt unverändert; verpasste Plantermine blockieren (`PostGuard::statusStable`). - Admin-Labels: ohne `label` automatisch „ – “ (Spec §5.5); Code-Modul erlaubt, aber mit Warnung; Platzhalter nach Spec §5.4 (allein im Wert, D9); Warnungen nach §5.5 (ähnliche Abschnitte ab Etappe 3, D6). - Destruktiv (`lit/divi-delete`, `lit/divi-replace-text`) nur mit `confirm: true`; `lit/divi-replace-text` nur mit `dry_run: false` und `targets` aus dem Probelauf. - Ability-Namen und Annotations: `lit/divi-insert` (direkt; idempotent false), `lit/divi-update` (direkt; idempotent true), `lit/divi-move` (direkt), `lit/divi-delete` (direkt; destructive true), `lit/divi-duplicate-node` (indirekt), `lit/divi-replace-text` (indirekt; destructive true). Output: `ToolResult::schema(['tree_rev' => …], ['tree_rev'])`. Texte nennen direkte Tools `lit-`, indirekte `lit/`; jeder Task mit neuem Tool aktualisiert `docs/guides/overview.md` (Ruling Q2). - JSON Schema im von `rest_validate_value_from_schema()` geprüften Umfang (kein `$ref`): Knotenbeschreibungen sind `{"type": "object"}`-Einträge, geprüft wird im `Composer`. - Commits: `git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit …` mit den Trailern `Co-Authored-By: Claude Opus 5.5 (1M context) ` und `Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7`. ## Review Focus 1. **Redakteur ohne `unfiltered_html` (z. B. auf Multisite auch Administratoren) ändert eine Divi-Seite:** Ohne Gegenmaßnahme würde WordPress' kses jeden unveränderten Block neu serialisieren (in DDEV nachgewiesen). Erwartet: unberührte Blöcke byte-identisch, neue Werte trotzdem bereinigt (kein `#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 */ 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 */ 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 [ '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' => 'Neu'], ], '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('Neu', $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('

x

', $writer->cleanValue('divi/text', 'content.innerContent', '

x

')); self::assertSame('', $writer->cleanValue('divi/code', 'content.innerContent', '')); self::assertSame('10px', $writer->cleanValue('divi/text', 'module.decoration.spacing', '10px')); 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 }}, {{var:}}, {{font:heading|body}}, {{post:title|…}}, * {{field:}}. A placeholder must be the whole value; unknown names fail with the * list of existing ones. Pure PHP. */ final class Placeholders { /** {{post:}} => 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|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 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 $attrs Current attributes of the block. * @param array $changes Path => breakpoint map, or null to remove the path. * @return array{attrs: array, errors: array, invalid_paths: list} */ 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> 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> $normalized * @return array> * @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> $changes * @return array */ private static function merge(mixed $existing, array $changes): array { $map = is_array($existing) && AttrMap::isBreakpointMap($existing) ? $existing : []; foreach ($changes as $breakpoint => $states) { $current = is_array($map[$breakpoint] ?? null) ? $map[$breakpoint] : []; foreach ($states as $state => $value) { if ($value === null) { unset($current[$state]); } else { $current[$state] = $value; } } if ($current === []) { unset($map[$breakpoint]); } else { $map[$breakpoint] = $current; } } return $map; } } ``` - [ ] **Step 4: Tests und Lint (grün)** Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'PlaceholdersTest|AttrWriterTest' && composer lint` Expected: PASS, phpcs ohne Meldungen. - [ ] **Step 5: Commit** ```bash cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP git add src/Integrations/Divi/Compose/ tests/Unit/Integrations/Divi/Compose/ git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi placeholders and full-form attribute writer Co-Authored-By: Claude Opus 5.5 (1M context) 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 `.desktop.value[.]` 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 `"."`); `ComposeFixture::shortform(): ShortformWriter`. - [ ] **Step 1: Failing Tests** In `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` ersetzen: ```php use LIT\MCP\Integrations\Divi\Compose\Placeholders; ``` durch: ```php use LIT\MCP\Integrations\Divi\Compose\Placeholders; use LIT\MCP\Integrations\Divi\Compose\ShortformWriter; ``` In `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` (Methode ergänzen) ersetzen: ```php public static function attrWriter(): AttrWriter { return new AttrWriter(ModuleCatalogTest::sampleCatalog(), self::placeholders(), self::sanitizer()); } ``` durch: ```php public static function attrWriter(): AttrWriter { return new AttrWriter(ModuleCatalogTest::sampleCatalog(), self::placeholders(), self::sanitizer()); } public static function shortform(): ShortformWriter { return new ShortformWriter(self::placeholders(), self::sanitizer(), self::media(), self::attrWriter()); } ``` `tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php`: ```php apply('button', [], [ 'text' => 'Termin buchen', '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 .desktop.value[.]". * 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 $attrs Current attributes. * @param array $values Field => value. * @return array{attrs: array, errors: array} */ 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 $attrs * @param array $spec * @param array $all All values of this call (media must not overwrite an explicit alt). * @return array * @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 $attrs * @param array{0: string, 1: ?string, 2: ?string} $keys URL key, ID key, alt key. * @return array * @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 $spec * @throws InvalidArgumentException */ private function value(string $module, array $spec, mixed $value): mixed { switch ($spec['kind']) { case 'onoff': if (is_bool($value)) { return $value ? 'on' : 'off'; } if ($value === 'on' || $value === 'off') { return $value; } throw new InvalidArgumentException('true oder false erwartet'); case 'level': if (is_string($value) && preg_match('/^h[1-6]$/', $value) === 1) { return $value; } throw new InvalidArgumentException('h1 bis h6 erwartet'); case 'number': if (is_int($value) || is_float($value) || (is_string($value) && is_numeric($value))) { return $value + 0; } throw new InvalidArgumentException('Zahl erwartet'); case 'enum': if (is_string($value) && in_array($value, $spec['values'], true)) { return $value; } throw new InvalidArgumentException('erlaubt: ' . implode(', ', $spec['values'])); case 'ids': return $this->ids($value); } if (! is_string($value) && ! is_int($value) && ! is_float($value)) { throw new InvalidArgumentException('Text erwartet'); } $value = (string) $value; if ($spec['kind'] !== 'code') { $value = $this->placeholders->resolve($value); if (Placeholders::isVariable($value)) { return $value; } } if ($spec['kind'] === 'color') { if (preg_match(self::COLOR, trim($value)) !== 1) { throw new InvalidArgumentException('Farbe erwartet, z. B. {{color:primary}} oder #1a1a1a'); } return $this->sanitizer->text($value); } if ($spec['kind'] === 'url') { $url = $this->sanitizer->url($value); if ($url === '' && trim($value) !== '') { throw new InvalidArgumentException('ungültige URL'); } return $url; } return match ($spec['kind']) { 'html', 'code' => $this->attrs->cleanValue($module, (string) $spec['path'], $value), default => $this->sanitizer->text($value), }; } /** @throws InvalidArgumentException */ private function ids(mixed $value): string { if (! is_array($value) || $value === [] || ! array_is_list($value)) { throw new InvalidArgumentException('Liste von Anhang-IDs erwartet, z. B. [12, 33]'); } $ids = []; foreach ($value as $id) { $id = is_int($id) ? $id : (is_string($id) && ctype_digit($id) ? (int) $id : 0); if ($id <= 0 || $this->media->image($id) === null) { throw new InvalidArgumentException(sprintf('%s ist kein Bild aus der Mediathek', (string) $id)); } $ids[] = $id; } return implode(',', $ids); } } ``` - [ ] **Step 4: Tests und Lint (grün)** Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter Compose && composer lint` Expected: PASS, phpcs ohne Meldungen. - [ ] **Step 5: Commit** ```bash cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP git add src/Integrations/Divi/Compose/ShortformWriter.php tests/Unit/Integrations/Divi/Compose/ git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi short-form writer Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" ``` --- ### Task 3: Aus Beschreibungen Blöcke bauen – `StructureRules` und `Composer` Spec §5.4 „Struktur“ und §5.5: Abschnitt → Zeilen (`"columns": "1_3,2_3"`) → Spalten → Module; Vollbreite über `section_type: "fullwidth"`; Kind-Module in `items`; `preset` per Name (Modul-Presets aus dem Designsystem); fehlendes `label` → „ – “; `heading` ohne `level` → `h2` (D9); jeder neue Block bekommt `builderVersion` (live `ET_BUILDER_PRODUCT_VERSION`). Spaltenbreiten über `module.decoration.sizing.desktop.value.flexType` wie Divi 5.2 (D8; `a_b` mit 24 % b = 0 → `(a·24/b)_24`, sonst Fünftel und 1/7, 1/9, 1/10, 1/11; Summe = 1). `StructureRules` prüft, was wohin darf (Seite → Abschnitte, regulärer Abschnitt → Zeilen, Vollbreite → `fullwidth-*`, Spalte/Gruppe → Module, Elternmodul → seine Kind-Module; Spalten nur mit ihrer Zeile; Spezialabschnitte und nur lesbare Knoten nicht). Alle Fehler werden mit Eingabepfad gesammelt. `update()` wendet `set`/`attrs`/`preset`/`label` auf einen vorhandenen Block an (für `lit-divi-update`). **Files:** - Create: `src/Integrations/Divi/Compose/{StructureRules,Composer}.php` - Modify: `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` (`rules()`, `composer()`) - Test: `tests/Unit/Integrations/Divi/Compose/ComposerTest.php` **Interfaces:** - Consumes: `Block::create()/insertChild()/name()/attrs()/isDivi()/isOpaque()`, `AttrMap::set/desktop` (2a), `ModuleCatalog::has/category/childrenOf/title/normalize` (2a), `ShortformMap::fields()` (2a), `DesignSystem::modulePresetId/modulePresetNames` (2a), `ShortformWriter`, `AttrWriter` (Tasks 1–2). - Produces: - `StructureRules(ModuleCatalog)`: `violation(?Block $parent, string $child): ?string` (null = erlaubt; `$parent` null = oberste Ebene); `isPlainModule(string $name): bool`. - `Composer(ModuleCatalog, StructureRules, ShortformWriter, AttrWriter, DesignSystem, string $builderVersion)`: `compose(array $specs, ?Block $parent, string $field, string $context = ''): array{blocks: list, errors: array, invalid_paths: list}` (Fehlerschlüssel z. B. `nodes[0].rows[1].modules[0][2].text`); `update(Block $block, array $change, string $field): array{attrs, errors, invalid_paths}`; `autoLabel(string $name, string $context): string`. - `ComposeFixture::rules()`, `ComposeFixture::composer()` (Builder-Version `5.2.1`). - [ ] **Step 1: Failing Tests** In `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` ersetzen: ```php use LIT\MCP\Integrations\Divi\Compose\AttrWriter; ``` durch: ```php use LIT\MCP\Integrations\Divi\Compose\AttrWriter; use LIT\MCP\Integrations\Divi\Compose\Composer; ``` In `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` ersetzen: ```php use LIT\MCP\Integrations\Divi\Compose\ShortformWriter; ``` durch: ```php use LIT\MCP\Integrations\Divi\Compose\ShortformWriter; use LIT\MCP\Integrations\Divi\Compose\StructureRules; ``` In `tests/Unit/Integrations/Divi/Compose/ComposeFixture.php` (Methoden ergänzen) ersetzen: ```php public static function shortform(): ShortformWriter { return new ShortformWriter(self::placeholders(), self::sanitizer(), self::media(), self::attrWriter()); } } ``` durch: ```php public static function shortform(): ShortformWriter { return new ShortformWriter(self::placeholders(), self::sanitizer(), self::media(), self::attrWriter()); } public static function rules(): StructureRules { return new StructureRules(ModuleCatalogTest::sampleCatalog()); } public static function composer(): Composer { return new Composer( ModuleCatalogTest::sampleCatalog(), self::rules(), self::shortform(), self::attrWriter(), DesignSystemTest::sample(), '5.2.1' ); } } ``` `tests/Unit/Integrations/Divi/Compose/ComposerTest.php`: ```php 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( '', 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' => '

Antwort

', 'open' => true]]], ['type' => 'text', 'content' => '

x

', '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 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 – " (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 */ private array $errors = []; /** @var list */ 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 $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, errors: array, invalid_paths: list} */ 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 $change set, attrs, preset, label * @return array{attrs: array, errors: array, invalid_paths: list} */ 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: " – " 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 $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 $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 $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 $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 $attrs * @param array $spec * @return array */ 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 $attrs * @return array */ 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 $attrs * @return array */ 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 */ 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 $spec * @param list $allowed */ private function unknownKeys(array $spec, array $allowed, string $path): void { foreach (array_diff(array_map('strval', array_keys($spec)), $allowed) as $key) { $this->errors[$path . '.' . $key] = 'unbekanntes Feld; erlaubt: ' . implode(', ', $allowed); } } private function clean(string $label): string { try { return (string) $this->attrWriter->cleanValue('divi/section', 'module.meta.adminLabel', $label); } catch (InvalidArgumentException) { return $label; } } } ``` - [ ] **Step 4: Tests und Lint (grün)** Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter Compose && composer lint` Expected: PASS (u. a. die exakte Markup-Erwartung in `testBuildsASectionFromTheShortForm` und dass der Parser das erzeugte Markup unverändert zurückgibt), phpcs ohne Meldungen. - [ ] **Step 5: Commit** ```bash cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP git add src/Integrations/Divi/Compose/StructureRules.php src/Integrations/Divi/Compose/Composer.php tests/Unit/Integrations/Divi/Compose/ git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi composer – short/full form to blocks with structure rules, presets and labels Co-Authored-By: Claude Opus 5.5 (1M context) 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, `

`–`

` 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 $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 $touched): list` (Format `": "`, IDs nach der Änderung). - [ ] **Step 1: Failing Tests** `tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php`: ```php 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 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' => '
x
'], ['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 $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 $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 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 $touched Created or changed blocks (their descendants count too). * @return list */ 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 */ 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 */ 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 */ 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 */ 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 */ 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 $touched * @return list */ 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 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('/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 $values * @return list [last key, value] of every string leaf (variables excluded). */ private function leaves(array $values): array { $leaves = []; foreach ($values as $name => $value) { if (is_array($value)) { array_push($leaves, ...$this->leaves($value)); } elseif (is_string($value) && ! Placeholders::isVariable($value)) { $leaves[] = [(string) $name, $value]; } } return $leaves; } /** @return array{int, int, int}|null */ private static function rgb(string $hex): ?array { $hex = ltrim(trim($hex), '#'); if (preg_match('/^([0-9a-f]{3}|[0-9a-f]{6})$/i', $hex) !== 1) { return null; } if (strlen($hex) === 3) { $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; } return [(int) hexdec(substr($hex, 0, 2)), (int) hexdec(substr($hex, 2, 2)), (int) hexdec(substr($hex, 4, 2))]; } /** * @param array{int, int, int} $a * @param array{int, int, int} $b */ private static function distance(array $a, array $b): float { return sqrt(($a[0] - $b[0]) ** 2 + ($a[1] - $b[1]) ** 2 + ($a[2] - $b[2]) ** 2); } } ``` - [ ] **Step 4: Tests und Lint (grün)** Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter Compose && composer lint` Expected: PASS, phpcs ohne Meldungen. - [ ] **Step 5: Commit** ```bash cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP git add src/Integrations/Divi/Compose/TreeEditor.php src/Integrations/Divi/Compose/Warnings.php tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php tests/Unit/Integrations/Divi/Compose/WarningsTest.php git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi tree editor and quality warnings Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" ``` --- ### Task 5: Speicher-Pipeline – `DiviSaver`, `DiviWriter`, `DiviComposition` Spec §5.6 und D7: `DiviSaver` legt den Wiederherstellungspunkt an (`RevisionPoint::capture`), speichert mit `wp_slash()` über `wp_update_post()` (Status unverändert) und schaltet dabei **nur für Benutzer ohne `unfiltered_html`** kses aus (`kses_remove_filters()` … `kses_init_filters()` im `finally`) – sonst würde `filter_block_content()` jeden unberührten Block neu serialisieren. Danach Divi-Flags und Divi-CSS-Cache (Fehler dort nur als Warnung über `FollowUp`), und eine Warnung, falls ein Filter den gespeicherten Inhalt verändert hat. Wirft ein Hook, wird der vorherige Inhalt zurückgeschrieben (`WriteFailed` rolled back; misslingt auch das: `partial_write`). `DiviWriter` ist der gemeinsame Rahmen aller Divi-Schreib-Tools: `page()` prüft Rechte, `writable()` und die `tree_rev`-Sperre (Ruling P16); `save()` serialisiert, speichert (nichts, wenn sich nichts ändert), ruft `lit_mcp_after_save`, protokolliert und baut das `ToolResult` (geänderte Knoten mit neuen IDs, Outline der betroffenen Abschnitte, Warnungen, `revision_id`, `rev`, `tree_rev`, `preview_url`, `edit_url` mit `et_fb=1`). `DiviComposition` verdrahtet die Compose-Klassen einmal je Anfrage mit Live-Katalog, Designsystem, `WpValueSanitizer` und `WpMediaResolver`. **Files:** - Create: `src/Integrations/Divi/Pages/{WpValueSanitizer,WpMediaResolver,DiviComposition,DiviChange,DiviSaver,DiviWriter}.php` - Test: `tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php` **Interfaces:** - Consumes: `RevisionPoint::capture`, `PostGuard::missedSchedule`, `FollowUp::run`, `Errors::revMismatch/notSavedKeeping`, `WriteFailed`, `ToolResult`, `Rev::of/ofPost`, `PostChangeResult` (`afterSave`, `reread`, `log`, `result`), `ChangeLog::recordPartial` (Etappe 1); `DiviPageLoader::load/writable/OUTLINE_HINT`, `DiviPage`, `NodeIndex`, `NodeSummary::label`, `Outline`, `Serializer::document`, `DesignSystemReader`, `DiviModules` (2a); Compose-Klassen (Tasks 1–4). - Produces: - `WpValueSanitizer implements ValueSanitizer` (`sanitize_text_field`, `wp_kses_post`, Code ungefiltert nur mit `unfiltered_html`, `esc_url_raw`); `WpMediaResolver implements MediaResolver` (nur Bilder, die der Benutzer lesen darf). - `DiviComposition` (nicht final): `__construct(DiviModules, DesignSystemReader, ValueSanitizer = new WpValueSanitizer(), MediaResolver = new WpMediaResolver())`; `composer(): Composer`; `attrWriter(): AttrWriter`; `rules(): StructureRules`; `editor(): TreeEditor`; `warnings(): Warnings`. - `DiviChange(string $summary)`: `changed(Block $b, string $action): self` (inserted/updated/moved/duplicated; alles außer moved zählt für Warnungen); `removed(string $idBefore, Block $b): self`; `warning(string): self`; `entries()`, `touched()`, `warnings()`; `readonly string $summary`. - `DiviSaver` (nicht final): `REASON`, `CHANGED_ON_SAVE`; `save(WP_Post $post, string $content): array{revision_id: ?int, warnings: list}` (wirft `WriteFailed`). - `DiviWriter` (nicht final): `__construct(DiviPageLoader, DiviSaver, ChangeLog, DesignSystemReader, DiviComposition)`; `static inputProperties(): array` (`post_id`, `tree_rev`); `static positionProperties(): array` (`parent`, `before`, `after`, `index`); `page(array $input): DiviPage|WP_Error`; `static sectionLabel(NodeIndex $index, ?Block $block): string`; `save(string $ability, DiviPage $page, DiviChange $change, array $input): array` (ToolResult-Array mit `tree_rev`; wirft `WriteFailed`). - [ ] **Step 1: Failing Tests** `tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php`: ```php */ 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 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 $url, 'alt' => (string) get_post_meta($id, '_wp_attachment_image_alt', true)]; } } ``` `src/Integrations/Divi/Pages/DiviComposition.php`: ```php 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 */ private array $changed = []; /** @var list */ private array $touched = []; /** @var list */ 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 */ public function entries(): array { return $this->changed; } /** @return list */ public function touched(): array { return $this->touched; } /** @return list */ public function warnings(): array { return $this->warnings; } } ``` `src/Integrations/Divi/Pages/DiviSaver.php`: ```php } * @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 > 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> 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 $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 $input * @return array * @throws WriteFailed */ public function save(string $ability, DiviPage $page, DiviChange $change, array $input): array { $post = $page->post; $content = Serializer::document($page->document); if ($content === (string) $post->post_content) { return ToolResult::ok('Keine Änderung – die Seite ist bereits so.') ->rev(Rev::ofPost($post)) ->with('tree_rev', $page->treeRev()) ->warnings($change->warnings()) ->toArray(); } $index = NodeIndex::build($page->document); $quality = $this->composition->warnings()->collect($index, $change->touched()); $warnings = array_merge($change->warnings(), $quality); try { $saved = $this->saver->save($post, $content); } catch (WriteFailed $e) { if (! $e->rolledBack) { $this->log->recordPartial( $ability, (string) $post->post_type, (string) $post->ID, sprintf('Divi "%s": Änderung nur teilweise zurückgenommen', $post->post_title), $e->revisionId ); } throw $e; } $result = (new PostChangeResult( $ability, $this->log, (int) $post->ID, 'die Änderung', array_merge($warnings, $saved['warnings']) ))->afterSave($input); $updated = $result->reread($post) ?? $post; $summary = sprintf('Divi "%s": %s', $post->post_title, $change->summary); $result->log((string) $post->post_type, $summary, $saved['revision_id']); $status = sprintf('%s (Status unverändert: %s).', $change->summary, $updated->post_status); $tool = $result->result($status, $updated) ->revisionId($saved['revision_id']) ->outline($this->outline($index, $change)) ->with('tree_rev', Rev::of((string) $updated->post_content)) ->urls((string) get_preview_post_link($updated), $page->editUrl()); foreach ($change->entries() as $entry) { $id = $entry['id'] ?? ($entry['block'] !== null ? $index->idOf($entry['block']) : null); $tool->changed($id ?? '?', $entry['type'], $entry['action'], $entry['label']); } return $tool->toArray(); } /** Outline of the sections that contain changed nodes. */ private function outline(NodeIndex $index, DiviChange $change): ?string { $sections = []; foreach ($change->entries() as $entry) { $id = $entry['block'] !== null ? $index->idOf($entry['block']) : null; if ($id !== null) { $sections[explode('.', $id)[0]] = true; } } if ($sections === []) { return null; } $outline = new Outline($this->design->read()->presetNames()); $texts = []; foreach (array_keys($sections) as $sectionId) { $texts[] = $outline->text($outline->rows($index, (string) $sectionId)); } return implode("\n", $texts); } private static function parentBlock(NodeIndex $index, Block $block): ?Block { $parent = $index->parentOf($block); return $parent instanceof Block && $parent->name() !== 'divi/placeholder' ? $parent : null; } } ``` - [ ] **Step 4: Tests und Lint (grün)** Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test && composer lint` Expected: PASS, phpcs ohne Meldungen. - [ ] **Step 5: Commit** ```bash cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP git add src/Integrations/Divi/Pages/WpValueSanitizer.php src/Integrations/Divi/Pages/WpMediaResolver.php src/Integrations/Divi/Pages/DiviComposition.php src/Integrations/Divi/Pages/DiviChange.php src/Integrations/Divi/Pages/DiviSaver.php src/Integrations/Divi/Pages/DiviWriter.php tests/Unit/Integrations/Divi/Pages/DiviSaverTest.php tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: Divi save pipeline – tree_rev lock, kses-safe save, revision, flags, cache, ToolResult Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" ``` --- ### Task 6: `lit/divi-insert` und `lit/divi-update` Spec §5.7: Einfügen von Knoten in Kurz- und/oder Vollform – Module, Zeilen oder ganze Abschnitte – an `parent`/`before`/`after` (ohne Position ans Seitenende); Ändern mehrerer vorhandener Knoten in einem Aufruf (`changes` mit `set`, `attrs`, `preset`, `label`), alles oder nichts. Unbekannte Attributpfade → neuer Fehlercode `invalid_attr_path` (Spec §8.2) mit `fields`; nur lesbare Knoten (globale Module, fremde Blöcke) lassen sich nicht ändern. Die Divi-Integration bekommt dafür das Änderungsprotokoll. **Files:** - Create: `src/Integrations/Divi/Pages/{DiviInsertAbility,DiviUpdateAbility}.php` - Modify: `src/Abilities/Errors.php` (`invalidAttrPath()`), `src/Integrations/Divi/DiviIntegration.php` (ganze Datei ersetzen), `src/Plugin.php`, `docs/guides/overview.md` - Test: `tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` **Interfaces:** - Consumes: `DiviWriter::page/save/inputProperties/positionProperties/sectionLabel`, `DiviChange`, `DiviComposition::composer/editor` (Task 5); `Composer::compose/update`, `TreeEditor::position/insert` (Tasks 3–4); `ToolResult::schema`. - Produces: - `Errors::invalidAttrPath(array $fields): WP_Error` (Code `lit_invalid_attr_path`, Status 400). - `DiviInsertAbility(DiviWriter, DiviComposition)` – `lit/divi-insert`, direkt; Input `{post_id, tree_rev, nodes (1–20 Objekte), parent?, before?, after?, index?}`. - `DiviUpdateAbility(DiviWriter, DiviComposition)` – `lit/divi-update`, direkt, idempotent; Input `{post_id, tree_rev, changes: [{id, set?, attrs?, preset?, label?}] (1–50)}`. - `DiviIntegration(ContentSchema $schema, ChangeLog $log)` (neue Signatur). - [ ] **Step 1: Failing Tests** `tests/Unit/Integrations/Divi/Pages/DiviWriteAbilitiesTest.php`: ```php 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' => '

Neu

']], ]); 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 = ''; self::assertSame(str_replace('' . $inserted . ''; 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' => '

x

']); 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 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('', $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 $sections * @return array{content: string, warnings: list}|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 schema->diviEnabled($postType)) { return Errors::invalidInput( 'Für diesen Inhaltstyp ist der Divi-Builder nicht aktiviert; nichts wurde angelegt.', ['divi' => 'Divi-Builder für "' . $postType . '" nicht aktiv'] ); } $composed = $this->composition->composer()->compose(array_values($sections), null, 'divi'); if ($composed['errors'] !== []) { return $composed['invalid_paths'] !== [] ? Errors::invalidAttrPath($composed['errors']) : Errors::invalidInput('Divi-Inhalt ist ungültig; nichts wurde angelegt.', $composed['errors']); } $document = new Document(); $document->append(Block::create('divi/placeholder', [], $composed['blocks'])); return [ 'content' => Serializer::document($document), 'warnings' => $this->composition->warnings()->collect(NodeIndex::build($document), $composed['blocks']), ]; } public function afterInsert(int $postId): void { if ( update_post_meta($postId, '_et_pb_use_divi_5', 'on') === false || update_post_meta($postId, '_et_pb_use_builder', 'on') === false ) { throw new RuntimeException(sprintf('Divi builder flags of post %d could not be set.', $postId)); } } } ``` In `src/Integrations/WordPress/Content/ContentCreateAbility.php` ersetzen: ```php use LIT\MCP\Abilities\Errors; ``` durch: ```php use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Rev; ``` In `src/Integrations/WordPress/Content/ContentCreateAbility.php` ersetzen: ```php private PostWriter $writer, private ChangeLog $log ) { } ``` durch: ```php private PostWriter $writer, private ChangeLog $log, private ?ContentComposer $divi = null ) { } ``` In `src/Integrations/WordPress/Content/ContentCreateAbility.php` ersetzen: ```php . 'in einem Schritt; bei einem Fehler wird nichts angelegt.'; ``` durch: ```php . 'in einem Schritt; bei einem Fehler wird nichts angelegt. Divi-Seiten direkt mit Inhalt: divi = Liste ' . 'von Abschnitten in Kurzform (lit-guide topic "divi-shortform"), statt content.'; ``` In `src/Integrations/WordPress/Content/ContentCreateAbility.php` ersetzen: ```php 'title' => ['type' => 'string', 'minLength' => 1], ], PostWriter::inputProperties()), ``` durch: ```php 'title' => ['type' => 'string', 'minLength' => 1], 'divi' => [ 'type' => 'array', 'minItems' => 1, 'maxItems' => 30, 'items' => ['type' => 'object'], 'description' => 'Divi-Layout in Kurzform: Abschnitte wie bei lit-divi-insert (nicht zusammen mit ' . 'content).', ], ], PostWriter::inputProperties()), ``` In `src/Integrations/WordPress/Content/ContentCreateAbility.php` ersetzen: ```php 'post_author' => get_current_user_id(), ]); ``` durch: ```php 'post_author' => get_current_user_id(), ]); $warnings = $prepared['warnings']; if (array_key_exists('divi', $input)) { $divi = $this->composeDivi($postType, $input); if ($divi instanceof WP_Error) { return $divi; } $data['post_content'] = $divi['content']; $warnings = array_merge($warnings, $divi['warnings']); } ``` In `src/Integrations/WordPress/Content/ContentCreateAbility.php` ersetzen: ```php try { $this->writer->writeExtras($postId, $postType, $prepared, $input); } catch (Throwable $e) { $this->discard($postId, $postType, $e); } return $this->result($postId, $postType, $input, $prepared['warnings']); } ``` durch: ```php try { $this->writer->writeExtras($postId, $postType, $prepared, $input); if (array_key_exists('divi', $input) && $this->divi !== null) { $this->divi->afterInsert($postId); } } catch (Throwable $e) { $this->discard($postId, $postType, $e); } return $this->result($postId, $postType, $input, $warnings); } /** * Divi layout from the short form (spec §6.2) – validated completely before the insert. * * @param array $input * @return array{content: string, warnings: list}|WP_Error */ private function composeDivi(string $postType, array $input): array|WP_Error { if ($this->divi === null || ! $this->divi->available()) { return Errors::unsupported( 'Divi-Inhalt ist auf dieser Website nicht möglich (Divi 5 ist nicht aktiv); nichts wurde angelegt.', 'Ohne divi anlegen und den Inhalt über content oder im Editor setzen.' ); } if (array_key_exists('content', $input)) { return Errors::invalidInput( 'content und divi schließen sich aus; nichts wurde angelegt.', ['content' => 'bei Divi-Inhalt weglassen'] ); } return $this->divi->compose($postType, is_array($input['divi']) ? array_values($input['divi']) : []); } ``` In `src/Integrations/WordPress/Content/ContentCreateAbility.php` ersetzen: ```php return $change->result(sprintf('"%s" als Entwurf angelegt (ID %d).', $title, $postId), $post) ->changed((string) $postId, $postType, 'created', $title) ->urls((string) get_preview_post_link($postId), (string) get_edit_post_link($postId, 'raw')) ->toArray(); ``` durch: ```php $result = $change->result(sprintf('"%s" als Entwurf angelegt (ID %d).', $title, $postId), $post) ->changed((string) $postId, $postType, 'created', $title) ->urls((string) get_preview_post_link($postId), (string) get_edit_post_link($postId, 'raw')); if (array_key_exists('divi', $input) && $post instanceof WP_Post) { // The Divi tools lock on tree_rev (ruling P16): the next lit-divi-* call can use it right away. $result->with('tree_rev', Rev::of((string) $post->post_content)); } return $result->toArray(); ``` In `src/Integrations/WordPress/WordPressIntegration.php` ersetzen: ```php use LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility; ``` durch: ```php use LIT\MCP\Integrations\WordPress\Content\ContentComposer; use LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility; ``` In `src/Integrations/WordPress/WordPressIntegration.php` ersetzen: ```php public function __construct(private ContentSchema $schema, private ChangeLog $log) { } ``` durch: ```php public function __construct( private ContentSchema $schema, private ChangeLog $log, private ?ContentComposer $divi = null ) { } ``` In `src/Integrations/WordPress/WordPressIntegration.php` ersetzen: ```php new ContentCreateAbility($this->schema, $writer, $this->log), ``` durch: ```php new ContentCreateAbility($this->schema, $writer, $this->log, $this->divi), ``` In `src/Integrations/Divi/DiviIntegration.php` ersetzen: ```php use LIT\MCP\Integrations\Divi\Pages\DiviComposition; ``` durch: ```php use LIT\MCP\Integrations\Divi\Pages\DiviComposition; use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; ``` In `src/Integrations/Divi/DiviIntegration.php` ersetzen: ```php use LIT\MCP\Integrations\Integration; ``` durch: ```php use LIT\MCP\Integrations\Integration; use LIT\MCP\Integrations\WordPress\Content\ContentComposer; ``` In `src/Integrations/Divi/DiviIntegration.php` ersetzen: ```php * Divi 5 pages (spec §5, Etappe 2). Only available when the Divi 5 builder is loaded * (spec §2 principle 4); Divi 4 is never edited (spec §3.4). */ ``` durch: ```php * Divi 5 pages (spec §5, Etappe 2). Only available when the Divi 5 builder is loaded * (spec §2 principle 4); Divi 4 is never edited (spec §3.4). contentComposer() gives * lit/content-create the Divi short form (spec §6.2). */ ``` In `src/Integrations/Divi/DiviIntegration.php` ersetzen: ```php public function abilities(): array { $loader ``` durch: ```php public function contentComposer(): ContentComposer { return new DiviContentComposer($this->schema, $this->composition); } public function abilities(): array { $loader ``` In `src/Plugin.php` ersetzen: ```php $schema = self::contentSchema(); $registry = new IntegrationRegistry( new CoreIntegration(self::pluginDir() . '/docs/guides'), new WordPressIntegration($schema, $changeLog), new MediaIntegration($schema, $changeLog, $limits), new DiviIntegration($schema, $changeLog) ); ``` durch: ```php $schema = self::contentSchema(); $divi = new DiviIntegration($schema, $changeLog); $registry = new IntegrationRegistry( new CoreIntegration(self::pluginDir() . '/docs/guides'), new WordPressIntegration($schema, $changeLog, $divi->contentComposer()), new MediaIntegration($schema, $changeLog, $limits), $divi ); ``` - [ ] **Step 4: Tests und Lint (grün)** Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test && composer lint` Expected: PASS (auch die bestehenden `ContentCreateAbilityTest`-Fälle ohne `divi`), phpcs ohne Meldungen. - [ ] **Step 5: Commit** ```bash cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP git add src/Integrations/WordPress/Content/ContentComposer.php src/Integrations/Divi/Pages/DiviContentComposer.php src/Integrations/WordPress/Content/ContentCreateAbility.php src/Integrations/WordPress/WordPressIntegration.php src/Integrations/Divi/DiviIntegration.php src/Plugin.php tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: lit/content-create with Divi short form Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" ``` --- ### Task 10: Hinweise, Guides, Version 0.3.0, Ende-zu-Ende-Test und Abnahme Die Hinweise nennen jetzt die Schreib-Tools (`PostPresenter::DIVI_NOTE`, Outline-Hinweise, Katalog-Hinweis), die Serveranweisungen bekommen die `tree_rev`-Regel (Spec §9.1), zwei neue Guides (Spec §9.2: `divi-shortform`, `divi-placeholders`) und Ergänzungen in `divi-structure`, `divi-attrs`, `overview`, `workflows`, `content-types`. Version 0.3.0 mit Readme (Auswirkungen auf die Website: kses-Umgehung beim Divi-Speichern und Divi-Cache; Rückgängig über Revisionen). Ein Ende-zu-Ende-Skript prüft alle Schreib-Tools gegen echtes Divi über den OAuth-Endpunkt – einschließlich eines Benutzers ohne `unfiltered_html`. **Files:** - Modify: `src/Integrations/WordPress/Content/PostPresenter.php`, `src/Integrations/Divi/Pages/DiviOutlineAbility.php`, `src/Integrations/Divi/Schema/DiviCatalogAbility.php`, `src/Guides/InstructionsBuilder.php`, `src/Plugin.php`, `wp-lit-mcp.php`, `readme.txt`, `docs/guides/{divi-structure,divi-attrs,overview,workflows,content-types}.md` - Create: `docs/guides/divi-shortform.md`, `docs/guides/divi-placeholders.md`, `tests/Integration/divi-write-smoke.sh` - Test: `tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php`, `tests/Unit/Guides/InstructionsBuilderTest.php` **Interfaces:** - Consumes: alle Schreib-Tools (Tasks 6–9); `tests/Integration/lib.sh` (`connect`, `rpc`, `tool`, `ability`, `error_code`, `cleanup_app_passwords_for`). - Produces: `PostPresenter::DIVI_NOTE` (nennt `lit-divi-insert`, `lit-divi-update`, `lit-divi-move`, `lit-divi-delete`); Serveranweisungen mit Zeile `- Divi-Änderungen verlangen die tree_rev …`; `Plugin::VERSION = '0.3.0'`. - [ ] **Step 1: Failing Tests** In `tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php` ersetzen: ```php $ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); self::assertSame(['Die Seite hat noch keinen Divi-Inhalt.'], $ability->execute(['post_id' => 7])['hints']); ``` durch: ```php $ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); self::assertStringContainsString('lit-divi-insert', $ability->execute(['post_id' => 7])['hints'][0]); ``` In `tests/Unit/Guides/InstructionsBuilderTest.php` ersetzen: ```php self::assertStringContainsString('lit-divi-outline und lit-divi-design-system lesen', $with); self::assertStringNotContainsString('lit-divi-outline und', $without); ``` durch: ```php self::assertStringContainsString('lit-divi-outline und lit-divi-design-system lesen', $with); self::assertStringContainsString('tree_rev', $with); self::assertStringNotContainsString('lit-divi-outline und', $without); ``` - [ ] **Step 2: Test laufen lassen (rot)** Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && vendor/bin/phpunit --filter 'DiviReadAbilitiesTest|InstructionsBuilderTest'` Expected: FAIL – Hinweis ohne `lit-divi-insert` bzw. Serveranweisungen ohne `tree_rev`. - [ ] **Step 3: Implementierung – Hinweise, Anweisungen, Version** In `src/Integrations/WordPress/Content/PostPresenter.php` ersetzen: ```php /** How Divi content is read and changed with the Divi tools. */ public const DIVI_NOTE = 'Den Aufbau mit lit/divi-outline (direkt: lit-divi-outline) lesen, einzelne Knoten mit ' . 'lit/divi-node-get (direkt: lit-divi-node-get); ändern vorerst im Divi-Builder.'; ``` durch: ```php /** How Divi content is read and changed with the Divi tools. */ public const DIVI_NOTE = 'Den Aufbau mit lit/divi-outline (direkt: lit-divi-outline) lesen und mit ' . 'lit/divi-insert, lit/divi-update, lit/divi-move bzw. lit/divi-delete (direkt: lit-divi-insert, ' . 'lit-divi-update, lit-divi-move, lit-divi-delete) ändern.'; ``` In `src/Integrations/Divi/Pages/DiviOutlineAbility.php` ersetzen: ```php $hints[] = $page->isDiviLayout() ? 'Die Seite hat noch keinen Divi-Inhalt.' : 'Der Inhalt ist kein Divi-Layout (Block-Editor oder klassisch); lit-content-get zeigt ihn.'; ``` durch: ```php $hints[] = $page->isDiviLayout() ? 'Die Seite hat noch keinen Divi-Inhalt: mit lit-divi-insert (ohne parent) den ersten Abschnitt ' . 'einfügen.' : 'Der Inhalt ist kein Divi-Layout (Block-Editor oder klassisch); lit-content-get zeigt ihn.'; ``` In `src/Integrations/Divi/Pages/DiviOutlineAbility.php` ersetzen: ```php if (in_array(false, array_column($rows, 'editable'), true)) { $hints[] = 'Knoten mit "nur lesbar" (globale Module, fremde Blöcke) ändern die Divi-Werkzeuge nicht; ' . 'ihren Inhalt nur im Divi-Builder bearbeiten.'; } ``` durch: ```php if (in_array(false, array_column($rows, 'editable'), true)) { $hints[] = 'Knoten mit "nur lesbar" (globale Module, fremde Blöcke) lassen sich nur als Ganzes ' . 'verschieben, duplizieren oder löschen; ihren Inhalt im Divi-Builder bearbeiten.'; } ``` In `src/Integrations/Divi/Schema/DiviCatalogAbility.php` ersetzen: ```php $catalog = $this->modules->catalog(); $notes = [self::CODE_NOTE, 'Details zur Vollform: lit-guide mit topic "divi-attrs".']; ``` durch: ```php $catalog = $this->modules->catalog(); $notes = [self::CODE_NOTE, 'Details und Beispiele: lit-guide mit topic "divi-shortform" bzw. "divi-attrs".']; ``` In `src/Guides/InstructionsBuilder.php` ersetzen: ```php $divi = str_contains(', ' . $active . ',', ', divi,') ? ['- Divi-Seiten: vor Änderungen lit-divi-outline und lit-divi-design-system lesen; Aufbau: lit-guide ' . 'topic "divi-structure".'] : []; ``` durch: ```php $divi = str_contains(', ' . $active . ',', ', divi,') ? [ '- Divi-Seiten: vor Änderungen lit-divi-outline und lit-divi-design-system lesen; Aufbau: lit-guide ' . 'topic "divi-structure".', '- Divi-Änderungen verlangen die tree_rev aus lit-divi-outline bzw. der letzten Divi-Antwort ' . '(nicht rev).', ] : []; ``` In `src/Plugin.php` ersetzen: ```php { public const VERSION = '0.2.0'; ``` durch: ```php { public const VERSION = '0.3.0'; ``` In `wp-lit-mcp.php` ersetzen: ```php * Description: MCP-Server für WordPress, Divi 5 und Pods – mit OAuth für claude.ai. * Version: 0.2.0 * Requires at least: 6.9 ``` durch: ```php * Description: MCP-Server für WordPress, Divi 5 und Pods – mit OAuth für claude.ai. * Version: 0.3.0 * Requires at least: 6.9 ``` In `readme.txt` ersetzen: ```text Requires PHP: 8.1 Stable tag: 0.2.0 License: GPL-2.0-or-later ``` durch: ```text Requires PHP: 8.1 Stable tag: 0.3.0 License: GPL-2.0-or-later ``` In `readme.txt` ersetzen: ```text Transient lit_mcp_endpoint_check. * Uploads über LIT MCP: nur Bilder (jpg, png, gif, webp, avif), PDF, MP4, MP3; Größenlimit einstellbar ``` durch: ```text Transient lit_mcp_endpoint_check. * Divi-Änderungen über LIT MCP speichern den Seiteninhalt für Benutzer ohne die Berechtigung unfiltered_html ohne die kses-Filterung von WordPress, weil diese sonst jeden unveränderten Divi-Block neu schreiben würde. Alle Werte, die LIT MCP dabei schreibt, werden vorher bereinigt (wp_kses_post, sanitize_text_field, esc_url_raw); Inhalte des Code-Moduls bleiben nur mit unfiltered_html ungefiltert. Nach jeder Divi-Änderung wird der statische CSS-Cache von Divi für die Seite geleert. * Uploads über LIT MCP: nur Bilder (jpg, png, gif, webp, avif), PDF, MP4, MP3; Größenlimit einstellbar ``` In `readme.txt` ersetzen: ```text lit/revision-restore). Felder und Begriffe sind in Revisionen nicht enthalten. * Original durch eine Kopie ersetzen (lit/post-replace-with-copy): vorher legt das Werkzeug eine Revision ``` durch: ```text lit/revision-restore). Felder und Begriffe sind in Revisionen nicht enthalten. * Divi-Seiten: jede Divi-Änderung legt vorher eine Revision an (revision_id in der Antwort); zurück über lit/revision-restore. Beim seitenübergreifenden Ersetzen erhält jede geänderte Seite eine eigene Revision. * Original durch eine Kopie ersetzen (lit/post-replace-with-copy): vorher legt das Werkzeug eine Revision ``` In `readme.txt` ersetzen: ```text = 0.2.0 = ``` durch: ```text = 0.3.0 = * Divi-Seiten: Seitenbaum mit Pfad-IDs und tree_rev, Knoten in Voll- und Kurzform, Modulkatalog, Designsystem (Farben, Variablen, Schriften, Presets), gerenderte Zusammenfassung; Abschnitte, Zeilen und Module einfügen, ändern, verschieben, duplizieren und löschen (Kurzform, Vollform, Platzhalter wie {{color:primary}}), Text seitenübergreifend ersetzen mit Probelauf, Qualitätswarnungen, neue Seiten direkt mit Divi-Inhalt. = 0.2.0 = ``` - [ ] **Step 4: Implementierung – Guides** `docs/guides/divi-shortform.md`: ````markdown # Divi-Kurzform Neue Divi-Inhalte beschreibt man als JSON-Knoten. `lit-divi-insert` (in `nodes`) und `lit-content-create` (in `divi`) nehmen dieselbe Form. Jeder Knoten hat einen `type`; dazu Kurzform-Felder, optional `label` (Admin-Label), `preset` (Name eines Modul-Presets aus `lit-divi-design-system`) und `attrs` (Vollform, siehe `divi-attrs`). Kurz- und Vollform lassen sich kombinieren; `attrs` gewinnt. ## Aufbau ```json { "type": "section", "label": "Hero", "background_color": "{{color:primary}}", "rows": [ { "columns": "1_3,2_3", "modules": [ [ { "type": "image", "image_id": 33 } ], [ { "type": "heading", "text": "Willkommen", "level": "h1" }, { "type": "text", "content": "

Seit 1987 in Köln.

" }, { "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 „ – “, z. B. „Hero – Button“. Abschnitten deshalb ein Label geben. - `heading` ohne `level` wird `h2` (eine Seite hat meist schon eine `h1` im Titel). ## Kurzform-Felder | Modul | Felder | |---|---| | `text` | `content` (HTML) | | `heading` | `text`, `level` (`h1`–`h6`) | | `button` | `text`, `url`, `new_window` (true/false) | | `image` | `image_id` (Mediathek, setzt URL/ID/Alt), `src`, `alt`, `title`, `url` | | `blurb` | `title`, `url`, `content`, `image_id`, `src`, `alt`, `level` | | `cta` | `title`, `content`, `button_text`, `button_url` | | `icon` | `url`, `title` | | `divider` | `show`, `color` | | `testimonial` | `author`, `job_title`, `company`, `company_url`, `content`, `portrait_id`, `portrait_url` | | `team-member` | `name`, `position`, `content`, `image_id`, `image_url` | | `accordion` → `accordion-item` | Eintrag: `title`, `content`, `open` | | `tabs` → `tab` | Eintrag: `title`, `content` | | `toggle` | `title`, `content`, `open` | | `gallery` | `image_ids` (Liste von Anhang-IDs) | | `slider` → `slide` | Eintrag: `title`, `content`, `button_text`, `button_url`, `image_id`, `src`, `alt` | | `contact-form` → `contact-field` | Formular: `title`, `email`, `success_message`, `submit_text`; Feld: `title`, `id`, `type` (`input`, `email`, `text`, `checkbox`, `radio`, `select`), `required` | | `map` → `map-pin` | Karte: `address`, `lat`, `lng`, `zoom`; Pin: `title`, `content`, `address`, `lat`, `lng` | | `video` | `src`, `thumbnail` | | `number-counter` | `number`, `title`, `percent` | | `social-media-follow` → `social-media-follow-network` | Eintrag: `network` (z. B. `facebook`), `url`, `label` | | `icon-list` → `icon-list-item` | Eintrag: `text` | | `code` | `content` – nur, wenn kein natives Modul passt (Warnung) | Alle anderen Module (siehe `lit-divi-catalog`) gehen über `attrs`. Die Kurzform-Felder eines Moduls samt Pfad zeigt `lit-divi-catalog` mit `module`. ## Einfügen, ändern, verschieben, löschen - `lit-divi-insert`: `nodes` + Position – `parent` (Pfad-ID, z. B. Spalte `s0.r0.c1`; optional `index`), `before` oder `after`; ohne Position ans Seitenende. Oben auf der Seite sind nur Abschnitte erlaubt, in Abschnitten Zeilen, in Spalten Module. - `lit-divi-update`: `changes` = `[{"id": "s0.r0.c1.m2", "set": {"text": "Jetzt buchen"}, "preset": "CTA primär", "label": "Hero – Button"}]` – mehrere Knoten in einem Aufruf; `null` entfernt einen Wert. - `lit-divi-move`: `id` + Position (auch in andere Zeilen oder Abschnitte). - `lit-divi-delete`: `ids`, erst nach Rückfrage mit `confirm: true`. - `lit/divi-duplicate-node`: Kopie direkt hinter dem Original. - Jede Antwort enthält die neue `tree_rev` (für die nächste Änderung), `changed` mit den neuen Pfad-IDs, den betroffenen Abschnitt als `outline`, `warnings` (Qualität, siehe unten) und `revision_id`. Fehler: `validation_failed` bzw. `invalid_attr_path` mit `fields` (Eingabepfad → Problem, z. B. `nodes[0].rows[0].columns`); dann wurde nichts gespeichert. ## Warnungen Neue oder geänderte Knoten werden geprüft: feste Farben/Schriften, obwohl eine passende globale existiert; Bilder ohne Alt-Text; leere Module; Sprünge in der Überschriftenhierarchie; Code-Modul. Warnungen dem Nutzer nennen und möglichst gleich beheben. ```` `docs/guides/divi-placeholders.md`: ````markdown # Platzhalter für Divi-Inhalte Platzhalter stehen **allein** im Wert (nicht mitten im Text) und werden beim Speichern in Divis eigene Verweise übersetzt. So bleibt die Seite mit dem Designsystem verbunden: ändert sich die Primärfarbe, ändert sich auch der Button. | Platzhalter | Bedeutung | |---|---| | `{{color:primary}}`, `secondary`, `heading`, `body`, `link` | die fünf Theme-Farben | | `{{color:}}` | eigene globale Farbe (Name wie in `lit-divi-design-system`) | | `{{var:}}` | 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:}}` | dynamischer Inhalt aus einem Feld (Pods/Meta), z. B. `{{field:telefon}}` | | `"preset": ""` | Modul-Preset (kein Platzhalter, sondern eigenes Feld) | Beispiele: ```json { "type": "button", "text": "Anrufen", "url": "{{field:telefon_link}}", "attrs": { "button.decoration.background": { "desktop": { "color": "{{color:primary}}" }, "hover": { "color": "{{color:Nachtblau}}" } } } } ``` ```json { "type": "heading", "text": "{{post:title}}", "level": "h1" } ``` Unbekannte Namen lehnt das Werkzeug ab und nennt die vorhandenen. In `lit-divi-node-get` erscheinen die übersetzten Verweise als `$variable({…})$` – diese Werte unverändert lassen oder wieder durch einen Platzhalter ersetzen. ```` In `docs/guides/divi-structure.md` ersetzen: ```markdown ## Was nicht bearbeitet wird ``` durch: ```markdown ## Ändern Neue Inhalte und Änderungen: siehe `divi-shortform` (Kurzform, `lit-divi-insert`, `lit-divi-update`, `lit-divi-move`, `lit-divi-delete`), `divi-attrs` (Vollform) und `divi-placeholders`. Ablauf: 1. `lit-divi-outline` lesen (liefert `tree_rev`), `lit-divi-design-system` für Farben, Variablen, Presets. 2. Änderung mit dieser `tree_rev` senden. Die Antwort enthält die neue `tree_rev` und die neuen Pfad-IDs – damit direkt weiterarbeiten, ohne erneut zu lesen. 3. `rev_mismatch`: jemand hat die Seite inzwischen geändert (z. B. im Divi-Builder) → neu lesen. 4. Zum Schluss `lit-page-render-summary` prüfen und dem Nutzer die Vorschau (`preview_url`) bzw. den Divi-Builder (`edit_url`) nennen. Der Status bleibt unverändert; veröffentlichte Seiten werden live geändert (vorher entsteht eine Revision, `revision_id`). ## Was nicht bearbeitet wird ``` In `docs/guides/divi-structure.md` ersetzen: ```markdown - Knoten mit „nur lesbar“ (globale Module `g…`, fremde Blöcke `x…`) ändern die Divi-Werkzeuge nicht. - Divi-4-Inhalt (Shortcodes `[et_pb_…]`) wird nicht gelesen oder geändert: Seite einmal im Divi-Builder ``` durch: ```markdown - Knoten mit „nur lesbar“ (globale Module `g…`, fremde Blöcke `x…`) lassen sich nur als Ganzes verschieben, duplizieren oder löschen; ihr Inhalt wird im Divi-Builder bearbeitet (globale Module folgen später). - Divi-4-Inhalt (Shortcodes `[et_pb_…]`) wird nicht gelesen oder geändert: Seite einmal im Divi-Builder ``` In `docs/guides/divi-attrs.md` ersetzen: ```markdown ## Pfade finden ``` durch: ````markdown ## Setzen In `attrs` (bei `lit-divi-insert` je Knoten, bei `lit-divi-update` je Änderung) steht Pfad → Wert je Breakpoint. Kurz: `{"desktop": "#1a1a1a", "phone": "…"}` bzw. bei Objektwerten `{"desktop": {"size": "28px"}}`; Hover und Sticky: `{"hover": {…}}` bzw. `{"desktop": {"value": {…}, "hover": {…}}}`. Nur die genannten Breakpoints werden ersetzt, der Rest bleibt; `null` entfernt einen Pfad, Breakpoint oder Zustand: ```json { "id": "s0.r0.c1.m0", "attrs": { "title.decoration.font.font": { "desktop": { "color": "{{color:heading}}" }, "phone": { "size": "28px" } }, "module.decoration.spacing": null } } ``` Unbekannte Pfade → Fehler `invalid_attr_path` mit Vorschlägen unter `fields`. Texte werden bereinigt (HTML nur, wo Divi Rich-Text erwartet; Links ohne `javascript:`). ## Pfade finden ```` In `docs/guides/overview.md` ersetzen: ```markdown Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows`, `media`, `divi-structure`, `divi-attrs` oder `divi-design-system`. ``` durch: ```markdown Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows`, `media`, `divi-structure`, `divi-shortform`, `divi-attrs`, `divi-placeholders` oder `divi-design-system`. ``` In `docs/guides/overview.md` ersetzen: ```markdown - Erfolg: `ok`, `summary`, `changed`, `warnings`, `rev`, `revision_id` (Revision mit dem Stand vorher, nur bei Tools, die Inhalt überschreiben, sonst `null`), `log_id`, URLs. - Fehler: JSON mit `code` (z. B. `validation_failed`, `rev_mismatch`, `write_failed`, `partial_write`), ``` durch: ```markdown - Erfolg: `ok`, `summary`, `changed`, `warnings`, `rev`, `revision_id` (Revision mit dem Stand vorher, nur bei Tools, die Inhalt überschreiben, sonst `null`), `log_id`, URLs. Divi-Werkzeuge zusätzlich `tree_rev` (Stand des Seiteninhalts, Pflicht für die nächste Divi-Änderung) und `outline` (betroffener Abschnitt). - Fehler: JSON mit `code` (z. B. `validation_failed`, `rev_mismatch`, `write_failed`, `partial_write`), ``` In `docs/guides/workflows.md` ersetzen: ```markdown 6. Veröffentlichen erst, wenn der Nutzer es ausdrücklich möchte. ``` durch: ```markdown 6. Veröffentlichen erst, wenn der Nutzer es ausdrücklich möchte. ## Divi-Seite bauen 1. `lit-divi-design-system` lesen (Farben, Schriften, Presets) und `lit-divi-catalog` für die Module. 2. Seite als Entwurf mit Inhalt anlegen: `lit-content-create` mit `divi` (Abschnitte in Kurzform, siehe `divi-shortform`) – oder eine leere Seite anlegen und Abschnitte mit `lit-divi-insert` einfügen. 3. Weitere Abschnitte/Module mit `lit-divi-insert`, Anpassungen mit `lit-divi-update`; immer die `tree_rev` der letzten Antwort mitgeben. 4. `lit-page-render-summary` prüfen, `warnings` beheben, dem Nutzer `preview_url` und `edit_url` (Divi-Builder) nennen. Veröffentlichen nur auf Wunsch (`lit-post-publish`). ## Text auf vielen Seiten ersetzen `lit/divi-replace-text` zuerst mit `dry_run: true` (Standard): zeigt Treffer je Seite mit `tree_rev`. Nach Rückfrage mit `dry_run: false`, `targets` (post_id + tree_rev aus dem Probelauf) und `confirm: true` ausführen; jede Seite bekommt eine eigene Revision, `results` meldet das Ergebnis je Seite. ``` In `docs/guides/content-types.md` ersetzen: ```markdown - `lit-content-get`: alle Daten eines Eintrags inkl. Feldern, Begriffen, Beitragsbild und `rev`. Bei Divi-Seiten fehlt `content`; den Aufbau liest `lit-divi-outline` (siehe `divi-structure`), geändert wird er vorerst im Divi-Builder (Titel, Auszug, Felder und Begriffe gehen weiter über `lit-content-update`). ``` durch: ```markdown - `lit-content-get`: alle Daten eines Eintrags inkl. Feldern, Begriffen, Beitragsbild und `rev`. Bei Divi-Seiten fehlt `content`; den Aufbau liest `lit-divi-outline`, geändert wird er mit den Divi-Werkzeugen (siehe `divi-structure`); Titel, Auszug, Felder und Begriffe gehen weiter über `lit-content-update`. ``` In `docs/guides/content-types.md` ersetzen: ```markdown - `lit-content-create` legt immer einen **Entwurf** an. Felder werden gegen das Schema geprüft; ein Fehler bricht den ganzen Aufruf ab (`fields.` im Fehler nennt die Ursache). - `lit-content-update` ändert nur die übergebenen Werte, der Status bleibt. `rev` aus `lit-content-get` ist ``` durch: ```markdown - `lit-content-create` legt immer einen **Entwurf** an. Felder werden gegen das Schema geprüft; ein Fehler bricht den ganzen Aufruf ab (`fields.` im Fehler nennt die Ursache). Divi-Seiten gleich mit Inhalt: `divi` = Liste von Abschnitten in Kurzform (siehe `divi-shortform`) statt `content`; die Antwort enthält dann die `tree_rev` für weitere Divi-Änderungen. - `lit-content-update` ändert nur die übergebenen Werte, der Status bleibt. `rev` aus `lit-content-get` ist ``` In `docs/guides/content-types.md` ersetzen: ```markdown 1. `lit/post-duplicate` → Entwurfskopie; die Antwort enthält die `rev` der Kopie für `lit-content-update`. 2. Kopie mit `lit-content-update` (Divi-Inhalte vorerst im Divi-Builder) bearbeiten und dem Nutzer die Vorschau zeigen; `lit-page-render-summary` fasst den gerenderten Inhalt zusammen. 3. `lit/post-replace-with-copy` übernimmt die Kopie ins Original (ID, URL, Status und Menüeinträge bleiben). ``` durch: ```markdown 1. `lit/post-duplicate` → Entwurfskopie; die Antwort enthält die `rev` der Kopie für `lit-content-update`. 2. Kopie mit `lit-content-update` bzw. den Divi-Werkzeugen bearbeiten und dem Nutzer die Vorschau zeigen; `lit-page-render-summary` fasst den gerenderten Inhalt zusammen. 3. `lit/post-replace-with-copy` übernimmt die Kopie ins Original (ID, URL, Status und Menüeinträge bleiben). ``` - [ ] **Step 5: Tests und Lint (grün)** Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test && composer lint` Expected: alle Tests PASS (inkl. `ToolNamingTest`: in den Guides nur `lit-divi-insert` usw. für direkte und `lit/divi-duplicate-node`, `lit/divi-replace-text` für indirekte Tools), phpcs ohne Meldungen. - [ ] **Step 6: Ende-zu-Ende-Test anlegen und alle Integrationstests ausführen** `tests/Integration/divi-write-smoke.sh`: ```bash #!/usr/bin/env bash # End-to-end checks of the Etappe-2b Divi write tools through the OAuth-protected endpoint. # Runs INSIDE the DDEV web container (Divi 5 must be the active theme): # cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-write-smoke.sh # Creates draft pages, a temporary role/user without unfiltered_html, and removes everything again. set -euo pipefail cd /var/www/html # shellcheck source=lib.sh source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1) FIXTURES=$PLUGIN/tests/Fixtures/divi ORIGINAL_ROLES=$(wp option get lit_mcp_allowed_roles --format=json 2>/dev/null || echo '["administrator"]') CREATED_POSTS=() cleanup() { for id in "${CREATED_POSTS[@]}"; do wp post delete "$id" --force >/dev/null 2>&1 || true done cleanup_app_passwords_for "$ADMIN_LOGIN" cleanup_app_passwords_for lit-smoke-editor wp user delete lit-smoke-editor --yes >/dev/null 2>&1 || true wp role delete lit_smoke_editor >/dev/null 2>&1 || true wp option update lit_mcp_allowed_roles "$ORIGINAL_ROLES" --format=json >/dev/null 2>&1 || true } trap cleanup EXIT create_page() { # $1 = fixture file, $2 = title -> prints the ID LIT_FILE="$FIXTURES/$1" LIT_TITLE="$2" wp --user="$ADMIN_LOGIN" eval ' $id = wp_insert_post(["post_type" => "page", "post_status" => "draft", "post_title" => getenv("LIT_TITLE"), "post_content" => wp_slash((string) file_get_contents(getenv("LIT_FILE")))]); update_post_meta($id, "_et_pb_use_builder", "on"); update_post_meta($id, "_et_pb_use_divi_5", "on"); echo $id;' } content_of() { # $1 = post ID wp post get "$1" --field=post_content } # Whether the stored content still contains the exact bytes of the fixture section "Leistungen". keeps_leistungen() { # $1 = post ID LIT_ID="$1" LIT_FILE="$FIXTURES/converted-landing.html" wp eval ' $fixture = (string) file_get_contents(getenv("LIT_FILE")); $start = strpos($fixture, "") - $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: "

Hallo Welt

"}, {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":"

Text

"}]}]}') 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: "

Neu vom Redakteur

"}}]}')") echo "$EDIT" | jq -e '.ok == true' >/dev/null || fail "editor update: $EDIT" content_of "$PAGE_ID" | grep -q 'u003cscript' && fail "editor could store a script" [ "$(keeps_leistungen "$PAGE_ID")" = "no" ] && fail "kses re-serialized untouched blocks of an editor save" pass "user without unfiltered_html: byte-identical untouched content, sanitized values" echo "ALL CHECKS PASSED" ``` Run: ```bash cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && chmod +x tests/Integration/divi-write-smoke.sh cd /home/janniclas/Projekte/WordPress timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-write-smoke.sh timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-read-smoke.sh timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/mcp-smoke.sh timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/wp-basis-smoke.sh timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/media-admin-smoke.sh ``` Expected: alle fünf Skripte enden mit `ALL CHECKS PASSED` (bekannte WARN-Zeilen aus Etappe 1 sind zulässig). Schlägt Abschnitt 7 von `divi-write-smoke.sh` fehl („kses re-serialized untouched blocks“), ist die kses-Umgehung in `DiviSaver` wirkungslos – nicht den Test lockern. - [ ] **Step 7: Commit** ```bash cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP git add src/Integrations/WordPress/Content/PostPresenter.php src/Integrations/Divi/Pages/DiviOutlineAbility.php src/Integrations/Divi/Schema/DiviCatalogAbility.php src/Guides/InstructionsBuilder.php src/Plugin.php wp-lit-mcp.php readme.txt docs/guides tests/Integration/divi-write-smoke.sh tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php tests/Unit/Guides/InstructionsBuilderTest.php git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "docs: Divi guides shortform/placeholders, hints name the write tools; test: Divi write end-to-end; version 0.3.0 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" ``` - [ ] **Step 8: Manuelle Abnahme (Jan-Niclas, Browser und claude.ai)** Der ausführende Agent bereitet vor und meldet; diese Punkte prüft Jan-Niclas selbst (Spec §1.3 Nr. 2, §13 „Bearbeitbarkeit im Visual Builder“): 1. **Visual Builder:** In claude.ai eine Testseite bauen lassen („Lege eine Entwurfsseite ‚Abnahme Divi‘ an mit Hero (Überschrift, Text, Button mit Preset), einer Zeile 1/3 + 2/3 mit Bild und Blurb, einem Akkordeon und einem Kontaktformular“). Die Seite im Divi Visual Builder öffnen (`edit_url`): alle Module erscheinen als native Module mit Admin-Labels „ – “, lassen sich anklicken, ändern und speichern; die Spaltenbreiten stimmen (1/3 + 2/3); Farben aus `{{color:…}}` sind im Builder als globale Farbe verknüpft (Farbwähler zeigt die globale Farbe). 2. **Zurück zu Claude:** Nach dem Speichern im Builder eine Änderung per claude.ai verlangen – `rev_mismatch` erscheint nur, wenn Claude eine alte `tree_rev` nutzt; nach erneutem Lesen gelingt die Änderung, im Builder gemachte Einstellungen bleiben erhalten. 3. **Rückgängig:** Einen Abschnitt löschen lassen (Rückfrage erscheint), danach über `lit/revision-restore` mit der gemeldeten `revision_id` zurückholen. 4. **Ersetzen:** „Ersetze auf allen Seiten ‚Termin‘ durch ‚Beratungstermin‘“ – Claude zeigt erst den Probelauf und fragt nach, dann ersetzt es; im Protokoll-Tab erscheint je Seite ein Eintrag. 5. **Builder-Fixtures (aus 2a, falls noch offen):** Seiten mit globalem Modul, Presets, Variablen und Dynamic Content mit `tests/Integration/divi-export-page.php` exportieren; `composer test` bleibt grün; danach auf einer Kopie einer solchen Seite `lit-divi-update` an einem normalen Modul ausführen – das globale Modul bleibt byte-identisch. Ergebnisse als kurze Notiz an den Ausführenden zurückmelden; Abweichungen (z. B. anders gespeicherte Spaltenbreiten, D8) werden als Folgeaufgaben erfasst. --- ## Self-Review (durchgeführt beim Schreiben) - **Spec-Abdeckung (Teil 2b):** §5.4 Kurzform (~20 Module + Kind-Module), Vollform mit Pfadprüfung (`invalid_attr_path` mit Vorschlägen), Breakpoints und Hover/Sticky, Kombination Kurz + Voll, Struktur Abschnitt → Zeilen (`columns`) → Spalten → Module, Vollbreite über `section_type`, Platzhalter-Tabelle vollständig inkl. `preset` → Tasks 1–3; §5.5 Warnungen (außer „ähnliche Abschnitte“, D6) und automatische Admin-Labels → Tasks 3–4; §5.6 Speicher-Pipeline Schritte 1–6 → Task 5; §5.7 `lit/divi-insert`, `lit/divi-update`, `lit/divi-move`, `lit/divi-delete`, `lit/divi-duplicate-node`, `lit/divi-replace-text` → Tasks 6–8; §6.2 `lit/content-create` mit Divi-Kurzform → Task 9; §8.1 `ToolResult` mit `changed`, `outline`, `rev`, `tree_rev`, `warnings`, `revision_id`, `log_id`, `preview_url`, `edit_url` (`&et_fb=1`) → Task 5; §8.2 Codes `rev_mismatch`, `invalid_attr_path`, `validation_failed`, `confirm_required`, `unsupported`, alles oder nichts, Stapel je Eintrag → Tasks 5–8; §9.1/§9.2 Regeln und Guides `divi-shortform`, `divi-placeholders` → Task 10; §10 Bereinigung (`wp_kses_post`, `sanitize_text_field`) und `confirm: true` → Tasks 1, 5, 7, 8; §11.3 Annotations → Tasks 6–8. Etappe 3: Library, globale Module bearbeiten, Theme Builder, `lit/divi-dynamic-sources`, ähnliche Abschnitte (D6). - **Gefundene Spec-Lücken und Auflösung:** kses re-serialisiert für Benutzer ohne `unfiltered_html` alle Blöcke → D7 (`DiviSaver`, Readme, E2E-Abschnitt 7); Spaltenformat von Divi 5.2 (Flex, `flexType`) nicht in der Spec → D8; `divi-replace-text` ohne Sperre je Seite → D10 (`targets` mit `tree_rev`); Platzhalter mitten im Text unklar → D9 (nur ganzer Wert); `content-create` mit Divi und kses → D11. - **Placeholder-Scan:** alle Code-Schritte enthalten vollständigen Code bzw. exakte Ersetzungen (alt → neu); keine „TBD“, keine „wie in Task N“. - **Typ-Konsistenz geprüft:** `Composer::compose/update` (Rückgabe `blocks|attrs`, `errors`, `invalid_paths`), `TreeEditor::position` (Array `container/parent/index` oder String), `DiviChange::changed/removed/entries/touched/warnings`, `DiviWriter::page/save/inputProperties/positionProperties/sectionLabel`, `DiviSaver::save` (Rückgabe `revision_id`, `warnings`), `DiviComposition::composer/attrWriter/rules/editor/warnings`, `ContentComposer::available/compose/afterInsert`, `Errors::invalidAttrPath` stimmen zwischen Interfaces-Blöcken, Code und Tests überein. - **Review Focus:** alle fünf Punkte haben ihren Test im genannten Task. Der Code wurde in einer Kopie des Plugins ausgeführt: nach jedem Task `composer test` grün (bis 742 Tests) und phpcs sauber; die Schreib-Pipeline wurde direkt gegen Divi 5.2.1 in DDEV geprüft (Einfügen, Ändern, Verschieben, Löschen, Probelauf, `{{post:title}}` gerendert, unberührte Bytes identisch auch mit aktivem kses).