From 2e9acd686a34069aebe4fdeef0213cb25143ae36 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 12:21:15 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20lit/page-render-summary=20=E2=80=93=20r?= =?UTF-8?q?endered=20content=20as=20structured=20excerpt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- docs/guides/overview.md | 4 +- .../Render/PageRenderSummaryAbility.php | 104 ++++++++++++++ .../WordPress/Render/PageRenderer.php | 36 +++++ .../WordPress/Render/RenderSummary.php | 133 ++++++++++++++++++ .../WordPress/WordPressIntegration.php | 6 +- .../Render/PageRenderSummaryAbilityTest.php | 67 +++++++++ .../WordPress/Render/PageRendererTest.php | 64 +++++++++ .../WordPress/WordPressIntegrationTest.php | 1 + 8 files changed, 412 insertions(+), 3 deletions(-) create mode 100644 src/Integrations/WordPress/Render/PageRenderSummaryAbility.php create mode 100644 src/Integrations/WordPress/Render/PageRenderer.php create mode 100644 src/Integrations/WordPress/Render/RenderSummary.php create mode 100644 tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php create mode 100644 tests/Unit/Integrations/WordPress/Render/PageRendererTest.php diff --git a/docs/guides/overview.md b/docs/guides/overview.md index 95b9a6d..b7dfe5d 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -19,8 +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-outline`, -`lit-divi-node-get`, `lit-divi-catalog`, `lit-divi-design-system`. +`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-page-render-summary`, +`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/WordPress/Render/PageRenderSummaryAbility.php b/src/Integrations/WordPress/Render/PageRenderSummaryAbility.php new file mode 100644 index 0000000..970b7b7 --- /dev/null +++ b/src/Integrations/WordPress/Render/PageRenderSummaryAbility.php @@ -0,0 +1,104 @@ + 'object', + 'properties' => ['post_id' => ['type' => 'integer', 'minimum' => 1]], + 'required' => ['post_id'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'post_id' => ['type' => 'integer'], + 'url' => ['type' => 'string'], + 'headings' => ['type' => 'array', 'items' => ['type' => 'object']], + 'texts' => ['type' => 'array', 'items' => ['type' => 'string']], + 'links' => ['type' => 'array', 'items' => ['type' => 'object']], + 'images' => ['type' => 'array', 'items' => ['type' => 'object']], + 'warnings' => ['type' => 'array', 'items' => ['type' => 'string']], + 'notes' => ['type' => 'array', 'items' => ['type' => 'string']], + ], + 'required' => ['post_id', 'headings', 'texts', 'links', 'images', 'warnings', 'notes'], + ]; + } + + 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 + { + $post = PostGuard::editable($input['post_id'] ?? null, $this->schema, 'post_id', true); + + return $post instanceof WP_Error ? $post : true; + } + + public function execute(array $input): mixed + { + $post = PostGuard::editable($input['post_id'] ?? null, $this->schema, 'post_id', true); + if ($post instanceof WP_Error) { + return $post; + } + + $notes = ['Nur der Inhaltsbereich; Header, Footer und Theme-Builder-Vorlagen fehlen.']; + if ((string) $post->post_password !== '') { + $notes[] = 'Der Eintrag ist passwortgeschützt; Besucher sehen zuerst das Passwortformular.'; + } + + return array_merge( + ['post_id' => (int) $post->ID, 'url' => (string) get_permalink($post)], + RenderSummary::fromHtml($this->renderer->render($post)), + ['notes' => $notes] + ); + } +} diff --git a/src/Integrations/WordPress/Render/PageRenderer.php b/src/Integrations/WordPress/Render/PageRenderer.php new file mode 100644 index 0000000..ceaca30 --- /dev/null +++ b/src/Integrations/WordPress/Render/PageRenderer.php @@ -0,0 +1,36 @@ +post_content); + } finally { + $echoed = (string) ob_get_clean(); + $GLOBALS['post'] = $previous; + if ($previous instanceof WP_Post) { + setup_postdata($previous); + } else { + wp_reset_postdata(); + } + } + + return $echoed . $html; + } +} diff --git a/src/Integrations/WordPress/Render/RenderSummary.php b/src/Integrations/WordPress/Render/RenderSummary.php new file mode 100644 index 0000000..cc8fbf8 --- /dev/null +++ b/src/Integrations/WordPress/Render/RenderSummary.php @@ -0,0 +1,133 @@ +, texts: list, + * links: list, images: list, + * warnings: list} + */ + public static function fromHtml(string $html, int $max = 50): array + { + $dom = new DOMDocument(); + $previous = libxml_use_internal_errors(true); + $dom->loadHTML( + '' . $html . '', + LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING + ); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + $xpath = new DOMXPath($dom); + + $headings = []; + foreach (self::elements($xpath, '//h1|//h2|//h3|//h4|//h5|//h6', $max) as $element) { + $headings[] = ['level' => (int) substr($element->nodeName, 1), 'text' => self::text($element)]; + } + + $texts = []; + foreach (self::elements($xpath, '//p|//li|//blockquote', $max) as $element) { + $text = self::text($element); + if ($text !== '') { + $texts[] = mb_strlen($text) > self::TEXT_LIMIT + ? mb_substr($text, 0, self::TEXT_LIMIT - 1) . '…' + : $text; + } + } + + $links = []; + foreach (self::elements($xpath, '//a[@href]', $max) as $element) { + $links[] = ['text' => self::text($element), 'href' => $element->getAttribute('href')]; + } + + $images = []; + foreach (self::elements($xpath, '//img', $max) as $element) { + $images[] = [ + 'src' => $element->getAttribute('src'), + 'alt' => $element->hasAttribute('alt') ? $element->getAttribute('alt') : null, + ]; + } + + return [ + 'headings' => $headings, + 'texts' => $texts, + 'links' => $links, + 'images' => $images, + 'warnings' => self::warnings($headings, $images, $links), + ]; + } + + /** + * @param list $headings + * @param list $images + * @param list $links + * @return list + */ + private static function warnings(array $headings, array $images, array $links): array + { + $warnings = []; + $previous = null; + foreach ($headings as $heading) { + if ($previous !== null && $heading['level'] > $previous + 1) { + $warnings[] = sprintf( + 'Sprung in der Überschriftenhierarchie: h%d → h%d ("%s").', + $previous, + $heading['level'], + $heading['text'] + ); + } + $previous = $heading['level']; + } + + $h1 = count(array_filter($headings, static fn (array $heading): bool => $heading['level'] === 1)); + if ($h1 > 1) { + $warnings[] = sprintf('%d Überschriften der Ebene h1 im Inhalt.', $h1); + } + + foreach ($images as $image) { + if ($image['alt'] === null || trim($image['alt']) === '') { + $warnings[] = 'Bild ohne Alt-Text: ' . $image['src']; + } + } + + foreach ($links as $link) { + if ($link['href'] === '' || $link['href'] === '#') { + $warnings[] = sprintf('Link ohne Ziel: "%s".', $link['text']); + } + } + + return $warnings; + } + + /** @return list */ + private static function elements(DOMXPath $xpath, string $query, int $max): array + { + $nodes = $xpath->query($query); + $elements = []; + foreach ($nodes === false ? [] : $nodes as $node) { + if ($node instanceof DOMElement && count($elements) < $max) { + $elements[] = $node; + } + } + + return $elements; + } + + private static function text(DOMElement $element): string + { + return trim((string) preg_replace('/\s+/u', ' ', $element->textContent)); + } +} diff --git a/src/Integrations/WordPress/WordPressIntegration.php b/src/Integrations/WordPress/WordPressIntegration.php index b08912d..3e6e31b 100644 --- a/src/Integrations/WordPress/WordPressIntegration.php +++ b/src/Integrations/WordPress/WordPressIntegration.php @@ -20,6 +20,8 @@ use LIT\MCP\Integrations\WordPress\Menus\MenuListAbility; use LIT\MCP\Integrations\WordPress\Menus\MenuOperations; use LIT\MCP\Integrations\WordPress\Menus\MenuSetItemsAbility; use LIT\MCP\Integrations\WordPress\Menus\MenuWriter; +use LIT\MCP\Integrations\WordPress\Render\PageRenderer; +use LIT\MCP\Integrations\WordPress\Render\PageRenderSummaryAbility; use LIT\MCP\Integrations\WordPress\Revisions\RevisionRestoreAbility; use LIT\MCP\Integrations\WordPress\Revisions\RevisionsListAbility; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; @@ -37,7 +39,8 @@ use LIT\MCP\Integrations\WordPress\Status\PostTrashAbility; use LIT\MCP\Integrations\WordPress\Status\PostUnpublishAbility; /** - * WordPress basics (spec §6): content, status, copies, revisions, menus, settings. + * WordPress basics (spec §6): content, status, copies, revisions, menus, settings, and the + * rendered-content summary lit/page-render-summary (spec §5.7 – useful for every entry). * Always available. */ final class WordPressIntegration implements Integration @@ -84,6 +87,7 @@ final class WordPressIntegration implements Integration new MenuAssignLocationAbility($this->log), new WpSettingsGetAbility($whitelist), new WpSettingsUpdateAbility($whitelist, $this->log), + new PageRenderSummaryAbility($this->schema, new PageRenderer()), ]; } } diff --git a/tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php b/tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php new file mode 100644 index 0000000..afa3b3a --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php @@ -0,0 +1,67 @@ +

Willkommen

Leistungen

' + . '

Seit 1987 in Köln.

  • Beratung
' + . 'TeamMehr'; + + public function testSummarizesHtml(): void + { + $summary = RenderSummary::fromHtml(self::HTML); + + self::assertSame([['level' => 1, 'text' => 'Willkommen'], ['level' => 3, 'text' => 'Leistungen']], $summary['headings']); + self::assertSame(['Seit 1987 in Köln.', 'Beratung'], $summary['texts']); + self::assertSame(['text' => 'in Köln', 'href' => '/kontakt'], $summary['links'][0]); + self::assertSame( + [['src' => 'https://k.test/a.jpg', 'alt' => 'Team'], ['src' => 'https://k.test/b.jpg', 'alt' => null]], + $summary['images'] + ); + self::assertSame( + [ + 'Sprung in der Überschriftenhierarchie: h1 → h3 ("Leistungen").', + 'Bild ohne Alt-Text: https://k.test/b.jpg', + 'Link ohne Ziel: "Mehr".', + ], + $summary['warnings'] + ); + self::assertSame([], RenderSummary::fromHtml('')['headings']); + self::assertCount(2, RenderSummary::fromHtml(str_repeat('

x

', 5), 2)['texts']); + } + + public function testRendersTheEntryAndAddsNotes(): void + { + $post = new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_password' => 'geheim']); + Functions\when('get_post')->justReturn($post); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_permalink')->justReturn('https://k.test/start/'); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + $renderer = Mockery::mock(PageRenderer::class); + $renderer->expects('render')->with($post)->andReturn(self::HTML); + $ability = new PageRenderSummaryAbility($schema, $renderer); + + self::assertTrue($ability->permission(['post_id' => 7])); + $result = $ability->execute(['post_id' => 7]); + + self::assertSame(7, $result['post_id']); + self::assertSame('https://k.test/start/', $result['url']); + self::assertCount(2, $result['headings']); + self::assertCount(2, $result['notes']); + self::assertTrue($ability->isDirect()); + self::assertTrue($ability->annotations()['readonly']); + } +} diff --git a/tests/Unit/Integrations/WordPress/Render/PageRendererTest.php b/tests/Unit/Integrations/WordPress/Render/PageRendererTest.php new file mode 100644 index 0000000..4975376 --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Render/PageRendererTest.php @@ -0,0 +1,64 @@ + 1]); + $GLOBALS['post'] = $previous; + $post = new WP_Post(['ID' => 7, 'post_content' => '']); + $setup = []; + Functions\when('setup_postdata')->alias(static function (WP_Post $p) use (&$setup): bool { + $setup[] = $p->ID; + + return true; + }); + Filters\expectApplied('the_content')->once()->with('')->andReturnUsing( + static function () use ($post): string { + echo '

echoed

'; + + return $GLOBALS['post'] === $post ? '
' : 'wrong post'; + } + ); + + $html = (new PageRenderer())->render($post); + + self::assertSame('

echoed

', $html); + self::assertSame($previous, $GLOBALS['post']); + self::assertSame([7, 1], $setup); + } + + public function testRestoresEvenWhenRenderingThrows(): void + { + Functions\when('setup_postdata')->justReturn(true); + Functions\expect('wp_reset_postdata')->once(); + Filters\expectApplied('the_content')->once()->andReturnUsing(static function (): string { + throw new RuntimeException('module crashed'); + }); + + try { + (new PageRenderer())->render(new WP_Post(['ID' => 7])); + self::fail('exception expected'); + } catch (RuntimeException) { + self::assertArrayHasKey('post', $GLOBALS); + self::assertNull($GLOBALS['post']); + } + } +} diff --git a/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php b/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php index 1c8fb28..dd64491 100644 --- a/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php +++ b/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php @@ -35,6 +35,7 @@ final class WordPressIntegrationTest extends TestCase 'lit/menu-assign-location', 'lit/wp-settings-get', 'lit/wp-settings-update', + 'lit/page-render-summary', ]; public function testProvidesTheWordPressAbilitiesInOrder(): void