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:
2026-09-26 14:42:26 +02:00
co-authored by Claude Opus 5.5
parent 8688a02402
commit ea1fd607b2
8 changed files with 791 additions and 7 deletions
+46 -3
View File
@@ -7,21 +7,44 @@ namespace LIT\MCP\Integrations\Divi\BlockTree;
/** /**
* The page tree as rows and as compact text (lit-divi-outline, spec §5.3/§5.7; also the * The page tree as rows and as compact text (lit-divi-outline, spec §5.3/§5.7; also the
* "outline" of write results). Preset IDs are shown with their names when known. * "outline" of write results). Preset IDs are shown with their names when known.
*
* Ruling S18: a page can have far more nodes at one level than are useful to show (e.g. a
* 200-item slider). rows() therefore caps the number of *direct* children shown per parent
* (breadth, not depth) at $limit; the parent whose children got cut is marked
* 'truncated' => true. wasTruncated() additionally reports whether ANY cap fired, including at
* the top level (the page root has no row of its own to mark).
*/ */
final class Outline final class Outline
{ {
/** Sane default breadth cap per parent when no explicit limit is given. */
public const DEFAULT_LIMIT = 50;
private bool $truncated = false;
/** @param array<string, string> $presetNames Preset ID => name. */ /** @param array<string, string> $presetNames Preset ID => name. */
public function __construct(private array $presetNames = []) public function __construct(private array $presetNames = [])
{ {
} }
/** Whether the last rows() call cut off any children (at any level, including the top). */
public function wasTruncated(): bool
{
return $this->truncated;
}
/** /**
* @return list<array{id: string, type: string, label: string, excerpt: string, info: string, * @return list<array{id: string, type: string, label: string, excerpt: string, info: string,
* presets: list<string>, children: int, editable: bool, depth: int}> * presets: list<string>, children: int, editable: bool, depth: int, truncated: bool}>
*/ */
public function rows(NodeIndex $index, ?string $rootId = null, ?int $maxDepth = null): array public function rows(NodeIndex $index, ?string $rootId = null, ?int $maxDepth = null, ?int $limit = null): array
{ {
$limit = $limit ?? self::DEFAULT_LIMIT;
$this->truncated = false;
$rows = []; $rows = [];
$rowIndexById = [];
$counts = [];
$skipped = [];
$baseDepth = null; $baseDepth = null;
foreach ($index->entries() as $entry) { foreach ($index->entries() as $entry) {
$id = $entry['id']; $id = $entry['id'];
@@ -29,12 +52,30 @@ final class Outline
continue; continue;
} }
$parentId = $index->parentId($entry['block']);
if ($parentId !== null && isset($skipped[$parentId])) {
$skipped[$id] = true;
continue;
}
$baseDepth ??= $entry['depth']; $baseDepth ??= $entry['depth'];
$depth = $entry['depth'] - $baseDepth; $depth = $entry['depth'] - $baseDepth;
if ($maxDepth !== null && $depth > $maxDepth) { if ($maxDepth !== null && $depth > $maxDepth) {
continue; continue;
} }
$parentKey = $parentId ?? '';
$counts[$parentKey] = ($counts[$parentKey] ?? 0) + 1;
if ($counts[$parentKey] > $limit) {
$this->truncated = true;
$skipped[$id] = true;
if ($parentId !== null && isset($rowIndexById[$parentId])) {
$rows[$rowIndexById[$parentId]]['truncated'] = true;
}
continue;
}
$rowIndexById[$id] = count($rows);
$rows[] = $this->row($entry['block'], $id, $depth); $rows[] = $this->row($entry['block'], $id, $depth);
} }
@@ -52,6 +93,7 @@ final class Outline
$line .= $row['presets'] !== [] ? ' {Preset: ' . implode(', ', $row['presets']) . '}' : ''; $line .= $row['presets'] !== [] ? ' {Preset: ' . implode(', ', $row['presets']) . '}' : '';
$line .= $row['excerpt'] !== '' ? ' – ' . $row['excerpt'] : ''; $line .= $row['excerpt'] !== '' ? ' – ' . $row['excerpt'] : '';
$line .= $row['editable'] ? '' : ' (nur lesbar)'; $line .= $row['editable'] ? '' : ' (nur lesbar)';
$line .= ($row['truncated'] ?? false) ? ' (weitere Kinder abgeschnitten)' : '';
$lines[] = $line; $lines[] = $line;
} }
@@ -60,7 +102,7 @@ final class Outline
/** /**
* @return array{id: string, type: string, label: string, excerpt: string, info: string, * @return array{id: string, type: string, label: string, excerpt: string, info: string,
* presets: list<string>, children: int, editable: bool, depth: int} * presets: list<string>, children: int, editable: bool, depth: int, truncated: bool}
*/ */
private function row(Block $block, string $id, int $depth): array private function row(Block $block, string $id, int $depth): array
{ {
@@ -79,6 +121,7 @@ final class Outline
'children' => $editable ? count($block->children()) : 0, 'children' => $editable ? count($block->children()) : 0,
'editable' => $editable, 'editable' => $editable,
'depth' => $depth, 'depth' => $depth,
'truncated' => false,
]; ];
} }
} }
@@ -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;
}
}
+269
View File
@@ -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);
}
}
@@ -36,7 +36,8 @@ final class DiviOutlineAbility implements Ability
{ {
return 'Liest den Aufbau einer Divi-Seite: Knoten mit Pfad-ID (s0, s0.r1, s0.r1.c0.m2), Modultyp, ' return 'Liest den Aufbau einer Divi-Seite: Knoten mit Pfad-ID (s0, s0.r1, s0.r1.c0.m2), Modultyp, '
. 'Admin-Label, Textauszug und Preset. Liefert tree_rev – Pflicht für jede Divi-Änderung. ' . 'Admin-Label, Textauszug und Preset. Liefert tree_rev – Pflicht für jede Divi-Änderung. '
. 'Optional nur ein Teilbaum (id) oder begrenzte Tiefe (depth).'; . 'Optional nur ein Teilbaum (id), begrenzte Tiefe (depth) oder begrenzte Breite je Ebene (limit); '
. 'abgeschnittene Kinder zeigt "truncated".';
} }
public function inputSchema(): array public function inputSchema(): array
@@ -52,6 +53,13 @@ final class DiviOutlineAbility implements Ability
'maximum' => 10, 'maximum' => 10,
'description' => 'Maximale Tiefe unterhalb des Startknotens (0 = nur der Knoten).', 'description' => 'Maximale Tiefe unterhalb des Startknotens (0 = nur der Knoten).',
], ],
'limit' => [
'type' => 'integer',
'minimum' => 1,
'maximum' => 500,
'description' => 'Höchstzahl Kind-Knoten je Elternknoten (Standard: '
. Outline::DEFAULT_LIMIT . '). Überzählige Kinder werden abgeschnitten (truncated).',
],
], ],
'required' => ['post_id'], 'required' => ['post_id'],
'additionalProperties' => false, 'additionalProperties' => false,
@@ -83,16 +91,26 @@ final class DiviOutlineAbility implements Ability
'children' => ['type' => 'integer'], 'children' => ['type' => 'integer'],
'editable' => ['type' => 'boolean'], 'editable' => ['type' => 'boolean'],
'depth' => ['type' => 'integer'], 'depth' => ['type' => 'integer'],
'truncated' => [
'type' => 'boolean',
'description' => 'Weitere Kinder dieses Knotens wurden wegen "limit" abgeschnitten.',
],
], ],
'required' => [ 'required' => [
'id', 'type', 'label', 'excerpt', 'info', 'presets', 'children', 'editable', 'depth', 'id', 'type', 'label', 'excerpt', 'info', 'presets', 'children', 'editable', 'depth',
'truncated',
], ],
], ],
], ],
'truncated' => [
'type' => 'boolean',
'description' => 'Irgendwo im Baum wurden Kind-Knoten wegen "limit" abgeschnitten (auch auf '
. 'oberster Ebene, die kein eigenes "truncated" an einem Knoten zeigen kann).',
],
'edit_url' => ['type' => 'string'], 'edit_url' => ['type' => 'string'],
'hints' => ['type' => 'array', 'items' => ['type' => 'string']], 'hints' => ['type' => 'array', 'items' => ['type' => 'string']],
], ],
'required' => ['post_id', 'tree_rev', 'rev', 'outline', 'nodes', 'hints'], 'required' => ['post_id', 'tree_rev', 'rev', 'outline', 'nodes', 'truncated', 'hints'],
]; ];
} }
@@ -129,7 +147,12 @@ final class DiviOutlineAbility implements Ability
} }
$outline = new Outline($this->design->read()->presetNames()); $outline = new Outline($this->design->read()->presetNames());
$rows = $outline->rows($page->index, $rootId, isset($input['depth']) ? (int) $input['depth'] : null); $rows = $outline->rows(
$page->index,
$rootId,
isset($input['depth']) ? (int) $input['depth'] : null,
isset($input['limit']) ? (int) $input['limit'] : null
);
return [ return [
'post_id' => (int) $page->post->ID, 'post_id' => (int) $page->post->ID,
@@ -139,6 +162,7 @@ final class DiviOutlineAbility implements Ability
'rev' => Rev::ofPost($page->post), 'rev' => Rev::ofPost($page->post),
'outline' => $outline->text($rows), 'outline' => $outline->text($rows),
'nodes' => $rows, 'nodes' => $rows,
'truncated' => $outline->wasTruncated(),
'edit_url' => $page->editUrl(), 'edit_url' => $page->editUrl(),
'hints' => $this->hints($page, $rows), 'hints' => $this->hints($page, $rows),
]; ];
@@ -21,10 +21,12 @@ final class OutlineTest extends TestCase
$outline = new Outline(['a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' => 'Hero-Titel']); $outline = new Outline(['a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' => 'Hero-Titel']);
$rows = $outline->rows($index); $rows = $outline->rows($index);
self::assertFalse($outline->wasTruncated());
self::assertSame( self::assertSame(
['id' => 's0.r0.c1.m0', 'type' => 'heading', 'label' => '', 'excerpt' => 'Willkommen bei Müller & Söhne', ['id' => 's0.r0.c1.m0', 'type' => 'heading', 'label' => '', 'excerpt' => 'Willkommen bei Müller & Söhne',
'info' => '', 'presets' => ['Hero-Titel'], 'children' => 0, 'editable' => true, 'depth' => 3], 'info' => '', 'presets' => ['Hero-Titel'], 'children' => 0, 'editable' => true, 'depth' => 3,
'truncated' => false],
$rows[5] $rows[5]
); );
self::assertSame('Spalten 8_24+16_24', $rows[1]['info']); self::assertSame('Spalten 8_24+16_24', $rows[1]['info']);
@@ -50,6 +52,36 @@ final class OutlineTest extends TestCase
self::assertSame(2, $rows[1]['children']); self::assertSame(2, $rows[1]['children']);
} }
/** Ruling S18: outline breadth limit – caps children per parent, marks the parent truncated. */
public function testBreadthLimitCapsChildrenPerParentAndMarksTruncation(): void
{
$index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-basic.html')));
$outline = new Outline();
// s0.r0.c1 has three children (heading, text, button); cap them at two.
$rows = $outline->rows($index, 's0.r0.c1', null, 2);
self::assertSame(['s0.r0.c1', 's0.r0.c1.m0', 's0.r0.c1.m1'], array_column($rows, 'id'));
self::assertTrue($rows[0]['truncated'], 'the parent whose children were cut is marked');
self::assertFalse($rows[1]['truncated']);
self::assertTrue($outline->wasTruncated());
$full = (new Outline())->rows($index, 's0.r0.c1');
self::assertSame(4, count($full), 'the default limit (50) does not cut three children');
}
/** The page root has no row of its own, so a top-level cap can only show up in wasTruncated(). */
public function testBreadthLimitAtTheTopLevelStillReportsTruncation(): void
{
$index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-basic.html')));
$outline = new Outline();
$rows = $outline->rows($index, null, 0, 1);
self::assertSame(['s0'], array_column($rows, 'id'));
self::assertTrue($outline->wasTruncated());
}
public function testReadOnlyNodesAreMarked(): void public function testReadOnlyNodesAreMarked(): void
{ {
$index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-mixed.html'))); $index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-mixed.html')));
@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\Document;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Compose\TreeEditor;
use PHPUnit\Framework\TestCase;
final class TreeEditorTest extends TestCase
{
private function document(string $name = 'synthetic-basic.html'): Document
{
return (new Parser())->parse((string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/' . $name));
}
public function testResolvesPositions(): void
{
$document = $this->document();
$index = NodeIndex::build($document);
$editor = new TreeEditor(ComposeFixture::rules());
$end = $editor->position($index, []);
self::assertSame($index->root(), $end['container']);
self::assertNull($end['parent']);
$inColumn = $editor->position($index, ['parent' => 's0.r0.c1', 'index' => 1]);
self::assertSame($index->find('s0.r0.c1'), $inColumn['parent']);
self::assertSame(1, $inColumn['index']);
$after = $editor->position($index, ['after' => 's0.r0.c1.m0']);
self::assertSame($index->find('s0.r0.c1'), $after['container']);
self::assertSame(1, $after['index']);
$beforeSection = $editor->position($index, ['before' => 's1']);
self::assertNull($beforeSection['parent']);
self::assertSame(1, $beforeSection['index']);
self::assertSame('nur eines von parent, before oder after angeben', $editor->position($index, ['parent' => 's0', 'after' => 's1']));
self::assertSame('Knoten "s9" gibt es nicht', $editor->position($index, ['before' => 's9']));
}
public function testInsertChecksTheStructureRules(): void
{
$document = $this->document();
$index = NodeIndex::build($document);
$editor = new TreeEditor(ComposeFixture::rules());
$button = Block::create('divi/button');
self::assertSame(
'auf oberster Ebene sind nur Abschnitte (section) erlaubt',
$editor->insert($editor->position($index, []), [$button])
);
self::assertStringContainsString('Zeilen', (string) $editor->insert($editor->position($index, ['parent' => 's0']), [$button]));
self::assertStringContainsString('Spalten entstehen mit der Zeile', (string) $editor->insert($editor->position($index, ['parent' => 's0.r0']), [$button]));
self::assertStringContainsString('accordion-item', (string) $editor->insert($editor->position($index, ['parent' => 's1.r0.c1.m0']), [$button]));
self::assertNull($editor->insert($editor->position($index, ['after' => 's0.r0.c1.m0']), [$button]));
self::assertSame('divi/button', NodeIndex::build($document)->find('s0.r0.c1.m1')?->name());
}
/**
* Ruling S9: inserting at the document root of an empty page (no divi/placeholder yet)
* wraps it in one first, the way DiviContentComposer (Task 9) writes new content.
*/
public function testInsertIntoAnEmptyPageCreatesAPlaceholder(): void
{
$document = new Document();
$index = NodeIndex::build($document);
self::assertSame($document, $index->root());
$editor = new TreeEditor(ComposeFixture::rules());
$section = Block::create('divi/section');
self::assertNull($editor->insert($editor->position($index, []), [$section]));
self::assertSame(
'<!-- wp:divi/placeholder --><!-- wp:divi/section --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->',
Serializer::document($document)
);
$rebuilt = NodeIndex::build($document);
self::assertSame($section, $rebuilt->find('s0'));
self::assertNull($rebuilt->parentId($section), 'the placeholder is transparent, like every other top-level node');
// A second insert on the now non-empty page must not wrap it again.
$second = Block::create('divi/section');
self::assertNull($editor->insert($editor->position($rebuilt, []), [$second]));
self::assertSame(1, substr_count(Serializer::document($document), '<!-- wp:divi/placeholder -->'));
}
public function testMoveWithinAndAcrossParents(): void
{
$document = $this->document();
$original = Serializer::document($document);
$index = NodeIndex::build($document);
$editor = new TreeEditor(ComposeFixture::rules());
$button = $index->find('s0.r0.c1.m2');
self::assertNull($editor->move($index, $button, $editor->position($index, ['before' => 's0.r0.c1.m0'])));
$index = NodeIndex::build($document);
self::assertSame($button, $index->find('s0.r0.c1.m0'));
self::assertNull($editor->move($index, $button, $editor->position($index, ['parent' => 's1.r0.c0'])));
$index = NodeIndex::build($document);
self::assertSame($button, $index->find('s1.r0.c0.m1'));
self::assertStringContainsString('Termin buchen', Serializer::document($document));
// Ruling S14: move it back (exact inverse of the two moves above) and assert the
// serialized page is byte-identical to the original – not merely the same length.
self::assertNull($editor->move($index, $button, $editor->position($index, ['before' => 's0.r0.c1.m0'])));
$index = NodeIndex::build($document);
self::assertNull($editor->move($index, $button, $editor->position($index, ['after' => 's0.r0.c1.m2'])));
self::assertSame($original, Serializer::document($document), 'moving a node back restores every byte');
$section = $index->find('s0');
self::assertSame(
'ein Knoten kann nicht in sich selbst verschoben werden',
$editor->move($index, $section, $editor->position($index, ['parent' => 's0.r0.c0']))
);
self::assertNull($editor->move($index, $section, $editor->position($index, [])));
self::assertSame($section, NodeIndex::build($document)->find('s1'));
}
public function testRemoveAndDuplicate(): void
{
$document = $this->document();
$index = NodeIndex::build($document);
$editor = new TreeEditor(ComposeFixture::rules());
$copy = $editor->duplicate($index, $index->find('s1.r0.c1.m0'));
$index = NodeIndex::build($document);
self::assertSame($copy, $index->find('s1.r0.c1.m1'));
self::assertSame(Serializer::block($index->find('s1.r0.c1.m0')), Serializer::block($copy));
$editor->remove($index, $index->find('s0'));
self::assertSame('divi/section', NodeIndex::build($document)->find('s0')?->name());
self::assertNull(NodeIndex::build($document)->find('s1'));
}
/**
* Duplicate never mutates an opaque block's internals; a copy of a global module stays a
* byte-identical reference, and the original is untouched (spec §5: byte-identical roundtrip).
*/
public function testDuplicateNeverMutatesAnOpaqueGlobalModule(): void
{
$document = $this->document('synthetic-mixed.html');
$index = NodeIndex::build($document);
$editor = new TreeEditor(ComposeFixture::rules());
$globalRow = $index->find('s1.g1');
self::assertNotNull($globalRow);
self::assertTrue($globalRow->isOpaque());
$originalBytes = Serializer::block($globalRow);
$copy = $editor->duplicate($index, $globalRow);
self::assertNotSame($globalRow, $copy);
self::assertTrue($copy->isOpaque());
self::assertSame($originalBytes, Serializer::block($copy), 'the copy is byte-identical to the original');
self::assertSame($originalBytes, Serializer::block($globalRow), 'the original is left untouched');
$index = NodeIndex::build($document);
self::assertSame($globalRow, $index->find('s1.g1'));
self::assertSame($copy, $index->find('s1.g2'));
}
public function testReadOnlyTargetsAndSpecialtySections(): void
{
$document = $this->document('synthetic-mixed.html');
$index = NodeIndex::build($document);
$editor = new TreeEditor(ComposeFixture::rules());
$text = Block::create('divi/text');
self::assertStringContainsString('nur lesbar', (string) $editor->insert($editor->position($index, ['parent' => 's1.g1']), [$text]));
self::assertStringContainsString('Spezialabschnitte', (string) $editor->insert($editor->position($index, ['parent' => 's0']), [Block::create('divi/row')]));
self::assertNull($editor->insert($editor->position($index, ['parent' => 's0.c0']), [$text]));
}
}
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
use LIT\MCP\Integrations\Divi\BlockTree\Document;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\Compose\Warnings;
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
use PHPUnit\Framework\TestCase;
final class WarningsTest extends TestCase
{
public function testWarnsAboutTheTouchedNodesOnly(): void
{
$composed = ComposeFixture::composer()->compose([
['type' => 'section', 'label' => 'Test', 'modules' => [
['type' => 'heading', 'text' => 'Titel', 'level' => 'h1'],
['type' => 'heading', 'text' => 'Zu tief', 'level' => 'h4'],
['type' => 'image', 'src' => 'https://kunde.test/a.jpg'],
['type' => 'text', 'content' => ''],
['type' => 'code', 'content' => '<div>x</div>'],
['type' => 'accordion'],
['type' => 'button', 'text' => 'Los', 'attrs' => [
'button.decoration.background' => ['desktop' => ['color' => '#2ea3f3']],
'button.decoration.font.font' => ['desktop' => ['family' => 'Open Sans', 'color' => '#abcdef']],
]],
]],
], null, 'nodes');
self::assertSame([], $composed['errors']);
$document = new Document();
foreach ($composed['blocks'] as $block) {
$document->append($block);
}
$warnings = (new Warnings(DesignSystemTest::sample()))->collect(NodeIndex::build($document), $composed['blocks']);
self::assertSame(
[
's0.r0.c0.m2: Bild ohne Alt-Text.',
's0.r0.c0.m3: text ist leer (content fehlt).',
's0.r0.c0.m4: Code-Modul verwendet – im Divi-Builder nicht visuell bearbeitbar; nur nutzen, wenn kein '
. 'natives Modul passt.',
's0.r0.c0.m5: accordion ohne Einträge.',
's0.r0.c0.m6: feste Farbe #2ea3f3 – globale Farbe "Primärfarbe" ({{color:primary}}) passt.',
's0.r0.c0.m6: feste Schrift Open Sans – {{font:heading}} verwenden.',
's0.r0.c0.m1: Sprung in der Überschriftenhierarchie (h4 nach h1).',
],
$warnings
);
self::assertSame([], (new Warnings(DesignSystemTest::sample()))->collect(NodeIndex::build($document), []));
}
}
@@ -37,11 +37,25 @@ final class DiviReadAbilitiesTest extends TestCase
self::assertNotSame($result['tree_rev'], $result['rev']); self::assertNotSame($result['tree_rev'], $result['rev']);
self::assertCount(16, $result['nodes']); self::assertCount(16, $result['nodes']);
self::assertStringStartsWith('s0 section "Hero"', $result['outline']); self::assertStringStartsWith('s0 section "Hero"', $result['outline']);
self::assertFalse($result['truncated']);
self::assertSame([], $result['hints']); self::assertSame([], $result['hints']);
self::assertTrue($ability->isDirect()); self::assertTrue($ability->isDirect());
self::assertTrue($ability->annotations()['readonly']); self::assertTrue($ability->annotations()['readonly']);
} }
/** Ruling S18: the "limit" input caps children per parent; "truncated" reports the cut. */
public function testOutlineBreadthLimitTruncatesAndReportsIt(): void
{
DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html'));
$ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design());
$result = $ability->execute(['post_id' => 7, 'id' => 's0.r0.c1', 'limit' => 2]);
self::assertSame(['s0.r0.c1', 's0.r0.c1.m0', 's0.r0.c1.m1'], array_column($result['nodes'], 'id'));
self::assertTrue($result['nodes'][0]['truncated']);
self::assertTrue($result['truncated']);
}
public function testOutlineOfASubtreeAndUnknownNode(): void public function testOutlineOfASubtreeAndUnknownNode(): void
{ {
DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html')); DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html'));