feat: Umschreib-Strategien für Preset-Zuweisung und -Entkopplung (TreeRewrite, AssignRewrite, DetachRewrite)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-18 16:57:33 +02:00
co-authored by Claude Sonnet 5.5
parent 763d765660
commit d36e8286e8
7 changed files with 787 additions and 0 deletions
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use JsonException;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
use LIT\MCP\Integrations\Divi\BlockTree\Json;
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
use LogicException;
defined('ABSPATH') || exit;
/**
* Mass assignment of a preset stack and group presets (lit/divi-presets-assign): every block of $module – optionally
* only those without an own preset (empty stack or only default IDs) or only those that use preset $onlyWithPresetId
* – gets $spec through PresetAssignment, the one choke point for preset assignments (D7). Local values that cover the
* assigned presets are noted, or removed with $clearOverrides (rulings R4, R5). A block that would not change stays
* untouched, so the call can be repeated. Pure PHP.
*/
final class AssignRewrite implements NodeRewrite
{
/**
* @param array{preset?: mixed, group_presets?: mixed} $spec Already checked: PresetAssignment::apply() returned
* no errors for it.
*/
public function __construct(
private string $module,
private PresetAssignment $assignment,
private array $spec,
private bool $onlyWithoutPreset = false,
private ?string $onlyWithPresetId = null,
private bool $clearOverrides = false
) {
}
/** @throws LogicException When $spec does not pass PresetAssignment (it was to be validated beforehand). */
public function rewrite(Block $block): ?array
{
if (! $this->selects($block)) {
return null;
}
$attrs = $block->attrs();
$result = $this->assignment->apply($this->module, $attrs, $this->spec, 'node');
if ($result['errors'] !== []) {
throw new LogicException('The preset assignment was not validated: ' . implode('; ', $result['errors']));
}
$new = $this->clearOverrides ? $this->assignment->clearOverrides($result['attrs']) : $result['attrs'];
return self::same($attrs, $new) ? null : $new;
}
public function notes(Block $block, string $nodeId): array
{
if ($this->clearOverrides) {
return [];
}
return $this->assignment->overrides()->describe($nodeId, $block->attrs(), $this->assignment->names());
}
public function concernsInstance(Block $instance): bool
{
return GlobalInstance::effectiveName($instance) === $this->module;
}
private function selects(Block $block): bool
{
if ($block->name() !== $this->module) {
return false;
}
$stack = NodeSummary::presetIds($block);
if ($this->onlyWithoutPreset && array_diff($stack, PresetUsageMatcher::DEFAULT_IDS) !== []) {
return false;
}
return $this->onlyWithPresetId === null || in_array($this->onlyWithPresetId, $stack, true);
}
/** @param array<string, mixed> $before @param array<string, mixed> $after */
private static function same(array $before, array $after): bool
{
try {
return Json::encode($before) === Json::encode($after);
} catch (JsonException) {
return false;
}
}
}
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use JsonException;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\Json;
defined('ABSPATH') || exit;
/**
* Removes the references to preset $id from blocks (lit/divi-design-detach, ruling R10): a module preset from the
* modulePreset stack of the blocks of its module (an empty stack leaves no key, so Divi falls back to the default
* preset), a group preset from every groupPreset entry of its group (an empty entry, then an empty map is removed).
* Nested references inside other presets are not blocks; the preset pipeline removes those. Pure PHP.
*/
final class DetachRewrite implements NodeRewrite
{
public function __construct(private PresetScope $scope, private string $id)
{
}
public function rewrite(Block $block): ?array
{
if (! $block->isDivi()) {
return null;
}
return $this->scope->kind === PresetScope::MODULE
? $this->withoutModulePreset($block)
: $this->withoutGroupPreset($block->attrs());
}
public function notes(Block $block, string $nodeId): array
{
return [];
}
public function concernsInstance(Block $instance): bool
{
return self::mentions($instance, $this->id);
}
/** @return array<string, mixed>|null */
private function withoutModulePreset(Block $block): ?array
{
$attrs = $block->attrs();
$stack = self::idList($attrs['modulePreset'] ?? null);
if ($block->name() !== $this->scope->name || ! in_array($this->id, $stack, true)) {
return null;
}
$rest = $this->without($stack);
if ($rest === []) {
unset($attrs['modulePreset']);
} else {
$attrs['modulePreset'] = $rest;
}
return $attrs;
}
/**
* @param array<string, mixed> $attrs
* @return array<string, mixed>|null
*/
private function withoutGroupPreset(array $attrs): ?array
{
$groups = $attrs['groupPreset'] ?? null;
if (! is_array($groups)) {
return null;
}
$changed = false;
foreach ($groups as $groupId => $entry) {
if (! is_array($entry) || ($entry['groupName'] ?? null) !== $this->scope->name) {
continue;
}
$ids = self::idList($entry['presetId'] ?? null);
if (! in_array($this->id, $ids, true)) {
continue;
}
$changed = true;
$rest = $this->without($ids);
if ($rest === []) {
unset($groups[$groupId]);
} else {
$groups[$groupId]['presetId'] = $rest;
}
}
if (! $changed) {
return null;
}
if ($groups === []) {
unset($attrs['groupPreset']);
} else {
$attrs['groupPreset'] = $groups;
}
return $attrs;
}
/** @return list<mixed> The IDs of a stored stack: a list, the legacy single string, else none. */
private static function idList(mixed $value): array
{
if (is_string($value)) {
return [$value];
}
return is_array($value) ? array_values($value) : [];
}
/**
* @param array<array-key, mixed> $ids
* @return list<mixed>
*/
private function without(array $ids): array
{
return array_values(array_filter($ids, fn (mixed $item): bool => $item !== $this->id));
}
/** Whether the attributes of $block or of any block below mention $id (fail closed on unencodable values). */
private static function mentions(Block $block, string $id): bool
{
try {
if (str_contains(Json::encode($block->attrs()), $id)) {
return true;
}
} catch (JsonException) {
return true;
}
foreach ($block->children() as $child) {
if (self::mentions($child, $id)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
defined('ABSPATH') || exit;
/**
* One change to the blocks of a tree (Etappe 4b): assigning a preset (AssignRewrite) or removing a reference to it
* (DetachRewrite). TreeRewrite applies it to every block of a page; the strategy only decides per block. Pure PHP.
*/
interface NodeRewrite
{
/** The new attributes of $block, or null when it stays as it is (then there is no note either). */
public function rewrite(Block $block): ?array;
/**
* Notes on $block after rewrite() changed it (e.g. local values that cover the assigned preset).
*
* @param string $nodeId Path ID of $block (NodeIndex).
* @return list<string>
*/
public function notes(Block $block, string $nodeId): array;
/** Whether the opaque global instance $instance would matter for this change (then it is reported as skipped). */
public function concernsInstance(Block $instance): bool;
}
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
defined('ABSPATH') || exit;
/**
* Applies a NodeRewrite to every block of a NodeIndex, in place (ruling R8): readable blocks are offered to
* rewrite(), a changed one gets its new attributes and then its notes; opaque blocks – global instances, blocks with
* unreadable attributes – are never offered and never changed, but listed as skipped when the strategy says the
* change concerns them (a global module is edited in its library entry). Pure PHP.
*/
final class TreeRewrite
{
public function __construct(private NodeRewrite $rewrite)
{
}
/**
* @return array{changed: list<array{id: string, module: string, label: string}>, warnings: list<string>,
* skipped: list<array{id: string, reason: string}>}
*/
public function apply(NodeIndex $index): array
{
$changed = [];
$warnings = [];
$skipped = [];
foreach ($index->entries() as $entry) {
$id = $entry['id'];
$block = $entry['block'];
if ($block->isOpaque()) {
if ($this->rewrite->concernsInstance($block)) {
$skipped[] = ['id' => $id, 'reason' => self::reason($block)];
}
continue;
}
$attrs = $this->rewrite->rewrite($block);
if ($attrs === null) {
continue;
}
$block->setAttrs($attrs);
$changed[] = ['id' => $id, 'module' => $block->name(), 'label' => NodeSummary::label($block)];
array_push($warnings, ...$this->rewrite->notes($block, $id));
}
return ['changed' => $changed, 'warnings' => $warnings, 'skipped' => $skipped];
}
private static function reason(Block $block): string
{
$ref = NodeSummary::globalRef($block);
if ($ref !== null) {
return sprintf('globales Modul – im Bibliothekseintrag %s ändern', $ref['id']);
}
return $block->isGlobal()
? 'globales Modul ohne Bibliothekseintrag – im Divi-Builder prüfen'
: 'Attribute nicht lesbar – im Divi-Builder prüfen';
}
}