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
+177
View File
@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Support;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;
use Mockery;
use WP_Post;
/**
* The Theme Builder of the tests: the real builder posts of tests/Fixtures/divi/stage3 (live TB post 1064,
* default template 1065, template 1066 with header 1061, body 1062, footer 1063 – both disabled), a store
* answering from Template values and the access built from them. Write methods of the store are real
* unless a test mocks them.
*/
final class ThemeBuilderFixtures
{
public const THEME_BUILDER = 1064;
public const DEFAULT_TEMPLATE = 1065;
public const TEMPLATE = 1066;
public const HEADER = 1061;
public const BODY = 1062;
public const FOOTER = 1063;
/** @var array<int, array{string, string}> Layout ID => [stage3 fixture file, post type]. */
public const LAYOUTS = [
1061 => ['tb-header--globaler-header.html', 'et_header_layout'],
1062 => ['tb-body--body-mit-dynamic-content.html', 'et_body_layout'],
1063 => ['tb-footer--footer.html', 'et_footer_layout'],
];
/** @param array<string, mixed> $fields */
public static function layout(int $id, array $fields = []): WP_Post
{
return new WP_Post(array_merge([
'ID' => $id,
'post_type' => self::LAYOUTS[$id][1],
'post_status' => 'publish',
'post_title' => '[Fixture] ' . $id,
'post_content' => DiviFixtures::markup('stage3/' . self::LAYOUTS[$id][0]),
], $fields));
}
/** @param array<string, mixed> $fields */
public static function templatePost(int $id = self::TEMPLATE, array $fields = []): WP_Post
{
return new WP_Post(array_merge([
'ID' => $id,
'post_type' => DiviThemeBuilder::TEMPLATE,
'post_status' => 'publish',
'post_title' => $id === self::DEFAULT_TEMPLATE ? 'Standard-Website-Template' : '[Fixture] Vorlage Globale Instanzen',
'post_content' => '',
], $fields));
}
/**
* The template as Divi reads it (et_theme_builder_get_template); $overrides replace keys of that
* array and may set status, attached and marked_unused.
*
* @param array<string, mixed> $overrides
*/
public static function template(int $id = self::TEMPLATE, array $overrides = []): Template
{
$base = $id === self::DEFAULT_TEMPLATE
? [
'id' => $id,
'title' => 'Standard-Website-Template',
'default' => true,
'enabled' => false,
'layouts' => [
'header' => ['id' => self::HEADER, 'enabled' => true],
'body' => ['id' => 0, 'enabled' => true],
'footer' => ['id' => 0, 'enabled' => true],
],
'use_on' => [],
'exclude_from' => [],
]
: [
'id' => $id,
'title' => '[Fixture] Vorlage Globale Instanzen',
'default' => false,
'enabled' => false,
'layouts' => [
'header' => ['id' => self::HEADER, 'enabled' => true],
'body' => ['id' => self::BODY, 'enabled' => true],
'footer' => ['id' => self::FOOTER, 'enabled' => true],
],
'use_on' => ['singular:post_type:page:id:1053'],
'exclude_from' => [],
];
return Template::fromDivi(
array_merge($base, array_intersect_key($overrides, $base)),
(string) ($overrides['status'] ?? 'publish'),
(bool) ($overrides['attached'] ?? true),
isset($overrides['marked_unused']) ? (string) $overrides['marked_unused'] : null
);
}
/**
* A store whose live list is $templates, with a TB draft when $draftId > 0, $detached as templates no
* Theme Builder post lists and $byIdOnly as templates template() finds but no list names (e.g. the one a
* test lets storeTemplate() create).
*
* @param list<Template> $templates
* @param list<Template> $detached
* @param list<Template> $byIdOnly
*/
public static function store(
array $templates = [],
int $draftId = 0,
array $detached = [],
array $byIdOnly = []
): DiviThemeBuilder {
$byId = [];
foreach (array_merge($templates, $detached, $byIdOnly) as $template) {
$byId[$template->id] = $template;
}
$store = Mockery::mock(DiviThemeBuilder::class)->makePartial();
$store->allows('liveId')->andReturn(self::THEME_BUILDER);
$store->allows('draftId')->andReturn($draftId);
$store->allows('liveTemplates')->andReturn($templates);
$store->allows('templateIds')->andReturn(array_map(static fn (Template $t): int => $t->id, $templates));
$store->allows('template')->andReturnUsing(static fn (int $id): ?Template => $byId[$id] ?? null);
$store->allows('detachedIds')->andReturn(array_map(static fn (Template $t): int => $t->id, $detached));
$store->allows('flatConditions')->andReturn(self::conditions());
return $store;
}
/** @param list<string> $deniedRoles Divi role options the user lacks. */
public static function access(
array $deniedRoles = [],
bool $writable = true,
?DiviThemeBuilder $store = null,
bool $visible = true
): ThemeBuilderAccess {
return new ThemeBuilderAccess(
$store ?? self::store(),
LibraryFixtures::roles($deniedRoles),
static fn (): bool => $writable,
static fn (): bool => $visible
);
}
/**
* An excerpt of et_theme_builder_get_flat_template_settings_options() as the adapter returns it
* (live DDEV dump 2026-09-27 plus the Pods type lit_person).
*
* @return array<string, array{label: string, type: ?string, value: ?string}>
*/
public static function conditions(): array
{
return [
'singular:post_type:page:all' => ['label' => 'Alle Seiten', 'type' => null, 'value' => null],
'homepage' => ['label' => 'Startseite', 'type' => null, 'value' => null],
'singular:post_type:page:id:' => ['label' => 'Bestimmte Seiten', 'type' => 'post_type', 'value' => 'page'],
'singular:post_type:page:children:id:' => [
'label' => 'Unterseiten bestimmter Seiten', 'type' => 'post_type', 'value' => 'page',
],
'singular:post_type:lit_person:all' => ['label' => 'Alle Personen', 'type' => null, 'value' => null],
'singular:post_type:lit_person:id:' => [
'label' => 'Bestimmte Personen', 'type' => 'post_type', 'value' => 'lit_person',
],
'archive:taxonomy:category:term:id:' => [
'label' => 'Bestimmte Kategorien', 'type' => 'taxonomy', 'value' => 'category',
],
'archive:user:id:' => ['label' => 'Bestimmte Autoren', 'type' => 'user', 'value' => null],
'archive:user:role:' => ['label' => 'Autoren mit Rolle', 'type' => 'user_role', 'value' => null],
'search' => ['label' => 'Suchergebnisse', 'type' => null, 'value' => null],
'404' => ['label' => '404-Seite', 'type' => null, 'value' => null],
];
}
}