feat: lit/divi-outline and lit/divi-node-get with tree_rev

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 12:14:38 +02:00
co-authored by Claude Opus 5.5
parent a54a337e0b
commit d27504a750
9 changed files with 677 additions and 1 deletions
@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\JsonMap;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
use WP_Error;
/**
* lit/divi-node-get: all attributes of one node in the full form (spec §5.4, §5.7) –
* attribute path => breakpoint map – plus the current short-form values.
*/
final class DiviNodeGetAbility implements Ability
{
public function __construct(private DiviPageLoader $loader, private DesignSystemReader $design)
{
}
public function name(): string
{
return 'lit/divi-node-get';
}
public function label(): string
{
return 'Divi-Knoten lesen';
}
public function description(): string
{
return 'Liest einen Knoten der Divi-Seite vollständig: attrs als Attributpfad → Werte je Breakpoint '
. '(Vollform, z. B. "title.decoration.font.font": {"desktop": {"value": {…}}}), die Kurzform-Werte, '
. 'Presets und die IDs der Kinder. Pfad-IDs aus lit-divi-outline.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'post_id' => ['type' => 'integer', 'minimum' => 1],
'id' => ['type' => 'string', 'description' => 'Pfad-ID aus lit-divi-outline, z. B. "s0.r0.c1.m2".'],
],
'required' => ['post_id', 'id'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return [
'type' => 'object',
'properties' => [
'post_id' => ['type' => 'integer'],
'tree_rev' => ['type' => 'string'],
'id' => ['type' => 'string'],
'parent' => ['type' => ['string', 'null']],
'type' => ['type' => 'string'],
'label' => ['type' => 'string'],
'editable' => ['type' => 'boolean'],
'attrs' => ['type' => 'object'],
'shortform' => ['type' => 'object'],
'presets' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => ['id' => ['type' => 'string'], 'name' => ['type' => 'string']],
'required' => ['id', 'name'],
],
],
'builder_version' => ['type' => ['string', 'null']],
'children' => ['type' => 'array', 'items' => ['type' => 'string']],
],
'required' => ['post_id', 'tree_rev', 'id', 'type', 'editable', 'attrs', 'shortform', 'children'],
];
}
public function annotations(): array
{
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return true;
}
public function permission(array $input): bool|WP_Error
{
$page = $this->loader->load($input['post_id'] ?? null, false);
return $page instanceof WP_Error ? $page : true;
}
public function execute(array $input): mixed
{
$page = $this->loader->load($input['post_id'] ?? null, false);
if ($page instanceof WP_Error) {
return $page;
}
$id = is_string($input['id'] ?? null) ? $input['id'] : '';
if (preg_match(NodeIndex::ID_PATTERN, $id) !== 1) {
return Errors::invalidInput('id ist keine Pfad-ID.', ['id' => 'z. B. "s0.r1.c0.m2"']);
}
$block = $page->index->find($id);
if ($block === null) {
return DiviPageLoader::unknownNode($id, $page->post);
}
$names = $this->design->read()->presetNames();
$editable = $block->isDivi() && ! $block->isOpaque();
$version = $block->attrs()['builderVersion'] ?? null;
return [
'post_id' => (int) $page->post->ID,
'tree_rev' => $page->treeRev(),
'id' => $id,
'parent' => $page->index->parentId($block),
'type' => $block->name(),
'label' => NodeSummary::label($block),
'editable' => $editable,
'attrs' => JsonMap::of(AttrMap::flatten($block->attrs())),
'shortform' => JsonMap::of(ShortformMap::values($block->shortName(), $block->attrs())),
'presets' => array_map(
static fn (string $presetId): array => ['id' => $presetId, 'name' => $names[$presetId] ?? ''],
NodeSummary::presetIds($block)
),
'builder_version' => is_string($version) ? $version : null,
'children' => $editable
? array_values(array_filter(array_map([$page->index, 'idOf'], $block->children())))
: [],
];
}
}
@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Integrations\Divi\BlockTree\Outline;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use WP_Error;
/**
* lit/divi-outline: the page tree with path IDs, types, admin labels, text excerpts and
* preset names plus tree_rev (spec §5.3, §5.7). Every Divi write needs this tree_rev.
*/
final class DiviOutlineAbility implements Ability
{
public function __construct(private DiviPageLoader $loader, private DesignSystemReader $design)
{
}
public function name(): string
{
return 'lit/divi-outline';
}
public function label(): string
{
return 'Divi-Seitenbaum';
}
public function description(): string
{
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. '
. 'Optional nur ein Teilbaum (id) oder begrenzte Tiefe (depth).';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'post_id' => ['type' => 'integer', 'minimum' => 1],
'id' => ['type' => 'string', 'description' => 'Nur diesen Teilbaum, z. B. "s1".'],
'depth' => [
'type' => 'integer',
'minimum' => 0,
'maximum' => 10,
'description' => 'Maximale Tiefe unterhalb des Startknotens (0 = nur der Knoten).',
],
],
'required' => ['post_id'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return [
'type' => 'object',
'properties' => [
'post_id' => ['type' => 'integer'],
'title' => ['type' => 'string'],
'status' => ['type' => 'string'],
'tree_rev' => ['type' => 'string'],
'rev' => ['type' => 'string'],
'outline' => ['type' => 'string'],
'nodes' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'string'],
'type' => ['type' => 'string'],
'label' => ['type' => 'string'],
'excerpt' => ['type' => 'string'],
'info' => ['type' => 'string'],
'presets' => ['type' => 'array', 'items' => ['type' => 'string']],
'children' => ['type' => 'integer'],
'editable' => ['type' => 'boolean'],
'depth' => ['type' => 'integer'],
],
'required' => ['id', 'type', 'label', 'excerpt', 'editable', 'depth'],
],
],
'edit_url' => ['type' => 'string'],
'hints' => ['type' => 'array', 'items' => ['type' => 'string']],
],
'required' => ['post_id', 'tree_rev', 'rev', 'outline', 'nodes', 'hints'],
];
}
public function annotations(): array
{
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return true;
}
public function permission(array $input): bool|WP_Error
{
$page = $this->loader->load($input['post_id'] ?? null, false);
return $page instanceof WP_Error ? $page : true;
}
public function execute(array $input): mixed
{
$page = $this->loader->load($input['post_id'] ?? null, false);
if ($page instanceof WP_Error) {
return $page;
}
$rootId = isset($input['id']) && is_string($input['id']) && $input['id'] !== '' ? $input['id'] : null;
if ($rootId !== null && $page->index->find($rootId) === null) {
return DiviPageLoader::unknownNode($rootId, $page->post);
}
$outline = new Outline($this->design->read()->presetNames());
$rows = $outline->rows($page->index, $rootId, isset($input['depth']) ? (int) $input['depth'] : null);
return [
'post_id' => (int) $page->post->ID,
'title' => (string) $page->post->post_title,
'status' => (string) $page->post->post_status,
'tree_rev' => $page->treeRev(),
'rev' => Rev::ofPost($page->post),
'outline' => $outline->text($rows),
'nodes' => $rows,
'edit_url' => $page->editUrl(),
'hints' => $this->hints($page, $rows),
];
}
/**
* @param list<array<string, mixed>> $rows
* @return list<string>
*/
private function hints(DiviPage $page, array $rows): array
{
$hints = [];
if ($rows === []) {
$hints[] = $page->isDiviLayout()
? 'Die Seite hat noch keinen Divi-Inhalt.'
: 'Der Inhalt ist kein Divi-Layout (Block-Editor oder klassisch); lit-content-get zeigt ihn.';
}
if (in_array(false, array_column($rows, 'editable'), true)) {
$hints[] = 'Knoten mit "nur lesbar" (globale Module, fremde Blöcke) ändern die Divi-Werkzeuge nicht; '
. 'ihren Inhalt nur im Divi-Builder bearbeiten.';
}
return $hints;
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Integrations\Divi\BlockTree\Document;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use WP_Post;
/**
* An entry loaded for the Divi tools: the post, its parsed tree, the path index and the
* tree_rev (hash of post_content – the optimistic lock of the Divi tools, ruling P16; the
* post-level lock stays `rev` = Rev::ofPost()).
*/
final class DiviPage
{
public function __construct(
public readonly WP_Post $post,
public readonly Document $document,
public readonly NodeIndex $index
) {
}
public function treeRev(): string
{
return Rev::of((string) $this->post->post_content);
}
/** Whether post_content is a Divi 5 layout (or empty, so a first section can be added). */
public function isDiviLayout(): bool
{
$content = (string) $this->post->post_content;
return trim($content) === '' || str_contains($content, '<!-- wp:divi/');
}
public function editUrl(): string
{
return (string) add_query_arg('et_fb', '1', (string) get_permalink($this->post));
}
}
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
/**
* Loads an entry for the Divi tools: rights and status via PostGuard (spec §4.5), then the
* block tree. Divi 4 shortcode content and unbalanced markup are refused with a hint –
* spec §3.4: Divi 4 content is kept, never edited. Not final so abilities can be tested
* with a mock.
*/
class DiviPageLoader
{
public const OUTLINE_HINT = 'Aktuellen Stand mit lit/divi-outline (direkt: lit-divi-outline) neu lesen und die '
. 'Änderung mit der neuen tree_rev wiederholen.';
public function __construct(private ContentSchema $schema, private Parser $parser = new Parser())
{
}
/** Readers pass $forWrite = false (trashed entries may be read, like lit-content-get). */
public function load(mixed $postId, bool $forWrite): DiviPage|WP_Error
{
$post = PostGuard::editable($postId, $this->schema, 'post_id', ! $forWrite);
if ($post instanceof WP_Error) {
return $post;
}
$content = (string) $post->post_content;
if (! str_contains($content, '<!-- wp:divi/') && str_contains($content, '[et_pb_')) {
return Errors::unsupported(
sprintf(
'Eintrag %d enthält Divi-4-Inhalt (Shortcodes); die Divi-Werkzeuge bearbeiten nur Divi 5.',
$post->ID
),
'Die Seite einmal im Divi-Builder öffnen und speichern – Divi 5 wandelt sie dabei um. Bis dahin '
. 'bleibt der Inhalt unverändert.'
);
}
try {
$document = $this->parser->parse($content);
} catch (MalformedMarkup $e) {
$message = sprintf('[wp-lit-mcp] divi parse of post %d failed: %s', $post->ID, $e->getMessage());
error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
return Errors::unsupported(
sprintf(
'Der Inhalt von Eintrag %d ist kein gültiges Block-Markup; nichts wurde gelesen oder geändert.',
$post->ID
),
'Die Seite im Divi-Builder öffnen und speichern (Divi repariert dabei die Struktur) oder den Nutzer '
. 'fragen, ob eine frühere Revision (lit/revisions-list) wiederhergestellt werden soll.'
);
}
return new DiviPage($post, $document, NodeIndex::build($document));
}
/**
* Additional checks for writing: the type must use the Divi builder, the content must be a
* Divi 5 layout (or empty) and saving must not publish a missed schedule (spec §2).
*/
public function writable(DiviPage $page): ?WP_Error
{
$post = $page->post;
if (! $this->schema->diviEnabled((string) $post->post_type)) {
return Errors::unsupported(
sprintf('Für den Inhaltstyp "%s" ist der Divi-Builder nicht aktiviert.', $post->post_type),
'In Divi → Theme-Optionen → Builder den Builder für diesen Inhaltstyp aktivieren (Administrator).'
);
}
if (! $page->isDiviLayout()) {
return Errors::unsupported(
sprintf('Eintrag %d ist kein Divi-Layout (Block-Editor- oder klassischer Inhalt).', $post->ID),
'Den Inhalt mit lit/content-update (direkt: lit-content-update) bearbeiten, oder den Nutzer fragen, ob '
. 'die Seite im Divi-Builder neu aufgebaut werden soll.'
);
}
return PostGuard::statusStable($post);
}
public static function unknownNode(string $id, WP_Post $post): WP_Error
{
return Errors::notFound(
sprintf('Knoten "%s" gibt es in Eintrag %d nicht.', $id, $post->ID),
self::OUTLINE_HINT
);
}
}