feat: lit-divi-move warnt, wenn Schleifen-Inhalte ihre Schleife verlassen oder in eine andere Art kommen (X3, A11)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 20:52:53 +02:00
co-authored by Claude Sonnet 5.5
parent 8888d246a9
commit ed6fe12ada
2 changed files with 99 additions and 2 deletions
@@ -6,6 +6,10 @@ 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\LoopScope;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use WP_Error;
@@ -14,7 +18,8 @@ defined('ABSPATH') || exit;
/**
* lit/divi-move: moves a node (with everything inside) to parent / before / after – also into
* another row or section, as long as the structure rules allow it (spec §5.7). Read-only
* nodes (global modules, foreign blocks) move as a whole, unchanged.
* nodes (global modules, foreign blocks) move as a whole, unchanged. Loop content that leaves its loop scope (out
* of a loop, into one of another kind) is moved with a warning – Divi allows it (rulings X3, A11).
*/
final class DiviMoveAbility extends DiviWriteAbility
{
@@ -76,6 +81,9 @@ final class DiviMoveAbility extends DiviWriteAbility
return DiviErrors::position($position);
}
// Both scopes from the tree before the move: the new parent never lies inside $node (TreeEditor::move()).
$from = self::loopAt($page->index, $page->index->parentBlock($node));
$to = self::loopAt($page->index, $position['parent']);
$problem = $editor->move($page->index, $node, $position);
if ($problem !== null) {
return Errors::invalidInput(
@@ -84,6 +92,58 @@ final class DiviMoveAbility extends DiviWriteAbility
);
}
return (new DiviChange(sprintf('Knoten %s verschoben', $id)))->record($node, 'moved');
$change = (new DiviChange(sprintf('Knoten %s verschoben', $id)))->record($node, 'moved');
$names = self::loopContent($node);
if ($names !== [] && $from->kind() !== $to->kind()) {
$change->warning(self::loopWarning($names, $from, $to));
}
return $change;
}
/**
* The loop_* options $block and its descendants show in the scope around $block; a block that runs its own
* loop keeps its content's scope, read-only blocks are not entered.
*
* @return list<string>
*/
private static function loopContent(Block $block): array
{
if ($block->isOpaque() || LoopContext::enabled($block->attrs())) {
return [];
}
$names = [];
$attrs = $block->attrs();
array_walk_recursive($attrs, static function (mixed $value) use (&$names): void {
$name = is_string($value) ? VariableRef::nameOf($value) : null;
if ($name !== null && str_starts_with($name, 'loop_')) {
$names[] = $name;
}
});
foreach ($block->children() as $child) {
array_push($names, ...self::loopContent($child));
}
return array_values(array_unique($names));
}
/** @param list<string> $names */
private static function loopWarning(array $names, LoopScope $from, LoopScope $to): string
{
$where = $to->active()
? sprintf(
'jetzt in einer Divi-Schleife vom Typ: %s (vorher: %s); passen sie nicht zu dieser Art, zeigt Divi '
. 'sie leer oder mit Daten eines anderen Objekts mit derselben ID',
$to->label(),
$from->active() ? $from->label() : 'keine Schleife'
)
: 'jetzt außerhalb jeder Divi-Schleife; Divi zeigt sie dort leer';
return sprintf(
'Schleifen-Inhalte (%s) liegen %s. Zurückverschieben oder die Inhalte anpassen.',
implode(', ', $names),
$where
);
}
}
@@ -221,4 +221,41 @@ final class DiviLoopWriteTest extends DiviWriteAbilityTestCase
self::assertStringContainsString('Typ: Terme', $problems['changes[1].set.content'] ?? '');
self::assertCount(2, $this->saved);
}
public function testMovingLoopContentOutOfItsLoopWarns(): void
{
$out = $this->moveNode([
'id' => 's0.r0.c0.m0', 'parent' => 's0.r0.c2',
]);
self::assertTrue($out['ok'], 'Divi allows the move (A11): a warning, no refusal');
$warnings = implode(' | ', $out['warnings']);
self::assertStringContainsString('loop_post_title', $warnings);
self::assertStringContainsString('außerhalb jeder Divi-Schleife', $warnings);
$other = $this->moveNode([
'id' => 's0.r0.c2.m1', 'parent' => 's0.r0.c1',
]);
self::assertStringContainsString('Typ: Terme (vorher: keine Schleife)', implode(' | ', $other['warnings']));
}
public function testMovesThatKeepTheLoopDoNotWarn(): void
{
// Path IDs are positions: after this move c0 holds [Fest, title, own loop].
$withinLoop = $this->moveNode([
'id' => 's0.r0.c0.m0', 'after' => 's0.r0.c0.m1',
]);
// m0 is the plain text now: nothing to warn about when it leaves the loop.
$plain = $this->moveNode([
'id' => 's0.r0.c0.m0', 'parent' => 's0.r0.c2',
]);
// m1 is the module with its own term loop now: it takes the loop along.
$loopItself = $this->moveNode([
'id' => 's0.r0.c0.m1', 'parent' => 's0.r0.c2',
]);
foreach ([$withinLoop, $plain, $loopItself] as $result) {
self::assertTrue($result['ok'], (string) json_encode($result));
self::assertStringNotContainsString('Schleife', implode(' | ', $result['warnings']));
}
}
}