Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
4674 lines
280 KiB
Markdown
4674 lines
280 KiB
Markdown
# Etappe 4e – Schleifen-Inhalte, Guides, Security-Audit, Version – 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:** Der KI-Assistent kann `loop_*`-Inhalte (z. B. Datum, Kategorien, Autor) in Modulen setzen, die in einem Divi-Loop-Element liegen oder selbst eines sind – mit Kontextprüfung aus dem Blockbaum, passenden Fehlermeldungen und der Kurzform `{{loop:<option>}}`; `lit/divi-dynamic-sources` zeigt die passenden Loop-Optionen je Knoten. Außerdem: Guides für alle Etappe-4-Werkzeuge, ein Security-Audit der ganzen Etappe mit Fix-Welle und der Versionssprung auf 0.7.0.
|
||
|
||
**Architecture:** Der Schleifen-Kontext wird **im Blockbaum** bestimmt (`LoopContext`: nächster Vorfahre-oder-selbst mit `module.advanced.loop.desktop.value.enable === "on"`, Typ aus `queryType`, Standard `post_types`) und als reines Wertobjekt `LoopScope` an `VariableRef`/`DynamicContentRules` gegeben; dort erlaubt er nur `loop_*`-Optionen der passenden Art (Post/Term/Benutzer/Menü). Die Einspeisung liegt in den Abilities (Insert, Update, Move), nicht in den unteren Schichten: `DiviComposition::inLoop()` liefert eine Komposition mit Kontext. `DynamicContentMap` behält die `loop_*`-Optionen (heute verworfen) gruppiert nach Art; Divi-Aufrufe ändern sich nicht (`DynamicContentOptions::get_options()` liefert sie schon). Guides, Audit und Version sind Dokumentations-/Prozess-Tasks mit klaren Checklisten.
|
||
|
||
**Tech Stack:** PHP ≥ 8.1, WordPress 7.x, Divi 5.15.1 (nur über vorhandene Adapter), PHPUnit 9.6 + Brain Monkey + Mockery, phpcs, DDEV + bash/jq-Smokes.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-10-16-etappe-4-designsystem-design.md` (§9 Schleifen-Inhalte, §12 Audit, §14 Schritt 8–9, §1.3 Nr. 5–6, A3, A10, A11), Entscheidungen `docs/superpowers/notes/2026-09-28-etappe-4-entscheidungen-entwurf.md` (G11, G12), Recherchen 4e: `docs/superpowers/notes/2026-10-19-etappe-4e-recherche-schleifen.md` (Divi-Interna; Task 10 legt sie ab, bis dahin `…/scratchpad/research-4e-loop-divi.md`, `…/research-4e-loop-plugin.md`, `…/research-4e-guides-audit-version.md` – die Task-Autoren haben sie gelesen), Pläne 4c/4d als Vorbild; der Code ist maßgeblich, wo er von den Plänen abweicht.
|
||
|
||
**Voraussetzung:** Branch `etappe-4` (= `main`), Stand 7678d22 (4a–4d fertig), 3368 Unit-Tests grün (`composer test`), `composer lint` sauber.
|
||
|
||
**Teil von Etappe 4:** 4a–4d (fertig) → **4e (dieser Plan)** = Abschluss von Etappe 4.
|
||
|
||
## Global Constraints
|
||
|
||
- **Dateien nur im Plugin-Verzeichnis** `/home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP` anlegen oder ändern. Integrationsskripte stellen im EXIT-Trap alles wieder her, was sie ändern (Seiten, Optionen).
|
||
- PHP ≥ 8.1 ohne 8.2+-Features; WordPress ≥ 6.9; Divi 5.15.1. Namespace `LIT\MCP\` → `src/`, PSR-4, eine Klasse pro Datei, jede Datei mit `defined('ABSPATH') || exit;`, `declare(strict_types=1);`, höchstens 120 Zeichen je Zeile. Nach jedem Task `composer test` und `composer lint` sauber (`composer test` = `php -d memory_limit=512M vendor/bin/phpunit`).
|
||
- **Divi-Grenze (Divi-Absicherung §3):** Divi-Aufrufe nur in Adaptern aus `DiviAdapters::CLASSES`. 4e braucht **keinen neuen Divi-Aufruf**: `DynamicContentMap` ruft `DynamicContentOptions::get_options()` schon und behält jetzt nur mehr von dessen Ergebnis. Ergibt sich doch ein neuer Divi-Aufruf, Task anhalten und melden (NEEDS_CONTEXT). `LoopScope`, `LoopContext` und die Kontext-Teile der Regeln sind **reines PHP** (keine WordPress-/Divi-Aufrufe).
|
||
- **Sicherheitsgrenze Typabgleich:** Ein `loop_*`-Wert ist nur erlaubt, wenn der Schleifen-Kontext des Knotens (Art) zur Art der Option passt. Das ist **keine Komfortmeldung**: In Divi liest `loop_post_meta_key_*` in einer Term-/Benutzer-/Menü-Schleife Post-Meta des Objekts mit derselben ID (und Benutzer-/Term-Meta in einer Post-Schleife umgekehrt) – ein Fehlabgleich würde fremde Daten ausgeben (Recherche 4e, Befund 1). Ohne Schleifen-Kontext ist jede `loop_*`-Option abgelehnt.
|
||
- **Rechte/Sperren unverändert:** Alle Dynamic-Content-Einstellungen laufen weiter durch `ValueCleaner`/`VariableRef`/`DynamicContentRules` mit den Meta-Schlüssel-Regeln S29/S30 (auch für `loop_*_meta_key_`) und `unfiltered_html`-Regeln; die Release-Gate-Fähigkeiten der Tools ändern sich nicht (`dynamic-sources` ist schon überall deklariert, wo Inhalte entstehen).
|
||
- **Sprache:** Meldungen, Hinweise, Guides, Notizen auf Deutsch mit echten Umlauten; neutral „KI-Assistent“, „MCP-Client“. Tool-Namen: direkte Tools `lit-<name>`, indirekte `lit/<name>` (ToolNamingTest).
|
||
- **Tests:** Abilities über `LIT\MCP\Tests\Unit\TestCase` (Brain Monkey + Mockery, `assertToolError`); vorbelegte Stubs (`wp_json_encode`, `is_wp_error`, `absint`, `wp_slash`, `wp_unslash`, `sanitize_text_field`) nie mit `Functions\expect()` prüfen. Reine Klassen erben von `PHPUnit\Framework\TestCase`. TDD: Test zuerst, RED-Lauf, Code, GREEN; ein Test muss den Codepfad erreichen, den sein Name behauptet (keine vakuösen Assertions – Lehre aus 4c/4d). Echte Loop-Optionsnamen aus Divi 5.15.1 verwenden (die bisherigen Testdaten `loop_post_meta_key` u. ä. sind erfunden und werden in Task 2 korrigiert). Integration: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/<skript>.sh` (läuft nur mit deaktivierten Plugins `postlos` und `asynclistry`; vorher Zustand mit `ddev exec wp plugin list` notieren und danach wiederherstellen).
|
||
- **Kein clean-code-review-Skill je Task** (die globale CLAUDE.md-Regel zieht sonst die Antwort des Implementierers an sich); der Gesamtreview folgt am Ende (Task 10).
|
||
- Commits: `git commit -m "<Nachricht>" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"` mit dem Namen des ausführenden Modells; nur die Dateien des Tasks stagen.
|
||
|
||
## Entscheidungen für 4e (Rulings, verbindlich)
|
||
|
||
- **X1 Kontextregel (Divi 5.15.1):** Der Schleifen-Kontext eines Knotens ist der des **nächsten Vorfahren-oder-selbst**, dessen `attrs.module.advanced.loop.desktop.value.enable` genau `"on"` ist (Tablet/Phone/Hover-Varianten und `true` zählen nicht – das liest auch Divi so). `queryType` fehlt → `post_types`. Verschachtelte Schleifen: die innerste gilt für alles darunter, egal welcher Art. Loop-Einstellungen, die nur über ein **Preset** kommen (`LoopPresetAttrsMap`), erkennt die Baum-Prüfung nicht – das ist eine dokumentierte Grenze (fail closed: ohne erkannte Schleife werden `loop_*` abgelehnt).
|
||
- **X2 Arten:** `post` (`post_types`, `current_page`), `term` (`post_taxonomies`), `user` (`user_roles`), `menu` (`menus`), `repeater` (`repeater_<acf-name>`, nur falls die Recherche zeigt, dass es eigene Optionen gibt – sonst fällt `repeater_*` unter `post` oder wird abgelehnt; der Task-Autor entscheidet anhand der Recherchetabelle und dokumentiert es). Die Zuordnung Option → Art stammt aus der `group` der Divi-Option („Loop Terms“ → term, „Loop Users“ → user, alle übrigen `loop_*` → post, Menü nach Tabelle); die drei Meta-Schlüssel-Optionen (`loop_post_meta_key_manual_custom_field`, `loop_user_…`, `loop_term_…`) ordnet der Name zu, weil ihre Gruppennamen übersetzt sind. Gespeichert wird der **Array-Schlüssel** der Option (`loop_term_name`), nicht die interne `id`.
|
||
- **X3 Einspeisung in den Abilities:** `VariableRef` und `DynamicContentRules` bekommen einen optionalen `?LoopScope` (Standard `LoopScope::none()` → verhält sich wie heute, kein bestehender Aufruf bricht). `DiviComposition::inLoop(LoopScope)` liefert eine Komposition mit Kontext (Vorbild `forPost()`). Insert: Kontext der Zielposition (Eltern-Knoten, alle neuen Knoten eines Aufrufs teilen ihn, solange sie nicht selbst eine Schleife einschalten – ein neuer Knoten mit eigener `loop`-Einstellung im Aufruf schaltet den Kontext für seine Nachfahren um: der Task-Autor löst das über die neuen Attribute des Knotens, wenn nötig fail closed = Schleife zählt erst ab dem gespeicherten Zustand und der Fehler nennt das). Update: Kontext des bestehenden Knotens (Vorfahren aus dem Baum, plus eigene neue `loop`-Attribute desselben Aufrufs); **ein Composer je Kontext** (Ruling S31 „ein VariableRef je Komposition“ wird entsprechend umformuliert). Move: Kontext am neuen Ziel; ein Move, der vorhandene `loop_*`-Inhalte aus einer Schleife herausführt oder in eine Schleife anderer Art, erzeugt eine **Warnung** (Divi lässt das zu), keine Ablehnung (A11). `lit-content-create` und Theme-Builder-Anlage haben nie einen Schleifen-Kontext.
|
||
- **X4 Kurzform:** `{{loop:<option>}}` (Option ohne Präfix, z. B. `{{loop:post_date}}` → `loop_post_date`) mit Einstellungen wie `{{post:…}}` (A10); die Tabelle `LOOP` in `Placeholders` ordnet den Kurznamen den Optionen zu; welche Optionen before/after tragen, kommt aus der `DynamicContentMap`.
|
||
- **X5 Fehlermeldungen (Spec §9):** ohne Schleife: „Schleifen-Inhalt "%s" braucht eine Divi-Schleife in diesem Modul oder einem übergeordneten Element (Abschnitt, Zeile, Spalte, Gruppe).“; falsche Art: „… passt nicht zur Schleife dieses Elements (Typ: %s).“ (Typ deutsch: Beiträge, Terme, Benutzer, Menü). Die irreführende alte Meldung „nur innerhalb von Divi-Schleifen“ entfällt; Ruling S36 ist abgelöst.
|
||
- **X6 `lit/divi-dynamic-sources`:** optional `post_id` + `node` (Pfad-ID): zusätzlich die zum Schleifen-Kontext dieses Knotens passenden `loop_*`-Optionen (Feldtypen wie die übrigen Optionen); lesen mit Leserecht auf die Seite (Muster `lit-divi-node-get`, `DiviPageLoader`); ohne Schleife im Kontext: Hinweis „kein Schleifen-Kontext“ statt Liste. Der Konstruktor bekommt den `DiviPageLoader` (Task 9 verdrahtet; die Tool-Fähigkeiten ändern sich nicht).
|
||
- **X7 Grenzen, dokumentiert statt gelöst:** (a) `module.advanced.loop` ist als Attributpfad schreibbar – Schleifen ein-/ausschalten über `attrs` war schon vorher möglich und bleibt (A3 verbietet nur ein *Anlegen/Konfigurieren-Werkzeug*); (b) globale Module werden nicht betreten – `loop_*` in Bibliothekseinträgen hat keinen Schleifen-Kontext und wird abgelehnt; (c) `StoredNodeGuard`/Kopieren: kopierte Schleifen-Container mit `loop_*`-Kindern bleiben für Benutzer ohne `unfiltered_html` abgelehnt (fail closed), solange die Prüfung nur einen Kontext je Unterbaum kennt; (d) Presets, die eine Schleife einschalten, erkennt die Baumprüfung nicht (X1); (e) ACF-Wiederholungsfelder verlangen die Einstellung `acf_type`, die die Einstellungsregeln heute ablehnen – bleibt außen vor, wenn die Task-Autoren keinen sicheren Weg finden. Alle Punkte kommen in die Audit-Notiz (Task 8) und den Guide (Task 7).
|
||
- **X8 Guides (G11):** keine Prompts, keine neue Guide-Datei ohne Not; Ablauf „Corporate Design übernehmen“ in `workflows.md`; `divi-design-system.md` bekommt Farben, Variablen, Theme-Optionen, Schriften, Restore je Bereich, Rechte; `overview.md` die vollständige Liste der indirekten Design-Tools und die neuen Fehlercodes (`design_rev`, `rev_mismatch`, `conflict`, `in_use`, `font_*`); `divi-placeholders.md`/`divi-shortform.md` `{{loop:…}}` und die Loop-Regeln. Die Inhalte folgen der Checkliste der Recherche (A) und dem echten Code der Tools.
|
||
- **X9 Audit (Task 8):** Ein unabhängiger Audit (Opus, Skill `anthropic-skills:security-audit` wenn verfügbar, sonst eigenes Vorgehen) über den Umfang von Spec §12 (Preset-`attrs`/XSS, Font-Upload, Massen-Schreibrechte, Schleifen-Optionen/Meta-Schlüssel/Typabgleich, Snapshot-Restore) im Format der Audits 3a/3b (`docs/superpowers/notes/2026-09-27-etappe-3a-security-audit.md`); Befunde mit Schwere; kritische/hohe Befunde werden in einer Fix-Welle behoben, mittlere nach Urteil, der Rest steht als Restrisiko in der Notiz. Das DNS-Rebinding-Fenster zwischen `allows()` und cURL (W13) gehört in den Audit.
|
||
- **X10 Version:** 0.6.1 → 0.7.0 (`wp-lit-mcp.php:6`, `src/Plugin.php:48`, `readme.txt:7` plus Changelog-Abschnitt in `readme.txt`); kein Git-Tag, kein Update-Server, kein `CHANGELOG`; `DiviReleases::TESTED` bleibt (5.2.1, 5.14.0, 5.15.1). Spec/§9 nennt 5.14.0 – Task 10 berichtigt auf 5.15.1.
|
||
|
||
## Schnittstellenvertrag (Namen und Signaturen, die spätere Tasks voraussetzen)
|
||
|
||
Klassen unter `src/Integrations/Divi/Compose/` (Namespace `LIT\MCP\Integrations\Divi\Compose`), Tests unter `tests/Unit/Integrations/Divi/Compose/`, sofern nicht anders genannt. Task-Autoren dürfen Details verfeinern, aber nur mit Eintrag unter `## ABWEICHUNGEN` am Ende ihres Abschnitts.
|
||
|
||
```php
|
||
// LoopScope.php (Task 1) – reines PHP, unveränderlich
|
||
public const POST = 'post'; public const TERM = 'term'; public const USER = 'user'; public const MENU = 'menu';
|
||
public static function none(): self;
|
||
public static function ofQueryType(string $queryType): self; // post_types|current_page → post, post_taxonomies → term, user_roles → user, menus → menu; unbekannt (repeater_… nach X2) → laut Task-Entscheidung
|
||
public function active(): bool;
|
||
public function kind(): ?string; // null ohne Schleife
|
||
public function queryType(): ?string;
|
||
public function label(): string; // deutsch für Meldungen: 'Beiträge' | 'Terme' | 'Benutzer' | 'Menü'
|
||
|
||
// LoopContext.php (Task 1) – reines PHP
|
||
/** @param array<string, mixed> $attrs Attribute eines Blocks */
|
||
public static function enabled(array $attrs): bool; // desktop.value.enable === 'on' (X1)
|
||
public static function ownScope(array $attrs): ?LoopScope; // Schleife an genau diesem Block, sonst null
|
||
/** @param list<array<string, mixed>> $attrChain Attribute von der Wurzel bis zum Knoten (inklusive) */
|
||
public static function fromChain(array $attrChain): LoopScope; // innerste gewinnt
|
||
public static function of(NodeIndex $index, string $nodeId): LoopScope; // Vorfahren-oder-selbst über die Pfad-ID (Namespace BlockTree\NodeIndex)
|
||
|
||
// DynamicContentMap (Pages/, Task 2) – loop_*-Optionen bleiben erhalten
|
||
/** @return array<string, array<string, mixed>> Option => Feldspezifikation, nur loop_*-Optionen der Art $kind */
|
||
public function loopFields(string $kind): array; // Name final nach Task 2; Task 3–5 nutzen genau diese Methode
|
||
|
||
// DynamicContentRules / VariableRef (Task 2) – optionaler Kontext
|
||
// VariableRef::__construct(DesignSystem $design, ?array $dynamicContent = null, ?LoopScope $loop = null)
|
||
// DynamicContentRules::__construct(DesignSystem $design, ?array $map, ?LoopScope $loop = null)
|
||
|
||
// DiviComposition (Pages/, Task 4)
|
||
public function inLoop(LoopScope $scope): self; // Klon mit Kontext, Vorbild forPost()
|
||
|
||
// Placeholders (Task 3): {{loop:<kurzname>}} → loop_<name>; Tabelle LOOP wie POST
|
||
// DiviDynamicSourcesAbility (Task 5): __construct(…, ?DiviPageLoader $loader = null); Eingabe post_id + node optional
|
||
```
|
||
|
||
## Dateistruktur
|
||
|
||
**Neu:** `Compose/LoopScope.php`, `Compose/LoopContext.php`, `tests/Integration/divi-loop-smoke.sh`, ggf. Fixture unter `tests/Fixtures/divi/` (echte Loop-Seite), Audit-Notiz `docs/superpowers/notes/2026-10-19-etappe-4e-security-audit.md`, Recherche-Notiz `docs/superpowers/notes/2026-10-19-etappe-4e-recherche-schleifen.md`.
|
||
|
||
**Geändert:** `Compose/{DynamicContentRules,VariableRef,Placeholders}.php`, `Pages/{DynamicContentMap,DiviComposition,DiviInsertAbility,DiviUpdateAbility,DiviMoveAbility,DiviDynamicSourcesAbility,DiviWriter}.php` (je nach Task), `DiviIntegration.php` (Loader für dynamic-sources), Guides (`docs/guides/*.md`), `wp-lit-mcp.php`, `src/Plugin.php`, `readme.txt`, Spec/Notizen, Tests der genannten Klassen (inkl. `DynamicContentRulesTest`, `VariableRefTest`, `DiviFixtures`).
|
||
|
||
## Task-Übersicht
|
||
|
||
| # | Task | Ergebnis |
|
||
|---|---|---|
|
||
| 1 | `LoopScope`, `LoopContext` | reiner Schleifen-Kontext aus dem Baum |
|
||
| 2 | `DynamicContentMap` behält `loop_*`; `DynamicContentRules`/`VariableRef` mit Kontext; Meldungen; Fixtures korrigieren | `loop_*` im passenden Kontext erlaubt, sonst Fehler |
|
||
| 3 | `{{loop:…}}` in `Placeholders`/Kurzform | Kurzform |
|
||
| 4 | Kontext in Insert, Update, Move; Move-Warnung | Werkzeuge nutzen den Kontext |
|
||
| 5 | `lit/divi-dynamic-sources` mit `post_id` + `node` | Optionen je Knoten |
|
||
| 6 | Smoke gegen echtes Divi (Loop-Seite, Speichern, Rendern) | `divi-loop-smoke.sh` |
|
||
| 7 | Guides | Etappe-4-Werkzeuge und Loops dokumentiert |
|
||
| 8 | Security-Audit Etappe 4 + Fix-Welle | Audit-Notiz, Fixes |
|
||
| 9 | Version 0.7.0, Spec-/Notiz-Nachträge, Verdrahtung | Release-Stand |
|
||
| 10 | Gesamtverifikation, Gesamtreview, Memory | Abschluss Etappe 4 |
|
||
|
||
---
|
||
|
||
### Task 1: `LoopScope` und `LoopContext` – reiner Schleifen-Kontext aus dem Baum (X1, X2)
|
||
|
||
Zwei reine Klassen ohne WordPress und Divi: `LoopScope` ist der unveränderliche Wert „in welcher Divi-Schleife steht ein Knoten“ (Art + `queryType`), `LoopContext` liest ihn aus Block-Attributen, einer Attribut-Kette oder dem echten `NodeIndex`. Beide Teilblöcke haben einen eigenen Commit; 1b baut auf 1a auf. Nichts anderes ändert sich, kein bestehender Test wird angefasst.
|
||
|
||
**Entscheidung `repeater_*` (X2, dokumentiert):** Es gibt **keine** Art `repeater`. Divi hat zwar eigene ACF-Optionen (`loop_acf_<rep>|||<feld>`, Gruppe `Loop ACF <rep>`), aber ihr Renderer liest die Einstellung `acf_type` (Recherche §2), die die Einstellungsregeln ablehnen (X7e), und die Art hinge am Feldnamen. `LoopScope::ofQueryType()` behandelt darum jeden anderen Wert als die fünf des Visual Builders (`post_types`, `current_page`, `post_taxonomies`, `user_roles`, `menus`) – also `repeater_<n>`, die Aliase `terms`/`users` (Divis Inhalts-Renderer kennt sie nicht, `LoopUtils.php:3130-3148`), `""` und Nicht-Strings (`sanitize_key()` → `""`) – als **Schleife ohne unterstützte Art**: `queryType()` nennt den Wert, `kind()` ist `null`, `active()` ist `false`; jede `loop_*`-Option wird dort abgelehnt (Meldung „passt nicht … (Typ: repeater_team)“, Task 2). Eine solche innere Schleife verdeckt trotzdem die äußere (Divi löst die innere zuerst auf) – `fromChain()`/`of()` halten an ihr an (fail closed). `loop_acf_*` hat nach `kindOfOption()` keine Art und passt nie.
|
||
|
||
**Art einer Option nach Name (Ergänzung, siehe `## ABWEICHUNGEN` A1):** `LoopScope::kindOfOption(string)` (`loop_term_` → term, `loop_user_` → user, `loop_menu_` → menu, `loop_acf_` → null, jedes andere `loop_*` → post, andere Namen → null) und `fits(string)` (aktiv und gleiche Art). Das ist die Sicherheitsprüfung von Task 2: Divi nimmt den Meta-Typ von `loop_<typ>_meta_key_*` aus dem **Namen** (`DynamicContentOptionLoopPostMetaKey::get_meta_type_from_name`, :334-345) und liest dann die Meta des Schleifen-Objekts mit derselben ID (`DynamicContentLoopOptions.php:145-158`) – darum prüft die Regel den Namen, nicht nur die Gruppe.
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Compose/LoopScope.php`, `src/Integrations/Divi/Compose/LoopContext.php`
|
||
- Create: `tests/Unit/Integrations/Divi/Compose/LoopScopeTest.php`, `tests/Unit/Integrations/Divi/Compose/LoopContextTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `LIT\MCP\Integrations\Divi\BlockTree\NodeIndex` (`find(string): ?Block`, `parentBlock(Block): ?Block` – `null` an der Wurzel und am `divi/placeholder`), `Block::attrs()`, `Block::isOpaque()` (globale Module und fremde Blöcke; `NodeIndex` steigt nicht in sie ab), `AttrMap::desktop(array, string)` (liest `<pfad>.desktop.value`, `stdClass` auf dem Weg wird gelesen, ein leeres JSON-Objekt am Ende ergibt `null`), `Parser::parse(string): Document` (nur im Test).
|
||
- Produces (Schnittstellenvertrag, Namespace `LIT\MCP\Integrations\Divi\Compose`):
|
||
- `LoopScope::POST|TERM|USER|MENU`, `none()`, `ofQueryType(string)`, `active(): bool`, `kind(): ?string`, `queryType(): ?string`, `label(): string` (`Beiträge`/`Terme`/`Benutzer`/`Menü`; bei nicht unterstützter Schleife der rohe `queryType`, ohne Schleife `""`), **neu** `fits(string $option): bool`, `static kindOfOption(string $option): ?string`.
|
||
- `LoopContext::enabled(array): bool` (genau `"on"` unter `module.advanced.loop.desktop.value.enable`), `ownScope(array): ?LoopScope` (`queryType` fehlt oder `null` → `post_types`), `fromChain(list<array>): LoopScope` (innerste gewinnt), `of(NodeIndex, string $nodeId): LoopScope` (Vorfahren-oder-selbst; opake Blöcke werden nicht gelesen; unbekannte ID → `none()`).
|
||
- Für Task 4/5: „Schleife vorhanden?“ = `active()`; zum Vergleich zweier Kontexte (Move-Warnung) `kind()` vergleichen; eine nicht unterstützte Schleife hat `queryType() !== null` und `kind() === null`.
|
||
- Ausgangslage vor dem Task: `composer test` = 3368 Tests grün (Stand 6203dbb).
|
||
|
||
#### Task 1a: `LoopScope`
|
||
|
||
- [ ] **Step 1a.1: Failing Test `LoopScopeTest` schreiben**
|
||
|
||
Datei `tests/Unit/Integrations/Divi/Compose/LoopScopeTest.php` anlegen:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||
|
||
use LIT\MCP\Integrations\Divi\Compose\LoopScope;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
/** Rulings X1/X2: the loop kind of a node, from Divi's queryType (LoopUtils.php:730-739, VB "Zj"). */
|
||
final class LoopScopeTest extends TestCase
|
||
{
|
||
/** @return array<string, array{string, string, string}> */
|
||
public static function queryTypes(): array
|
||
{
|
||
return [
|
||
'post types' => ['post_types', LoopScope::POST, 'Beiträge'],
|
||
'current page (archive main query)' => ['current_page', LoopScope::POST, 'Beiträge'],
|
||
'taxonomies' => ['post_taxonomies', LoopScope::TERM, 'Terme'],
|
||
'user roles' => ['user_roles', LoopScope::USER, 'Benutzer'],
|
||
'menus' => ['menus', LoopScope::MENU, 'Menü'],
|
||
];
|
||
}
|
||
|
||
/** @dataProvider queryTypes */
|
||
public function testTheFiveQueryTypesOfTheBuilderHaveAKind(string $queryType, string $kind, string $label): void
|
||
{
|
||
$scope = LoopScope::ofQueryType($queryType);
|
||
|
||
self::assertTrue($scope->active());
|
||
self::assertSame($kind, $scope->kind());
|
||
self::assertSame($queryType, $scope->queryType());
|
||
self::assertSame($label, $scope->label());
|
||
}
|
||
|
||
public function testNoneIsNoLoop(): void
|
||
{
|
||
$scope = LoopScope::none();
|
||
|
||
self::assertFalse($scope->active());
|
||
self::assertNull($scope->kind());
|
||
self::assertNull($scope->queryType());
|
||
self::assertSame('', $scope->label());
|
||
}
|
||
|
||
/**
|
||
* Ruling X2: repeater_<acf field> rows need the acf_type setting the rules refuse (X7e); Divi's
|
||
* renderer knows no "terms"/"users" aliases for the content (LoopUtils.php:3130-3148), and sanitize_key()
|
||
* turns "" or an array into "" – a loop the tools do not support: no kind, every loop_* refused.
|
||
*/
|
||
public function testOtherQueryTypesAreALoopWithoutKind(): void
|
||
{
|
||
foreach (['repeater_team', 'terms', 'users', '', 'Post_Types', 'post_types '] as $queryType) {
|
||
$scope = LoopScope::ofQueryType($queryType);
|
||
|
||
self::assertFalse($scope->active(), $queryType);
|
||
self::assertNull($scope->kind(), $queryType);
|
||
self::assertSame($queryType, $scope->queryType(), $queryType);
|
||
self::assertSame($queryType, $scope->label(), $queryType);
|
||
}
|
||
}
|
||
|
||
/** @return array<string, array{string, string|null}> */
|
||
public static function optionKinds(): array
|
||
{
|
||
return [
|
||
'post title' => ['loop_post_title', LoopScope::POST],
|
||
'post terms' => ['loop_post_terms', LoopScope::POST],
|
||
'post meta' => ['loop_post_meta_key_manual_custom_field', LoopScope::POST],
|
||
'product (post loop of products)' => ['loop_product_price_current', LoopScope::POST],
|
||
'term name (key, not the id "loop_name")' => ['loop_term_name', LoopScope::TERM],
|
||
'term meta' => ['loop_term_meta_key_manual_custom_field', LoopScope::TERM],
|
||
'user avatar' => ['loop_user_avatar', LoopScope::USER],
|
||
'user meta' => ['loop_user_meta_key_manual_custom_field', LoopScope::USER],
|
||
'menu link' => ['loop_menu_link', LoopScope::MENU],
|
||
'acf repeater row' => ['loop_acf_team|||name', null],
|
||
'no loop option' => ['post_title', null],
|
||
'prefix without underscore' => ['loopy_title', null],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* The kind an option NAME belongs to – Divi takes the meta type of loop_<type>_meta_key_* from the
|
||
* name too (DynamicContentOptionLoopPostMetaKey::get_meta_type_from_name, :334-345).
|
||
*
|
||
* @dataProvider optionKinds
|
||
*/
|
||
public function testTheOptionNameGivesItsKind(string $option, ?string $kind): void
|
||
{
|
||
self::assertSame($kind, LoopScope::kindOfOption($option));
|
||
}
|
||
|
||
public function testFitsOnlyAnOptionOfTheSameKind(): void
|
||
{
|
||
$terms = LoopScope::ofQueryType('post_taxonomies');
|
||
|
||
self::assertTrue($terms->fits('loop_term_name'));
|
||
self::assertTrue($terms->fits('loop_term_meta_key_manual_custom_field'));
|
||
self::assertFalse($terms->fits('loop_post_meta_key_manual_custom_field'));
|
||
self::assertFalse($terms->fits('loop_acf_team|||name'));
|
||
self::assertFalse($terms->fits('post_title'));
|
||
self::assertFalse(LoopScope::none()->fits('loop_post_title'));
|
||
self::assertFalse(LoopScope::ofQueryType('repeater_team')->fits('loop_post_title'));
|
||
self::assertTrue(LoopScope::ofQueryType('current_page')->fits('loop_post_title'));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 1a.2: Test laufen lassen – RED**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter LoopScopeTest`
|
||
Expected: FAIL – `ERRORS! Tests: 5, Assertions: 0, Errors: 5.` mit `Error: Class "LIT\MCP\Integrations\Divi\Compose\LoopScope" not found` (die Datenprovider lösen die Klassenkonstanten noch vor den Tests auf).
|
||
|
||
- [ ] **Step 1a.3: `LoopScope` implementieren**
|
||
|
||
Datei `src/Integrations/Divi/Compose/LoopScope.php` anlegen:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Compose;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* The Divi loop a node sits in (rulings X1/X2) – an immutable value, pure PHP. Its kind comes from Divi's
|
||
* queryType: post_types and current_page → post, post_taxonomies → term, user_roles → user, menus → menu
|
||
* (the five values the Visual Builder offers; LoopUtils.php:730-739, QueryTypeController.php:89-96).
|
||
*
|
||
* Any other queryType – repeater_<ACF field>, the aliases "terms"/"users", "" – is a loop the tools do
|
||
* not support: queryType() names it, but it has no kind and is not active(), so every loop_* option is
|
||
* refused inside it (fail closed). ACF repeater rows would need the setting acf_type, which the content
|
||
* rules refuse (ruling X7e); Divi's content renderer knows no aliases (LoopUtils.php:3130-3148).
|
||
*
|
||
* kindOfOption() is the kind an option NAME belongs to: Divi takes the meta type of
|
||
* loop_<post|user|term>_meta_key_* from the name and reads the meta of the loop object's ID, whatever the
|
||
* object is (DynamicContentLoopOptions.php:145-158) – a post meta option in a term loop reads the post
|
||
* with the term's ID. fits() is that security check (Global Constraints: "Sicherheitsgrenze Typabgleich").
|
||
*/
|
||
final class LoopScope
|
||
{
|
||
public const POST = 'post';
|
||
public const TERM = 'term';
|
||
public const USER = 'user';
|
||
public const MENU = 'menu';
|
||
|
||
/** Divi queryType => kind. */
|
||
private const KINDS = [
|
||
'post_types' => self::POST,
|
||
'current_page' => self::POST,
|
||
'post_taxonomies' => self::TERM,
|
||
'user_roles' => self::USER,
|
||
'menus' => self::MENU,
|
||
];
|
||
|
||
private const LABELS = [
|
||
self::POST => 'Beiträge',
|
||
self::TERM => 'Terme',
|
||
self::USER => 'Benutzer',
|
||
self::MENU => 'Menü',
|
||
];
|
||
|
||
/** Name prefix => kind of a loop option; every other loop_* name is a post option (loop_post_*, loop_product_*). */
|
||
private const OPTION_PREFIXES = [
|
||
'loop_term_' => self::TERM,
|
||
'loop_user_' => self::USER,
|
||
'loop_menu_' => self::MENU,
|
||
'loop_acf_' => null,
|
||
];
|
||
|
||
private function __construct(private ?string $queryType)
|
||
{
|
||
}
|
||
|
||
public static function none(): self
|
||
{
|
||
return new self(null);
|
||
}
|
||
|
||
public static function ofQueryType(string $queryType): self
|
||
{
|
||
return new self($queryType);
|
||
}
|
||
|
||
/** Whether there is a loop whose loop_* options the tools allow. */
|
||
public function active(): bool
|
||
{
|
||
return $this->kind() !== null;
|
||
}
|
||
|
||
/** POST, TERM, USER or MENU; null without a loop or in an unsupported one. */
|
||
public function kind(): ?string
|
||
{
|
||
return $this->queryType === null ? null : (self::KINDS[$this->queryType] ?? null);
|
||
}
|
||
|
||
/** Divi's queryType of the loop; null without a loop. */
|
||
public function queryType(): ?string
|
||
{
|
||
return $this->queryType;
|
||
}
|
||
|
||
/** German name of the kind for messages; the raw queryType of an unsupported loop; "" without a loop. */
|
||
public function label(): string
|
||
{
|
||
$kind = $this->kind();
|
||
|
||
return $kind === null ? (string) $this->queryType : self::LABELS[$kind];
|
||
}
|
||
|
||
/** Whether the loop_* option $option belongs to this loop's kind. */
|
||
public function fits(string $option): bool
|
||
{
|
||
return $this->active() && self::kindOfOption($option) === $this->kind();
|
||
}
|
||
|
||
/** Kind of a loop_* option by its name; null for other names and ACF repeater options. */
|
||
public static function kindOfOption(string $option): ?string
|
||
{
|
||
if (! str_starts_with($option, 'loop_')) {
|
||
return null;
|
||
}
|
||
|
||
foreach (self::OPTION_PREFIXES as $prefix => $kind) {
|
||
if (str_starts_with($option, $prefix)) {
|
||
return $kind;
|
||
}
|
||
}
|
||
|
||
return self::POST;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 1a.4: GREEN und Lint**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter LoopScopeTest && composer lint`
|
||
Expected: `OK (20 tests, 68 assertions)`; phpcs sauber.
|
||
|
||
- [ ] **Step 1a.5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Compose/LoopScope.php tests/Unit/Integrations/Divi/Compose/LoopScopeTest.php
|
||
git commit -m "feat: LoopScope – Schleifen-Art aus queryType und Optionsname (X1/X2)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### Task 1b: `LoopContext`
|
||
|
||
Die Baum-Fälle laufen über eine echte geparste Seite (`Parser` + `NodeIndex::build`), nicht über Mocks: Pfad-IDs wie `s0.r0.c0.m1.m1.m0` entstehen so, wie die Werkzeuge sie sehen. Die Seite enthält eine Beitrags-Schleife (Spalte) mit einer Term-Schleife (Gruppe) darin, darin eine ausgeschaltete Schleife (Desktop `off`, Tablet `on`), eine Spalte ohne Schleife mit einer globalen Modul-Instanz, die `enable: "on"` trägt (opak → nicht gelesen), und einen Abschnitt, der selbst eine Benutzer-Schleife ist. Der Fall „globale Instanz“ prüft wirklich den `isOpaque()`-Zweig: ohne ihn liefert `of()` dort `post` (im Arbeitsstand nachgeprüft: Mutation → 1 Failure); ebenso fällt `== 'on'` statt `=== 'on'` durch (`true == 'on'`).
|
||
|
||
- [ ] **Step 1b.1: Failing Test `LoopContextTest` schreiben**
|
||
|
||
Datei `tests/Unit/Integrations/Divi/Compose/LoopContextTest.php` anlegen:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||
|
||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||
use LIT\MCP\Integrations\Divi\Compose\LoopContext;
|
||
use LIT\MCP\Integrations\Divi\Compose\LoopScope;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
/** Ruling X1: the nearest ancestor-or-self with desktop.value.enable === "on" decides (LoopUtils.php:4720). */
|
||
final class LoopContextTest extends TestCase
|
||
{
|
||
/**
|
||
* Block attributes with module.advanced.loop.
|
||
*
|
||
* @param mixed $loop The value of module.advanced.loop
|
||
* @return array<string, mixed>
|
||
*/
|
||
private static function attrs(mixed $loop): array
|
||
{
|
||
return ['module' => ['advanced' => ['loop' => $loop]], 'builderVersion' => '5.15.1'];
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $value desktop.value of the loop
|
||
* @return array<string, mixed>
|
||
*/
|
||
private static function loop(array $value): array
|
||
{
|
||
return self::attrs(['desktop' => ['value' => $value]]);
|
||
}
|
||
|
||
public function testOnlyTheStringOnAtDesktopValueEnablesALoop(): void
|
||
{
|
||
self::assertTrue(LoopContext::enabled(self::loop(['enable' => 'on'])));
|
||
|
||
$off = [
|
||
'off' => self::loop(['enable' => 'off']),
|
||
'true' => self::loop(['enable' => true]),
|
||
'int 1' => self::loop(['enable' => 1]),
|
||
'"1"' => self::loop(['enable' => '1']),
|
||
'"true"' => self::loop(['enable' => 'true']),
|
||
'"ON"' => self::loop(['enable' => 'ON']),
|
||
'missing enable' => self::loop(['queryType' => 'post_types']),
|
||
'tablet only' => self::attrs(['tablet' => ['value' => ['enable' => 'on']]]),
|
||
'phone only' => self::attrs(['phone' => ['value' => ['enable' => 'on']]]),
|
||
'hover only' => self::attrs(['desktop' => ['hover' => ['enable' => 'on']]]),
|
||
'sticky only' => self::attrs(['desktop' => ['sticky' => ['enable' => 'on']]]),
|
||
'no loop' => ['builderVersion' => '5.15.1'],
|
||
'loop is a string' => self::attrs('on'),
|
||
'desktop is a string' => self::attrs(['desktop' => 'on']),
|
||
'value is a string' => self::attrs(['desktop' => ['value' => 'on']]),
|
||
'value is a list' => self::attrs(['desktop' => ['value' => ['on']]]),
|
||
'empty JSON object' => self::attrs(new \stdClass()),
|
||
'advanced is a string' => ['module' => ['advanced' => 'loop']],
|
||
];
|
||
foreach ($off as $case => $attrs) {
|
||
self::assertFalse(LoopContext::enabled($attrs), $case);
|
||
self::assertNull(LoopContext::ownScope($attrs), $case);
|
||
}
|
||
}
|
||
|
||
public function testOwnScopeReadsTheQueryTypeWithPostTypesAsDefault(): void
|
||
{
|
||
self::assertSame('post_types', LoopContext::ownScope(self::loop(['enable' => 'on']))?->queryType());
|
||
self::assertSame(LoopScope::POST, LoopContext::ownScope(self::loop(['enable' => 'on']))?->kind());
|
||
self::assertSame(
|
||
'post_types',
|
||
LoopContext::ownScope(self::loop(['enable' => 'on', 'queryType' => null]))?->queryType()
|
||
);
|
||
self::assertSame(
|
||
LoopScope::TERM,
|
||
LoopContext::ownScope(self::loop(['enable' => 'on', 'queryType' => 'post_taxonomies']))?->kind()
|
||
);
|
||
self::assertSame(
|
||
LoopScope::USER,
|
||
LoopContext::ownScope(self::loop(['enable' => 'on', 'queryType' => 'user_roles']))?->kind()
|
||
);
|
||
self::assertSame(
|
||
LoopScope::MENU,
|
||
LoopContext::ownScope(self::loop(['enable' => 'on', 'queryType' => 'menus']))?->kind()
|
||
);
|
||
}
|
||
|
||
public function testAnUnknownOrMalformedQueryTypeIsALoopWithoutKind(): void
|
||
{
|
||
$repeater = LoopContext::ownScope(self::loop(['enable' => 'on', 'queryType' => 'repeater_team']));
|
||
self::assertNotNull($repeater);
|
||
self::assertFalse($repeater->active());
|
||
self::assertSame('repeater_team', $repeater->queryType());
|
||
|
||
// sanitize_key() turns a non-string into "" – no post_types default (LoopUtils.php:730-732 isset()).
|
||
$array = LoopContext::ownScope(self::loop(['enable' => 'on', 'queryType' => ['post_types']]));
|
||
self::assertNotNull($array);
|
||
self::assertFalse($array->active());
|
||
self::assertSame('', $array->queryType());
|
||
}
|
||
|
||
public function testTheInnermostEnabledLoopOfTheChainWins(): void
|
||
{
|
||
$posts = self::loop(['enable' => 'on', 'queryType' => 'post_types']);
|
||
$terms = self::loop(['enable' => 'on', 'queryType' => 'post_taxonomies']);
|
||
$plain = ['builderVersion' => '5.15.1'];
|
||
$disabled = self::loop(['enable' => 'off', 'queryType' => 'user_roles']);
|
||
|
||
self::assertSame(LoopScope::TERM, LoopContext::fromChain([$plain, $posts, $terms, $plain])->kind());
|
||
self::assertSame(LoopScope::POST, LoopContext::fromChain([$terms, $posts])->kind(), 'post loop inside terms');
|
||
self::assertSame(LoopScope::POST, LoopContext::fromChain([$posts, $disabled, $plain])->kind());
|
||
self::assertSame(LoopScope::POST, LoopContext::fromChain([$posts])->kind(), 'the node itself');
|
||
self::assertNull(LoopContext::fromChain([$plain, $disabled])->queryType());
|
||
self::assertNull(LoopContext::fromChain([])->queryType());
|
||
// An unsupported inner loop still hides the outer one: Divi resolves the inner loop first.
|
||
$repeater = self::loop(['enable' => 'on', 'queryType' => 'repeater_team']);
|
||
$inner = LoopContext::fromChain([$posts, $repeater, $plain]);
|
||
self::assertFalse($inner->active());
|
||
self::assertSame('repeater_team', $inner->queryType());
|
||
}
|
||
|
||
private static function page(): NodeIndex
|
||
{
|
||
$loop = static fn (string $queryType): string => '{"module":{"advanced":{"loop":{"desktop":{"value":'
|
||
. '{"enable":"on","loopId":"loop-lit4etest1","queryType":"' . $queryType . '"}}}}},'
|
||
. '"builderVersion":"5.15.1"}';
|
||
$off = '{"module":{"advanced":{"loop":{"desktop":{"value":{"enable":"off","queryType":"menus"}},'
|
||
. '"tablet":{"value":{"enable":"on"}}}}},"builderVersion":"5.15.1"}';
|
||
$text = '<!-- wp:divi/text {"builderVersion":"5.15.1"} /-->';
|
||
|
||
return NodeIndex::build((new Parser())->parse(
|
||
'<!-- wp:divi/placeholder -->'
|
||
. '<!-- wp:divi/section {"builderVersion":"5.15.1"} --><!-- wp:divi/row {"builderVersion":"5.15.1"} -->'
|
||
// s0.r0.c0: a post loop, a term loop group inside it, a disabled loop group inside that.
|
||
. '<!-- wp:divi/column ' . $loop('post_types') . ' -->' . $text
|
||
. '<!-- wp:divi/group ' . $loop('post_taxonomies') . ' -->' . $text
|
||
. '<!-- wp:divi/group ' . $off . ' -->' . $text . '<!-- /wp:divi/group -->'
|
||
. '<!-- /wp:divi/group -->'
|
||
. '<!-- /wp:divi/column -->'
|
||
// s0.r0.c1: no loop; a global module instance claiming a loop (opaque: not read).
|
||
. '<!-- wp:divi/column {"builderVersion":"5.15.1"} -->' . $text
|
||
. '<!-- wp:divi/text {"globalModule":"812","module":{"advanced":{"loop":{"desktop":{"value":'
|
||
. '{"enable":"on"}}}}},"builderVersion":"5.15.1"} /-->'
|
||
. '<!-- /wp:divi/column -->'
|
||
. '<!-- /wp:divi/row --><!-- /wp:divi/section -->'
|
||
// s1: the section itself is a user loop.
|
||
. '<!-- wp:divi/section ' . $loop('user_roles') . ' --><!-- wp:divi/row {"builderVersion":"5.15.1"} -->'
|
||
. '<!-- wp:divi/column {"builderVersion":"5.15.1"} -->' . $text . '<!-- /wp:divi/column -->'
|
||
. '<!-- /wp:divi/row --><!-- /wp:divi/section -->'
|
||
. '<!-- /wp:divi/placeholder -->'
|
||
));
|
||
}
|
||
|
||
public function testTheTreeWalksAncestorsOrSelfByPathId(): void
|
||
{
|
||
$index = self::page();
|
||
|
||
// The ids the cases below rely on.
|
||
self::assertSame('divi/group', $index->find('s0.r0.c0.m1.m1')?->name());
|
||
self::assertTrue($index->find('s0.r0.c1.g0')?->isOpaque());
|
||
|
||
$kinds = [
|
||
's0' => null,
|
||
's0.r0' => null,
|
||
's0.r0.c0' => LoopScope::POST,
|
||
's0.r0.c0.m0' => LoopScope::POST,
|
||
's0.r0.c0.m1' => LoopScope::TERM,
|
||
's0.r0.c0.m1.m0' => LoopScope::TERM,
|
||
's0.r0.c0.m1.m1' => LoopScope::TERM,
|
||
's0.r0.c0.m1.m1.m0' => LoopScope::TERM,
|
||
's0.r0.c1' => null,
|
||
's0.r0.c1.m0' => null,
|
||
's0.r0.c1.g0' => null,
|
||
's1' => LoopScope::USER,
|
||
's1.r0.c0.m0' => LoopScope::USER,
|
||
'nope' => null,
|
||
's9.r0' => null,
|
||
];
|
||
foreach ($kinds as $id => $kind) {
|
||
self::assertSame($kind, LoopContext::of($index, $id)->kind(), $id);
|
||
}
|
||
self::assertSame('post_taxonomies', LoopContext::of($index, 's0.r0.c0.m1.m1.m0')->queryType());
|
||
self::assertNull(LoopContext::of($index, 's0.r0.c1.g0')->queryType());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 1b.2: Test laufen lassen – RED**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter LoopContextTest`
|
||
Expected: FAIL – `ERRORS! Tests: 5, Assertions: 2, Errors: 5.`, je `Error: Class "LIT\MCP\Integrations\Divi\Compose\LoopContext" not found`.
|
||
|
||
- [ ] **Step 1b.3: `LoopContext` implementieren**
|
||
|
||
Datei `src/Integrations/Divi/Compose/LoopContext.php` anlegen:
|
||
|
||
```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\NodeIndex;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* The loop context of a node from the block tree (ruling X1), pure PHP. A block runs a loop exactly when
|
||
* attrs.module.advanced.loop.desktop.value.enable is the string "on" – Divi's render gate
|
||
* (LoopUtils::_is_block_loop_enabled, LoopUtils.php:4720-4724) and the Visual Builder read only that;
|
||
* tablet, phone, hover/sticky values and true/1 do not count. A missing queryType is post_types
|
||
* (LoopUtils.php:730-732). The nearest enabled ancestor-or-self wins whatever its kind: Divi resolves
|
||
* nested loops innermost first (LoopUtils.php:4576-4605).
|
||
*
|
||
* Limits (rulings X1, X7): loop settings that come only from a preset (LoopPresetAttrsMap) are not seen,
|
||
* and opaque blocks (global modules, foreign blocks) are not read – both leave the node without a loop,
|
||
* so loop_* options are refused there (fail closed).
|
||
*/
|
||
final class LoopContext
|
||
{
|
||
private const PATH = 'module.advanced.loop';
|
||
|
||
/** @param array<string, mixed> $attrs Attributes of a block */
|
||
public static function enabled(array $attrs): bool
|
||
{
|
||
return (self::settings($attrs)['enable'] ?? null) === 'on';
|
||
}
|
||
|
||
/**
|
||
* The loop of exactly this block; null when it runs none.
|
||
*
|
||
* @param array<string, mixed> $attrs Attributes of a block
|
||
*/
|
||
public static function ownScope(array $attrs): ?LoopScope
|
||
{
|
||
if (! self::enabled($attrs)) {
|
||
return null;
|
||
}
|
||
|
||
$queryType = self::settings($attrs)['queryType'] ?? 'post_types';
|
||
|
||
// Divi's sanitize_key() makes a non-string "" – a loop without a kind the tools support.
|
||
return LoopScope::ofQueryType(is_string($queryType) ? $queryType : '');
|
||
}
|
||
|
||
/**
|
||
* @param list<array<string, mixed>> $attrChain Attributes from the root down to the node (inclusive)
|
||
*/
|
||
public static function fromChain(array $attrChain): LoopScope
|
||
{
|
||
foreach (array_reverse($attrChain) as $attrs) {
|
||
$scope = self::ownScope($attrs);
|
||
if ($scope !== null) {
|
||
return $scope;
|
||
}
|
||
}
|
||
|
||
return LoopScope::none();
|
||
}
|
||
|
||
/** The loop of the node $nodeId (path ID) or its nearest ancestor; none() for an unknown ID. */
|
||
public static function of(NodeIndex $index, string $nodeId): LoopScope
|
||
{
|
||
for ($node = $index->find($nodeId); $node !== null; $node = $index->parentBlock($node)) {
|
||
$scope = $node->isOpaque() ? null : self::ownScope($node->attrs());
|
||
if ($scope !== null) {
|
||
return $scope;
|
||
}
|
||
}
|
||
|
||
return LoopScope::none();
|
||
}
|
||
|
||
/**
|
||
* desktop.value of module.advanced.loop; [] when it is no object.
|
||
*
|
||
* @param array<string, mixed> $attrs
|
||
* @return array<array-key, mixed>
|
||
*/
|
||
private static function settings(array $attrs): array
|
||
{
|
||
$value = AttrMap::desktop($attrs, self::PATH);
|
||
|
||
return is_array($value) ? $value : [];
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 1b.4: GREEN und Lint**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'LoopContextTest|LoopScopeTest' && composer lint`
|
||
Expected: `OK (25 tests, 144 assertions)`; phpcs sauber.
|
||
|
||
- [ ] **Step 1b.5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Compose/LoopContext.php tests/Unit/Integrations/Divi/Compose/LoopContextTest.php
|
||
git commit -m "feat: LoopContext – Schleifen-Kontext aus dem Blockbaum, innerste gewinnt (X1)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
- [ ] **Step 1b.6: Task-Abschluss**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: `OK (3393 tests, …)` (3368 + 20 + 5); phpcs sauber.
|
||
|
||
---
|
||
|
||
### Task 2: `DynamicContentMap` behält `loop_*`; `DynamicContentRules`/`VariableRef` mit Kontext; Meldungen; Testdaten (X2, X5)
|
||
|
||
Die Map hält die `loop_*`-Optionen jetzt bereit (`loopFields()`), die Regeln lassen eine `loop_*`-Option nur zu, wenn der optionale `LoopScope` aktiv ist **und** die Option zu seiner Art passt; ohne Kontext (Standard) wird jede `loop_*`-Option mit der neuen Meldung abgelehnt. Kein Aufrufer im Code ändert sich (die neuen Parameter sind optional und hinten); die Einspeisung des Kontexts macht Task 4. Kein neuer Divi-Aufruf: `loopFields()` nutzt dieselbe `raw()`-Abfrage wie `forPost()` (Manifest `Call(REGISTRY . '::get_options', 2)` unverändert; die Gruppennamen `'Loop Terms'` usw. sind keine `et_…`-Literale, `DiviBoundaryTest` bleibt grün – im Arbeitsstand im Gesamtlauf nachgeprüft).
|
||
|
||
**Teilblöcke:** 2a Map + echte Loop-Optionen in `DiviFixtures` (die Map-Tests brauchen sie, darum hier statt in 2c), 2b Regeln/`VariableRef` + Korrektur der erfundenen Namen in `VariableRefTest` (die Meta-Schlüssel-Tests brauchen ab 2b einen Beitrags-Kontext, sonst würden sie an der Schleifen-Prüfung statt an S29/S30 scheitern – vakuös), 2c Docblocks und ein Pin-Test der Map in `VariableRef`. Jeder Teilblock hat einen Commit.
|
||
|
||
**Verhalten „ohne Kontext“:** byte-gleich für alles außer `loop_*`. Für `loop_*` ändert sich (a) die Meldung (X5 statt S36) und (b) ein `loop_*`-Name, den eine eingespeiste Map nennt, wird **nicht mehr kontextfrei akzeptiert** (bisher `VariableRefTest::testInjectedDynamicContentMap`; produktiv kam das nie vor, weil `fromOptions()` `loop_*` verwirft und das weiter tut). Das ist die Sicherheitsgrenze aus den Global Constraints („Ohne Schleifen-Kontext ist jede `loop_*`-Option abgelehnt“), siehe `## ABWEICHUNGEN` A3.
|
||
|
||
**Reihenfolge in `fieldsFor()`:** `custom_meta_*` (unverändert) → **`loop_*` → `loopFieldsFor()`** (vor dem Map-Treffer, damit ein Map-Eintrag die Kontextprüfung nie umgeht) → Map → Design-Variablen → unbekannt. `loopFieldsFor()`: kein Kontext (`queryType() === null`) → Meldung 1 (X5); `! fits($name)` → Meldung 2 mit `label()`; Name nicht in der Map → `VariableRef::unknownName()`; sonst die Map-Einstellungen, die dann wie alle anderen laufen (`enable_html` immer abgelehnt in `VariableRef::contentSettings()`, S29/S30 für `select_loop_meta_key`/`loop_meta_key` über das Suffix `meta_key`, S28, S37). Mit der Standard-Map (`null`, Registry nicht verfügbar) gibt es keine `loop_*`-Einträge → „Unbekannter Name“.
|
||
|
||
**Für Task 4 (Vertrag):** Eine Komposition in einer Schleife baut ihre Map als `(array) $map->forPost($postId) + $map->loopFields((string) $scope->kind(), $postId)` und übergibt `$scope`; `forPost()` enthält nie `loop_*`, die Summe ist also überschneidungsfrei (gepinnt in 2c). `forPost()` = `null` (Registry fehlt) → `loopFields()` = `[]` → nur die Standard-Map ohne `loop_*`.
|
||
|
||
**Files:**
|
||
- Modify: `src/Integrations/Divi/Pages/DynamicContentMap.php`, `src/Integrations/Divi/Compose/DynamicContentRules.php`, `src/Integrations/Divi/Compose/VariableRef.php`, `src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php` (nur Docblock)
|
||
- Create: `tests/Unit/Integrations/Divi/Compose/DynamicContentRulesTest.php`
|
||
- Modify: `tests/Support/DiviFixtures.php`, `tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php`, `tests/Unit/Integrations/Divi/Compose/VariableRefTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `LoopScope` (`none()`, `ofQueryType()`, `queryType()`, `kind()`, `fits()`, `label()`, `kindOfOption()`) aus Task 1; `DynamicContentMap::raw()`/`type()` (vorhanden, privat).
|
||
- Produces:
|
||
- `DynamicContentMap::loopFields(string $kind, int $postId = 0): array<string, array<string, string>>` – Option => [Einstellung => Feldtyp], dieselbe Form wie `forPost()` (before/after `richtext`, `loop_position` `text`, `date_format`/`thumbnail_size`/`select_loop_meta_key` `select`, `enable_html` bleibt gelistet und wird von `VariableRef` abgelehnt). `$postId` ist eine rückwärtskompatible Ergänzung des Vertrags (A2); `loopFields($kind)` fragt die Registry mit Post 0 ab. Leer bei unbekannter Art und ohne Registry.
|
||
- `DynamicContentRules::__construct(DesignSystem $design, ?array $map = null, ?LoopScope $loop = null)`, `VariableRef::__construct(DesignSystem $design, ?array $dynamicContent = null, ?LoopScope $loop = null)` – wie im Vertrag.
|
||
- Meldungen (X5, wörtlich): `Schleifen-Inhalt "%s" braucht eine Divi-Schleife in diesem Modul oder einem übergeordneten Element (Abschnitt, Zeile, Spalte, Gruppe).` und `Schleifen-Inhalt "%s" passt nicht zur Schleife dieses Elements (Typ: %s).`
|
||
- Ausgangslage: 3393 Tests grün (nach Task 1).
|
||
|
||
#### Task 2a: `DynamicContentMap::loopFields()` und echte Loop-Optionen in `DiviFixtures`
|
||
|
||
Art je Option (X2): die drei Meta-Optionen nach Name (`str_contains($name, '_meta_key_')` nach `kindOfOption()`; ihre Gruppen `Loop %s Custom Fields` sind übersetzt), `loop_acf_*` keine Art, sonst die Gruppe (`Loop Terms` → term, `Loop Users` → user, `Loop Menus` → menu, jede andere – `Loop`, `Loop Product` – → post). Gespeichert und gerendert wird der Array-Schlüssel (`get_options()` setzt `id` = Schlüssel, `DynamicContentOptions.php:114-116`); die Fixture übernimmt das (`id` = Schlüssel). Die bisherigen Fixture-Einträge waren falsch: `loop_post_meta_key_manual_custom_field` stand in Gruppe `Loop` mit nur `loop_meta_key`; echt sind Gruppe `Loop Post Custom Fields` und die Felder before/after/loop_position/date_format/custom_date_format/select_loop_meta_key/loop_meta_key/enable_html (`DynamicContentOptionLoopPostMetaKey.php:108-229`; `enable_html` nur für `unfiltered_html`). `fromOptions()` verwirft `loop_*` weiter – `testTheAllowMapIsUnchangedByTheRawLookupRefactor` (JSON-Snapshot) und `testLoopOptionsNeverEnterTheMap` bleiben unverändert grün; `options()` (dynamic-sources) listet weiter keine `loop_*` (Task 5 ergänzt sie je Knoten).
|
||
|
||
- [ ] **Step 2a.1: Fixture korrigieren und Failing Tests schreiben**
|
||
|
||
`tests/Support/DiviFixtures.php` (Hunks gegen 6203dbb):
|
||
|
||
```diff
|
||
diff --git a/tests/Support/DiviFixtures.php b/tests/Support/DiviFixtures.php
|
||
--- a/tests/Support/DiviFixtures.php
|
||
+++ b/tests/Support/DiviFixtures.php
|
||
@@ -102,6 +102,14 @@ final class DiviFixtures
|
||
$option = static fn (string $id, string $group, array $fields): array => [
|
||
'id' => $id, 'label' => $id, 'type' => 'text', 'custom' => false, 'group' => $group, 'fields' => $fields,
|
||
];
|
||
+ $select = static fn (): array => ['label' => 'x', 'type' => 'select', 'default' => ''];
|
||
+ $loop = ['before' => $text(), 'after' => $text(), 'loop_position' => $text()];
|
||
+ $date = ['date_format' => $select(), 'custom_date_format' => $text()];
|
||
+ $meta = $loop + $date + [
|
||
+ 'select_loop_meta_key' => $select(),
|
||
+ 'loop_meta_key' => $text(),
|
||
+ 'enable_html' => ['label' => 'x', 'type' => 'yes_no_button', 'default' => 'off'],
|
||
+ ];
|
||
|
||
return [
|
||
'post_title' => $option('post_title', 'Default', ['before' => $text(), 'after' => $text()]),
|
||
@@ -131,12 +139,32 @@ final class DiviFixtures
|
||
'meta_key' => $text(),
|
||
'enable_html' => ['type' => 'yes_no_button'],
|
||
]),
|
||
- 'loop_post_title' => $option('loop_post_title', 'Loop', [
|
||
- 'before' => $text(), 'after' => $text(), 'loop_position' => $text(),
|
||
- ]),
|
||
- 'loop_post_meta_key_manual_custom_field' => $option('loop_post_meta_key_manual_custom_field', 'Loop', [
|
||
- 'loop_meta_key' => $text(),
|
||
+ // Loop options as Divi 5.15.1's get_options() returns them (research 4e §2): id = key = stored name.
|
||
+ 'loop_post_title' => $option('loop_post_title', 'Loop', $loop),
|
||
+ 'loop_post_date' => $option('loop_post_date', 'Loop', $loop + $date),
|
||
+ 'loop_post_featured_image' => $option('loop_post_featured_image', 'Loop', [
|
||
+ 'loop_position' => $text(), 'thumbnail_size' => $select(),
|
||
]),
|
||
+ 'loop_post_meta_key_manual_custom_field' => $option(
|
||
+ 'loop_post_meta_key_manual_custom_field',
|
||
+ 'Loop Post Custom Fields',
|
||
+ $meta
|
||
+ ),
|
||
+ 'loop_product_title' => $option('loop_product_title', 'Loop Product', $loop),
|
||
+ 'loop_term_name' => $option('loop_term_name', 'Loop Terms', $loop),
|
||
+ 'loop_term_meta_key_manual_custom_field' => $option(
|
||
+ 'loop_term_meta_key_manual_custom_field',
|
||
+ 'Loop Term Custom Fields',
|
||
+ $meta
|
||
+ ),
|
||
+ 'loop_user_name' => $option('loop_user_name', 'Loop Users', $loop),
|
||
+ 'loop_user_meta_key_manual_custom_field' => $option(
|
||
+ 'loop_user_meta_key_manual_custom_field',
|
||
+ 'Loop User Custom Fields',
|
||
+ $meta
|
||
+ ),
|
||
+ 'loop_menu_text' => $option('loop_menu_text', 'Loop Menus', $loop),
|
||
+ 'loop_acf_team|||name' => $option('loop_acf_team|||name', 'Loop ACF team', $loop + ['acf_type' => $text()]),
|
||
// Odd entries a third-party filter could add.
|
||
'broken_fields' => $option('broken_fields', 'Default', ['a' => 'not-an-array', 'b' => ['type' => 3]]),
|
||
'no_fields' => ['id' => 'no_fields', 'type' => 'text'],
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php` – Import und vier Tests vor `assertRejected()`:
|
||
|
||
```diff
|
||
diff --git a/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php b/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php
|
||
--- a/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php
|
||
+++ b/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php
|
||
@@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
|
||
+use LIT\MCP\Integrations\Divi\Compose\LoopScope;
|
||
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||
@@ -210,6 +211,88 @@ final class DynamicContentMapTest extends TestCase
|
||
self::assertSame($before, json_encode($after));
|
||
}
|
||
|
||
+ /** Ruling X2: loop_* options stay, grouped by kind – by group, the three meta options by name. */
|
||
+ public function testLoopFieldsGroupTheLoopOptionsByKind(): void
|
||
+ {
|
||
+ $map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry());
|
||
+
|
||
+ self::assertSame(
|
||
+ [
|
||
+ 'loop_post_title', 'loop_post_date', 'loop_post_featured_image',
|
||
+ 'loop_post_meta_key_manual_custom_field', 'loop_product_title',
|
||
+ ],
|
||
+ array_keys($map->loopFields(LoopScope::POST))
|
||
+ );
|
||
+ self::assertSame(
|
||
+ ['loop_term_name', 'loop_term_meta_key_manual_custom_field'],
|
||
+ array_keys($map->loopFields(LoopScope::TERM))
|
||
+ );
|
||
+ self::assertSame(
|
||
+ ['loop_user_name', 'loop_user_meta_key_manual_custom_field'],
|
||
+ array_keys($map->loopFields(LoopScope::USER))
|
||
+ );
|
||
+ self::assertSame(['loop_menu_text'], array_keys($map->loopFields(LoopScope::MENU)));
|
||
+ // ACF repeater rows have no kind (X2/X7e); an unknown kind has no options.
|
||
+ self::assertSame([], $map->loopFields('repeater_team'));
|
||
+ self::assertSame([], $map->loopFields(''));
|
||
+ }
|
||
+
|
||
+ public function testLoopFieldsHaveTheShapeOfTheAllowMap(): void
|
||
+ {
|
||
+ $map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry());
|
||
+
|
||
+ $post = $map->loopFields(LoopScope::POST);
|
||
+
|
||
+ self::assertSame(
|
||
+ [
|
||
+ 'before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text',
|
||
+ 'date_format' => 'select', 'custom_date_format' => 'text',
|
||
+ ],
|
||
+ $post['loop_post_date']
|
||
+ );
|
||
+ self::assertSame(['loop_position' => 'text', 'thumbnail_size' => 'select'], $post['loop_post_featured_image']);
|
||
+ self::assertSame(
|
||
+ ['select', 'text', 'yes_no_button'],
|
||
+ [
|
||
+ $post['loop_post_meta_key_manual_custom_field']['select_loop_meta_key'],
|
||
+ $post['loop_post_meta_key_manual_custom_field']['loop_meta_key'],
|
||
+ $post['loop_post_meta_key_manual_custom_field']['enable_html'],
|
||
+ ]
|
||
+ );
|
||
+ }
|
||
+
|
||
+ /** The custom field groups are translated ("Loop %s Custom Fields", esc_html__) – their name decides. */
|
||
+ public function testTheMetaOptionsFollowTheirNameNotTheirGroup(): void
|
||
+ {
|
||
+ $registry = DiviFixtures::dynamicContentRegistry();
|
||
+ $registry['loop_user_meta_key_manual_custom_field']['group'] = 'Schleife: Benutzer-Felder';
|
||
+ $registry['loop_term_meta_key_manual_custom_field']['group'] = 'Loop';
|
||
+ $registry['loop_post_meta_key_manual_custom_field']['group'] = 'Loop Users';
|
||
+ $map = new DynamicContentMap(static fn (int $id): array => $registry);
|
||
+
|
||
+ self::assertArrayHasKey('loop_user_meta_key_manual_custom_field', $map->loopFields(LoopScope::USER));
|
||
+ self::assertArrayHasKey('loop_term_meta_key_manual_custom_field', $map->loopFields(LoopScope::TERM));
|
||
+ self::assertArrayHasKey('loop_post_meta_key_manual_custom_field', $map->loopFields(LoopScope::POST));
|
||
+ self::assertArrayNotHasKey('loop_post_meta_key_manual_custom_field', $map->loopFields(LoopScope::USER));
|
||
+ }
|
||
+
|
||
+ public function testLoopFieldsReadTheRegistryOfThePostAndAreEmptyWithoutIt(): void
|
||
+ {
|
||
+ $seen = [];
|
||
+ $map = new DynamicContentMap(static function (int $postId) use (&$seen): array {
|
||
+ $seen[] = $postId;
|
||
+
|
||
+ return DiviFixtures::dynamicContentRegistry();
|
||
+ });
|
||
+
|
||
+ $map->loopFields(LoopScope::POST, 42);
|
||
+ $map->loopFields(LoopScope::TERM);
|
||
+
|
||
+ self::assertSame([42, 0], $seen);
|
||
+ self::assertSame([], (new DynamicContentMap(static fn (): array => []))->loopFields(LoopScope::POST));
|
||
+ self::assertSame([], (new DynamicContentMap())->loopFields(LoopScope::POST), 'without Divi');
|
||
+ }
|
||
+
|
||
private static function assertRejected(callable $call, string $fragment): void
|
||
{
|
||
try {
|
||
```
|
||
|
||
- [ ] **Step 2a.2: Tests laufen lassen – RED**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DynamicContentMapTest`
|
||
Expected: FAIL – `Tests: 14, Assertions: 79, Errors: 4.`, die vier neuen Tests mit `Error: Call to undefined method LIT\MCP\Integrations\Divi\Pages\DynamicContentMap::loopFields()`; die zehn bestehenden (auch der JSON-Snapshot mit der erweiterten Fixture) grün.
|
||
|
||
- [ ] **Step 2a.3: `loopFields()` implementieren**
|
||
|
||
`src/Integrations/Divi/Pages/DynamicContentMap.php` (die Feld-Schleife von `fromOptions()` wird zu `fields()` herausgelöst, damit `forPost()` und `loopFields()` dieselbe Form liefern):
|
||
|
||
```diff
|
||
diff --git a/src/Integrations/Divi/Pages/DynamicContentMap.php b/src/Integrations/Divi/Pages/DynamicContentMap.php
|
||
--- a/src/Integrations/Divi/Pages/DynamicContentMap.php
|
||
+++ b/src/Integrations/Divi/Pages/DynamicContentMap.php
|
||
@@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi\Pages;
|
||
use Closure;
|
||
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
||
use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
|
||
+use LIT\MCP\Integrations\Divi\Compose\LoopScope;
|
||
use LIT\MCP\Integrations\Divi\Release\Call;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
@@ -25,8 +26,8 @@ defined('ABSPATH') || exit;
|
||
* date_format => select]. DynamicContentRules routes each setting by that type and keeps its fixed
|
||
* rules whatever the map says (enable_html refused, meta keys S29/S30, no markers S28).
|
||
*
|
||
- * - loop_* options never enter the map: Divi offers them only inside loop-enabled modules,
|
||
- * which the tools do not track – DynamicContentRules refuses them ("nur innerhalb von Divi-Schleifen").
|
||
+ * - loop_* options never enter this map: loopFields() keeps them apart, grouped by loop kind (ruling X2), for
|
||
+ * a composition inside a Divi loop (LoopScope); DynamicContentRules allows one only in a loop of its kind.
|
||
* - The map REPLACES DynamicContentRules' default, so every Placeholders::POST option is added when
|
||
* the registry lacks it, with the settings the placeholders write (DynamicContentRules' default).
|
||
* - Registry unavailable (no Divi, empty, error): null – DynamicContentRules' default map applies.
|
||
@@ -38,6 +39,13 @@ class DynamicContentMap implements DiviAdapter
|
||
/** Divi's dynamic-content registry class; listed in manifest(). */
|
||
public const REGISTRY = 'ET\Builder\Packages\Module\Layout\Components\DynamicContent\DynamicContentOptions';
|
||
|
||
+ /** Divi's loop option groups of other kinds than post (DynamicContentOptionTermsGroups.php, …UsersGroups.php). */
|
||
+ private const LOOP_GROUPS = [
|
||
+ 'Loop Terms' => LoopScope::TERM,
|
||
+ 'Loop Users' => LoopScope::USER,
|
||
+ 'Loop Menus' => LoopScope::MENU,
|
||
+ ];
|
||
+
|
||
/**
|
||
* @param (Closure(int): mixed)|null $registry Registry lookup by post ID (tests); null = Divi's.
|
||
*/
|
||
@@ -64,7 +72,7 @@ class DynamicContentMap implements DiviAdapter
|
||
|
||
/**
|
||
* The options for lit/divi-dynamic-sources: name => label, group and setting types (the same
|
||
- * names and types as forPost(), loop_* left out). custom_meta_<key> options a filter registers
|
||
+ * names and types as forPost(), loop_* left out – see loopFields()). custom_meta_<key> options a filter registers
|
||
* for a key DynamicContentRules refuses (protected "_" keys, S12/S30) are left out as well, so
|
||
* the listing never names them. Only label, group and setting types leave Divi's registry –
|
||
* no select options or defaults (they list meta keys). Null when the registry is unavailable.
|
||
@@ -95,6 +103,31 @@ class DynamicContentMap implements DiviAdapter
|
||
return $options;
|
||
}
|
||
|
||
+ /**
|
||
+ * The loop_* options of one loop kind (LoopScope::POST, TERM, USER, MENU): option name => [setting key =>
|
||
+ * field type], the shape of forPost() – a composition inside a loop of that kind adds them to its map.
|
||
+ * Kind (ruling X2): the three meta key options by their name (their groups are translated:
|
||
+ * "Loop %s Custom Fields", DynamicContentOptionLoopPostMetaKey.php:225), the others by Divi's group
|
||
+ * ("Loop Terms", "Loop Users", "Loop Menus"; every other group – "Loop", "Loop Product" – is post).
|
||
+ * ACF repeater options (loop_acf_*) have no kind (X2/X7e). Names are the registry keys – the names
|
||
+ * Divi stores and renders (get_options() sets every id to its key). [] when the registry is unavailable.
|
||
+ *
|
||
+ * @return array<string, array<string, string>>
|
||
+ */
|
||
+ public function loopFields(string $kind, int $postId = 0): array
|
||
+ {
|
||
+ $raw = $this->raw($postId) ?? [];
|
||
+
|
||
+ $fields = [];
|
||
+ foreach ($raw as $name => $option) {
|
||
+ if (is_string($name) && is_array($option) && self::loopKind($name, $option) === $kind) {
|
||
+ $fields[$name] = self::fields($option);
|
||
+ }
|
||
+ }
|
||
+
|
||
+ return $fields;
|
||
+ }
|
||
+
|
||
/**
|
||
* Divi's registry for the post; any filter error is logged (message only) and yields null.
|
||
*
|
||
@@ -125,13 +158,7 @@ class DynamicContentMap implements DiviAdapter
|
||
continue;
|
||
}
|
||
|
||
- $fields = [];
|
||
- foreach (is_array($option['fields'] ?? null) ? $option['fields'] : [] as $key => $field) {
|
||
- if (is_string($key) && $key !== '') {
|
||
- $fields[$key] = self::type($key, $field);
|
||
- }
|
||
- }
|
||
- $map[$name] = $fields;
|
||
+ $map[$name] = self::fields($option);
|
||
}
|
||
|
||
foreach (Placeholders::POST as $option) {
|
||
@@ -141,6 +168,39 @@ class DynamicContentMap implements DiviAdapter
|
||
return $map;
|
||
}
|
||
|
||
+ /**
|
||
+ * Setting key => field type of one registry option.
|
||
+ *
|
||
+ * @param array<array-key, mixed> $option
|
||
+ * @return array<string, string>
|
||
+ */
|
||
+ private static function fields(array $option): array
|
||
+ {
|
||
+ $fields = [];
|
||
+ foreach (is_array($option['fields'] ?? null) ? $option['fields'] : [] as $key => $field) {
|
||
+ if (is_string($key) && $key !== '') {
|
||
+ $fields[$key] = self::type($key, $field);
|
||
+ }
|
||
+ }
|
||
+
|
||
+ return $fields;
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Loop kind of a registry option (see loopFields()); null for other names and ACF repeater options.
|
||
+ *
|
||
+ * @param array<array-key, mixed> $option
|
||
+ */
|
||
+ private static function loopKind(string $name, array $option): ?string
|
||
+ {
|
||
+ $byName = LoopScope::kindOfOption($name);
|
||
+ if ($byName === null || str_contains($name, '_meta_key_')) {
|
||
+ return $byName;
|
||
+ }
|
||
+
|
||
+ return self::LOOP_GROUPS[is_string($option['group'] ?? null) ? $option['group'] : ''] ?? LoopScope::POST;
|
||
+ }
|
||
+
|
||
/**
|
||
* Field type of one setting. before/after are "richtext" whatever Divi declares ("text"): Divi
|
||
* outputs them as HTML (DynamicContentElements::get_wrapper_element, wp_kses by the author's
|
||
```
|
||
|
||
- [ ] **Step 2a.4: GREEN, Gesamtlauf und Lint**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DynamicContentMapTest && composer test && composer lint`
|
||
Expected: `OK (14 tests, 95 assertions)`, dann `OK (3397 tests, …)`; phpcs sauber. (Die erweiterte Fixture berührt keinen anderen Test: `DiviDynamicSourcesAbilityTest`, `DiviContentComposerTest`, `ShortformWriterTest`, `PlaceholdersTest`, `StoredNodeGuardTest`, `DiviCompositionTest`, `DiviWriterTest` gehen über `fromOptions()`/`options()`, die `loop_*` verwerfen.)
|
||
|
||
- [ ] **Step 2a.5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Pages/DynamicContentMap.php tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php tests/Support/DiviFixtures.php
|
||
git commit -m "feat: DynamicContentMap::loopFields() – loop_*-Optionen nach Art (X2); echte Loop-Optionen in DiviFixtures" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### Task 2b: `DynamicContentRules` und `VariableRef` mit `LoopScope` (X5, Typabgleich)
|
||
|
||
`DynamicContentRulesTest` (neu, reine Klasse) prüft `fieldsFor()` direkt mit einer Map, die Optionen **aller** Arten nennt (der ungünstigste Fall: ein Map-Eintrag darf die Art-Prüfung nicht ersetzen), die Meldungen wörtlich. `VariableRefTest` prüft den Weg über `canonical()` (Einstellungen, `enable_html`, Meta-Schlüssel in einer Term-Schleife). Die bestehenden Meta-Schlüssel-Tests bekommen den echten Namen `loop_post_meta_key_manual_custom_field` (Helfer `loopMeta()`) und einen Beitrags-Kontext; `testMetaKeysAreCheckedAfterDivisDerivation` verlangt jetzt zusätzlich eine Meldung „Meta-Schlüssel“/„Unbekannter Name“, damit keine Zeile vakuös an der Schleifen-Prüfung scheitert. `testInjectedDynamicContentMap` prüft statt der alten kontextfreien Annahme: ohne Schleife abgelehnt, in der Beitrags-Schleife angenommen (A3).
|
||
|
||
- [ ] **Step 2b.1: Failing Tests schreiben**
|
||
|
||
Datei `tests/Unit/Integrations/Divi/Compose/DynamicContentRulesTest.php` anlegen:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
|
||
use LIT\MCP\Integrations\Divi\Compose\LoopScope;
|
||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
/** Rulings X2/X5 and the security boundary "Typabgleich": loop_* names only in a loop of their kind. */
|
||
final class DynamicContentRulesTest extends TestCase
|
||
{
|
||
/** The map of a composition inside a loop: forPost() plus loopFields() of every kind (worst case). */
|
||
private const MAP = [
|
||
'post_title' => ['before' => 'richtext', 'after' => 'richtext'],
|
||
'loop_post_title' => ['before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text'],
|
||
'loop_post_meta_key_manual_custom_field' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'],
|
||
'loop_term_name' => ['before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text'],
|
||
'loop_term_meta_key_manual_custom_field' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'],
|
||
'loop_user_meta_key_manual_custom_field' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'],
|
||
'loop_menu_text' => ['before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text'],
|
||
'loop_acf_team|||name' => ['before' => 'richtext', 'after' => 'richtext'],
|
||
];
|
||
|
||
private static function rules(?LoopScope $loop = null): DynamicContentRules
|
||
{
|
||
return new DynamicContentRules(DiviFixtures::designSystem(), self::MAP, $loop);
|
||
}
|
||
|
||
private static function assertRefused(DynamicContentRules $rules, string $name, string $message): void
|
||
{
|
||
try {
|
||
$rules->fieldsFor($name);
|
||
self::fail('Accepted: ' . $name);
|
||
} catch (InvalidArgumentException $e) {
|
||
self::assertSame($message, $e->getMessage(), $name);
|
||
}
|
||
}
|
||
|
||
/** Spec §9 / X5: without a loop every loop_* name is refused – also one the map names. */
|
||
public function testWithoutALoopEveryLoopOptionIsRefused(): void
|
||
{
|
||
foreach ([self::rules(), self::rules(LoopScope::none())] as $rules) {
|
||
foreach (array_keys(self::MAP) as $name) {
|
||
if (! str_starts_with($name, 'loop_')) {
|
||
continue;
|
||
}
|
||
self::assertRefused(
|
||
$rules,
|
||
$name,
|
||
sprintf(
|
||
'Schleifen-Inhalt "%s" braucht eine Divi-Schleife in diesem Modul oder einem übergeordneten '
|
||
. 'Element (Abschnitt, Zeile, Spalte, Gruppe).',
|
||
$name
|
||
)
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
public function testALoopOptionOfTheLoopsKindHasItsMapSettings(): void
|
||
{
|
||
self::assertSame(self::MAP['loop_post_title'], self::rules(LoopScope::ofQueryType('post_types'))
|
||
->fieldsFor('loop_post_title'));
|
||
self::assertSame(self::MAP['loop_post_title'], self::rules(LoopScope::ofQueryType('current_page'))
|
||
->fieldsFor('loop_post_title'));
|
||
self::assertSame(self::MAP['loop_term_name'], self::rules(LoopScope::ofQueryType('post_taxonomies'))
|
||
->fieldsFor('loop_term_name'));
|
||
self::assertSame(
|
||
self::MAP['loop_user_meta_key_manual_custom_field'],
|
||
self::rules(LoopScope::ofQueryType('user_roles'))->fieldsFor('loop_user_meta_key_manual_custom_field')
|
||
);
|
||
self::assertSame(self::MAP['loop_menu_text'], self::rules(LoopScope::ofQueryType('menus'))
|
||
->fieldsFor('loop_menu_text'));
|
||
}
|
||
|
||
/**
|
||
* Security boundary: Divi reads the meta of the loop object's ID whatever the object is – post meta
|
||
* of the post with the term's ID (DynamicContentLoopOptions.php:145-158). A hard error, not a hint.
|
||
*
|
||
* @return array<string, array{string, string, string}>
|
||
*/
|
||
public static function mismatches(): array
|
||
{
|
||
return [
|
||
'post meta in a term loop' => ['post_taxonomies', 'loop_post_meta_key_manual_custom_field', 'Terme'],
|
||
'post meta in a user loop' => ['user_roles', 'loop_post_meta_key_manual_custom_field', 'Benutzer'],
|
||
'post meta in a menu loop' => ['menus', 'loop_post_meta_key_manual_custom_field', 'Menü'],
|
||
'user meta in a post loop' => ['post_types', 'loop_user_meta_key_manual_custom_field', 'Beiträge'],
|
||
'term meta in a post loop' => ['current_page', 'loop_term_meta_key_manual_custom_field', 'Beiträge'],
|
||
'term meta in a user loop' => ['user_roles', 'loop_term_meta_key_manual_custom_field', 'Benutzer'],
|
||
'term name in a post loop' => ['post_types', 'loop_term_name', 'Beiträge'],
|
||
'post title in a menu loop' => ['menus', 'loop_post_title', 'Menü'],
|
||
'menu text in a term loop' => ['post_taxonomies', 'loop_menu_text', 'Terme'],
|
||
'acf row in a post loop' => ['post_types', 'loop_acf_team|||name', 'Beiträge'],
|
||
'post title in an acf repeater loop' => ['repeater_team', 'loop_post_title', 'repeater_team'],
|
||
'acf row in an acf repeater loop' => ['repeater_team', 'loop_acf_team|||name', 'repeater_team'],
|
||
];
|
||
}
|
||
|
||
/** @dataProvider mismatches */
|
||
public function testAnOptionOfAnotherKindIsRefused(string $queryType, string $name, string $label): void
|
||
{
|
||
self::assertRefused(
|
||
self::rules(LoopScope::ofQueryType($queryType)),
|
||
$name,
|
||
sprintf('Schleifen-Inhalt "%s" passt nicht zur Schleife dieses Elements (Typ: %s).', $name, $label)
|
||
);
|
||
}
|
||
|
||
public function testALoopOptionTheMapDoesNotNameIsUnknown(): void
|
||
{
|
||
$rules = self::rules(LoopScope::ofQueryType('post_types'));
|
||
|
||
foreach (['loop_post_date', 'loop_post_meta_key_telefon', 'loop_'] as $name) {
|
||
try {
|
||
$rules->fieldsFor($name);
|
||
self::fail('Accepted: ' . $name);
|
||
} catch (InvalidArgumentException $e) {
|
||
self::assertStringContainsString('Unbekannter Name "' . $name . '"', $e->getMessage());
|
||
}
|
||
}
|
||
// The default map (registry unavailable) names no loop option.
|
||
$this->expectExceptionMessage('Unbekannter Name "loop_post_title"');
|
||
(new DynamicContentRules(DiviFixtures::designSystem(), null, LoopScope::ofQueryType('post_types')))
|
||
->fieldsFor('loop_post_title');
|
||
}
|
||
|
||
public function testOtherNamesAreTheSameInsideALoop(): void
|
||
{
|
||
foreach ([null, LoopScope::ofQueryType('post_taxonomies')] as $loop) {
|
||
$rules = self::rules($loop);
|
||
|
||
self::assertSame(self::MAP['post_title'], $rules->fieldsFor('post_title'));
|
||
self::assertSame(DynamicContentRules::META_SETTINGS, $rules->fieldsFor('custom_meta_telefon'));
|
||
self::assertSame(DynamicContentRules::DESIGN_SETTINGS, $rules->fieldsFor('gvid-s1'));
|
||
foreach (['custom_meta__edit_lock', 'site_title'] as $name) {
|
||
try {
|
||
$rules->fieldsFor($name);
|
||
self::fail('Accepted: ' . $name);
|
||
} catch (InvalidArgumentException $e) {
|
||
self::assertStringContainsString('Unbekannter Name "' . $name . '"', $e->getMessage());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/Compose/VariableRefTest.php` (Hunks gegen den Stand nach 2a = 6203dbb):
|
||
|
||
```diff
|
||
diff --git a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php
|
||
--- a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php
|
||
+++ b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php
|
||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||
|
||
use InvalidArgumentException;
|
||
+use LIT\MCP\Integrations\Divi\Compose\LoopScope;
|
||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||
use LIT\MCP\Tests\Support\ComposeFixture;
|
||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||
@@ -14,9 +15,14 @@ use PHPUnit\Framework\TestCase;
|
||
final class VariableRefTest extends TestCase
|
||
{
|
||
/** @param array<string, array<string, string>>|null $dynamicContent */
|
||
- private static function ref(?array $dynamicContent = null): VariableRef
|
||
+ private static function ref(?array $dynamicContent = null, ?LoopScope $loop = null): VariableRef
|
||
{
|
||
- return new VariableRef(DiviFixtures::designSystem(), $dynamicContent);
|
||
+ return new VariableRef(DiviFixtures::designSystem(), $dynamicContent, $loop);
|
||
+ }
|
||
+
|
||
+ private static function postLoop(): LoopScope
|
||
+ {
|
||
+ return LoopScope::ofQueryType('post_types');
|
||
}
|
||
|
||
private static function content(string $name, string $settings): string
|
||
@@ -24,6 +30,12 @@ final class VariableRefTest extends TestCase
|
||
return '$variable({"type":"content","value":{"name":"' . $name . '","settings":' . $settings . '}})$';
|
||
}
|
||
|
||
+ /** Divi's one post meta loop option (DynamicContentOptionLoopPostMetaKey.php:218-227). */
|
||
+ private static function loopMeta(string $settings): string
|
||
+ {
|
||
+ return self::content('loop_post_meta_key_manual_custom_field', $settings);
|
||
+ }
|
||
+
|
||
/** A JSON "\uXXXX" escape, built from bytes so no tool turns it into the character itself. */
|
||
private static function escape(string $hex): string
|
||
{
|
||
@@ -219,7 +231,18 @@ final class VariableRefTest extends TestCase
|
||
self::content('post_date', '{"date_format":"d.m.Y","offset":3}'),
|
||
$ref->canonical(self::content('post_date', '{"date_format":" d.m.Y","offset":3}'), $sanitizer)
|
||
);
|
||
- self::assertSame(self::content('loop_post_title', '{}'), $ref->canonical(self::content('loop_post_title', '{}'), $sanitizer));
|
||
+ // Ruling X5: a loop_* name the map names still needs a loop of its kind.
|
||
+ try {
|
||
+ $ref->canonical(self::content('loop_post_title', '{}'), $sanitizer);
|
||
+ self::fail('loop_post_title accepted without a loop');
|
||
+ } catch (InvalidArgumentException $e) {
|
||
+ self::assertStringContainsString('braucht eine Divi-Schleife', $e->getMessage());
|
||
+ }
|
||
+ $inLoop = self::ref(['loop_post_title' => []], self::postLoop());
|
||
+ self::assertSame(
|
||
+ self::content('loop_post_title', '{}'),
|
||
+ $inLoop->canonical(self::content('loop_post_title', '{}'), $sanitizer)
|
||
+ );
|
||
// Names outside the map: custom_meta_ keeps its rule, the rest is unknown.
|
||
self::assertSame(
|
||
self::content('custom_meta_telefon', '{"before":"<b>T</b>"}'),
|
||
@@ -241,6 +264,57 @@ final class VariableRefTest extends TestCase
|
||
}
|
||
}
|
||
|
||
+ /** Ruling X2/X5: loop content in a loop of its kind is cleaned like any other content reference. */
|
||
+ public function testLoopContentInALoopOfItsKind(): void
|
||
+ {
|
||
+ $meta = [
|
||
+ 'before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text',
|
||
+ 'select_loop_meta_key' => 'select', 'loop_meta_key' => 'text', 'enable_html' => 'yes_no_button',
|
||
+ ];
|
||
+ $map = [
|
||
+ 'loop_post_date' => [
|
||
+ 'before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text',
|
||
+ 'date_format' => 'select', 'custom_date_format' => 'text',
|
||
+ ],
|
||
+ 'loop_post_meta_key_manual_custom_field' => $meta,
|
||
+ 'loop_term_meta_key_manual_custom_field' => $meta,
|
||
+ ];
|
||
+ $sanitizer = ComposeFixture::sanitizer();
|
||
+ $date = self::content(
|
||
+ 'loop_post_date',
|
||
+ '{"before":"Am ","after":"","loop_position":"","date_format":"custom","custom_date_format":"d.m.Y"}'
|
||
+ );
|
||
+
|
||
+ self::assertSame($date, self::ref($map, self::postLoop())->canonical($date, $sanitizer));
|
||
+ self::assertSame(
|
||
+ self::content('loop_post_date', '{"before":"Am ","after":""}'),
|
||
+ self::ref($map, self::postLoop())->canonical(
|
||
+ self::content('loop_post_date', '{"before":"<script>x()</script>Am ","after":""}'),
|
||
+ $sanitizer
|
||
+ )
|
||
+ );
|
||
+
|
||
+ $terms = self::ref($map, LoopScope::ofQueryType('post_taxonomies'));
|
||
+ $termMeta = self::content('loop_term_meta_key_manual_custom_field', '{"select_loop_meta_key":"%s"}');
|
||
+ $farbe = sprintf($termMeta, 'loop_term_meta_key_farbe');
|
||
+ self::assertSame($farbe, $terms->canonical($farbe, $sanitizer));
|
||
+
|
||
+ $rejected = [
|
||
+ 'Typ: Terme' => [$terms, self::loopMeta('{"select_loop_meta_key":"loop_post_meta_key_telefon"}')],
|
||
+ 'Meta-Schlüssel' => [$terms, sprintf($termMeta, 'loop_term_meta_key__x')],
|
||
+ 'HTML-Ausgabe' => [self::ref($map, self::postLoop()), self::loopMeta('{"enable_html":"on"}')],
|
||
+ 'braucht eine Divi-Schleife' => [self::ref($map), $date],
|
||
+ ];
|
||
+ foreach ($rejected as $fragment => [$ref, $value]) {
|
||
+ try {
|
||
+ $ref->canonical($value, $sanitizer);
|
||
+ self::fail('Accepted: ' . $value);
|
||
+ } catch (InvalidArgumentException $e) {
|
||
+ self::assertStringContainsString($fragment, $e->getMessage());
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+
|
||
/** S28: the split-marker rows really carry no marker before sanitizing. */
|
||
public function testSplitMarkerRowsHaveNoRawMarker(): void
|
||
{
|
||
@@ -258,8 +332,8 @@ final class VariableRefTest extends TestCase
|
||
$ref = self::ref([
|
||
'post_meta_key' => ['before' => 'text', 'meta_key' => 'text', 'select_meta_key' => 'select'],
|
||
'custom_meta__edit_lock' => ['before' => 'text'],
|
||
- 'loop_post_meta_key' => ['loop_meta_key' => 'text'],
|
||
- ]);
|
||
+ 'loop_post_meta_key_manual_custom_field' => ['loop_meta_key' => 'text'],
|
||
+ ], self::postLoop());
|
||
$sanitizer = ComposeFixture::sanitizer();
|
||
|
||
self::assertSame(
|
||
@@ -275,7 +349,7 @@ final class VariableRefTest extends TestCase
|
||
self::content('post_meta_key', '{"meta_key":"_wp_page_template"}') => 'meta_key',
|
||
self::content('post_meta_key', '{"select_meta_key":"custom_meta__edit_lock"}') => 'select_meta_key',
|
||
self::content('post_meta_key', '{"meta_key":"a b"}') => 'meta_key',
|
||
- self::content('loop_post_meta_key', '{"loop_meta_key":"_secret"}') => 'loop_meta_key',
|
||
+ self::loopMeta('{"loop_meta_key":"_secret"}') => 'loop_meta_key',
|
||
self::content('custom_meta__edit_lock', '{}') => 'custom_meta__edit_lock',
|
||
];
|
||
foreach ($rejected as $value => $fragment) {
|
||
@@ -300,20 +374,20 @@ final class VariableRefTest extends TestCase
|
||
self::content('post_meta_key', '{"select_meta_key":"custom_meta_custom_meta__wp_page_template"}'),
|
||
],
|
||
'doubled prefix in meta_key' => [self::content('post_meta_key', '{"meta_key":"custom_meta_custom_meta__x"}')],
|
||
- 'loop post prefix' => [self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_post_meta_key__edit_lock"}')],
|
||
- 'loop user prefix' => [self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_user_meta_key__session_tokens"}')],
|
||
- 'loop term prefix' => [self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_term_meta_key__secret"}')],
|
||
+ 'loop post prefix' => [self::loopMeta('{"select_loop_meta_key":"loop_post_meta_key__edit_lock"}')],
|
||
+ 'loop user prefix' => [self::loopMeta('{"select_loop_meta_key":"loop_user_meta_key__session_tokens"}')],
|
||
+ 'loop term prefix' => [self::loopMeta('{"select_loop_meta_key":"loop_term_meta_key__secret"}')],
|
||
'doubled loop prefix' => [
|
||
- self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_post_meta_key_loop_post_meta_key__edit_lock"}'),
|
||
+ self::loopMeta('{"select_loop_meta_key":"loop_post_meta_key_loop_post_meta_key__edit_lock"}'),
|
||
],
|
||
'mixed loop and meta prefix' => [
|
||
- self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_post_meta_key_custom_meta__edit_lock"}'),
|
||
+ self::loopMeta('{"select_loop_meta_key":"loop_post_meta_key_custom_meta__edit_lock"}'),
|
||
],
|
||
'mixed meta and loop prefix' => [
|
||
self::content('post_meta_key', '{"select_meta_key":"custom_meta_loop_term_meta_key__secret"}'),
|
||
],
|
||
'two loop prefixes' => [
|
||
- self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_user_meta_key_loop_post_meta_key_telefon"}'),
|
||
+ self::loopMeta('{"select_loop_meta_key":"loop_user_meta_key_loop_post_meta_key_telefon"}'),
|
||
],
|
||
];
|
||
}
|
||
@@ -328,10 +402,12 @@ final class VariableRefTest extends TestCase
|
||
{
|
||
$ref = self::ref([
|
||
'post_meta_key' => ['meta_key' => 'text', 'select_meta_key' => 'select'],
|
||
- 'loop_post_meta_key' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'],
|
||
- ]);
|
||
+ 'loop_post_meta_key_manual_custom_field' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'],
|
||
+ ], self::postLoop());
|
||
|
||
$this->expectException(InvalidArgumentException::class);
|
||
+ // The meta key rule refuses it – not the loop check (the post loop fits the post meta option).
|
||
+ $this->expectExceptionMessageMatches('/Meta-Schlüssel|Unbekannter Name/');
|
||
|
||
$ref->canonical($value, ComposeFixture::sanitizer());
|
||
}
|
||
@@ -340,18 +416,17 @@ final class VariableRefTest extends TestCase
|
||
{
|
||
$ref = self::ref([
|
||
'post_meta_key' => ['meta_key' => 'text', 'select_meta_key' => 'select'],
|
||
- 'loop_post_meta_key' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'],
|
||
- ]);
|
||
+ 'loop_post_meta_key_manual_custom_field' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'],
|
||
+ ], self::postLoop());
|
||
$sanitizer = ComposeFixture::sanitizer();
|
||
|
||
foreach ([
|
||
self::content('custom_meta_telefon', '{}'),
|
||
self::content('post_meta_key', '{"select_meta_key":"custom_meta_fax"}'),
|
||
- self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_post_meta_key_telefon"}'),
|
||
- self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_user_meta_key_first_name"}'),
|
||
- self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_term_meta_key_farbe"}'),
|
||
- self::content(
|
||
- 'loop_post_meta_key',
|
||
+ self::loopMeta('{"select_loop_meta_key":"loop_post_meta_key_telefon"}'),
|
||
+ self::loopMeta('{"select_loop_meta_key":"loop_user_meta_key_first_name"}'),
|
||
+ self::loopMeta('{"select_loop_meta_key":"loop_term_meta_key_farbe"}'),
|
||
+ self::loopMeta(
|
||
'{"select_loop_meta_key":"loop_post_meta_key_manual_custom_field_value","loop_meta_key":"telefon"}'
|
||
),
|
||
] as $value) {
|
||
```
|
||
|
||
- [ ] **Step 2b.2: Tests laufen lassen – RED**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'VariableRefTest|DynamicContentRulesTest'`
|
||
Expected: FAIL – `Tests: 96, Assertions: 226, Failures: 16.`: `testWithoutALoopEveryLoopOptionIsRefused` (`Accepted: loop_post_title`), die zwölf Zeilen von `testAnOptionOfAnotherKindIsRefused` (`Accepted: …`), `testALoopOptionTheMapDoesNotNameIsUnknown` (`… ist hier nicht verfügbar (nur innerhalb von Divi-Schleifen).' contains "Unbekannter Name "loop_post_date""`), `VariableRefTest::testInjectedDynamicContentMap` (`loop_post_title accepted without a loop`), `VariableRefTest::testLoopContentInALoopOfItsKind` (`Accepted: …loop_post_meta_key_manual_custom_field…`). Die umbenannten Meta-Schlüssel-Tests sind schon grün (heute nimmt die Map jeden genannten `loop_*`-Namen an; PHP ignoriert das überzählige Konstruktor-Argument) – sie sichern in 2b.4 ab, dass S29/S30 im Kontext greifen.
|
||
|
||
- [ ] **Step 2b.3: Regeln und `VariableRef` implementieren**
|
||
|
||
`src/Integrations/Divi/Compose/DynamicContentRules.php`:
|
||
|
||
```diff
|
||
diff --git a/src/Integrations/Divi/Compose/DynamicContentRules.php b/src/Integrations/Divi/Compose/DynamicContentRules.php
|
||
--- a/src/Integrations/Divi/Compose/DynamicContentRules.php
|
||
+++ b/src/Integrations/Divi/Compose/DynamicContentRules.php
|
||
@@ -15,7 +15,8 @@ defined('ABSPATH') || exit;
|
||
*
|
||
* Allowed names: a non-color global variable or a theme font (no settings, DESIGN_SETTINGS,
|
||
* S27); custom_meta_<key> with META_KEY (no leading "_", ruling S12; settings from the map entry
|
||
- * of that exact name, else META_SETTINGS); or a key of the dynamic content map.
|
||
+ * of that exact name, else META_SETTINGS); a loop_* key of the map only inside a Divi loop of its
|
||
+ * kind (constructor argument $loop, rulings X2/X5 – see loopFieldsFor()); or another key of the map.
|
||
*
|
||
* Dynamic content map (ruling S22), constructor argument $map:
|
||
* array<string, array<string, string>> option name => [setting key => field type]
|
||
@@ -27,7 +28,7 @@ defined('ABSPATH') || exit;
|
||
* Default (null): DEFAULT_DYNAMIC_CONTENT. Task 5 builds the map from Divi's live registry
|
||
* DynamicContentOptions::get_options($post_id, 'edit') – option name => its "fields" (key =>
|
||
* field "type"). Always, whatever the map says: "enable_html" is rejected (VariableRef), loop_*
|
||
- * names only when the map names them, custom_meta_<key> names and the values of settings whose
|
||
+ * names only in a loop of their kind, custom_meta_<key> names and the values of settings whose
|
||
* key ends in "meta_key" (meta_key, select_meta_key, loop_meta_key …) obey META_KEY after Divi's
|
||
* own prefix stripping (rulings S29, S30), and no string – before or after cleaning (ruling S28)
|
||
* – may contain a reference or ")$". A map of another shape fails in the constructor with
|
||
@@ -86,13 +87,17 @@ final class DynamicContentRules
|
||
/** @var array<string, array<string, string>> */
|
||
private array $map;
|
||
|
||
+ private LoopScope $loop;
|
||
+
|
||
/**
|
||
- * @param array<array-key, mixed>|null $map See the class docblock.
|
||
+ * @param array<array-key, mixed>|null $map See the class docblock.
|
||
+ * @param LoopScope|null $loop The Divi loop the content sits in; null = none (no loop_* names).
|
||
* @throws InvalidArgumentException When the map does not have that shape.
|
||
*/
|
||
- public function __construct(private DesignSystem $design, ?array $map = null)
|
||
+ public function __construct(private DesignSystem $design, ?array $map = null, ?LoopScope $loop = null)
|
||
{
|
||
$this->map = $map === null ? self::DEFAULT_DYNAMIC_CONTENT : self::checkMap($map);
|
||
+ $this->loop = $loop ?? LoopScope::none();
|
||
}
|
||
|
||
/**
|
||
@@ -110,22 +115,53 @@ final class DynamicContentRules
|
||
return $this->map[$name] ?? self::META_SETTINGS;
|
||
}
|
||
|
||
+ if (str_starts_with($name, 'loop_')) {
|
||
+ return $this->loopFieldsFor($name);
|
||
+ }
|
||
+
|
||
if (isset($this->map[$name])) {
|
||
return $this->map[$name];
|
||
}
|
||
|
||
- if (str_starts_with($name, 'loop_')) {
|
||
+ if (in_array($name, $this->design->contentVariableIds(), true)) {
|
||
+ return self::DESIGN_SETTINGS;
|
||
+ }
|
||
+
|
||
+ throw VariableRef::unknownName($name, 'content');
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * A loop_* option (rulings X2/X5): only inside a Divi loop, only of the loop's kind (LoopScope::fits(),
|
||
+ * by the option name – Divi reads the meta of the loop object's ID whatever the object is, so a post
|
||
+ * meta option in a term loop would print the post with the term's ID: a security boundary, always an
|
||
+ * error), and only when the map names it. Its settings then follow the usual rules (meta keys S29/S30).
|
||
+ *
|
||
+ * @return array<string, string>
|
||
+ * @throws InvalidArgumentException
|
||
+ */
|
||
+ private function loopFieldsFor(string $name): array
|
||
+ {
|
||
+ if ($this->loop->queryType() === null) {
|
||
throw new InvalidArgumentException(sprintf(
|
||
- 'Schleifen-Inhalt "%s" ist hier nicht verfügbar (nur innerhalb von Divi-Schleifen).',
|
||
+ 'Schleifen-Inhalt "%s" braucht eine Divi-Schleife in diesem Modul oder einem übergeordneten '
|
||
+ . 'Element (Abschnitt, Zeile, Spalte, Gruppe).',
|
||
$name
|
||
));
|
||
}
|
||
|
||
- if (in_array($name, $this->design->contentVariableIds(), true)) {
|
||
- return self::DESIGN_SETTINGS;
|
||
+ if (! $this->loop->fits($name)) {
|
||
+ throw new InvalidArgumentException(sprintf(
|
||
+ 'Schleifen-Inhalt "%s" passt nicht zur Schleife dieses Elements (Typ: %s).',
|
||
+ $name,
|
||
+ $this->loop->label()
|
||
+ ));
|
||
}
|
||
|
||
- throw VariableRef::unknownName($name, 'content');
|
||
+ if (! isset($this->map[$name])) {
|
||
+ throw VariableRef::unknownName($name, 'content');
|
||
+ }
|
||
+
|
||
+ return $this->map[$name];
|
||
}
|
||
|
||
/**
|
||
```
|
||
|
||
`src/Integrations/Divi/Compose/VariableRef.php`:
|
||
|
||
```diff
|
||
diff --git a/src/Integrations/Divi/Compose/VariableRef.php b/src/Integrations/Divi/Compose/VariableRef.php
|
||
--- a/src/Integrations/Divi/Compose/VariableRef.php
|
||
+++ b/src/Integrations/Divi/Compose/VariableRef.php
|
||
@@ -27,7 +27,8 @@ defined('ABSPATH') || exit;
|
||
* - type "content": DynamicContentRules – the dynamic content map (ruling S22), custom_meta_<key>
|
||
* (S12/S29/S30), non-color global variables and theme fonts (S27); it also cleans each content
|
||
* setting. "enable_html" is always rejected here. Internal links post_link_url_<type> ({{link:…}},
|
||
- * E13a) only in URL fields.
|
||
+ * E13a) only in URL fields. loop_* options only inside a Divi loop of their kind (constructor
|
||
+ * argument $loop, rulings X2/X5); without it no loop_* name is allowed.
|
||
* A dynamic content map of the wrong shape fails in the constructor with InvalidArgumentException.
|
||
*/
|
||
final class VariableRef
|
||
@@ -47,11 +48,12 @@ final class VariableRef
|
||
|
||
/**
|
||
* @param array<array-key, mixed>|null $dynamicContent See DynamicContentRules.
|
||
+ * @param LoopScope|null $loop The Divi loop of the values; null = none.
|
||
* @throws InvalidArgumentException When the map does not have that shape.
|
||
*/
|
||
- public function __construct(private DesignSystem $design, ?array $dynamicContent = null)
|
||
+ public function __construct(private DesignSystem $design, ?array $dynamicContent = null, ?LoopScope $loop = null)
|
||
{
|
||
- $this->content = new DynamicContentRules($design, $dynamicContent);
|
||
+ $this->content = new DynamicContentRules($design, $dynamicContent, $loop);
|
||
}
|
||
|
||
/** Whether $value contains a reference anywhere (such a value may only be a whole reference). */
|
||
```
|
||
|
||
- [ ] **Step 2b.4: GREEN, Gesamtlauf und Lint**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'VariableRefTest|DynamicContentRulesTest' && composer test && composer lint`
|
||
Expected: `OK (96 tests, 250 assertions)`, dann `OK (3414 tests, …)`; phpcs sauber. Unverändert grün bleiben insbesondere `DiviFixtures::maliciousRefs()` „loop content outside a loop“ (die Meldung nennt `loop_post_title`, in `VariableRefTest`, `AttrWriterTest`, `ShortformWriterTest`) und `DynamicContentMapTest::testTheMapWorksInVariableRef` (`'Schleifen'` steckt in „Schleifen-Inhalt“). Gegenprobe im Arbeitsstand: `fits()`-Prüfung entfernt → 13 Failures.
|
||
|
||
- [ ] **Step 2b.5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Compose/DynamicContentRules.php src/Integrations/Divi/Compose/VariableRef.php tests/Unit/Integrations/Divi/Compose/DynamicContentRulesTest.php tests/Unit/Integrations/Divi/Compose/VariableRefTest.php
|
||
git commit -m "feat: DynamicContentRules/VariableRef mit LoopScope – loop_* nur in passender Schleife (X2, X5)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### Task 2c: Docblocks (S36 abgelöst) und Pin-Test der Loop-Map
|
||
|
||
Die S36-Stellen aus Recherche §5 im Code: `DynamicContentRules.php:29-31` und `:117-122` (in 2b), `DynamicContentMap.php:28-29`, `:67` (in 2a), `DiviDynamicSourcesAbility.php:25` (hier). `Placeholders.php` gehört zu Task 3, Tool-Beschreibungen und Guides zu Task 7, Spec/Notizen zu Task 9. Der neue Test pinnt die Kombination, die Task 4 baut (`forPost() + loopFields()` mit Kontext) – er ist sofort grün (Charakterisierung, kein RED).
|
||
|
||
- [ ] **Step 2c.1: Docblock, Testkommentar und Pin-Test**
|
||
|
||
```diff
|
||
diff --git a/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php b/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php
|
||
--- a/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php
|
||
+++ b/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php
|
||
@@ -22,8 +22,8 @@ defined('ABSPATH') || exit;
|
||
/**
|
||
* lit/divi-dynamic-sources (spec §5.7, E12): Divi's dynamic content options for a post type
|
||
* (DynamicContentOptions::get_options with a sample entry of the type the user may edit, else 0;
|
||
- * no loop_* – S36; via DynamicContentMap::options()) with the placeholder that writes each, and
|
||
- * the fields of the type usable as {{field:<key>}}.
|
||
+ * no loop_* – they need a node in a Divi loop, ruling X6; via DynamicContentMap::options()) with the
|
||
+ * placeholder that writes each, and the fields of the type usable as {{field:<key>}}.
|
||
*
|
||
* Answer F4: only text-like fields are offered; relation/file fields show IDs in Divi (F26), Pods
|
||
* table storage is not post meta, protected keys never (S12), unknown types not (ruling P9) – each
|
||
diff --git a/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php b/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php
|
||
--- a/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php
|
||
+++ b/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php
|
||
@@ -101,7 +101,7 @@ final class DynamicContentMapTest extends TestCase
|
||
// enable_html stays forbidden even though the registry lists it.
|
||
$html = $ref('post_meta_key', ['enable_html' => 'on']);
|
||
self::assertRejected(static fn () => $references->canonical($html, $sanitizer), 'enable_html');
|
||
- // Loop content: Divi offers it only inside loops – never through the tools.
|
||
+ // Loop content needs a Divi loop of its kind (ruling X5) – this VariableRef has none.
|
||
$loop = $ref('loop_post_title', []);
|
||
self::assertRejected(static fn () => $references->canonical($loop, $sanitizer), 'Schleifen');
|
||
}
|
||
@@ -276,6 +276,36 @@ final class DynamicContentMapTest extends TestCase
|
||
self::assertArrayNotHasKey('loop_post_meta_key_manual_custom_field', $map->loopFields(LoopScope::USER));
|
||
}
|
||
|
||
+ /** The map a loop composition builds (Task 4): forPost() + loopFields() of the loop's kind. */
|
||
+ public function testLoopFieldsWorkInVariableRefInsideALoopOfTheirKind(): void
|
||
+ {
|
||
+ $map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry());
|
||
+ $terms = LoopScope::ofQueryType('post_taxonomies');
|
||
+ $references = new VariableRef(
|
||
+ DiviFixtures::designSystem(),
|
||
+ (array) $map->forPost(831) + $map->loopFields((string) $terms->kind(), 831),
|
||
+ $terms
|
||
+ );
|
||
+ $sanitizer = ComposeFixture::sanitizer();
|
||
+ $ref = static fn (string $name, array $settings): string => VariableRef::serialize('content', $name, $settings);
|
||
+
|
||
+ $name = $ref('loop_term_name', ['before' => 'Kategorie: ', 'after' => '', 'loop_position' => '']);
|
||
+ self::assertSame($name, $references->canonical($name, $sanitizer));
|
||
+ $title = $ref('post_title', ['before' => '', 'after' => '']);
|
||
+ self::assertSame($title, $references->canonical($title, $sanitizer));
|
||
+ self::assertRejected(
|
||
+ static fn () => $references->canonical($ref('loop_post_title', []), $sanitizer),
|
||
+ 'passt nicht zur Schleife dieses Elements (Typ: Terme)'
|
||
+ );
|
||
+ self::assertRejected(
|
||
+ static fn () => $references->canonical(
|
||
+ $ref('loop_term_meta_key_manual_custom_field', ['enable_html' => 'on']),
|
||
+ $sanitizer
|
||
+ ),
|
||
+ 'enable_html'
|
||
+ );
|
||
+ }
|
||
+
|
||
public function testLoopFieldsReadTheRegistryOfThePostAndAreEmptyWithoutIt(): void
|
||
{
|
||
$seen = [];
|
||
```
|
||
|
||
- [ ] **Step 2c.2: Test laufen lassen**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DynamicContentMapTest`
|
||
Expected: `OK (15 tests, 99 assertions)`.
|
||
|
||
- [ ] **Step 2c.3: Task-Abschluss und Commit**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: `OK (3415 tests, 21157 assertions)`; phpcs sauber. `grep -rn "S36\|nur innerhalb von Divi-Schleifen" src tests` findet nichts mehr.
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php
|
||
git commit -m "docs/test: S36-Hinweise abgelöst (X5); loopFields() in VariableRef gepinnt" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
|
||
### Task 3: Kurzform `{{loop:<option>}}` in `Placeholders` (X4)
|
||
|
||
`{{loop:<kurzname>}}` schreibt die Divi-Loop-Option `loop_<kurzname>` (z. B. `{{loop:post_date}}` → `loop_post_date`), mit leeren `before`/`after` wie `{{post:…}}` (A10). Die Tabelle `Placeholders::LOOP` nennt die echten Optionen aus Divi 5.15.1 (Recherche 4e, Tabelle §2) ohne Präfix: Post (`post_*`), Term (`term_*`), Benutzer (`user_*`), Menü (`menu_*`). Bewusst nicht in der Tabelle: die drei Meta-Schlüssel-Optionen `loop_<post|user|term>_meta_key_manual_custom_field` (sie brauchen den Schlüssel als Einstellung `select_loop_meta_key`/`loop_meta_key`, also nur Vollform), `loop_product_*` (WooCommerce, außerhalb des Umfangs) und `loop_acf_*` (X7 e).
|
||
|
||
**Welche Optionen before/after tragen (X4):** `Placeholders` hat keinen Zugriff auf die `DynamicContentMap`. Es bekommt deshalb im Konstruktor (optionaler dritter Parameter) die Loop-Felder seiner Komposition – genau das Ergebnis von `DynamicContentMap::loopFields()` für den Schleifen-Kontext –, und schreibt `before`/`after` nur, wenn die Option dort ein `before` hat (Divi: alle außer `loop_post_author_profile_picture`, `loop_post_featured_image` und den Produkt-Bildern). Steht die Option dort nicht (kein Schleifen-Kontext oder andere Art), schreibt der Platzhalter `{}` – die Prüfung in `VariableRef`/`DynamicContentRules` (Task 2) lehnt sie dann mit der passenden X5-Meldung ab; der Platzhalter selbst bleibt kontextfrei. Die Komposition speist die Loop-Felder erst in Task 4 ein (`DiviComposition::placeholders()`); bis dahin ist der Parameter `[]`.
|
||
|
||
**Dokumentations-Haken:** nur Code-Docblocks (Klassen-Docblock, `LOOP`). `ShortformMap`/`ShortformCheck` validieren keine Platzhalter (nur die Beschreibung `ShortformMap.php:179` für Farben) und bleiben unverändert; die Guides (`divi-placeholders.md`, `divi-shortform.md`) schreibt Task 7. `DiviDynamicSourcesAbility::placeholder()` nennt `{{loop:…}}` ab Task 5.
|
||
|
||
**Files:**
|
||
- Modify: `src/Integrations/Divi/Compose/Placeholders.php`
|
||
- Modify: `tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `LoopScope::ofQueryType()` (Task 1), `VariableRef::__construct(DesignSystem, ?array, ?LoopScope)` (Task 2), `DynamicContentMap::fromOptions()` (vorhanden), die X5-Regeln in `DynamicContentRules` (Task 2: `loop_*` aus der Map nur mit aktivem Kontext).
|
||
- Produces:
|
||
- `Placeholders::LOOP` (`public const`, Kurzname ⇒ Option, 31 Einträge).
|
||
- `Placeholders::__construct(DesignSystem $design, ?LinkTargets $links = null, array $loopFields = [])` – `$loopFields` = `array<string, array<string, string>>` (Option ⇒ Einstellung ⇒ Feldtyp, Form von `DynamicContentMap::loopFields()`). Bestehende Aufrufe (`DiviComposition::placeholders()`, `ComposeFixture::placeholders()`, Tests) bleiben gültig.
|
||
- Fehler „Unbekannter Schleifen-Inhalt "%s". Vorhanden: … Den Namen ohne "loop_" angeben (z. B. {{loop:post_date}}); Schleifen-Felder mit Meta-Schlüssel nur in der Vollform (lit/divi-dynamic-sources).“ und die Liste „bekannt: color, var, font, post, field, link, loop“.
|
||
|
||
- [ ] **Step 3.1: Failing Tests in `PlaceholdersTest` ergänzen**
|
||
|
||
In `tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php` (Import, Konstante am Klassenanfang, vier Fehlerzeilen, zwei Tests):
|
||
|
||
```diff
|
||
diff --git a/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php b/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php
|
||
--- a/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php
|
||
+++ b/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php
|
||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||
|
||
use InvalidArgumentException;
|
||
+use LIT\MCP\Integrations\Divi\Compose\LoopScope;
|
||
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
|
||
@@ -15,6 +16,15 @@ use PHPUnit\Framework\TestCase;
|
||
|
||
final class PlaceholdersTest extends TestCase
|
||
{
|
||
+ /** Loop fields of a post loop as DynamicContentMap::loopFields() gives them (Divi 5.15.1 shapes). */
|
||
+ private const POST_LOOP_FIELDS = [
|
||
+ 'loop_post_date' => [
|
||
+ 'before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text',
|
||
+ 'date_format' => 'select', 'custom_date_format' => 'text',
|
||
+ ],
|
||
+ 'loop_post_featured_image' => ['loop_position' => 'text', 'thumbnail_size' => 'select'],
|
||
+ ];
|
||
+
|
||
/**
|
||
* Rows with a third value true go into a URL field (ruling P4: only the link row – URL mode
|
||
* refuses colour references by design).
|
||
@@ -78,6 +88,10 @@ final class PlaceholdersTest extends TestCase
|
||
'bad field key' => ['{{field:a b}}', 'Ungültiger Feldschlüssel'],
|
||
'protected field key' => ['{{field:_edit_lock}}', 'Ungültiger Feldschlüssel'],
|
||
'embedded' => ['Seite: {{post:title}}', 'allein im Wert'],
|
||
+ 'embedded loop value' => ['Am {{loop:post_date}}', 'post, field, link, loop'],
|
||
+ 'unknown loop value' => ['{{loop:views}}', 'Unbekannter Schleifen-Inhalt "views". Vorhanden: post_title,'],
|
||
+ 'loop value with its prefix' => ['{{loop:loop_post_date}}', 'ohne "loop_"'],
|
||
+ 'loop meta key option' => ['{{loop:post_meta_key_manual_custom_field}}', 'nur in der Vollform'],
|
||
'link without id' => ['{{link:start}}', 'Ungültige Beitrags-ID'],
|
||
'link to a missing post' => ['{{link:999}}', 'Eintrag 999'],
|
||
'link without link targets' => ['{{link:0}}', 'Ungültige Beitrags-ID'],
|
||
@@ -168,4 +182,44 @@ final class PlaceholdersTest extends TestCase
|
||
|
||
(new Placeholders(DiviFixtures::designSystem()))->resolve('{{link:839}}');
|
||
}
|
||
+
|
||
+ /**
|
||
+ * Ruling X4: {{loop:<option>}} names the loop option without its prefix; before/after only where the loop
|
||
+ * fields of the composition's scope give the option those settings (Divi has none on image options).
|
||
+ */
|
||
+ public function testLoopPlaceholdersNameTheLoopOption(): void
|
||
+ {
|
||
+ $placeholders = new Placeholders(DiviFixtures::designSystem(), null, self::POST_LOOP_FIELDS);
|
||
+
|
||
+ $date = $placeholders->resolve('{{ loop : Post_Date }}');
|
||
+ self::assertSame(
|
||
+ '$variable({"type":"content","value":{"name":"loop_post_date","settings":{"before":"","after":""}}})$',
|
||
+ $date
|
||
+ );
|
||
+ self::assertSame(
|
||
+ '$variable({"type":"content","value":{"name":"loop_post_featured_image","settings":{}}})$',
|
||
+ $placeholders->resolve('{{loop:post_featured_image}}')
|
||
+ );
|
||
+ // Not in the scope's fields (no loop, another kind): no settings – VariableRef names the loop problem.
|
||
+ self::assertSame(
|
||
+ '$variable({"type":"content","value":{"name":"loop_term_name","settings":{}}})$',
|
||
+ $placeholders->resolve('{{loop:term_name}}')
|
||
+ );
|
||
+
|
||
+ // What the placeholder writes passes the checker of a post loop unchanged.
|
||
+ $references = new VariableRef(
|
||
+ DiviFixtures::designSystem(),
|
||
+ DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry()) + self::POST_LOOP_FIELDS,
|
||
+ LoopScope::ofQueryType('post_types')
|
||
+ );
|
||
+ self::assertSame($date, $references->canonical($date, ComposeFixture::sanitizer()));
|
||
+ }
|
||
+
|
||
+ public function testEveryLoopPlaceholderNamesALoopOption(): void
|
||
+ {
|
||
+ foreach (Placeholders::LOOP as $short => $option) {
|
||
+ self::assertSame('loop_' . $short, $option);
|
||
+ self::assertStringNotContainsString('meta_key', $option, 'meta key options need settings (full form)');
|
||
+ }
|
||
+ }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3.2: RED**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php`
|
||
Expected: FAIL – `Tests: 35, Assertions: 67, Errors: 2, Failures: 4`: die vier neuen Fehlerzeilen bekommen die alte Meldung „Platzhalter müssen allein im Wert stehen … (bekannt: color, var, font, post, field, link).“, `testLoopPlaceholdersNameTheLoopOption` wirft dieselbe Meldung, `testEveryLoopPlaceholderNamesALoopOption` endet mit `Error: Undefined constant …Placeholders::LOOP`.
|
||
|
||
- [ ] **Step 3.3: `Placeholders` erweitern**
|
||
|
||
```diff
|
||
diff --git a/src/Integrations/Divi/Compose/Placeholders.php b/src/Integrations/Divi/Compose/Placeholders.php
|
||
--- a/src/Integrations/Divi/Compose/Placeholders.php
|
||
+++ b/src/Integrations/Divi/Compose/Placeholders.php
|
||
@@ -12,7 +12,9 @@ defined('ABSPATH') || exit;
|
||
/**
|
||
* Translates the placeholders of spec §5.4 into Divi's $variable(…)$ syntax:
|
||
* {{color:primary|<Name>|gcid-…}}, {{var:<Name>|gvid-…}}, {{font:heading|body}},
|
||
- * {{post:title|…}}, {{field:<meta_key>}}, {{link:<ID>}} (internal link, URL fields only – E13a).
|
||
+ * {{post:title|…}}, {{field:<meta_key>}}, {{link:<ID>}} (internal link, URL fields only – E13a),
|
||
+ * {{loop:<option>}} (a loop option without "loop_", ruling X4 – whether the node sits in a loop of the
|
||
+ * option's kind VariableRef checks against the composition's LoopScope, not this class).
|
||
* A placeholder must be the whole value (D9); unknown names fail with the list of existing ones,
|
||
* labels two entries share fail with their IDs (ruling S13a, DesignSystem::colorIdFor/variableFor).
|
||
* The result is still checked by VariableRef wherever it is written. Pure PHP.
|
||
@@ -32,16 +34,64 @@ final class Placeholders
|
||
'tags' => 'post_tags',
|
||
];
|
||
|
||
+ /**
|
||
+ * {{loop:<key>}} => Divi loop option (Divi 5.15.1, DynamicContentOption*.php): the key is the option without
|
||
+ * "loop_". The meta key options (loop_*_meta_key_manual_custom_field) are missing on purpose – they need the
|
||
+ * meta key as a setting, so only the full form can write them.
|
||
+ */
|
||
+ public const LOOP = [
|
||
+ 'post_title' => 'loop_post_title',
|
||
+ 'post_excerpt' => 'loop_post_excerpt',
|
||
+ 'post_date' => 'loop_post_date',
|
||
+ 'post_modified_date' => 'loop_post_modified_date',
|
||
+ 'post_author' => 'loop_post_author',
|
||
+ 'post_author_bio' => 'loop_post_author_bio',
|
||
+ 'post_author_profile_picture' => 'loop_post_author_profile_picture',
|
||
+ 'post_comment_count' => 'loop_post_comment_count',
|
||
+ 'post_featured_image' => 'loop_post_featured_image',
|
||
+ 'post_id' => 'loop_post_id',
|
||
+ 'post_link' => 'loop_post_link',
|
||
+ 'post_terms' => 'loop_post_terms',
|
||
+ 'term_name' => 'loop_term_name',
|
||
+ 'term_description' => 'loop_term_description',
|
||
+ 'term_count' => 'loop_term_count',
|
||
+ 'term_permalink' => 'loop_term_permalink',
|
||
+ 'term_taxonomy' => 'loop_term_taxonomy',
|
||
+ 'term_featured_image' => 'loop_term_featured_image',
|
||
+ 'user_name' => 'loop_user_name',
|
||
+ 'user_username' => 'loop_user_username',
|
||
+ 'user_email' => 'loop_user_email',
|
||
+ 'user_avatar' => 'loop_user_avatar',
|
||
+ 'user_description' => 'loop_user_description',
|
||
+ 'user_url' => 'loop_user_url',
|
||
+ 'menu_text' => 'loop_menu_text',
|
||
+ 'menu_link' => 'loop_menu_link',
|
||
+ 'menu_menu_order' => 'loop_menu_menu_order',
|
||
+ 'menu_attr_title' => 'loop_menu_attr_title',
|
||
+ 'menu_classes' => 'loop_menu_classes',
|
||
+ 'menu_xfn' => 'loop_menu_xfn',
|
||
+ 'menu_description' => 'loop_menu_description',
|
||
+ ];
|
||
+
|
||
/** 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|link)\s*:\s*([^{}]+?)\s*\}\}$/';
|
||
+ private const PATTERN = '/^\{\{\s*(color|var|font|post|field|link|loop)\s*:\s*([^{}]+?)\s*\}\}$/';
|
||
|
||
- /** @param LinkTargets|null $links Entries {{link:…}} may point to; without them every link fails. */
|
||
- public function __construct(private DesignSystem $design, private ?LinkTargets $links = null)
|
||
- {
|
||
+ /**
|
||
+ * @param LinkTargets|null $links Entries {{link:…}} may point to; without them every
|
||
+ * link fails.
|
||
+ * @param array<string, array<string, string>> $loopFields Loop options of the composition's loop scope
|
||
+ * (DynamicContentMap::loopFields): {{loop:…}} writes
|
||
+ * before/after only for options that have them.
|
||
+ */
|
||
+ public function __construct(
|
||
+ private DesignSystem $design,
|
||
+ private ?LinkTargets $links = null,
|
||
+ private array $loopFields = []
|
||
+ ) {
|
||
}
|
||
|
||
/**
|
||
@@ -55,7 +105,7 @@ final class Placeholders
|
||
if (str_contains($value, '{{')) {
|
||
throw new InvalidArgumentException(
|
||
'Platzhalter müssen allein im Wert stehen, z. B. "{{color:primary}}" (bekannt: color, var, font, '
|
||
- . 'post, field, link).'
|
||
+ . 'post, field, link, loop).'
|
||
);
|
||
}
|
||
|
||
@@ -70,6 +120,7 @@ final class Placeholders
|
||
'font' => $this->font($name),
|
||
'post' => $this->post($name),
|
||
'link' => $this->link($name),
|
||
+ 'loop' => $this->loop($name),
|
||
default => $this->field($name),
|
||
};
|
||
}
|
||
@@ -154,6 +205,31 @@ final class Placeholders
|
||
return VariableRef::serialize('content', $option, $settings);
|
||
}
|
||
|
||
+ /**
|
||
+ * {{loop:<key>}} → loop_<key> with empty before/after where the scope's loop fields have them, else no
|
||
+ * settings. Outside a loop or in one of another kind the option is not in those fields: the reference is
|
||
+ * written without settings and VariableRef refuses it with the loop message (ruling X5).
|
||
+ *
|
||
+ * @throws InvalidArgumentException
|
||
+ */
|
||
+ private function loop(string $name): string
|
||
+ {
|
||
+ $option = self::LOOP[strtolower($name)] ?? null;
|
||
+ if ($option === null) {
|
||
+ throw new InvalidArgumentException(sprintf(
|
||
+ 'Unbekannter Schleifen-Inhalt "%s". Vorhanden: %s. Den Namen ohne "loop_" angeben '
|
||
+ . '(z. B. {{loop:post_date}}); Schleifen-Felder mit Meta-Schlüssel nur in der Vollform '
|
||
+ . '(lit/divi-dynamic-sources).',
|
||
+ $name,
|
||
+ implode(', ', array_keys(self::LOOP))
|
||
+ ));
|
||
+ }
|
||
+
|
||
+ $settings = isset($this->loopFields[$option]['before']) ? ['before' => '', 'after' => ''] : [];
|
||
+
|
||
+ return VariableRef::serialize('content', $option, $settings);
|
||
+ }
|
||
+
|
||
/**
|
||
* {{link:<ID>}} → Divi's own internal link post_link_url_<type> with settings.post_id (F27):
|
||
* rendered as get_permalink() at view time, so a draft target gets its pretty URL once published.
|
||
```
|
||
|
||
- [ ] **Step 3.4: GREEN, Gesamtlauf, Lint**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php`
|
||
Expected: `OK (35 tests, 133 assertions)`.
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: grün, sechs Tests mehr als nach Task 2 (vier Datenzeilen, zwei Tests); phpcs ohne Meldung.
|
||
|
||
- [ ] **Step 3.5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Compose/Placeholders.php tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php
|
||
git commit -m "feat: Kurzform {{loop:<option>}} in Placeholders – Tabelle LOOP, before/after aus den Loop-Feldern der Komposition (X4)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Schleifen-Kontext in `lit-divi-insert`, `lit-divi-update`, `lit-divi-move` (X3)
|
||
|
||
Die Werkzeuge bestimmen den Schleifen-Kontext im Blockbaum (`LoopContext`, Task 1) und komponieren in einer an diesen Kontext gebundenen Komposition (`DiviComposition::inLoop()`). Die unteren Schichten (Composer, AttrWriter, ShortformWriter, ValueCleaner) bleiben unverändert: der Kontext steckt in der `VariableRef` der Komposition.
|
||
|
||
**Entscheidungen (X3, verbindlich für diesen Task):**
|
||
- **Kontext an einem Knoten:** `DiviWriteAbility::loopAt(NodeIndex, ?Block, array $newAttrs = [])` baut die Attributkette Wurzel → Knoten über `NodeIndex::parentBlock()` und gibt sie an `LoopContext::fromChain()`; `$newAttrs` (Pfad-ID ⇒ Attribute) ersetzt die gespeicherten Attribute von Knoten, die derselbe Aufruf ändert. Top-Level (`null`) → `LoopScope::none()`. Ein Helfer für alle drei Werkzeuge.
|
||
- **`DiviComposition::inLoop(LoopScope)`:** Klon nach dem Muster `forPost()` (gemeinsamer privater `fresh()`, der alle Caches leert). `references()` = `new VariableRef($design, $map + loopFields, $scope)` mit `$map = forPost($postId)`; `placeholders()` bekommt dieselben Loop-Felder (Task 3). Ohne Eintrag (`postId === null`) oder ohne Registry (`forPost()` = `null`) gibt es keine Loop-Felder → `loop_*` bleibt abgelehnt (fail closed). Ruling **S31** heißt jetzt: *eine `VariableRef` je Komposition, eine Komposition je Eintrag und Schleifen-Kontext* (Klassen-Docblock; die Rulings-Notiz `2026-09-26-etappe-2b-rulings.md` ergänzt Task 9).
|
||
- **Insert:** alle neuen Knoten eines Aufrufs teilen den Kontext des Ziel-Elternknotens (`$position['parent']`). Ein sauberer Weg, den Kontext für die Nachfahren eines *neuen* Knotens mit eigener `loop`-Einstellung umzuschalten, fehlt: `Composer::compose()` komponiert den ganzen Teilbaum mit einer Komposition, ein Neukomponieren einzelner Unterspezifikationen (Abschnitt → Zeile → Spalte → Modul) würde den Composer aufbrechen. Deshalb **fail closed**: schaltet ein neuer Knoten oder ein Nachfahre eine Schleife ein (`LoopContext::enabled()` auf den komponierten Blöcken) **und** die Eingabe enthält Schleifen-Inhalt (`{{loop:…}}` oder ein Verweis auf eine `loop_*`-Option), lehnt das Werkzeug den ganzen Aufruf ab und nennt den Weg („zuerst das Element mit Schleife einfügen, dann die Schleifen-Inhalte mit lit-divi-insert (parent = Pfad-ID des Elements) ergänzen“). Das schließt auch die Sicherheitslücke, dass eine neue Term-Schleife in einer Post-Schleife `loop_post_*`-Inhalte gegen den äußeren Kontext bestehen ließe (Divi läse dann Post-Meta mit der Term-ID). Grobkörnig: auch Schleifen-Inhalte an anderer Stelle desselben Aufrufs fallen darunter (dokumentiert, Guide in Task 7).
|
||
- **Update:** pro Änderung der Kontext des bestehenden Knotens. Zwei Durchgänge in `DiviUpdateAbility::composeInLoops()`: (1) `Composer::update()` je Änderung im Kontext der gespeicherten Vorfahren; (2) mit den neuen Attributen des Aufrufs (Knoten selbst **und** geänderte Vorfahren) wird der Kontext neu bestimmt, und nur Änderungen, deren Kontext sich dadurch verschiebt, werden im neuen Kontext erneut komponiert. Loop-Attribute selbst enthalten keinen Dynamic Content, also sieht Durchgang 2 dieselben Schleifen. **Ein Composer je Kontext** (Cache nach `queryType`, `''` = keine Schleife) – ohne Schleifen bleibt es bei genau einem Composer wie bisher.
|
||
- **Move:** ändert keine Werte, prüft also nichts; Kontext alt = Vorfahren des Knotens, Kontext neu = Ziel-Elternknoten, beide aus dem Baum vor dem Verschieben (`TreeEditor::move()` garantiert, dass das Ziel nicht im Knoten liegt). Enthält der verschobene Teilbaum `loop_*`-Verweise (über `VariableRef::nameOf()`, Muster `ThemeBuilderEdits::collectKeys()`, ohne opake Blöcke und ohne Teilbäume mit eigener Schleife) und unterscheidet sich die Art (`kind()`) von alt und neu, kommt eine **Warnung** über `DiviChange::warning()` (A11: Divi lässt das zu). Fälle: raus aus jeder Schleife („Divi zeigt sie dort leer“) oder in eine Schleife anderer Art bzw. aus keiner in eine („Typ: Terme (vorher: Beiträge|keine Schleife)“). Ein Knoten mit eigener Schleife nimmt sie mit – keine Warnung.
|
||
- **`lit-content-create` und Theme-Builder-Anlage** (`LayoutBuilder` über `DiviContentComposer`/`TbCreateTemplateAbility` mit `forPost(…)`) haben nie einen Schleifen-Kontext: keine Code-Änderung, `loop_*` wird mit der X5-Meldung „braucht eine Divi-Schleife …“ abgelehnt (Test in 4e; auch neue Loop-Container mit Inhalt in einem Aufruf – fail closed wie Insert).
|
||
- **`lit-divi-node-copy`, `lit/library-insert`, StoredNodeGuard:** keine Änderung (X7 c). `storedGuard()` kommt aus der Komposition ohne `inLoop()` → kopierte `loop_*`-Verweise bleiben für Benutzer ohne `unfiltered_html` abgelehnt (jetzt mit der X5-Meldung); Benutzer mit `unfiltered_html` kopieren wie bisher ungeprüft (`StoredNodeGuard::problems()` gibt `[]`), auch in Schleifen anderer Art – Restrisiko für Task 8.
|
||
- **Update ohne Warnung:** schaltet ein Aufruf über `attrs` (`module.advanced.loop`, X7 a) die Schleife eines Containers um, dessen *unveränderte* Kinder `loop_*` zeigen, prüft das niemand (wie heute). Restrisiko für Task 8.
|
||
|
||
**Files:**
|
||
- Modify: `src/Integrations/Divi/Pages/DiviComposition.php` (4a)
|
||
- Modify: `src/Integrations/Divi/Pages/DiviWriteAbility.php`, `src/Integrations/Divi/Pages/DiviInsertAbility.php` (4b)
|
||
- Modify: `src/Integrations/Divi/Pages/DiviUpdateAbility.php` (4c)
|
||
- Modify: `src/Integrations/Divi/Pages/DiviMoveAbility.php` (4d)
|
||
- Modify: `tests/Support/DiviWriterFixture.php` (4b: `writer()` bekommt die Registry)
|
||
- Create: `tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php` (4b, erweitert in 4c/4d)
|
||
- Modify: `tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php` (4a), `tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php` (4e)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `LoopScope::{none, ofQueryType, active, kind, queryType, label}`, `LoopContext::{enabled, fromChain}` (Task 1); `DynamicContentMap::loopFields(string $kind, int $postId = 0)` (Task 2, siehe ABWEICHUNGEN A1); `VariableRef::__construct(DesignSystem, ?array, ?LoopScope)` und die X5-Meldungen (Task 2); `Placeholders::__construct(…, array $loopFields)` (Task 3).
|
||
- Produces:
|
||
- `DiviComposition::inLoop(LoopScope $scope): static` (Vertrag: `self`; `static` wie `forPost()`, ABWEICHUNGEN A3).
|
||
- `DiviWriteAbility::loopAt(NodeIndex $index, ?Block $block, array $newAttrs = []): LoopScope` (`protected static`).
|
||
- `DiviWriterFixture::writer(…, ?SimilarSections $similar = null, ?DynamicContentMap $registry = null)` (Testhilfe).
|
||
- Neue Fehlermeldung (Insert): „Schleifen-Inhalte unter einer neuen Divi-Schleife gehen nicht in einem Aufruf; nichts wurde gespeichert.“ (Feld `nodes`); neue Warnung (Move): „Schleifen-Inhalte (loop_…) liegen jetzt außerhalb jeder Divi-Schleife; Divi zeigt sie dort leer. Zurückverschieben oder die Inhalte anpassen.“ bzw. „… liegen jetzt in einer Divi-Schleife vom Typ: Terme (vorher: Beiträge); passen sie nicht zu dieser Art, zeigt Divi sie leer oder mit Daten eines anderen Objekts mit derselben ID. …“.
|
||
|
||
**Testseite** (in `DiviLoopWriteTest`, kein Fixture-File – die echte Loop-Fixture bringt Task 6): `s0.r0.c0` Post-Schleife (`m0` zeigt `loop_post_title`, `m1` fester Text, `m2` mit eigener Term-Schleife und `loop_term_name`), `s0.r0.c1` Term-Schleife, `s0.r0.c2` ohne Schleife. Die Registry ist `DiviFixtures::dynamicContentRegistry()` plus `loop_post_date` (Gruppe `Loop`) und `loop_term_name` (Gruppe `Loop Terms`) in Divi-5.15.1-Form (Union `+`: Einträge, die Task 2 in `DiviFixtures` ergänzt, gewinnen).
|
||
|
||
#### Task 4a: `DiviComposition::inLoop()`
|
||
|
||
- [ ] **Step 4a.1: Failing Test in `DiviCompositionTest` ergänzen**
|
||
|
||
```diff
|
||
diff --git a/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php b/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php
|
||
--- a/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php
|
||
+++ b/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php
|
||
@@ -9,6 +9,7 @@ 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\NodeSummary;
|
||
+use LIT\MCP\Integrations\Divi\Compose\LoopScope;
|
||
use LIT\MCP\Integrations\Divi\Compose\StoredNodeGuard;
|
||
use LIT\MCP\Integrations\Divi\Compose\TreeEditor;
|
||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||
@@ -265,4 +266,36 @@ final class DiviCompositionTest extends TestCase
|
||
self::assertCount(1, $covering);
|
||
self::assertStringContainsString('„CTA primär“', $covering[0]);
|
||
}
|
||
+
|
||
+ /**
|
||
+ * Ruling X3: inLoop() gives a fresh composition bound to a loop scope – placeholders and references of the
|
||
+ * entry's map plus the loop options of that kind; outside a loop and in another kind loop content fails.
|
||
+ */
|
||
+ public function testInLoopChecksLoopContentAgainstItsScope(): void
|
||
+ {
|
||
+ $post = $this->composition()->forPost(7);
|
||
+ $inPostLoop = $post->inLoop(LoopScope::ofQueryType('post_types'));
|
||
+ $compose = static fn (DiviComposition $composition): array => $composition->composer()->compose(
|
||
+ [['type' => 'text', 'content' => '{{loop:post_title}}']],
|
||
+ Block::create('divi/column'),
|
||
+ 'nodes'
|
||
+ );
|
||
+
|
||
+ $inside = $compose($inPostLoop);
|
||
+ self::assertSame([], $inside['errors']);
|
||
+ self::assertSame(
|
||
+ VariableRef::serialize('content', 'loop_post_title', ['before' => '', 'after' => '']),
|
||
+ AttrMap::desktop($inside['blocks'][0]->attrs(), 'content.innerContent')
|
||
+ );
|
||
+
|
||
+ self::assertStringContainsString('braucht eine Divi-Schleife', implode(' ', $compose($post)['errors']));
|
||
+ self::assertStringContainsString(
|
||
+ 'passt nicht zur Schleife dieses Elements (Typ: Terme)',
|
||
+ implode(' ', $compose($post->inLoop(LoopScope::ofQueryType('post_taxonomies')))['errors'])
|
||
+ );
|
||
+
|
||
+ self::assertNotSame($post->references(), $inPostLoop->references());
|
||
+ self::assertNotSame($post->placeholders(), $inPostLoop->placeholders());
|
||
+ self::assertSame($inPostLoop->references(), $inPostLoop->references(), 'one VariableRef per scope');
|
||
+ }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4a.2: RED**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php`
|
||
Expected: FAIL – `Tests: 12, Assertions: 43, Errors: 1` mit `Error: Call to undefined method LIT\MCP\Integrations\Divi\Pages\DiviComposition::inLoop()`.
|
||
|
||
- [ ] **Step 4a.3: `DiviComposition` erweitern**
|
||
|
||
```diff
|
||
diff --git a/src/Integrations/Divi/Pages/DiviComposition.php b/src/Integrations/Divi/Pages/DiviComposition.php
|
||
--- a/src/Integrations/Divi/Pages/DiviComposition.php
|
||
+++ b/src/Integrations/Divi/Pages/DiviComposition.php
|
||
@@ -7,6 +7,7 @@ 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\LinkTargets;
|
||
+use LIT\MCP\Integrations\Divi\Compose\LoopScope;
|
||
use LIT\MCP\Integrations\Divi\Compose\MediaResolver;
|
||
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
||
use LIT\MCP\Integrations\Divi\Compose\ShortformWriter;
|
||
@@ -27,10 +28,11 @@ defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Wires the pure Compose classes with the live catalog, design system, Divi's dynamic content
|
||
- * registry and the WordPress sanitizer/media lookup – once per request and entry. Ruling S31:
|
||
- * ONE VariableRef (carrying the live dynamic content map of the entry, S22), ONE Placeholders
|
||
- * and ONE ValueCleaner instance are shared by AttrWriter, ShortformWriter and Composer, and the
|
||
- * same AttrWriter serves Composer and the abilities.
|
||
+ * registry and the WordPress sanitizer/media lookup – once per request, entry and loop scope. Ruling S31
|
||
+ * (as reworded by X3): ONE VariableRef per composition (carrying the live dynamic content map of the entry, S22,
|
||
+ * plus the loop options of its LoopScope), ONE Placeholders and ONE ValueCleaner instance are shared by
|
||
+ * AttrWriter, ShortformWriter and Composer, and the same AttrWriter serves Composer and the abilities. A tool whose
|
||
+ * nodes sit in several loop scopes uses one composition – and so one Composer – per scope (inLoop()).
|
||
*
|
||
* Presets (D7) reach blocks through ONE PresetAssignment per composition: its book is Divi's stored preset
|
||
* inventory (DesignSystemWriter::presets()), read lazily and at most once – only when a write names a preset or a
|
||
@@ -38,8 +40,9 @@ defined('ABSPATH') || exit;
|
||
* Without a preset source (null, unit tests) there are no presets.
|
||
*
|
||
* forPost() binds the composition to the entry being written (DiviWriter::composition() does it
|
||
- * for the abilities); unbound, VariableRef's default map applies. Not final so abilities can be
|
||
- * tested with a mock.
|
||
+ * for the abilities); unbound, VariableRef's default map applies. inLoop() binds it to the loop scope of the
|
||
+ * nodes it composes (LoopContext); without it the scope is none and every loop_* option is refused. Not final so
|
||
+ * abilities can be tested with a mock.
|
||
*/
|
||
class DiviComposition
|
||
{
|
||
@@ -51,6 +54,10 @@ class DiviComposition
|
||
private ?AttrWriter $attrWriter = null;
|
||
private ?Composer $composer = null;
|
||
private ?PresetAssignment $assignment = null;
|
||
+ private LoopScope $loop;
|
||
+
|
||
+ /** @var array<string, array<string, string>>|null Loop options of $loop for the entry (read once). */
|
||
+ private ?array $loopFields = null;
|
||
|
||
public function __construct(
|
||
private DiviModules $modules,
|
||
@@ -61,35 +68,40 @@ class DiviComposition
|
||
private LinkTargets $links = new WpLinkTargets(),
|
||
private ?DesignSystemWriter $presetSource = null
|
||
) {
|
||
+ $this->loop = LoopScope::none();
|
||
}
|
||
|
||
/** A fresh composition for entry $postId (own VariableRef with its live map; TB area for StructureRules). */
|
||
public function forPost(int $postId, ?string $themeBuilderArea = null): static
|
||
{
|
||
- $bound = clone $this;
|
||
+ $bound = $this->fresh();
|
||
$bound->postId = $postId;
|
||
$bound->themeBuilderArea = $themeBuilderArea;
|
||
- $bound->references = null;
|
||
- $bound->placeholders = null;
|
||
- $bound->cleaner = null;
|
||
- $bound->attrWriter = null;
|
||
- $bound->composer = null;
|
||
- $bound->assignment = null;
|
||
|
||
return $bound;
|
||
}
|
||
|
||
/**
|
||
- * The one checker of $variable(…)$ values of this composition, with the entry's live map (S22, S31).
|
||
+ * A fresh composition of the same entry for nodes inside $scope (ruling X3): its VariableRef and Placeholders
|
||
+ * know the loop options of that kind; the abilities take $scope from LoopContext.
|
||
+ */
|
||
+ public function inLoop(LoopScope $scope): static
|
||
+ {
|
||
+ $bound = $this->fresh();
|
||
+ $bound->loop = $scope;
|
||
+
|
||
+ return $bound;
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * The one checker of $variable(…)$ values of this composition: the entry's live map (S22, S31) plus the loop
|
||
+ * options of its scope, and the scope itself for the loop messages (X5).
|
||
*
|
||
* @internal Public for tests; code uses cleaner() / composer().
|
||
*/
|
||
public function references(): VariableRef
|
||
{
|
||
- return $this->references ??= new VariableRef(
|
||
- $this->design->read(),
|
||
- $this->postId === null ? null : $this->dynamicContent->forPost($this->postId)
|
||
- );
|
||
+ return $this->references ??= new VariableRef($this->design->read(), $this->dynamicContentMap(), $this->loop);
|
||
}
|
||
|
||
/**
|
||
@@ -99,7 +111,7 @@ class DiviComposition
|
||
*/
|
||
public function placeholders(): Placeholders
|
||
{
|
||
- return $this->placeholders ??= new Placeholders($this->design->read(), $this->links);
|
||
+ return $this->placeholders ??= new Placeholders($this->design->read(), $this->links, $this->loopFields());
|
||
}
|
||
|
||
/** The one choke point of this composition (ValueCleaner, rulings S1–S3, S7, S13, S28, S32). */
|
||
@@ -161,4 +173,37 @@ class DiviComposition
|
||
{
|
||
return new Warnings($this->design->read(), $this->presets());
|
||
}
|
||
+
|
||
+ /** @return array<string, array<string, string>>|null The entry's live map with the scope's loop options. */
|
||
+ private function dynamicContentMap(): ?array
|
||
+ {
|
||
+ $map = $this->postId === null ? null : $this->dynamicContent->forPost($this->postId);
|
||
+
|
||
+ return $map === null ? null : $map + $this->loopFields();
|
||
+ }
|
||
+
|
||
+ /** @return array<string, array<string, string>> The loop options of this scope for the entry ([] without). */
|
||
+ private function loopFields(): array
|
||
+ {
|
||
+ $kind = $this->loop->kind();
|
||
+
|
||
+ return $this->loopFields ??= $this->postId === null || $kind === null
|
||
+ ? []
|
||
+ : $this->dynamicContent->loopFields($kind, $this->postId);
|
||
+ }
|
||
+
|
||
+ /** A clone without any cached instance (they hang on the entry and the loop scope). */
|
||
+ private function fresh(): static
|
||
+ {
|
||
+ $bound = clone $this;
|
||
+ $bound->references = null;
|
||
+ $bound->placeholders = null;
|
||
+ $bound->cleaner = null;
|
||
+ $bound->attrWriter = null;
|
||
+ $bound->composer = null;
|
||
+ $bound->assignment = null;
|
||
+ $bound->loopFields = null;
|
||
+
|
||
+ return $bound;
|
||
+ }
|
||
}
|
||
```
|
||
|
||
`dynamicContentMap()` liest die Registry nur beim ersten `references()` (der Test `testOneLiveMapIsSharedByTheFullAndTheShortForm` – „the registry is read once per post“ – bleibt grün); eine Komposition mit Schleife fragt die Registry zweimal (`forPost()` und `loopFields()`), siehe ABWEICHUNGEN A6.
|
||
|
||
- [ ] **Step 4a.4: GREEN und Lint**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php && composer lint`
|
||
Expected: `OK (12 tests, 50 assertions)`; phpcs ohne Meldung.
|
||
|
||
- [ ] **Step 4a.5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Pages/DiviComposition.php tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php
|
||
git commit -m "feat: DiviComposition::inLoop() – VariableRef und Placeholders mit Schleifen-Kontext; S31 je Kontext (X3)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### Task 4b: Insert im Kontext des Ziel-Elternknotens, fail closed bei neuer Schleife
|
||
|
||
- [ ] **Step 4b.1: `DiviWriterFixture::writer()` um die Registry erweitern**
|
||
|
||
```diff
|
||
diff --git a/tests/Support/DiviWriterFixture.php b/tests/Support/DiviWriterFixture.php
|
||
--- a/tests/Support/DiviWriterFixture.php
|
||
+++ b/tests/Support/DiviWriterFixture.php
|
||
@@ -92,7 +92,8 @@ final class DiviWriterFixture
|
||
}
|
||
|
||
/**
|
||
- * The writer over the page of DiviFixtures::givenPost(), saving into $saved (see saver()).
|
||
+ * The writer over the page of DiviFixtures::givenPost(), saving into $saved (see saver()); $registry is the
|
||
+ * composition's dynamic content map (see composition()).
|
||
*
|
||
* @param list<string> $saved
|
||
* @param list<Throwable> $failures
|
||
@@ -104,7 +105,8 @@ final class DiviWriterFixture
|
||
?DiviPageLoader $loader = null,
|
||
?GlobalEdits $globals = null,
|
||
?ThemeBuilderEdits $themeBuilder = null,
|
||
- ?SimilarSections $similar = null
|
||
+ ?SimilarSections $similar = null,
|
||
+ ?DynamicContentMap $registry = null
|
||
): DiviWriter {
|
||
$design = self::designReader();
|
||
|
||
@@ -113,7 +115,7 @@ final class DiviWriterFixture
|
||
self::saver($saved, $failures),
|
||
self::log(),
|
||
$design,
|
||
- self::composition($design, $unfiltered),
|
||
+ self::composition($design, $unfiltered, $registry),
|
||
$globals,
|
||
$themeBuilder,
|
||
$similar
|
||
```
|
||
|
||
- [ ] **Step 4b.2: Failing Test `DiviLoopWriteTest` anlegen (Insert-Teil)**
|
||
|
||
Datei `tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php` anlegen (die Helfer `changeAll()` und `moveNode()` nutzen 4c/4d):
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||
|
||
use LIT\MCP\Abilities\Rev;
|
||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||
use WP_Error;
|
||
|
||
/**
|
||
* Ruling X3: lit/divi-insert, lit/divi-update and lit/divi-move use the loop scope of the node in the tree
|
||
* (LoopContext). The page: s0.r0.c0 runs a post loop (m0 shows loop_post_title, m1 is plain text, m2 runs its own
|
||
* term loop and shows loop_term_name), s0.r0.c1 a term loop, s0.r0.c2 no loop.
|
||
*/
|
||
final class DiviLoopWriteTest extends DiviWriteAbilityTestCase
|
||
{
|
||
private const POST_LOOP = ['enable' => 'on', 'queryType' => 'post_types'];
|
||
private const TERM_LOOP = ['enable' => 'on', 'queryType' => 'post_taxonomies'];
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
$this->original = self::page();
|
||
$this->post->post_content = $this->original;
|
||
$registry = self::registry();
|
||
$this->writer = DiviWriterFixture::writer(
|
||
$this->saved,
|
||
registry: new DynamicContentMap(static fn (int $postId): array => $registry)
|
||
);
|
||
}
|
||
|
||
/** DiviFixtures' registry plus the loop options these tests name (Divi 5.15.1 groups and fields). */
|
||
private static function registry(): array
|
||
{
|
||
$field = static fn (string $type): array => ['label' => 'x', 'type' => $type, 'default' => ''];
|
||
$common = ['before' => $field('text'), 'after' => $field('text'), 'loop_position' => $field('text')];
|
||
$option = static fn (string $id, string $group, array $fields): array => [
|
||
'id' => $id, 'label' => $id, 'type' => 'text', 'custom' => false, 'group' => $group, 'fields' => $fields,
|
||
];
|
||
|
||
return DiviFixtures::dynamicContentRegistry() + [
|
||
'loop_post_date' => $option('loop_post_date', 'Loop', $common + [
|
||
'date_format' => $field('select'), 'custom_date_format' => $field('text'),
|
||
]),
|
||
'loop_term_name' => $option('loop_term_name', 'Loop Terms', $common),
|
||
];
|
||
}
|
||
|
||
/** @param array<string, mixed> $attrs */
|
||
private static function block(string $name, array $attrs, string $inner = ''): string
|
||
{
|
||
$json = (string) json_encode($attrs + ['builderVersion' => '5.2.1'], JSON_UNESCAPED_UNICODE);
|
||
|
||
return sprintf('<!-- wp:divi/%s %s -->%s<!-- /wp:divi/%s -->', $name, $json, $inner, $name);
|
||
}
|
||
|
||
/** @param array<string, string> $loop */
|
||
private static function loop(array $loop): array
|
||
{
|
||
return ['module' => ['advanced' => ['loop' => ['desktop' => ['value' => $loop]]]]];
|
||
}
|
||
|
||
private static function text(string $value): string
|
||
{
|
||
return self::block('text', ['content' => ['innerContent' => ['desktop' => ['value' => $value]]]]);
|
||
}
|
||
|
||
private static function page(): string
|
||
{
|
||
$title = VariableRef::serialize('content', 'loop_post_title', ['before' => '', 'after' => '']);
|
||
$term = VariableRef::serialize('content', 'loop_term_name', ['before' => '', 'after' => '']);
|
||
$own = self::block('text', self::loop(self::TERM_LOOP) + [
|
||
'content' => ['innerContent' => ['desktop' => ['value' => $term]]],
|
||
]);
|
||
|
||
return '<!-- wp:divi/placeholder -->' . self::block('section', [], self::block('row', [], implode('', [
|
||
self::block('column', self::loop(self::POST_LOOP), self::text($title) . self::text('<p>Fest</p>') . $own),
|
||
self::block('column', self::loop(self::TERM_LOOP), self::text('<p>Term</p>')),
|
||
self::block('column', [], self::text('<p>Ohne</p>')),
|
||
]))) . '<!-- /wp:divi/placeholder -->';
|
||
}
|
||
|
||
/** @param array<string, mixed> $input */
|
||
private function insertInto(string $parent, array $input): mixed
|
||
{
|
||
return $this->insert()->execute(
|
||
['post_id' => 7, 'tree_rev' => $this->currentRev(), 'parent' => $parent] + $input
|
||
);
|
||
}
|
||
|
||
/** @param list<array<string, mixed>> $changes */
|
||
private function changeAll(array $changes): mixed
|
||
{
|
||
return $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->currentRev(), 'changes' => $changes]);
|
||
}
|
||
|
||
/** @param array<string, mixed> $input */
|
||
private function moveNode(array $input): mixed
|
||
{
|
||
return $this->move()->execute(['post_id' => 7, 'tree_rev' => $this->currentRev()] + $input);
|
||
}
|
||
|
||
/** tree_rev of what the page holds now (the saver puts every saved content on the post). */
|
||
private function currentRev(): string
|
||
{
|
||
return Rev::of((string) $this->post->post_content);
|
||
}
|
||
|
||
private static function problems(mixed $error): string
|
||
{
|
||
self::assertInstanceOf(WP_Error::class, $error);
|
||
|
||
return implode(' | ', self::errorFields($error));
|
||
}
|
||
|
||
public function testInsertUsesTheLoopOfTheTargetParent(): void
|
||
{
|
||
$result = $this->insertInto('s0.r0.c0', ['nodes' => [
|
||
['type' => 'text', 'content' => '{{loop:post_date}}'],
|
||
['type' => 'text', 'content' => '{{loop:post_title}}'],
|
||
]]);
|
||
|
||
self::assertTrue($result['ok'], (string) json_encode($result));
|
||
$date = VariableRef::serialize('content', 'loop_post_date', ['before' => '', 'after' => '']);
|
||
self::assertStringContainsString(str_replace('"', '\u0022', $date), $this->saved[0]);
|
||
|
||
$term = $this->insertInto('s0.r0.c1', ['nodes' => [['type' => 'text', 'content' => '{{loop:term_name}}']]]);
|
||
self::assertTrue($term['ok'], (string) json_encode($term));
|
||
}
|
||
|
||
public function testInsertRefusesLoopContentOutsideALoopOrOfAnotherKind(): void
|
||
{
|
||
$outside = $this->insertInto('s0.r0.c2', ['nodes' => [['type' => 'text', 'content' => '{{loop:post_date}}']]]);
|
||
self::assertStringContainsString('braucht eine Divi-Schleife', self::problems($outside));
|
||
|
||
$term = $this->insertInto('s0.r0.c1', ['nodes' => [['type' => 'text', 'content' => '{{loop:post_date}}']]]);
|
||
$wrongKind = self::problems($term);
|
||
self::assertStringContainsString('passt nicht zur Schleife dieses Elements (Typ: Terme)', $wrongKind);
|
||
|
||
$topLevel = $this->insert()->execute([
|
||
'post_id' => 7,
|
||
'tree_rev' => $this->currentRev(),
|
||
'nodes' => [['type' => 'section', 'modules' => [['type' => 'text', 'content' => '{{loop:post_date}}']]]],
|
||
]);
|
||
self::assertStringContainsString('braucht eine Divi-Schleife', self::problems($topLevel));
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
/**
|
||
* Ruling X3, fail closed: a loop a new node of this call switches on counts only once it is saved – loop
|
||
* content in the same call is refused even where the parent's loop would let it pass (a term loop inside the
|
||
* post loop c0 would otherwise get post content: Divi reads the post with the term's ID).
|
||
*/
|
||
public function testANewLoopInTheSameCallIsRefusedWithLoopContent(): void
|
||
{
|
||
$ownLoop = ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]];
|
||
|
||
foreach (['s0.r0.c0' => '{{loop:post_title}}', 's0.r0.c2' => '{{loop:term_name}}'] as $parent => $content) {
|
||
$refused = $this->insertInto($parent, ['nodes' => [
|
||
['type' => 'text', 'attrs' => $ownLoop, 'content' => $content],
|
||
]]);
|
||
self::assertStringContainsString('erst nach dem Speichern', self::problems($refused), $parent);
|
||
}
|
||
self::assertSame([], $this->saved);
|
||
|
||
$withoutLoopContent = $this->insertInto('s0.r0.c2', ['nodes' => [
|
||
['type' => 'text', 'attrs' => $ownLoop, 'content' => '<p>Liste</p>'],
|
||
]]);
|
||
self::assertTrue($withoutLoopContent['ok'], (string) json_encode($withoutLoopContent));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4b.3: RED**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php`
|
||
Expected: FAIL – `Tests: 3, Assertions: 15, Errors: 1, Failures: 2`: `testInsertUsesTheLoopOfTheTargetParent` bekommt ein `WP_Error` statt `ok` („Cannot use object of type WP_Error as array“, die Komposition hat keinen Kontext), `testInsertRefusesLoopContentOutsideALoopOrOfAnotherKind` findet „braucht eine Divi-Schleife …“ statt „passt nicht zur Schleife dieses Elements (Typ: Terme)“, `testANewLoopInTheSameCallIsRefusedWithLoopContent` findet „braucht eine Divi-Schleife …“ statt „erst nach dem Speichern“.
|
||
|
||
- [ ] **Step 4b.4: `DiviWriteAbility::loopAt()` ergänzen**
|
||
|
||
```diff
|
||
diff --git a/src/Integrations/Divi/Pages/DiviWriteAbility.php b/src/Integrations/Divi/Pages/DiviWriteAbility.php
|
||
--- a/src/Integrations/Divi/Pages/DiviWriteAbility.php
|
||
+++ b/src/Integrations/Divi/Pages/DiviWriteAbility.php
|
||
@@ -6,6 +6,10 @@ namespace LIT\MCP\Integrations\Divi\Pages;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\ToolResult;
|
||
+use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||
+use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||
+use LIT\MCP\Integrations\Divi\Compose\LoopContext;
|
||
+use LIT\MCP\Integrations\Divi\Compose\LoopScope;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Integrations\Divi\Release\FeatureSets;
|
||
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
|
||
@@ -114,4 +118,20 @@ abstract class DiviWriteAbility implements Ability, RequiresDiviFeatures
|
||
{
|
||
return null;
|
||
}
|
||
+
|
||
+ /**
|
||
+ * Ruling X3: the loop scope at $block – its nearest loop-enabled ancestor-or-self (LoopContext, X1); none at
|
||
+ * the top level. $newAttrs (node ID => attrs) replaces the stored attrs of nodes this call changes.
|
||
+ *
|
||
+ * @param array<string, array<string, mixed>> $newAttrs
|
||
+ */
|
||
+ protected static function loopAt(NodeIndex $index, ?Block $block, array $newAttrs = []): LoopScope
|
||
+ {
|
||
+ $chain = [];
|
||
+ for ($node = $block; $node !== null; $node = $index->parentBlock($node)) {
|
||
+ array_unshift($chain, $newAttrs[(string) $index->idOf($node)] ?? $node->attrs());
|
||
+ }
|
||
+
|
||
+ return LoopContext::fromChain($chain);
|
||
+ }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4b.5: `DiviInsertAbility` im Kontext komponieren lassen**
|
||
|
||
```diff
|
||
diff --git a/src/Integrations/Divi/Pages/DiviInsertAbility.php b/src/Integrations/Divi/Pages/DiviInsertAbility.php
|
||
--- a/src/Integrations/Divi/Pages/DiviInsertAbility.php
|
||
+++ b/src/Integrations/Divi/Pages/DiviInsertAbility.php
|
||
@@ -6,6 +6,9 @@ namespace LIT\MCP\Integrations\Divi\Pages;
|
||
|
||
use LIT\MCP\Abilities\Annotations;
|
||
use LIT\MCP\Abilities\Errors;
|
||
+use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||
+use LIT\MCP\Integrations\Divi\Compose\LoopContext;
|
||
+use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use WP_Error;
|
||
|
||
@@ -17,6 +20,9 @@ defined('ABSPATH') || exit;
|
||
* validated first and every error is returned together; the blocks come only from Composer
|
||
* (client values pass its choke point) and enter the tree only through TreeEditor::insert(),
|
||
* which also wraps the first section of an empty page in divi/placeholder (ruling S9).
|
||
+ *
|
||
+ * Loop content (ruling X3): all new nodes share the loop scope of the target parent. A loop a new node of this
|
||
+ * call switches on counts only once it is saved – with loop content in the same call the insert fails closed.
|
||
*/
|
||
final class DiviInsertAbility extends DiviWriteAbility
|
||
{
|
||
@@ -97,7 +103,18 @@ final class DiviInsertAbility extends DiviWriteAbility
|
||
]);
|
||
}
|
||
|
||
- $composed = $composition->composer()->compose($nodes, $position['parent'], 'nodes', $context);
|
||
+ $scope = self::loopAt($page->index, $position['parent']);
|
||
+ $composed = $composition->inLoop($scope)->composer()->compose($nodes, $position['parent'], 'nodes', $context);
|
||
+ if (self::startsLoop($composed['blocks']) && self::hasLoopContent($nodes)) {
|
||
+ return Errors::invalidInput(
|
||
+ 'Schleifen-Inhalte unter einer neuen Divi-Schleife gehen nicht in einem Aufruf; nichts wurde '
|
||
+ . 'gespeichert.',
|
||
+ ['nodes' => 'Die Schleife eines neuen Elements gilt erst nach dem Speichern: zuerst das Element mit '
|
||
+ . 'Schleife einfügen, dann die Schleifen-Inhalte mit lit-divi-insert (parent = Pfad-ID des '
|
||
+ . 'Elements) ergänzen']
|
||
+ );
|
||
+ }
|
||
+
|
||
if ($composed['errors'] !== []) {
|
||
return DiviErrors::composed($composed, 'Knoten sind ungültig; nichts wurde gespeichert.');
|
||
}
|
||
@@ -117,4 +134,38 @@ final class DiviInsertAbility extends DiviWriteAbility
|
||
|
||
return $change;
|
||
}
|
||
+
|
||
+ /**
|
||
+ * Whether one of $blocks or a descendant switches a loop on.
|
||
+ *
|
||
+ * @param list<Block> $blocks
|
||
+ */
|
||
+ private static function startsLoop(array $blocks): bool
|
||
+ {
|
||
+ foreach ($blocks as $block) {
|
||
+ if (LoopContext::enabled($block->attrs()) || self::startsLoop($block->children())) {
|
||
+ return true;
|
||
+ }
|
||
+ }
|
||
+
|
||
+ return false;
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Whether the input names loop content: a {{loop:…}} placeholder or a reference to a loop_* option.
|
||
+ *
|
||
+ * @param array<array-key, mixed> $nodes
|
||
+ */
|
||
+ private static function hasLoopContent(array $nodes): bool
|
||
+ {
|
||
+ $found = false;
|
||
+ array_walk_recursive($nodes, static function (mixed $value) use (&$found): void {
|
||
+ $found = $found || (is_string($value) && (
|
||
+ preg_match('/^\s*\{\{\s*loop\s*:/', $value) === 1
|
||
+ || str_starts_with((string) VariableRef::nameOf($value), 'loop_')
|
||
+ ));
|
||
+ });
|
||
+
|
||
+ return $found;
|
||
+ }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4b.6: GREEN, Insert-Suite, Lint**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php`
|
||
Expected: `OK (3 tests, 29 assertions)`.
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages && composer lint`
|
||
Expected: grün (die bestehenden Insert-Tests, auch die Byte-Identität in `DiviInsertAbilityTest`, bleiben unverändert); phpcs ohne Meldung.
|
||
|
||
- [ ] **Step 4b.7: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Pages/DiviWriteAbility.php src/Integrations/Divi/Pages/DiviInsertAbility.php \
|
||
tests/Support/DiviWriterFixture.php tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php
|
||
git commit -m "feat: lit-divi-insert prüft Schleifen-Inhalte im Kontext des Ziels; neue Schleife plus Inhalt in einem Aufruf fail closed (X3)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### Task 4c: Update – ein Composer je Kontext, neue Loop-Attribute desselben Aufrufs
|
||
|
||
- [ ] **Step 4c.1: Failing Tests an `DiviLoopWriteTest` anhängen**
|
||
|
||
Vor der schließenden `}` der Klasse einfügen:
|
||
|
||
```php
|
||
|
||
/** Ruling X3: one Composer per loop scope – changes in a post loop and a term loop in one call. */
|
||
public function testUpdateChecksEveryChangeInItsOwnLoop(): void
|
||
{
|
||
$result = $this->changeAll([
|
||
['id' => 's0.r0.c0.m1', 'set' => ['content' => '{{loop:post_date}}']],
|
||
['id' => 's0.r0.c1.m0', 'set' => ['content' => '{{loop:term_name}}']],
|
||
['id' => 's0.r0.c2.m0', 'set' => ['content' => '<p>Neu</p>']],
|
||
]);
|
||
|
||
self::assertTrue($result['ok'], (string) json_encode($result));
|
||
self::assertStringContainsString('loop_term_name', $this->saved[0]);
|
||
|
||
$refused = $this->changeAll([
|
||
['id' => 's0.r0.c0.m1', 'set' => ['content' => '{{loop:term_name}}']],
|
||
['id' => 's0.r0.c2.m0', 'set' => ['content' => '{{loop:post_date}}']],
|
||
]);
|
||
$problems = self::errorFields($refused);
|
||
self::assertStringContainsString('Typ: Beiträge', $problems['changes[0].set.content'] ?? '');
|
||
self::assertStringContainsString('braucht eine Divi-Schleife', $problems['changes[1].set.content'] ?? '');
|
||
}
|
||
|
||
/**
|
||
* Ruling X3: the scope of a change takes the loop attrs this call gives the node or an ancestor – a loop
|
||
* switched on admits its content, a loop switched to another kind refuses the old kind's content.
|
||
*/
|
||
public function testUpdateUsesTheLoopsThisCallSets(): void
|
||
{
|
||
$switchedOn = $this->changeAll([
|
||
['id' => 's0.r0.c2', 'attrs' => ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]]],
|
||
['id' => 's0.r0.c2.m0', 'set' => ['content' => '{{loop:term_name}}']],
|
||
]);
|
||
self::assertTrue($switchedOn['ok'], (string) json_encode($switchedOn));
|
||
|
||
$own = $this->changeAll([[
|
||
'id' => 's0.r0.c0.m1',
|
||
'attrs' => ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]],
|
||
'set' => ['content' => '{{loop:term_name}}'],
|
||
]]);
|
||
self::assertTrue($own['ok'], (string) json_encode($own));
|
||
|
||
$switchedKind = $this->changeAll([
|
||
['id' => 's0.r0.c0', 'attrs' => ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]]],
|
||
['id' => 's0.r0.c0.m0', 'set' => ['content' => '{{loop:post_date}}']],
|
||
]);
|
||
$problems = self::errorFields($switchedKind);
|
||
self::assertStringContainsString('Typ: Terme', $problems['changes[1].set.content'] ?? '');
|
||
self::assertCount(2, $this->saved);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4c.2: RED**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php`
|
||
Expected: FAIL – `Tests: 5, Assertions: 31, Errors: 2`: beide Update-Tests bekommen ein `WP_Error` („Cannot use object of type WP_Error as array“), weil der eine Composer ohne Kontext jeden `{{loop:…}}`-Wert ablehnt.
|
||
|
||
- [ ] **Step 4c.3: `DiviUpdateAbility` umbauen**
|
||
|
||
Vorher (Zeile 126 ff.) ein `$composer = $composition->composer();` für alle Änderungen und `update()` im Validierungs-Loop; nachher sammelt der Loop nur `[$block, $change, $field]` und `composeInLoops()` komponiert je Kontext:
|
||
|
||
```diff
|
||
diff --git a/src/Integrations/Divi/Pages/DiviUpdateAbility.php b/src/Integrations/Divi/Pages/DiviUpdateAbility.php
|
||
--- a/src/Integrations/Divi/Pages/DiviUpdateAbility.php
|
||
+++ b/src/Integrations/Divi/Pages/DiviUpdateAbility.php
|
||
@@ -7,7 +7,10 @@ namespace LIT\MCP\Integrations\Divi\Pages;
|
||
use LIT\MCP\Abilities\Annotations;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||
+use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||
use LIT\MCP\Integrations\Divi\Compose\ColumnWidths;
|
||
+use LIT\MCP\Integrations\Divi\Compose\Composer;
|
||
+use LIT\MCP\Integrations\Divi\Compose\LoopScope;
|
||
use LIT\MCP\Integrations\Divi\Design\PresetAssignment;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use WP_Error;
|
||
@@ -21,6 +24,9 @@ defined('ABSPATH') || exit;
|
||
* come back together; only then are the attributes Composer::update() computed set. A node may
|
||
* appear once per call (ruling S14); set and attrs of one change must not reach the same path
|
||
* (ruling S38, checked by Composer::update()).
|
||
+ *
|
||
+ * Loop content (ruling X3): each change is checked in the loop scope of its node – the stored ancestors plus the
|
||
+ * loop attrs this call gives the node or an ancestor – with one Composer per scope.
|
||
*/
|
||
final class DiviUpdateAbility extends DiviWriteAbility
|
||
{
|
||
@@ -123,14 +129,13 @@ final class DiviUpdateAbility extends DiviWriteAbility
|
||
]);
|
||
}
|
||
|
||
- $composer = $composition->composer();
|
||
$errors = [];
|
||
/** @var list<string> $invalidPaths */
|
||
$invalidPaths = [];
|
||
/** @var array<string, int> $seen id => position of its first change */
|
||
$seen = [];
|
||
- /** @var list<array{0: Block, 1: array<string, mixed>, 2: string}> $updates */
|
||
- $updates = [];
|
||
+ /** @var list<array{0: Block, 1: array<array-key, mixed>, 2: string}> $pending */
|
||
+ $pending = [];
|
||
foreach ($changes as $position => $change) {
|
||
$field = sprintf('changes[%d]', $position);
|
||
if (! is_array($change)) {
|
||
@@ -159,10 +164,15 @@ final class DiviUpdateAbility extends DiviWriteAbility
|
||
continue;
|
||
}
|
||
|
||
- $result = $composer->update($block, $change, $field);
|
||
+ $pending[] = [$block, $change, $field];
|
||
+ }
|
||
+
|
||
+ /** @var list<array{0: Block, 1: array<string, mixed>, 2: string}> $updates */
|
||
+ $updates = [];
|
||
+ foreach (self::composeInLoops($page->index, $composition, $pending) as $position => $result) {
|
||
$errors += $result['errors'];
|
||
array_push($invalidPaths, ...$result['invalid_paths']);
|
||
- $updates[] = [$block, $result['attrs'], $field];
|
||
+ $updates[] = [$pending[$position][0], $result['attrs'], $pending[$position][2]];
|
||
}
|
||
|
||
if ($errors === []) {
|
||
@@ -189,4 +199,46 @@ final class DiviUpdateAbility extends DiviWriteAbility
|
||
|
||
return $change;
|
||
}
|
||
+
|
||
+ /**
|
||
+ * Composer::update() of every pending change in its loop scope (ruling X3). First with the stored ancestors;
|
||
+ * then changes whose scope the new attrs of this call move (a loop switched on, off or to another kind on the
|
||
+ * node or an ancestor) are composed again in that scope – loop attrs are no client content, so the second
|
||
+ * pass sees the same loops.
|
||
+ *
|
||
+ * @param list<array{0: Block, 1: array<array-key, mixed>, 2: string}> $pending
|
||
+ * @return list<array{attrs: array<string, mixed>, errors: array<string, string>, invalid_paths: list<string>}>
|
||
+ */
|
||
+ private static function composeInLoops(NodeIndex $index, DiviComposition $composition, array $pending): array
|
||
+ {
|
||
+ /** @var array<string, Composer> $composers query type ('' = no loop) => Composer of that scope */
|
||
+ $composers = [];
|
||
+ $scopes = [];
|
||
+ $results = [];
|
||
+ foreach ($pending as $position => [$block, $change, $field]) {
|
||
+ $scopes[$position] = self::loopAt($index, $block);
|
||
+ $results[$position] = self::composerFor($composition, $scopes[$position], $composers)
|
||
+ ->update($block, $change, $field);
|
||
+ }
|
||
+
|
||
+ $newAttrs = [];
|
||
+ foreach ($pending as $position => [$block]) {
|
||
+ $newAttrs[(string) $index->idOf($block)] = $results[$position]['attrs'];
|
||
+ }
|
||
+ foreach ($pending as $position => [$block, $change, $field]) {
|
||
+ $scope = self::loopAt($index, $block, $newAttrs);
|
||
+ if ($scope->queryType() !== $scopes[$position]->queryType()) {
|
||
+ $results[$position] = self::composerFor($composition, $scope, $composers)
|
||
+ ->update($block, $change, $field);
|
||
+ }
|
||
+ }
|
||
+
|
||
+ return $results;
|
||
+ }
|
||
+
|
||
+ /** @param array<string, Composer> $composers One Composer per loop scope (S31 as reworded by X3). */
|
||
+ private static function composerFor(DiviComposition $composition, LoopScope $scope, array &$composers): Composer
|
||
+ {
|
||
+ return $composers[$scope->queryType() ?? ''] ??= $composition->inLoop($scope)->composer();
|
||
+ }
|
||
}
|
||
```
|
||
|
||
Die Reihenfolge der Fehler ändert sich nur darin, dass id-Fehler (`changes[i].id`) vor den Kompositionsfehlern stehen; die Fehler sind nach Feldpfad geschlüsselt, `DiviUpdateAbilityTest` bleibt unverändert grün.
|
||
|
||
- [ ] **Step 4c.4: GREEN, Update-Suite, Lint**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php`
|
||
Expected: `OK (5 tests, 43 assertions)`.
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviUpdateAbilityTest.php && composer lint`
|
||
Expected: `OK (16 tests, 143 assertions)`; phpcs ohne Meldung.
|
||
|
||
- [ ] **Step 4c.5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Pages/DiviUpdateAbility.php tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php
|
||
git commit -m "feat: lit-divi-update prüft jede Änderung im Schleifen-Kontext ihres Knotens – ein Composer je Kontext, neue loop-Attribute zählen (X3, S31)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### Task 4d: Move-Warnung
|
||
|
||
- [ ] **Step 4d.1: Failing Tests an `DiviLoopWriteTest` anhängen**
|
||
|
||
Vor der schließenden `}` der Klasse einfügen:
|
||
|
||
```php
|
||
|
||
public function testMovingLoopContentOutOfItsLoopWarns(): void
|
||
{
|
||
$out = $this->moveNode([
|
||
'id' => 's0.r0.c0.m0', 'parent' => 's0.r0.c2',
|
||
]);
|
||
self::assertTrue($out['ok'], 'Divi allows the move (A11): a warning, no refusal');
|
||
$warnings = implode(' | ', $out['warnings']);
|
||
self::assertStringContainsString('loop_post_title', $warnings);
|
||
self::assertStringContainsString('außerhalb jeder Divi-Schleife', $warnings);
|
||
|
||
$other = $this->moveNode([
|
||
'id' => 's0.r0.c2.m1', 'parent' => 's0.r0.c1',
|
||
]);
|
||
self::assertStringContainsString('Typ: Terme (vorher: keine Schleife)', implode(' | ', $other['warnings']));
|
||
}
|
||
|
||
public function testMovesThatKeepTheLoopDoNotWarn(): void
|
||
{
|
||
$withinLoop = $this->moveNode([
|
||
'id' => 's0.r0.c0.m0', 'after' => 's0.r0.c0.m1',
|
||
]);
|
||
$plain = $this->moveNode([
|
||
'id' => 's0.r0.c0.m0', 'parent' => 's0.r0.c2',
|
||
]);
|
||
// A module with its own loop takes the loop along.
|
||
$loopItself = $this->moveNode([
|
||
'id' => 's0.r0.c0.m1', 'parent' => 's0.r0.c2',
|
||
]);
|
||
|
||
foreach ([$withinLoop, $plain, $loopItself] as $result) {
|
||
self::assertTrue($result['ok'], (string) json_encode($result));
|
||
self::assertStringNotContainsString('Schleife', implode(' | ', $result['warnings']));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4d.2: RED**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php`
|
||
Expected: FAIL – `Tests: 7, Assertions: 53, Failures: 1`: `testMovingLoopContentOutOfItsLoopWarns` – `Failed asserting that '' contains "loop_post_title"`. `testMovesThatKeepTheLoopDoNotWarn` ist schon grün (heute warnt nichts); er sichert die Bedingung gegen Fehlalarme ab.
|
||
|
||
- [ ] **Step 4d.3: `DiviMoveAbility` warnen lassen**
|
||
|
||
```diff
|
||
diff --git a/src/Integrations/Divi/Pages/DiviMoveAbility.php b/src/Integrations/Divi/Pages/DiviMoveAbility.php
|
||
--- a/src/Integrations/Divi/Pages/DiviMoveAbility.php
|
||
+++ b/src/Integrations/Divi/Pages/DiviMoveAbility.php
|
||
@@ -6,6 +6,10 @@ namespace LIT\MCP\Integrations\Divi\Pages;
|
||
|
||
use LIT\MCP\Abilities\Annotations;
|
||
use LIT\MCP\Abilities\Errors;
|
||
+use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||
+use LIT\MCP\Integrations\Divi\Compose\LoopContext;
|
||
+use LIT\MCP\Integrations\Divi\Compose\LoopScope;
|
||
+use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use WP_Error;
|
||
|
||
@@ -14,7 +18,8 @@ defined('ABSPATH') || exit;
|
||
/**
|
||
* 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.
|
||
+ * nodes (global modules, foreign blocks) move as a whole, unchanged. Loop content that leaves its loop scope (out
|
||
+ * of a loop, into one of another kind) is moved with a warning – Divi allows it (rulings X3, A11).
|
||
*/
|
||
final class DiviMoveAbility extends DiviWriteAbility
|
||
{
|
||
@@ -76,6 +81,9 @@ final class DiviMoveAbility extends DiviWriteAbility
|
||
return DiviErrors::position($position);
|
||
}
|
||
|
||
+ // Both scopes from the tree before the move: the new parent never lies inside $node (TreeEditor::move()).
|
||
+ $from = self::loopAt($page->index, $page->index->parentBlock($node));
|
||
+ $to = self::loopAt($page->index, $position['parent']);
|
||
$problem = $editor->move($page->index, $node, $position);
|
||
if ($problem !== null) {
|
||
return Errors::invalidInput(
|
||
@@ -84,6 +92,58 @@ final class DiviMoveAbility extends DiviWriteAbility
|
||
);
|
||
}
|
||
|
||
- return (new DiviChange(sprintf('Knoten %s verschoben', $id)))->record($node, 'moved');
|
||
+ $change = (new DiviChange(sprintf('Knoten %s verschoben', $id)))->record($node, 'moved');
|
||
+ $names = self::loopContent($node);
|
||
+ if ($names !== [] && $from->kind() !== $to->kind()) {
|
||
+ $change->warning(self::loopWarning($names, $from, $to));
|
||
+ }
|
||
+
|
||
+ return $change;
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * The loop_* options $block and its descendants show in the scope around $block; a block that runs its own
|
||
+ * loop keeps its content's scope, read-only blocks are not entered.
|
||
+ *
|
||
+ * @return list<string>
|
||
+ */
|
||
+ private static function loopContent(Block $block): array
|
||
+ {
|
||
+ if ($block->isOpaque() || LoopContext::enabled($block->attrs())) {
|
||
+ return [];
|
||
+ }
|
||
+
|
||
+ $names = [];
|
||
+ $attrs = $block->attrs();
|
||
+ array_walk_recursive($attrs, static function (mixed $value) use (&$names): void {
|
||
+ $name = is_string($value) ? VariableRef::nameOf($value) : null;
|
||
+ if ($name !== null && str_starts_with($name, 'loop_')) {
|
||
+ $names[] = $name;
|
||
+ }
|
||
+ });
|
||
+ foreach ($block->children() as $child) {
|
||
+ array_push($names, ...self::loopContent($child));
|
||
+ }
|
||
+
|
||
+ return array_values(array_unique($names));
|
||
+ }
|
||
+
|
||
+ /** @param list<string> $names */
|
||
+ private static function loopWarning(array $names, LoopScope $from, LoopScope $to): string
|
||
+ {
|
||
+ $where = $to->active()
|
||
+ ? sprintf(
|
||
+ 'jetzt in einer Divi-Schleife vom Typ: %s (vorher: %s); passen sie nicht zu dieser Art, zeigt Divi '
|
||
+ . 'sie leer oder mit Daten eines anderen Objekts mit derselben ID',
|
||
+ $to->label(),
|
||
+ $from->active() ? $from->label() : 'keine Schleife'
|
||
+ )
|
||
+ : 'jetzt außerhalb jeder Divi-Schleife; Divi zeigt sie dort leer';
|
||
+
|
||
+ return sprintf(
|
||
+ 'Schleifen-Inhalte (%s) liegen %s. Zurückverschieben oder die Inhalte anpassen.',
|
||
+ implode(', ', $names),
|
||
+ $where
|
||
+ );
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4d.4: GREEN, Move-Suite, Lint**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php`
|
||
Expected: `OK (7 tests, 55 assertions)`.
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviMoveAbilityTest.php && composer lint`
|
||
Expected: grün (Byte-Identität „move there and back“ unverändert); phpcs ohne Meldung.
|
||
|
||
- [ ] **Step 4d.5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Pages/DiviMoveAbility.php tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php
|
||
git commit -m "feat: lit-divi-move warnt, wenn Schleifen-Inhalte ihre Schleife verlassen oder in eine andere Art kommen (X3, A11)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### Task 4e: Neue Einträge ohne Schleifen-Kontext (Absicherung)
|
||
|
||
- [ ] **Step 4e.1: Test in `DiviContentComposerTest` ergänzen**
|
||
|
||
```diff
|
||
diff --git a/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php b/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php
|
||
--- a/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php
|
||
+++ b/tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php
|
||
@@ -177,6 +177,17 @@ final class DiviContentComposerTest extends TestCase
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
+ /** Ruling X3: a new entry has no loop scope (nor has a new Theme Builder layout, same LayoutBuilder). */
|
||
+ public function testNewEntriesRefuseLoopContentWithTheLoopMessage(): void
|
||
+ {
|
||
+ $refused = $this->composer()->check('page', [
|
||
+ ['type' => 'section', 'modules' => [['type' => 'text', 'content' => '{{loop:post_title}}']]],
|
||
+ ]);
|
||
+
|
||
+ self::assertInstanceOf(WP_Error::class, $refused);
|
||
+ self::assertStringContainsString('braucht eine Divi-Schleife', implode(' ', self::errorFields($refused)));
|
||
+ }
|
||
+
|
||
public function testAFailingSaveIsThrown(): void
|
||
{
|
||
$this->saver = Mockery::mock(DiviSaver::class);
|
||
```
|
||
|
||
- [ ] **Step 4e.2: Lauf**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php`
|
||
Expected: `OK (10 tests, 54 assertions)` – sofort grün: Absicherungstest ohne Code-Änderung (`DiviContentComposer` und `TbCreateTemplateAbility` rufen nie `inLoop()`). Er bricht, falls jemand dort später einen Kontext einspeist oder Task 2 die Meldung ändert.
|
||
|
||
- [ ] **Step 4e.3: Gesamtlauf und Lint**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: grün, neun Tests mehr als nach Task 3 (1 + 7 + 1); phpcs ohne Meldung.
|
||
|
||
- [ ] **Step 4e.4: Commit**
|
||
|
||
```bash
|
||
git add tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php
|
||
git commit -m "test: lit-content-create hat keinen Schleifen-Kontext – loop_* mit der Schleifen-Meldung abgelehnt (X3)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: `lit/divi-dynamic-sources` mit `post_id` + `node` (X6)
|
||
|
||
Optional `post_id` (Ganzzahl) und `node` (Pfad-ID), nur zusammen. Dann fragt das Werkzeug Divis Registry mit dem Eintrag selbst (statt eines Beispiel-Eintrags, `sample_post_id` = `post_id`) und ergänzt:
|
||
- `loop`: `{node, query_type, label}` – `query_type`/`label` `null` ohne Schleife (`LoopContext::of()`, Task 1; `label()` deutsch: Beiträge, Terme, Benutzer, Menü);
|
||
- `loop_sources`: `[{name, placeholder, settings}]` aus `DynamicContentMap::loopFields($kind, $postId)` (Task 2) – Einstellung ⇒ Feldtyp wie bei `sources` (`JsonMap::of`), Platzhalter `{{loop:<kurz>}}` aus `Placeholders::LOOP` (Task 3), Meta-Schlüssel-Optionen ohne Platzhalter (Vollform);
|
||
- ohne Schleife eine Notiz „Knoten … liegt in keiner Divi-Schleife (kein Schleifen-Kontext): …“ und `loop_sources = []`.
|
||
`sources` bleibt ohne `loop_*` (`DynamicContentMap::options()`, Smoke `divi-library-smoke.sh:344-349` gilt weiter). `loop`/`loop_sources` stehen nicht in `required` und fehlen ohne `node`.
|
||
|
||
**Rechte und Laden:** Muster `lit-divi-node-get`: `DiviPageLoader::load($postId, false)` in `permission()` und erneut in `execute()` (Leserecht = `edit_post`, auch Bibliothekseinträge und Theme-Builder-Layouts über ihre Zugriffe); `node` gegen `NodeIndex::ID_PATTERN`, unbekannter Knoten → `DiviErrors::unknownNode()` (`not_found`). Der Typ des Eintrags muss `post_type` sein (sonst `validation_failed`). Read-Tools nehmen keine `rev`/`tree_rev` entgegen; die Antwort bleibt ohne `tree_rev` (sie beschreibt Möglichkeiten, keinen Lesestand). Die Fehlerprüfungen laufen vor jedem Registry-Aufruf.
|
||
|
||
**Fähigkeiten:** Ohne `post_id` unverändert `[dynamic-sources, roles]` (Tabelle `DiviToolFeaturesTest::EXPECTED` bleibt); mit `post_id` zusätzlich `page-read` und `FeatureSets::ofEntry($postId, false)` wie bei `lit-divi-node-get` – zwei neue Input-Fälle in `DiviToolFeaturesTest::inputCases()`.
|
||
|
||
**Verdrahtung:** `DiviIntegration::abilities()` gibt den schon vorhandenen `$loader` mit (benanntes Argument `loader:`); `DiviIntegrationTest` und `DiviToolFeaturesTest` bauen die Abilities über `DiviIntegration` und bleiben grün (der Loader wird erst in `permission()`/`execute()` benutzt). Ohne Loader (Konstruktor-Standard `null`) antworten `post_id`/`node` mit `unsupported`.
|
||
|
||
**Files:**
|
||
- Modify: `src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php`
|
||
- Modify: `src/Integrations/Divi/DiviIntegration.php`
|
||
- Modify: `tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php`
|
||
- Modify: `tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `LoopContext::of(NodeIndex, string)`, `LoopScope::{kind, queryType, label}` (Task 1); `DynamicContentMap::loopFields(string $kind, int $postId = 0)` (Task 2, A1); `Placeholders::LOOP` (Task 3); `DiviPageLoader::load()`, `DiviErrors::unknownNode()`, `FeatureSets::{merge, ofEntry}`, `NodeIndex::ID_PATTERN` (vorhanden).
|
||
- Produces:
|
||
- `DiviDynamicSourcesAbility::__construct(ContentSchema $schema, DynamicContentMap $map, DiviRoles $roles, ?Closure $samplePost = null, ?Closure $podStorage = null, ?DiviPageLoader $loader = null)`.
|
||
- Eingabe `{post_type (Pflicht), post_id?, node?}`; Ausgabe zusätzlich `loop?`, `loop_sources?` (siehe oben).
|
||
|
||
- [ ] **Step 5.1: Failing Tests in `DiviDynamicSourcesAbilityTest` ergänzen**
|
||
|
||
```diff
|
||
diff --git a/tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php
|
||
--- a/tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php
|
||
+++ b/tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php
|
||
@@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviDynamicSourcesAbility;
|
||
+use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||
@@ -16,6 +17,9 @@ use Mockery;
|
||
|
||
final class DiviDynamicSourcesAbilityTest extends TestCase
|
||
{
|
||
+ /** @var list<int> Post IDs Divi's registry was asked for. */
|
||
+ private array $registryCalls = [];
|
||
+
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
@@ -51,17 +55,111 @@ final class DiviDynamicSourcesAbilityTest extends TestCase
|
||
private function ability(
|
||
array $deniedRoles = [],
|
||
?string $storage = 'meta',
|
||
- ?array $registry = null
|
||
+ ?array $registry = null,
|
||
+ ?DiviPageLoader $loader = null
|
||
): DiviDynamicSourcesAbility {
|
||
$registry ??= DiviFixtures::dynamicContentRegistry();
|
||
|
||
return new DiviDynamicSourcesAbility(
|
||
$this->schema(),
|
||
- new DynamicContentMap(static fn (int $id): array => $registry),
|
||
+ new DynamicContentMap(function (int $id) use ($registry): array {
|
||
+ $this->registryCalls[] = $id;
|
||
+
|
||
+ return $registry;
|
||
+ }),
|
||
LibraryFixtures::roles($deniedRoles),
|
||
static fn (string $postType): int => 831,
|
||
- static fn (string $postType): ?string => $storage
|
||
+ static fn (string $postType): ?string => $storage,
|
||
+ $loader
|
||
+ );
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Ruling X6: the ability with a loader over post 7 – s0.r0.c0 runs a term loop, s0.r0.c1 none – and a
|
||
+ * registry with one post and one term loop option (Divi 5.15.1 groups and fields).
|
||
+ */
|
||
+ private function withLoopPage(): DiviDynamicSourcesAbility
|
||
+ {
|
||
+ $loop = ['module' => ['advanced' => ['loop' => ['desktop' => ['value' => [
|
||
+ 'enable' => 'on', 'queryType' => 'post_taxonomies',
|
||
+ ]]]]]];
|
||
+ $text = '<!-- wp:divi/text {"builderVersion":"5.2.1"} --><!-- /wp:divi/text -->';
|
||
+ $column = static fn (array $attrs): string => '<!-- wp:divi/column '
|
||
+ . json_encode($attrs + ['builderVersion' => '5.2.1']) . ' -->' . $text . '<!-- /wp:divi/column -->';
|
||
+ DiviFixtures::givenPost(
|
||
+ '<!-- wp:divi/placeholder --><!-- wp:divi/section {"builderVersion":"5.2.1"} --><!-- wp:divi/row '
|
||
+ . '{"builderVersion":"5.2.1"} -->' . $column($loop) . $column([])
|
||
+ . '<!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->'
|
||
+ );
|
||
+ $field = static fn (string $type): array => ['label' => 'x', 'type' => $type, 'default' => ''];
|
||
+ $common = ['before' => $field('text'), 'after' => $field('text'), 'loop_position' => $field('text')];
|
||
+ $registry = DiviFixtures::dynamicContentRegistry() + [
|
||
+ 'loop_post_date' => ['label' => 'Datum', 'group' => 'Loop', 'fields' => $common + [
|
||
+ 'date_format' => $field('select'),
|
||
+ ]],
|
||
+ 'loop_term_name' => ['label' => 'Name', 'group' => 'Loop Terms', 'fields' => $common],
|
||
+ ];
|
||
+
|
||
+ return $this->ability([], 'meta', $registry, new DiviPageLoader(DiviFixtures::schema()));
|
||
+ }
|
||
+
|
||
+ /** Ruling X6: post_id + node add the loop sources of the node's loop kind, read from that entry. */
|
||
+ public function testANodeInALoopAddsTheLoopSourcesOfItsKind(): void
|
||
+ {
|
||
+ $result = $this->withLoopPage()->execute(['post_type' => 'page', 'post_id' => 7, 'node' => 's0.r0.c0.m0']);
|
||
+
|
||
+ self::assertSame(7, $result['sample_post_id'], 'the entry itself, not a sample');
|
||
+ self::assertSame([7], array_values(array_unique($this->registryCalls)));
|
||
+ self::assertSame(
|
||
+ ['node' => 's0.r0.c0.m0', 'query_type' => 'post_taxonomies', 'label' => 'Terme'],
|
||
+ $result['loop']
|
||
);
|
||
+ self::assertSame(['loop_term_name'], array_column($result['loop_sources'], 'name'));
|
||
+ self::assertSame('{{loop:term_name}}', $result['loop_sources'][0]['placeholder']);
|
||
+ self::assertEquals(
|
||
+ ['before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text'],
|
||
+ (array) $result['loop_sources'][0]['settings']
|
||
+ );
|
||
+ self::assertSame([], array_filter(
|
||
+ array_column($result['sources'], 'name'),
|
||
+ static fn (string $name): bool => str_starts_with($name, 'loop_')
|
||
+ ));
|
||
+ }
|
||
+
|
||
+ public function testANodeOutsideALoopGetsNoLoopSources(): void
|
||
+ {
|
||
+ $result = $this->withLoopPage()->execute(['post_type' => 'page', 'post_id' => 7, 'node' => 's0.r0.c1.m0']);
|
||
+
|
||
+ self::assertSame(['node' => 's0.r0.c1.m0', 'query_type' => null, 'label' => null], $result['loop']);
|
||
+ self::assertSame([], $result['loop_sources']);
|
||
+ self::assertStringContainsString('kein Schleifen-Kontext', implode(' ', $result['notes']));
|
||
+ }
|
||
+
|
||
+ public function testWithoutANodeTheAnswerHasNoLoopPart(): void
|
||
+ {
|
||
+ $result = $this->ability()->execute(['post_type' => 'page']);
|
||
+
|
||
+ self::assertArrayNotHasKey('loop', $result);
|
||
+ self::assertArrayNotHasKey('loop_sources', $result);
|
||
+ }
|
||
+
|
||
+ public function testPostIdAndNodeComeTogetherAndNeedTheEntry(): void
|
||
+ {
|
||
+ $ability = $this->withLoopPage();
|
||
+
|
||
+ foreach ([['post_id' => 7], ['node' => 's0']] as $half) {
|
||
+ self::assertToolError($ability->permission(['post_type' => 'page'] + $half), 'validation_failed');
|
||
+ self::assertToolError($ability->execute(['post_type' => 'page'] + $half), 'validation_failed');
|
||
+ }
|
||
+ $node = ['post_type' => 'page', 'post_id' => 7, 'node' => 's0'];
|
||
+ self::assertToolError($ability->execute(['node' => 's9'] + $node), 'not_found');
|
||
+ self::assertToolError($ability->execute(['node' => 'x'] + $node), 'validation_failed');
|
||
+ self::assertToolError($ability->execute(['post_type' => 'post'] + $node), 'validation_failed');
|
||
+
|
||
+ Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_post');
|
||
+ self::assertToolError($ability->permission($node), 'forbidden');
|
||
+ self::assertToolError($ability->execute($node), 'forbidden');
|
||
+ self::assertSame([], $this->registryCalls, 'nothing is read for a refused entry');
|
||
}
|
||
|
||
public function testListsDiviSourcesWithTheirPlaceholders(): void
|
||
```
|
||
|
||
- [ ] **Step 5.2: Input-Fälle in `DiviToolFeaturesTest` ergänzen**
|
||
|
||
```diff
|
||
diff --git a/tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php b/tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php
|
||
--- a/tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php
|
||
+++ b/tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php
|
||
@@ -203,6 +203,14 @@ final class DiviToolFeaturesTest extends TestCase
|
||
'lit/divi-design-restore', ['snapshot_id' => 'theme_options-20261016T100000Z-aaaaaa'],
|
||
['theme-option-read', 'theme-option-write'],
|
||
],
|
||
+ 'dynamic-sources of a node' => [
|
||
+ 'lit/divi-dynamic-sources', ['post_type' => 'page', 'post_id' => $page, 'node' => 's0'],
|
||
+ ['dynamic-sources', 'roles', 'page-read'],
|
||
+ ],
|
||
+ 'dynamic-sources of a node in a layout' => [
|
||
+ 'lit/divi-dynamic-sources', ['post_type' => 'et_body_layout', 'post_id' => $layout, 'node' => 's0'],
|
||
+ ['dynamic-sources', 'roles', 'page-read'],
|
||
+ ],
|
||
'library-list' => ['lit/library-list', [], ['library-read', 'roles']],
|
||
'library-list of one entry' => [
|
||
'lit/library-list', ['id' => $library], ['library-read', 'roles', 'page-read', 'design-read'],
|
||
```
|
||
|
||
- [ ] **Step 5.3: RED**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php`
|
||
Expected: FAIL – `Tests: 14, Assertions: 46, Errors: 1, Failures: 2`: `testANodeInALoopAddsTheLoopSourcesOfItsKind` (sample 831 statt 7), `testANodeOutsideALoopGetsNoLoopSources` (Schlüssel `loop` fehlt), `testPostIdAndNodeComeTogetherAndNeedTheEntry` (kein Fehler für eine halbe Angabe). `testWithoutANodeTheAnswerHasNoLoopPart` ist schon grün (Absicherung des alten Antwortformats).
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php`
|
||
Expected: FAIL – `Tests: 41, Assertions: 335, Failures: 2` (die zwei neuen Input-Fälle: `page-read` fehlt).
|
||
|
||
- [ ] **Step 5.4: `DiviDynamicSourcesAbility` erweitern**
|
||
|
||
```diff
|
||
diff --git a/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php b/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php
|
||
--- a/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php
|
||
+++ b/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php
|
||
@@ -7,11 +7,15 @@ namespace LIT\MCP\Integrations\Divi\Pages;
|
||
use Closure;
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Annotations;
|
||
+use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Abilities\JsonMap;
|
||
+use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||
use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
|
||
+use LIT\MCP\Integrations\Divi\Compose\LoopContext;
|
||
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
||
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
+use LIT\MCP\Integrations\Divi\Release\FeatureSets;
|
||
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
|
||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
@@ -22,9 +26,13 @@ defined('ABSPATH') || exit;
|
||
/**
|
||
* lit/divi-dynamic-sources (spec §5.7, E12): Divi's dynamic content options for a post type
|
||
* (DynamicContentOptions::get_options with a sample entry of the type the user may edit, else 0;
|
||
- * no loop_* – S36; via DynamicContentMap::options()) with the placeholder that writes each, and
|
||
+ * no loop_* there; via DynamicContentMap::options()) with the placeholder that writes each, and
|
||
* the fields of the type usable as {{field:<key>}}.
|
||
*
|
||
+ * Ruling X6: with post_id and node (path ID) the entry itself is asked instead of a sample – loaded with the
|
||
+ * rights of lit-divi-node-get (DiviPageLoader) – and loop_sources lists the loop_* options of the node's loop
|
||
+ * kind (LoopContext, DynamicContentMap::loopFields()); a node outside any loop gets a note instead.
|
||
+ *
|
||
* Answer F4: only text-like fields are offered; relation/file fields show IDs in Divi (F26), Pods
|
||
* table storage is not post meta, protected keys never (S12), unknown types not (ruling P9) – each
|
||
* left out with its reason (DynamicFields). Hidden fields (security audit M4) are neither offered nor
|
||
@@ -44,13 +52,15 @@ final class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures
|
||
* @param (Closure(string): int)|null $samplePost Post type → an entry to ask Divi with (0 = none).
|
||
* @param (Closure(string): ?string)|null $podStorage Post type → Pods storage ("meta", "table");
|
||
* null without a pod.
|
||
+ * @param DiviPageLoader|null $loader Loads the entry of post_id; null: post_id/node unsupported.
|
||
*/
|
||
public function __construct(
|
||
private ContentSchema $schema,
|
||
private DynamicContentMap $map,
|
||
private DiviRoles $roles,
|
||
?Closure $samplePost = null,
|
||
- ?Closure $podStorage = null
|
||
+ ?Closure $podStorage = null,
|
||
+ private ?DiviPageLoader $loader = null
|
||
) {
|
||
$this->samplePost = $samplePost ?? self::defaultSample(...);
|
||
$this->fields = new DynamicFields($schema, $podStorage);
|
||
@@ -71,14 +81,26 @@ final class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures
|
||
return 'Listet die Dynamic-Content-Quellen, die Divi für einen Inhaltstyp anbietet (Beitragsdaten, Links, '
|
||
. 'Website-Daten …) mit dem Platzhalter dazu ({{post:…}}, {{link:<ID>}}) bzw. dem Namen für die Vollform, '
|
||
. 'und die Felder des Typs, die sich als {{field:<Schlüssel>}} eignen. Nur textartige Felder; Beziehungs-, '
|
||
- . 'Datei-, Tabellen- und unbekannte Felder stehen mit Grund unter omitted.';
|
||
+ . 'Datei-, Tabellen- und unbekannte Felder stehen mit Grund unter omitted. Mit post_id und node (Pfad-ID '
|
||
+ . 'aus lit-divi-outline) zusätzlich die Schleifen-Quellen ({{loop:…}}), die an diesem Knoten passen.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
- 'properties' => ['post_type' => ['type' => 'string', 'description' => 'z. B. "page" oder "person".']],
|
||
+ 'properties' => [
|
||
+ 'post_type' => ['type' => 'string', 'description' => 'z. B. "page" oder "person".'],
|
||
+ 'post_id' => [
|
||
+ 'type' => 'integer',
|
||
+ 'minimum' => 1,
|
||
+ 'description' => 'Eintrag dieses Typs; nur zusammen mit node.',
|
||
+ ],
|
||
+ 'node' => [
|
||
+ 'type' => 'string',
|
||
+ 'description' => 'Pfad-ID aus lit-divi-outline, z. B. "s0.r0.c1.m0"; nur zusammen mit post_id.',
|
||
+ ],
|
||
+ ],
|
||
'required' => ['post_type'],
|
||
'additionalProperties' => false,
|
||
];
|
||
@@ -117,6 +139,28 @@ final class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures
|
||
'fields' => $entry(['key', 'label', 'type', 'source', 'placeholder']),
|
||
'omitted' => $entry(['key', 'label', 'reason']),
|
||
'notes' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||
+ 'loop' => [
|
||
+ 'type' => 'object',
|
||
+ 'description' => 'Nur mit node: die Divi-Schleife des Knotens (query_type null = keine).',
|
||
+ 'properties' => [
|
||
+ 'node' => ['type' => 'string'],
|
||
+ 'query_type' => ['type' => ['string', 'null']],
|
||
+ 'label' => ['type' => ['string', 'null']],
|
||
+ ],
|
||
+ 'required' => ['node', 'query_type', 'label'],
|
||
+ ],
|
||
+ 'loop_sources' => [
|
||
+ 'type' => 'array',
|
||
+ 'items' => [
|
||
+ 'type' => 'object',
|
||
+ 'properties' => [
|
||
+ 'name' => ['type' => 'string'],
|
||
+ 'placeholder' => ['type' => ['string', 'null']],
|
||
+ 'settings' => ['type' => 'object'],
|
||
+ ],
|
||
+ 'required' => ['name', 'placeholder', 'settings'],
|
||
+ ],
|
||
+ ],
|
||
],
|
||
'required' => ['post_type', 'sample_post_id', 'sources', 'fields', 'omitted', 'notes'],
|
||
];
|
||
@@ -135,17 +179,28 @@ final class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures
|
||
/**
|
||
* Divi's dynamic content registry (DynamicSources); listing custom fields asks the role option
|
||
* read_dynamic_content_custom_fields (Roles) and omits them without it, so a broken roles check must not pass.
|
||
+ * post_id loads that entry (PageRead, plus what the entry adds like in lit-divi-node-get).
|
||
*/
|
||
public function diviFeatures(array $input): array
|
||
{
|
||
- return [DiviFeature::DynamicSources, DiviFeature::Roles];
|
||
+ $base = [DiviFeature::DynamicSources, DiviFeature::Roles];
|
||
+ if (! array_key_exists('post_id', $input)) {
|
||
+ return $base;
|
||
+ }
|
||
+
|
||
+ return FeatureSets::merge($base, [DiviFeature::PageRead], FeatureSets::ofEntry($input['post_id'], false));
|
||
}
|
||
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
$type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts');
|
||
+ if ($type instanceof WP_Error) {
|
||
+ return $type;
|
||
+ }
|
||
|
||
- return $type instanceof WP_Error ? $type : true;
|
||
+ $page = $this->page($input);
|
||
+
|
||
+ return $page instanceof WP_Error ? $page : true;
|
||
}
|
||
|
||
public function execute(array $input): mixed
|
||
@@ -156,7 +211,17 @@ final class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures
|
||
}
|
||
|
||
$postType = (string) $input['post_type'];
|
||
- $sample = ($this->samplePost)($postType);
|
||
+ $page = $this->page($input);
|
||
+ if ($page instanceof WP_Error) {
|
||
+ return $page;
|
||
+ }
|
||
+
|
||
+ $node = is_string($input['node'] ?? null) ? $input['node'] : '';
|
||
+ if ($page !== null && $page->index->find($node) === null) {
|
||
+ return DiviErrors::unknownNode($node, $page->post);
|
||
+ }
|
||
+
|
||
+ $sample = $page === null ? ($this->samplePost)($postType) : (int) $page->post->ID;
|
||
$notes = [
|
||
'Quellen ohne Platzhalter in der Vollform als $variable({"type":"content","value":{"name":"<name>",'
|
||
. '"settings":{…}}})$ mit den genannten Einstellungen schreiben.',
|
||
@@ -191,7 +256,7 @@ final class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures
|
||
. 'Felder lesen“ nicht (Divi → Rollen-Editor).';
|
||
}
|
||
|
||
- return [
|
||
+ $result = [
|
||
'post_type' => $postType,
|
||
'sample_post_id' => $sample,
|
||
'sources' => $sources,
|
||
@@ -199,6 +264,93 @@ final class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures
|
||
'omitted' => $omitted,
|
||
'notes' => $notes,
|
||
];
|
||
+
|
||
+ return $page === null ? $result : $this->withLoop($result, $page, $node);
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * The entry of post_id with node checked as a path ID; null when neither is given. Both or none (X6); the entry
|
||
+ * is loaded with read rights (DiviPageLoader::load(…, false), like lit-divi-node-get) and must be of post_type.
|
||
+ *
|
||
+ * @param array<string, mixed> $input
|
||
+ */
|
||
+ private function page(array $input): DiviPage|WP_Error|null
|
||
+ {
|
||
+ $hasPost = array_key_exists('post_id', $input);
|
||
+ if (! $hasPost && ! array_key_exists('node', $input)) {
|
||
+ return null;
|
||
+ }
|
||
+
|
||
+ if (! $hasPost || ! is_string($input['node'] ?? null)) {
|
||
+ return Errors::invalidInput('post_id und node nur zusammen angeben.', [
|
||
+ $hasPost ? 'node' : 'post_id' => 'fehlt (post_id und node gehören zusammen)',
|
||
+ ]);
|
||
+ }
|
||
+
|
||
+ if (preg_match(NodeIndex::ID_PATTERN, $input['node']) !== 1) {
|
||
+ return Errors::invalidInput('node ist keine Pfad-ID.', ['node' => 'z. B. "s0.r1.c0.m2"']);
|
||
+ }
|
||
+
|
||
+ if ($this->loader === null) {
|
||
+ return Errors::unsupported(
|
||
+ 'Schleifen-Quellen je Knoten sind hier nicht verfügbar.',
|
||
+ 'lit/divi-dynamic-sources ohne post_id und node aufrufen.'
|
||
+ );
|
||
+ }
|
||
+
|
||
+ $page = $this->loader->load($input['post_id'], false);
|
||
+ if ($page instanceof WP_Error || $page->post->post_type === $input['post_type']) {
|
||
+ return $page;
|
||
+ }
|
||
+
|
||
+ $type = (string) $page->post->post_type;
|
||
+
|
||
+ return Errors::invalidInput(
|
||
+ sprintf('Eintrag %d ist vom Typ "%s", nicht "%s".', $page->post->ID, $type, $input['post_type']),
|
||
+ ['post_type' => sprintf('"%s" (Typ von Eintrag %d)', $type, $page->post->ID)]
|
||
+ );
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * Adds the loop of $node and the loop_* sources of its kind (X6), or the note that there is no loop context.
|
||
+ *
|
||
+ * @param array<string, mixed> $result
|
||
+ * @return array<string, mixed>
|
||
+ */
|
||
+ private function withLoop(array $result, DiviPage $page, string $node): array
|
||
+ {
|
||
+ $scope = LoopContext::of($page->index, $node);
|
||
+ $kind = $scope->kind();
|
||
+ $result['loop'] = [
|
||
+ 'node' => $node,
|
||
+ 'query_type' => $scope->queryType(),
|
||
+ 'label' => $kind === null ? null : $scope->label(),
|
||
+ ];
|
||
+ $result['loop_sources'] = [];
|
||
+ if ($kind === null) {
|
||
+ $result['notes'][] = sprintf(
|
||
+ 'Knoten %s liegt in keiner Divi-Schleife (kein Schleifen-Kontext): Schleifen-Inhalte (loop_…, '
|
||
+ . '{{loop:…}}) gehen dort nicht. Eine Schleife wird im Divi-Builder (Loop Builder) eingerichtet.',
|
||
+ $node
|
||
+ );
|
||
+
|
||
+ return $result;
|
||
+ }
|
||
+
|
||
+ foreach ($this->map->loopFields($kind, (int) $page->post->ID) as $name => $settings) {
|
||
+ $result['loop_sources'][] = [
|
||
+ 'name' => (string) $name,
|
||
+ 'placeholder' => self::placeholder((string) $name),
|
||
+ 'settings' => JsonMap::of($settings),
|
||
+ ];
|
||
+ }
|
||
+ $result['notes'][] = sprintf(
|
||
+ 'Schleifen-Quellen gelten nur in dieser Schleife (Typ: %s) – in diesem Knoten und darunter, bis eine '
|
||
+ . 'innere Schleife beginnt.',
|
||
+ $scope->label()
|
||
+ );
|
||
+
|
||
+ return $result;
|
||
}
|
||
|
||
private static function placeholder(string $name): ?string
|
||
@@ -208,6 +360,11 @@ final class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures
|
||
return '{{post:' . $post . '}}';
|
||
}
|
||
|
||
+ $loop = array_search($name, Placeholders::LOOP, true);
|
||
+ if ($loop !== false) {
|
||
+ return '{{loop:' . $loop . '}}';
|
||
+ }
|
||
+
|
||
if (! str_starts_with($name, DynamicContentRules::LINK_PREFIX)) {
|
||
return null;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5.5: Loader in `DiviIntegration` durchreichen**
|
||
|
||
```diff
|
||
diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php
|
||
--- a/src/Integrations/Divi/DiviIntegration.php
|
||
+++ b/src/Integrations/Divi/DiviIntegration.php
|
||
@@ -276,7 +276,12 @@ final class DiviIntegration implements Integration, Bootable
|
||
new DiviDuplicateNodeAbility($writer),
|
||
new DiviReplaceTextAbility($this->schema, $loader, $writer),
|
||
new DiviNodeCopyAbility($writer, $loader, $this->diviWritable, $this->libraryInsertable),
|
||
- new DiviDynamicSourcesAbility($this->schema, new DynamicContentMap(), $this->access->roles()),
|
||
+ new DiviDynamicSourcesAbility(
|
||
+ $this->schema,
|
||
+ new DynamicContentMap(),
|
||
+ $this->access->roles(),
|
||
+ loader: $loader
|
||
+ ),
|
||
new DiviPresetGetAbility($this->designWriter, $this->globalUsages()),
|
||
new DiviPresetsSetAbility(
|
||
$this->designWriter,
|
||
```
|
||
|
||
- [ ] **Step 5.6: GREEN**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php`
|
||
Expected: `OK (14 tests, 82 assertions)`.
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php`
|
||
Expected: grün (`DiviToolFeaturesTest` allein: `OK (41 tests, 335 assertions)`).
|
||
|
||
- [ ] **Step 5.7: Gesamtlauf und Lint**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: grün, sechs Tests mehr als nach Task 4 (vier Tests, zwei Input-Fälle); phpcs ohne Meldung.
|
||
|
||
- [ ] **Step 5.8: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php src/Integrations/Divi/DiviIntegration.php \
|
||
tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php \
|
||
tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php
|
||
git commit -m "feat: lit/divi-dynamic-sources mit post_id + node – Schleife des Knotens und passende loop_*-Quellen (X6)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
|
||
### Task 6: Smoke gegen echtes Divi (Schleifen-Inhalte, Kontext, Rendern)
|
||
|
||
**Files:**
|
||
- Create: `tests/Integration/divi-loop-smoke.sh`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `lit-divi-outline`, `lit-divi-insert`, `lit-divi-update`, `lit-divi-move` (direkte Tools, `tool …`) und `lit/divi-dynamic-sources` (indirekt, `ability …`) über den OAuth-Endpunkt; die Hilfen aus `tests/Integration/lib.sh` (`connect`, `tool`, `ability`, `pass`, `fail`, `finish`, `guard_exit`, `cleanup_app_passwords_for`); das Verhalten der Tools laut Rulings X1–X6 (Schleifen-Kontext aus dem Baum, Meldungen X5, `{{loop:…}}` X4, dynamic-sources mit `post_id` + `node` X6, Move-Warnung X3).
|
||
- Produces: ein Skript, das 4e gegen Divi 5.15.1 prüft (echte Loop-Seite, Speichern, Rendern) und alles, was es ändert, im EXIT-Trap zurücksetzt: die Seite und den temporären Beitrag (nur per exakter ID, samt Revisionen und Protokollzeilen), die Optionen `et_divi` und `et_divi_global_variables` byte-genau (serialisiert, base64 über die Umgebung; fehlende Option → Sentinel, wird wieder gelöscht). Bei leeren Erfassungen bricht es vor der ersten Änderung ab.
|
||
|
||
Das Skript wird ausgeführt und – wie in 4c/4d – anhand der echten Antworten korrigiert. Die Tools aus Task 4 und 5 haben ihre Eingabe- und Antwortformen erst im Lauf dieses Plans bekommen; das Skript ist gegen die Rulings geschrieben. Stellen, die der Implementierer gegen die echten Antworten abgleichen muss, tragen im Skript den Kommentar `ADAPT (Task n)`:
|
||
|
||
| Stelle im Skript | Annahme | Prüfen gegen |
|
||
|---|---|---|
|
||
| `LOOP_NODE=s0.r0.c0.m1` (Schritt 2) | das eingefügte Modul ist das zweite Kind der Schleifen-Spalte (das statische Modul hat `m0`, Einfügen ohne `index` hängt hinten an) | Antwort von `lit-divi-insert` (`changed`) |
|
||
| `{{loop:term_name}}` (Schritt 4, Positivkontrolle) | Kurzname `term_name` → `loop_term_name` | Tabelle `LOOP` in `Placeholders` (Task 3) |
|
||
| `sources()` / `loop_names` (Schritt 5) | Eingabe `post_type` + `post_id` + `node`; die Antwort nennt die Loop-Optionen irgendwo als `name`; ohne Schleife enthält sie den Text „Schleifen-Kontext“ | Schema und Antwort von `lit/divi-dynamic-sources` (Task 5), ggf. `jq`-Pfad verengen |
|
||
| `.warnings` mit „Schleife“ (Schritt 6) | die Move-Warnung ist ein String in `.warnings`, der „Schleife“ enthält | Antwort von `lit-divi-move` (Task 4) |
|
||
|
||
Weitere Annahmen, die bei einem Fehlschlag zuerst zu prüfen sind (Skriptfehler, nicht Produktfehler): Erfolg heißt `.ok == true`; Fehlertexte stehen als JSON-String in `.__error` und enthalten die Meldung; `lit-divi-update` nimmt `set: {content: …}` für das Text-Modul; die Position heißt `parent`; `lit-divi-outline` nennt die Pfad-IDs `s0.r0.c0.m0`, `s0.r1.c0.m0`, `s0.r2.c0.m0`. Die Prüfungen selbst sind verbindlich und werden nicht gelockert.
|
||
|
||
Die Reihenfolge im Skript weicht von der Nummerierung der Prüfungen ab: Rendern (7) kommt vor dem Verschieben (6), weil das Modul nach dem Verschieben außerhalb der Schleife liegt und leer rendert.
|
||
|
||
| Prüfung | Schritt im Skript | Inhalt |
|
||
|---|---|---|
|
||
| (1) Loop-Seite | 1 | Entwurf mit Post-Schleifen-Spalte (`post_types`, Beitragstyp `post`), Spalte ohne Schleife in der Zeile darunter, Term-Schleifen-Spalte (`post_taxonomies`, `category`); je ein statisches Text-Modul |
|
||
| (2) `{{loop:post_date}}` in der Schleife | 2 | Kurzform und Vollform (`$variable(…)$`, Datumsformat `d.m.Y`) angenommen, gespeicherter Inhalt enthält `loop_post_date`; Positivkontrolle für die Feld-Option in der Post-Schleife |
|
||
| (3) außerhalb jeder Schleife | 3 | Insert (Kurz- und Vollform) und Update abgelehnt mit „braucht eine Divi-Schleife“ und dem Optionsnamen; Seite unverändert (MD5 des gespeicherten Inhalts) |
|
||
| (4) Sicherheitsgrenze | 4 | in der Term-Schleife werden `loop_post_date`, `loop_post_meta_key_manual_custom_field` und `loop_user_meta_key_manual_custom_field` abgelehnt („passt nicht zur Schleife“, Typ Terme), umgekehrt die Benutzer-Feld-Option in der Post-Schleife; Positivkontrolle `{{loop:term_name}}` in der Term-Schleife |
|
||
| (5) dynamic-sources | 5 | `post_id` + `node`: Post-Optionen im Post-Schleifen-Modul (keine Term-/Benutzer-Optionen), Term-Optionen im Term-Modul, keine Optionen und der Hinweis „Schleifen-Kontext“ außerhalb |
|
||
| (7) Rendern | 7 | `apply_filters('the_content', …)` der Seite: kein PHP-Fehlertext, kein offener `$variable(`-Verweis, kein Optionsname, mindestens ein formatiertes Datum (`d.m.Y`) eines veröffentlichten Beitrags (gibt es keinen, legt das Skript einen temporären an) |
|
||
| (6) Move | 6 | Verschieben des Moduls aus der Schleife: `ok` mit Warnung zu Schleifen-Inhalt, Inhalt bleibt gespeichert |
|
||
| (8) Aufräumen | 8 | Seite und temporärer Beitrag gelöscht (nicht mehr auffindbar, kein Beitrag „LIT Smoke <run>“ übrig), `et_divi` und `et_divi_global_variables` gleich der Erfassung |
|
||
|
||
- [ ] **Step 1: Das Skript anlegen**
|
||
|
||
Datei `tests/Integration/divi-loop-smoke.sh` (danach `chmod +x`, wie die übrigen Skripte):
|
||
|
||
|
||
````bash
|
||
#!/usr/bin/env bash
|
||
# End-to-end checks of Etappe 4e (loop content: loop_* options inside a Divi loop, refused outside and in a loop of
|
||
# another kind, dynamic sources per node, move warning, real frontend render) through the OAuth-protected endpoint
|
||
# against the real Divi. Runs INSIDE the DDEV web container:
|
||
# cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-loop-smoke.sh
|
||
# Runs only with the plugins postlos and asynclistry deactivated (Monolog 2 / deprecation output of the WP-CLI calls).
|
||
# Creates one draft page with a post loop, a plain row and a term loop (and one temporary published post when the site
|
||
# has none). The EXIT trap deletes the page and the temporary post by exact ID, removes their change-log rows and puts
|
||
# the options et_divi and et_divi_global_variables back byte-exact (serialized, base64 through the environment).
|
||
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)
|
||
RUN="$(date +%s)-$$"
|
||
# $1 = option name. An option that does not exist is captured as the sentinel string, so restore() can delete it again.
|
||
capture() { wp eval "echo base64_encode(serialize(get_option('$1', '__lit_absent__')));"; }
|
||
ORIGINAL_THEME=$(capture et_divi)
|
||
ORIGINAL_VARIABLES=$(capture et_divi_global_variables)
|
||
# Abort before any change: without a capture there is nothing to restore.
|
||
[ -n "$ORIGINAL_THEME" ] || { echo "FAIL: could not capture et_divi"; exit 1; }
|
||
[ -n "$ORIGINAL_VARIABLES" ] || { echo "FAIL: could not capture et_divi_global_variables"; exit 1; }
|
||
PAGE_ID=""
|
||
TEMP_POST_ID=""
|
||
|
||
restore() { # $1 = option name, $2 = name of the environment variable that holds the capture
|
||
local LIT_CAPTURE="$2"; export LIT_CAPTURE
|
||
LIT_OPTION="$1" wp eval '$n = getenv("LIT_OPTION"); $v = unserialize(base64_decode(getenv(getenv("LIT_CAPTURE")))); if ($v === "__lit_absent__") { delete_option($n); exit(get_option($n, "__lit_absent__") === $v ? 0 : 1); } update_option($n, $v); exit(serialize(get_option($n)) === serialize($v) ? 0 : 1);' >/dev/null 2>&1 \
|
||
|| { echo "FAIL: $1 not restored"; return 1; }
|
||
}
|
||
delete_content() { # deletes the page and the temporary post by exact ID (with revisions), then their change-log rows
|
||
local ids="$PAGE_ID $TEMP_POST_ID"
|
||
for id in $ids; do
|
||
wp post delete "$id" --force >/dev/null 2>&1 || true
|
||
done
|
||
LIT_IDS="$ids" wp eval '
|
||
global $wpdb;
|
||
$table = $wpdb->prefix . "lit_mcp_log";
|
||
foreach (array_filter(explode(" ", (string) getenv("LIT_IDS"))) as $id) {
|
||
$wpdb->delete($table, ["object_id" => $id]);
|
||
}' >/dev/null 2>&1 || true
|
||
}
|
||
cleanup() {
|
||
local failed=0
|
||
delete_content
|
||
restore et_divi LIT_THEME || failed=1
|
||
restore et_divi_global_variables LIT_VARIABLES || failed=1
|
||
wp eval 'ET_Core_PageResource::remove_static_resources("all", "all", true);' >/dev/null 2>&1 || true
|
||
cleanup_app_passwords_for "$ADMIN_LOGIN"
|
||
return "$failed"
|
||
}
|
||
export LIT_THEME="$ORIGINAL_THEME" LIT_VARIABLES="$ORIGINAL_VARIABLES"
|
||
trap 'rc=$?; cleanup || rc=1; guard_exit "$rc" || exit $?' EXIT
|
||
|
||
[ "$(wp eval 'echo get_template();')" = "Divi" ] || fail "Divi must be the active theme"
|
||
connect "$ADMIN_LOGIN"
|
||
|
||
# --- helpers -----------------------------------------------------------------------------------------------------
|
||
|
||
# Draft page: s0.r0 = column c0 with a POST loop (queryType post_types, post type "post") and one static text module,
|
||
# s0.r1 = column c0 WITHOUT a loop and one static text module (below the loop), s0.r2 = column c0 with a TERM loop
|
||
# (queryType post_taxonomies, taxonomy category) and one static text module. Loop attributes exactly as Divi's own
|
||
# builder stores them (module.advanced.loop.desktop.value, enable "on").
|
||
create_loop_page() { # prints the ID
|
||
LIT_TITLE="LIT Smoke $RUN Schleife" wp --user="$ADMIN_LOGIN" eval '
|
||
$b = static fn (string $n, array $a, array $k = []): array => ["blockName" => $n, "attrs" => $a, "innerBlocks" => $k,
|
||
"innerHTML" => "", "innerContent" => array_fill(0, count($k), null)];
|
||
$text = static fn (string $t): array => $b("divi/text", ["content" => ["innerContent" => ["desktop" => ["value" => $t]]]]);
|
||
$width = ["module" => ["decoration" => ["sizing" => ["desktop" => ["value" => ["flexType" => "24_24"]]]]]];
|
||
$loop = static fn (string $id, string $type, string $sub, string $label): array => array_replace_recursive($width, [
|
||
"module" => ["advanced" => ["loop" => ["desktop" => ["value" => ["enable" => "on", "loopId" => $id,
|
||
"queryType" => $type, "subTypes" => [["value" => $sub, "label" => $label]], "postPerPage" => "3"]]]]]]);
|
||
$row = static fn (array $column): array => $b("divi/row", [], [$column]);
|
||
$section = $b("divi/section", [], [
|
||
$row($b("divi/column", $loop("loop-litsmoke01", "post_types", "post", "Post"), [$text("<p>Schleife</p>")])),
|
||
$row($b("divi/column", $width, [$text("<p>Ausserhalb</p>")])),
|
||
$row($b("divi/column", $loop("loop-litsmoke02", "post_taxonomies", "category", "Categories"), [$text("<p>Terme</p>")])),
|
||
]);
|
||
$id = wp_insert_post(["post_type" => "page", "post_status" => "draft", "post_title" => getenv("LIT_TITLE"),
|
||
"post_content" => wp_slash(serialize_block($b("divi/placeholder", [], [$section])))]);
|
||
update_post_meta($id, "_et_pb_use_builder", "on");
|
||
update_post_meta($id, "_et_pb_use_divi_5", "on");
|
||
echo $id;' 2>/dev/null
|
||
}
|
||
stored() { wp post get "$PAGE_ID" --field=post_content; }
|
||
# Substring tests without a pipe: with pipefail, grep -q closing the pipe early would fail the pipeline (SIGPIPE).
|
||
stored_has() { local s; s=$(stored); [[ "$s" == *"$1"* ]]; }
|
||
has_line() { grep -qx -- "$2" <<<"$1"; } # $1 = lines, $2 = exact line
|
||
has_prefix() { grep -q -- "^$2" <<<"$1"; }
|
||
tree_rev() { tool lit-divi-outline "{\"post_id\":$PAGE_ID}" | jq -r '.tree_rev'; }
|
||
|
||
# insert <parent path id> <content value>: one text module with the given content (short form or $variable string).
|
||
insert_text() {
|
||
tool lit-divi-insert "$(jq -cn --argjson p "$PAGE_ID" --arg r "$(tree_rev)" --arg parent "$1" --arg c "$2" \
|
||
'{post_id: $p, tree_rev: $r, parent: $parent, nodes: [{type: "text", content: $c}]}')"
|
||
}
|
||
# refused_with <answer> <text>: the call was refused (an error, not the dry run of anything) and the error names <text>.
|
||
refused_with() {
|
||
echo "$1" | jq -e --arg t "$2" 'has("__error") and (.__error | contains($t))' >/dev/null
|
||
}
|
||
# variable_ref <option name> [settings JSON]: the full form $variable({...})$ of a content reference.
|
||
variable_ref() {
|
||
local settings="${2:-}"; [ -n "$settings" ] || settings='{}'
|
||
jq -rn --arg n "$1" --argjson s "$settings" \
|
||
'"$variable(" + ({type: "content", value: {name: $n, settings: ({before: "", after: ""} + $s)}} | tojson) + ")$"'
|
||
}
|
||
# loop_names <dynamic-sources answer>: every option name starting with loop_ anywhere in the answer, one per line.
|
||
# ADAPT (Task 5): the jq walk finds "name" keys in any nesting; narrow it when the answer has a fixed list.
|
||
loop_names() { echo "$1" | jq -r '[.. | objects | .name? // empty | strings | select(startswith("loop_"))] | unique[]'; }
|
||
|
||
# --- 1. The loop page --------------------------------------------------------------------------------------------
|
||
PAGE_ID=$(create_loop_page)
|
||
[ -n "$PAGE_ID" ] || fail "loop page not created"
|
||
OUTLINE=$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}")
|
||
echo "$OUTLINE" | jq -e '.tree_rev | type == "string"' >/dev/null || fail "outline of the loop page: $OUTLINE"
|
||
for NODE in s0.r0.c0.m0 s0.r1.c0.m0 s0.r2.c0.m0; do
|
||
[[ "$OUTLINE" == *"$NODE"* ]] || fail "outline lacks $NODE: $OUTLINE"
|
||
done
|
||
stored_has '"enable":"on"' || fail "stored page has no enabled loop"
|
||
pass "loop page $PAGE_ID created (post loop, plain row, term loop)"
|
||
|
||
# --- 2. {{loop:post_date}} inside the post loop is accepted and stored -----------------------------------------------
|
||
# ADAPT (Task 4): the new module is the second child of the loop column, so its path id is s0.r0.c0.m1.
|
||
LOOP_NODE=s0.r0.c0.m1
|
||
DONE=$(insert_text s0.r0.c0 '{{loop:post_date}}')
|
||
[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "{{loop:post_date}} in the loop refused: $DONE"
|
||
stored_has 'loop_post_date' || fail "stored content lacks loop_post_date after the insert"
|
||
# Full form with a German date format (the select of Divi offers no German format, so "custom"): update the module.
|
||
FULL=$(variable_ref loop_post_date '{"loop_position":"","date_format":"custom","custom_date_format":"d.m.Y"}')
|
||
DONE=$(tool lit-divi-update "$(jq -cn --argjson p "$PAGE_ID" --arg r "$(tree_rev)" --arg n "$LOOP_NODE" --arg c "$FULL" \
|
||
'{post_id: $p, tree_rev: $r, changes: [{id: $n, set: {content: $c}}]}')")
|
||
[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "full-form loop_post_date in the loop refused: $DONE"
|
||
stored_has 'custom_date_format' || fail "stored content lacks the date format of the full form"
|
||
# Positive control for the security boundary below: the post meta key option belongs to a post loop.
|
||
META=$(variable_ref loop_post_meta_key_manual_custom_field \
|
||
'{"select_loop_meta_key":"loop_post_meta_key_manual_custom_field_value","loop_meta_key":"lit_smoke_key"}')
|
||
DONE=$(insert_text s0.r0.c0 "$META")
|
||
[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "post meta key option in a post loop refused: $DONE"
|
||
pass "loop_post_date (short and full form) and the post meta key option accepted in the post loop"
|
||
|
||
# --- 3. The same content OUTSIDE any loop is refused ---------------------------------------------------------------
|
||
BEFORE=$(stored | md5sum)
|
||
for VALUE in '{{loop:post_date}}' "$FULL"; do
|
||
BAD=$(insert_text s0.r1.c0 "$VALUE")
|
||
refused_with "$BAD" 'braucht eine Divi-Schleife' || fail "loop content outside a loop not refused with the loop message: $BAD"
|
||
refused_with "$BAD" 'loop_post_date' || fail "refusal does not name the option: $BAD"
|
||
done
|
||
# Updating the static module outside the loop is refused as well, and so is a module whose parent is the section row.
|
||
BAD=$(tool lit-divi-update "$(jq -cn --argjson p "$PAGE_ID" --arg r "$(tree_rev)" --arg c "$FULL" \
|
||
'{post_id: $p, tree_rev: $r, changes: [{id: "s0.r1.c0.m0", set: {content: $c}}]}')")
|
||
refused_with "$BAD" 'braucht eine Divi-Schleife' || fail "update outside a loop not refused: $BAD"
|
||
[ "$(stored | md5sum)" = "$BEFORE" ] || fail "a refused call changed the stored page"
|
||
pass "loop content outside a loop refused (insert short and full form, update), page unchanged"
|
||
|
||
# --- 4. A term loop refuses post-kind options (security boundary: meta keys read foreign objects) -----------------------
|
||
BEFORE=$(stored | md5sum)
|
||
POST_META=$(variable_ref loop_post_meta_key_manual_custom_field \
|
||
'{"select_loop_meta_key":"loop_post_meta_key_manual_custom_field_value","loop_meta_key":"lit_smoke_key"}')
|
||
USER_META=$(variable_ref loop_user_meta_key_manual_custom_field \
|
||
'{"select_loop_meta_key":"loop_user_meta_key_manual_custom_field_value","loop_meta_key":"lit_smoke_key"}')
|
||
for VALUE in '{{loop:post_date}}' "$FULL" "$POST_META" "$USER_META"; do
|
||
BAD=$(insert_text s0.r2.c0 "$VALUE")
|
||
refused_with "$BAD" 'passt nicht zur Schleife' || fail "post/user-kind option in a term loop not refused: $BAD"
|
||
refused_with "$BAD" 'Terme' || fail "refusal does not name the loop kind (Terme): $BAD"
|
||
done
|
||
[ "$(stored | md5sum)" = "$BEFORE" ] || fail "a refused call changed the stored page"
|
||
# The reverse direction: a user-kind option in the POST loop.
|
||
BAD=$(insert_text s0.r0.c0 "$USER_META")
|
||
refused_with "$BAD" 'passt nicht zur Schleife' || fail "user meta key option in a post loop not refused: $BAD"
|
||
# Positive control: an option of the term kind is accepted in the term loop (ADAPT (Task 3): short name term_name).
|
||
DONE=$(insert_text s0.r2.c0 '{{loop:term_name}}')
|
||
[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "{{loop:term_name}} in the term loop refused: $DONE"
|
||
stored_has 'loop_term_name' || fail "stored content lacks loop_term_name"
|
||
pass "post/user-kind options (incl. meta keys) refused in the wrong loop kind, term option accepted in the term loop"
|
||
|
||
# --- 5. lit/divi-dynamic-sources per node -------------------------------------------------------------------------
|
||
# ADAPT (Task 5): input post_type + post_id + node; the answer lists the loop options; without a loop context it says so.
|
||
sources() { ability lit/divi-dynamic-sources "$(jq -cn --argjson p "$PAGE_ID" --arg n "$1" '{post_type: "page", post_id: $p, node: $n}')"; }
|
||
IN_LOOP=$(sources "$LOOP_NODE")
|
||
IN_LOOP_NAMES=$(loop_names "$IN_LOOP")
|
||
has_line "$IN_LOOP_NAMES" loop_post_date || fail "no loop_post_date for the module in the post loop: $IN_LOOP"
|
||
has_line "$IN_LOOP_NAMES" loop_post_meta_key_manual_custom_field || fail "no post meta key option in the post loop: $IN_LOOP"
|
||
! has_prefix "$IN_LOOP_NAMES" 'loop_term_\|^loop_user_' || fail "post loop lists term/user options: $IN_LOOP"
|
||
IN_TERM=$(sources s0.r2.c0.m0)
|
||
IN_TERM_NAMES=$(loop_names "$IN_TERM")
|
||
has_line "$IN_TERM_NAMES" loop_term_name || fail "no loop_term_name for the module in the term loop: $IN_TERM"
|
||
! has_prefix "$IN_TERM_NAMES" loop_post_ || fail "term loop lists post options: $IN_TERM"
|
||
OUTSIDE=$(sources s0.r1.c0.m0)
|
||
[ -z "$(loop_names "$OUTSIDE")" ] || fail "loop options listed for a node outside any loop: $OUTSIDE"
|
||
[[ "$OUTSIDE" == *'Schleifen-Kontext'* ]] || fail "no hint about the missing loop context: $OUTSIDE"
|
||
pass "dynamic sources: post options in the post loop, term options in the term loop, none outside"
|
||
|
||
# --- 7. Frontend render (before the move: afterwards the module is outside the loop and renders empty) ------------------
|
||
# At least one published post must exist; otherwise one temporary post is created (and deleted by exact ID).
|
||
if [ "$(wp post list --post_type=post --post_status=publish --format=count)" = "0" ]; then
|
||
TEMP_POST_ID=$(wp post create --post_type=post --post_status=publish --post_title="LIT Smoke $RUN Beitrag" --porcelain)
|
||
fi
|
||
HTML=$(LIT_ID="$PAGE_ID" wp eval 'global $post; $post = get_post((int) getenv("LIT_ID")); setup_postdata($post); echo apply_filters("the_content", $post->post_content);' 2>&1)
|
||
[ -n "$HTML" ] || fail "the page rendered nothing"
|
||
if grep -Eqi 'Fatal error|Parse error|Warning:|Notice:|Deprecated:|Stack trace|Uncaught' <<<"$HTML"; then
|
||
fail "PHP error text in the rendered page: $(grep -Eio '.{0,80}(Fatal error|Parse error|Warning:|Notice:|Deprecated:|Stack trace|Uncaught).{0,120}' <<<"$HTML" | head -3)"
|
||
fi
|
||
[[ "$HTML" != *'$variable('* ]] || fail "an unresolved \$variable reference is in the rendered page"
|
||
[[ "$HTML" != *loop_post_date* ]] || fail "the option name loop_post_date is in the rendered page"
|
||
# The module renders the date of a published post in d.m.Y (the loop shows up to 3 of them, newest first).
|
||
DATES=$(wp eval 'foreach (get_posts(["post_type" => "post", "post_status" => "publish", "numberposts" => -1, "fields" => "ids"]) as $i) { echo get_the_date("d.m.Y", $i), "\n"; }')
|
||
[ -n "$DATES" ] || fail "no published post to compare against"
|
||
grep -qF -f <(echo "$DATES") <<<"$HTML" \
|
||
|| fail "the rendered page shows no formatted date (d.m.Y) of a published post; dates: $(echo "$DATES" | head -3 | tr '\n' ' ')"
|
||
pass "frontend render shows a formatted post date, no PHP error text"
|
||
|
||
# --- 6. Moving loop content out of the loop: warning, not refusal ----------------------------------------------------
|
||
# ADAPT (Task 4): the warning is a string in .warnings that names the loop content ("Schleife").
|
||
MOVED=$(tool lit-divi-move "$(jq -cn --argjson p "$PAGE_ID" --arg r "$(tree_rev)" --arg n "$LOOP_NODE" \
|
||
'{post_id: $p, tree_rev: $r, id: $n, parent: "s0.r1.c0"}')")
|
||
[ "$(echo "$MOVED" | jq -r '.ok')" = "true" ] || fail "moving loop content out of the loop was refused: $MOVED"
|
||
echo "$MOVED" | jq -e '(.warnings // []) | map(select(test("Schleife"))) | length > 0' >/dev/null \
|
||
|| fail "move out of the loop carries no warning about loop content: $MOVED"
|
||
stored_has 'loop_post_date' || fail "the moved module lost its loop content"
|
||
pass "move out of the loop answers ok with a loop warning"
|
||
|
||
# --- 8. Everything restored, no leftover pages ----------------------------------------------------------------------
|
||
GONE_PAGE=$PAGE_ID
|
||
GONE_POST=$TEMP_POST_ID
|
||
delete_content
|
||
PAGE_ID=""
|
||
TEMP_POST_ID=""
|
||
[ -z "$(wp post list --post_type=any --post_status=any --post__in="$GONE_PAGE" --format=ids)" ] || fail "page $GONE_PAGE still exists"
|
||
[ -z "$GONE_POST" ] || [ -z "$(wp post list --post_type=any --post_status=any --post__in="$GONE_POST" --format=ids)" ] \
|
||
|| fail "temporary post $GONE_POST still exists"
|
||
[ -z "$(wp post list --post_type=any --post_status=any --s="LIT Smoke $RUN" --format=ids)" ] || fail "a post of this run is left"
|
||
[ "$(capture et_divi)" = "$ORIGINAL_THEME" ] || fail "et_divi differs from its state before the run"
|
||
[ "$(capture et_divi_global_variables)" = "$ORIGINAL_VARIABLES" ] || fail "et_divi_global_variables differs from its state before the run"
|
||
pass "page and temporary post deleted, options unchanged"
|
||
|
||
finish
|
||
````
|
||
|
||
- [ ] **Step 2: Syntax prüfen**
|
||
|
||
Run: `bash -n tests/Integration/divi-loop-smoke.sh && (command -v shellcheck >/dev/null && shellcheck -x -e SC2016 tests/Integration/divi-loop-smoke.sh || true)`
|
||
Expected: keine Syntaxfehler (`SC2016` – `$` in einfachen Anführungszeichen – ist hier gewollt, das PHP/jq läuft in `wp eval` bzw. `jq`).
|
||
|
||
- [ ] **Step 3: Zustand notieren, Plugins deaktivieren, Skript in DDEV ausführen und korrigieren**
|
||
|
||
Vorbereitung: `cd /home/janniclas/Projekte/WordPress && ddev exec wp plugin list` notieren (Stand beim Schreiben dieses Plans: `postlos`, `asynclistry`, `breva-map` und `WP-LIT-MCP` aktiv), dann `ddev exec wp plugin deactivate postlos asynclistry` (nur falls aktiv). Vor dem Lauf die Vergleichswerte festhalten:
|
||
|
||
```bash
|
||
cd /home/janniclas/Projekte/WordPress
|
||
ddev exec wp eval 'global $wpdb; echo md5(serialize(get_option("et_divi"))), " ", md5(serialize(get_option("et_divi_global_variables", "absent"))), " log=", $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}lit_mcp_log"), "\n";'
|
||
ddev exec wp post list --post_type=any --post_status=any --format=count
|
||
```
|
||
|
||
Run: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-loop-smoke.sh`
|
||
Expected: `ALL CHECKS PASSED`. Schlägt eine Prüfung fehl, die Antwort lesen (`fail` druckt sie): Ein Skriptfehler (Feldname, Pfad-ID, Quoting, `jq`-Pfad, Annahme aus der Tabelle oben) wird im Skript berichtigt – die Prüfung bleibt. Ein Produktfehler (z. B. ein Tool nimmt `loop_post_date` außerhalb einer Schleife an, lässt `loop_post_meta_key_manual_custom_field` in der Term-Schleife durch oder rendert eine Fehlermeldung) wird NICHT im Skript versteckt, sondern mit Diagnose (Antwort, erwartet/ist, Verdacht mit Datei:Zeile) berichtet (DONE_WITH_CONCERNS). Besonders prüfen, falls Schritt 7 scheitert: (a) lief die Schleife überhaupt (kein `loop`-Attribut im gespeicherten Inhalt nach Divis Normalisierung? `wp post get <ID> --field=post_content`), (b) das Rendern braucht Divis Hook während `the_content` – bei Bedarf zusätzlich über `LIT\MCP\Integrations\WordPress\Render\PageRenderer` rendern (wie `divi-write-smoke.sh`, `probe_findings`) und beide Ausgaben vergleichen, (c) der Beitragstyp `post` hat veröffentlichte Beiträge (das Skript legt sonst einen an).
|
||
|
||
Danach prüfen, dass nichts übrig ist: dieselbe `wp eval`-Zeile wie vorher – MD5 von `et_divi` und `et_divi_global_variables` und die Zahl der Protokollzeilen (`log=`) identisch zu vorher; `ddev exec wp post list --post_type=any --post_status=any --s="LIT Smoke" --format=ids` leer; `ddev exec wp post list --post_type=any --post_status=any --format=count` gleich dem Wert von vorher.
|
||
|
||
Zusätzlich müssen die übrigen Smokes weiter bestehen, je mit `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/<skript>.sh` und `ALL CHECKS PASSED`: `divi-colors-variables-smoke.sh`, `divi-presets-smoke.sh`, `divi-presets-assign-smoke.sh`, `divi-release-smoke.sh` (unverändert sechzehn Fähigkeiten, 4e fügt keine hinzu), `divi-theme-fonts-smoke.sh` (braucht Netz im Container). Und `ddev exec wp --user=<Admin> lit-mcp divi-check` mit Exit-Code 0 (`echo $?`).
|
||
|
||
Zum Schluss den notierten Plugin-Zustand wiederherstellen (`ddev exec wp plugin activate postlos asynclistry`, falls sie vorher aktiv waren) und `ddev exec wp plugin list` mit der Notiz vergleichen.
|
||
|
||
- [ ] **Step 4: `composer test && composer lint`**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: grün und sauber (das Skript ändert keinen PHP-Code).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add tests/Integration/divi-loop-smoke.sh
|
||
git commit -m "test: Smoke für Schleifen-Inhalte gegen Divi 5.15.1" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
|
||
### Task 7: Guides (Etappe-4-Werkzeuge, Schleifen-Inhalte)
|
||
|
||
**Files:**
|
||
- Modify: `docs/guides/divi-design-system.md`, `docs/guides/divi-placeholders.md`, `docs/guides/divi-shortform.md`, `docs/guides/workflows.md`, `docs/guides/overview.md`
|
||
- Modify: `tests/Unit/Guides/ToolNamingTest.php`
|
||
- Create: `tests/Unit/Guides/LoopGuideTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: die fertigen Tools (Beschreibungen und Eingaben in `src/Integrations/Divi/Design/*Ability.php`, Task 2–5: Meldungen X5, `{{loop:…}}`, `lit/divi-dynamic-sources` mit `post_id` + `node`, Move-Warnung); Ruling X8 (keine Prompts, keine neue Guide-Datei, Ablauf „Corporate Design übernehmen“ in `workflows.md`) und X7 (Grenzen als Benutzerhinweise).
|
||
- Produces: aktualisierte Guides; zwei Tests, die die Guides an den Code binden (`testOverviewNamesEveryIndirectTool`: jedes indirekte Tool steht in `overview.md`; `LoopGuideTest`: Meldungsphrasen stehen im Code und im Guide, jedes `{{loop:<name>}}`-Beispiel ist eine echte Divi-Option).
|
||
|
||
**Bestehende Tests, an die der Text gebunden ist** (nicht umbauen): `ToolNamingTest` (indirekte Tools nie als `lit-<name>`, direkte nie als `lit/<name>`; Fehlercodes in Backticks ohne `lit_`; „Weitere Themen“ in `overview.md` = genau die vorhandenen Guide-Dateien – es kommt **keine** Datei hinzu, die Liste bleibt unverändert; „Direkt sichtbar“ = genau die direkten Tools – alle Design-Tools sind indirekt, die Liste bleibt), `ModuleCatalogTest` (Tabelle „Kurzform-Felder“ in `divi-shortform.md` bleibt unberührt), `FeatureTextsTest` (keine Formulierungen wie „Schreiben ist gesperrt“).
|
||
|
||
**Veraltete Aussagen, die dieser Task entfernt** (Recherche 4e A.3): `divi-design-system.md` Regel 3 („im Divi-Builder anlegen“, Farben/Variablen/Theme-Optionen/Schriften lassen sich seit 4c/4d per Tool anlegen), der Snapshot-Punkt „vor jeder Änderung … je Bereich die letzten 20“ ohne Bereiche und ohne die Ausnahme Schriften, der Rechte-Absatz nur für Presets; `overview.md`: der Absatz „Über `mcp-adapter-execute-ability`“ ohne die zwölf indirekten Design-Tools, Grundregel 4 ohne `design_rev`, Fehlercodes ohne `conflict`/`in_use`/`forbidden`/`snapshot_*`/`font_*`; `workflows.md`: „Design auf Seiten ausrollen“ als einziger Designablauf. Es gibt in den Guides keine Aussage „folgt in Etappe …“ (geprüft per `grep -nE "folgt|noch nicht|4c|4d|Etappe" docs/guides/*.md`: keine Treffer).
|
||
|
||
**Geprüfte Fakten** (gegen den Code, nicht gegen die Pläne): Eingaben und Beschreibungen von `lit/divi-colors-set` (`DiviColorsSetAbility.php`, `ColorValue.php`: Hex/`rgb()`/`hsl()`/Farbname/`{{color:<Name>}}` + `relative` mit den Bereichen −360…360 bzw. −100…100, Hauptfarben nur `color`), `lit/divi-variables-set` (`DiviVariablesSetAbility.php`, `VariableValue::TYPES`: `numbers`, `strings`, `images`, `links`, `fonts`; je Aufruf höchstens 50 Einträge, aktive Variablen brauchen einen Wert), `lit/divi-theme-options-get/-update` (`ThemeOptionSpec::KEYS` und Bereiche), `lit/divi-fonts-upload` (`FontNameRules`: nur A–Z a–z 0–9 Leerzeichen `_` `-`, 1–100 Zeichen, Namen von Google-/Web-Safe-/hochgeladenen Schriften = `conflict`; Fehlercodes `font_fetch_failed`, `font_invalid`; kein Snapshot), Snapshots/Restore (`SnapshotStore::LIMIT` 20, `MAX_BYTES` 2 000 000 → `snapshot_too_large`, `snapshot_not_found`; Semantik je Bereich laut Beschreibung von `lit/divi-design-restore`), Rechte (`DesignRights`: Presets/Farben = `edit_theme_options` + Visual Builder, Variablen zusätzlich Variablen-Manager, Theme-Optionen = `edit_theme_options` + Theme-Anpassung, Schriften = `upload_files` + `edit_theme_options` + „Eigene Schriften verwalten“; Snapshots listen = `edit_theme_options`). **Nicht** übernommen aus der Recherche, weil der Code es nicht hergibt: ein Fehlercode `ambiguous_name` (mehrdeutige Namen sind `validation_failed` mit den IDs in `fields`).
|
||
|
||
Die Texte unten wurden in einer Kopie des Repos (`…/scratchpad/work-4e-C`) angewendet; dort laufen die Guide-Tests grün (`ToolNamingTest`, `FeatureTextsTest`, `ModuleCatalogTest`, `GuideRepositoryTest`, `InstructionsBuilderTest`), `testOverviewNamesEveryIndirectTool` ist ohne die Überschrift-Änderungen rot (zwölf fehlende Tools) und mit ihnen grün, `LoopGuideTest::testRefusalPhrasesAreTheOnesTheCodeSays` ist dort erwartungsgemäß rot, solange Task 2–5 fehlen (Code sagt die Phrasen noch nicht), `phpcs` ist sauber.
|
||
|
||
- [ ] **Step 1: Tests zuerst (RED)**
|
||
|
||
In `tests/Unit/Guides/ToolNamingTest.php` vor `testOverviewListsExactlyTheDirectTools` einfügen:
|
||
|
||
|
||
````php
|
||
/** Every indirect tool is named in the overview (the only place a client learns which `lit/` tools exist). */
|
||
public function testOverviewNamesEveryIndirectTool(): void
|
||
{
|
||
$overview = (string) file_get_contents(dirname(__DIR__, 3) . '/docs/guides/overview.md');
|
||
preg_match_all('#`lit/([a-z]+(?:-[a-z]+)*)`#', $overview, $named);
|
||
|
||
$indirect = array_keys(array_filter(self::abilities(), static fn (bool $direct): bool => ! $direct));
|
||
self::assertNotEmpty($indirect);
|
||
self::assertSame([], array_values(array_diff($indirect, $named[1])), 'indirect tools missing in overview.md');
|
||
}
|
||
|
||
````
|
||
|
||
Neue Datei `tests/Unit/Guides/LoopGuideTest.php`:
|
||
|
||
|
||
````php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Guides;
|
||
|
||
use PHPUnit\Framework\TestCase;
|
||
use RecursiveDirectoryIterator;
|
||
use RecursiveIteratorIterator;
|
||
|
||
/**
|
||
* The loop part of the guides (Etappe 4e) says what the tools say: the refusal phrases come from the code, and every
|
||
* {{loop:<name>}} example names an option Divi 5.15.1 really registers (research 4e, table of the loop_* options).
|
||
*/
|
||
final class LoopGuideTest extends TestCase
|
||
{
|
||
/** Divi 5.15.1 option keys (without WooCommerce and ACF repeater options). */
|
||
private const DIVI_LOOP_OPTIONS = [
|
||
'loop_post_title', 'loop_post_excerpt', 'loop_post_date', 'loop_post_modified_date', 'loop_post_author',
|
||
'loop_post_author_bio', 'loop_post_author_profile_picture', 'loop_post_comment_count',
|
||
'loop_post_featured_image', 'loop_post_id', 'loop_post_link', 'loop_post_terms',
|
||
'loop_term_name', 'loop_term_description', 'loop_term_count', 'loop_term_permalink', 'loop_term_taxonomy',
|
||
'loop_term_featured_image', 'loop_user_name', 'loop_user_username', 'loop_user_email', 'loop_user_avatar',
|
||
'loop_user_description', 'loop_user_url', 'loop_menu_text', 'loop_menu_link', 'loop_menu_menu_order',
|
||
'loop_menu_attr_title', 'loop_menu_classes', 'loop_menu_xfn', 'loop_menu_description',
|
||
];
|
||
|
||
private const PHRASES = ['braucht eine Divi-Schleife', 'passt nicht zur Schleife', 'kein Schleifen-Kontext'];
|
||
|
||
private static function root(): string
|
||
{
|
||
return dirname(__DIR__, 3);
|
||
}
|
||
|
||
/** @return string Content of all guides. */
|
||
private static function guides(): string
|
||
{
|
||
$text = '';
|
||
foreach (glob(self::root() . '/docs/guides/*.md') ?: [] as $file) {
|
||
$text .= (string) file_get_contents($file) . "\n";
|
||
}
|
||
|
||
return $text;
|
||
}
|
||
|
||
public function testGuidesExplainTheLoopRefusals(): void
|
||
{
|
||
$guides = self::guides();
|
||
foreach (self::PHRASES as $phrase) {
|
||
self::assertStringContainsString($phrase, $guides, 'guides lack "' . $phrase . '"');
|
||
}
|
||
}
|
||
|
||
public function testRefusalPhrasesAreTheOnesTheCodeSays(): void
|
||
{
|
||
$code = '';
|
||
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(self::root() . '/src'));
|
||
foreach ($files as $file) {
|
||
if (str_ends_with((string) $file, '.php')) {
|
||
$code .= (string) file_get_contents((string) $file);
|
||
}
|
||
}
|
||
|
||
foreach (self::PHRASES as $phrase) {
|
||
self::assertNotFalse(mb_stripos($code, $phrase), 'no source says "' . $phrase . '"');
|
||
}
|
||
}
|
||
|
||
public function testLoopShortFormExamplesNameRealDiviOptions(): void
|
||
{
|
||
preg_match_all('/\{\{loop:([a-z0-9_]+)\}\}/', self::guides(), $examples);
|
||
self::assertNotEmpty($examples[1], 'the guides show no {{loop:…}} example');
|
||
foreach (array_unique($examples[1]) as $name) {
|
||
self::assertContains('loop_' . $name, self::DIVI_LOOP_OPTIONS, '{{loop:' . $name . '}} is no Divi option');
|
||
}
|
||
}
|
||
}
|
||
````
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter "ToolNamingTest|LoopGuideTest"`
|
||
Expected: FAIL – `testOverviewNamesEveryIndirectTool` nennt zwölf fehlende Tools (`divi-colors-set`, `divi-design-detach`, `divi-design-restore`, `divi-design-snapshots`, `divi-fonts-upload`, `divi-preset-get`, `divi-presets-assign`, `divi-presets-delete`, `divi-presets-set`, `divi-theme-options-get`, `divi-theme-options-update`, `divi-variables-set`); `LoopGuideTest::testGuidesExplainTheLoopRefusals` und `testLoopShortFormExamplesNameRealDiviOptions` scheitern an den noch fehlenden Guide-Texten. (`testRefusalPhrasesAreTheOnesTheCodeSays` ist grün, wenn Task 2 und 5 die Meldungen „braucht eine Divi-Schleife“, „passt nicht zur Schleife“ und „kein Schleifen-Kontext“ im Code haben; sonst gehört der Fehler zu diesen Tasks, nicht zu den Guides – dann den Wortlaut im Guide an den Code angleichen, nie umgekehrt.)
|
||
|
||
- [ ] **Step 2: Guide-Texte anwenden**
|
||
|
||
Jede Änderung ist ein `Edit` mit genau diesem Anker (`old_string`, kommt in der Datei genau einmal vor) und dem Ersatz (`new_string`). Reihenfolge je Datei wie unten.
|
||
|
||
|
||
#### `docs/guides/divi-design-system.md`
|
||
|
||
**Änderung 1** (Anker, alt):
|
||
|
||
````markdown
|
||
- **Größen** (`sizes`): Schriftgrößen, Inhaltsbreite und Spaltenabstand aus den Theme-Optionen.
|
||
````
|
||
|
||
Neu:
|
||
|
||
````markdown
|
||
- **Größen** (`sizes`): Schriftgrößen, Inhaltsbreite und Spaltenabstand aus den Theme-Optionen. Alle 20
|
||
Theme-Optionen mit Standardwerten liefert `lit/divi-theme-options-get` (siehe „Theme-Optionen“).
|
||
````
|
||
|
||
**Änderung 2** (Anker, alt):
|
||
|
||
````markdown
|
||
3. Gibt es für einen Zweck noch keine Farbe, Variable oder kein Preset, dem Nutzer vorschlagen, es im
|
||
Divi-Builder anzulegen, statt feste Werte zu verteilen.
|
||
|
||
## Presets anlegen, ändern, löschen
|
||
````
|
||
|
||
Neu:
|
||
|
||
````markdown
|
||
3. Gibt es für einen Zweck noch keine Farbe, Variable oder kein Preset, dem Nutzer vorschlagen, es anzulegen
|
||
(`lit/divi-colors-set`, `lit/divi-variables-set`, `lit/divi-presets-set`), statt feste Werte zu verteilen –
|
||
zuerst Probelauf, nach Rückfrage `confirm: true`.
|
||
4. Das Designsystem wirkt auf alle Seiten der Website. Werte, Schriften und Abstände nennt der Nutzer; nichts
|
||
erfinden und nie ohne seine Rückfrage bestätigen.
|
||
5. Alle Schritte eines Designwechsels (Schriften, Farben, Variablen, Theme-Optionen, Presets) beschreibt
|
||
`workflows`, Abschnitt „Corporate Design übernehmen“.
|
||
|
||
## Ablauf jeder Änderung
|
||
|
||
Presets, Farben, Variablen und Theme-Optionen (`lit/divi-presets-set`, `lit/divi-presets-delete`,
|
||
`lit/divi-colors-set`, `lit/divi-variables-set`, `lit/divi-theme-options-update`) sowie
|
||
`lit/divi-design-restore` und `lit/divi-design-detach` laufen gleich, alle über `mcp-adapter-execute-ability`:
|
||
|
||
1. Aufruf ohne `confirm` = Probelauf. Er schreibt nichts und antwortet mit `confirm_required`: `diff` (was sich
|
||
ändert), je nach Werkzeug die betroffenen Seiten und `warnings`, dazu die `design_rev`.
|
||
2. Dem Nutzer die Änderung zeigen und **erst nach seiner Antwort** denselben Aufruf mit `confirm: true` und der
|
||
`design_rev` senden (wo der Probelauf eine nennt).
|
||
3. Vor dem Schreiben legt LIT MCP einen Snapshot an; die Antwort nennt seine `snapshot_id` (siehe „Snapshots und
|
||
Wiederherstellen“).
|
||
4. Hat sich der Bestand inzwischen geändert (z. B. im Divi-Builder), kommt `rev_mismatch` – neu prüfen.
|
||
`confirm: true` ohne `design_rev` ergibt `validation_failed`; läuft gerade eine andere Änderung am selben
|
||
Bereich, kommt `conflict` – kurz warten und den Probelauf wiederholen.
|
||
|
||
Eigene Schriften hochladen (`lit/divi-fonts-upload`) hat einen eigenen Probelauf und keinen Snapshot, siehe
|
||
„Eigene Schriften hochladen“.
|
||
|
||
## Presets anlegen, ändern, löschen
|
||
````
|
||
|
||
**Änderung 3** (Anker, alt):
|
||
|
||
````markdown
|
||
- `lit/divi-design-snapshots` / `lit/divi-design-restore` – vor jeder Änderung legt LIT MCP einen Snapshot an
|
||
(je Bereich die letzten 20); wiederherstellen mit `snapshot_id`.
|
||
|
||
Ablauf jeder Änderung: Aufruf ohne `confirm` = Probelauf (`confirm_required` mit `diff`, betroffenen Seiten und
|
||
`design_rev`), dem Nutzer die Änderung zeigen, dann derselbe Aufruf mit `confirm: true` und `design_rev`. Hat sich
|
||
der Bestand inzwischen geändert (z. B. im Divi-Builder), kommt `rev_mismatch` – neu prüfen. `confirm: true` ohne
|
||
`design_rev` ergibt `validation_failed`; läuft gerade eine andere Änderung, kommt `conflict` – kurz warten und den
|
||
Probelauf wiederholen.
|
||
|
||
Rechte: Presets ändern verlangt das Recht, das Design anzupassen, und in Divis Rollen-Editor den Visual Builder.
|
||
````
|
||
|
||
Neu:
|
||
|
||
````markdown
|
||
- `lit/divi-design-snapshots` / `lit/divi-design-restore` – Snapshots listen und wiederherstellen (siehe
|
||
„Snapshots und Wiederherstellen“).
|
||
|
||
Der Ablauf (Probelauf, `confirm: true`, `design_rev`) steht oben unter „Ablauf jeder Änderung“.
|
||
|
||
Rechte: Presets ändern verlangt das Recht, das Design anzupassen, und in Divis Rollen-Editor den Visual Builder.
|
||
````
|
||
|
||
**Änderung 4** (Anker, alt):
|
||
|
||
````markdown
|
||
- Danach `lit/divi-presets-delete` aufrufen (Probelauf, `confirm`, `design_rev`); es meldet kein `in_use` mehr,
|
||
solange niemand das Preset neu zugewiesen hat.
|
||
````
|
||
|
||
Neu:
|
||
|
||
````markdown
|
||
- Danach `lit/divi-presets-delete` aufrufen (Probelauf, `confirm`, `design_rev`); es meldet kein `in_use` mehr,
|
||
solange niemand das Preset neu zugewiesen hat.
|
||
|
||
## Farben und Variablen anlegen und ändern
|
||
|
||
Über `mcp-adapter-execute-ability`, Ablauf wie oben, bis zu 50 Einträge je Aufruf in `items`. Farben und
|
||
Variablen werden nie gelöscht, nur deaktiviert bzw. archiviert. Angelegte Farben und Variablen lassen sich sofort
|
||
als `{{color:<Name>}}` und `{{var:<Name>}}` verwenden (`divi-placeholders`). Fehlerhafte Angaben (unbekannte ID,
|
||
vergebener oder mehrdeutiger Name – die Meldung nennt dann die IDs –, ungültiger Wert) sammelt das Werkzeug unter
|
||
`fields` als `validation_failed`; nichts wird gespeichert.
|
||
|
||
**`lit/divi-colors-set`** – je Eintrag:
|
||
|
||
- `id` nennt eine vorhandene Farbe (ID `gcid-…`, Name, Alias `primary`, `secondary`, `heading`, `body`, `link` oder
|
||
Optionsname wie `accent_color`); ohne `id` entsteht aus `label` (Name) und `color` eine neue Farbe.
|
||
- `color`: Hex (`#rgb`, `#rrggbb`, auch mit Alpha), `rgb()`/`rgba()`/`hsl()`/`hsla()`, ein Farbname (z. B. `red`)
|
||
oder der Platzhalter `{{color:<Name>}}` zusammen mit `relative`. So entstehen abgeleitete Farben, z. B.
|
||
`{"label": "Primär abgestuft", "color": "{{color:primary}}", "relative": {"lightness": 20}}`; `relative` nimmt
|
||
`hue` (−360 bis 360), `saturation`, `lightness` und `opacity` (je −100 bis 100).
|
||
- `label` benennt um, `status: inactive` deaktiviert die Farbe, `active` holt sie zurück.
|
||
- Die fünf Hauptfarben (`primary`, `secondary`, `heading`, `body`, `link`) ändern nur `color`, und nur mit einem
|
||
festen Wert (kein Platzhalter, kein `label`, `folder` oder `status`).
|
||
|
||
**`lit/divi-variables-set`** – je Eintrag:
|
||
|
||
- `id` nennt eine vorhandene Variable (ID `gvid-…` oder Name); ohne `id` entsteht aus `type` (`numbers`,
|
||
`strings`, `images`, `links` oder `fonts`), `label` und `value` eine neue. Der Typ einer Variable bleibt.
|
||
- `value` wird je Typ geprüft (Zahl, Text, Bild- oder Link-Adresse; Schrift-Variablen nur mit einem Schriftnamen,
|
||
den Divi kennt). Aktive Variablen brauchen einen Wert.
|
||
- `label` benennt um, `status: archived` archiviert, `active` holt die Variable zurück.
|
||
- Vor dem Archivieren die Verweise ersetzen: `lit/divi-design-detach` mit `variable` setzt auf Seiten und in
|
||
Presets den aktuellen Wert ein.
|
||
|
||
Der Probelauf beider Werkzeuge nennt die betroffenen Seiten und `warnings`. Die Rechte stehen unter „Rechte im
|
||
Überblick“.
|
||
|
||
## Theme-Optionen: Schriften, Größen und Layout
|
||
|
||
`lit/divi-theme-options-get` liefert je Theme-Option den gespeicherten Wert (`null` = nicht gesetzt), den
|
||
wirksamen Wert, den Divi-Standard, Typ und Bereich bzw. Auswahl und die `design_rev`; `keys` grenzt auf einzelne
|
||
Optionen ein. `lit/divi-theme-options-update` ändert sie mit `set` (`{"Schlüssel": Wert}`, Probelauf und
|
||
`confirm` wie oben); `null` setzt eine Option auf den Divi-Standard zurück. Die 20 Schlüssel:
|
||
|
||
- Schriften: `heading_font`, `body_font`, `primary_nav_font`, `secondary_nav_font` – der exakte Name einer
|
||
Schrift, die Divi kennt (Google-Schrift, Web-Safe-Schrift oder hochgeladene Schrift); bei unbekannten Namen
|
||
antwortet das Werkzeug mit `validation_failed` und bis zu fünf Vorschlägen. Stärken: `heading_font_weight`,
|
||
`body_font_weight` (100–900).
|
||
- Größen: `body_font_size` (10–32), `body_header_size` (22–72) und je eine Tablet- und Smartphone-Größe
|
||
(`tablet_body_font_size`, `phone_body_font_size`, `tablet_header_font_size`, `phone_header_font_size`); sie
|
||
erben vom nächstgrößeren Wert, solange sie nicht gesetzt sind.
|
||
- Text: `body_font_height`, `body_header_height` (Zeilenhöhe, 0,8–3), `body_header_spacing` (Buchstabenabstand,
|
||
−2 bis 10), `body_header_style` (`italic`, `uppercase` oder `underline`; Divi kennt hier nur einen Wert).
|
||
- Layout: `content_width` (960–1920), `gutter_width` (1–4), `section_padding`, `row_padding` (je 0–10).
|
||
|
||
Die Hauptfarben gehören nicht hierher, sondern in `lit/divi-colors-set`. Theme-Optionen wirken auf die gesamte
|
||
Website: den Nutzer ausdrücklich fragen. Jede Änderung legt einen Snapshot an.
|
||
|
||
## Eigene Schriften hochladen
|
||
|
||
`lit/divi-fonts-upload` lädt eine Schrift von öffentlichen Adressen in Divis hochgeladene Schriften:
|
||
|
||
- `files`: je Format eine Datei (`woff2`, `woff`, `ttf`, `otf`) als öffentliche http(s)-Adresse, höchstens 5 MB je
|
||
Datei; `name`: 1–100 Zeichen aus A–Z, a–z, 0–9, Leerzeichen, `_` und `-` (keine Umlaute), kein Name einer
|
||
Google-, Web-Safe- oder schon hochgeladenen Schrift (auch nicht in anderer Schreibweise); `weights`: `"all"`
|
||
oder eine Liste aus 100 bis 900; `generic_family`: `serif`, `sans-serif` (Standard), `monospace`, `cursive` oder
|
||
`fantasy`.
|
||
- Ablauf: Der Probelauf (ohne `confirm`) lädt die Dateien wirklich, prüft Inhalt und Format und nennt je Datei
|
||
Format, Größe und SHA-256 – installiert aber nichts. Dem Nutzer zeigen, nach seiner Antwort denselben Aufruf mit
|
||
`confirm: true`; er lädt die Dateien erneut.
|
||
- Fehler: `font_fetch_failed` (eine Adresse ist nicht erreichbar), `font_invalid` (die Datei passt nicht zum
|
||
Format, etwa ein Bild als `woff2`), `conflict` (der Name ist vergeben), `validation_failed` (Name oder Angaben
|
||
ungültig). Nichts wird gespeichert.
|
||
- Es gibt **keinen Snapshot** und keine Rücknahme per Werkzeug: Eine hochgeladene Schrift entfernt nur der
|
||
Nutzer im Divi-Builder.
|
||
- Datenschutz: Hochgeladene Schriften liefert der eigene Server aus, nicht Google. Die Lizenz der Schrift klärt der
|
||
Nutzer.
|
||
- Danach ist die Schrift ein gültiger Name für `lit/divi-theme-options-update` (z. B. `body_font`) und für
|
||
Schrift-Variablen (`lit/divi-variables-set` mit `type: fonts`).
|
||
|
||
## Snapshots und Wiederherstellen
|
||
|
||
Vor jeder Änderung an Presets, Farben, Variablen und Theme-Optionen sichert LIT MCP den Bereich (je Bereich bleiben
|
||
die letzten 20 Snapshots); die Antwort nennt die `snapshot_id`. Wäre der Bereich zu groß für eine Sicherung (über
|
||
2 MB), antwortet das Werkzeug mit `snapshot_too_large` und schreibt nichts.
|
||
|
||
- `lit/divi-design-snapshots` mit `area` (`presets`, `colors`, `variables` oder `theme_options`; Standard
|
||
`presets`) listet die Snapshots eines Bereichs, neueste zuerst, mit Zeitpunkt, Werkzeug und Protokolleintrag.
|
||
- `lit/divi-design-restore` mit `snapshot_id`: Der Probelauf zeigt den Unterschied zum aktuellen Stand (`diff`,
|
||
`design_rev`); nach Rückfrage `confirm: true`. Vorher sichert LIT MCP den aktuellen Stand selbst – auch eine
|
||
Wiederherstellung lässt sich zurücknehmen. Ist die ID unbekannt oder verdrängt, kommt `snapshot_not_found`.
|
||
- Was ein Restore macht, hängt vom Bereich ab: **Farben**, die es im Snapshot nicht gibt, entfallen. **Variablen**,
|
||
die erst nach dem Snapshot entstanden, bleiben archiviert erhalten (Divi löscht keine). **Theme-Optionen**, die
|
||
im Snapshot nicht gesetzt waren, kehren zum Divi-Standard zurück. **Presets**: Rechte-Regel `unfiltered_html`
|
||
wie oben.
|
||
- Nicht gesichert und nicht wiederherstellbar: Seiteninhalt (dort gibt es die WordPress-Revision, die
|
||
`revision_id` der Antwort) und hochgeladene Schriften.
|
||
|
||
## Rechte im Überblick
|
||
|
||
Fehlt ein Recht, antwortet das Werkzeug mit `forbidden`; die Meldung nennt das Recht, den fehlenden Divi-Rollenwert
|
||
bitte ein Administrator unter Divi → Rollen-Editor freigeben.
|
||
|
||
- Presets und Farben: `edit_theme_options` und in Divis Rollen-Editor „Visual Builder verwenden“.
|
||
- Variablen: dasselbe und der Variablen-Manager.
|
||
- Theme-Optionen: `edit_theme_options` und die Theme-Anpassung (Divis Customizer, kein Visual Builder nötig).
|
||
- Schriften hochladen: `upload_files`, `edit_theme_options` und „Eigene Schriften verwalten“.
|
||
- Snapshots listen: `edit_theme_options`; wiederherstellen: die Rechte des jeweiligen Bereichs.
|
||
- Seiten umschreiben (`lit/divi-presets-assign`, `lit/divi-design-detach`): zusätzlich `edit_post` je Seite.
|
||
````
|
||
|
||
|
||
#### `docs/guides/divi-placeholders.md`
|
||
|
||
**Änderung 5** (Anker, alt):
|
||
|
||
````markdown
|
||
| `{{link:<ID>}}` | Link auf einen anderen Eintrag
|
||
````
|
||
|
||
Neu:
|
||
|
||
````markdown
|
||
| `{{loop:<Option>}}`, z. B. `{{loop:post_date}}` | Inhalt des aktuellen Treffers einer Divi-Schleife (Option ohne `loop_`); nur in Modulen innerhalb einer Schleife, siehe „Schleifen-Inhalte“ |
|
||
| `{{link:<ID>}}` | Link auf einen anderen Eintrag
|
||
````
|
||
|
||
**Änderung 6** (Anker, alt):
|
||
|
||
````markdown
|
||
| `{{color:<Name>}}` | eigene globale Farbe (Name wie in `lit-divi-design-system`) |
|
||
| `{{var:<Name>}}` | globale Variable (Zahl, Text, Bild, Link) |
|
||
````
|
||
|
||
Neu:
|
||
|
||
````markdown
|
||
| `{{color:<Name>}}` | eigene globale Farbe (Name wie in `lit-divi-design-system`); neue Farben legt `lit/divi-colors-set` an und sie gelten sofort als Platzhalter |
|
||
| `{{var:<Name>}}` | globale Variable (Zahl, Text, Bild, Link, Schrift); neue legt `lit/divi-variables-set` an |
|
||
````
|
||
|
||
**Änderung 7** (Anker, alt):
|
||
|
||
````markdown
|
||
| `{{font:heading}}`, `{{font:body}}` | Theme-Schrift für Überschriften bzw. Fließtext |
|
||
````
|
||
|
||
Neu:
|
||
|
||
````markdown
|
||
| `{{font:heading}}`, `{{font:body}}` | Theme-Schrift für Überschriften bzw. Fließtext (die Theme-Optionen `heading_font` und `body_font`, siehe `divi-design-system`) |
|
||
````
|
||
|
||
**Änderung 8** (Anker, alt):
|
||
|
||
````markdown
|
||
zeigt Divi den gespeicherten Wert, nicht die Beschriftung.
|
||
|
||
## `$variable`-Werte
|
||
````
|
||
|
||
Neu:
|
||
|
||
````markdown
|
||
zeigt Divi den gespeicherten Wert, nicht die Beschriftung.
|
||
|
||
## Schleifen-Inhalte
|
||
|
||
Ein Element mit eingeschalteter Divi-Schleife (Abschnitt, Zeile, Spalte, Gruppe oder Modul; im Divi-Builder unter
|
||
„Schleife“) wiederholt sich je Treffer der Abfrage. Module darin zeigen mit `loop_*`-Inhalten Daten des jeweiligen
|
||
Treffers, z. B. das Datum des Beitrags. LIT MCP legt keine Schleifen an und stellt ihre Abfrage nicht ein: das macht
|
||
der Nutzer im Divi-Builder. Danach setzt der KI-Assistent die Inhalte:
|
||
|
||
```json
|
||
{ "type": "text", "content": "{{loop:post_date}}" }
|
||
```
|
||
|
||
`{{loop:<Option>}}` nimmt den Namen der Option ohne `loop_` (`{{loop:post_date}}` schreibt `loop_post_date`) und steht
|
||
wie jeder Platzhalter allein im Wert. Einstellungen wie ein Datumsformat gibt es nur in der Vollform
|
||
(`$variable({…})$` mit `settings`, z. B. `"date_format": "custom", "custom_date_format": "d.m.Y"` – Divis Auswahl hat
|
||
kein deutsches Format). Welche Optionen ein Element anbietet, liefert `lit/divi-dynamic-sources` mit `post_id` und
|
||
`node` (siehe unten).
|
||
|
||
Die Art der Schleife bestimmt die Optionen:
|
||
|
||
| Schleife (Abfrage) | Optionen |
|
||
|---|---|
|
||
| Beiträge (Beitragstypen, aktuelle Seite) | `loop_post_*`, z. B. Titel, Auszug, Datum, Autor, Beitragsbild, Link, Kategorien oder Schlagwörter (`loop_post_terms`); Feld eines Beitrags: `loop_post_meta_key_manual_custom_field` |
|
||
| Terme (Taxonomien) | `loop_term_*`, z. B. Name, Beschreibung, Anzahl, Link; Feld: `loop_term_meta_key_manual_custom_field` |
|
||
| Benutzer (Rollen) | `loop_user_*`, z. B. Name, E-Mail, Avatar, Beschreibung; Feld: `loop_user_meta_key_manual_custom_field` |
|
||
| Menü | `loop_menu_*`, z. B. Text, Link, Beschreibung |
|
||
|
||
Regeln und Fehlermeldungen:
|
||
|
||
1. Der Schleifen-Kontext ergibt sich aus der Seite: das Modul selbst oder das nächste Element über ihm, dessen
|
||
Schleife eingeschaltet ist (Desktop-Einstellung „Schleife ein“). Bei verschachtelten Schleifen gilt die innerste
|
||
für alles darunter, egal welcher Art.
|
||
2. Ohne Schleife lehnt das Werkzeug jeden `loop_*`-Inhalt ab: `Schleifen-Inhalt "loop_post_date" braucht eine
|
||
Divi-Schleife in diesem Modul oder einem übergeordneten Element (Abschnitt, Zeile, Spalte, Gruppe).` Dann das
|
||
Modul in das Schleifen-Element setzen (`parent` = die Spalte bzw. das Element mit der Schleife) oder den
|
||
Nutzer bitten, die Schleife im Divi-Builder einzuschalten.
|
||
3. Passt die Option nicht zur Art der Schleife, steht in der Meldung: `… passt nicht zur Schleife dieses Elements
|
||
(Typ: Terme).` (Typ: Beiträge, Terme, Benutzer oder Menü). Eine Option der passenden Art wählen. Das ist eine
|
||
Sicherheitsgrenze, kein Schönheitsfehler: Divi liest die Feld-Optionen in einer Term-, Benutzer- oder
|
||
Menü-Schleife sonst bei dem Objekt mit derselben ID und gäbe fremde Daten aus. Nie umgehen.
|
||
4. Die Feld-Optionen (`loop_*_meta_key_manual_custom_field`) gibt es nur in der Vollform; für den Schlüssel gelten
|
||
dieselben Regeln wie bei `{{field:<meta_key>}}` (kein Schlüssel mit `_` am Anfang).
|
||
5. Vor dem Setzen lesen: `lit/divi-dynamic-sources` mit `post_type`, `post_id` und `node` (Pfad-ID des Moduls aus
|
||
`lit-divi-outline`) nennt zusätzlich die `loop_*`-Optionen, die zur Schleife dieses Knotens passen; liegt der
|
||
Knoten in keiner Schleife, kommt stattdessen der Hinweis „kein Schleifen-Kontext“. Ohne `post_id` und `node`
|
||
liefert das Werkzeug wie bisher keine Schleifen-Optionen.
|
||
6. `lit-divi-move` erlaubt das Verschieben von Schleifen-Inhalt aus der Schleife heraus oder in eine Schleife
|
||
anderer Art (Divi lässt es zu), antwortet aber mit einer Warnung: der Inhalt zeigt dort nichts oder falsche
|
||
Daten. Warnung dem Nutzer nennen, Modul zurückschieben oder den Inhalt neu setzen.
|
||
|
||
Grenzen (bewusst nicht gelöst; dem Nutzer sagen, statt zu umgehen):
|
||
|
||
- Die Prüfung liest die Schleife aus den Attributen des Moduls und seiner Vorfahren. Eine Schleife, die nur über ein
|
||
**Preset** eingeschaltet ist, erkennt sie nicht; dann Schleifen-Inhalte abgelehnt – der Nutzer schaltet die
|
||
Schleife am Element selbst ein.
|
||
- **Globale Module** (`[global <ID>]`) haben keinen Schleifen-Kontext; `loop_*` dort ist abgelehnt.
|
||
- `lit-content-create` und Theme-Builder-Vorlagen kennen keinen Schleifen-Kontext: erst die Seite mit Schleife
|
||
anlegen (Nutzer im Divi-Builder), dann die Inhalte mit `lit-divi-insert` setzen.
|
||
- Beim Kopieren von Schleifen-Containern samt Inhalt (`lit/divi-node-copy`, Bibliothek) bleiben Schleifen-Inhalte
|
||
für Konten ohne `unfiltered_html` abgelehnt; ein Administrator oder der Divi-Builder erledigt das.
|
||
- Schleifen über ACF-Wiederholungsfelder (`repeater_…`) unterstützt LIT MCP nicht.
|
||
- Das Attribut `module.advanced.loop` lässt sich über `attrs` schreiben; Schleifen ein- oder auszuschalten ändert den
|
||
Kontext aller Module darunter und geschieht nur auf ausdrücklichen Wunsch. Abfrage und Anzahl gehören in den
|
||
Divi-Builder.
|
||
|
||
## `$variable`-Werte
|
||
````
|
||
|
||
|
||
#### `docs/guides/divi-shortform.md`
|
||
|
||
**Änderung 9** (Anker, alt):
|
||
|
||
````markdown
|
||
Dasselbe Preset auf vielen Seiten oder Modulen setzen: `lit/divi-presets-assign` (siehe `divi-design-system`).
|
||
````
|
||
|
||
Neu:
|
||
|
||
````markdown
|
||
Dasselbe Preset auf vielen Seiten oder Modulen setzen: `lit/divi-presets-assign` (siehe `divi-design-system`).
|
||
|
||
## Schleifen-Inhalte
|
||
|
||
Module in einem Element mit eingeschalteter Divi-Schleife zeigen Daten des jeweiligen Treffers, z. B.
|
||
`{"type": "text", "content": "{{loop:post_date}}"}` mit `parent` = die Spalte (oder das Element) der Schleife.
|
||
Das gilt in `lit-divi-insert` und `lit-divi-update` (`set`); außerhalb einer Schleife und in einer Schleife anderer
|
||
Art lehnt das Werkzeug `loop_*`-Inhalte ab (Meldungen, Optionen und Grenzen: `divi-placeholders`, Abschnitt
|
||
„Schleifen-Inhalte“). `lit-divi-move` warnt, wenn ein Modul mit Schleifen-Inhalt die Schleife verlässt.
|
||
`lit-content-create` kennt keinen Schleifen-Kontext.
|
||
````
|
||
|
||
|
||
#### `docs/guides/workflows.md`
|
||
|
||
**Änderung 10** (Anker, alt):
|
||
|
||
````markdown
|
||
1. `lit-divi-design-system` lesen (Farben, Schriften, Presets) und `lit-divi-catalog` für die Module.
|
||
````
|
||
|
||
Neu:
|
||
|
||
````markdown
|
||
1. `lit-divi-design-system` lesen (Farben, Schriften, Presets) und `lit-divi-catalog` für die Module. Fehlt eine
|
||
Farbe, Variable oder ein Preset, dem Nutzer vorschlagen, sie anzulegen (`lit/divi-colors-set`,
|
||
`lit/divi-variables-set`, `lit/divi-presets-set`; Ablauf in `divi-design-system`), statt feste Werte zu setzen.
|
||
````
|
||
|
||
**Änderung 11** (Anker, alt):
|
||
|
||
````markdown
|
||
Ein Preset soll auf vielen Seiten gelten oder ein altes Preset verschwinden (Presets anlegen: `divi-design-system`):
|
||
````
|
||
|
||
Neu:
|
||
|
||
````markdown
|
||
Ein Preset soll auf vielen Seiten gelten oder ein altes Preset verschwinden (Presets anlegen: `divi-design-system`;
|
||
ein ganzes Corporate Design: „Corporate Design übernehmen“):
|
||
````
|
||
|
||
**Änderung 12** (Anker, alt):
|
||
|
||
````markdown
|
||
5. Stichprobe: `lit-divi-outline` einer betroffenen Seite (zeigt Preset und Gruppen-Presets je Modul) und
|
||
`lit-page-render-summary`; dem Nutzer die Vorschau nennen.
|
||
````
|
||
|
||
Neu:
|
||
|
||
````markdown
|
||
5. Stichprobe: `lit-divi-outline` einer betroffenen Seite (zeigt Preset und Gruppen-Presets je Modul) und
|
||
`lit-page-render-summary`; dem Nutzer die Vorschau nennen.
|
||
|
||
## Corporate Design übernehmen
|
||
|
||
Farben, Schriften und Abstände des Corporate Designs ins Designsystem von Divi übertragen. Die Werte nennt der
|
||
Nutzer (Styleguide, Logo-Farben, Schriftdateien) – nichts erfinden. Jede Änderung: Probelauf, Rückfrage,
|
||
`confirm: true` mit `design_rev` (Ablauf: `divi-design-system`, „Ablauf jeder Änderung“); alles wirkt auf die
|
||
ganze Website.
|
||
|
||
1. Ist-Stand lesen: `lit-divi-design-system` (Farben, Variablen, Presets; bei großen Websites mit `only` und
|
||
`module` eingrenzen) und `lit/divi-theme-options-get` (Schriften, Größen, Layout). Dem Nutzer sagen, was schon
|
||
da ist, und die Quelle der neuen Werte klären.
|
||
2. Schriften: Gibt es die Schrift schon in Divi (Google-Schrift, Web-Safe-Schrift, hochgeladene)? Dann genügt der
|
||
exakte Name in Schritt 5. Sonst `lit/divi-fonts-upload` (der Probelauf lädt die Dateien und nennt Format, Größe
|
||
und SHA-256; nach Rückfrage `confirm: true`). Es gibt keinen Snapshot, entfernen kann die Schrift nur der Nutzer
|
||
im Divi-Builder; den Datenschutz-Hinweis (eigener Server statt Google) nennen.
|
||
3. Farben: `lit/divi-colors-set` – die Hauptfarben (`primary`, `secondary`, `heading`, `body`, `link`) ändern, weitere
|
||
Farben mit `label` und `color` anlegen, abgeleitete mit `{{color:<Name>}}` und `relative`. Der Probelauf nennt
|
||
betroffene Seiten und Warnungen; dem Nutzer zeigen.
|
||
4. Variablen: `lit/divi-variables-set` für Abstände, Zahlen, Texte, Bilder, Links und Schriften. Eine Variable,
|
||
die nicht mehr gebraucht wird, erst mit `lit/divi-design-detach` (`variable`) aus Seiten und Presets lösen, dann
|
||
mit `status: archived` archivieren.
|
||
5. Theme-Optionen: `lit/divi-theme-options-update` (`heading_font`, `body_font`, Größen, Zeilenhöhen,
|
||
`content_width`, `gutter_width`, Abstände; `null` = Divi-Standard). Das ändert jede Seite der Website: den Nutzer
|
||
ausdrücklich fragen.
|
||
6. Presets: `lit/divi-presets-set` (aus einem gestalteten Modul mit `from_node` oder mit `attrs` und Platzhaltern wie
|
||
`{{color:primary}}`; `make_default: true` für das Standard-Preset), kontrollieren mit `lit/divi-preset-get`.
|
||
Auf Seiten ausrollen: „Design auf Seiten ausrollen“; alte Presets mit `lit/divi-design-detach` lösen und mit
|
||
`lit/divi-presets-delete` löschen.
|
||
7. Kontrolle: eine Beispielseite mit `lit-page-render-summary` und `lit-divi-outline` prüfen; dem Nutzer
|
||
`preview_url` und `edit_url` nennen.
|
||
8. Zurücknehmen: `lit/divi-design-snapshots` (`area` = `presets`, `colors`, `variables` oder `theme_options`), dann
|
||
`lit/divi-design-restore` (Probelauf, `confirm: true`); die `snapshot_id` steht auch in jeder Änderungsantwort.
|
||
Für Seiteninhalt gibt es nur die WordPress-Revisionen, für hochgeladene Schriften kein Restore.
|
||
9. Fehler: `conflict` (andere Änderung läuft, bei Schriften: Name vergeben) – warten und den Probelauf wiederholen
|
||
bzw. anderen Namen wählen; `rev_mismatch` (Bestand inzwischen geändert) – neu lesen; `forbidden` (Recht fehlt, die
|
||
Meldung nennt es) – Administrator bitten; `in_use` (Preset wird noch verwendet) – erst `lit/divi-design-detach`;
|
||
`divi_untested`/`divi_incompatible` – siehe „Ein Divi-Werkzeug lehnt ab“.
|
||
|
||
## Inhalte einer Divi-Schleife setzen
|
||
|
||
Beispiel: Datum des Beitrags in einer Beitragsliste. Details, Optionen und Grenzen: `divi-placeholders`,
|
||
Abschnitt „Schleifen-Inhalte“.
|
||
|
||
1. Der Nutzer schaltet im Divi-Builder die Schleife an einem Element ein (Abfrage: Beiträge, Terme, Benutzer oder
|
||
Menü); LIT MCP legt Schleifen nicht an. Das Element (Pfad-ID, z. B. `s0.r1.c0`) mit dem Nutzer klären;
|
||
`lit-divi-node-get` zeigt seine Attribute (`module.advanced.loop`).
|
||
2. `lit/divi-dynamic-sources` mit `post_type`, `post_id` und `node` (Pfad-ID eines Moduls in der Schleife) nennt die
|
||
passenden `loop_*`-Optionen mit Platzhalter.
|
||
3. `lit-divi-insert` mit `parent` = das Schleifen-Element, z. B.
|
||
`{"type": "text", "content": "{{loop:post_date}}"}`; vorhandene Module mit `lit-divi-update`.
|
||
4. Lehnt das Werkzeug ab („braucht eine Divi-Schleife“ bzw. „passt nicht zur Schleife“), die Meldung ernst nehmen:
|
||
das Modul in die Schleife setzen oder eine Option der passenden Art wählen, nie die Prüfung umgehen.
|
||
5. `lit-page-render-summary` prüfen und dem Nutzer die Vorschau nennen; ohne Treffer der Abfrage bleibt der Inhalt
|
||
leer.
|
||
````
|
||
|
||
|
||
#### `docs/guides/overview.md`
|
||
|
||
**Änderung 13** (Anker, alt):
|
||
|
||
````markdown
|
||
4. **Lesen vor Schreiben:** Vor Änderungen den aktuellen Stand lesen; Schreib-Tools
|
||
verlangen den `rev` des letzten Lesevorgangs.
|
||
````
|
||
|
||
Neu:
|
||
|
||
````markdown
|
||
4. **Lesen vor Schreiben:** Vor Änderungen den aktuellen Stand lesen; Schreib-Tools
|
||
verlangen den `rev` des letzten Lesevorgangs, Divi-Seiten die `tree_rev`, Änderungen am Designsystem
|
||
die `design_rev` aus dem Probelauf.
|
||
````
|
||
|
||
**Änderung 14** (Anker, alt):
|
||
|
||
````markdown
|
||
`lit/divi-node-copy`, `lit/divi-dynamic-sources`), Divi-Bibliothek
|
||
````
|
||
|
||
Neu:
|
||
|
||
````markdown
|
||
`lit/divi-node-copy`, `lit/divi-dynamic-sources`), Divi-Designsystem (Presets: `lit/divi-preset-get`,
|
||
`lit/divi-presets-set`, `lit/divi-presets-delete`, `lit/divi-presets-assign`, `lit/divi-design-detach`; Farben und
|
||
Variablen: `lit/divi-colors-set`, `lit/divi-variables-set`; Theme-Optionen: `lit/divi-theme-options-get`,
|
||
`lit/divi-theme-options-update`; Schriften: `lit/divi-fonts-upload`; Snapshots: `lit/divi-design-snapshots`,
|
||
`lit/divi-design-restore`; Ablauf: `lit-guide` topic `divi-design-system`), Divi-Bibliothek
|
||
````
|
||
|
||
**Änderung 15** (Anker, alt):
|
||
|
||
````markdown
|
||
- Fehler: JSON mit `code` (z. B. `validation_failed`, `rev_mismatch`, `write_failed`, `partial_write`),
|
||
`message`, `hint` (und ggf. `fields`). Dem `hint` folgen.
|
||
- `confirm_required`: Nutzer fragen, dann mit `confirm: true` wiederholen.
|
||
````
|
||
|
||
Neu:
|
||
|
||
````markdown
|
||
- Fehler: JSON mit `code` (z. B. `validation_failed`, `rev_mismatch`, `write_failed`, `partial_write`),
|
||
`message`, `hint` (und ggf. `fields`). Dem `hint` folgen. Weitere Codes: `forbidden` (ein Recht fehlt, die
|
||
Meldung nennt es), `in_use` (ein Preset oder Eintrag wird noch verwendet; `usages` nennt die Fundstellen),
|
||
`conflict` (eine andere Änderung am Designsystem läuft noch, oder der Name einer Schrift ist vergeben),
|
||
`snapshot_not_found`, `snapshot_too_large`, `font_fetch_failed` (Schriftdatei nicht ladbar) und `font_invalid`
|
||
(Datei passt nicht zum Format).
|
||
- `confirm_required`: Nutzer fragen, dann mit `confirm: true` wiederholen. Beim Designsystem enthält die Antwort
|
||
die `design_rev`, die mit `confirm: true` zurückgegeben wird; `rev_mismatch` heißt dort: der Bestand hat sich
|
||
seit dem Probelauf geändert, den Probelauf wiederholen.
|
||
````
|
||
|
||
**Änderung 16** (Anker, alt):
|
||
|
||
````markdown
|
||
LIT MCP gibt jede Divi-Fähigkeit einzeln frei, z. B. „Seiten lesen“, „Seiten schreiben“, „Presets schreiben“ oder
|
||
„Theme Builder lesen“.
|
||
````
|
||
|
||
Neu:
|
||
|
||
````markdown
|
||
LIT MCP gibt jede Divi-Fähigkeit einzeln frei, z. B. „Seiten lesen“, „Seiten schreiben“, „Presets schreiben“,
|
||
„Farben schreiben“, „Variablen schreiben“, „Theme-Optionen schreiben“, „Schriften hochladen“ oder
|
||
„Theme Builder lesen“.
|
||
````
|
||
|
||
|
||
- [ ] **Step 3: Abgleich mit Task 1–5 (ABWEICHUNGEN lesen)**
|
||
|
||
Die Guides beschreiben das Verhalten laut Rulings. Vor dem Test-Lauf jede Aussage gegen den fertigen Code und die `## ABWEICHUNGEN` der Tasks 1–5 prüfen und den Guide-Text (nie den Code) berichtigen, wo sie abweichen:
|
||
|
||
```bash
|
||
grep -rn "braucht eine Divi-Schleife\|passt nicht zur Schleife\|kein Schleifen-Kontext" src | head # Wortlaut X5/X6
|
||
grep -n "'term_name'\|'post_date'\|LOOP" src/Integrations/Divi/Compose/Placeholders.php | head # Beispiele {{loop:post_date}}, {{loop:term_name}}
|
||
grep -n "post_id\|'node'" src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php | head # Eingabe von dynamic-sources
|
||
grep -n "repeater" src/Integrations/Divi/Compose/LoopScope.php src/Integrations/Divi/Compose/LoopContext.php # X2: Entscheidung zu repeater_*
|
||
grep -n "warn" src/Integrations/Divi/Pages/DiviMoveAbility.php | head # Move-Warnung statt Ablehnung
|
||
grep -rn "StoredNodeGuard" src/Integrations/Divi/Pages/DiviNodeCopyAbility.php | head -3 # Kopieren: Aussage über Konten ohne unfiltered_html
|
||
```
|
||
|
||
Zu klären und im Guide festzuhalten: (a) Wortlaut der drei Phrasen identisch zum Code; (b) die Beispiele `{{loop:post_date}}`/`{{loop:term_name}}` sind in der `LOOP`-Tabelle; (c) `lit/divi-dynamic-sources`: ist `post_type` mit `post_id` + `node` noch Pflicht (sonst „mit `post_type`, `post_id` und `node`“ in `divi-placeholders.md` und `workflows.md` anpassen); (d) hat ein Task `repeater_*` doch unterstützt, den Grenzen-Punkt zu ACF streichen und die Art in die Tabelle aufnehmen; (e) Move-Warnung: sagt sie „Schleife“ (das Smoke prüft das); (f) die Grenze „Kopieren … ohne `unfiltered_html`“ stimmt nur, wenn Task 4/die Aufräumschritte das Verhalten so lassen – sonst streichen oder anpassen; (g) hat Task 2 eine eigene Meldung für Feld-Optionen (Meta-Schlüssel) in der Vollform, den Punkt 4 der Regeln darauf abstimmen.
|
||
|
||
- [ ] **Step 4: Tests und Lint**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter "ToolNamingTest|LoopGuideTest|FeatureTextsTest|ModuleCatalogTest|GuideRepositoryTest|InstructionsBuilderTest"`
|
||
Expected: PASS.
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: grün und sauber.
|
||
|
||
Sichtprüfung: `grep -nE "lit_[a-z]+" docs/guides/*.md` zeigt keinen Code mit `lit_`-Präfix (außer `lit_mcp…`); in den neuen Texten steht jedes Design-Tool als `lit/divi-…`, jedes direkte als `lit-divi-…`; `docs/guides/overview.md` „Weitere Themen“ unverändert. Die Länge: `divi-design-system.md` wächst von 132 auf rund 260 Zeilen (die übrigen Guides bleiben unter 190) – das ist gewollt, weil G11/X8 keine neue Datei erlauben; wird sie beim Lesen zu lang, ist ein eigener Guide (`divi-design-colors`…) eine spätere Entscheidung (dann „Weitere Themen“ und den Test anpassen).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add docs/guides/divi-design-system.md docs/guides/divi-placeholders.md docs/guides/divi-shortform.md docs/guides/workflows.md docs/guides/overview.md tests/Unit/Guides/ToolNamingTest.php tests/Unit/Guides/LoopGuideTest.php
|
||
git commit -m "docs: Guides für Farben, Variablen, Theme-Optionen, Schriften, Snapshots und Schleifen-Inhalte" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
|
||
|
||
### Task 8: Security-Audit Etappe 4 mit Fix-Welle
|
||
|
||
**Files:**
|
||
- Create: `docs/superpowers/notes/2026-10-19-etappe-4e-security-audit.md`
|
||
- Modify (nur durch die Fix-Welle, je Befund): die betroffenen Klassen unter `src/` und deren Tests
|
||
- Lies als Vorlage (Format): `docs/superpowers/notes/2026-09-27-etappe-3a-security-audit.md`, `…-3b-security-audit.md`
|
||
|
||
**Interfaces:**
|
||
- Consumes: alles aus Etappe 4 (Tasks 1–7 dieses Plans und 4a–4d).
|
||
- Produces: eine Audit-Notiz im Format der Audits 3a/3b (Scope, Environment, Method, Result, Findings nach Schwere, Info/hardening, Deferred items re-checked, Areas checked and found sound, Status nach der Fix-Welle) und eine Fix-Welle für alle Critical/High-Befunde (Medium nach Urteil, siehe Step 3).
|
||
|
||
Dieser Task ist ein **Review-Task** (Opus als Auditor), kein TDD-Task. Der Auditor ändert **keinen** Produktcode; Fixes macht eine eigene Fix-Welle.
|
||
|
||
- [ ] **Step 1: Auditor beauftragen (Opus, unabhängig, schreibend nur in die Notiz)**
|
||
|
||
Der Auditor bekommt den vollen Auftrag aus diesem Step als Prompt. Pflichtinhalt des Prompts:
|
||
|
||
1. **Scope (Spec §12):** (a) Preset-`attrs`/`from_node`: XSS über Style-/Render-Attribute, `unfiltered_html`, Inhalte in Presets; (b) Font-Upload: SSRF, Dateityp/Magic Bytes, Pfad/Dateiname, Größen-/DoS-Grenzen, Rollback, Rechte; (c) Massen-Schreibrechte: `PageRewriter` (`lit/divi-presets-assign`, `lit/divi-design-detach` inkl. Variablen), `edit_post` je Live-Fundstelle (P8/P8a), globale Instanzen, Bibliothek/Theme Builder, `HARD_CAP`/50-Seiten-Grenze; (d) Schleifen-Optionen: Typabgleich als Sicherheitsgrenze (Post-Meta über Term-/Benutzer-ID, siehe Global Constraints), Meta-Schlüssel-Regeln S29/S30 für `loop_*_meta_key_`, Kontext-Bestimmung (X1), `StoredNodeGuard`, Move-Warnung; (e) Snapshot-Restore: Rechte je Bereich, Größe (`MAX_BYTES`), FIFO, Sperre (`DesignLock`), Wiederherstellen fremder/manipulierter Snapshot-Daten (werden die Rohdaten beim Restore erneut geprüft? Divi-Sanitizer?), Snapshot-ID-Parsing; (f) alles Weitere aus Etappe 4: Theme-Optionen-Whitelist/Sanitizer, `et_update_option`-Ganz-Array-Schreiben, Farben/Variablen-Adapter, `ItemKeys`/Gruppenschlüssel (numerische Namen), Rechte in `permission()` UND `execute()` aller neuen Tools, Release-Gate je Tool, Fehlertexte/Orakel.
|
||
2. **Einstiegspunkte (zu lesen):** alle Klassen in `src/Integrations/Divi/Design/` (u. a. `DesignRights`, `DesignChange`, `DesignLock`, `SnapshotStore`, `PresetComposer`, `PresetAssignment`, `PresetBook`, `PageRewriter`, `TreeRewrite`, `ReferenceUsageQuery`, `VariableDetachRewrite`, `FontInstaller`, `FontNameRules`, `FontSignature`, `FontCatalog`, `DiviFontsUploadAbility`, `ThemeOption*`, `Divi*SetAbility`, `Divi*Restore*`, `*RestorePlanner`, `ItemKeys`, `GroupKeys`), `src/Integrations/Media/RemoteFileFetcher.php`, `MediaImporter.php`, `src/Integrations/Divi/Compose/{DynamicContentRules,VariableRef,Placeholders,LoopScope,LoopContext,StoredNodeGuard,ValueCleaner}.php`, `src/Integrations/Divi/Pages/{DynamicContentMap,DiviDynamicSourcesAbility,DiviInsertAbility,DiviUpdateAbility,DiviMoveAbility}.php`, die Adapter `Design/{GlobalColorsAdapter,GlobalVariablesAdapter,ThemeOptionsAdapter,FontCatalog,FontInstaller,DesignSystemWriter}.php` samt Manifesten, `src/Abilities/AbilityRegistrar.php` (Rechte-/Gate-Reihenfolge).
|
||
3. **Bekannte, bewusst belassene Restrisiken (nachprüfen, nicht neu melden, aber bewerten):** W13 (Lost Update von `et_update_option`, `body_header_style`, fehlende Bereichsprüfung in Divi, `font_add`-Teilerfolg, `remove_static_resources` nur mit angemeldetem Benutzer, Tablet/Phone-14/30-Quirk, DNS-Rebinding-Fenster zwischen `allows()` und cURL, Schema-Fehler ohne lit-Feldpfade), Ruling R9 (Umschreiber ohne `unfiltered_html`-Zwang), P8a mit `post_ids`, `SnapshotStore::attachLog` ohne Fehlerprüfung und RMW ohne Lock, `DesignLock`-Übernahmerennen, Rulings X7(a)–(e) (Schleifen können über `attrs` ein-/ausgeschaltet werden; globale Module ohne Kontext; Kopieren fail closed; Presets mit Schleife; ACF-Wiederholungsfelder), Mirror `customizer-typography`.
|
||
4. **Methode (wie 3a/3b):** alle neuen/geänderten `src/`-Dateien lesen; jede Schreib-Route bis zu den Prüfungen verfolgen; Divi-Code lesen, wo Divi-Verhalten behauptet wird; selbstreinigende `wp eval-file`-Probes in DDEV (Projekt `~/Projekte/WordPress`, Plugins `postlos` und `asynclistry` deaktiviert, danach Zustand wiederherstellen; jede Probe stellt `et_divi`/`et_uploaded_fonts`/Variablen/Seiten wieder her und wird nach dem Lauf per md5-Vergleich verifiziert); simulierte Rollen (In-Memory-`WP_User`) für die Rechte-Matrix: Editor ohne `edit_theme_options`, Admin ohne `unfiltered_html` (`DISALLOW_UNFILTERED_HTML`), ohne `upload_files`, ohne `custom_fonts_management`/`variables_manager`/`theme_customizer`; Testlauf `vendor/bin/phpunit` (Zahl notieren).
|
||
5. **Ausgabe:** `docs/superpowers/notes/2026-10-19-etappe-4e-security-audit.md` im Format der Audits 3a/3b: Kopf (Scope inkl. Commit-Range `d73144a^..HEAD` oder die tatsächliche Etappe-4-Range, Environment, Method, Result: Anzahl je Schwere), `## Findings (most severe first)` je Befund `### <Kürzel><n> – <Schwere> (<Kategorie>): Titel` mit **Where** (Datei:Zeile), **Scenario**, **Fix**; `## Info / hardening`; `## Deferred items re-checked` (jedes bekannte Restrisiko aus Punkt 3 mit Urteil: akzeptabel / zu beheben); `## Areas checked and found sound`; Schweregrade Critical/High/Medium/Low/Info. Keine Produktänderungen, keine Commits.
|
||
6. Antwort des Auditors: höchstens 15 Zeilen (Zahl Befunde je Schwere, die zwei wichtigsten, Ort der Notiz).
|
||
|
||
Run (Controller): Dispatch mit Modell **Opus**; den Prompt aus den Punkten 1–6 zusammenstellen und den Notiz-Pfad nennen.
|
||
Expected: Notiz liegt im Arbeitsverzeichnis (ungestaged), Befundzahlen in der Antwort.
|
||
|
||
- [ ] **Step 2: Notiz sichten und Befunde einordnen**
|
||
|
||
Der Controller liest die Notiz, ordnet jeden Befund ein und trägt Rulings ins Ledger:
|
||
- Critical/High → **Fix-Welle (Step 3)**, verbindlich.
|
||
- Medium → beheben, wenn der Fix klein ist und kein Verhalten der Tools ändert; sonst als `Ruling:` mit Begründung und Aufnahme in „Status nach der Fix-Welle“ als „bewusst belassen“.
|
||
- Low/Info → bleiben in der Notiz; bei einem Fix mit weniger als etwa 10 Zeilen in die Fix-Welle aufnehmen.
|
||
- Befunde, die eine Produktentscheidung verlangen (z. B. Schema-Fehlerform projektweit, Aufspalten von Rechten), werden **nicht** still entschieden, sondern mit Optionen und Empfehlung im Abschlussbericht dem Nutzer vorgelegt (das ist eine der vier Stopp-Klassen nur, wenn der Befund Critical/High ist und jeder Fix eine Verhaltensänderung für Nutzer wäre).
|
||
|
||
Run: `git status --short`
|
||
Expected: nur die Notiz ist neu.
|
||
|
||
- [ ] **Step 3: Fix-Welle (Sonnet, EIN Dispatch)**
|
||
|
||
Prompt-Inhalt: alle zu behebenden Befunde der Notiz wörtlich (ID, Where, Scenario, Fix), TDD für Verhaltensänderungen (Test zuerst, RED, Fix, GREEN), je Befund oder logische Gruppe ein Commit (Nachricht nennt die Befund-ID, z. B. `fix(audit H1): …`), `composer test` und `composer lint` vor dem letzten Commit; falls ein Befund einen Smoke berührt (Font-Upload, Seiten-Umschreiber, Schleifen), den passenden Smoke in DDEV neu laufen lassen (`divi-theme-fonts-smoke.sh`, `divi-presets-assign-smoke.sh`, `divi-loop-smoke.sh`; Pluginzustand notieren und wiederherstellen; md5 der berührten Optionen vorher/nachher). Keine anderen Änderungen. Danach **ein** scoped Re-Review (Sonnet, nur die Befunde und das Fix-Diff).
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: grün (Zahl größer als vor der Fix-Welle bei neuen Tests).
|
||
|
||
- [ ] **Step 4: Notiz abschließen**
|
||
|
||
Der Controller (oder ein kleiner Docs-Dispatch) ergänzt `## Status nach der Fix-Welle (2026-10-19)` in deutscher Sprache: je Befund „behoben in <Commit>“ oder „bewusst belassen: <Begründung>“, dazu „Scoped Re-Review: Approved; N Tests grün, Smokes grün“. Die Rest-Risiken aus `## Deferred items re-checked` stehen mit Urteil darin.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add docs/superpowers/notes/2026-10-19-etappe-4e-security-audit.md
|
||
git commit -m "docs: Security-Audit Etappe 4 mit Status nach der Fix-Welle" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
### Task 9: Version 0.7.0, Spec- und Notiz-Nachträge
|
||
|
||
**Files:**
|
||
- Modify: `wp-lit-mcp.php` (Zeile 6, Plugin-Header `Version:`), `src/Plugin.php` (Zeile 48, Versions-Konstante), `readme.txt` (Zeile 7 `Stable tag`/Version plus neuer Changelog-Abschnitt)
|
||
- Create: `docs/superpowers/notes/2026-10-19-etappe-4e-recherche-schleifen.md` (Kopie der Divi-Schleifen-Recherche, redaktionell auf X1–X10 abgestimmt)
|
||
- Modify: `docs/superpowers/specs/2026-10-16-etappe-4-designsystem-design.md` (Status, §9: Version 5.15.1 statt 5.14.0, Kontextregel/Arten/Meldungen wie gebaut), `docs/superpowers/notes/2026-09-28-etappe-4-entscheidungen-entwurf.md` (Abschnitt „Etappe 4e“ mit X1–X10 und Restpunkten), `docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md` (Nachtrag „Schleifen (Etappe 4e)“ mit den Divi-Fakten), `docs/superpowers/notes/2026-10-18-divi-faehigkeiten.md` nur, falls dort noch ein 4e-Hinweis steht
|
||
|
||
**Interfaces:**
|
||
- Consumes: Zustand nach Task 8 (alle Fixes).
|
||
- Produces: Version 0.7.0, nachgeführte Spec und Notizen.
|
||
|
||
- [ ] **Step 1: Aktuelle Versionsstellen lesen**
|
||
|
||
Run: `grep -n "0\.6\.1" wp-lit-mcp.php src/Plugin.php readme.txt composer.json; grep -rn "0\.6\.1" tests src docs --include=*.php --include=*.md --include=*.txt | head`
|
||
Expected: Treffer genau in `wp-lit-mcp.php:6`, `src/Plugin.php:48`, `readme.txt:7`; kein Test pinnt die Nummer (die Recherche hat das geprüft; weicht der Befund ab, die Stelle im Bericht nennen und mit ändern).
|
||
|
||
- [ ] **Step 2: Version erhöhen**
|
||
|
||
In den drei Dateien jeweils nur `0.6.1` → `0.7.0` ersetzen (Zeilen wie oben, keine weiteren Änderungen an diesen Zeilen). In `readme.txt` unter `== Changelog ==` (Format der vorhandenen Einträge übernehmen, Sprache wie dort) einen neuen Abschnitt `= 0.7.0 =` voranstellen mit diesen Punkten (an den Stil der Datei anpassen, keine Zeilen über 120 Zeichen erzwingen):
|
||
- Designsystem schreiben (Etappe 4): Presets lesen, anlegen, ändern, löschen, einzelnen Modulen und ganzen Seiten zuweisen und Verweise überall entfernen (Probelauf, Bestätigung, Snapshot, Wiederherstellen).
|
||
- Globale Farben und Variablen anlegen und ändern; Variablen werden archiviert, nicht gelöscht; Verweise auf Variablen durch ihren Wert ersetzen.
|
||
- Divi-Theme-Optionen (Schriften, Größen, Layout) lesen und ändern; eigene Schriften von einer URL hochladen; Schriftnamen mit Vorschlägen prüfen.
|
||
- Schleifen-Inhalte (`loop_*`, `{{loop:…}}`) in Modulen innerhalb einer Divi-Schleife; `lit/divi-dynamic-sources` zeigt sie je Knoten.
|
||
- Divi-Freigabe je Fähigkeit statt pauschaler Sperre; neue Guides; Security-Audit der Etappe.
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: grün (keine Versions-Tests).
|
||
|
||
- [ ] **Step 3: Recherche, Spec und Notizen nachführen**
|
||
|
||
1. `…/scratchpad/research-4e-loop-divi.md` als `docs/superpowers/notes/2026-10-19-etappe-4e-recherche-schleifen.md` ablegen (nur redaktionell glätten, alle Dateizeilen behalten; Fußnote „5.15.1“).
|
||
2. Spec: im Kopf „Status“ von „Entwurf zur Freigabe“ auf „umgesetzt (Etappe 4a–4e, Version 0.7.0)“ ändern; §9 – „aus Divi 5.14.0 abgeleitet“ → „5.15.1“, die Absätze Kontext/Optionen/Meldungen/Kurzform/dynamic-sources/Move so formulieren, wie gebaut (X1–X7, Arten, Typabgleich als Sicherheitsgrenze); S36 als abgelöst vermerken.
|
||
3. Entscheidungsnotiz: Abschnitt „Etappe 4e (Plan 2026-10-19-etappe-4e-schleifen-guides-audit.md)“ mit X1–X10 in je einem Satz und „Offene Kleinpunkte aus 4e“ (aus dem Ledger, soweit nicht behoben) sowie die aus 4d übernommenen offenen Punkte als Liste „Folgearbeit nach Etappe 4“ (Schema-Fehler-Form projektweit, Schriftnamen in Preset-/Modul-Attributen, Rev-Helfer der Book-Klassen, `dryRun()`/`installed()` aus `DiviFontsUploadAbility::upload()`, sha256-Pin beim Upload-confirm, weitere Minor-Punkte).
|
||
4. Formate-Nachtrag „Divi 5.15.1 – Schleifen (Etappe 4e)“: Speicherort/Schlüssel der Schleife, `queryType`-Werte, Kontextregel (`desktop.value.enable === "on"`), `loop_*`-Optionen und Arten, Gefahr des Fehlabgleichs (Post-Meta über Term-/Benutzer-ID), Rendering-Verhalten außerhalb einer Schleife, Grenzen (Presets, globale Module).
|
||
|
||
Run: `grep -n "Entwurf zur Freigabe\|5\.14\.0" docs/superpowers/specs/2026-10-16-etappe-4-designsystem-design.md`
|
||
Expected: keine Treffer mehr für den Status; „5.14.0“ nur noch dort, wo es historisch die Formate-Basis meint (dann mit Zusatz „Formate aus 5.2.1/5.14.0 geprüft, 5.15.1 maßgeblich“).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add wp-lit-mcp.php src/Plugin.php readme.txt docs/superpowers
|
||
git commit -m "chore: Version 0.7.0 (Etappe 4); Spec und Notizen nachgeführt" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
### Task 10: Gesamtverifikation, Gesamtreview und Abschluss von Etappe 4
|
||
|
||
**Files:**
|
||
- Modify: Memory `etappe-4-stand` und `MEMORY.md` (außerhalb des Repos, `/home/janniclas/.claude/projects/-home-janniclas-Projekte-WordPress-wp-content-plugins-WP-LIT-MCP/memory/`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: alles aus den Tasks 1–9.
|
||
- Produces: grüne Suite, grüne Smokes, Gesamtreview ohne offene wichtige Befunde, aktualisierter Stand; Etappe 4 abgeschlossen.
|
||
|
||
- [ ] **Step 1: Suite, Lint, Divi-Prüfung**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: alle Tests grün (3368 + neue), phpcs ohne Befund.
|
||
|
||
Run: `cd /home/janniclas/Projekte/WordPress && ddev exec wp --user=<Admin> lit-mcp divi-check`
|
||
Expected: Exit-Code 0; alle Fähigkeiten nutzbar.
|
||
|
||
- [ ] **Step 2: Alle Smokes**
|
||
|
||
Pluginzustand notieren (`ddev exec wp plugin list`), `postlos` und `asynclistry` deaktivieren, nacheinander ausführen (jeder muss mit `ALL CHECKS PASSED` enden), Zustand wiederherstellen:
|
||
|
||
```bash
|
||
cd /home/janniclas/Projekte/WordPress
|
||
timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-loop-smoke.sh
|
||
timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-theme-fonts-smoke.sh
|
||
timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-colors-variables-smoke.sh
|
||
timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-presets-smoke.sh
|
||
timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-presets-assign-smoke.sh
|
||
timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-release-smoke.sh
|
||
```
|
||
|
||
Expected: sechsmal `ALL CHECKS PASSED`; md5 von `et_divi`, `et_uploaded_fonts`, `et_divi_global_variables` vor und nach allen Läufen identisch; keine Testseiten/-Schriften übrig.
|
||
|
||
- [ ] **Step 3: Gesamtreview (zwei unabhängige Opus-Reviews parallel)**
|
||
|
||
Dispatch (zwei Subagents, Modell Opus; Paket `scripts/review-package PLAN BASE HEAD` über die Etappe-4e-Commits; Ledger-Datei mit Deferred-Items und Rulings mitgeben):
|
||
1. Fachreview: Korrektheit gegen Plan und Spec (§9), Sicherheit des Schleifen-Pfads (Typabgleich, Kontextregel, Move-Warnung, Update mit mehreren Scopes, `StoredNodeGuard`), Konsistenz der Guides mit dem Code, Audit-Notiz vs. tatsächliche Fixes, Version; Triage aller Ledger-Einträge (fix-before-merge | follow-up | wontfix).
|
||
2. `anthropic-skills:clean-code-review` (Quick) über die neuen/geänderten Klassen (`LoopScope`, `LoopContext`, `DynamicContentRules`, `VariableRef`, `Placeholders`, `DiviUpdateAbility`, `DiviMoveAbility`, `DiviDynamicSourcesAbility`).
|
||
|
||
Wichtige Befunde in EINER Fix-Welle (Sonnet) beheben, danach EIN scoped Re-Review (Sonnet); Kleinigkeiten in die Folgearbeit-Liste.
|
||
|
||
- [ ] **Step 4: Memory nachführen**
|
||
|
||
`etappe-4-stand.md` und `MEMORY.md`: Etappe 4 fertig (4a–4e, Version 0.7.0, HEAD, Testzahl, Datum), nichts gepusht, Folgearbeit nach Etappe 4 (Liste aus der Entscheidungsnotiz); Eintrag zur nächsten Etappe (Gesamt-Spec `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` §13: Etappe 5) – vorher dort lesen, was als Nächstes dran ist.
|
||
|
||
- [ ] **Step 5: Abschluss**
|
||
|
||
`git status` sauber; nichts pushen oder mergen ohne Auftrag. Dem Nutzer berichten: Testzahl, Smoke-Ergebnisse, Audit-Ergebnis (Zahl je Schwere, was behoben), Rulings (inkl. X1–X10 und Audit-Rulings), offene Folgearbeit, und fragen, ob gemergt/gepusht werden soll.
|
||
|
||
---
|
||
|
||
## Anhang: Abweichungen der Task-Autoren (geprüft, im Plan berücksichtigt)
|
||
|
||
Die Autoren haben ihre Tasks in Kopien des Repos mit Stand-ins für fremde Tasks ausgeführt. Verbindlich bleibt der Taskinhalt; bei Zählungen (Tests) gilt beim Umsetzen der echte Stand.
|
||
|
||
**Vom Controller aufgelöst:** `DynamicContentMap::loopFields` hat die Signatur `loopFields(string $kind, int $postId = 0)` (Task 2); die Aufrufe in den Tasks 4 und 5 sind darauf angepasst (A1 der Tasks 3–5 ist damit gegenstandslos).
|
||
|
||
### Tasks 1-2
|
||
|
||
- **A1 (Ergänzung `LoopScope`):** zusätzlich `public function fits(string $option): bool` und `public static function kindOfOption(string $option): ?string`. Grund: die Regeln brauchen die Art der **Option**; die Map liefert sie nach Gruppe (X2), die Sicherheitsprüfung nimmt sie wie Divi selbst aus dem Namen (Meta-Typ aus dem Namen, `get_meta_type_from_name`). Beide Quellen müssen übereinstimmen (Map-Eintrag der Art **und** Name der Art), sonst Ablehnung – fail closed bei Filtern, die Optionen in fremde Gruppen hängen. Task 4/5 dürfen `fits()` für die Move-Warnung nutzen.
|
||
- **A2 (`loopFields`):** Signatur `loopFields(string $kind, int $postId = 0)` statt `loopFields(string $kind)`; der Vertragsaufruf funktioniert unverändert (Post 0), Task 4/5 sollten die Post-ID mitgeben (dieselbe Registry-Abfrage wie `forPost($postId)`; die Felder der Loop-Optionen hängen in 5.15.1 nicht vom Beitrag ab, nur Woo/ACF von Plugins).
|
||
- **A3 (Standardverhalten):** „ohne Kontext wie heute“ gilt für alles außer `loop_*`: ein `loop_*`-Name in einer eingespeisten Map war bisher kontextfrei erlaubt (nur in Tests, `VariableRefTest:209/222`), jetzt abgelehnt – das verlangt die Sicherheitsgrenze der Global Constraints. Der Test wird entsprechend umgestellt.
|
||
- **A4 (`label()`/`active()` bei nicht unterstützter Schleife):** `ofQueryType('repeater_x')` u. ä. liefert `active() === false`, `kind() === null`, `queryType() === 'repeater_x'`, `label() === 'repeater_x'`; `label()` von `none()` ist `""`. Die Regeln unterscheiden „keine Schleife“ (Meldung 1) von „Schleife ohne unterstützte Art“ (Meldung 2 mit dem rohen `queryType`) über `queryType() === null`. Task 5 („kein Schleifen-Kontext“) sollte bei `queryType() !== null && ! active()` besser „Schleife vom Typ … ohne unterstützte Optionen“ melden.
|
||
- **A5 (Strenge gegenüber Divi):** `queryType` wird nicht wie bei Divi mit `sanitize_key()` normalisiert – `"Post_Types"` oder `"post_types "` sind für die Werkzeuge keine Beitrags-Schleife (Divi rendert sie als solche). Fail closed; der Visual Builder schreibt nur die exakten Werte.
|
||
|
||
### Tasks 3-5
|
||
|
||
- **A1 – `loopFields()` braucht die Post-ID.** Der Vertrag nennt `DynamicContentMap::loopFields(string $kind)`, aber Divis Registry ist je Eintrag (`raw(int $postId)`, `get_options($postId, 'edit')`). Tasks 3–5 rufen `loopFields(string $kind, int $postId = 0): array<string, array<string, string>>` (Option ⇒ Einstellung ⇒ Feldtyp, dieselbe `type()`-Abbildung wie `fromOptions()`, `[]` ohne Registry). Behält Task 2 `loopFields(string $kind)` (z. B. auf einem vorher gelesenen Stand), ändern sich genau zwei Aufrufe: `DiviComposition::loopFields()` und `DiviDynamicSourcesAbility::withLoop()`.
|
||
- **A2 – Wer die Loop-Optionen in die Map bringt.** Der Vertrag lässt offen, wie die passenden `loop_*`-Optionen in `DynamicContentRules` kommen. Diese Tasks setzen voraus: `forPost()` liefert weiter **keine** `loop_*` (Kontext-frei); `DiviComposition::dynamicContentMap()` hängt `loopFields($scope->kind(), $postId)` an (`$map + …`), und `DynamicContentRules` erlaubt einen `loop_*`-Namen nur, wenn der Kontext aktiv ist **und** die Map ihn nennt; sonst X5-Meldung 1 (kein aktiver Kontext) bzw. X5-Meldung 2 mit `LoopScope::label()` (aktiv, aber nicht in der Map). Lässt Task 2 `forPost()` die `loop_*` behalten und prüft die Art selbst (Name/Gruppe), ist `$map + loopFields()` ein harmloser Doppel-Eintrag – aber dann muss Task 2 sicherstellen, dass ein `loop_*` aus der Map **ohne** Kontext abgelehnt wird (der bisherige `VariableRefTest::testInjectedDynamicContentMap` akzeptiert `loop_post_title` ohne Kontext). Die Tests hier prüfen die Meldungsteile „braucht eine Divi-Schleife“, „passt nicht zur Schleife dieses Elements (Typ: Terme)“ und „Typ: Beiträge“ wörtlich nach X5.
|
||
- **A3 – `inLoop()` liefert `static`** (wie `forPost()`), nicht `self`; für Aufrufer gleich.
|
||
- **A4 – Insert fail closed statt Kontext-Umschaltung (X3 erlaubt das).** Begründung im Task-4-Abschnitt; die Ablehnung ist bewusst grob (eine neue Schleife irgendwo im Aufruf plus Schleifen-Inhalt irgendwo im Aufruf). Gleiches gilt ohne eigenen Code für `lit-content-create`/Theme-Builder-Anlage (dort mit Meldung 1, nicht mit dem Insert-Hinweis).
|
||
- **A5 – Verdrahtung schon in Task 5.** X6 sagt „Task 9 verdrahtet“; der Auftrag dieses Abschnitts verlangt die minimale `DiviIntegration`-Änderung (ein benanntes Argument). Task 9 muss dafür nichts mehr tun.
|
||
- **A6 – Zwei Registry-Lesungen je Komposition mit Schleife** (`forPost()` und `loopFields()`; `DynamicContentMap::raw()` cacht nicht). Divi baut die Liste dabei zweimal; ohne Schleife bleibt es bei einer Lesung. Kann Task 2 billig je Post merken, entfällt das.
|
||
- **A7 – Fähigkeiten von `lit/divi-dynamic-sources`.** X6: „die Tool-Fähigkeiten ändern sich nicht“ – gelesen als: der Grundsatz ohne Eingabe bleibt `[dynamic-sources, roles]`; mit `post_id` kommt `page-read` (+ `ofEntry`) dazu, weil der Loader ohne gesperrtes PageRead nicht laufen darf.
|
||
- **A8 – Abhängigkeit von Task 1:** `LoopScope::ofQueryType()` für `repeater_<acf>` (X2) – der Code hier nutzt nur `active()`, `kind()`, `queryType()`, `label()` und vergleicht Kontexte über `queryType()` (Update-Cache) bzw. `kind()` (Move-Warnung); jede Task-1-Entscheidung für `repeater_*` funktioniert, solange `kind()` ohne Schleife `null` ist.
|
||
- **A9 – Testhilfe:** `DiviWriterFixture::writer()` bekommt einen letzten Parameter `?DynamicContentMap $registry` (bestehende Aufrufe unverändert).
|
||
- **A10 – Nachweis:** Alle RED/GREEN-Zahlen stammen aus Läufen in einer Kopie des Repos (Stand 6203dbb) mit Platzhaltern für Task 1/2 nach dem Vertrag (A1, A2); die drei `VariableRefTest`-Fälle, die Task 2 umschreibt (`testInjectedDynamicContentMap`, `testDerivedMetaKeysThatAreFineStayAccepted`, `testMetaKeysObeyTheRuleEvenFromTheMap`), waren dabei übersprungen. Gesamtläufe: 3368 → 3374 (Task 3) → 3383 (Task 4) → 3389 (Task 5), `composer lint` ohne Meldung; nach dem echten Task 2 verschiebt sich die Basis.
|
||
|
||
### Tasks 6-7
|
||
|
||
|
||
|
||
### Tasks 8-10
|
||
|
||
|
||
|