Files
WP-LIT-MCP/src/Integrations/WordPress/Content/PostPresenter.php
T
janniclas.loosenandClaude Opus 5.5 10c98cd3c6 docs: Divi guides shortform/placeholders, hints name the write tools; test: Divi write end-to-end; version 0.3.0
- Hints (PostPresenter::DIVI_NOTE, outline, catalog) and server instructions (tree_rev rule)
  name the Divi write tools; guides divi-shortform and divi-placeholders, additions to
  divi-structure, divi-attrs, overview (tool groups, 'nur lesen'), workflows, content-types.
- D7 wording and the S7 exception in readme, guides and spec 1.3/5.2; readme 0.3.0.
- ToolNamingTest: every referenced guide topic exists; overview lists exactly the topics.
- tests/Integration/divi-write-smoke.sh: all write tools via OAuth, editor without
  unfiltered_html with benign probes (S6), Divi's own save filters on the new fixture
  synthetic-divi-filters.html (S7); admin-render.php accepts the new wrap class.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-26 17:13:59 +02:00

214 lines
8.2 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Content;
use LIT\MCP\Abilities\JsonMap;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
use WP_Post;
/**
* Output format of an entry for the content tools. Not final so abilities can be
* unit-tested with a mock.
*
* Layering exception: diviNote()/diviHint() call DiviModules::isAvailable() (Divi
* integration) to decide whether the Divi tools exist – the WordPress integration
* otherwise never depends on Divi. Kept because the alternative (injecting
* availability through every constructor that reaches PostPresenter) would spread the
* same one-line check across content-get, content-update and revisions-list for no
* behavioral gain; $toolsAvailable can still be passed in explicitly for tests.
*/
class PostPresenter
{
/** How Divi content is read and changed with the Divi tools. */
public const DIVI_NOTE = 'Den Aufbau mit lit/divi-outline (direkt: lit-divi-outline) lesen und mit '
. 'lit/divi-insert, lit/divi-update, lit/divi-move bzw. lit/divi-delete (direkt: lit-divi-insert, '
. 'lit-divi-update, lit-divi-move, lit-divi-delete) ändern.';
/** Divi content on a site where the Divi 5 builder is not loaded (no Divi tools registered). */
public const DIVI_INACTIVE_NOTE = 'Der Divi-5-Builder ist auf dieser Website nicht aktiv, daher gibt es keine '
. 'Divi-Werkzeuge; Divi-Inhalte nur im Divi-Builder bearbeiten.';
/** $toolsAvailable defaults to whether the Divi integration is available (Divi 5 loaded). */
public static function diviNote(?bool $toolsAvailable = null): string
{
return ($toolsAvailable ?? DiviModules::isAvailable()) ? self::DIVI_NOTE : self::DIVI_INACTIVE_NOTE;
}
public static function diviHint(?bool $toolsAvailable = null): string
{
return 'Dieser Inhalt ist mit Divi gebaut; content wird deshalb nicht ausgegeben. '
. self::diviNote($toolsAvailable) . ' Titel, Auszug, Felder und Begriffe sind über lit/content-update '
. '(direkt: lit-content-update) änderbar.';
}
public function __construct(private ContentSchema $schema)
{
}
/** @return array<string, mixed> */
public function summary(WP_Post $post): array
{
return [
'id' => (int) $post->ID,
'type' => (string) $post->post_type,
'status' => (string) $post->post_status,
'title' => (string) $post->post_title,
'slug' => (string) $post->post_name,
'parent' => (int) $post->post_parent,
'date' => (string) $post->post_date,
'modified' => (string) $post->post_modified,
'url' => (string) get_permalink($post),
'edit_url' => (string) get_edit_post_link($post->ID, 'raw'),
'divi' => self::usesDivi($post),
];
}
/** @return array<string, mixed> */
public function full(WP_Post $post): array
{
$divi = self::usesDivi($post);
return array_merge($this->summary($post), [
'excerpt' => (string) $post->post_excerpt,
'content' => $divi ? null : (string) $post->post_content,
'menu_order' => (int) $post->menu_order,
'author' => [
'id' => (int) $post->post_author,
'name' => (string) get_the_author_meta('display_name', (int) $post->post_author),
],
'template' => (string) get_page_template_slug($post),
'preview_url' => (string) get_preview_post_link($post),
'terms' => JsonMap::of($this->terms($post)),
'fields' => JsonMap::of($this->fields($post)),
'featured_image' => $this->featuredImage($post),
'rev' => Rev::ofPost($post),
'hints' => $divi ? [self::diviHint()] : [],
]);
}
public static function usesDivi(WP_Post $post): bool
{
return get_post_meta((int) $post->ID, '_et_pb_use_builder', true) === 'on'
|| str_contains((string) $post->post_content, '<!-- wp:divi/');
}
/** @return array<string, mixed> */
public static function summarySchema(): array
{
return [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer'],
'type' => ['type' => 'string'],
'status' => ['type' => 'string'],
'title' => ['type' => 'string'],
'slug' => ['type' => 'string'],
'parent' => ['type' => 'integer'],
'date' => ['type' => 'string'],
'modified' => ['type' => 'string'],
'url' => ['type' => 'string'],
'edit_url' => ['type' => 'string'],
'divi' => ['type' => 'boolean'],
],
'required' => ['id', 'type', 'status', 'title', 'url', 'divi'],
];
}
/** @return array<string, mixed> */
public static function fullSchema(): array
{
$schema = self::summarySchema();
$schema['properties'] = array_merge($schema['properties'], [
'excerpt' => ['type' => 'string'],
'content' => ['type' => ['string', 'null']],
'menu_order' => ['type' => 'integer'],
'author' => ['type' => 'object'],
'template' => ['type' => 'string'],
'preview_url' => ['type' => 'string'],
'terms' => ['type' => 'object'],
'fields' => ['type' => 'object'],
'featured_image' => ['type' => ['object', 'null']],
'rev' => ['type' => 'string'],
'hints' => ['type' => 'array', 'items' => ['type' => 'string']],
]);
$schema['required'] = array_merge($schema['required'], ['content', 'terms', 'fields', 'rev', 'hints']);
return $schema;
}
/** @return array<string, list<array{id: int, name: string, slug: string}>> */
private function terms(WP_Post $post): array
{
$result = [];
foreach ($this->schema->taxonomies((string) $post->post_type) as $taxonomy) {
$terms = wp_get_post_terms((int) $post->ID, $taxonomy['name']);
if (! is_array($terms)) {
continue;
}
$result[$taxonomy['name']] = array_map(
static fn (object $term): array => [
'id' => (int) $term->term_id,
'name' => (string) $term->name,
'slug' => (string) $term->slug,
],
$terms
);
}
return $result;
}
/** @return array<string, mixed> */
private function fields(WP_Post $post): array
{
$result = [];
foreach ($this->schema->fields((string) $post->post_type) as $key => $field) {
if (self::isHiddenField($field)) {
continue;
}
$result[$key] = $this->schema->sourceOf($field)->read((int) $post->ID, $field);
}
return $result;
}
/**
* Pods password fields and readonly protected meta (WordPress' own "_"-prefixed
* internal keys) are never read out via lit-content-get/-query: neither belongs in
* front of the model, and both are one plugin registration away from leaking secrets
* or internal engine state. Public and static so ContentQueryAbility can apply the
* same rule to its `field` filter – a hidden field must be as unreachable through the
* query filter as it is through the fields map, or the filter becomes a probe.
*/
public static function isHiddenField(FieldDefinition $field): bool
{
if ($field->type === 'password') {
return true;
}
return $field->status !== FieldDefinition::WRITABLE && str_starts_with($field->key, '_');
}
/** @return array{id: int, url: string, alt: string}|null */
private function featuredImage(WP_Post $post): ?array
{
$id = (int) get_post_thumbnail_id($post);
if ($id === 0) {
return null;
}
return [
'id' => $id,
'url' => (string) wp_get_attachment_url($id),
'alt' => (string) get_post_meta($id, '_wp_attachment_image_alt', true),
];
}
}