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\AttrWriter;
use LIT\MCP\Integrations\Divi\Compose\Composer; use LIT\MCP\Integrations\Divi\Compose\Composer;
use LIT\MCP\Integrations\Divi\Compose\LinkTargets; 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\MediaResolver;
use LIT\MCP\Integrations\Divi\Compose\Placeholders; use LIT\MCP\Integrations\Divi\Compose\Placeholders;
use LIT\MCP\Integrations\Divi\Compose\ShortformWriter; 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 * 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: * registry and the WordPress sanitizer/media lookup – once per request, entry and loop scope. Ruling S31
* ONE VariableRef (carrying the live dynamic content map of the entry, S22), ONE Placeholders * (as reworded by X3): ONE VariableRef per composition (carrying the live dynamic content map of the entry, S22,
* and ONE ValueCleaner instance are shared by AttrWriter, ShortformWriter and Composer, and the * plus the loop options of its LoopScope), ONE Placeholders and ONE ValueCleaner instance are shared by
* same AttrWriter serves Composer and the abilities. * 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 * 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 * 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. * Without a preset source (null, unit tests) there are no presets.
* *
* forPost() binds the composition to the entry being written (DiviWriter::composition() does it * 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 * for the abilities); unbound, VariableRef's default map applies. inLoop() binds it to the loop scope of the
* tested with a mock. * 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 class DiviComposition
{ {
@@ -51,6 +54,10 @@ class DiviComposition
private ?AttrWriter $attrWriter = null; private ?AttrWriter $attrWriter = null;
private ?Composer $composer = null; private ?Composer $composer = null;
private ?PresetAssignment $assignment = 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( public function __construct(
private DiviModules $modules, private DiviModules $modules,
@@ -61,35 +68,40 @@ class DiviComposition
private LinkTargets $links = new WpLinkTargets(), private LinkTargets $links = new WpLinkTargets(),
private ?DesignSystemWriter $presetSource = null private ?DesignSystemWriter $presetSource = null
) { ) {
$this->loop = LoopScope::none();
} }
/** A fresh composition for entry $postId (own VariableRef with its live map; TB area for StructureRules). */ /** 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 public function forPost(int $postId, ?string $themeBuilderArea = null): static
{ {
$bound = clone $this; $bound = $this->fresh();
$bound->postId = $postId; $bound->postId = $postId;
$bound->themeBuilderArea = $themeBuilderArea; $bound->themeBuilderArea = $themeBuilderArea;
$bound->references = null;
$bound->placeholders = null;
$bound->cleaner = null;
$bound->attrWriter = null;
$bound->composer = null;
$bound->assignment = null;
return $bound; 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(). * @internal Public for tests; code uses cleaner() / composer().
*/ */
public function references(): VariableRef public function references(): VariableRef
{ {
return $this->references ??= new VariableRef( return $this->references ??= new VariableRef($this->design->read(), $this->dynamicContentMap(), $this->loop);
$this->design->read(),
$this->postId === null ? null : $this->dynamicContent->forPost($this->postId)
);
} }
/** /**
@@ -99,7 +111,7 @@ class DiviComposition
*/ */
public function placeholders(): Placeholders 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). */ /** 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 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\Document;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary; 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\StoredNodeGuard;
use LIT\MCP\Integrations\Divi\Compose\TreeEditor; use LIT\MCP\Integrations\Divi\Compose\TreeEditor;
use LIT\MCP\Integrations\Divi\Compose\VariableRef; use LIT\MCP\Integrations\Divi\Compose\VariableRef;
@@ -265,4 +266,46 @@ final class DiviCompositionTest extends TestCase
self::assertCount(1, $covering); self::assertCount(1, $covering);
self::assertStringContainsString('„CTA primär“', $covering[0]); 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');
}
} }