feat: lit-divi-insert prüft Schleifen-Inhalte im Kontext des Ziels; neue Schleife plus Inhalt in einem Aufruf fail closed (X3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
2f484b77ae
commit
249bc7b932
@@ -6,6 +6,9 @@ namespace LIT\MCP\Integrations\Divi\Pages;
|
|||||||
|
|
||||||
use LIT\MCP\Abilities\Annotations;
|
use LIT\MCP\Abilities\Annotations;
|
||||||
use LIT\MCP\Abilities\Errors;
|
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 LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||||||
use WP_Error;
|
use WP_Error;
|
||||||
|
|
||||||
@@ -17,6 +20,9 @@ defined('ABSPATH') || exit;
|
|||||||
* validated first and every error is returned together; the blocks come only from Composer
|
* 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(),
|
* (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).
|
* 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
|
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'] !== []) {
|
if ($composed['errors'] !== []) {
|
||||||
return DiviErrors::composed($composed, 'Knoten sind ungültig; nichts wurde gespeichert.');
|
return DiviErrors::composed($composed, 'Knoten sind ungültig; nichts wurde gespeichert.');
|
||||||
}
|
}
|
||||||
@@ -117,4 +134,38 @@ final class DiviInsertAbility extends DiviWriteAbility
|
|||||||
|
|
||||||
return $change;
|
return $change;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether one of $blocks or a descendant switches a loop on.
|
||||||
|
*
|
||||||
|
* @param list<Block> $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<array-key, mixed> $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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ namespace LIT\MCP\Integrations\Divi\Pages;
|
|||||||
|
|
||||||
use LIT\MCP\Abilities\Ability;
|
use LIT\MCP\Abilities\Ability;
|
||||||
use LIT\MCP\Abilities\ToolResult;
|
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\DiviFeature;
|
||||||
use LIT\MCP\Integrations\Divi\Release\FeatureSets;
|
use LIT\MCP\Integrations\Divi\Release\FeatureSets;
|
||||||
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
|
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
|
||||||
@@ -114,4 +118,22 @@ abstract class DiviWriteAbility implements Ability, RequiresDiviFeatures
|
|||||||
{
|
{
|
||||||
return null;
|
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<string, array<string, mixed>> $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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string> $saved
|
* @param list<string> $saved
|
||||||
* @param list<Throwable> $failures
|
* @param list<Throwable> $failures
|
||||||
@@ -104,7 +105,8 @@ final class DiviWriterFixture
|
|||||||
?DiviPageLoader $loader = null,
|
?DiviPageLoader $loader = null,
|
||||||
?GlobalEdits $globals = null,
|
?GlobalEdits $globals = null,
|
||||||
?ThemeBuilderEdits $themeBuilder = null,
|
?ThemeBuilderEdits $themeBuilder = null,
|
||||||
?SimilarSections $similar = null
|
?SimilarSections $similar = null,
|
||||||
|
?DynamicContentMap $registry = null
|
||||||
): DiviWriter {
|
): DiviWriter {
|
||||||
$design = self::designReader();
|
$design = self::designReader();
|
||||||
|
|
||||||
@@ -113,7 +115,7 @@ final class DiviWriterFixture
|
|||||||
self::saver($saved, $failures),
|
self::saver($saved, $failures),
|
||||||
self::log(),
|
self::log(),
|
||||||
$design,
|
$design,
|
||||||
self::composition($design, $unfiltered),
|
self::composition($design, $unfiltered, $registry),
|
||||||
$globals,
|
$globals,
|
||||||
$themeBuilder,
|
$themeBuilder,
|
||||||
$similar
|
$similar
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\Rev;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||||||
|
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||||
|
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||||||
|
use WP_Error;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruling X3: lit/divi-insert, lit/divi-update and lit/divi-move use the loop scope of the node in the tree
|
||||||
|
* (LoopContext). The page: s0.r0.c0 runs a post loop (m0 shows loop_post_title, m1 is plain text, m2 runs its own
|
||||||
|
* term loop and shows loop_term_name), s0.r0.c1 a term loop, s0.r0.c2 no loop.
|
||||||
|
*/
|
||||||
|
final class DiviLoopWriteTest extends DiviWriteAbilityTestCase
|
||||||
|
{
|
||||||
|
private const POST_LOOP = ['enable' => '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<string, mixed> $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('<!-- wp:divi/%s %s -->%s<!-- /wp:divi/%s -->', $name, $json, $inner, $name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, string> $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 '<!-- wp:divi/placeholder -->' . self::block('section', [], self::block('row', [], implode('', [
|
||||||
|
self::block('column', self::loop(self::POST_LOOP), self::text($title) . self::text('<p>Fest</p>') . $own),
|
||||||
|
self::block('column', self::loop(self::TERM_LOOP), self::text('<p>Term</p>')),
|
||||||
|
self::block('column', [], self::text('<p>Ohne</p>')),
|
||||||
|
]))) . '<!-- /wp:divi/placeholder -->';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $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<array<string, mixed>> $changes */
|
||||||
|
private function changeAll(array $changes): mixed
|
||||||
|
{
|
||||||
|
return $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->currentRev(), 'changes' => $changes]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $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' => '<p>Liste</p>'],
|
||||||
|
]]);
|
||||||
|
self::assertTrue($withoutLoopContent['ok'], (string) json_encode($withoutLoopContent));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user