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
@@ -4,6 +4,10 @@ declare(strict_types=1);
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 Throwable;
use WP_Error;
@@ -11,26 +15,26 @@ use WP_Error;
defined('ABSPATH') || exit;
/**
* Adapter for Divi's Theme Builder (E7, E15; decisions note F14–F23; fixtures stage3/stage3b).
* Reading uses Divi's own functions (theme-builder.php:378-521). Writing:
* Adapter "Theme Builder" (spec §3; E7, E15; decisions note F14–F23; fixtures stage3/stage3b).
* 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()
* (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);
* - 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
* store_template (theme-builder.php:1545-1583) – store_template on an existing template would also
* rewrite its default flag, title and onboarding meta.
* et_theme_builder_api_save() (admin-ajax only, not loaded in REST requests, F20) and
* et_theme_builder_store_template() – see the mirrors in manifest(); store_template on an existing
* 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().
* Not final so abilities can be tested with a mock.
*/
class DiviThemeBuilder
class DiviThemeBuilder implements DiviAdapter
{
public const TEMPLATE = 'et_template';
/** Post type of the Theme Builder posts (live, draft, library) that list the templates. */
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 = [
'header' => 'et_header_layout',
'body' => 'et_body_layout',
@@ -44,18 +48,6 @@ class DiviThemeBuilder
public const UNUSED = '_et_theme_builder_marked_as_unused';
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 = [
'wp_die_handler', 'wp_die_ajax_handler', 'wp_die_json_handler', 'wp_die_jsonp_handler',
'wp_die_xml_handler', 'wp_die_xmlrpc_handler',
@@ -83,10 +75,53 @@ class DiviThemeBuilder
return is_string($area) ? $area : null;
}
/** @return list<string> */
public function missing(): array
public static function manifest(): Manifest
{
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. */
@@ -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.
*
* @param list<string> $useOn
@@ -338,7 +373,7 @@ class DiviThemeBuilder
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
{
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
* 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
*/
@@ -369,7 +404,7 @@ class DiviThemeBuilder
if (! current_user_can('edit_others_posts')) {
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 !== []) {
throw new RuntimeException('Divi function missing: ' . implode(', ', $missing));
}
@@ -9,7 +9,7 @@ use LIT\MCP\Abilities\Rev;
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
* "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.
@@ -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
* 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
* (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():
* the last segment is the object). The object must be readable by the user (ruling P10: PostIdParser): a post
* of that type with read_post (not trashed, no auto-draft/attachment status), a term of that taxonomy with
* assign_term, a user only with list_users (ruling Q10, stricter than Divi), an existing role. Refusals for
* missing and unreadable objects read the same and never name an object's title (P12). Nothing is written here.
* plus one object segment, split the way Divi reads it back (mirror tb-load-setting-options in
* DiviThemeBuilder::manifest(): the last segment is the object). The object must be readable by the user
* (ruling P10: PostIdParser): a post of that type with read_post (not trashed, no auto-draft/attachment status),
* a term of that taxonomy with assign_term, a user only with list_users (ruling Q10, stricter than Divi), an
* 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
* 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).