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