From d27504a750e5dcea136c21af7a2406b9cc612d51 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 12:13:58 +0200 Subject: [PATCH] feat: lit/divi-outline and lit/divi-node-get with tree_rev Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- docs/guides/overview.md | 3 +- src/Integrations/Divi/DiviIntegration.php | 7 + .../Divi/Pages/DiviNodeGetAbility.php | 144 ++++++++++++++++ .../Divi/Pages/DiviOutlineAbility.php | 160 ++++++++++++++++++ src/Integrations/Divi/Pages/DiviPage.php | 43 +++++ .../Divi/Pages/DiviPageLoader.php | 102 +++++++++++ .../Integrations/Divi/DiviIntegrationTest.php | 2 + .../Divi/Pages/DiviPageLoaderTest.php | 118 +++++++++++++ .../Divi/Pages/DiviReadAbilitiesTest.php | 99 +++++++++++ 9 files changed, 677 insertions(+), 1 deletion(-) create mode 100644 src/Integrations/Divi/Pages/DiviNodeGetAbility.php create mode 100644 src/Integrations/Divi/Pages/DiviOutlineAbility.php create mode 100644 src/Integrations/Divi/Pages/DiviPage.php create mode 100644 src/Integrations/Divi/Pages/DiviPageLoader.php create mode 100644 tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php create mode 100644 tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php diff --git a/docs/guides/overview.md b/docs/guides/overview.md index a6ba907..95b9a6d 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -19,7 +19,8 @@ des verbundenen WordPress-Benutzers. Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`, `lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`, -`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-divi-catalog`, `lit-divi-design-system`. +`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-divi-outline`, +`lit-divi-node-get`, `lit-divi-catalog`, `lit-divi-design-system`. Welche Tools direkt sichtbar sind, lässt sich unter Einstellungen → LIT MCP → Tools ändern (außer bei `lit-guide`, das immer direkt bleibt). diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index eb351d2..52b66fa 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -6,6 +6,9 @@ namespace LIT\MCP\Integrations\Divi; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility; +use LIT\MCP\Integrations\Divi\Pages\DiviNodeGetAbility; +use LIT\MCP\Integrations\Divi\Pages\DiviOutlineAbility; +use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility; use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LIT\MCP\Integrations\Integration; @@ -38,7 +41,11 @@ final class DiviIntegration implements Integration public function abilities(): array { + $loader = new DiviPageLoader($this->schema); + return [ + new DiviOutlineAbility($loader, $this->design), + new DiviNodeGetAbility($loader, $this->design), new DiviCatalogAbility($this->modules), new DiviDesignSystemAbility($this->design), ]; diff --git a/src/Integrations/Divi/Pages/DiviNodeGetAbility.php b/src/Integrations/Divi/Pages/DiviNodeGetAbility.php new file mode 100644 index 0000000..be8400a --- /dev/null +++ b/src/Integrations/Divi/Pages/DiviNodeGetAbility.php @@ -0,0 +1,144 @@ + 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()))) + : [], + ]; + } +} diff --git a/src/Integrations/Divi/Pages/DiviOutlineAbility.php b/src/Integrations/Divi/Pages/DiviOutlineAbility.php new file mode 100644 index 0000000..2587483 --- /dev/null +++ b/src/Integrations/Divi/Pages/DiviOutlineAbility.php @@ -0,0 +1,160 @@ + '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> $rows + * @return list + */ + 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; + } +} diff --git a/src/Integrations/Divi/Pages/DiviPage.php b/src/Integrations/Divi/Pages/DiviPage.php new file mode 100644 index 0000000..6ac6b67 --- /dev/null +++ b/src/Integrations/Divi/Pages/DiviPage.php @@ -0,0 +1,43 @@ +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, ''); + // error_log() is a PHP internal and cannot be stubbed; redirect it to a temp file instead. + $log = (string) tempnam(sys_get_temp_dir(), 'lit-log'); + $previous = ini_set('error_log', $log); + try { + $result = (new DiviPageLoader(self::schema()))->load(7, false); + } finally { + ini_set('error_log', (string) $previous); + } + + self::assertSame('lit_unsupported', $result->get_error_code()); + self::assertStringContainsString('kein gültiges Block-Markup', $result->get_error_message()); + self::assertStringContainsString('divi parse of post 7 failed', (string) file_get_contents($log)); + unlink($log); + } + + public function testTrashedEntriesCanBeReadButNotWritten(): void + { + self::givenPost(self::fixture('synthetic-basic.html'), ['post_status' => 'trash']); + $loader = new DiviPageLoader(self::schema()); + + self::assertInstanceOf(DiviPage::class, $loader->load(7, false)); + self::assertSame('lit_validation_failed', $loader->load(7, true)->get_error_code()); + } + + public function testWritableChecks(): void + { + $loader = new DiviPageLoader(self::schema(false)); + self::givenPost(''); + $empty = $loader->load(7, true); + self::assertSame('lit_unsupported', $loader->writable($empty)->get_error_code()); + + $loader = new DiviPageLoader(self::schema()); + self::givenPost("\n

Gutenberg

\n"); + $gutenberg = $loader->load(7, true); + self::assertFalse($gutenberg->isDiviLayout()); + self::assertStringContainsString('kein Divi-Layout', $loader->writable($gutenberg)->get_error_message()); + + self::givenPost('', ['post_status' => 'future', 'post_date_gmt' => '2020-01-01 00:00:00']); + $missed = $loader->load(7, true); + self::assertTrue($missed->isDiviLayout()); + self::assertSame('lit_validation_failed', $loader->writable($missed)->get_error_code()); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php new file mode 100644 index 0000000..76b52a5 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php @@ -0,0 +1,99 @@ +allows('read')->andReturn(DesignSystemTest::sample()); + + return $reader; + } + + public function testOutlineListsNodesWithTreeRevAndPostRev(): void + { + $content = DiviPageLoaderTest::fixture('synthetic-basic.html'); + DiviPageLoaderTest::givenPost($content); + $ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); + + self::assertTrue($ability->permission(['post_id' => 7])); + $result = $ability->execute(['post_id' => 7]); + + self::assertSame(substr(hash('sha256', $content), 0, 12), $result['tree_rev']); + self::assertSame(12, strlen($result['rev'])); + self::assertNotSame($result['tree_rev'], $result['rev']); + self::assertCount(16, $result['nodes']); + self::assertStringStartsWith('s0 section "Hero"', $result['outline']); + self::assertSame([], $result['hints']); + self::assertTrue($ability->isDirect()); + self::assertTrue($ability->annotations()['readonly']); + } + + public function testOutlineOfASubtreeAndUnknownNode(): void + { + DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html')); + $ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); + + $result = $ability->execute(['post_id' => 7, 'id' => 's1', 'depth' => 1]); + self::assertSame(['s1', 's1.r0'], array_column($result['nodes'], 'id')); + + $missing = $ability->execute(['post_id' => 7, 'id' => 's7']); + self::assertSame('lit_not_found', $missing->get_error_code()); + self::assertStringContainsString('tree_rev', $missing->get_error_message()); + } + + public function testOutlineHintsForEmptyAndReadOnlyContent(): void + { + DiviPageLoaderTest::givenPost(''); + $ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); + self::assertSame(['Die Seite hat noch keinen Divi-Inhalt.'], $ability->execute(['post_id' => 7])['hints']); + + DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-mixed.html')); + $hints = $ability->execute(['post_id' => 7])['hints']; + self::assertStringContainsString('nur lesbar', $hints[0]); + } + + public function testNodeGetReturnsFullFormShortformAndPresets(): void + { + DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html')); + $ability = new DiviNodeGetAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); + + $heading = $ability->execute(['post_id' => 7, 'id' => 's0.r0.c1.m0']); + + self::assertSame('divi/heading', $heading['type']); + self::assertSame('s0.r0.c1', $heading['parent']); + self::assertSame( + ['desktop' => ['value' => ['headingLevel' => 'h1', 'color' => '#1a1a1a']], 'phone' => ['value' => ['size' => '28px']]], + $heading['attrs']['title.decoration.font.font'] + ); + self::assertSame(['text' => 'Willkommen bei Müller & Söhne', 'level' => 'h1'], $heading['shortform']); + self::assertSame([['id' => 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', 'name' => '']], $heading['presets']); + self::assertSame('5.2.1', $heading['builder_version']); + + $accordion = $ability->execute(['post_id' => 7, 'id' => 's1.r0.c1.m0']); + self::assertSame(['s1.r0.c1.m0.m0', 's1.r0.c1.m0.m1'], $accordion['children']); + self::assertInstanceOf(stdClass::class, $accordion['shortform']); + } + + public function testNodeGetValidatesTheId(): void + { + DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html')); + $ability = new DiviNodeGetAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); + + self::assertSame('lit_validation_failed', $ability->execute(['post_id' => 7, 'id' => 'hero'])->get_error_code()); + self::assertSame('lit_not_found', $ability->execute(['post_id' => 7, 'id' => 's5.r0'])->get_error_code()); + } +}