feat: lit/page-render-summary – rendered content as structured excerpt

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:21:23 +02:00
co-authored by Claude Opus 5.5
parent d27504a750
commit 2e9acd686a
8 changed files with 412 additions and 3 deletions
+2 -2
View File
@@ -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).
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Render;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
/**
* lit/page-render-summary: what a visitor sees in the content area – headings, texts, links,
* images with alt text and obvious problems (spec §5.7, §8.1). Works for every managed entry,
* Divi or not; the content is rendered in-process, header/footer/Theme Builder are not included.
*/
final class PageRenderSummaryAbility implements Ability
{
public function __construct(private ContentSchema $schema, private PageRenderer $renderer)
{
}
public function name(): string
{
return 'lit/page-render-summary';
}
public function label(): string
{
return 'Gerenderte Seite (Auszug)';
}
public function description(): string
{
return 'Rendert den Inhaltsbereich eines Eintrags wie im Frontend und liefert einen strukturierten Auszug: '
. 'Überschriften, Texte, Links, Bilder mit Alt-Text und Warnungen (Hierarchie-Sprünge, fehlende '
. 'Alt-Texte). Header, Footer und Theme-Builder-Vorlagen sind nicht enthalten. Gut zum Prüfen nach '
. 'Änderungen.';
}
public function inputSchema(): array
{
return [
'type' => '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]
);
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Render;
use WP_Post;
/**
* Renders the content area of an entry in-process, like the front end does for the_content
* (Divi 5 renders its blocks there – verified in DDEV). Header, footer and Theme Builder
* layouts are not part of it. Not final so abilities can be unit-tested with a mock.
*/
class PageRenderer
{
public function render(WP_Post $post): string
{
$previous = $GLOBALS['post'] ?? null;
$GLOBALS['post'] = $post;
setup_postdata($post);
ob_start();
try {
$html = (string) apply_filters('the_content', (string) $post->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;
}
}
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Render;
use DOMDocument;
use DOMElement;
use DOMXPath;
/**
* Structured excerpt of rendered HTML (spec §5.7 lit/page-render-summary): headings, text
* blocks, links, images with alt text and the obvious problems. Pure PHP (ext-dom).
*/
final class RenderSummary
{
private const TEXT_LIMIT = 300;
/**
* @return array{headings: list<array{level: int, text: string}>, texts: list<string>,
* links: list<array{text: string, href: string}>, images: list<array{src: string, alt: ?string}>,
* warnings: list<string>}
*/
public static function fromHtml(string $html, int $max = 50): array
{
$dom = new DOMDocument();
$previous = libxml_use_internal_errors(true);
$dom->loadHTML(
'<?xml encoding="UTF-8"><html><body>' . $html . '</body></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<array{level: int, text: string}> $headings
* @param list<array{src: string, alt: ?string}> $images
* @param list<array{text: string, href: string}> $links
* @return list<string>
*/
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<DOMElement> */
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));
}
}
@@ -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()),
];
}
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Render;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\WordPress\Render\PageRenderer;
use LIT\MCP\Integrations\WordPress\Render\PageRenderSummaryAbility;
use LIT\MCP\Integrations\WordPress\Render\RenderSummary;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class PageRenderSummaryAbilityTest extends TestCase
{
private const HTML = '<div class="et_pb_section"><h1>Willkommen</h1><h3>Leistungen</h3>'
. '<p>Seit 1987 <a href="/kontakt">in Köln</a>.</p><ul><li>Beratung</li></ul>'
. '<img src="https://k.test/a.jpg" alt="Team"><img src="https://k.test/b.jpg"><a href="#">Mehr</a></div>';
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('<p>x</p>', 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']);
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Render;
use Brain\Monkey\Filters;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\WordPress\Render\PageRenderer;
use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException;
use WP_Post;
final class PageRendererTest extends TestCase
{
protected function tearDown(): void
{
unset($GLOBALS['post']);
parent::tearDown();
}
public function testRendersTheContentFilterAndRestoresTheGlobalPost(): void
{
$previous = new WP_Post(['ID' => 1]);
$GLOBALS['post'] = $previous;
$post = new WP_Post(['ID' => 7, 'post_content' => '<!-- wp:divi/section /-->']);
$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('<!-- wp:divi/section /-->')->andReturnUsing(
static function () use ($post): string {
echo '<p>echoed</p>';
return $GLOBALS['post'] === $post ? '<div class="et_pb_section"></div>' : 'wrong post';
}
);
$html = (new PageRenderer())->render($post);
self::assertSame('<p>echoed</p><div class="et_pb_section"></div>', $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']);
}
}
}
@@ -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