diff --git a/src/Integrations/Divi/Pages/DiviInsertAbility.php b/src/Integrations/Divi/Pages/DiviInsertAbility.php index 8a284dc..addfa9e 100644 --- a/src/Integrations/Divi/Pages/DiviInsertAbility.php +++ b/src/Integrations/Divi/Pages/DiviInsertAbility.php @@ -6,6 +6,9 @@ namespace LIT\MCP\Integrations\Divi\Pages; use LIT\MCP\Abilities\Annotations; use LIT\MCP\Abilities\Errors; +use LIT\MCP\Integrations\Divi\BlockTree\Block; +use LIT\MCP\Integrations\Divi\Compose\LoopContext; +use LIT\MCP\Integrations\Divi\Compose\VariableRef; use LIT\MCP\Integrations\Divi\Release\DiviFeature; use WP_Error; @@ -17,6 +20,9 @@ defined('ABSPATH') || exit; * validated first and every error is returned together; the blocks come only from Composer * (client values pass its choke point) and enter the tree only through TreeEditor::insert(), * which also wraps the first section of an empty page in divi/placeholder (ruling S9). + * + * Loop content (ruling X3): all new nodes share the loop scope of the target parent. A loop a new node of this + * call switches on counts only once it is saved – with loop content in the same call the insert fails closed. */ final class DiviInsertAbility extends DiviWriteAbility { @@ -97,7 +103,18 @@ final class DiviInsertAbility extends DiviWriteAbility ]); } - $composed = $composition->composer()->compose($nodes, $position['parent'], 'nodes', $context); + $scope = self::loopAt($page->index, $position['parent']); + $composed = $composition->inLoop($scope)->composer()->compose($nodes, $position['parent'], 'nodes', $context); + if (self::startsLoop($composed['blocks']) && self::hasLoopContent($nodes)) { + return Errors::invalidInput( + 'Schleifen-Inhalte unter einer neuen Divi-Schleife gehen nicht in einem Aufruf; nichts wurde ' + . 'gespeichert.', + ['nodes' => 'Die Schleife eines neuen Elements gilt erst nach dem Speichern: zuerst das Element mit ' + . 'Schleife einfügen, dann die Schleifen-Inhalte mit lit-divi-insert (parent = Pfad-ID des ' + . 'Elements) ergänzen'] + ); + } + if ($composed['errors'] !== []) { return DiviErrors::composed($composed, 'Knoten sind ungültig; nichts wurde gespeichert.'); } @@ -117,4 +134,38 @@ final class DiviInsertAbility extends DiviWriteAbility return $change; } + + /** + * Whether one of $blocks or a descendant switches a loop on. + * + * @param list $blocks + */ + private static function startsLoop(array $blocks): bool + { + foreach ($blocks as $block) { + if (LoopContext::enabled($block->attrs()) || self::startsLoop($block->children())) { + return true; + } + } + + return false; + } + + /** + * Whether the input names loop content: a {{loop:…}} placeholder or a reference to a loop_* option. + * + * @param array $nodes + */ + private static function hasLoopContent(array $nodes): bool + { + $found = false; + array_walk_recursive($nodes, static function (mixed $value) use (&$found): void { + $found = $found || (is_string($value) && ( + preg_match('/^\s*\{\{\s*loop\s*:/', $value) === 1 + || str_starts_with((string) VariableRef::nameOf($value), 'loop_') + )); + }); + + return $found; + } } diff --git a/src/Integrations/Divi/Pages/DiviWriteAbility.php b/src/Integrations/Divi/Pages/DiviWriteAbility.php index 3da25a4..6052287 100644 --- a/src/Integrations/Divi/Pages/DiviWriteAbility.php +++ b/src/Integrations/Divi/Pages/DiviWriteAbility.php @@ -6,6 +6,10 @@ namespace LIT\MCP\Integrations\Divi\Pages; use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\ToolResult; +use LIT\MCP\Integrations\Divi\BlockTree\Block; +use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; +use LIT\MCP\Integrations\Divi\Compose\LoopContext; +use LIT\MCP\Integrations\Divi\Compose\LoopScope; use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\FeatureSets; use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures; @@ -114,4 +118,22 @@ abstract class DiviWriteAbility implements Ability, RequiresDiviFeatures { return null; } + + /** + * Ruling X3: the loop scope at $block – its nearest loop-enabled ancestor-or-self (LoopContext, X1); none at + * the top level. $newAttrs (node ID => attrs) replaces the stored attrs of nodes this call changes. The index + * is the tree loaded for this call, never a client value. Opaque blocks are never entered by NodeIndex, so one + * can only be $block itself – and every tool refuses to write into or onto it. + * + * @param array> $newAttrs + */ + protected static function loopAt(NodeIndex $index, ?Block $block, array $newAttrs = []): LoopScope + { + $chain = []; + for ($node = $block; $node !== null; $node = $index->parentBlock($node)) { + array_unshift($chain, $newAttrs[(string) $index->idOf($node)] ?? $node->attrs()); + } + + return LoopContext::fromChain($chain); + } } diff --git a/tests/Support/DiviWriterFixture.php b/tests/Support/DiviWriterFixture.php index 8a54352..750a6fd 100644 --- a/tests/Support/DiviWriterFixture.php +++ b/tests/Support/DiviWriterFixture.php @@ -92,7 +92,8 @@ final class DiviWriterFixture } /** - * The writer over the page of DiviFixtures::givenPost(), saving into $saved (see saver()). + * The writer over the page of DiviFixtures::givenPost(), saving into $saved (see saver()); $registry is the + * composition's dynamic content map (see composition()). * * @param list $saved * @param list $failures @@ -104,7 +105,8 @@ final class DiviWriterFixture ?DiviPageLoader $loader = null, ?GlobalEdits $globals = null, ?ThemeBuilderEdits $themeBuilder = null, - ?SimilarSections $similar = null + ?SimilarSections $similar = null, + ?DynamicContentMap $registry = null ): DiviWriter { $design = self::designReader(); @@ -113,7 +115,7 @@ final class DiviWriterFixture self::saver($saved, $failures), self::log(), $design, - self::composition($design, $unfiltered), + self::composition($design, $unfiltered, $registry), $globals, $themeBuilder, $similar diff --git a/tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php b/tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php new file mode 100644 index 0000000..f28f89b --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DiviLoopWriteTest.php @@ -0,0 +1,175 @@ + 'on', 'queryType' => 'post_types']; + private const TERM_LOOP = ['enable' => 'on', 'queryType' => 'post_taxonomies']; + + protected function setUp(): void + { + parent::setUp(); + $this->original = self::page(); + $this->post->post_content = $this->original; + $registry = self::registry(); + $this->writer = DiviWriterFixture::writer( + $this->saved, + registry: new DynamicContentMap(static fn (int $postId): array => $registry) + ); + } + + /** DiviFixtures' registry plus the loop options these tests name (Divi 5.15.1 groups and fields). */ + private static function registry(): array + { + $field = static fn (string $type): array => ['label' => 'x', 'type' => $type, 'default' => '']; + $common = ['before' => $field('text'), 'after' => $field('text'), 'loop_position' => $field('text')]; + $option = static fn (string $id, string $group, array $fields): array => [ + 'id' => $id, 'label' => $id, 'type' => 'text', 'custom' => false, 'group' => $group, 'fields' => $fields, + ]; + + return DiviFixtures::dynamicContentRegistry() + [ + 'loop_post_date' => $option('loop_post_date', 'Loop', $common + [ + 'date_format' => $field('select'), 'custom_date_format' => $field('text'), + ]), + 'loop_term_name' => $option('loop_term_name', 'Loop Terms', $common), + ]; + } + + /** @param array $attrs */ + private static function block(string $name, array $attrs, string $inner = ''): string + { + $json = (string) json_encode($attrs + ['builderVersion' => '5.2.1'], JSON_UNESCAPED_UNICODE); + + return sprintf('%s', $name, $json, $inner, $name); + } + + /** @param array $loop */ + private static function loop(array $loop): array + { + return ['module' => ['advanced' => ['loop' => ['desktop' => ['value' => $loop]]]]]; + } + + private static function text(string $value): string + { + return self::block('text', ['content' => ['innerContent' => ['desktop' => ['value' => $value]]]]); + } + + private static function page(): string + { + $title = VariableRef::serialize('content', 'loop_post_title', ['before' => '', 'after' => '']); + $term = VariableRef::serialize('content', 'loop_term_name', ['before' => '', 'after' => '']); + $own = self::block('text', self::loop(self::TERM_LOOP) + [ + 'content' => ['innerContent' => ['desktop' => ['value' => $term]]], + ]); + + return '' . self::block('section', [], self::block('row', [], implode('', [ + self::block('column', self::loop(self::POST_LOOP), self::text($title) . self::text('

Fest

') . $own), + self::block('column', self::loop(self::TERM_LOOP), self::text('

Term

')), + self::block('column', [], self::text('

Ohne

')), + ]))) . ''; + } + + /** @param array $input */ + private function insertInto(string $parent, array $input): mixed + { + return $this->insert()->execute( + ['post_id' => 7, 'tree_rev' => $this->currentRev(), 'parent' => $parent] + $input + ); + } + + /** @param list> $changes */ + private function changeAll(array $changes): mixed + { + return $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->currentRev(), 'changes' => $changes]); + } + + /** @param array $input */ + private function moveNode(array $input): mixed + { + return $this->move()->execute(['post_id' => 7, 'tree_rev' => $this->currentRev()] + $input); + } + + /** tree_rev of what the page holds now (the saver puts every saved content on the post). */ + private function currentRev(): string + { + return Rev::of((string) $this->post->post_content); + } + + private static function problems(mixed $error): string + { + self::assertInstanceOf(WP_Error::class, $error); + + return implode(' | ', self::errorFields($error)); + } + + public function testInsertUsesTheLoopOfTheTargetParent(): void + { + $result = $this->insertInto('s0.r0.c0', ['nodes' => [ + ['type' => 'text', 'content' => '{{loop:post_date}}'], + ['type' => 'text', 'content' => '{{loop:post_title}}'], + ]]); + + self::assertTrue($result['ok'], (string) json_encode($result)); + $date = VariableRef::serialize('content', 'loop_post_date', ['before' => '', 'after' => '']); + self::assertStringContainsString(str_replace('"', '\\' . 'u0022', $date), $this->saved[0]); + + $term = $this->insertInto('s0.r0.c1', ['nodes' => [['type' => 'text', 'content' => '{{loop:term_name}}']]]); + self::assertTrue($term['ok'], (string) json_encode($term)); + } + + public function testInsertRefusesLoopContentOutsideALoopOrOfAnotherKind(): void + { + $outside = $this->insertInto('s0.r0.c2', ['nodes' => [['type' => 'text', 'content' => '{{loop:post_date}}']]]); + self::assertStringContainsString('braucht eine Divi-Schleife', self::problems($outside)); + + $term = $this->insertInto('s0.r0.c1', ['nodes' => [['type' => 'text', 'content' => '{{loop:post_date}}']]]); + $wrongKind = self::problems($term); + self::assertStringContainsString('passt nicht zur Schleife dieses Elements (Typ: Terme)', $wrongKind); + + $topLevel = $this->insert()->execute([ + 'post_id' => 7, + 'tree_rev' => $this->currentRev(), + 'nodes' => [['type' => 'section', 'modules' => [['type' => 'text', 'content' => '{{loop:post_date}}']]]], + ]); + self::assertStringContainsString('braucht eine Divi-Schleife', self::problems($topLevel)); + self::assertSame([], $this->saved); + } + + /** + * Ruling X3, fail closed: a loop a new node of this call switches on counts only once it is saved – loop + * content in the same call is refused even where the parent's loop would let it pass (a term loop inside the + * post loop c0 would otherwise get post content: Divi reads the post with the term's ID). + */ + public function testANewLoopInTheSameCallIsRefusedWithLoopContent(): void + { + $ownLoop = ['module.advanced.loop' => ['desktop' => self::TERM_LOOP]]; + + foreach (['s0.r0.c0' => '{{loop:post_title}}', 's0.r0.c2' => '{{loop:term_name}}'] as $parent => $content) { + $refused = $this->insertInto($parent, ['nodes' => [ + ['type' => 'text', 'attrs' => $ownLoop, 'content' => $content], + ]]); + self::assertStringContainsString('erst nach dem Speichern', self::problems($refused), $parent); + } + self::assertSame([], $this->saved); + + $withoutLoopContent = $this->insertInto('s0.r0.c2', ['nodes' => [ + ['type' => 'text', 'attrs' => $ownLoop, 'content' => '

Liste

'], + ]]); + self::assertTrue($withoutLoopContent['ok'], (string) json_encode($withoutLoopContent)); + } +}