diff --git a/src/Integrations/Divi/Pages/DiviComposition.php b/src/Integrations/Divi/Pages/DiviComposition.php index e09145c..ed38b06 100644 --- 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>|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>|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> Loop options of the OWN scope's kind only ([] 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; + } } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php b/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php index fad65fb..9ba86c8 100644 --- 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,46 @@ 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, string $content = '{{loop:post_title}}'): array => $composition + ->composer()->compose( + [['type' => 'text', 'content' => $content]], + 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') + ); + + // Placeholders get the loop fields: the short form carries before/after; outside a loop it is refused. + $date = $compose($inPostLoop, '{{loop:post_date}}'); + self::assertSame([], $date['errors']); + self::assertStringContainsString( + 'loop_post_date', + (string) AttrMap::desktop($date['blocks'][0]->attrs(), 'content.innerContent') + ); + self::assertStringContainsString('braucht eine Divi-Schleife', implode(' ', $compose($post, '{{loop:post_date}}')['errors'])); + + 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'); + } }