feat: PresetOverrides – lokale Werte finden, beschreiben und entfernen, die Presets überdecken

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-18 16:43:39 +02:00
co-authored by Claude Sonnet 5.5
parent c3f08e2d33
commit 6546e23e7f
2 changed files with 572 additions and 0 deletions
@@ -0,0 +1,305 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use Closure;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use stdClass;
defined('ABSPATH') || exit;
/**
* Local values that cover values of the presets a block names explicitly (ruling R4, decision G9). Divi merges
* preset and block attributes with array_replace_recursive, so the comparison runs on leaves: one value at
* "<attribute path>|<breakpoint>|<state>|<sub path>" (the sub path is empty for a scalar state value, e.g. the text of
* "title.innerContent"). A local padding.top covers a preset's padding.top, never its padding.bottom.
*
* Counted are the presets of the block's modulePreset stack, the entries of its groupPreset, and the groupPresets
* nested in the assigned module presets – not the default presets Divi applies implicitly. A group preset stores its
* attributes below its own groupId; they are moved to the key the block (or the nesting module preset) uses for it.
* Two values are the same when their scalars are the same text (Divi's builder writes "700", an API client may write
* 700), lists and objects compare by content. Pure PHP; the preset data enters through the closure.
*/
final class PresetOverrides
{
private const MAX_PATHS = 5;
/** @param Closure(string $id): ?array<string, mixed> $item Stored preset item (module or group) or null. */
public function __construct(private Closure $item)
{
}
/**
* @param array<string, mixed> $attrs
* @return array<string, mixed> "path|breakpoint|state|subpath" => value; AttrMap::META_KEYS are left out.
*/
public static function leaves(array $attrs): array
{
$leaves = [];
foreach (AttrMap::flatten($attrs) as $path => $map) {
if (! AttrMap::isBreakpointMap($map)) {
continue;
}
foreach ($map as $breakpoint => $states) {
$states = self::plain($states);
foreach (is_array($states) ? $states : [] as $state => $value) {
self::collect($path . '|' . $breakpoint . '|' . $state, $value, '', $leaves);
}
}
}
return $leaves;
}
/**
* @param array<string, mixed> $attrs Attributes of a block.
* @return array<string, list<string>> Preset ID => local leaves with another value than the preset's.
*/
public function find(array $attrs): array
{
$local = self::leaves($attrs);
$found = [];
foreach ($this->assigned($attrs) as $id => $leaves) {
foreach ($leaves as $key => $value) {
if (array_key_exists($key, $local) && self::canonical($local[$key]) !== self::canonical($value)) {
$found[$id][] = $key;
}
}
}
return $found;
}
/**
* Removes every local leaf that lies on a leaf of an assigned preset – also one with the same value, which
* changes nothing – and the parents that become empty.
*
* @param array<string, mixed> $attrs
* @return array<string, mixed>
*/
public function clear(array $attrs): array
{
$covered = [];
foreach ($this->assigned($attrs) as $leaves) {
$covered += $leaves;
}
foreach (array_keys(self::leaves($attrs)) as $key) {
if (array_key_exists($key, $covered)) {
$attrs = AttrMap::remove($attrs, self::pathOf($key));
}
}
return $attrs;
}
/**
* One warning per overridden preset (ruling R5).
*
* @param array<string, mixed> $attrs
* @param array<string, string> $names Preset ID => name.
* @return list<string>
*/
public function describe(string $nodeId, array $attrs, array $names): array
{
$lines = [];
foreach ($this->find($attrs) as $id => $keys) {
$paths = array_values(array_unique(array_map([self::class, 'label'], $keys)));
$shown = implode(', ', array_slice($paths, 0, self::MAX_PATHS));
$more = count($paths) - self::MAX_PATHS;
$lines[] = sprintf(
'%s: lokale Werte überdecken „%s“: %s%s – clear_overrides: true entfernt sie.',
$nodeId,
$names[$id] ?? $id,
$shown,
$more > 0 ? sprintf(' und %d weitere', $more) : ''
);
}
return $lines;
}
/**
* Leaves of the explicitly assigned presets, in assignment order: each module preset of the stack followed by the
* group presets nested in it, then the block's own group presets.
*
* @param array<string, mixed> $attrs
* @return array<string, array<string, mixed>> Preset ID => leaves (keys as in leaves()).
*/
private function assigned(array $attrs): array
{
$assigned = [];
foreach (self::idList($attrs['modulePreset'] ?? null) as $id) {
$item = $this->lookup($id);
if ($item === null) {
continue;
}
$this->add($assigned, $id, $item, null);
foreach (self::groupEntries($item['groupPresets'] ?? null) as $target => $ids) {
$this->addGroupPresets($assigned, $target, $ids);
}
}
foreach (self::groupEntries($attrs['groupPreset'] ?? null) as $target => $ids) {
$this->addGroupPresets($assigned, $target, $ids);
}
return $assigned;
}
/**
* @param array<string, array<string, mixed>> $assigned
* @param list<string> $ids
*/
private function addGroupPresets(array &$assigned, string $target, array $ids): void
{
foreach ($ids as $id) {
$item = $this->lookup($id);
if ($item !== null) {
$this->add($assigned, $id, $item, $target);
}
}
}
/**
* @param array<string, array<string, mixed>> $assigned
* @param array<string, mixed> $item
*/
private function add(array &$assigned, string $id, array $item, ?string $target): void
{
$attrs = is_array($item['attrs'] ?? null) ? $item['attrs'] : [];
$leaves = self::leaves($attrs);
$from = $item['groupId'] ?? null;
if ($target !== null && is_string($from) && $from !== $target) {
$leaves = self::moved($leaves, $from, $target);
}
$assigned[$id] = ($assigned[$id] ?? []) + $leaves;
}
/** @return array<string, mixed>|null */
private function lookup(string $id): ?array
{
$item = ($this->item)($id);
return is_array($item) ? $item : null;
}
/**
* Leaves below the attribute path $from, moved below $to; leaves outside $from do not belong to the group.
*
* @param array<string, mixed> $leaves
* @return array<string, mixed>
*/
private static function moved(array $leaves, string $from, string $to): array
{
$moved = [];
foreach ($leaves as $key => $value) {
[$path, $rest] = explode('|', $key, 2);
if ($path === $from) {
$moved[$to . '|' . $rest] = $value;
} elseif (str_starts_with($path, $from . '.')) {
$moved[$to . substr($path, strlen($from)) . '|' . $rest] = $value;
}
}
return $moved;
}
/**
* @param array<string, mixed> $leaves
*/
private static function collect(string $head, mixed $value, string $sub, array &$leaves): void
{
$value = self::plain($value);
if (is_array($value) && ! array_is_list($value)) {
foreach ($value as $key => $inner) {
self::collect($head, $inner, $sub === '' ? (string) $key : $sub . '.' . $key, $leaves);
}
return;
}
if ($value !== []) {
$leaves[$head . '|' . $sub] = $value;
}
}
private static function pathOf(string $key): string
{
[$path, $breakpoint, $state, $sub] = explode('|', $key, 4);
return $path . '.' . $breakpoint . '.' . $state . ($sub === '' ? '' : '.' . $sub);
}
/** "<path>" for desktop/value, else "<path> (<breakpoint>[, <state>])" (ruling R5). */
private static function label(string $key): string
{
[$path, $breakpoint, $state] = explode('|', $key, 4);
if ($breakpoint === 'desktop' && $state === 'value') {
return $path;
}
return $path . ' (' . $breakpoint . ($state === 'value' ? '' : ', ' . $state) . ')';
}
/** @return list<string> */
private static function idList(mixed $ids): array
{
if (is_string($ids)) {
$ids = [$ids];
}
return is_array($ids)
? array_values(array_filter($ids, static fn (mixed $id): bool => is_string($id) && $id !== ''))
: [];
}
/**
* @return array<string, list<string>> Attribute path of the group => preset IDs.
*/
private static function groupEntries(mixed $entries): array
{
$entries = self::plain($entries);
$result = [];
foreach (is_array($entries) ? $entries : [] as $groupId => $entry) {
$ids = is_array($entry) ? self::idList($entry['presetId'] ?? null) : [];
if ($ids !== []) {
$result[(string) $groupId] = $ids;
}
}
return $result;
}
private static function plain(mixed $value): mixed
{
return $value instanceof stdClass ? get_object_vars($value) : $value;
}
/** A form two equal values share: scalars as text, objects by sorted keys, lists in order. */
private static function canonical(mixed $value): string
{
// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP (spec §4.4).
return (string) json_encode(self::normalized($value), JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
}
private static function normalized(mixed $value): mixed
{
$value = self::plain($value);
if (! is_array($value)) {
return is_scalar($value) ? (string) $value : '';
}
$value = array_map([self::class, 'normalized'], $value);
if (! array_is_list($value)) {
ksort($value);
}
return $value;
}
}