feat: DynamicContentMap::loopFields() – loop_*-Optionen nach Art (X2); echte Loop-Optionen in DiviFixtures

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 20:43:10 +02:00
co-authored by Claude Sonnet 5.5
parent 617ad2888d
commit 3d12043b4f
3 changed files with 186 additions and 15 deletions
@@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi\Pages;
use Closure;
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
use LIT\MCP\Integrations\Divi\Compose\LoopScope;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
@@ -25,8 +26,8 @@ defined('ABSPATH') || exit;
* date_format => select]. DynamicContentRules routes each setting by that type and keeps its fixed
* rules whatever the map says (enable_html refused, meta keys S29/S30, no markers S28).
*
* - loop_* options never enter the map: Divi offers them only inside loop-enabled modules,
* which the tools do not track – DynamicContentRules refuses them ("nur innerhalb von Divi-Schleifen").
* - loop_* options never enter this map: loopFields() keeps them apart, grouped by loop kind (ruling X2), for
* a composition inside a Divi loop (LoopScope); DynamicContentRules allows one only in a loop of its kind.
* - The map REPLACES DynamicContentRules' default, so every Placeholders::POST option is added when
* the registry lacks it, with the settings the placeholders write (DynamicContentRules' default).
* - Registry unavailable (no Divi, empty, error): null – DynamicContentRules' default map applies.
@@ -38,6 +39,13 @@ class DynamicContentMap implements DiviAdapter
/** Divi's dynamic-content registry class; listed in manifest(). */
public const REGISTRY = 'ET\Builder\Packages\Module\Layout\Components\DynamicContent\DynamicContentOptions';
/** Divi's loop option groups of other kinds than post (DynamicContentOptionTermsGroups.php, …UsersGroups.php). */
private const LOOP_GROUPS = [
'Loop Terms' => LoopScope::TERM,
'Loop Users' => LoopScope::USER,
'Loop Menus' => LoopScope::MENU,
];
/**
* @param (Closure(int): mixed)|null $registry Registry lookup by post ID (tests); null = Divi's.
*/
@@ -64,7 +72,7 @@ class DynamicContentMap implements DiviAdapter
/**
* The options for lit/divi-dynamic-sources: name => label, group and setting types (the same
* names and types as forPost(), loop_* left out). custom_meta_<key> options a filter registers
* names and types as forPost(), loop_* left out – see loopFields()). custom_meta_<key> options a filter registers
* for a key DynamicContentRules refuses (protected "_" keys, S12/S30) are left out as well, so
* the listing never names them. Only label, group and setting types leave Divi's registry –
* no select options or defaults (they list meta keys). Null when the registry is unavailable.
@@ -95,6 +103,31 @@ class DynamicContentMap implements DiviAdapter
return $options;
}
/**
* The loop_* options of one loop kind (LoopScope::POST, TERM, USER, MENU): option name => [setting key =>
* field type], the shape of forPost() – a composition inside a loop of that kind adds them to its map.
* Kind (ruling X2): the three meta key options by their name (their groups are translated:
* "Loop %s Custom Fields", DynamicContentOptionLoopPostMetaKey.php:225), the others by Divi's group
* ("Loop Terms", "Loop Users", "Loop Menus"; every other group – "Loop", "Loop Product" – is post).
* ACF repeater options (loop_acf_*) have no kind (X2/X7e). Names are the registry keys – the names
* Divi stores and renders (get_options() sets every id to its key). [] when the registry is unavailable.
*
* @return array<string, array<string, string>>
*/
public function loopFields(string $kind, int $postId = 0): array
{
$raw = $this->raw($postId) ?? [];
$fields = [];
foreach ($raw as $name => $option) {
if (is_string($name) && is_array($option) && self::loopKind($name, $option) === $kind) {
$fields[$name] = self::fields($option);
}
}
return $fields;
}
/**
* Divi's registry for the post; any filter error is logged (message only) and yields null.
*
@@ -125,13 +158,7 @@ class DynamicContentMap implements DiviAdapter
continue;
}
$fields = [];
foreach (is_array($option['fields'] ?? null) ? $option['fields'] : [] as $key => $field) {
if (is_string($key) && $key !== '') {
$fields[$key] = self::type($key, $field);
}
}
$map[$name] = $fields;
$map[$name] = self::fields($option);
}
foreach (Placeholders::POST as $option) {
@@ -141,6 +168,39 @@ class DynamicContentMap implements DiviAdapter
return $map;
}
/**
* Setting key => field type of one registry option.
*
* @param array<array-key, mixed> $option
* @return array<string, string>
*/
private static function fields(array $option): array
{
$fields = [];
foreach (is_array($option['fields'] ?? null) ? $option['fields'] : [] as $key => $field) {
if (is_string($key) && $key !== '') {
$fields[$key] = self::type($key, $field);
}
}
return $fields;
}
/**
* Loop kind of a registry option (see loopFields()); null for other names and ACF repeater options.
*
* @param array<array-key, mixed> $option
*/
private static function loopKind(string $name, array $option): ?string
{
$byName = LoopScope::kindOfOption($name);
if ($byName === null || str_contains($name, '_meta_key_')) {
return $byName;
}
return self::LOOP_GROUPS[is_string($option['group'] ?? null) ? $option['group'] : ''] ?? LoopScope::POST;
}
/**
* Field type of one setting. before/after are "richtext" whatever Divi declares ("text"): Divi
* outputs them as HTML (DynamicContentElements::get_wrapper_element, wp_kses by the author's