feat: Theme Builder conditions only from Divi's list, objects readable

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 15:55:44 +02:00
co-authored by Claude Opus 5.5
parent b1dd35abd8
commit ac69fdf113
2 changed files with 424 additions and 0 deletions
@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
use LIT\MCP\Integrations\Divi\Compose\PostIdParser;
use WP_Post;
use WP_Term;
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.
*/
final class TemplateConditions
{
public const MAX = 50;
private const SEPARATOR = ':';
private const DEAD = ['trash', 'auto-draft', 'inherit'];
/** @var array<string, array{label: string, type: ?string, value: ?string}>|null */
private ?array $flat = null;
public function __construct(private DiviThemeBuilder $store)
{
}
/**
* Checks a client's condition list for field $field: null = no conditions; errors are keyed "<field>"
* (the list itself) or "<field>.<index>". Duplicates are dropped.
*
* @return array{ids: list<string>, errors: array<string, string>}
*/
public function check(mixed $conditions, string $field): array
{
if ($conditions === null) {
return ['ids' => [], 'errors' => []];
}
if (! is_array($conditions) || ! array_is_list($conditions)) {
return ['ids' => [], 'errors' => [$field => 'Liste von Bedingungs-IDs erwartet']];
}
if (count($conditions) > self::MAX) {
return ['ids' => [], 'errors' => [$field => sprintf('höchstens %d Bedingungen', self::MAX)]];
}
$ids = [];
$errors = [];
foreach ($conditions as $index => $condition) {
$problem = is_string($condition) ? $this->problem($condition) : 'Bedingungs-ID als Text erwartet';
if ($problem === null) {
$ids[] = (string) $condition;
} else {
$errors[$field . '.' . $index] = $problem;
}
}
return ['ids' => array_values(array_unique($ids)), 'errors' => $errors];
}
/** Divi's label, for an ID with object "<label>: <object>" (the object as stored, never its title). */
public function label(string $id): string
{
$flat = $this->flat();
if (isset($flat[$id]) && ! str_ends_with($id, self::SEPARATOR)) {
return $flat[$id]['label'];
}
$split = $this->split($id);
return $split === null ? $id : sprintf('%s: %s', $flat[$split[0]]['label'], $split[1]);
}
/**
* Divi's conditions for lit/tb-list: "needs" names what an ID ending in ":" takes, e.g. "post_type:page".
*
* @return list<array{id: string, label: string, needs: ?string}>
*/
public function available(): array
{
$list = [];
foreach ($this->flat() as $id => $setting) {
$id = (string) $id;
$list[] = [
'id' => $id,
'label' => $setting['label'],
'needs' => str_ends_with($id, self::SEPARATOR)
? trim(($setting['type'] ?? '') . self::SEPARATOR . ($setting['value'] ?? ''), self::SEPARATOR)
: null,
];
}
return $list;
}
private function problem(string $id): ?string
{
if (preg_match('/^[A-Za-z0-9_:\-]{1,200}$/D', $id) !== 1) {
return 'ungültige Bedingungs-ID (erlaubt: Buchstaben, Ziffern, „_“, „-“ und „:“)';
}
$flat = $this->flat();
if (isset($flat[$id]) && ! str_ends_with($id, self::SEPARATOR)) {
return null;
}
if (isset($flat[$id])) {
return sprintf('Bedingung %s braucht ein Objekt am Ende (siehe „needs“ in lit/tb-list)', $id);
}
$split = $this->split($id);
if ($split === null) {
return 'unbekannte Bedingung (verfügbare: lit/tb-list mit conditions: true)';
}
return $this->objectProblem($flat[$split[0]], $split[1]);
}
/**
* [Divi's parent ID ending in ":", object] as Divi reads the stored ID back (last segment = object),
* or null when that parent is not in Divi's list or the object is empty.
*
* @return array{string, string}|null
*/
private function split(string $id): ?array
{
$cut = strrpos($id, self::SEPARATOR);
if ($cut === false || $cut === strlen($id) - 1) {
return null;
}
$parent = substr($id, 0, $cut + 1);
return isset($this->flat()[$parent]) ? [$parent, substr($id, $cut + 1)] : null;
}
/** @param array{label: string, type: ?string, value: ?string} $setting */
private function objectProblem(array $setting, string $object): ?string
{
$value = (string) $setting['value'];
$id = PostIdParser::parse($object);
if ($setting['type'] === 'post_type') {
return $this->postProblem($value, $id, $object);
}
if ($setting['type'] === 'taxonomy') {
$valid = $id !== null && get_term($id, $value) instanceof WP_Term && current_user_can('assign_term', $id);
return $valid
? null
: sprintf('Begriff %s gibt es in „%s“ nicht oder er ist nicht zuweisbar', $object, $value);
}
if ($setting['type'] === 'user') {
$valid = $id !== null && current_user_can('list_users') && get_userdata($id) !== false;
return $valid ? null : 'Autoren-Bedingungen nur mit dem Recht list_users und einer vorhandenen Benutzer-ID';
}
if ($setting['type'] === 'user_role') {
return wp_roles()->is_role($object) ? null : sprintf('Rolle „%s“ gibt es nicht', $object);
}
return 'diese Bedingung nur im Divi Theme Builder setzen';
}
private function postProblem(string $postType, ?int $id, string $object): ?string
{
$post = $id === null ? null : get_post($id);
$valid = $post instanceof WP_Post
&& (string) $post->post_type === $postType
&& ! in_array((string) $post->post_status, self::DEAD, true)
&& current_user_can('read_post', $id);
return $valid
? null
: sprintf('Eintrag %s vom Typ „%s“ gibt es nicht oder er ist nicht lesbar', $object, $postType);
}
/** @return array<string, array{label: string, type: ?string, value: ?string}> */
private function flat(): array
{
return $this->flat ??= $this->store->flatConditions();
}
}
@@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
use LIT\MCP\Integrations\Divi\ThemeBuilder\TemplateConditions;
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
use WP_Term;
final class TemplateConditionsTest extends TestCase
{
/** @var list<string> Capabilities the user lacks (read_post of 2202 is always missing). */
private array $denied = ['list_users'];
protected function setUp(): void
{
parent::setUp();
Functions\when('get_post')->alias(static fn (int $id): ?WP_Post => match ($id) {
1053 => new WP_Post(['ID' => 1053, 'post_type' => 'page', 'post_status' => 'draft']),
2201 => new WP_Post(['ID' => 2201, 'post_type' => 'lit_person', 'post_status' => 'publish']),
2202 => new WP_Post(['ID' => 2202, 'post_type' => 'page', 'post_status' => 'private']),
2203 => new WP_Post(['ID' => 2203, 'post_type' => 'page', 'post_status' => 'trash']),
7 => new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => 'publish']),
default => null,
});
Functions\when('current_user_can')->alias(
fn (string $cap, mixed ...$args): bool => ! in_array($cap, $this->denied, true)
&& ! ($cap === 'read_post' && ($args[0] ?? 0) === 2202)
);
Functions\when('get_term')->alias(
static fn (int $id, string $taxonomy): ?WP_Term => $id === 5 && $taxonomy === 'category'
? new WP_Term(['term_id' => 5, 'slug' => 'news', 'name' => 'News'])
: null
);
Functions\when('get_userdata')->justReturn(false);
Functions\when('wp_roles')->justReturn(new class {
public function is_role(string $role): bool
{
return $role === 'editor';
}
});
}
private static function conditions(): TemplateConditions
{
return new TemplateConditions(ThemeBuilderFixtures::store());
}
public function testAcceptsDivisConditionsAndReadableObjects(): void
{
$checked = self::conditions()->check([
'singular:post_type:lit_person:all',
'homepage',
'404',
'singular:post_type:page:id:1053',
'singular:post_type:lit_person:id:2201',
'archive:taxonomy:category:term:id:5',
'archive:user:role:editor',
'homepage',
], 'use_on');
self::assertSame([], $checked['errors']);
self::assertSame([
'singular:post_type:lit_person:all',
'homepage',
'404',
'singular:post_type:page:id:1053',
'singular:post_type:lit_person:id:2201',
'archive:taxonomy:category:term:id:5',
'archive:user:role:editor',
], $checked['ids']);
}
public function testRefusesUnknownUnreadableAndMalformedConditions(): void
{
$checked = self::conditions()->check([
'singular:post_type:page:all:x',
'singular:post_type:page:id:2202',
'singular:post_type:page:id:2203',
'singular:post_type:page:id:2201',
'singular:post_type:page:id:0123',
"homepage\n",
'archive:user:id:1',
'archive:taxonomy:category:term:id:6',
'archive:user:role:hacker',
'singular:post_type:page:id:',
42,
], 'use_on');
self::assertSame([], $checked['ids']);
self::assertSame(
array_map(static fn (int $i): string => 'use_on.' . $i, range(0, 10)),
array_keys($checked['errors'])
);
self::assertStringContainsString('unbekannte Bedingung', $checked['errors']['use_on.0']);
// Private and trashed look the same – no oracle over other users' entries.
self::assertSame(
$checked['errors']['use_on.1'],
str_replace('2203', '2202', $checked['errors']['use_on.2'])
);
self::assertStringContainsString('list_users', $checked['errors']['use_on.6']);
}
public function testListShapeAndSize(): void
{
self::assertSame(['ids' => [], 'errors' => []], self::conditions()->check(null, 'use_on'));
self::assertArrayHasKey('use_on', self::conditions()->check('homepage', 'use_on')['errors']);
self::assertArrayHasKey('use_on', self::conditions()->check(['a' => 'homepage'], 'use_on')['errors']);
self::assertArrayHasKey(
'exclude_from',
self::conditions()->check(array_fill(0, TemplateConditions::MAX + 1, 'homepage'), 'exclude_from')['errors']
);
self::assertSame(
[],
self::conditions()->check(array_fill(0, TemplateConditions::MAX, 'homepage'), 'exclude_from')['errors']
);
}
public function testLabelsAndTheCatalog(): void
{
self::assertSame('Alle Personen', self::conditions()->label('singular:post_type:lit_person:all'));
self::assertSame('Bestimmte Seiten: 1053', self::conditions()->label('singular:post_type:page:id:1053'));
self::assertSame('Unterseiten bestimmter Seiten: 7', self::conditions()->label('singular:post_type:page:children:id:7'));
self::assertSame('404-Seite', self::conditions()->label('404'));
self::assertSame('unbekannt:x', self::conditions()->label('unbekannt:x'));
self::assertSame('singular:post_type:page:id:', self::conditions()->label('singular:post_type:page:id:'));
$available = array_column(self::conditions()->available(), null, 'id');
self::assertNull($available['homepage']['needs']);
self::assertSame('post_type:page', $available['singular:post_type:page:id:']['needs']);
self::assertSame('taxonomy:category', $available['archive:taxonomy:category:term:id:']['needs']);
self::assertSame('user', $available['archive:user:id:']['needs']);
self::assertSame('user_role', $available['archive:user:role:']['needs']);
self::assertSame('404-Seite', $available['404']['label']);
self::assertSame(array_keys(ThemeBuilderFixtures::conditions()), array_keys($available));
}
/** Every ID Divi offers round-trips: exactly, or with an object the user may read. */
public function testEveryConditionDiviOffersRoundTrips(): void
{
$this->denied = [];
Functions\when('get_userdata')->alias(static fn (int $id): object|false => $id === 1 ? (object) ['ID' => 1] : false);
$objects = [
'post_type:page' => '1053',
'post_type:lit_person' => '2201',
'taxonomy:category' => '5',
'user' => '1',
'user_role' => 'editor',
];
$ids = [];
foreach (self::conditions()->available() as $condition) {
$ids[] = $condition['id'] . ($condition['needs'] === null ? '' : $objects[$condition['needs']]);
}
$checked = self::conditions()->check($ids, 'use_on');
self::assertSame([], $checked['errors']);
self::assertSame($ids, $checked['ids']);
}
/** Ruling Q10: authors only with list_users, and a missing user reads like a forbidden one. */
public function testAuthorConditionsNeedListUsers(): void
{
Functions\when('get_userdata')->alias(static fn (int $id): object|false => $id === 1 ? (object) ['ID' => 1] : false);
$forbidden = self::conditions()->check(['archive:user:id:1'], 'use_on')['errors'];
$this->denied = [];
$missing = self::conditions()->check(['archive:user:id:2'], 'use_on')['errors'];
$allowed = self::conditions()->check(['archive:user:id:1'], 'use_on');
self::assertSame($forbidden['use_on.0'], $missing['use_on.0']);
self::assertSame(['archive:user:id:1'], $allowed['ids']);
}
/** A term the user may not assign reads like a missing one; no term name or slug in any message. */
public function testTermsMustBeAssignable(): void
{
$this->denied = ['list_users', 'assign_term'];
$errors = self::conditions()->check(
['archive:taxonomy:category:term:id:5', 'archive:taxonomy:category:term:id:6'],
'use_on'
)['errors'];
self::assertSame($errors['use_on.0'], str_replace('6', '5', $errors['use_on.1']));
self::assertStringNotContainsString('News', implode(' ', $errors));
self::assertStringNotContainsString('news', implode(' ', $errors));
}
/** The object is the last segment as Divi reads it back; types a filter adds stay builder-only. */
public function testObjectIsTheLastSegmentAndForeignTypesStayInTheBuilder(): void
{
$store = Mockery::mock(DiviThemeBuilder::class);
$store->allows('flatConditions')->andReturn(ThemeBuilderFixtures::conditions() + [
'woo:shop:id:' => ['label' => 'Shop', 'type' => 'product_set', 'value' => 'x'],
]);
$conditions = new TemplateConditions($store);
$errors = $conditions->check([
'singular:post_type:page:id:7:1053',
'woo:shop:id:3',
'singular:post_type:page:id:1053 ',
'Homepage',
], 'exclude_from')['errors'];
self::assertSame(
['exclude_from.0', 'exclude_from.1', 'exclude_from.2', 'exclude_from.3'],
array_keys($errors)
);
self::assertStringContainsString('unbekannte Bedingung', $errors['exclude_from.0']);
self::assertStringContainsString('Divi Theme Builder', $errors['exclude_from.1']);
self::assertStringContainsString('unbekannte Bedingung', $errors['exclude_from.3']);
}
/** No existence oracle: a readable post of another type reads like a missing one. */
public function testWrongTypeReadsLikeMissing(): void
{
$errors = self::conditions()->check(
['singular:post_type:lit_person:id:1053', 'singular:post_type:lit_person:id:9999'],
'use_on'
)['errors'];
self::assertSame($errors['use_on.0'], str_replace('9999', '1053', $errors['use_on.1']));
}
}