feat: PresetComposer – Preset aus Kurz-/Vollform, Modul und Gruppen-Presets

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-16 17:42:18 +02:00
co-authored by Claude Sonnet 5.5
parent bb4d9b3baf
commit cc20100cbc
4 changed files with 558 additions and 0 deletions
@@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi\Design;
use ET\Builder\Framework\UserRole\UserRole;
use ET\Builder\Packages\Conversion\Conversion;
use ET\Builder\Packages\GlobalData\GlobalPreset;
use ET\Builder\Packages\ModuleLibrary\ModuleRegistration;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
@@ -51,6 +52,8 @@ class DesignSystemWriter implements DiviAdapter
new Call(GlobalPreset::class . '::save_data', 1),
new Call(Conversion::class . '::get_preset_attrs_mapping', 1),
new Call(Conversion::class . '::get_preset_attrs', 3),
new Call(GlobalPreset::class . '::get_group_preset_default_attr', 1),
new Call(ModuleRegistration::class . '::get_module_settings', 1),
new Call(UserRole::class . '::can_current_user_use_visual_builder', 0),
],
mirrors: [
@@ -113,6 +116,33 @@ class DesignSystemWriter implements DiviAdapter
return is_array($saved) ? $saved : [];
}
/**
* The option groups of $moduleName that take group presets: attribute path (groupId) => group name, e.g.
* "module.decoration.spacing" => "divi/spacing" (formats §3). [] without Divi or for an unknown module.
*
* @return array<string, string>
*/
public function groupsOf(string $moduleName): array
{
if (! class_exists(ModuleRegistration::class)) {
return [];
}
$settings = ModuleRegistration::get_module_settings($moduleName);
if ($settings === null) {
return [];
}
$groups = [];
foreach (GlobalPreset::get_group_preset_default_attr($settings) as $groupId => $group) {
if (is_array($group) && is_string($group['groupName'] ?? null)) {
$groups[(string) $groupId] = $group['groupName'];
}
}
return $groups;
}
/**
* The parts of $attrs a preset of $moduleName carries (Divi's preset attribute mapping): all, style
* (styleAttrs) and render (renderAttrs: html + script).
@@ -0,0 +1,324 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use Closure;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\Compose\Composer;
use LIT\MCP\Integrations\Divi\Compose\StoredNodeGuard;
defined('ABSPATH') || exit;
/**
* Builds one preset item (spec §6.3, formats §3) from the tool input: the short form ("set") and the full form
* ("attrs") pass Composer::update() on a virtual block of the preset's module – the same choke point, placeholders
* and S7/S26 locks as for modules (D7) –, from_node takes the preset attributes of a stored node after
* StoredNodeGuard (E6), group_presets nest option group presets. Every resulting path must be a preset attribute
* of the module (Divi's mapping, $presetAttrs) and, for a group preset, lie below its group. styleAttrs/renderAttrs
* are split with the same mapping, like the Visual Builder does. Pure PHP; Divi enters through the closures.
*/
final class PresetComposer
{
private const CONTENT = 'kein Preset-Attribut (Inhalte wie Texte, Links und Bilder gehören ins Modul)';
/**
* @param Closure(string, array<string, mixed>): array{all: array<string, mixed>, style: array<string, mixed>,
* render: array<string, mixed>} $presetAttrs DesignSystemWriter::presetAttrs()
* @param Closure(string): array<string, string> $groupsOf DesignSystemWriter::groupsOf()
* @param Closure(): int $now Milliseconds, like the builder's Date.now().
*/
public function __construct(
private Composer $composer,
private StoredNodeGuard $guard,
private Closure $presetAttrs,
private Closure $groupsOf,
private string $version,
private Closure $now
) {
}
/**
* @param array<string, mixed>|null $existing The preset to change, null for a new one.
* @param array<string, mixed> $input One item of lit/divi-presets-set.
* @return array{item: array<string, mixed>, errors: array<string, string>, invalid_paths: list<string>}
*/
public function compose(
PresetBook $book,
PresetScope $scope,
?array $existing,
array $input,
string $field,
?Block $source
): array {
$errors = [];
$module = $this->moduleOf($scope, $existing, $input, $source);
if ($module === null) {
$errors[$field . '.source_module'] = 'Modul des Gruppen-Presets fehlt (source_module oder from_node)';
return ['item' => [], 'errors' => $errors, 'invalid_paths' => []];
}
$groupId = null;
if ($scope->kind === PresetScope::GROUP) {
$groupId = $this->groupIdOf($scope, $module, $existing, $input, $field, $errors);
if ($groupId === null) {
return ['item' => [], 'errors' => $errors, 'invalid_paths' => []];
}
}
$attrs = is_array($existing['attrs'] ?? null) ? $existing['attrs'] : [];
if ($source !== null) {
$problems = $this->guard->problems($source);
if ($problems !== []) {
$errors[$field . '.from_node'] = implode('; ', $problems);
}
$taken = ($this->presetAttrs)($module, $source->attrs())['all'];
$attrs = array_replace_recursive($attrs, $groupId === null ? $taken : self::below($taken, $groupId));
}
$change = array_intersect_key($input, ['set' => true, 'attrs' => true]);
if ($change !== []) {
$updated = $this->composer->update(Block::create($module, $attrs), $change, $field);
$attrs = $updated['attrs'];
$errors += $updated['errors'];
if ($updated['invalid_paths'] !== []) {
return ['item' => [], 'errors' => $errors, 'invalid_paths' => $updated['invalid_paths']];
}
}
$split = ($this->presetAttrs)($module, $attrs);
$allowed = AttrMap::flatten($split['all']);
foreach (array_keys(AttrMap::flatten($attrs)) as $path) {
if (! array_key_exists($path, $allowed)) {
$errors[$field . '.attrs.' . $path] = self::CONTENT;
} elseif ($groupId !== null && ! self::inGroup($path, $groupId)) {
$errors[$field . '.attrs.' . $path] = 'gehört nicht zur Gruppe ' . $groupId;
}
}
$nested = $this->groupPresets($book, $scope, $module, $existing, $input, $field, $errors);
$name = $this->nameOf($existing, $input, $field, $errors);
return [
'item' => $this->item($scope, $existing, $name, $module, $groupId, $attrs, $split, $nested),
'errors' => $errors,
'invalid_paths' => [],
];
}
/**
* @param array<string, mixed>|null $existing
* @param array<string, mixed> $input
*/
private function moduleOf(PresetScope $scope, ?array $existing, array $input, ?Block $source): ?string
{
if ($scope->kind === PresetScope::MODULE) {
return $scope->name;
}
if (is_string($existing['moduleName'] ?? null)) {
return $existing['moduleName'];
}
if ($source !== null) {
return $source->name();
}
$short = $input['source_module'] ?? null;
if (! is_string($short) || preg_match('/^[a-z0-9][a-z0-9-]*(\/[a-z0-9][a-z0-9-]*)?$/D', $short) !== 1) {
return null;
}
return str_contains($short, '/') ? $short : 'divi/' . $short;
}
/**
* @param array<string, mixed>|null $existing
* @param array<string, mixed> $input
* @param array<string, string> $errors
*/
private function groupIdOf(
PresetScope $scope,
string $module,
?array $existing,
array $input,
string $field,
array &$errors
): ?string {
if (is_string($existing['groupId'] ?? null)) {
return $existing['groupId'];
}
$paths = array_keys(array_filter(
($this->groupsOf)($module),
static fn (string $group): bool => $group === $scope->name
));
$wanted = $input['group_id'] ?? null;
if (is_string($wanted) && in_array($wanted, $paths, true)) {
return $wanted;
}
if (count($paths) === 1 && $wanted === null) {
return (string) $paths[0];
}
$errors[$field . '.group_id'] = $paths === []
? sprintf('%s hat keine Gruppe %s', $module, $scope->name)
: sprintf('mehrere Gruppen %s in %s – group_id angeben: %s', $scope->name, $module, implode(', ', $paths));
return null;
}
/**
* @param array<string, mixed>|null $existing
* @param array<string, mixed> $input
* @param array<string, string> $errors
* @return array<string, array{presetId: list<string>, groupName: string}>
*/
private function groupPresets(
PresetBook $book,
PresetScope $scope,
string $module,
?array $existing,
array $input,
string $field,
array &$errors
): array {
$nested = is_array($existing['groupPresets'] ?? null) ? $existing['groupPresets'] : [];
$wanted = $input['group_presets'] ?? null;
if ($wanted === null) {
return $nested;
}
if ($scope->kind !== PresetScope::MODULE || ! is_array($wanted)) {
$errors[$field . '.group_presets'] = 'nur bei Modul-Presets, als Objekt Gruppe → Preset';
return $nested;
}
$groups = ($this->groupsOf)($module);
foreach ($wanted as $group => $preset) {
$path = $field . '.group_presets.' . $group;
$groupId = self::findGroup($groups, (string) $group);
if ($groupId === null) {
$errors[$path] = sprintf('keine Gruppe "%s" in %s', $group, $module);
continue;
}
if ($preset === null) {
unset($nested[$groupId]);
continue;
}
try {
$found = is_string($preset)
? $book->find(new PresetScope(PresetScope::GROUP, $groups[$groupId]), $preset)
: null;
} catch (InvalidArgumentException $e) {
$errors[$path] = $e->getMessage();
continue;
}
if ($found === null) {
$errors[$path] = sprintf('unbekanntes Gruppen-Preset "%s" (%s)', (string) $preset, $groups[$groupId]);
continue;
}
$nested[$groupId] = ['presetId' => [(string) $found['id']], 'groupName' => $groups[$groupId]];
}
return $nested;
}
/** @param array<string, string> $groups groupId => groupName */
private static function findGroup(array $groups, string $wanted): ?string
{
foreach ($groups as $groupId => $name) {
if ($wanted === $groupId || $wanted === $name || 'divi/' . $wanted === $name) {
return (string) $groupId;
}
}
return null;
}
/**
* @param array<string, mixed>|null $existing
* @param array<string, mixed> $input
* @param array<string, string> $errors
*/
private function nameOf(?array $existing, array $input, string $field, array &$errors): string
{
foreach (['rename', 'name'] as $key) {
if (is_string($input[$key] ?? null) && trim($input[$key]) !== '') {
return trim($input[$key]);
}
}
if (is_string($existing['name'] ?? null)) {
return $existing['name'];
}
$errors[$field . '.name'] = 'Name des neuen Presets fehlt';
return '';
}
/**
* @param array<string, mixed>|null $existing
* @param array<string, mixed> $attrs
* @param array{all: array<string, mixed>, style: array<string, mixed>, render: array<string, mixed>} $split
* @param array<string, mixed> $nested
* @return array<string, mixed>
*/
private function item(
PresetScope $scope,
?array $existing,
string $name,
string $module,
?string $groupId,
array $attrs,
array $split,
array $nested
): array {
$now = ($this->now)();
$item = ['type' => $scope->kind, 'id' => (string) ($existing['id'] ?? PresetBook::newId()), 'name' => $name];
if ($groupId !== null) {
$item += ['groupName' => $scope->name, 'groupId' => $groupId];
}
$item += [
'moduleName' => $module,
'version' => $this->version,
'created' => (int) ($existing['created'] ?? $now),
'updated' => $now,
'attrs' => $attrs,
];
if ($split['style'] !== []) {
$item['styleAttrs'] = $split['style'];
}
if ($split['render'] !== []) {
$item['renderAttrs'] = $split['render'];
}
if ($nested !== []) {
$item['groupPresets'] = $nested;
}
foreach (['priority', 'order'] as $key) {
if (isset($existing[$key])) {
$item[$key] = $existing[$key];
}
}
return $item;
}
private static function inGroup(string $path, string $groupId): bool
{
return $path === $groupId || str_starts_with($path, $groupId . '.');
}
/**
* The part of $attrs below the dotted path $groupId.
*
* @param array<string, mixed> $attrs
* @return array<string, mixed>
*/
private static function below(array $attrs, string $groupId): array
{
$value = AttrMap::get($attrs, $groupId);
return $value === null ? [] : AttrMap::set([], $groupId, $value);
}
}
@@ -27,6 +27,7 @@ final class DesignSystemWriterTest extends TestCase
'GlobalPreset::get_data()', 'GlobalPreset::prepare_data()', 'GlobalPreset::validate_preset_count()',
'GlobalPreset::get_deleted_preset_ids()', 'GlobalPreset::remove_preset_from_legacy_option()',
'GlobalPreset::save_data()', 'Conversion::get_preset_attrs_mapping()', 'Conversion::get_preset_attrs()',
'GlobalPreset::get_group_preset_default_attr()', 'ModuleRegistration::get_module_settings()',
'UserRole::can_current_user_use_visual_builder()',
],
array_map(static fn (Call $call): string => $call->label(), $manifest->calls)
@@ -48,4 +49,9 @@ final class DesignSystemWriterTest extends TestCase
{
self::assertFalse((new DesignSystemWriter())->canUseVisualBuilder());
}
public function testWithoutDiviAModuleHasNoGroups(): void
{
self::assertSame([], (new DesignSystemWriter())->groupsOf('divi/button'));
}
}
@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\Compose\StoredNodeGuard;
use LIT\MCP\Integrations\Divi\Design\PresetBook;
use LIT\MCP\Integrations\Divi\Design\PresetComposer;
use LIT\MCP\Integrations\Divi\Design\PresetScope;
use LIT\MCP\Tests\Support\ComposeFixture;
use PHPUnit\Framework\TestCase;
final class PresetComposerTest extends TestCase
{
private const NOW = 1760000000000;
/** Divi's mapping in small: decoration paths are preset attributes, innerContent is content. */
private static function presetAttrs(): \Closure
{
return static function (string $module, array $attrs): array {
$all = [];
foreach ($attrs as $key => $value) {
if (is_array($value)) {
$kept = array_diff_key($value, ['innerContent' => true]);
if ($kept !== []) {
$all[$key] = $kept;
}
}
}
return ['all' => $all, 'style' => $all, 'render' => []];
};
}
private static function composer(bool $unfiltered = true): PresetComposer
{
return new PresetComposer(
ComposeFixture::composer($unfiltered),
new StoredNodeGuard(
ComposeFixture::cleaner($unfiltered),
ComposeFixture::references(),
ComposeFixture::sanitizer($unfiltered)
),
self::presetAttrs(),
static fn (string $module): array => [
'module.decoration.spacing' => 'divi/spacing',
'button.decoration.font' => 'divi/font',
],
'5.15.1',
static fn (): int => self::NOW
);
}
private static function book(): PresetBook
{
return PresetBook::fromDivi(PresetBookTest::stored());
}
public function testNewModulePresetFromAttrs(): void
{
$result = self::composer()->compose(self::book(), new PresetScope('module', 'divi/button'), null, [
'name' => 'CTA sekundär',
'attrs' => ['button.decoration.background' => ['desktop' => ['color' => '#123456']]],
], 'items[0]', null);
self::assertSame([], $result['errors']);
$item = $result['item'];
self::assertMatchesRegularExpression('/^[0-9a-z]{10}$/', $item['id']);
self::assertSame(['module', 'CTA sekundär', 'divi/button', '5.15.1'], [$item['type'], $item['name'],
$item['moduleName'], $item['version']]);
self::assertSame([self::NOW, self::NOW], [$item['created'], $item['updated']]);
self::assertSame('#123456', $item['attrs']['button']['decoration']['background']['desktop']['value']['color']);
self::assertSame($item['attrs'], $item['styleAttrs']);
self::assertArrayNotHasKey('renderAttrs', $item);
}
public function testContentPathsAreRefused(): void
{
$result = self::composer()->compose(self::book(), new PresetScope('module', 'divi/button'), null, [
'name' => 'Mit Text',
'set' => ['text' => 'Jetzt buchen'],
], 'items[0]', null);
self::assertArrayHasKey('items[0].attrs.button.innerContent', $result['errors']);
self::assertStringContainsString('kein Preset-Attribut', $result['errors']['items[0].attrs.button.innerContent']);
}
public function testChangingMergesKeepsIdCreatedAndOrder(): void
{
$existing = self::book()->items(new PresetScope('module', 'divi/button'))['aaaaaaaaaa'];
$existing['created'] = 5;
$existing['order'] = 3;
$existing['attrs'] = ['button' => ['decoration' => ['border' => ['desktop' => ['value' => ['radius' => '4px']]]]]];
$result = self::composer()->compose(self::book(), new PresetScope('module', 'divi/button'), $existing, [
'rename' => 'CTA Primär',
'attrs' => ['button.decoration.background' => ['desktop' => ['color' => '#000000']]],
], 'items[0]', null);
self::assertSame([], $result['errors']);
self::assertSame(['aaaaaaaaaa', 'CTA Primär', 5, self::NOW, 3], [$result['item']['id'], $result['item']['name'],
$result['item']['created'], $result['item']['updated'], $result['item']['order']]);
self::assertSame('4px', $result['item']['attrs']['button']['decoration']['border']['desktop']['value']['radius']);
}
public function testFromNodeTakesTheDesignButNotTheContent(): void
{
$node = Block::create('divi/button', [
'button' => [
'innerContent' => ['desktop' => ['value' => ['text' => 'Hallo']]],
'decoration' => ['background' => ['desktop' => ['value' => ['color' => '#abcdef']]]],
],
]);
$result = self::composer()->compose(self::book(), new PresetScope('module', 'divi/button'), null, [
'name' => 'Aus Seite',
], 'items[0]', $node);
self::assertSame([], $result['errors']);
self::assertSame('#abcdef', $result['item']['attrs']['button']['decoration']['background']['desktop']['value']['color']);
self::assertArrayNotHasKey('innerContent', $result['item']['attrs']['button']);
}
public function testEditorCannotCarryBuilderOnlyValuesFromANode(): void
{
$node = Block::create('divi/button', [
'css' => ['desktop' => ['value' => ['mainElement' => 'color:red']]],
'button' => ['decoration' => ['background' => ['desktop' => ['value' => ['color' => '#000000']]]]],
]);
$result = self::composer(false)->compose(self::book(), new PresetScope('module', 'divi/button'), null, [
'name' => 'Mit CSS',
], 'items[0]', $node);
self::assertArrayHasKey('items[0].from_node', $result['errors']);
}
public function testGroupPresetOnlyTakesItsGroup(): void
{
$result = self::composer()->compose(self::book(), new PresetScope('group', 'divi/spacing'), null, [
'name' => 'Abstand groß',
'source_module' => 'button',
'attrs' => [
'module.decoration.spacing' => ['desktop' => ['padding' => ['top' => '40px']]],
'button.decoration.background' => ['desktop' => ['color' => '#000000']],
],
], 'items[0]', null);
self::assertSame(
'gehört nicht zur Gruppe module.decoration.spacing',
$result['errors']['items[0].attrs.button.decoration.background']
);
self::assertSame(['group', 'divi/spacing', 'module.decoration.spacing', 'divi/button'], [
$result['item']['type'], $result['item']['groupName'], $result['item']['groupId'],
$result['item']['moduleName'],
]);
}
public function testNestedGroupPresetsByShortName(): void
{
$book = self::book()->with(new PresetScope('group', 'divi/font'), [
'type' => 'group', 'id' => 'ffffffffff', 'name' => 'Button-Schrift', 'groupName' => 'divi/font',
'groupId' => 'button.decoration.font', 'moduleName' => 'divi/button', 'attrs' => [],
]);
$result = self::composer()->compose($book, new PresetScope('module', 'divi/button'), null, [
'name' => 'Mit Schrift',
'group_presets' => ['font' => 'Button-Schrift'],
], 'items[0]', null);
self::assertSame([], $result['errors']);
self::assertSame(
['button.decoration.font' => ['presetId' => ['ffffffffff'], 'groupName' => 'divi/font']],
$result['item']['groupPresets']
);
}
public function testUnknownGroupAndPresetAreErrors(): void
{
$result = self::composer()->compose(self::book(), new PresetScope('module', 'divi/button'), null, [
'name' => 'X',
'group_presets' => ['sizing' => 'Gibt es nicht', 'font' => 'Fehlt'],
], 'items[0]', null);
self::assertStringContainsString('keine Gruppe', $result['errors']['items[0].group_presets.sizing']);
self::assertStringContainsString('unbekanntes Gruppen-Preset', $result['errors']['items[0].group_presets.font']);
}
public function testNewPresetNeedsAName(): void
{
$result = self::composer()->compose(self::book(), new PresetScope('module', 'divi/button'), null, [],
'items[0]', null);
self::assertSame('Name des neuen Presets fehlt', $result['errors']['items[0].name']);
}
}