refactor: Divi pages adapter replaces DiviFlags

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 01:08:08 +02:00
co-authored by Claude Opus 5.5
parent e10a766e2a
commit fed77b0584
13 changed files with 232 additions and 132 deletions
@@ -20,7 +20,7 @@ defined('ABSPATH') || exit;
/**
* Divi short form for lit/content-create (spec §6.2): the sections become a Divi 5 layout in
* exactly one divi/placeholder – inserted by TreeEditor like the first section of an empty page
* (ruling S9) – stored through DiviSaver's kses window, with the builder flags (DiviFlags, S11).
* (ruling S9) – stored through DiviSaver's kses window, with the builder flags (DiviPages, S11).
*
* Allow map (ruling S22) for an entry that does not exist yet: check() runs before the insert
* with the composition bound to post ID 0 – Divi's own registry for "no entry" (get_options(0)
-40
View File
@@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use RuntimeException;
defined('ABSPATH') || exit;
/**
* Marks an entry as a Divi 5 builder layout, like the Visual Builder's own save does
* (Divi 5.2.1 SyncToServerController.php:382-387: _et_pb_use_divi_5 = "on", legacy
* _et_pb_use_builder = "on", and _et_pb_show_page_creation = "off" so the builder does not show
* its page creation flow on the first open). Ruling S11: update_post_meta() returns false for
* an unchanged value too, so success is checked by reading the flags back. Used by DiviSaver
* (inside FollowUp) and the Divi content creation.
*/
final class DiviFlags
{
public const FLAGS = [
'_et_pb_use_divi_5' => 'on',
'_et_pb_use_builder' => 'on',
'_et_pb_show_page_creation' => 'off',
];
/** @throws RuntimeException When a flag does not read back as set. */
public static function set(int $postId): void
{
foreach (self::FLAGS as $key => $value) {
update_post_meta($postId, $key, $value);
}
foreach (self::FLAGS as $key => $value) {
if (get_post_meta($postId, $key, true) !== $value) {
throw new RuntimeException(sprintf('Divi flag %s of post %d is not "%s".', $key, $postId, $value));
}
}
}
}
+1 -1
View File
@@ -74,6 +74,6 @@ final class DiviPage
/** The entry opened in the Divi Visual Builder – the edit_url of every Divi write (review I-1). */
public static function builderUrl(WP_Post|int $post): string
{
return (string) add_query_arg('et_fb', '1', (string) get_permalink($post));
return DiviPages::builderUrl($post);
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\Mirror;
use RuntimeException;
use WP_Post;
defined('ABSPATH') || exit;
/**
* Adapter "Seiten" (spec §3): the Divi 5 builder flags of an entry as the Visual Builder's own save sets them
* (mirror vb-sync-flags: _et_pb_use_divi_5 = "on", legacy _et_pb_use_builder = "on", _et_pb_show_page_creation =
* "off" so the builder skips its page creation flow), whether an entry uses the builder, its builder URL, the
* static CSS of one page and Divi's list of linkable post types. Ruling S11: update_post_meta() returns false for an
* unchanged value too, so setFlags() checks by reading the flags back. Used by DiviSaver, the Divi content creation,
* WpLinkTargets and PostPresenter.
*/
final class DiviPages implements DiviAdapter
{
public const FLAGS = [
'_et_pb_use_divi_5' => 'on',
'_et_pb_use_builder' => 'on',
'_et_pb_show_page_creation' => 'off',
];
public static function manifest(): Manifest
{
return new Manifest(
Manifest::PAGES,
calls: [
new Call('ET_Core_PageResource::remove_static_resources', 3),
new Call('et_builder_get_public_post_types', 0),
],
keys: ['_et_pb_use_divi_5', '_et_pb_use_builder', '_et_pb_show_page_creation', 'et_fb'],
mirrors: [
new Mirror(
'vb-sync-flags',
'includes/builder-5/server/VisualBuilder/REST/SyncToServer/SyncToServerController.php',
'update',
'SyncToServerController',
'Builder-Kennzeichen einer Seite (_et_pb_use_divi_5, _et_pb_use_builder, _et_pb_show_page_creation)'
),
]
);
}
/** @throws RuntimeException When a flag does not read back as set. */
public static function setFlags(int $postId): void
{
foreach (self::FLAGS as $key => $value) {
update_post_meta($postId, $key, $value);
}
foreach (self::FLAGS as $key => $value) {
if (get_post_meta($postId, $key, true) !== $value) {
throw new RuntimeException(sprintf('Divi flag %s of post %d is not "%s".', $key, $postId, $value));
}
}
}
/** Whether Divi's builder flag marks $postId as a builder layout. */
public static function usesBuilder(int $postId): bool
{
return get_post_meta($postId, '_et_pb_use_builder', true) === 'on';
}
/** The entry opened in the Divi Visual Builder: its permalink with et_fb=1. */
public static function builderUrl(WP_Post|int $post): string
{
return (string) add_query_arg('et_fb', '1', (string) get_permalink($post));
}
/** Removes the static CSS files Divi generated for $postId (spec §5.6 step 5). */
public static function clearPageCss(int $postId): void
{
if (class_exists('ET_Core_PageResource')) {
\ET_Core_PageResource::remove_static_resources($postId, 'all', true);
}
}
/** Whether Divi offers a post_link_url_<type> option for $postType. Fail closed: without Divi's list, none. */
public static function isLinkable(string $postType): bool
{
if (! function_exists('et_builder_get_public_post_types')) {
return false;
}
$types = et_builder_get_public_post_types();
return is_array($types) && array_key_exists($postType, $types);
}
}
+5 -7
View File
@@ -20,7 +20,7 @@ defined('ABSPATH') || exit;
/**
* Save pipeline of spec §5.6, steps 3–5: restore point and wp_update_post() with wp_slash() (the
* status stays), Divi 5 flags (DiviFlags), Divi's static CSS cache. A failure after saving is
* status stays), Divi 5 flags and the page's static CSS (DiviPages). A failure after saving is
* only a warning (FollowUp); a filter that changed the stored content is reported as well.
*
* kses (spec D7): for users without unfiltered_html WordPress' kses would run
@@ -45,7 +45,7 @@ defined('ABSPATH') || exit;
* for its single wp_update_post().
*
* Divi's own save filters are NOT kses and are never removed (ruling S7, defense in depth):
* Security::initialize() (Divi 5.2.1 Security.php:74-81) registers on wp_insert_post_data
* Security::initialize() (Divi's Security.php) registers on wp_insert_post_data
* AttributeSecurity (priority 5, always), HtmlSecurity (priority 6, by capability) and
* DynamicContentFixes (priority 10: strips enable_html for users without unfiltered_html). The first
* two re-serialize the whole page when it holds custom attributes or htmlBefore/htmlAfter/elementType, so on such
@@ -98,7 +98,7 @@ class DiviSaver
$warnings = [];
if ($scope->setsFlags()) {
FollowUp::run(
static fn () => DiviFlags::set($postId),
static fn () => DiviPages::setFlags($postId),
'divi builder flags',
'Die Divi-Kennzeichen der Seite konnten nicht gesetzt werden; der Inhalt ist gespeichert – die Seite '
. 'einmal im Divi-Builder öffnen und speichern.',
@@ -107,9 +107,7 @@ class DiviSaver
}
FollowUp::run(
static function () use ($postId, $scope): void {
if (class_exists('ET_Core_PageResource')) {
\ET_Core_PageResource::remove_static_resources($postId, 'all', true);
}
DiviPages::clearPageCss($postId);
// A global library entry is part of every page that shows it (spec §5.6 step 5).
if ($scope->clearsSiteCss()) {
DiviThemeBuilder::clearSiteCaches();
@@ -149,7 +147,7 @@ class DiviSaver
}
if ($scope->setsFlags()) {
DiviFlags::set($postId);
DiviPages::setFlags($postId);
}
$stored = get_post($postId);
+5 -15
View File
@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Divi\Pages;
use Closure;
use LIT\MCP\Integrations\Divi\Compose\LinkTargets;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use WP_Post;
defined('ABSPATH') || exit;
@@ -22,18 +23,19 @@ final class WpLinkTargets implements LinkTargets
private const DEAD = ['trash', 'auto-draft', 'inherit'];
/** Never link targets, whatever Divi lists: attachments and the Divi Library (ruling Q14). */
private const NEVER = ['attachment', 'et_pb_layout'];
private const NEVER = ['attachment', DiviLibraryStore::POST_TYPE];
/** @var Closure(string): bool */
private Closure $linkable;
/**
* @param (Closure(string): bool)|null $linkable Whether Divi offers post_link_url_<type> (tests);
* null = Divi's et_builder_get_public_post_types(), the list its link option uses.
* null = DiviPages::isLinkable() (Divi's et_builder_get_public_post_types(), the list its
* link option uses).
*/
public function __construct(?Closure $linkable = null)
{
$this->linkable = $linkable ?? self::diviLinkable(...);
$this->linkable = $linkable ?? DiviPages::isLinkable(...);
}
public function postTypeOf(int $id): ?string
@@ -50,16 +52,4 @@ final class WpLinkTargets implements LinkTargets
return current_user_can('read_post', $id) ? $type : null;
}
/** Fail closed: without Divi's list no type has a link option. */
private static function diviLinkable(string $type): bool
{
if (! function_exists('et_builder_get_public_post_types')) {
return false;
}
$types = et_builder_get_public_post_types();
return is_array($types) && array_key_exists($type, $types);
}
}
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\Pages\DiviPages;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
@@ -14,6 +15,7 @@ final class DiviAdapters
{
/** @var list<class-string<DiviAdapter>> */
public const CLASSES = [
DiviPages::class,
DiviModules::class,
DynamicContentMap::class,
];
@@ -6,6 +6,7 @@ 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\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
@@ -95,7 +96,7 @@ class PostPresenter
public static function usesDivi(WP_Post $post): bool
{
return get_post_meta((int) $post->ID, '_et_pb_use_builder', true) === 'on'
return DiviPages::usesBuilder((int) $post->ID)
|| str_contains((string) $post->post_content, '<!-- wp:divi/');
}
@@ -133,7 +133,7 @@ final class GlobalEditsTest extends TestCase
);
}
public function testPlainLibraryItemIsSavedWithoutConfirmAndWithoutDiviFlags(): void
public function testPlainLibraryItemIsSavedWithoutConfirmAndWithoutPageFlags(): void
{
LibraryFixtures::given([LibraryFixtures::item(1057)]);
@@ -563,7 +563,7 @@ final class GlobalEditsTest extends TestCase
}
/** Ruling P1: lit-divi-replace-text saves library entries through DiviWriter::commit(). */
public function testReplaceTextSavesAPlainLibraryItemWithoutDiviFlags(): void
public function testReplaceTextSavesAPlainLibraryItemWithoutPageFlags(): void
{
LibraryFixtures::given([LibraryFixtures::item(1057)]);
@@ -1,58 +0,0 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Pages\DiviFlags;
use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException;
final class DiviFlagsTest extends TestCase
{
public function testSetsBothFlagsAndAcceptsAnUnchangedValue(): void
{
$written = [];
// update_post_meta() is false when the value is already "on" – no failure (ruling S11).
Functions\when('update_post_meta')->alias(static function (int $id, string $key, string $value) use (&$written): bool {
$written[] = "$id:$key=$value";
return false;
});
Functions\when('get_post_meta')->alias(
static fn (int $id, string $key): string => $key === '_et_pb_show_page_creation' ? 'off' : 'on'
);
DiviFlags::set(7);
// Like Divi's own save (SyncToServerController.php:382-387), incl. the page creation flow flag.
self::assertSame(
['7:_et_pb_use_divi_5=on', '7:_et_pb_use_builder=on', '7:_et_pb_show_page_creation=off'],
$written
);
}
public function testThrowsWhenAFlagDoesNotReadBack(): void
{
Functions\when('update_post_meta')->justReturn(true);
Functions\when('get_post_meta')->alias(
static fn (int $id, string $key): string => $key === '_et_pb_use_builder' ? 'off' : 'on'
);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('_et_pb_use_builder');
DiviFlags::set(7);
}
public function testThrowsWhenThePageCreationFlagDoesNotReadBack(): void
{
Functions\when('update_post_meta')->justReturn(true);
Functions\when('get_post_meta')->justReturn('on');
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('_et_pb_show_page_creation');
DiviFlags::set(7);
}
}
@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Pages\DiviPages;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException;
use WP_Post;
final class DiviPagesTest extends TestCase
{
public function testSetsBothFlagsAndAcceptsAnUnchangedValue(): void
{
$written = [];
// update_post_meta() is false when the value is already "on" – no failure (ruling S11).
Functions\when('update_post_meta')->alias(static function (int $id, string $key, string $value) use (&$written): bool {
$written[] = "$id:$key=$value";
return false;
});
Functions\when('get_post_meta')->alias(
static fn (int $id, string $key): string => $key === '_et_pb_show_page_creation' ? 'off' : 'on'
);
DiviPages::setFlags(7);
// Like Divi's own save (SyncToServerController::update, Mirror vb-sync-flags), incl. the page creation flag.
self::assertSame(
['7:_et_pb_use_divi_5=on', '7:_et_pb_use_builder=on', '7:_et_pb_show_page_creation=off'],
$written
);
}
public function testThrowsWhenAFlagDoesNotReadBack(): void
{
Functions\when('update_post_meta')->justReturn(true);
Functions\when('get_post_meta')->alias(
static fn (int $id, string $key): string => $key === '_et_pb_use_builder' ? 'off' : 'on'
);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('_et_pb_use_builder');
DiviPages::setFlags(7);
}
public function testThrowsWhenThePageCreationFlagDoesNotReadBack(): void
{
Functions\when('update_post_meta')->justReturn(true);
Functions\when('get_post_meta')->justReturn('on');
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('_et_pb_show_page_creation');
DiviPages::setFlags(7);
}
public function testIsTheRegisteredPagesAdapterWithTheFlagMirror(): void
{
$manifest = DiviPages::manifest();
self::assertContains(DiviPages::class, DiviAdapters::CLASSES);
self::assertSame(Manifest::PAGES, $manifest->area);
self::assertSame(['vb-sync-flags'], array_map(static fn ($mirror): string => $mirror->id, $manifest->mirrors));
self::assertSame('SyncToServerController::update()', $manifest->mirrors[0]->label());
}
public function testUsesBuilderReadsDivisFlag(): void
{
Functions\when('get_post_meta')->alias(
static fn (int $id, string $key): string => $id === 7 && $key === '_et_pb_use_builder' ? 'on' : ''
);
self::assertTrue(DiviPages::usesBuilder(7));
self::assertFalse(DiviPages::usesBuilder(8));
}
public function testBuilderUrlOpensTheVisualBuilder(): void
{
Functions\when('get_permalink')->justReturn('https://k.de/start/');
Functions\when('add_query_arg')->alias(
static fn (string $key, string $value, string $url): string => $url . '?' . $key . '=' . $value
);
self::assertSame('https://k.de/start/?et_fb=1', DiviPages::builderUrl(new WP_Post(['ID' => 7])));
}
public function testClearPageCssRemovesThePagesStaticFiles(): void
{
\ET_Core_PageResource::$removed = [];
DiviPages::clearPageCss(7);
self::assertSame([[7, 'all', true]], \ET_Core_PageResource::$removed);
}
/**
* Own process (Brain Monkey's FunctionStub eval's a real global function the first time a name is
* stubbed – function_exists() would report it as permanently defined for the rest of the run
* otherwise, breaking WpLinkTargetsTest's "without Divi" case).
*
* @runInSeparateProcess
*/
public function testIsLinkableFollowsDivisPublicPostTypes(): void
{
Functions\when('et_builder_get_public_post_types')->justReturn(['page' => 'Seiten', 'post' => 'Beiträge']);
self::assertTrue(DiviPages::isLinkable('page'));
self::assertFalse(DiviPages::isLinkable('product'));
}
}
@@ -62,7 +62,7 @@ final class DiviSaverTest extends TestCase
parent::tearDown();
}
public function testSavesWithoutKsesForFilteredUsersAndSetsDiviFlags(): void
public function testSavesWithoutKsesForFilteredUsersAndSetsThePageFlags(): void
{
Functions\when('current_user_can')->justReturn(false);
Functions\when('has_filter')->justReturn(10);
@@ -269,7 +269,7 @@ final class DiviSaverTest extends TestCase
(new DiviSaver())->saveNew(42, 'NEU');
}
public function testLibraryItemsGetNoDiviFlagsAndGlobalOnesClearEveryPage(): void
public function testLibraryItemsGetNoPageFlagsAndGlobalOnesClearEveryPage(): void
{
\ET_Core_PageResource::$removed = [];
Functions\when('current_user_can')->justReturn(true);
@@ -30,13 +30,8 @@ final class DiviBoundaryTest extends TestCase
'Integrations/Divi/Library/DiviRoles.php',
'Integrations/Divi/Library/LibraryItemSpec.php',
'Integrations/Divi/Library/NewLibraryEntry.php',
'Integrations/Divi/Pages/DiviFlags.php',
'Integrations/Divi/Pages/DiviPage.php',
'Integrations/Divi/Pages/DiviSaver.php',
'Integrations/Divi/Pages/WpLinkTargets.php',
'Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php',
'Integrations/Media/MediaUsageFinder.php',
'Integrations/WordPress/Content/PostPresenter.php',
];
public function testOnlyAdaptersTouchDivi(): void