feat: Theme Builder adapter, template value and per-object access

DiviThemeBuilder wraps Divi's Theme Builder: reads through Divi's functions,
writes layouts/templates through insert_layout/store_template behind a
wp_die guard (edit_others_posts and function checks first, every handler
filter and output buffer restored in finally), list/conditions/enabled
mirrored from Divi's own save. Template: live = attached and enabled,
whatever the post status (ruling Q8/B8). ThemeBuilderAccess: edit_others_posts
plus Divi's theme_builder role, hidden IDs answer like missing ones,
canEditTemplate (B11) and layoutsRefusal (B10). DiviCompatibility checks the
Theme Builder functions and post types (E15).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 15:18:42 +02:00
co-authored by Claude Opus 5.5
parent 82ef69a99c
commit d04a508751
9 changed files with 1515 additions and 0 deletions
@@ -8,6 +8,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
use Throwable;
defined('ABSPATH') || exit;
@@ -64,6 +65,15 @@ final class DiviCompatibility
static fn (): bool => self::knowsGlobalLayout(),
'Taxonomien layout_category, layout_tag (Bibliothek)' =>
static fn (): bool => taxonomy_exists('layout_category') && taxonomy_exists('layout_tag'),
'Theme Builder: Funktionen et_theme_builder_* und et_()' =>
static fn (): bool => (new DiviThemeBuilder())->missing() === [],
'Theme Builder: Post-Typen et_template und Layouts' => static fn (): bool => array_filter(
array_merge(
[DiviThemeBuilder::THEME_BUILDER, DiviThemeBuilder::TEMPLATE],
array_values(DiviThemeBuilder::LAYOUT_TYPES)
),
static fn (string $type): bool => ! post_type_exists($type)
) === [],
];
}
@@ -0,0 +1,367 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
use RuntimeException;
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:
* - 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,
* 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.
* 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
{
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). */
public const LAYOUT_TYPES = [
'header' => 'et_header_layout',
'body' => 'et_body_layout',
'footer' => 'et_footer_layout',
];
public const TEMPLATE_LIST = '_et_template';
public const USE_ON = '_et_use_on';
public const EXCLUDE_FROM = '_et_exclude_from';
public const ENABLED = '_et_enabled';
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',
];
/** "header", "body" or "footer" for a layout post type, else null. */
public static function areaOf(string $postType): ?string
{
$area = array_search($postType, self::LAYOUT_TYPES, true);
return is_string($area) ? $area : null;
}
/** @return list<string> */
public function missing(): array
{
return array_values(array_filter(self::FUNCTIONS, static fn (string $name): bool => ! function_exists($name)));
}
/** The live Theme Builder post (publish, no library TB); $create makes it like Divi's first save. 0 = none. */
public function liveId(bool $create = false): int
{
return function_exists('et_theme_builder_get_theme_builder_post_id')
? (int) et_theme_builder_get_theme_builder_post_id(true, $create)
: 0;
}
/** The Theme Builder draft (auto-draft) of an unsaved Theme Builder session; 0 = none (F15). */
public function draftId(): int
{
return function_exists('et_theme_builder_get_theme_builder_post_id')
? (int) et_theme_builder_get_theme_builder_post_id(false, false)
: 0;
}
/** @return list<int> Templates listed by Theme Builder post $themeBuilderId, in Divi's order (F15). */
public function templateIds(int $themeBuilderId): array
{
if ($themeBuilderId <= 0 || ! function_exists('et_theme_builder_get_theme_builder_template_ids')) {
return [];
}
return array_values(array_map(
'intval',
(array) et_theme_builder_get_theme_builder_template_ids(true, $themeBuilderId)
));
}
/** Template $id as Divi reads it, null when it is no template. */
public function template(int $id, bool $attached): ?Template
{
if (! function_exists('et_theme_builder_get_template')) {
return null;
}
$divi = et_theme_builder_get_template($id);
if (! is_array($divi) || $divi === []) {
return null;
}
$marked = (string) get_post_meta($id, self::UNUSED, true);
return Template::fromDivi($divi, (string) get_post_status($id), $attached, $marked === '' ? null : $marked);
}
/** @return list<Template> The templates of the live Theme Builder post. */
public function liveTemplates(): array
{
$templates = [];
foreach ($this->templateIds($this->liveId()) as $id) {
$template = $this->template($id, true);
if ($template !== null) {
$templates[] = $template;
}
}
return $templates;
}
/**
* Published templates that no Theme Builder post lists (e.g. removed by a save in a Theme Builder
* that was opened before they were created; Divi marks and later trashes them, F19).
*
* @return list<int>
*/
public function detachedIds(int $limit): array
{
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery -- every _et_template row, live, draft and library TB posts.
$listed = array_map('intval', (array) $wpdb->get_col($wpdb->prepare(
"SELECT meta_value FROM {$wpdb->postmeta} WHERE meta_key = %s",
self::TEMPLATE_LIST
)));
$ids = get_posts([
'post_type' => self::TEMPLATE,
'post_status' => 'publish',
'posts_per_page' => $limit,
'orderby' => 'ID',
'order' => 'ASC',
'fields' => 'ids',
'no_found_rows' => true,
'post__not_in' => $listed === [] ? [0] : $listed,
]);
return array_values(array_map('intval', is_array($ids) ? $ids : []));
}
/** @throws RuntimeException */
public function insertLayout(string $area, string $title): int
{
$type = self::LAYOUT_TYPES[$area] ?? throw new RuntimeException('unknown Theme Builder area ' . $area);
$this->assertCanWrite('et_theme_builder_insert_layout');
$id = $this->guarded(
static fn (): mixed => et_theme_builder_insert_layout(['post_type' => $type, 'post_title' => $title])
);
if (! is_int($id) || $id <= 0) {
$reason = $id instanceof WP_Error ? $id->get_error_message() : 'no id';
throw new RuntimeException('et_theme_builder_insert_layout refused: ' . $reason);
}
return $id;
}
/**
* A new template through Divi's store_template (allow_default false) – always disabled and never the
* default. Divi drops layout IDs that are no published layout of the area; callers read it back.
*
* @param array{title: string, layouts: array<string, array{id: int, enabled: bool}>, use_on: list<string>,
* exclude_from: list<string>} $template
* @throws RuntimeException
*/
public function storeTemplate(int $themeBuilderId, array $template): int
{
$this->assertCanWrite('et_theme_builder_store_template', 'et_');
$payload = [
'title' => $template['title'],
'autogenerated_title' => '0',
'default' => '0',
'enabled' => '0',
'layouts' => $template['layouts'],
'use_on' => $template['use_on'],
'exclude_from' => $template['exclude_from'],
];
$id = $this->guarded(static fn (): mixed => et_theme_builder_store_template($themeBuilderId, $payload, false));
if (! is_int($id) || $id <= 0) {
throw new RuntimeException('et_theme_builder_store_template stored no template');
}
return $id;
}
/**
* Appends $templateId to the list of Theme Builder post $themeBuilderId in et_theme_builder_api_save()'s
* order: the current list as backup option (readers use it meanwhile), the new row, backup removed.
* Divi stores one string row per ID (stage3b theme-builder.json); an ID already listed stays listed once.
* A Theme Builder editor opened before this call drops the row again on its next save and marks the
* template unused (stage3b README finding 10) – detachedIds() finds it, callers unmarkUnused() on reattach.
*
* @throws RuntimeException
*/
public function attach(int $themeBuilderId, int $templateId): void
{
$existing = get_post_meta($themeBuilderId, self::TEMPLATE_LIST, false);
if (is_array($existing) && in_array((string) $templateId, array_map('strval', $existing), true)) {
return;
}
if (is_array($existing) && $existing !== []) {
update_option(self::BACKUP_OPTION . $themeBuilderId, $existing);
}
$added = add_post_meta($themeBuilderId, self::TEMPLATE_LIST, $templateId);
delete_option(self::BACKUP_OPTION . $themeBuilderId);
if ($added === false) {
throw new RuntimeException(
sprintf('template %d not added to Theme Builder %d', $templateId, $themeBuilderId)
);
}
}
public function detach(int $themeBuilderId, int $templateId): void
{
delete_post_meta($themeBuilderId, self::TEMPLATE_LIST, (string) $templateId);
}
/**
* Replaces both condition lists like store_template (theme-builder.php:1565-1583): delete, then one
* row per unique condition.
*
* @param list<string> $useOn
* @param list<string> $excludeFrom
* @throws RuntimeException
*/
public function setConditions(int $templateId, array $useOn, array $excludeFrom): void
{
foreach ([self::USE_ON => $useOn, self::EXCLUDE_FROM => $excludeFrom] as $key => $conditions) {
delete_post_meta($templateId, $key);
foreach (array_values(array_unique($conditions)) as $condition) {
if (add_post_meta($templateId, $key, $condition) === false) {
throw new RuntimeException(sprintf('%s of template %d not stored', $key, $templateId));
}
}
}
}
/** @throws RuntimeException */
public function setEnabled(int $templateId, bool $enabled): void
{
$value = $enabled ? '1' : '0';
update_post_meta($templateId, self::ENABLED, $value);
if ((string) get_post_meta($templateId, self::ENABLED, true) !== $value) {
throw new RuntimeException(sprintf('_et_enabled of template %d not stored', $templateId));
}
}
/** Removes Divi's "unused" mark (F19), as Divi's own save does for every listed post. */
public function unmarkUnused(int $postId): void
{
delete_post_meta($postId, self::UNUSED);
}
/**
* Divi's condition list (et_theme_builder_get_flat_template_settings_options()): ID => label and, for
* IDs that end in ":" and take an object ID or slug, the object type and its value (e.g. post_type/page).
*
* @return array<string, array{label: string, type: ?string, value: ?string}>
*/
public function flatConditions(): array
{
if (! function_exists('et_theme_builder_get_flat_template_settings_options')) {
return [];
}
$flat = [];
foreach ((array) et_theme_builder_get_flat_template_settings_options() as $id => $setting) {
if (! is_array($setting)) {
continue;
}
$options = is_array($setting['options'] ?? null) ? $setting['options'] : [];
$flat[(string) $id] = [
'label' => wp_strip_all_tags((string) ($setting['label'] ?? $id)),
'type' => is_string($options['type'] ?? null) ? $options['type'] : null,
'value' => is_string($options['value'] ?? null) ? $options['value'] : null,
];
}
return $flat;
}
/** Divi's Theme Builder cache and every static CSS file (spec §5.6 step 5; api.php:249-254). */
public function clearCaches(): void
{
if (function_exists('et_theme_builder_clear_wp_cache')) {
et_theme_builder_clear_wp_cache('all');
}
if (class_exists('ET_Core_PageResource')) {
\ET_Core_PageResource::remove_static_resources('all', 'all', true);
}
}
/**
* 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).
*
* @throws RuntimeException
*/
private function assertCanWrite(string ...$functions): void
{
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()));
if ($missing !== []) {
throw new RuntimeException('Divi function missing: ' . implode(', ', $missing));
}
}
/**
* Runs $call with every wp_die handler replaced by one that throws (F17/F18) and with its output
* discarded. Only this request's in-memory hooks change; the handler filters (added at PHP_INT_MAX
* with a closure of this call) and every output buffer opened meanwhile are removed on every path.
*/
private function guarded(callable $call): mixed
{
$handler = static function (): callable {
return static function (mixed $message = ''): never {
if ($message instanceof WP_Error) {
$message = $message->get_error_message();
}
throw new RuntimeException(
'Divi called wp_die(): ' . (is_string($message) && $message !== '' ? $message : 'without message')
);
};
};
foreach (self::DIE_FILTERS as $filter) {
add_filter($filter, $handler, PHP_INT_MAX);
}
$level = ob_get_level();
ob_start();
try {
return $call();
} finally {
while (ob_get_level() > $level) {
ob_end_clean();
}
foreach (self::DIE_FILTERS as $filter) {
remove_filter($filter, $handler, PHP_INT_MAX);
}
}
}
}
@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
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;
* 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.
*/
final class Template
{
public const AREAS = ['header', 'body', 'footer'];
/**
* @param array<string, array{id: int, enabled: bool}> $layouts
* @param list<string> $useOn
* @param list<string> $excludeFrom
*/
public function __construct(
public readonly int $id,
public readonly string $title,
public readonly string $status,
public readonly bool $default,
public readonly bool $enabled,
public readonly array $layouts,
public readonly array $useOn,
public readonly array $excludeFrom,
public readonly bool $attached,
public readonly ?string $markedUnused
) {
}
/** @param array<string, mixed> $divi et_theme_builder_get_template() */
public static function fromDivi(array $divi, string $status, bool $attached, ?string $markedUnused): self
{
$layouts = [];
foreach (self::AREAS as $area) {
$layout = is_array($divi['layouts'][$area] ?? null) ? $divi['layouts'][$area] : [];
$layouts[$area] = ['id' => (int) ($layout['id'] ?? 0), 'enabled' => (bool) ($layout['enabled'] ?? true)];
}
return new self(
(int) ($divi['id'] ?? 0),
(string) ($divi['title'] ?? ''),
$status,
(bool) ($divi['default'] ?? false),
(bool) ($divi['enabled'] ?? false),
$layouts,
self::strings($divi['use_on'] ?? []),
self::strings($divi['exclude_from'] ?? []),
$attached,
$markedUnused
);
}
/**
* Whether visitors can see it: listed by the live Theme Builder post and enabled. Divi renders such a
* template whatever its post status (et_theme_builder_get_template() and ThemeBuilderRequest only read
* the enabled flag), so the status does not count here – fail closed (ruling Q8/B8).
*/
public function isLive(): bool
{
return $this->attached && $this->enabled;
}
/** Whether visitors see layout $layoutId as its $area through this template. */
public function showsLayout(string $area, int $layoutId): bool
{
return $this->isLive()
&& ($this->layouts[$area]['id'] ?? 0) === $layoutId
&& ($this->layouts[$area]['enabled'] ?? false);
}
/** template_rev: changes with everything the Theme Builder tools read or write. */
public function rev(): string
{
$state = [$this->id, $this->title, $this->enabled, $this->layouts];
// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure value object, no WordPress.
return Rev::of((string) json_encode(
array_merge($state, [$this->useOn, $this->excludeFrom, $this->attached]),
JSON_UNESCAPED_UNICODE
));
}
/** @return list<string> Post types of its singular conditions (singular:post_type:<type>:…). */
public function postTypes(): array
{
$types = [];
foreach ($this->useOn as $condition) {
$parts = explode(':', $condition);
if (($parts[0] ?? '') === 'singular' && ($parts[1] ?? '') === 'post_type' && ($parts[2] ?? '') !== '') {
$types[] = $parts[2];
}
}
return array_values(array_unique($types));
}
/** @return array<string, mixed> */
public function toArray(): array
{
return [
'id' => $this->id,
'title' => $this->title,
'default' => $this->default,
'enabled' => $this->enabled,
'live' => $this->isLive(),
'attached' => $this->attached,
'marked_unused' => $this->markedUnused,
'template_rev' => $this->rev(),
'layouts' => $this->layouts,
'use_on' => $this->useOn,
'exclude_from' => $this->excludeFrom,
];
}
/**
* Live templates other than $exceptId that share a condition of $useOn (Divi then picks one of them).
*
* @param list<Template> $live
* @param list<string> $useOn
* @return list<array{id: int, title: string, conditions: list<string>}>
*/
public static function overlaps(array $live, int $exceptId, array $useOn): array
{
$overlaps = [];
foreach ($live as $other) {
if ($other->id === $exceptId || ! $other->isLive()) {
continue;
}
$shared = array_values(array_intersect($other->useOn, $useOn));
if ($shared !== []) {
$overlaps[] = ['id' => $other->id, 'title' => $other->title, 'conditions' => $shared];
}
}
return $overlaps;
}
/** @return list<string> */
private static function strings(mixed $list): array
{
return array_values(array_map('strval', array_filter(is_array($list) ? $list : [], 'is_scalar')));
}
}
@@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
use Closure;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
use LIT\MCP\Integrations\WordPress\PostGuard;
use WP_Error;
use WP_Post;
defined('ABSPATH') || exit;
/**
* Rights on Theme Builder objects (spec §4.5, E3 – never laxer than Divi): the Theme Builder at all needs
* edit_others_posts (Divi's own TB requirement, F19/F23) and the Divi role option theme_builder (off for
* every role but administrator by default, F12); one layout or template additionally edit_post on it.
* Without those, or with the group "Divi Theme Builder" disabled, every TB ID answers like a missing ID
* (P12 pattern). The group's "nur lesen" stops writes (E9). Not final so abilities can be tested with a mock.
*/
class ThemeBuilderAccess
{
/** Tool group "Divi Theme Builder" (Tools tab). */
public const GROUP = 'divi-theme-builder';
/**
* @param (Closure(): bool)|null $writable Group writable (neither "nur lesen" nor disabled); null = always.
* @param (Closure(): bool)|null $visible Group not disabled; null = always.
*/
public function __construct(
private DiviThemeBuilder $store,
private DiviRoles $roles,
private ?Closure $writable = null,
private ?Closure $visible = null
) {
}
public function store(): DiviThemeBuilder
{
return $this->store;
}
public function roles(): DiviRoles
{
return $this->roles;
}
public function canUse(): ?WP_Error
{
if (! current_user_can('edit_others_posts')) {
return Errors::forbidden('Keine Berechtigung für den Divi Theme Builder (edit_others_posts).');
}
return $this->roles->refusal('theme_builder');
}
/** Whether Theme Builder objects exist for this connection: group not disabled and canUse(). */
public function visible(): bool
{
return ($this->visible === null || ($this->visible)()) && $this->canUse() === null;
}
public function writableGate(): ?WP_Error
{
if ($this->writable === null || ($this->writable)()) {
return null;
}
return Errors::forbidden(
'Schreiben im Divi Theme Builder ist für diese Verbindung abgeschaltet (Gruppe „Divi Theme Builder“ auf '
. '„nur lesen“ oder deaktiviert).',
'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.'
);
}
/**
* Layout $postId for the Divi tools (E1): null when it is no Theme Builder layout (the caller goes on);
* like a missing ID without visible(); forbidden without edit_post; for writing also the group gate and
* a published layout (Divi's TB layouts are always published).
*/
public function layout(mixed $postId, bool $forWrite): WP_Post|WP_Error|null
{
$id = PostGuard::id($postId);
$post = $id === null ? null : get_post($id);
if (! $post instanceof WP_Post || DiviThemeBuilder::areaOf((string) $post->post_type) === null) {
return null;
}
$id = (int) $post->ID;
if (! $this->visible()) {
return PostGuard::notFound($id);
}
if (! current_user_can('edit_post', $id)) {
return Errors::forbidden(sprintf('Keine Berechtigung, Theme-Builder-Layout %d zu bearbeiten.', $id));
}
if (! $forWrite) {
return $post;
}
$gate = $this->writableGate();
if ($gate !== null) {
return $gate;
}
if ((string) $post->post_status !== 'publish') {
return Errors::invalidInput(
sprintf(
'Theme-Builder-Layout %d ist nicht aktiv (Status „%s“); nichts wurde gespeichert.',
$id,
(string) $post->post_status
),
['post_id' => 'nur aktive Theme-Builder-Layouts']
);
}
return $post;
}
/** Template $templateId for the Theme Builder tools; any other ID, or a hidden Theme Builder, is not found. */
public function template(mixed $templateId, bool $forWrite): WP_Post|WP_Error
{
$id = PostGuard::id($templateId);
if ($id === null) {
return Errors::invalidInput(
'template_id fehlt oder ist keine ID.',
['template_id' => 'ID aus lit/tb-list']
);
}
$post = get_post($id);
if (
! $post instanceof WP_Post
|| $post->post_type !== DiviThemeBuilder::TEMPLATE
|| (string) $post->post_status !== 'publish'
|| ! $this->visible()
) {
return Errors::notFound(
sprintf('Theme-Builder-Vorlage %d gibt es nicht.', $id),
'Vorlagen mit lit/tb-list auflisten.'
);
}
if (! current_user_can('edit_post', $id)) {
return Errors::forbidden(sprintf('Keine Berechtigung, Theme-Builder-Vorlage %d zu bearbeiten.', $id));
}
return $forWrite ? ($this->writableGate() ?? $post) : $post;
}
/** Whether template $id may be named to this user (tb-list shows other IDs not at all, ruling Q8/B11). */
public function canEditTemplate(int $id): bool
{
return $this->visible() && current_user_can('edit_post', $id);
}
/**
* Refusal when a layout $template refers to is no longer a published layout of its area (Divi trashes
* layouts marked unused after seven days, stage3b README finding 10); reattach and activation need this
* first (ruling Q8/B10). Areas without a layout (ID 0) need nothing.
*/
public function layoutsRefusal(Template $template): ?WP_Error
{
$fields = [];
foreach ($template->layouts as $area => $layout) {
if ($layout['id'] <= 0) {
continue;
}
$post = get_post($layout['id']);
if (
! $post instanceof WP_Post
|| $post->post_type !== (DiviThemeBuilder::LAYOUT_TYPES[$area] ?? null)
|| (string) $post->post_status !== 'publish'
) {
$fields['layouts.' . $area] = sprintf(
'Layout %d ist kein aktives Theme-Builder-Layout (%s) mehr',
$layout['id'],
$area
);
}
}
if ($fields === []) {
return null;
}
return Errors::invalidInput(
sprintf(
'Theme-Builder-Vorlage %d verweist auf Layouts, die nicht mehr aktiv sind; nichts wurde gespeichert.',
$template->id
),
$fields,
'Im Theme Builder (Divi → Theme Builder) die Layouts der Vorlage wiederherstellen oder ersetzen, '
. 'dann erneut aufrufen.'
);
}
/**
* Creating a template with layouts: canUse(), the group gate and publish_posts of the template and
* layout types – Divi publishes them at once (F17, F18).
*/
public function canCreate(): ?WP_Error
{
$refusal = $this->canUse() ?? $this->writableGate();
if ($refusal !== null) {
return $refusal;
}
foreach (array_merge([DiviThemeBuilder::TEMPLATE], array_values(DiviThemeBuilder::LAYOUT_TYPES)) as $type) {
$object = get_post_type_object($type);
$cap = is_object($object) && isset($object->cap->publish_posts)
? (string) $object->cap->publish_posts
: 'publish_posts';
if (! current_user_can($cap)) {
return Errors::forbidden(
sprintf('Keine Berechtigung, Inhalte des Theme Builders zu veröffentlichen (%s).', $cap)
);
}
}
return null;
}
/** E7: while the Theme Builder has unsaved changes (draft), its later save would replace the template list. */
public function draftRefusal(): ?WP_Error
{
if ($this->store->draftId() <= 0) {
return null;
}
return Errors::unsupported(
'Der Divi Theme Builder hat ungespeicherte Änderungen (Entwurf); solange ändert LIT MCP keine Vorlagen – '
. 'ein späteres Speichern dort würde die Zuordnung ersetzen. Nichts wurde gespeichert.',
'Im Theme Builder (Divi → Theme Builder) speichern oder die Änderungen verwerfen, dann erneut aufrufen.'
);
}
}