feat: lit-site-status, Admin-Tab und Admin-Hinweis zeigen Divi-Fähigkeiten mit Zustand, Quelle, Problemen und betroffenen Werkzeugen

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-18 22:29:38 +02:00
co-authored by Claude Sonnet 5.5
parent b92545a8fe
commit 2aef6e1154
13 changed files with 831 additions and 31 deletions
+142 -10
View File
@@ -10,6 +10,8 @@ use LIT\MCP\Integrations\Divi\Release\CheckReport;
use LIT\MCP\Integrations\Divi\Release\CheckReportStore;
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
use LIT\MCP\Integrations\Divi\Release\DiviEnvironment;
use LIT\MCP\Integrations\Divi\Release\FeatureOverview;
use LIT\MCP\Integrations\Divi\Release\FeatureStatus;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
@@ -21,10 +23,12 @@ use LIT\MCP\Plugin;
defined('ABSPATH') || exit;
/**
* Tab "Divi" (spec §6): release status of the installed Divi, the latest check report, "Prüfung starten" (REST,
* DiviCheckRoute), "Divi <Version> freigeben" (only after a fresh green report) and "Freigabe zurückziehen" (only
* for a database release). Loading the tab removes check pages a dead check left behind – in the page's load hook
* (removeOrphans()), not while rendering.
* Tab "Divi" (spec §6): release status of the installed Divi, the table of its features (state, source, problems,
* affected tools – E3), the latest check report, "Prüfung starten" (REST, DiviCheckRoute), "Divi <Version>
* freigeben" (only after a fresh green report) and "Freigabe zurückziehen" (whenever a database release applies,
* whatever the overall state). Loading the tab removes check pages a dead check left behind – in the page's load
* hook (removeOrphans()), not while rendering. renderNotice() is the admin notice on every admin page while a
* feature is unusable.
*/
final class DiviTab implements AdminTab
{
@@ -38,6 +42,7 @@ final class DiviTab implements AdminTab
/**
* @param (Closure(): string)|null $version Installed Divi version ('' = none); null = DiviEnvironment.
* @param (Closure(string): string)|null $stateKey null = ReleaseResolver::stateKey().
* @param FeatureOverview|null $overview null = a table without the affected tools.
*/
public function __construct(
private ?CheckReportStore $reports = null,
@@ -45,7 +50,8 @@ final class DiviTab implements AdminTab
private ?ChangeLog $log = null,
private ?AdminPostAction $action = null,
private ?Closure $version = null,
private ?Closure $stateKey = null
private ?Closure $stateKey = null,
private ?FeatureOverview $overview = null
) {
}
@@ -68,6 +74,55 @@ final class DiviTab implements AdminTab
add_action('admin_post_' . self::RELEASE_ACTION, [$this, 'release']);
add_action('admin_post_' . self::WITHDRAW_ACTION, [$this, 'withdraw']);
add_action('load-' . SettingsPage::SCREEN_HOOK, [$this, 'removeOrphans']);
$this->registerNotice();
}
/** The admin notice for administrators while a Divi feature is unusable (registerActions() runs in admin only). */
public function registerNotice(): void
{
add_action('admin_notices', [$this, 'renderNotice']);
}
/**
* Only for administrators, only with Divi active, only while a feature is not usable – and not on the Divi tab,
* which shows the same table. Reads the cached matrix (D7), so no check runs on an ordinary admin page.
*/
public function renderNotice(): void
{
if (! current_user_can('manage_options') || $this->version() === '' || $this->onDiviTab()) {
return;
}
$unusable = ReleaseGate::status()->matrix->unusable();
if ($unusable === []) {
return;
}
printf(
'<div class="notice notice-warning"><p>%s <a href="%s">%s</a></p></div>',
esc_html(sprintf(
/* translators: 1: Divi version, 2: comma separated feature names */
__(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument.
'LIT MCP: Mit Divi %1$s sind diese Fähigkeiten nicht nutzbar: %2$s. Die betroffenen Werkzeuge lehnen ab, alle anderen laufen weiter.',
'wp-lit-mcp'
),
$this->version(),
implode(', ', array_map(
static fn (FeatureStatus $status): string => $status->feature->label(),
$unusable
))
)),
esc_url(SettingsPage::url(self::SLUG)),
esc_html__('Details im Reiter „Divi“', 'wp-lit-mcp')
);
}
private function onDiviTab(): bool
{
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
return is_object($screen) && $screen->id === SettingsPage::SCREEN_HOOK && $this->requested();
}
/**
@@ -153,7 +208,7 @@ final class DiviTab implements AdminTab
echo '<h2>' . esc_html__('Divi-Freigabe', 'wp-lit-mcp') . '</h2>';
echo '<p>' . esc_html__(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument.
'LIT MCP schreibt nur mit Divi-Versionen, die geprüft sind. Nach einem Divi-Update hier die Prüfung starten und die Version freigeben; bis dahin lesen die Divi-Werkzeuge nur.',
'LIT MCP prüft nach einem Divi-Update jede Fähigkeit einzeln (z. B. „Seiten schreiben“). Ist sie nicht nutzbar, lehnen nur die Werkzeuge ab, die sie brauchen; alle anderen laufen weiter.',
'wp-lit-mcp'
) . '</p>';
if ($this->removed !== []) {
@@ -168,6 +223,7 @@ final class DiviTab implements AdminTab
}
$this->renderStatus($status);
$this->renderFeatures($status);
$this->renderForms($status, $report);
if ($report !== null) {
$this->renderReport($report);
@@ -184,7 +240,7 @@ final class DiviTab implements AdminTab
);
printf(
'<tr><th scope="row">%s</th><td><strong>%s</strong> – %s</td></tr>',
esc_html__('Zustand', 'wp-lit-mcp'),
esc_html__('Gesamtzustand', 'wp-lit-mcp'),
esc_html($this->stateLabel($status->state)),
esc_html($this->explanation($status))
);
@@ -198,6 +254,80 @@ final class DiviTab implements AdminTab
echo '</tbody></table>';
}
private function renderFeatures(ReleaseStatus $status): void
{
echo '<h3>' . esc_html__('Fähigkeiten', 'wp-lit-mcp') . '</h3>';
echo '<table class="widefat striped lit-mcp-divi-features"><thead><tr>';
$headings = [
__('Fähigkeit', 'wp-lit-mcp'),
__('Zustand', 'wp-lit-mcp'),
__('Quelle', 'wp-lit-mcp'),
__('Probleme', 'wp-lit-mcp'),
__('Betroffene Werkzeuge', 'wp-lit-mcp'),
];
foreach ($headings as $heading) {
echo '<th scope="col">' . esc_html($heading) . '</th>';
}
echo '</tr></thead><tbody>';
foreach (($this->overview ?? FeatureOverview::withoutTools())->rows($status->matrix) as $row) {
echo '<tr><td>' . esc_html($row['label']) . '</td>';
echo '<td><strong>' . esc_html($this->stateLabel(ReleaseState::from($row['state']))) . '</strong></td>';
echo '<td>' . esc_html($this->sourceLabel($row['source'])) . '</td>';
echo '<td>';
$this->renderList($row['problems'], false);
echo '</td><td>';
$this->renderList($row['tools'], true);
echo '</td></tr>';
}
echo '</tbody></table>';
$this->renderAutomationNote();
}
/** @param list<string> $items */
private function renderList(array $items, bool $code): void
{
if ($items === []) {
echo '–';
return;
}
echo '<ul>';
foreach ($items as $item) {
echo $code ? '<li><code>' . esc_html($item) . '</code></li>' : '<li>' . esc_html($item) . '</li>';
}
echo '</ul>';
}
private function sourceLabel(?string $source): string
{
return match ($source) {
ReleaseStatus::SOURCE_CODE => __('Plugin (code)', 'wp-lit-mcp'),
ReleaseStatus::SOURCE_DATABASE => __('Freigabe auf dieser Website (database)', 'wp-lit-mcp'),
ReleaseStatus::SOURCE_AUTO => __('Automatik (auto)', 'wp-lit-mcp'),
default => '–',
};
}
/** E1: what the automatic release does and how to switch it off. */
private function renderAutomationNote(): void
{
echo '<p class="description">' . esc_html__(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument.
'Automatik: Ist die installierte Divi-Version im Plugin nicht als getestet hinterlegt, gibt LIT MCP jede Fähigkeit einzeln frei, wenn alle benutzten Divi-Funktionen vorhanden sind, ihre Aufrufe (Signaturen) und die Fingerabdrücke nachgebauter Divi-Abläufe unverändert sind und alle Konstanten definiert sind. Das prüft Aufrufformen, nicht das Verhalten von Divi. Fehlt für eine Stelle die Referenz, ist die Fähigkeit ungetestet; weicht etwas ab, ist sie inkompatibel.',
'wp-lit-mcp'
) . '</p>';
echo '<p class="description">' . sprintf(
/* translators: %s: filter name in a code element */
esc_html__(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument.
'Die Automatik abschalten: Filter %s auf false setzen (z. B. in einem mu-Plugin). Dann gelten nicht hinterlegte Divi-Versionen bis zur Freigabe unten als ungetestet.',
'wp-lit-mcp'
),
'<code>lit_mcp_divi_auto_release</code>'
) . '</p>';
}
private function stateLabel(ReleaseState $state): string
{
return match ($state) {
@@ -212,13 +342,14 @@ final class DiviTab implements AdminTab
if ($status->state === ReleaseState::Incompatible) {
return __(
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument.
'Divi hat Stellen geändert, die LIT MCP nutzt. Schreiben bleibt gesperrt, bis ein Plugin-Update von LIT MCP eingespielt ist.',
'Divi hat Stellen geändert, die LIT MCP nutzt. Die betroffenen Fähigkeiten (Tabelle unten) bleiben gesperrt, bis ein Plugin-Update von LIT MCP eingespielt ist; alle anderen laufen weiter.',
'wp-lit-mcp'
);
}
if ($status->state === ReleaseState::Untested) {
return __(
'Diese Version ist nicht geprüft. Die Divi-Werkzeuge lesen nur, bis sie freigegeben ist.',
// phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument.
'Diese Version ist nicht geprüft. Die Werkzeuge der ungetesteten Fähigkeiten (Tabelle unten) lehnen ab, bis die Version freigegeben ist; alle anderen laufen weiter.',
'wp-lit-mcp'
);
}
@@ -283,7 +414,8 @@ final class DiviTab implements AdminTab
}
}
if ($status->state === ReleaseState::Released && $status->source === ReleaseStatus::SOURCE_DATABASE) {
// A database release applies whatever the overall state: one incompatible feature must not hide the way back.
if ($status->source === ReleaseStatus::SOURCE_DATABASE || $status->release !== null) {
$this->actionForm(self::WITHDRAW_ACTION, __('Freigabe zurückziehen', 'wp-lit-mcp'), 'secondary');
}
}
+42 -3
View File
@@ -31,8 +31,10 @@ final class SiteStatusAbility implements Ability
{
return 'Überblick: Website, WordPress-Version, eigener Benutzer und Rollen, '
. 'erkannte Integrationen (Divi, Pods, The Events Calendar). '
. 'Bei Divi nennt problems Divi-Stellen, die nach einem Update fehlen oder sich geändert haben, und '
. 'release, ob LIT MCP mit dieser Divi-Version schreiben darf (released, untested, incompatible).';
. 'Bei Divi nennt problems Divi-Stellen, die nach einem Update fehlen oder sich geändert haben, '
. 'release den Gesamtzustand und features je Fähigkeit (z. B. Seiten schreiben) Zustand '
. '(released, untested, incompatible), Quelle (code, database, auto), Probleme und betroffene Werkzeuge. '
. 'Werkzeuge, deren Fähigkeit nicht released ist, lehnen ab; alle anderen Divi-Werkzeuge laufen weiter.';
}
public function inputSchema(): array
@@ -49,12 +51,49 @@ final class SiteStatusAbility implements Ability
'wordpress' => ['type' => 'string'],
'user' => ['type' => 'object'],
'allowed_roles' => ['type' => 'array', 'items' => ['type' => 'string']],
'components' => ['type' => 'array'],
'components' => ['type' => 'array', 'items' => self::componentSchema()],
],
'required' => ['site', 'wordpress', 'user', 'allowed_roles', 'components'],
];
}
/** @return array<string, mixed> */
private static function componentSchema(): array
{
$text = ['type' => 'string'];
return [
'type' => 'object',
'properties' => [
'id' => $text,
'active' => ['type' => 'boolean'],
'version' => ['type' => ['string', 'null']],
'problems' => ['type' => 'array', 'items' => $text],
'release' => ['type' => ['object', 'null']],
'features' => [
'type' => 'array',
'description' => 'Nur bei Divi: Zustand je Fähigkeit.',
'items' => [
'type' => 'object',
'properties' => [
'id' => $text,
'label' => $text,
'state' => ['type' => 'string', 'enum' => ['released', 'untested', 'incompatible']],
'source' => [
'type' => ['string', 'null'],
'description' => 'code, database, auto oder null (nicht nutzbar).',
],
'problems' => ['type' => 'array', 'items' => $text],
'tools' => ['type' => 'array', 'items' => $text],
],
'required' => ['id', 'label', 'state', 'source', 'problems', 'tools'],
],
],
],
'required' => ['id', 'active', 'version', 'problems', 'release'],
];
}
public function annotations(): array
{
return Annotations::read();
@@ -6,31 +6,47 @@ namespace LIT\MCP\Integrations\Divi;
use LIT\MCP\Integrations\Core\EnvironmentComponent;
use LIT\MCP\Integrations\Divi\Release\DiviEnvironment;
use LIT\MCP\Integrations\Divi\Release\FeatureOverview;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
defined('ABSPATH') || exit;
/**
* Divi in lit-site-status: version, the internals that are missing (DiviCompatibility) and the release status
* (spec §5.5); inactive without problems and release when Divi is not the template.
* Divi in lit-site-status: version, missing internals (DiviCompatibility), the release status and – per feature –
* state, source, problems and the tools it affects (E3). The features come from the matrix the release gate
* already holds (cached, D7), so a site-status call never recomputes the mirror fingerprints.
*/
final class DiviEnvironmentComponent implements EnvironmentComponent
{
public function __construct(private ?DiviCompatibility $compatibility = null)
private FeatureOverview $overview;
public function __construct(private ?DiviCompatibility $compatibility = null, ?FeatureOverview $overview = null)
{
$this->overview = $overview ?? FeatureOverview::withoutTools();
}
/**
* @return array{id: string, active: bool, version: ?string, problems: list<string>, release: ?array<string, mixed>,
* features: list<array{id: string, label: string, state: string, source: ?string, problems: list<string>,
* tools: list<string>}>}
*/
public function describe(): array
{
$version = DiviEnvironment::version();
$active = $version !== null;
if ($version === null) {
return ['id' => 'divi', 'active' => false, 'version' => null, 'problems' => [], 'release' => null,
'features' => []];
}
$status = ReleaseGate::status();
return [
'id' => 'divi',
'active' => $active,
'active' => true,
'version' => $version,
'problems' => $active ? ($this->compatibility ?? new DiviCompatibility())->problems() : [],
'release' => $active ? ReleaseGate::status()->toArray() : null,
'problems' => ($this->compatibility ?? new DiviCompatibility())->problems(),
'release' => $status->toArray(),
'features' => $this->overview->rows($status->matrix),
];
}
}
+18 -4
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi;
use Closure;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\ToolAvailability;
use LIT\MCP\Admin\DiviTab;
use LIT\MCP\Admin\SettingsPage;
@@ -55,6 +56,7 @@ use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
use LIT\MCP\Integrations\Divi\Release\FeatureOverview;
use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
@@ -95,11 +97,14 @@ final class DiviIntegration implements Integration, Bootable
* "Divi Theme Builder" gate their entries in the Divi tools (ruling P9, E9), lit/divi-insert gates
* lit/divi-node-copy, lit-library-insert and content-create's divi input (audit M2), lit/library-insert
* gates lit/divi-node-copy from a library entry (audit L5). null = no Tools tab: everything allowed.
* @param (Closure(): list<Ability>)|null $allAbilities The abilities of every integration (Plugin passes
* IntegrationRegistry::abilities()); the tools a Divi feature affects are found among them (E3).
*/
public function __construct(
private ContentSchema $schema,
private ChangeLog $log,
private ?ToolAvailability $tools = null
private ?ToolAvailability $tools = null,
private ?Closure $allAbilities = null
) {
$this->modules = new DiviModules();
$this->design = new DesignSystemReader();
@@ -177,10 +182,16 @@ final class DiviIntegration implements Integration, Bootable
return [new DiviLogoUsage()];
}
/** Port for lit-site-status: Divi's version, missing internals and release status. */
/** Which tools each Divi feature affects – for lit-site-status and the Divi tab (E3). */
public function featureOverview(): FeatureOverview
{
return new FeatureOverview($this->allAbilities ?? static fn (): array => []);
}
/** Port for lit-site-status: Divi's version, missing internals, release status and features. */
public function environmentComponent(): EnvironmentComponent
{
return new DiviEnvironmentComponent();
return new DiviEnvironmentComponent(overview: $this->featureOverview());
}
/**
@@ -210,7 +221,10 @@ final class DiviIntegration implements Integration, Bootable
(new DiviCheckRoute($check))->register();
DiviCheckCommand::register($check);
add_filter(SettingsPage::TABS_FILTER, fn (array $tabs): array => [...$tabs, new DiviTab(log: $this->log)]);
add_filter(
SettingsPage::TABS_FILTER,
fn (array $tabs): array => [...$tabs, new DiviTab(log: $this->log, overview: $this->featureOverview())]
);
}
public function abilities(): array
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Abilities\Ability;
defined('ABSPATH') || exit;
/**
* The per-feature view shared by lit-site-status and the admin tab (E3): each row of the compatibility matrix plus
* the tools that need the feature. Reads the matrix the release gate already holds (cached, D7) – nothing is
* checked again here.
*/
final class FeatureOverview
{
/** @param Closure(): list<Ability> $abilities All registered abilities (IntegrationRegistry::abilities()). */
public function __construct(private Closure $abilities)
{
}
/** An overview that knows no tools (tests, and a Divi tab built without a registry). */
public static function withoutTools(): self
{
return new self(static fn (): array => []);
}
/**
* Tools whose diviFeatures([]) contain the feature, named as the MCP client sees them: direct tools
* lit-<name>, indirect ones lit/<name>. A tool that needs a feature only for certain input (content-create
* with divi) is not listed – its refusal names the feature.
*
* @return array<string, list<string>> Feature ID => tool names.
*/
public function toolsByFeature(): array
{
$map = [];
foreach (($this->abilities)() as $ability) {
if (! $ability instanceof RequiresDiviFeatures) {
continue;
}
foreach ($ability->diviFeatures([]) as $feature) {
$map[$feature->value][] = $ability->isDirect()
? str_replace('/', '-', $ability->name())
: $ability->name();
}
}
return $map;
}
/**
* @return list<array{id: string, label: string, state: string, source: ?string, problems: list<string>,
* tools: list<string>}>
*/
public function rows(CompatibilityMatrix $matrix): array
{
$tools = $this->toolsByFeature();
return array_map(
static fn (array $row): array => $row + ['tools' => $tools[$row['id']] ?? []],
$matrix->toArray()
);
}
}
+10 -2
View File
@@ -131,9 +131,15 @@ final class Plugin
Limits $limits,
ToolAvailability $tools
): IntegrationRegistry {
$divi = new DiviIntegration($schema, $changeLog, $tools);
// E3: lit-site-status and the Divi tab name the tools each feature affects; the registry exists only after
// the Divi integration, so it is asked at call time.
$registry = null;
$allAbilities = static function () use (&$registry): array {
return $registry instanceof IntegrationRegistry ? $registry->abilities() : [];
};
$divi = new DiviIntegration($schema, $changeLog, $tools, $allAbilities);
return new IntegrationRegistry(
$registry = new IntegrationRegistry(
new CoreIntegration(self::pluginDir() . '/docs/guides', [$divi->environmentComponent()]),
new WordPressIntegration(
$schema,
@@ -147,6 +153,8 @@ final class Plugin
$divi->library(),
$divi->themeBuilder()
);
return $registry;
}
public static function settingsPage(): ?SettingsPage