feat: GroupKeys und PresetAssignment – Preset-Stapel und Gruppen-Presets über eine Engstelle

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-18 16:45:29 +02:00
co-authored by Claude Sonnet 5.5
parent 6546e23e7f
commit fafa140546
6 changed files with 796 additions and 17 deletions
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use InvalidArgumentException;
defined('ABSPATH') || exit;
/**
* Resolves the key of a "group_presets" entry (ruling R3) to the groupId – the attribute path of the option group in
* the module, e.g. "content.decoration.bodyFont". The key is the short name ("font-body"), the full name
* ("divi/font-body") or the attribute path itself; a short or full name that several groups of the module share must
* be given as path. The groups come from Divi (DesignSystemWriter::groupsOf()), never from the client. Pure PHP.
*/
final class GroupKeys
{
/** Exception code: no group of the module matches the key. */
public const UNKNOWN = 1;
/** Exception code: several groups of the module have that name. */
public const AMBIGUOUS = 2;
/**
* @param array<string, string> $groups groupId => groupName, e.g. "module.decoration.spacing" => "divi/spacing"
* @return string The groupId.
* @throws InvalidArgumentException German message; code UNKNOWN or AMBIGUOUS. The UNKNOWN message does not name
* the module – the caller adds it.
*/
public static function resolve(array $groups, string $key): string
{
if (array_key_exists($key, $groups)) {
return (string) $key;
}
$paths = [];
foreach ($groups as $groupId => $name) {
if ($key === $name || 'divi/' . $key === $name) {
$paths[] = (string) $groupId;
}
}
if ($paths === []) {
throw new InvalidArgumentException(sprintf('keine Gruppe "%s"', $key), self::UNKNOWN);
}
if (count($paths) > 1) {
throw new InvalidArgumentException(sprintf(
'Gruppe "%s" ist mehrdeutig; bitte den Attributpfad angeben: %s.',
$key,
implode(', ', $paths)
), self::AMBIGUOUS);
}
return $paths[0];
}
}
@@ -0,0 +1,289 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use Closure;
use InvalidArgumentException;
defined('ABSPATH') || exit;
/**
* The one place that turns the client's "preset" and "group_presets" into the attributes Divi stores on a block
* (D7, rulings R2/R3): modulePreset (a stack of preset IDs) and groupPreset ({groupId: {presetId, groupName}}). Names
* resolve through the preset inventory (PresetBook::find(), a name several presets share is an error), group keys
* through GroupKeys, every ID must pass the ValueCleaner ($validId). Nothing is stored for a key with an error; the
* caller (Composer, AssignRewrite) refuses the whole write when "errors" is not empty. The inventory is read lazily
* and at most once. Pure PHP; Divi and WordPress enter through the closures.
*/
final class PresetAssignment
{
private const MAX_STACK = 10;
private ?PresetBook $loaded = null;
/** @var array<string, array<string, mixed>>|null Preset ID => stored item. */
private ?array $index = null;
private ?PresetOverrides $overrides = null;
/**
* @param Closure(): PresetBook $book The inventory (DesignSystemReader), called lazily.
* @param Closure(string): array<string, string> $groupsOf DesignSystemWriter::groupsOf(): groupId => name.
* @param Closure(string): bool $validId True when the ID passes the ValueCleaner unchanged.
*/
public function __construct(private Closure $book, private Closure $groupsOf, private Closure $validId)
{
}
/**
* Applies "preset" and "group_presets" of $spec (only the keys present) to the attributes of a $module block.
* null (or "" for "preset") removes the assignment; a string or a list of 1–10 strings sets it.
*
* @param array<string, mixed> $attrs
* @param array<array-key, mixed> $spec
* @return array{attrs: array<string, mixed>, errors: array<string, string>} Error paths: "$path.preset",
* "$path.preset.<i>" (one entry of a list), "$path.group_presets" and "$path.group_presets.<key>".
*/
public function apply(string $module, array $attrs, array $spec, string $path): array
{
$errors = [];
if (array_key_exists('preset', $spec)) {
$attrs = $this->stack($module, $attrs, $spec['preset'], $path . '.preset', $errors);
}
if (array_key_exists('group_presets', $spec)) {
$attrs = $this->groups($module, $attrs, $spec['group_presets'], $path . '.group_presets', $errors);
}
return ['attrs' => $attrs, 'errors' => $errors];
}
/**
* The attributes without the local values that lie on values of the presets the block names (ruling R4).
*
* @param array<string, mixed> $attrs
* @return array<string, mixed>
*/
public function clearOverrides(array $attrs): array
{
return $this->overrides()->clear($attrs);
}
/** The same instance every time, so the Warnings and clear_overrides read one inventory. */
public function overrides(): PresetOverrides
{
$this->overrides ??= new PresetOverrides(fn (string $id): ?array => $this->items()[$id] ?? null);
return $this->overrides;
}
/** @return array<string, string> Preset ID => name, module and group presets. */
public function names(): array
{
$names = [];
foreach ($this->items() as $id => $item) {
if (is_string($item['name'] ?? null)) {
$names[$id] = $item['name'];
}
}
return $names;
}
/**
* @param array<string, mixed> $attrs
* @param array<string, string> $errors
* @return array<string, mixed>
*/
private function stack(string $module, array $attrs, mixed $preset, string $path, array &$errors): array
{
if ($preset === null || $preset === '') {
unset($attrs['modulePreset']);
return $attrs;
}
$ids = $this->resolve(
new PresetScope(PresetScope::MODULE, $module),
$preset,
$path,
true,
'leere Liste – null entfernt das Preset',
$errors
);
if ($ids !== null) {
$attrs['modulePreset'] = $ids;
}
return $attrs;
}
/**
* @param array<string, mixed> $attrs
* @param array<string, string> $errors
* @return array<string, mixed>
*/
private function groups(string $module, array $attrs, mixed $wanted, string $path, array &$errors): array
{
if (! is_array($wanted) || ($wanted !== [] && array_is_list($wanted))) {
$errors[$path] = 'Objekt Gruppe → Preset erwartet';
return $attrs;
}
$groups = ($this->groupsOf)($module);
$entries = is_array($attrs['groupPreset'] ?? null) ? $attrs['groupPreset'] : [];
foreach ($wanted as $key => $value) {
$keyPath = $path . '.' . $key;
try {
$groupId = GroupKeys::resolve($groups, (string) $key);
} catch (InvalidArgumentException $e) {
$errors[$keyPath] = $e->getCode() === GroupKeys::UNKNOWN
? sprintf('%s in %s; vorhanden: %s', $e->getMessage(), $module, self::shortNames($groups))
: $e->getMessage();
continue;
}
if ($value === null) {
unset($entries[$groupId]);
continue;
}
$name = $groups[$groupId];
$ids = $this->resolve(
new PresetScope(PresetScope::GROUP, $name),
$value,
$keyPath,
false,
'leere Liste – null entfernt das Gruppen-Preset',
$errors
);
if ($ids !== null) {
$entries[$groupId] = ['presetId' => $ids, 'groupName' => $name];
}
}
if ($entries === []) {
unset($attrs['groupPreset']);
} else {
$attrs['groupPreset'] = $entries;
}
return $attrs;
}
/**
* A name, an ID or a list of 1–10 of them to the IDs in the same order; null when anything is wrong (the reasons
* are in $errors: one per list entry at "$path.<i>" when $perEntry, else the first one at $path).
*
* @param array<string, string> $errors
* @return list<string>|null
*/
private function resolve(
PresetScope $scope,
mixed $value,
string $path,
bool $perEntry,
string $emptyMessage,
array &$errors
): ?array {
$single = is_string($value);
if (! $single && ! (is_array($value) && array_is_list($value))) {
$errors[$path] = 'Text, Liste von Texten oder null erwartet';
return null;
}
$entries = $single ? [$value] : $value;
if ($entries === [] || count($entries) > self::MAX_STACK) {
$errors[$path] = $entries === [] ? $emptyMessage : 'höchstens ' . self::MAX_STACK . ' Presets im Stapel';
return null;
}
$ids = [];
$failed = false;
foreach ($entries as $position => $entry) {
try {
$ids[] = $this->idOf($scope, $entry, $ids);
} catch (InvalidArgumentException $e) {
$errors[$single || ! $perEntry ? $path : $path . '.' . $position] ??= $e->getMessage();
$failed = true;
}
}
return $failed ? null : $ids;
}
/**
* @param list<string> $taken IDs of the entries before this one.
* @throws InvalidArgumentException The German reason the entry cannot be used.
*/
private function idOf(PresetScope $scope, mixed $entry, array $taken): string
{
if (! is_string($entry)) {
throw new InvalidArgumentException('Preset-Name oder -ID als Text erwartet');
}
$found = $this->book()->find($scope, $entry);
if ($found === null) {
throw new InvalidArgumentException($scope->kind === PresetScope::MODULE
? sprintf('unbekanntes Preset; vorhanden für %s: %s', $scope->name, $this->namesIn($scope))
: sprintf('unbekanntes Gruppen-Preset "%s" (%s)', $entry, $scope->name));
}
$id = (string) $found['id'];
if (! ($this->validId)($id)) {
throw new InvalidArgumentException(sprintf('Preset-ID "%s" ist ungültig', $id));
}
if (in_array($id, $taken, true)) {
throw new InvalidArgumentException(sprintf('Preset "%s" steht mehrfach im Stapel', $id));
}
return $id;
}
private function namesIn(PresetScope $scope): string
{
$names = array_column($this->book()->items($scope), 'name');
return $names === [] ? '(keine)' : implode(', ', $names);
}
/** @param array<string, string> $groups groupId => groupName */
private static function shortNames(array $groups): string
{
$names = array_unique(array_map(
static fn (string $name): string => str_starts_with($name, 'divi/') ? substr($name, 5) : $name,
array_values($groups)
));
return $names === [] ? '(keine)' : implode(', ', $names);
}
private function book(): PresetBook
{
$this->loaded ??= ($this->book)();
return $this->loaded;
}
/** @return array<string, array<string, mixed>> */
private function items(): array
{
if ($this->index === null) {
$this->index = [];
foreach ($this->book()->data() as $records) {
foreach ($records as $record) {
foreach ($record['items'] as $id => $item) {
$this->index[(string) $id] = $item;
}
}
}
}
return $this->index;
}
}
@@ -199,9 +199,12 @@ final class PresetComposer
$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);
try {
$groupId = GroupKeys::resolve($groups, (string) $group);
} catch (InvalidArgumentException $e) {
$errors[$path] = $e->getCode() === GroupKeys::UNKNOWN
? sprintf('keine Gruppe "%s" in %s', $group, $module)
: $e->getMessage();
continue;
}
if ($preset === null) {
@@ -226,18 +229,6 @@ final class PresetComposer
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;
}
/**
* The name the ability resolved and cleaned ("name", also for a rename), else the existing preset's name.
*