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