refactor: Theme Builder adapter manifest with three mirrors

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 01:25:39 +02:00
co-authored by Claude Opus 5.5
parent e972898f52
commit 7fc41bc2ac
7 changed files with 108 additions and 46 deletions
+5 -2
View File
@@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi;
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance; use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder; use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
use Throwable; use Throwable;
@@ -64,8 +65,10 @@ final class DiviCompatibility
'Taxonomien layout_category, layout_tag (Bibliothek)' => 'Taxonomien layout_category, layout_tag (Bibliothek)' =>
static fn (): bool => taxonomy_exists('layout_category') && taxonomy_exists('layout_tag'), static fn (): bool => taxonomy_exists('layout_category') && taxonomy_exists('layout_tag'),
// Includes et_theme_builder_clear_wp_cache (Theme Builder cache of every site-wide clear, review M-2). // Includes et_theme_builder_clear_wp_cache (Theme Builder cache of every site-wide clear, review M-2).
'Theme Builder: Funktionen et_theme_builder_* und et_()' => 'Theme Builder: Funktionen et_theme_builder_* und et_()' => static fn (): bool => array_filter(
static fn (): bool => (new DiviThemeBuilder())->missing() === [], DiviThemeBuilder::manifest()->calls,
static fn (Call $call): bool => $call->className() === null && ! function_exists($call->member())
) === [],
'Theme Builder: Post-Typen et_template und Layouts' => static fn (): bool => array_filter( 'Theme Builder: Post-Typen et_template und Layouts' => static fn (): bool => array_filter(
DiviThemeBuilder::postTypes(), DiviThemeBuilder::postTypes(),
static fn (string $type): bool => ! post_type_exists($type) static fn (string $type): bool => ! post_type_exists($type)
@@ -9,6 +9,7 @@ use LIT\MCP\Integrations\Divi\Library\DiviRoles;
use LIT\MCP\Integrations\Divi\Pages\DiviPages; use LIT\MCP\Integrations\Divi\Pages\DiviPages;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -20,6 +21,7 @@ final class DiviAdapters
DiviPages::class, DiviPages::class,
DiviLibraryStore::class, DiviLibraryStore::class,
DiviRoles::class, DiviRoles::class,
DiviThemeBuilder::class,
DiviModules::class, DiviModules::class,
DynamicContentMap::class, DynamicContentMap::class,
]; ];
@@ -4,6 +4,10 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\ThemeBuilder; namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
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 RuntimeException;
use Throwable; use Throwable;
use WP_Error; use WP_Error;
@@ -11,26 +15,26 @@ use WP_Error;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/** /**
* Adapter for Divi's Theme Builder (E7, E15; decisions note F14–F23; fixtures stage3/stage3b). * Adapter "Theme Builder" (spec §3; E7, E15; decisions note F14–F23; fixtures stage3/stage3b).
* Reading uses Divi's own functions (theme-builder.php:378-521). Writing: * Reading uses Divi's own functions (theme-builder.php). Writing:
* - layouts and new templates through et_theme_builder_insert_layout() and et_theme_builder_store_template() * - layouts and new templates through et_theme_builder_insert_layout() and et_theme_builder_store_template()
* (theme-builder.php:1463-1667). Both wp_die() without edit_others_posts: the right is checked first, * (theme-builder.php). Both wp_die() without edit_others_posts: the right is checked first,
* and while they run every wp_die handler throws instead (guarded(), defense in depth); * and while they run every wp_die handler throws instead (guarded(), defense in depth);
* - the live Theme Builder post's template list, conditions and the enabled flag are mirrored from * - the live Theme Builder post's template list, conditions and the enabled flag are mirrored from
* et_theme_builder_api_save() (api.php:223-243; admin-ajax only, not loaded in REST requests, F20) and * et_theme_builder_api_save() (admin-ajax only, not loaded in REST requests, F20) and
* store_template (theme-builder.php:1545-1583) – store_template on an existing template would also * et_theme_builder_store_template() – see the mirrors in manifest(); store_template on an existing
* rewrite its default flag, title and onboarding meta. * template would also rewrite its default flag, title and onboarding meta.
* Never touched: the Theme Builder draft (auto-draft) and et_theme_builder_trash_draft_and_unused_posts(). * Never touched: the Theme Builder draft (auto-draft) and et_theme_builder_trash_draft_and_unused_posts().
* Not final so abilities can be tested with a mock. * Not final so abilities can be tested with a mock.
*/ */
class DiviThemeBuilder class DiviThemeBuilder implements DiviAdapter
{ {
public const TEMPLATE = 'et_template'; public const TEMPLATE = 'et_template';
/** Post type of the Theme Builder posts (live, draft, library) that list the templates. */ /** Post type of the Theme Builder posts (live, draft, library) that list the templates. */
public const THEME_BUILDER = 'et_theme_builder'; public const THEME_BUILDER = 'et_theme_builder';
/** Area => layout post type (theme-builder.php:7-23). */ /** Area => layout post type (theme-builder.php). */
public const LAYOUT_TYPES = [ public const LAYOUT_TYPES = [
'header' => 'et_header_layout', 'header' => 'et_header_layout',
'body' => 'et_body_layout', 'body' => 'et_body_layout',
@@ -44,18 +48,6 @@ class DiviThemeBuilder
public const UNUSED = '_et_theme_builder_marked_as_unused'; public const UNUSED = '_et_theme_builder_marked_as_unused';
public const BACKUP_OPTION = 'et_tb_templates_backup_'; public const BACKUP_OPTION = 'et_tb_templates_backup_';
/** Divi functions this adapter calls; DiviCompatibility reports missing ones. */
public const FUNCTIONS = [
'et_theme_builder_get_theme_builder_post_id',
'et_theme_builder_get_theme_builder_template_ids',
'et_theme_builder_get_template',
'et_theme_builder_insert_layout',
'et_theme_builder_store_template',
'et_theme_builder_get_flat_template_settings_options',
'et_theme_builder_clear_wp_cache',
'et_',
];
private const DIE_FILTERS = [ private const DIE_FILTERS = [
'wp_die_handler', 'wp_die_ajax_handler', 'wp_die_json_handler', 'wp_die_jsonp_handler', 'wp_die_handler', 'wp_die_ajax_handler', 'wp_die_json_handler', 'wp_die_jsonp_handler',
'wp_die_xml_handler', 'wp_die_xmlrpc_handler', 'wp_die_xml_handler', 'wp_die_xmlrpc_handler',
@@ -83,10 +75,53 @@ class DiviThemeBuilder
return is_string($area) ? $area : null; return is_string($area) ? $area : null;
} }
/** @return list<string> */ public static function manifest(): Manifest
public function missing(): array
{ {
return array_values(array_filter(self::FUNCTIONS, static fn (string $name): bool => ! function_exists($name))); $dir = 'includes/builder/frontend-builder/theme-builder/';
return new Manifest(
Manifest::THEME_BUILDER,
calls: [
new Call('et_theme_builder_get_theme_builder_post_id', 2),
new Call('et_theme_builder_get_theme_builder_template_ids', 2),
new Call('et_theme_builder_get_template', 1),
new Call('et_theme_builder_insert_layout', 1),
new Call('et_theme_builder_store_template', 3),
new Call('et_theme_builder_get_flat_template_settings_options', 0),
new Call('et_theme_builder_clear_wp_cache', 1),
// store_template needs Divi's et_() helper (E15).
new Call('et_'),
new Call('ET_Core_PageResource::remove_static_resources', 3),
],
postTypes: self::postTypes(),
keys: [
self::TEMPLATE_LIST, self::USE_ON, self::EXCLUDE_FROM, self::ENABLED, self::UNUSED, self::BACKUP_OPTION,
],
mirrors: [
new Mirror(
'tb-api-save',
$dir . 'api.php',
'et_theme_builder_api_save',
null,
'Vorlagenliste des Live-Theme-Builders (_et_template mit Sicherung et_tb_templates_backup_) und '
. '„ungenutzt“-Markierung'
),
new Mirror(
'tb-store-template',
$dir . 'theme-builder.php',
'et_theme_builder_store_template',
null,
'Bedingungen (_et_use_on, _et_exclude_from) und Aktiv-Schalter (_et_enabled) einer Vorlage'
),
new Mirror(
'tb-load-setting-options',
$dir . 'theme-builder.php',
'et_theme_builder_load_template_setting_options',
null,
'Zerlegen der Bedingungs-IDs (TemplateConditions)'
),
]
);
} }
/** The live Theme Builder post (publish, no library TB); $create makes it like Divi's first save. 0 = none. */ /** The live Theme Builder post (publish, no library TB); $create makes it like Divi's first save. 0 = none. */
@@ -275,7 +310,7 @@ class DiviThemeBuilder
} }
/** /**
* Replaces both condition lists like store_template (theme-builder.php:1565-1583): delete, then one * Replaces both condition lists like store_template (theme-builder.php): delete, then one
* row per unique condition. * row per unique condition.
* *
* @param list<string> $useOn * @param list<string> $useOn
@@ -338,7 +373,7 @@ class DiviThemeBuilder
return $flat; return $flat;
} }
/** Divi's Theme Builder cache and every static CSS file (spec §5.6 step 5; api.php:249-254). */ /** Divi's Theme Builder cache and every static CSS file (spec §5.6 step 5; api.php). */
public function clearCaches(): void public function clearCaches(): void
{ {
self::clearSiteCaches(); self::clearSiteCaches();
@@ -360,7 +395,7 @@ class DiviThemeBuilder
/** /**
* Before any Divi write function runs: the functions exist (E15 – no fatal error halfway) and the user * Before any Divi write function runs: the functions exist (E15 – no fatal error halfway) and the user
* holds edit_others_posts, without which Divi calls wp_die() (theme-builder.php:1464, 1644). * holds edit_others_posts, without which Divi calls wp_die() (theme-builder.php).
* *
* @throws RuntimeException * @throws RuntimeException
*/ */
@@ -369,7 +404,7 @@ class DiviThemeBuilder
if (! current_user_can('edit_others_posts')) { if (! current_user_can('edit_others_posts')) {
throw new RuntimeException('edit_others_posts missing – Divi would call wp_die()'); throw new RuntimeException('edit_others_posts missing – Divi would call wp_die()');
} }
$missing = array_values(array_intersect($functions, $this->missing())); $missing = array_values(array_filter($functions, static fn (string $name): bool => ! function_exists($name)));
if ($missing !== []) { if ($missing !== []) {
throw new RuntimeException('Divi function missing: ' . implode(', ', $missing)); throw new RuntimeException('Divi function missing: ' . implode(', ', $missing));
} }
@@ -9,7 +9,7 @@ use LIT\MCP\Abilities\Rev;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/** /**
* A Theme Builder template as Divi reads it (et_theme_builder_get_template(), theme-builder.php:471-521; * A Theme Builder template as Divi reads it (et_theme_builder_get_template(), theme-builder.php;
* fixtures stage3/stage3b), plus whether the live Theme Builder post lists it (attached) and Divi's * fixtures stage3/stage3b), plus whether the live Theme Builder post lists it (attached) and Divi's
* "unused" mark (set when a Theme Builder save drops it from the list, stage3b README finding 10). * "unused" mark (set when a Theme Builder save drops it from the list, stage3b README finding 10).
* rev() is template_rev, the lock of the Theme Builder tools. No WordPress calls. * rev() is template_rev, the lock of the Theme Builder tools. No WordPress calls.
@@ -238,7 +238,7 @@ final class Template
} }
/** /**
* The page families a condition covers (Divi's IDs, theme-builder.php:689-1060): singular:post_type:<type>, * The page families a condition covers (Divi's IDs, theme-builder.php): singular:post_type:<type>,
* archive:taxonomy:<taxonomy>, archive:post_type:<type>, archive:user, archive:date; "<kind>:all" and posts * archive:taxonomy:<taxonomy>, archive:post_type:<type>, archive:user, archive:date; "<kind>:all" and posts
* in a term (singular:taxonomy:…) the whole kind; the front page may be a page or the posts archive. * in a term (singular:taxonomy:…) the whole kind; the front page may be a page or the posts archive.
* *
@@ -13,11 +13,12 @@ defined('ABSPATH') || exit;
/** /**
* Theme Builder conditions (_et_use_on/_et_exclude_from, F21, E7): only IDs from Divi's own list * Theme Builder conditions (_et_use_on/_et_exclude_from, F21, E7): only IDs from Divi's own list
* (et_theme_builder_get_flat_template_settings_options()) – exactly, or for an entry ending in ":" that entry * (et_theme_builder_get_flat_template_settings_options()) – exactly, or for an entry ending in ":" that entry
* plus one object segment, split the way Divi reads it back (et_theme_builder_load_template_setting_options(): * plus one object segment, split the way Divi reads it back (mirror tb-load-setting-options in
* the last segment is the object). The object must be readable by the user (ruling P10: PostIdParser): a post * DiviThemeBuilder::manifest(): the last segment is the object). The object must be readable by the user
* of that type with read_post (not trashed, no auto-draft/attachment status), a term of that taxonomy with * (ruling P10: PostIdParser): a post of that type with read_post (not trashed, no auto-draft/attachment status),
* assign_term, a user only with list_users (ruling Q10, stricter than Divi), an existing role. Refusals for * a term of that taxonomy with assign_term, a user only with list_users (ruling Q10, stricter than Divi), an
* missing and unreadable objects read the same and never name an object's title (P12). Nothing is written here. * existing role. Refusals for missing and unreadable objects read the same and never name an object's title
* (P12). Nothing is written here.
* Every condition ID is limited to letters, digits, "_", ":" and "-" (at most 200 characters): a role slug with * Every condition ID is limited to letters, digits, "_", ":" and "-" (at most 200 characters): a role slug with
* other characters (possible with role plugins) is refused as an invalid condition ID and must be set in Divi's * other characters (possible with role plugins) is refused as an invalid condition ID and must be set in Divi's
* Theme Builder (review M-6). * Theme Builder (review M-6).
@@ -26,7 +26,6 @@ final class DiviBoundaryTest extends TestCase
private const LEGACY = [ private const LEGACY = [
'Integrations/Divi/Design/DesignSystemReader.php', 'Integrations/Divi/Design/DesignSystemReader.php',
'Integrations/Divi/DiviCompatibility.php', 'Integrations/Divi/DiviCompatibility.php',
'Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php',
'Integrations/Media/MediaUsageFinder.php', 'Integrations/Media/MediaUsageFinder.php',
]; ];
@@ -7,6 +7,9 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
use Brain\Monkey\Actions; use Brain\Monkey\Actions;
use Brain\Monkey\Filters; use Brain\Monkey\Filters;
use Brain\Monkey\Functions; use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder; use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
use LIT\MCP\Integrations\Divi\ThemeBuilder\InsertedBeforeFailure; use LIT\MCP\Integrations\Divi\ThemeBuilder\InsertedBeforeFailure;
use LIT\MCP\Tests\Unit\TestCase; use LIT\MCP\Tests\Unit\TestCase;
@@ -17,6 +20,23 @@ use WP_Post;
final class DiviThemeBuilderTest extends TestCase final class DiviThemeBuilderTest extends TestCase
{ {
public function testIsTheRegisteredThemeBuilderAdapterWithItsMirrors(): void
{
$manifest = DiviThemeBuilder::manifest();
self::assertContains(DiviThemeBuilder::class, DiviAdapters::CLASSES);
self::assertSame(Manifest::THEME_BUILDER, $manifest->area);
self::assertSame(DiviThemeBuilder::postTypes(), $manifest->postTypes);
self::assertSame(
['tb-api-save', 'tb-store-template', 'tb-load-setting-options'],
array_map(static fn ($mirror): string => $mirror->id, $manifest->mirrors)
);
self::assertContains('et_theme_builder_store_template()', array_map(
static fn (Call $call): string => $call->label(),
$manifest->calls
));
}
public function testAreaOfTheLayoutTypes(): void public function testAreaOfTheLayoutTypes(): void
{ {
self::assertSame('header', DiviThemeBuilder::areaOf('et_header_layout')); self::assertSame('header', DiviThemeBuilder::areaOf('et_header_layout'));
@@ -383,26 +403,28 @@ final class DiviThemeBuilderTest extends TestCase
(new DiviThemeBuilder())->insertLayout('sidebar', 'Seite'); (new DiviThemeBuilder())->insertLayout('sidebar', 'Seite');
} }
/** E15: a Divi function that moved is reported, never called (no fatal error mid-write). */ /**
* E15: a Divi function that moved is reported, never called (no fatal error mid-write). Own process:
* no other test may have stubbed these names yet, or function_exists() would report them as present
* for the rest of the run (Brain Monkey defines a stubbed function process-wide).
*
* @runInSeparateProcess
*/
public function testMissingDiviFunctionsAreRefusedBeforeWriting(): void public function testMissingDiviFunctionsAreRefusedBeforeWriting(): void
{ {
Functions\when('current_user_can')->justReturn(true); Functions\when('current_user_can')->justReturn(true);
Functions\expect('et_theme_builder_insert_layout')->never();
Functions\expect('et_theme_builder_store_template')->never();
$store = Mockery::mock(DiviThemeBuilder::class)->makePartial();
$store->allows('missing')->andReturn(['et_theme_builder_insert_layout', 'et_']);
foreach ([ foreach ([
static fn () => $store->insertLayout('body', 'Person – Inhalt'), static fn (DiviThemeBuilder $store) => $store->insertLayout('body', 'Person – Inhalt'),
static fn () => $store->storeTemplate(1064, [ static fn (DiviThemeBuilder $store) => $store->storeTemplate(1064, [
'title' => 'Person', 'layouts' => [], 'use_on' => [], 'exclude_from' => [], 'title' => 'Person', 'layouts' => [], 'use_on' => [], 'exclude_from' => [],
]), ]),
] as $call) { ] as $call) {
try { try {
$call(); $call(new DiviThemeBuilder());
self::fail('Divi was called although it is missing'); self::fail('Divi was called although it is missing');
} catch (RuntimeException $e) { } catch (RuntimeException $e) {
self::assertStringContainsString('missing', $e->getMessage()); self::assertStringContainsString('Divi function missing', $e->getMessage());
} }
} }
} }