feat: Divi tree editor and quality warnings
TreeEditor (position/insert/move/remove/duplicate on the BlockTree) and Warnings (post-write quality checks), plus S18's outline breadth limit on the 2a Outline/DiviOutlineAbility. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Container;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Document;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||
|
||||
/**
|
||||
* Structural changes on a parsed page (spec §5.7): where to insert (parent / before / after,
|
||||
* optional index), move, remove, duplicate – always checked against StructureRules. Works on
|
||||
* the tree only; saving is DiviWriter's job. Pure PHP.
|
||||
*/
|
||||
final class TreeEditor
|
||||
{
|
||||
public function __construct(private StructureRules $rules)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a target position: exactly one of parent, before, after; index only with parent.
|
||||
* No position at all means "end of the page".
|
||||
*
|
||||
* @param array<string, mixed> $input
|
||||
* @return array{container: Container, parent: ?Block, index: int}|string Position or German error.
|
||||
*/
|
||||
public function position(NodeIndex $index, array $input): array|string
|
||||
{
|
||||
$given = array_filter(
|
||||
[
|
||||
'parent' => $input['parent'] ?? null,
|
||||
'before' => $input['before'] ?? null,
|
||||
'after' => $input['after'] ?? null,
|
||||
],
|
||||
static fn (mixed $value): bool => is_string($value) && $value !== ''
|
||||
);
|
||||
if (count($given) > 1) {
|
||||
return 'nur eines von parent, before oder after angeben';
|
||||
}
|
||||
|
||||
$at = isset($input['index']) && is_int($input['index']) ? max(0, $input['index']) : PHP_INT_MAX;
|
||||
if ($given === []) {
|
||||
return ['container' => $index->root(), 'parent' => null, 'index' => $at];
|
||||
}
|
||||
|
||||
$key = (string) array_key_first($given);
|
||||
$node = $index->find((string) $given[$key]);
|
||||
if ($node === null) {
|
||||
return sprintf('Knoten "%s" gibt es nicht', (string) $given[$key]);
|
||||
}
|
||||
|
||||
if ($key === 'parent') {
|
||||
return ['container' => $node, 'parent' => $node, 'index' => $at];
|
||||
}
|
||||
|
||||
$container = $index->parentOf($node);
|
||||
if ($container === null) {
|
||||
return sprintf('Knoten "%s" hat keinen Elternknoten', (string) $given[$key]);
|
||||
}
|
||||
|
||||
$sibling = (int) $container->indexOf($node);
|
||||
|
||||
return [
|
||||
'container' => $container,
|
||||
'parent' => $container instanceof Block && $container->name() !== 'divi/placeholder' ? $container : null,
|
||||
'index' => $key === 'before' ? $sibling : $sibling + 1,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{container: Container, parent: ?Block, index: int} $position
|
||||
* @param list<Block> $blocks
|
||||
*/
|
||||
public function insert(array $position, array $blocks): ?string
|
||||
{
|
||||
foreach ($blocks as $block) {
|
||||
$violation = $this->rules->violation($position['parent'], $block->name());
|
||||
if ($violation !== null) {
|
||||
return $violation;
|
||||
}
|
||||
}
|
||||
|
||||
$container = $this->targetContainer($position['container']);
|
||||
$start = $position['index'];
|
||||
foreach (array_values($blocks) as $offset => $block) {
|
||||
$at = $start === PHP_INT_MAX ? PHP_INT_MAX : $start + $offset;
|
||||
$container->insertChild($at, $block);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @param array{container: Container, parent: ?Block, index: int} $position */
|
||||
public function move(NodeIndex $index, Block $node, array $position): ?string
|
||||
{
|
||||
for ($ancestor = $position['parent']; $ancestor !== null; $ancestor = $this->parentBlock($index, $ancestor)) {
|
||||
if ($ancestor === $node) {
|
||||
return 'ein Knoten kann nicht in sich selbst verschoben werden';
|
||||
}
|
||||
}
|
||||
|
||||
$violation = $this->rules->violation($position['parent'], $node->name());
|
||||
if ($violation !== null) {
|
||||
return $violation;
|
||||
}
|
||||
|
||||
$from = $index->parentOf($node);
|
||||
if ($from === null) {
|
||||
return 'der Knoten hat keinen Elternknoten';
|
||||
}
|
||||
|
||||
$old = $from->removeChild($node);
|
||||
$target = $position['index'];
|
||||
if ($from === $position['container'] && $target !== PHP_INT_MAX && $old < $target) {
|
||||
--$target;
|
||||
}
|
||||
$position['container']->insertChild($target, $node);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function remove(NodeIndex $index, Block $node): void
|
||||
{
|
||||
$index->parentOf($node)?->removeChild($node);
|
||||
}
|
||||
|
||||
/** Inserts a copy right after $node and returns it. Never touches $node or the copy afterwards. */
|
||||
public function duplicate(NodeIndex $index, Block $node): Block
|
||||
{
|
||||
$copy = $node->copy();
|
||||
$container = $index->parentOf($node);
|
||||
if ($container !== null) {
|
||||
$container->insertChild((int) $container->indexOf($node) + 1, $copy);
|
||||
}
|
||||
|
||||
return $copy;
|
||||
}
|
||||
|
||||
private function parentBlock(NodeIndex $index, Block $block): ?Block
|
||||
{
|
||||
$parent = $index->parentOf($block);
|
||||
|
||||
return $parent instanceof Block && $parent->name() !== 'divi/placeholder' ? $parent : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruling S9: inserting at the document root of an empty page (no divi/placeholder yet)
|
||||
* first wraps it in one, the same way DiviContentComposer (Task 9) writes new content –
|
||||
* so a first-section page gets the structure the Visual Builder itself writes.
|
||||
*/
|
||||
private function targetContainer(Container $container): Container
|
||||
{
|
||||
if (! $container instanceof Document || $container->children() !== []) {
|
||||
return $container;
|
||||
}
|
||||
|
||||
$placeholder = Block::create('divi/placeholder');
|
||||
$container->insertChild(0, $placeholder);
|
||||
|
||||
return $placeholder;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user