feat: DiviComposition::inLoop() – VariableRef und Placeholders mit Schleifen-Kontext; S31 je Kontext (X3)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 20:50:24 +02:00
co-authored by Claude Sonnet 5.5
parent 6da2c0090f
commit 2f484b77ae
2 changed files with 107 additions and 19 deletions
+64 -19
View File
@@ -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>> 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;
}
}
@@ -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');
}
}