Behebt die fünf Befunde der Abschlussprüfung von divi-absicherung: Divi-Reiter ohne aktives Divi zeigt nur noch einen Hinweis (kein Status, keine Prüfung, kein Aufräumen), Zustandstexte werden erst beim Rendern übersetzt, DiviPages::setFlags() und DiviThemeBuilder::liveId(true) prüfen jetzt selbst die Freigabesperre, WriteProbe::removeOrphans() löscht nur noch Entwürfe mit passendem Titel, und wp lit-mcp divi-check --update-reference verweigert sich ohne Entwicklungskopie, bevor irgendetwas geschrieben wird. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
225 lines
8.0 KiB
PHP
225 lines
8.0 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Release;
|
||
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviPages;
|
||
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
|
||
use LIT\MCP\Integrations\WordPress\Render\PageRenderer;
|
||
use Throwable;
|
||
use WP_Error;
|
||
use WP_Post;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Spec §6.2 step 4: a throwaway draft page written through the normal Divi write path (ContentComposer → Composer
|
||
* → Serializer → DiviSaver, with the release lock opened for this page only), checked (content, builder flags,
|
||
* Divi renders every module without a PHP warning from Divi's files) and always deleted for good – in finally,
|
||
* not to the trash, with its revisions and meta. Pages a dead check left behind carry MARKER; removeOrphans()
|
||
* deletes them. No change log entry. Not final so CompatibilityCheck can be tested with a mock.
|
||
*/
|
||
class WriteProbe
|
||
{
|
||
public const MARKER = '_lit_mcp_check_page';
|
||
|
||
public const TITLE = '[LIT MCP Prüfung]';
|
||
|
||
/** Everyday modules without media, each with a text to find in the rendered page. */
|
||
public const SECTIONS = [[
|
||
'type' => 'section',
|
||
'label' => 'LIT MCP Prüfung',
|
||
'rows' => [
|
||
[
|
||
'columns' => '1_2,1_2',
|
||
'modules' => [
|
||
[
|
||
['type' => 'heading', 'text' => 'litcheck-heading', 'level' => 'h2'],
|
||
['type' => 'text', 'content' => '<p>litcheck-text</p>'],
|
||
['type' => 'button', 'text' => 'litcheck-button', 'url' => '/'],
|
||
],
|
||
[
|
||
['type' => 'blurb', 'title' => 'litcheck-blurb', 'content' => '<p>litcheck-blurb-text</p>'],
|
||
[
|
||
'type' => 'cta',
|
||
'title' => 'litcheck-cta',
|
||
'button_text' => 'litcheck-cta-button',
|
||
'button_url' => '/',
|
||
],
|
||
['type' => 'divider'],
|
||
],
|
||
],
|
||
],
|
||
[
|
||
'columns' => '4_4',
|
||
'modules' => [
|
||
['type' => 'toggle', 'title' => 'litcheck-toggle', 'content' => '<p>litcheck-toggle-text</p>'],
|
||
['type' => 'accordion', 'items' => [['title' => 'litcheck-accordion', 'content' => '<p>a</p>']]],
|
||
['type' => 'tabs', 'items' => [['title' => 'litcheck-tab', 'content' => '<p>t</p>']]],
|
||
['type' => 'icon-list', 'items' => [['text' => 'litcheck-icon-list']]],
|
||
],
|
||
],
|
||
],
|
||
]];
|
||
|
||
public const TEXTS = [
|
||
'litcheck-heading', 'litcheck-text', 'litcheck-button', 'litcheck-blurb', 'litcheck-cta', 'litcheck-toggle',
|
||
'litcheck-accordion', 'litcheck-tab', 'litcheck-icon-list',
|
||
];
|
||
|
||
/** Only pages older than this are orphans – a running check's page is younger. */
|
||
private const ORPHAN_AGE = '10 minutes ago';
|
||
|
||
/**
|
||
* @param string|null $diviDir PHP warnings from files below it fail the check; null = DiviEnvironment::themeDir().
|
||
*/
|
||
public function __construct(
|
||
private ContentComposer $composer,
|
||
private ?PageRenderer $renderer = null,
|
||
private ?string $diviDir = null
|
||
) {
|
||
}
|
||
|
||
/** @return list<string> German problems; [] = green. */
|
||
public function run(): array
|
||
{
|
||
$id = wp_insert_post([
|
||
'post_type' => 'page',
|
||
'post_status' => 'draft',
|
||
'post_title' => self::TITLE . ' ' . gmdate('Y-m-d H:i'),
|
||
'post_content' => '',
|
||
'meta_input' => [self::MARKER => '1'],
|
||
], true);
|
||
if ($id instanceof WP_Error || (int) $id <= 0) {
|
||
return ['Die Testseite konnte nicht angelegt werden: '
|
||
. ($id instanceof WP_Error ? $id->get_error_message() : 'keine ID')];
|
||
}
|
||
$id = (int) $id;
|
||
|
||
try {
|
||
$problems = $this->exercise($id);
|
||
} catch (Throwable $e) {
|
||
$problems = ['Schreibtest abgebrochen: ' . $e->getMessage()];
|
||
} finally {
|
||
if (! self::delete($id)) {
|
||
$problems[] = sprintf(
|
||
'Die Testseite %d konnte nicht gelöscht werden; bitte unter Seiten endgültig löschen.',
|
||
$id
|
||
);
|
||
}
|
||
}
|
||
|
||
return $problems;
|
||
}
|
||
|
||
/**
|
||
* @return list<int> IDs of check pages a dead check left behind, deleted now. Only drafts whose title starts
|
||
* with self::TITLE are considered – the marker meta alone is not enough (it is only a query hint here).
|
||
*/
|
||
public static function removeOrphans(): array
|
||
{
|
||
$posts = get_posts([
|
||
'post_type' => 'page',
|
||
'post_status' => 'draft',
|
||
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- a handful of marked pages at most.
|
||
'meta_key' => self::MARKER,
|
||
'date_query' => [['before' => self::ORPHAN_AGE, 'column' => 'post_date_gmt']],
|
||
'fields' => 'all',
|
||
'posts_per_page' => 20,
|
||
'no_found_rows' => true,
|
||
'suppress_filters' => true,
|
||
]);
|
||
|
||
$removed = [];
|
||
foreach (is_array($posts) ? $posts : [] as $post) {
|
||
if (! $post instanceof WP_Post || ! str_starts_with((string) $post->post_title, self::TITLE)) {
|
||
continue;
|
||
}
|
||
if (self::delete((int) $post->ID)) {
|
||
$removed[] = (int) $post->ID;
|
||
}
|
||
}
|
||
|
||
return $removed;
|
||
}
|
||
|
||
/** @return list<string> */
|
||
private function exercise(int $id): array
|
||
{
|
||
$written = ReleaseGate::whileProbing(
|
||
$id,
|
||
fn (): array|WP_Error => $this->composer->write($id, 'page', self::SECTIONS)
|
||
);
|
||
if ($written instanceof WP_Error) {
|
||
return ['Die Kurzform der Testseite wurde abgelehnt: ' . Errors::clientMessage($written)];
|
||
}
|
||
|
||
$post = get_post($id);
|
||
if (! $post instanceof WP_Post) {
|
||
return ['Die Testseite ist nach dem Schreiben nicht lesbar.'];
|
||
}
|
||
|
||
$problems = [];
|
||
if (! str_contains((string) $post->post_content, '<!-- wp:divi/')) {
|
||
$problems[] = 'Es wurde kein Divi-Inhalt gespeichert.';
|
||
}
|
||
foreach (DiviPages::FLAGS as $key => $value) {
|
||
if (get_post_meta($id, $key, true) !== $value) {
|
||
$problems[] = sprintf('Builder-Kennzeichen %s fehlt.', $key);
|
||
}
|
||
}
|
||
|
||
[$html, $warnings] = $this->render($post);
|
||
foreach (self::TEXTS as $text) {
|
||
if (! str_contains($html, $text)) {
|
||
$problems[] = sprintf('„%s“ fehlt in der gerenderten Seite.', $text);
|
||
}
|
||
}
|
||
foreach ($warnings as $warning) {
|
||
$problems[] = 'PHP-Warnung aus Divi beim Rendern: ' . $warning;
|
||
}
|
||
|
||
return $problems;
|
||
}
|
||
|
||
/** @return array{0: string, 1: list<string>} The rendered content and PHP warnings raised in Divi's files. */
|
||
private function render(WP_Post $post): array
|
||
{
|
||
$dir = $this->diviDir ?? DiviEnvironment::themeDir();
|
||
$warnings = [];
|
||
set_error_handler(
|
||
static function (
|
||
int $level,
|
||
string $message,
|
||
string $file = '',
|
||
int $line = 0
|
||
) use (
|
||
$dir,
|
||
&$warnings
|
||
): bool {
|
||
if ($dir !== null && str_starts_with($file, $dir)) {
|
||
$warnings[] = sprintf('%s (%s:%d)', $message, basename($file), $line);
|
||
}
|
||
|
||
return false;
|
||
},
|
||
E_WARNING | E_USER_WARNING
|
||
);
|
||
|
||
try {
|
||
$html = ($this->renderer ?? new PageRenderer())->render($post);
|
||
} finally {
|
||
restore_error_handler();
|
||
}
|
||
|
||
return [$html, $warnings];
|
||
}
|
||
|
||
private static function delete(int $id): bool
|
||
{
|
||
return wp_delete_post($id, true) instanceof WP_Post;
|
||
}
|
||
}
|