diff --git a/docs/superpowers/plans/2026-10-19-etappe-4e-schleifen-guides-audit.md b/docs/superpowers/plans/2026-10-19-etappe-4e-schleifen-guides-audit.md index b24e2d1..dec49e4 100644 --- a/docs/superpowers/plans/2026-10-19-etappe-4e-schleifen-guides-audit.md +++ b/docs/superpowers/plans/2026-10-19-etappe-4e-schleifen-guides-audit.md @@ -99,4 +99,4575 @@ public function inLoop(LoopScope $scope): self; // Klon mit Ko --- - +### 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_|||`, Gruppe `Loop ACF `), 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_`, 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__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 `.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): 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 + */ + 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_ 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 */ + 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__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 +, 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__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 " +``` + +#### 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 + + */ + private static function attrs(mixed $loop): array + { + return ['module' => ['advanced' => ['loop' => $loop]], 'builderVersion' => '5.15.1']; + } + + /** + * @param array $value desktop.value of the loop + * @return array + */ + 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 = ''; + + return NodeIndex::build((new Parser())->parse( + '' + . '' + // s0.r0.c0: a post loop, a term loop group inside it, a disabled loop group inside that. + . '' . $text + . '' . $text + . '' . $text . '' + . '' + . '' + // s0.r0.c1: no loop; a global module instance claiming a loop (opaque: not read). + . '' . $text + . '' + . '' + . '' + // s1: the section itself is a user loop. + . '' + . '' . $text . '' + . '' + . '' + )); + } + + 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 + $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 $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> $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 $attrs + * @return array + */ + 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 " +``` + +- [ ] **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>` – 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_ options a filter registers ++ * names and types as forPost(), loop_* left out – see loopFields()). custom_meta_ 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> ++ */ ++ 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 $option ++ * @return array ++ */ ++ 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 $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 " +``` + +#### 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 + ['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 + */ + 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>|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":"T"}'), +@@ -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":"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_ 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> 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_ names and the values of settings whose ++ * names only in a loop of their kind, custom_meta_ 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> */ + private array $map; + ++ private LoopScope $loop; ++ + /** +- * @param array|null $map See the class docblock. ++ * @param array|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 ++ * @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_ + * (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_ ({{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|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 " +``` + +#### 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:}}. ++ * 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:}}. + * + * 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 " +``` + + +### Task 3: Kurzform `{{loop: