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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
|
||||
use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
|
||||
|
||||
/**
|
||||
* Quality warnings of a Divi write (spec §5.5) – only about the nodes this call created or
|
||||
* changed (and their children): fixed colors/fonts where a global one fits, images without
|
||||
* alt text, empty modules, heading jumps, the code module. Similar existing sections
|
||||
* (lit/library-find-similar) follow with the Library in Etappe 3. Pure PHP.
|
||||
*/
|
||||
final class Warnings
|
||||
{
|
||||
/** RGB distance up to which a fixed color counts as "the same" as a global color. */
|
||||
private const SIMILAR = 24.0;
|
||||
|
||||
/** Default heading level per module (Divi's default render attributes). */
|
||||
private const HEADING_DEFAULTS = ['divi/heading' => 1, 'divi/blurb' => 4, 'divi/cta' => 2];
|
||||
|
||||
private const IMAGE_SOURCES = [
|
||||
'divi/image' => ['image.innerContent', 'src', 'alt'],
|
||||
'divi/blurb' => ['imageIcon.innerContent', 'src', 'alt'],
|
||||
'divi/slide' => ['image.innerContent', 'src', 'alt'],
|
||||
];
|
||||
|
||||
public function __construct(private DesignSystem $design)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Block> $touched Created or changed blocks (their descendants count too).
|
||||
* @return list<string>
|
||||
*/
|
||||
public function collect(NodeIndex $index, array $touched): array
|
||||
{
|
||||
$blocks = [];
|
||||
foreach ($touched as $block) {
|
||||
foreach ($this->withDescendants($block) as $node) {
|
||||
$blocks[spl_object_id($node)] = $node;
|
||||
}
|
||||
}
|
||||
|
||||
$warnings = [];
|
||||
foreach ($blocks as $block) {
|
||||
$id = $index->idOf($block) ?? '?';
|
||||
array_push(
|
||||
$warnings,
|
||||
...$this->code($block, $id),
|
||||
...$this->image($block, $id),
|
||||
...$this->empty($block, $id),
|
||||
...$this->fixedValues($block, $id)
|
||||
);
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_merge($warnings, $this->headings($index, $blocks))));
|
||||
}
|
||||
|
||||
/**
|
||||
* Opaque blocks (global modules) are never descended into – their content is edited only in
|
||||
* the Divi Builder (Etappe 3), so no warning ever looks inside one.
|
||||
*
|
||||
* @return list<Block>
|
||||
*/
|
||||
private function withDescendants(Block $block): array
|
||||
{
|
||||
$all = [$block];
|
||||
if (! $block->isOpaque()) {
|
||||
foreach ($block->children() as $child) {
|
||||
array_push($all, ...$this->withDescendants($child));
|
||||
}
|
||||
}
|
||||
|
||||
return $all;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function code(Block $block, string $id): array
|
||||
{
|
||||
return in_array($block->name(), ['divi/code', 'divi/fullwidth-code'], true)
|
||||
? [sprintf('%s: Code-Modul verwendet – im Divi-Builder nicht visuell bearbeitbar; nur nutzen, wenn kein '
|
||||
. 'natives Modul passt.', $id)]
|
||||
: [];
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function image(Block $block, string $id): array
|
||||
{
|
||||
$spec = self::IMAGE_SOURCES[$block->name()] ?? null;
|
||||
if ($spec === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$value = AttrMap::desktop($block->attrs(), $spec[0]);
|
||||
$src = is_array($value) ? ($value[$spec[1]] ?? '') : '';
|
||||
$alt = is_array($value) ? ($value[$spec[2]] ?? '') : '';
|
||||
|
||||
return is_string($src) && $src !== '' && (! is_string($alt) || trim($alt) === '')
|
||||
? [sprintf('%s: Bild ohne Alt-Text.', $id)]
|
||||
: [];
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function empty(Block $block, string $id): array
|
||||
{
|
||||
$name = $block->name();
|
||||
if (in_array($name, ['divi/section', 'divi/row', 'divi/column', 'divi/group'], true)) {
|
||||
return $block->children() === [] ? [sprintf('%s: leer (%s ohne Inhalt).', $id, $block->shortName())] : [];
|
||||
}
|
||||
|
||||
$type = $block->shortName();
|
||||
if (ShortformMap::childType($type) !== null && $block->children() === []) {
|
||||
return [sprintf('%s: %s ohne Einträge.', $id, $type)];
|
||||
}
|
||||
|
||||
$primary = ShortformMap::primary($type);
|
||||
if ($primary === null || ShortformMap::childType($type) !== null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$values = ShortformMap::values($type, $block->attrs());
|
||||
$value = $values[$primary] ?? '';
|
||||
|
||||
return NodeSummary::textOf($value) === '' ? [sprintf('%s: %s ist leer (%s fehlt).', $id, $type, $primary)] : [];
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function fixedValues(Block $block, string $id): array
|
||||
{
|
||||
$warnings = [];
|
||||
$fonts = array_filter($this->design->fonts());
|
||||
foreach ($this->leaves($block->attrs()) as [$key, $value]) {
|
||||
if (preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', trim($value)) === 1) {
|
||||
$match = $this->similarColor($value);
|
||||
if ($match !== null) {
|
||||
$warnings[] = sprintf(
|
||||
'%s: feste Farbe %s – globale Farbe "%s" (%s) passt.',
|
||||
$id,
|
||||
$value,
|
||||
$match['label'],
|
||||
DesignSystem::colorPlaceholder($match['id'], $match['label'])
|
||||
);
|
||||
}
|
||||
} elseif ($key === 'family') {
|
||||
$font = array_search(mb_strtolower(trim($value)), array_map('mb_strtolower', $fonts), true);
|
||||
if ($font !== false) {
|
||||
$warnings[] = sprintf('%s: feste Schrift %s – {{font:%s}} verwenden.', $id, $value, $font);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Heading jumps (h2 → h4) in page order where the later heading belongs to a touched block.
|
||||
*
|
||||
* @param array<int, Block> $touched
|
||||
* @return list<string>
|
||||
*/
|
||||
private function headings(NodeIndex $index, array $touched): array
|
||||
{
|
||||
$warnings = [];
|
||||
$previous = null;
|
||||
foreach ($index->entries() as $entry) {
|
||||
$block = $entry['block'];
|
||||
foreach ($this->levels($block) as $level) {
|
||||
if ($previous !== null && $level > $previous + 1 && isset($touched[spl_object_id($block)])) {
|
||||
$warnings[] = sprintf(
|
||||
'%s: Sprung in der Überschriftenhierarchie (h%d nach h%d).',
|
||||
$entry['id'],
|
||||
$level,
|
||||
$previous
|
||||
);
|
||||
}
|
||||
$previous = $level;
|
||||
}
|
||||
}
|
||||
|
||||
return $warnings;
|
||||
}
|
||||
|
||||
/** @return list<int> Heading levels a block renders, in order. */
|
||||
private function levels(Block $block): array
|
||||
{
|
||||
$default = self::HEADING_DEFAULTS[$block->name()] ?? null;
|
||||
if ($default !== null) {
|
||||
$font = AttrMap::desktop($block->attrs(), 'title.decoration.font.font');
|
||||
$level = is_array($font) && is_string($font['headingLevel'] ?? null)
|
||||
? $font['headingLevel']
|
||||
: 'h' . $default;
|
||||
|
||||
return [(int) substr($level, 1)];
|
||||
}
|
||||
|
||||
if ($block->name() === 'divi/text') {
|
||||
$html = AttrMap::desktop($block->attrs(), 'content.innerContent');
|
||||
preg_match_all('/<h([1-6])\b/i', is_string($html) ? $html : '', $matches);
|
||||
|
||||
return array_map('intval', $matches[1]);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @return array{id: string, label: string, value: string}|null */
|
||||
private function similarColor(string $hex): ?array
|
||||
{
|
||||
$rgb = self::rgb($hex);
|
||||
foreach ($this->design->colors() as $color) {
|
||||
$other = self::rgb($color['value']);
|
||||
if ($rgb !== null && $other !== null && self::distance($rgb, $other) <= self::SIMILAR) {
|
||||
return $color;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array-key, mixed> $values
|
||||
* @return list<array{0: string, 1: string}> [last key, value] of every string leaf (Divi
|
||||
* references excluded – a fixed value that references a global one is not "fixed").
|
||||
*/
|
||||
private function leaves(array $values): array
|
||||
{
|
||||
$leaves = [];
|
||||
foreach ($values as $name => $value) {
|
||||
if (is_array($value)) {
|
||||
array_push($leaves, ...$this->leaves($value));
|
||||
} elseif (is_string($value) && ! VariableRef::contains($value)) {
|
||||
$leaves[] = [(string) $name, $value];
|
||||
}
|
||||
}
|
||||
|
||||
return $leaves;
|
||||
}
|
||||
|
||||
/** @return array{int, int, int}|null */
|
||||
private static function rgb(string $hex): ?array
|
||||
{
|
||||
$hex = ltrim(trim($hex), '#');
|
||||
if (preg_match('/^([0-9a-f]{3}|[0-9a-f]{6})$/i', $hex) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (strlen($hex) === 3) {
|
||||
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
|
||||
}
|
||||
|
||||
return [(int) hexdec(substr($hex, 0, 2)), (int) hexdec(substr($hex, 2, 2)), (int) hexdec(substr($hex, 4, 2))];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{int, int, int} $a
|
||||
* @param array{int, int, int} $b
|
||||
*/
|
||||
private static function distance(array $a, array $b): float
|
||||
{
|
||||
return sqrt(($a[0] - $b[0]) ** 2 + ($a[1] - $b[1]) ** 2 + ($a[2] - $b[2]) ** 2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user