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
@@ -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.'
);
}
}