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