feat: Divi adapters for roles and library with a live compatibility check

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 00:51:09 +02:00
co-authored by Claude Opus 5.5
parent b389f983de
commit ce1ad7c6c3
10 changed files with 491 additions and 5 deletions
+17 -3
View File
@@ -4,14 +4,21 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Core;
use LIT\MCP\Integrations\Divi\DiviCompatibility;
defined('ABSPATH') || exit;
/**
* Detects the themes/plugins LIT MCP integrates with.
* Detects the themes/plugins LIT MCP integrates with; for Divi also the internals that are missing
* (DiviCompatibility).
*/
class EnvironmentProbe
{
/** @return list<array{id: string, active: bool, version: ?string}> */
public function __construct(private ?DiviCompatibility $divi = null)
{
}
/** @return list<array{id: string, active: bool, version: ?string, problems: list<string>}> */
public function components(): array
{
$theme = wp_get_theme();
@@ -22,16 +29,23 @@ class EnvironmentProbe
$tecActive = class_exists('Tribe__Events__Main');
return [
['id' => 'divi', 'active' => $isDivi, 'version' => $diviVersion],
[
'id' => 'divi',
'active' => $isDivi,
'version' => $diviVersion,
'problems' => $isDivi ? ($this->divi ?? new DiviCompatibility())->problems() : [],
],
[
'id' => 'pods',
'active' => defined('PODS_VERSION'),
'version' => defined('PODS_VERSION') ? (string) PODS_VERSION : null,
'problems' => [],
],
[
'id' => 'the-events-calendar',
'active' => $tecActive,
'version' => $tecActive ? (string) constant('Tribe__Events__Main::VERSION') : null,
'problems' => [],
],
];
}
+2 -1
View File
@@ -30,7 +30,8 @@ final class SiteStatusAbility implements Ability
public function description(): string
{
return 'Überblick: Website, WordPress-Version, eigener Benutzer und Rollen, '
. 'erkannte Integrationen (Divi, Pods, The Events Calendar).';
. 'erkannte Integrationen (Divi, Pods, The Events Calendar). '
. 'Bei Divi nennt problems Divi-Funktionen, die nach einem Update fehlen.';
}
public function inputSchema(): array
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use Throwable;
defined('ABSPATH') || exit;
/**
* Live check of the Divi internals LIT MCP relies on (decisions note §5, E15): classes,
* functions, the library post type and taxonomies. lit-site-status shows the failed checks, so a
* Divi update that moves one of them is visible before a tool reports "unsupported".
*/
final class DiviCompatibility
{
/** @param array<string, callable(): bool>|null $checks Label => check; null = defaults(). */
public function __construct(private ?array $checks = null)
{
}
/** @return list<string> Labels of the checks that failed or threw. */
public function problems(): array
{
$problems = [];
foreach ($this->checks ?? self::defaults() as $label => $check) {
try {
$ok = (bool) $check();
} catch (Throwable) {
$ok = false;
}
if (! $ok) {
$problems[] = (string) $label;
}
}
return $problems;
}
/** @return array<string, callable(): bool> */
public static function defaults(): array
{
return [
'ModuleRegistration (Modul-Katalog)' => static fn (): bool => DiviModules::isAvailable(),
'DynamicContentOptions (Dynamic Content)' =>
static fn (): bool => class_exists(DynamicContentMap::REGISTRY),
'et_pb_is_allowed (Divi-Rollen)' => static fn (): bool => function_exists('et_pb_is_allowed'),
'Post-Typ et_pb_layout (Bibliothek)' =>
static fn (): bool => post_type_exists(DiviLibraryStore::POST_TYPE),
'Taxonomien scope, layout_type, module_width (Bibliothek)' =>
static fn (): bool => (new DiviLibraryStore())->missingTaxonomies() === [],
'GlobalLayout::is_global_layout_template (globale Module)' =>
static fn (): bool => method_exists(DiviLibraryStore::GLOBAL_LAYOUT, 'is_global_layout_template'),
'ET_Core_PageResource (CSS-Cache)' => static fn (): bool => class_exists('ET_Core_PageResource'),
];
}
}
@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library;
use WP_Post;
use WP_Term;
defined('ABSPATH') || exit;
/**
* Adapter for the Divi Library (E4, E15): post type et_pb_layout, the taxonomies scope,
* layout_type, module_width, layout_category, layout_tag and the meta Divi's et_pb_submit_layout()
* writes (F1, F2, fixtures stage3). "Global" is Divi's own definition
* GlobalLayout::is_global_layout_template() (F5) with the scope term as fallback when the class is
* gone. find() filters by the scope term only, so an entry Divi counts as global through its D4
* meta fallback can show up under scope "normal" (preflight M-12, accepted). Library entries are
* always "publish" (F2). Not final so abilities can be tested with a mock.
*/
class DiviLibraryStore
{
public const POST_TYPE = 'et_pb_layout';
public const GLOBAL_LAYOUT = 'ET\Builder\Packages\GlobalLayout\GlobalLayout';
public const LAYOUT_TYPES = ['layout', 'section', 'row', 'module'];
/** Taxonomies every new entry needs (et_pb_submit_layout). */
public const TAXONOMIES = ['scope', 'layout_type', 'module_width'];
public function isGlobal(int $id): bool
{
if (method_exists(self::GLOBAL_LAYOUT, 'is_global_layout_template')) {
return (bool) call_user_func([self::GLOBAL_LAYOUT, 'is_global_layout_template'], $id);
}
return in_array('global', $this->termSlugs($id, 'scope'), true);
}
/** "layout", "section", "row" or "module"; null when the entry has no known type. */
public function layoutType(int $id): ?string
{
foreach ($this->termSlugs($id, 'layout_type') as $slug) {
if (in_array($slug, self::LAYOUT_TYPES, true)) {
return $slug;
}
}
return null;
}
/** @return list<string> */
public function termSlugs(int $id, string $taxonomy): array
{
return array_map(static fn (WP_Term $term): string => (string) $term->slug, $this->terms($id, $taxonomy));
}
/** @return list<string> */
public function termNames(int $id, string $taxonomy): array
{
return array_map(static fn (WP_Term $term): string => (string) $term->name, $this->terms($id, $taxonomy));
}
/**
* Published entries, $limit + 1 of them (Cursor::page() tells whether there are more).
*
* @param array{layout_type?: string, scope?: string, search?: string, category?: string} $filters
* scope: "global" or "normal"; category: name of a layout_category term.
* @return list<WP_Post>
*/
public function find(array $filters, int $limit, int $offset): array
{
$taxQuery = [];
if (in_array($filters['layout_type'] ?? '', self::LAYOUT_TYPES, true)) {
$taxQuery[] = ['taxonomy' => 'layout_type', 'field' => 'slug', 'terms' => [$filters['layout_type']]];
}
if (($filters['scope'] ?? '') === 'global') {
$taxQuery[] = ['taxonomy' => 'scope', 'field' => 'slug', 'terms' => ['global']];
} elseif (($filters['scope'] ?? '') === 'normal') {
$taxQuery[] = ['taxonomy' => 'scope', 'field' => 'slug', 'terms' => ['global'], 'operator' => 'NOT IN'];
}
if (($filters['category'] ?? '') !== '') {
$taxQuery[] = ['taxonomy' => 'layout_category', 'field' => 'name', 'terms' => [$filters['category']]];
}
$args = [
'post_type' => self::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => $limit + 1,
'offset' => $offset,
'orderby' => 'title',
'order' => 'ASC',
'no_found_rows' => true,
'suppress_filters' => false,
];
if (($filters['search'] ?? '') !== '') {
$args['s'] = $filters['search'];
}
if ($taxQuery !== []) {
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- the library is small.
$args['tax_query'] = array_merge(['relation' => 'AND'], $taxQuery);
}
return array_values(array_filter(get_posts($args), static fn (mixed $post): bool => $post instanceof WP_Post));
}
/**
* @return array{id: int, title: string, layout_type: ?string, scope: string, module_type: ?string,
* categories: list<string>, tags: list<string>, modified: string}
*/
public function describe(WP_Post $post): array
{
$id = (int) $post->ID;
$moduleType = (string) get_post_meta($id, '_et_pb_module_type', true);
return [
'id' => $id,
'title' => (string) $post->post_title,
'layout_type' => $this->layoutType($id),
'scope' => $this->isGlobal($id) ? 'global' : 'normal',
'module_type' => $moduleType === '' ? null : $moduleType,
'categories' => $this->termNames($id, 'layout_category'),
'tags' => $this->termNames($id, 'layout_tag'),
'modified' => (string) $post->post_modified_gmt,
];
}
/**
* Library taxonomies not registered in this request (F2: Divi registers them in
* ET_Builder_Library; fixture finding 6: not in every context).
*
* @return list<string>
*/
public function missingTaxonomies(): array
{
return array_values(array_filter(
self::TAXONOMIES,
static fn (string $taxonomy): bool => ! taxonomy_exists($taxonomy)
));
}
/** @return list<WP_Term> */
private function terms(int $id, string $taxonomy): array
{
$terms = get_the_terms($id, $taxonomy);
return is_array($terms)
? array_values(array_filter($terms, static fn (mixed $term): bool => $term instanceof WP_Term))
: [];
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library;
use Closure;
use LIT\MCP\Abilities\Errors;
use WP_Error;
defined('ABSPATH') || exit;
/**
* Adapter for Divi's role editor (E3, E15): et_pb_is_allowed() (Divi 5.2.1 builder/core.php:1128)
* answers for the current user's role, "dangerous" options (theme_builder,
* read_dynamic_content_custom_fields) default to off for every role but administrator. When
* Divi no longer has the function, nothing is allowed – DiviCompatibility reports it.
* Not final so tests can pass a check.
*/
class DiviRoles
{
private const LABELS = [
'divi_library' => 'Divi-Bibliothek',
'save_library' => 'In der Bibliothek speichern',
'add_library' => 'Aus der Bibliothek hinzufügen',
'edit_global_library' => 'Globale Bibliothekselemente bearbeiten',
'read_dynamic_content_custom_fields' => 'Dynamic Content: benutzerdefinierte Felder lesen',
];
/** @param (Closure(string): bool)|null $check Test seam; null = Divi's et_pb_is_allowed(). */
public function __construct(private ?Closure $check = null)
{
}
public function allows(string $capability): bool
{
if ($this->check !== null) {
return ($this->check)($capability);
}
return function_exists('et_pb_is_allowed') && (bool) et_pb_is_allowed($capability);
}
/** Null when the role allows $capability, else a forbidden error naming the option. */
public function refusal(string $capability): ?WP_Error
{
if ($this->allows($capability)) {
return null;
}
return Errors::forbidden(
sprintf('Die Divi-Rolle dieses Kontos erlaubt „%s“ nicht.', self::LABELS[$capability] ?? $capability),
'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.'
);
}
}
@@ -31,7 +31,8 @@ defined('ABSPATH') || exit;
*/
class DynamicContentMap
{
private const REGISTRY = 'ET\Builder\Packages\Module\Layout\Components\DynamicContent\DynamicContentOptions';
/** Divi's dynamic-content registry class; also checked by DiviCompatibility. */
public const REGISTRY = 'ET\Builder\Packages\Module\Layout\Components\DynamicContent\DynamicContentOptions';
/**
* @param (Closure(int): mixed)|null $registry Registry lookup by post ID (tests); null = Divi's.