refactor: Kern ohne Divi – Ports BuilderDetector, MediaUsageSource, EnvironmentComponent und ausgeschlossene Post-Typen im ContentSchema; Divi-Implementierungen und Verdrahtung über DiviIntegration/Plugin; CoreIndependenceTest

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-18 21:49:28 +02:00
co-authored by Claude Sonnet 5.5
parent abeb4b0eed
commit 06148fb8c1
33 changed files with 952 additions and 154 deletions
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress;
defined('ABSPATH') || exit;
/**
* Port: what the WordPress content tools need to know about a page builder (today only Divi) without knowing the
* builder – implemented by the builder's integration, wired in Plugin.
*/
interface BuilderDetector
{
/** Whether the builder's tools exist on this site (the builder is loaded). */
public function isAvailable(): bool;
/** Whether the builder's own flag marks $postId as a builder layout. */
public function usesBuilder(int $postId): bool;
}
@@ -45,11 +45,16 @@ final class ContentUpdateAbility implements Ability
/** Post columns a revision stores (and wp_restore_post_revision() restores). */
private const REVISIONED = ['post_title', 'post_content', 'post_excerpt'];
private PostPresenter $presenter;
/** @param PostPresenter|null $presenter Knows the page builder (BuilderDetector); null = no builder. */
public function __construct(
private ContentSchema $schema,
private PostWriter $writer,
private ChangeLog $log
private ChangeLog $log,
?PostPresenter $presenter = null
) {
$this->presenter = $presenter ?? new PostPresenter($schema);
}
public function name(): string
@@ -123,10 +128,10 @@ final class ContentUpdateAbility implements Ability
return $unstable;
}
if (array_key_exists('content', $input) && PostPresenter::usesDivi($post)) {
if (array_key_exists('content', $input) && $this->presenter->usesDivi($post)) {
return Errors::unsupported(
'Divi-Inhalte können nicht als Rohtext überschrieben werden; nichts wurde gespeichert.',
PostPresenter::diviNote() . ' Titel, Auszug, Felder und Begriffe hier ohne content ändern.'
$this->presenter->diviNote() . ' Titel, Auszug, Felder und Begriffe hier ohne content ändern.'
);
}
@@ -6,8 +6,8 @@ namespace LIT\MCP\Integrations\WordPress\Content;
use LIT\MCP\Abilities\JsonMap;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Integrations\Divi\Pages\DiviPages;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\WordPress\BuilderDetector;
use LIT\MCP\Integrations\WordPress\NoBuilderDetector;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
use WP_Post;
@@ -18,12 +18,9 @@ defined('ABSPATH') || exit;
* 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.
* The builder (Divi) is known only through the BuilderDetector port: usesDivi() asks it for the builder flag
* of a post, diviNote()/diviHint() for whether the builder's tools exist; $toolsAvailable can still be passed in
* explicitly. Without a detector no builder is assumed (NoBuilderDetector).
*/
class PostPresenter
{
@@ -36,23 +33,26 @@ class PostPresenter
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
private BuilderDetector $builders;
public function __construct(private ContentSchema $schema, ?BuilderDetector $builders = null)
{
return ($toolsAvailable ?? DiviModules::isAvailable()) ? self::DIVI_NOTE : self::DIVI_INACTIVE_NOTE;
$this->builders = $builders ?? new NoBuilderDetector();
}
public static function diviHint(?bool $toolsAvailable = null): string
/** $toolsAvailable defaults to whether the builder is available (Divi 5 loaded). */
public function diviNote(?bool $toolsAvailable = null): string
{
return ($toolsAvailable ?? $this->builders->isAvailable()) ? self::DIVI_NOTE : self::DIVI_INACTIVE_NOTE;
}
public 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 '
. $this->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
{
@@ -67,14 +67,14 @@ class PostPresenter
'modified' => (string) $post->post_modified,
'url' => (string) get_permalink($post),
'edit_url' => (string) get_edit_post_link($post->ID, 'raw'),
'divi' => self::usesDivi($post),
'divi' => $this->usesDivi($post),
];
}
/** @return array<string, mixed> */
public function full(WP_Post $post): array
{
$divi = self::usesDivi($post);
$divi = $this->usesDivi($post);
return array_merge($this->summary($post), [
'excerpt' => (string) $post->post_excerpt,
@@ -90,13 +90,13 @@ class PostPresenter
'fields' => JsonMap::of($this->fields($post)),
'featured_image' => $this->featuredImage($post),
'rev' => Rev::ofPost($post),
'hints' => $divi ? [self::diviHint()] : [],
'hints' => $divi ? [$this->diviHint()] : [],
]);
}
public static function usesDivi(WP_Post $post): bool
public function usesDivi(WP_Post $post): bool
{
return DiviPages::usesBuilder((int) $post->ID)
return $this->builders->usesBuilder((int) $post->ID)
|| str_contains((string) $post->post_content, '<!-- wp:divi/');
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress;
defined('ABSPATH') || exit;
/** The BuilderDetector of a site without page-builder integration: no builder, no layouts. */
final class NoBuilderDetector implements BuilderDetector
{
public function isAvailable(): bool
{
return false;
}
public function usesBuilder(int $postId): bool
{
return false;
}
}
@@ -26,8 +26,12 @@ defined('ABSPATH') || exit;
*/
final class RevisionsListAbility implements Ability
{
public function __construct(private ContentSchema $schema)
private PostPresenter $presenter;
/** @param PostPresenter|null $presenter Knows the page builder (BuilderDetector); null = no builder. */
public function __construct(private ContentSchema $schema, ?PostPresenter $presenter = null)
{
$this->presenter = $presenter ?? new PostPresenter($schema);
}
public function name(): string
@@ -127,7 +131,7 @@ final class RevisionsListAbility implements Ability
'post_id' => (int) $post->ID,
'items' => $items,
'next_cursor' => $page['next_cursor'],
'hints' => in_array(true, array_column($items, 'divi'), true) ? [PostPresenter::diviHint()] : [],
'hints' => in_array(true, array_column($items, 'divi'), true) ? [$this->presenter->diviHint()] : [],
];
}
@@ -146,7 +150,7 @@ final class RevisionsListAbility implements Ability
'title' => (string) $revision->post_title,
'autosave' => (bool) wp_is_post_autosave($revision),
'excerpt' => mb_substr($text, 0, 160),
'divi' => PostPresenter::usesDivi($revision),
'divi' => $this->presenter->usesDivi($revision),
];
}
}
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Schema;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LogicException;
defined('ABSPATH') || exit;
@@ -16,11 +15,10 @@ defined('ABSPATH') || exit;
*/
class ContentSchema
{
/** Post types handled elsewhere (media tools, Divi tools, WordPress internals). */
/** WordPress' own post types and those of the media tools; integrations add theirs (the constructor). */
public const EXCLUDED = [
'attachment', 'nav_menu_item', 'revision', 'wp_block', 'wp_template', 'wp_template_part',
'wp_navigation', 'wp_global_styles', 'wp_font_family', 'wp_font_face',
...DiviModules::INTERNAL_POST_TYPES,
];
private const COUNTED_STATUSES = ['publish', 'draft', 'pending', 'private', 'future'];
@@ -31,19 +29,27 @@ class ContentSchema
/** @var array<string, array<string, FieldDefinition>> */
private array $fieldCache = [];
/** @var list<string> */
private array $excluded;
/**
* @param list<FieldSource> $sources Highest priority first.
* @param list<FieldSource> $sources Highest priority first.
* @param callable(string): bool|null $diviEnabled Whether the page builder is switched on for a post type;
* null = never (Plugin passes Divi's check).
* @param list<string> $excludedPostTypes Post types of integrations that the content tools leave
* alone (Plugin passes Divi's library, Theme Builder, canvas and speculation types), added to EXCLUDED.
*/
public function __construct(private array $sources, ?callable $diviEnabled = null)
public function __construct(private array $sources, ?callable $diviEnabled = null, array $excludedPostTypes = [])
{
$this->diviEnabled = $diviEnabled ?? DiviModules::builderEnabledFor(...);
$this->diviEnabled = $diviEnabled ?? static fn (string $postType): bool => false;
$this->excluded = array_values(array_unique([...self::EXCLUDED, ...$excludedPostTypes]));
}
/** @return list<string> */
public function postTypeNames(): array
{
$excluded = apply_filters('lit_mcp_excluded_post_types', self::EXCLUDED);
$excluded = is_array($excluded) ? $excluded : self::EXCLUDED;
$excluded = apply_filters('lit_mcp_excluded_post_types', $this->excluded);
$excluded = is_array($excluded) ? $excluded : $this->excluded;
$names = array_values(array_diff(
array_map('strval', array_keys(get_post_types(['show_ui' => true], 'names'))),
$excluded
@@ -53,12 +53,15 @@ final class WordPressIntegration implements Integration
* @param ContentComposer|null $divi Divi short form for lit/content-create (DiviIntegration).
* @param (Closure(): bool)|null $globalIdsVisible Ruling P13: whether lit-page-render-summary may name
* global module IDs (the Divi integration's library access); null = always.
* @param BuilderDetector|null $builders The page builder behind the Divi flag of a post (Divi's
* BuilderDetector); null = no builder.
*/
public function __construct(
private ContentSchema $schema,
private ChangeLog $log,
private ?ContentComposer $divi = null,
private ?Closure $globalIdsVisible = null
private ?Closure $globalIdsVisible = null,
private ?BuilderDetector $builders = null
) {
}
@@ -79,7 +82,7 @@ final class WordPressIntegration implements Integration
public function abilities(): array
{
$presenter = new PostPresenter($this->schema);
$presenter = new PostPresenter($this->schema, $this->builders);
$writer = new PostWriter($this->schema, new FieldValidator(), new TermAssigner());
$cloner = new PostCloner();
$whitelist = new SettingsWhitelist();
@@ -89,14 +92,14 @@ final class WordPressIntegration implements Integration
new ContentQueryAbility($this->schema, $presenter),
new ContentGetAbility($this->schema, $presenter),
new ContentCreateAbility($this->schema, $writer, $this->log, $this->divi),
new ContentUpdateAbility($this->schema, $writer, $this->log),
new ContentUpdateAbility($this->schema, $writer, $this->log, $presenter),
new PostPublishAbility($this->schema, $this->log),
new PostUnpublishAbility($this->schema, $this->log),
new PostScheduleAbility($this->schema, $this->log),
new PostTrashAbility($this->schema, $this->log),
new PostDuplicateAbility($this->schema, $cloner, $this->log),
new PostReplaceWithCopyAbility($this->schema, $cloner, $this->log),
new RevisionsListAbility($this->schema),
new RevisionsListAbility($this->schema, $presenter),
new RevisionRestoreAbility($this->schema, $this->log),
new MenuListAbility(),
new MenuGetAbility(),