refactor: Divi-Boot-Schritte in DiviIntegration::boot() (Bootable) – Upgrader-Hook, Prüf-Route, CLI und Reiter „Divi“ über lit_mcp_admin_tabs; IntegrationRegistry::boot() bootet jede Integration genau einmal; Prüfseiten-Aufräumen im load-Hook statt beim Rendern

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-18 21:53:04 +02:00
co-authored by Claude Opus 5.5
parent 06148fb8c1
commit 6c59934caa
10 changed files with 293 additions and 37 deletions
+32 -4
View File
@@ -23,7 +23,8 @@ 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). Opening the tab removes check pages a dead check left behind.
* 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.
*/
final class DiviTab implements AdminTab
{
@@ -31,6 +32,9 @@ final class DiviTab implements AdminTab
public const RELEASE_ACTION = 'lit_mcp_divi_release';
public const WITHDRAW_ACTION = 'lit_mcp_divi_withdraw';
/** @var list<int> Check pages removeOrphans() deleted in this request; render() reports them. */
private array $removed = [];
/**
* @param (Closure(): string)|null $version Installed Divi version ('' = none); null = DiviEnvironment.
* @param (Closure(string): string)|null $stateKey null = ReleaseResolver::stateKey().
@@ -63,6 +67,22 @@ 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']);
}
/**
* load-<page> of the settings page, before any output: on this tab, for manage_options and with Divi active,
* deletes the check pages a dead check left behind (WriteProbe::removeOrphans(): marked drafts with the check
* title, older than ten minutes – no running check uses them). On a GET without nonce on purpose: it removes
* only the plugin's own leftovers and is idempotent. render() only reports what was deleted.
*/
public function removeOrphans(): void
{
if (! $this->requested() || ! current_user_can('manage_options') || $this->version() === '') {
return;
}
$this->removed = WriteProbe::removeOrphans();
}
public function release(): void
@@ -127,7 +147,6 @@ final class DiviTab implements AdminTab
return;
}
$removed = WriteProbe::removeOrphans();
$status = ReleaseGate::status();
$report = $this->reports()->latest();
@@ -137,13 +156,13 @@ final class DiviTab implements AdminTab
'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.',
'wp-lit-mcp'
) . '</p>';
if ($removed !== []) {
if ($this->removed !== []) {
printf(
'<div class="notice notice-warning inline"><p>%s</p></div>',
esc_html(sprintf(
/* translators: %s: comma separated page IDs */
__('Liegengebliebene Prüfseiten gelöscht: %s', 'wp-lit-mcp'),
implode(', ', $removed)
implode(', ', $this->removed)
))
);
}
@@ -305,6 +324,15 @@ final class DiviTab implements AdminTab
) . '</p>';
}
/** Whether this request opens this tab of the settings page (the load hook fires for every tab). */
private function requested(): bool
{
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab switch.
$tab = isset($_GET['tab']) ? sanitize_key(wp_unslash((string) $_GET['tab'])) : '';
return $tab === self::SLUG;
}
private function version(): string
{
return ($this->version ?? static fn (): string => DiviEnvironment::version() ?? '')();
+33 -13
View File
@@ -16,24 +16,27 @@ final class SettingsPage
/** Hook suffix WordPress gives this page (add_options_page → "settings_page_<slug>"). */
public const SCREEN_HOOK = 'settings_page_' . self::SLUG;
/** @var array<string, AdminTab> */
private array $tabs = [];
/**
* Filter on the tab list (list<AdminTab>): integrations append their own tabs in Bootable::boot() – Divi its
* tab "Divi". Applied once, on first use of the tabs (after Plugin booted the integrations).
*/
public const TABS_FILTER = 'lit_mcp_admin_tabs';
/** @var array<string, AdminTab>|null null = TABS_FILTER not applied yet. */
private ?array $tabs = null;
/**
* @param list<AdminTab> $tabs The first tab is the default.
* @param list<AdminTab> $baseTabs The plugin's own tabs; the first tab is the default.
* @param string $logoUrl Loosen-IT logo shown top right of the page ('' = none).
* @param string $assetsUrl URL of the plugin's assets/ directory ('' = no own CSS/JS).
* @param string $version Asset version for cache busting.
*/
public function __construct(
array $tabs,
private array $baseTabs,
private string $logoUrl = '',
private string $assetsUrl = '',
private string $version = ''
) {
foreach ($tabs as $tab) {
$this->tabs[$tab->slug()] = $tab;
}
}
public function register(): void
@@ -41,7 +44,7 @@ final class SettingsPage
add_action('admin_menu', [$this, 'addMenu']);
add_action('admin_init', [$this, 'registerSettings']);
add_action('admin_enqueue_scripts', [$this, 'enqueueAssets']);
foreach ($this->tabs as $tab) {
foreach ($this->tabs() as $tab) {
$tab->registerActions();
}
}
@@ -59,7 +62,7 @@ final class SettingsPage
public function registerSettings(): void
{
foreach ($this->tabs as $tab) {
foreach ($this->tabs() as $tab) {
$tab->registerSettings();
}
}
@@ -90,7 +93,7 @@ final class SettingsPage
/** @return list<string> */
public function tabSlugs(): array
{
return array_keys($this->tabs);
return array_keys($this->tabs());
}
public function currentTab(): string
@@ -98,7 +101,9 @@ final class SettingsPage
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab switch.
$requested = isset($_GET['tab']) ? sanitize_key(wp_unslash((string) $_GET['tab'])) : '';
return isset($this->tabs[$requested]) ? $requested : (string) array_key_first($this->tabs);
$tabs = $this->tabs();
return isset($tabs[$requested]) ? $requested : (string) array_key_first($tabs);
}
/** @param array<string, string> $args */
@@ -123,7 +128,7 @@ final class SettingsPage
echo '</div><hr class="wp-header-end">';
$this->renderNotice();
printf('<nav class="nav-tab-wrapper wp-clearfix" aria-label="%s">', esc_attr__('Bereiche', 'wp-lit-mcp'));
foreach ($this->tabs as $slug => $tab) {
foreach ($this->tabs() as $slug => $tab) {
printf(
'<a href="%s" class="nav-tab%s"%s>%s</a>',
esc_url(self::url($slug)),
@@ -133,10 +138,25 @@ final class SettingsPage
);
}
echo '</nav>';
$this->tabs[$current]->render();
$this->tabs()[$current]->render();
echo '</div>';
}
/** @return array<string, AdminTab> The plugin's tabs and those the integrations add (TABS_FILTER), by slug. */
private function tabs(): array
{
if ($this->tabs === null) {
$this->tabs = [];
foreach ((array) apply_filters(self::TABS_FILTER, $this->baseTabs) as $tab) {
if ($tab instanceof AdminTab) {
$this->tabs[$tab->slug()] = $tab;
}
}
}
return $this->tabs;
}
private function renderLogo(): void
{
if ($this->logoUrl === '') {
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations;
defined('ABSPATH') || exit;
/**
* An integration with boot steps of its own (hooks, REST routes, WP-CLI commands, admin tabs), so the composition
* root does not need to know them. IntegrationRegistry::boot() calls boot() whether or not the integration is
* available: Plugin boots on plugins_loaded, before the theme – isAvailable() cannot be asked yet.
*/
interface Bootable
{
/** Registers the integration's own hooks, routes, CLI commands and admin tabs. Called once by Plugin. */
public function boot(): void;
}
+30 -2
View File
@@ -5,7 +5,11 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi;
use Closure;
use LIT\MCP\Admin\DiviTab;
use LIT\MCP\Admin\SettingsPage;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Cli\DiviCheckCommand;
use LIT\MCP\Integrations\Bootable;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
@@ -47,6 +51,9 @@ use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
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\Schema\DiviCatalogAbility;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
@@ -66,9 +73,9 @@ defined('ABSPATH') || exit;
* Divi 5 pages (spec §5, Etappe 2). Only available when the Divi 5 builder is loaded
* (spec §2 principle 4); Divi 4 is never edited (spec §3.4). The write abilities share one
* DiviWriter; each binds the composition to its entry via DiviWriter::composition().
* contentComposer() gives lit/content-create the Divi short form (spec §6.2).
* contentComposer() gives lit/content-create the Divi short form (spec §6.2). boot() registers Divi's own hooks.
*/
final class DiviIntegration implements Integration
final class DiviIntegration implements Integration, Bootable
{
private DiviModules $modules;
private DesignSystemReader $design;
@@ -190,6 +197,27 @@ final class DiviIntegration implements Integration
);
}
/**
* Divi's own boot steps, registered whether or not Divi is loaded yet (Bootable): a theme update checks the
* fingerprints again at once (spec §5.4), the admin check as REST route and WP-CLI command (spec §6, §7) and
* the tab "Divi" after the plugin's own tabs.
*/
public function boot(): void
{
// Spec §5.4: a theme update checks the Divi fingerprints again at once (the state key would, too).
add_action('upgrader_process_complete', static function (): void {
delete_transient(ReleaseResolver::CACHE);
});
// Spec §6: the admin check runs as a REST request (same context as MCP calls). Its short form has no
// Tools-tab gate: the check tests Divi, not this site's tool switches.
$check = fn (): CompatibilityCheck => CompatibilityCheck::create($this->contentComposer());
(new DiviCheckRoute($check))->register();
DiviCheckCommand::register($check);
add_filter(SettingsPage::TABS_FILTER, fn (array $tabs): array => [...$tabs, new DiviTab(log: $this->log)]);
}
public function abilities(): array
{
$loader = $this->loader();
+21
View File
@@ -16,11 +16,32 @@ final class IntegrationRegistry
/** @var list<array{Integration, list<Ability>}>|null Available integrations with their abilities. */
private ?array $loaded = null;
private bool $booted = false;
public function __construct(Integration ...$integrations)
{
$this->integrations = array_values($integrations);
}
/**
* Boots every Bootable integration once, in registry order – available or not: Plugin boots on plugins_loaded,
* before the theme (Divi) is loaded, so isAvailable() is not known yet (as the hooks were registered before
* Bootable). A second call does nothing.
*/
public function boot(): void
{
if ($this->booted) {
return;
}
$this->booted = true;
foreach ($this->integrations as $integration) {
if ($integration instanceof Bootable) {
$integration->boot();
}
}
}
/** @return list<Integration> */
public function available(): array
{
+3 -15
View File
@@ -12,7 +12,6 @@ use LIT\MCP\Admin\ConnectionRepository;
use LIT\MCP\Admin\ConnectionStatus;
use LIT\MCP\Admin\ConnectionTab;
use LIT\MCP\Admin\ConnectionUsage;
use LIT\MCP\Admin\DiviTab;
use LIT\MCP\Admin\LimitsTab;
use LIT\MCP\Admin\LogTab;
use LIT\MCP\Admin\SettingsPage;
@@ -21,15 +20,11 @@ use LIT\MCP\Admin\ToolsTab;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\ChangeLog\ChangeLogCleanup;
use LIT\MCP\ChangeLog\ChangeLogInstaller;
use LIT\MCP\Cli\DiviCheckCommand;
use LIT\MCP\Guides\InstructionsBuilder;
use LIT\MCP\Integrations\Core\CoreIntegration;
use LIT\MCP\Integrations\Divi\DiviIntegration;
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
use LIT\MCP\Integrations\Divi\Release\DiviToolGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;
use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Integrations\Media\MediaIntegration;
@@ -78,11 +73,6 @@ final class Plugin
(new ChangeLogInstaller($changeLog))->maybeInstall();
(new ChangeLogCleanup($changeLog, $limits))->register();
// Spec §5.4: a theme update checks the Divi fingerprints again at once (the state key would, too).
add_action('upgrader_process_complete', static function (): void {
delete_transient(ReleaseResolver::CACHE);
});
$schema = self::contentSchema();
$registry = null;
$policy = null;
@@ -139,10 +129,9 @@ final class Plugin
$divi->themeBuilder()
);
// Spec §6: the admin check runs as a REST request (same context as MCP calls).
$diviCheck = static fn (): CompatibilityCheck => CompatibilityCheck::create($divi->contentComposer());
(new DiviCheckRoute($diviCheck))->register();
DiviCheckCommand::register($diviCheck);
// Integration-owned hooks (Divi: upgrader hook, check route, CLI command, tab "Divi") – before the settings
// page reads its tabs.
$registry->boot();
$policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...));
(new AbilityRegistrar(
@@ -167,7 +156,6 @@ final class Plugin
new ToolsTab($registry, $policy),
new LogTab($changeLog, $limits, $registry),
new LimitsTab(),
new DiviTab(log: $changeLog),
];
self::$settingsPage = new SettingsPage(
$tabs,