diff --git a/src/Integrations/Divi/Pages/DynamicContentMap.php b/src/Integrations/Divi/Pages/DynamicContentMap.php index 19bce45..5eeffac 100644 --- a/src/Integrations/Divi/Pages/DynamicContentMap.php +++ b/src/Integrations/Divi/Pages/DynamicContentMap.php @@ -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_ options a filter registers + * names and types as forPost(), loop_* left out – see loopFields()). custom_meta_ 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> + */ + 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 $option + * @return array + */ + 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 $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 diff --git a/tests/Support/DiviFixtures.php b/tests/Support/DiviFixtures.php index f0ec80e..879d3cd 100644 --- a/tests/Support/DiviFixtures.php +++ b/tests/Support/DiviFixtures.php @@ -102,6 +102,14 @@ final class DiviFixtures $option = static fn (string $id, string $group, array $fields): array => [ 'id' => $id, 'label' => $id, 'type' => 'text', 'custom' => false, 'group' => $group, 'fields' => $fields, ]; + $select = static fn (): array => ['label' => 'x', 'type' => 'select', 'default' => '']; + $loop = ['before' => $text(), 'after' => $text(), 'loop_position' => $text()]; + $date = ['date_format' => $select(), 'custom_date_format' => $text()]; + $meta = $loop + $date + [ + 'select_loop_meta_key' => $select(), + 'loop_meta_key' => $text(), + 'enable_html' => ['label' => 'x', 'type' => 'yes_no_button', 'default' => 'off'], + ]; return [ 'post_title' => $option('post_title', 'Default', ['before' => $text(), 'after' => $text()]), @@ -131,12 +139,32 @@ final class DiviFixtures 'meta_key' => $text(), 'enable_html' => ['type' => 'yes_no_button'], ]), - 'loop_post_title' => $option('loop_post_title', 'Loop', [ - 'before' => $text(), 'after' => $text(), 'loop_position' => $text(), - ]), - 'loop_post_meta_key_manual_custom_field' => $option('loop_post_meta_key_manual_custom_field', 'Loop', [ - 'loop_meta_key' => $text(), + // Loop options as Divi 5.15.1's get_options() returns them (research 4e §2): id = key = stored name. + 'loop_post_title' => $option('loop_post_title', 'Loop', $loop), + 'loop_post_date' => $option('loop_post_date', 'Loop', $loop + $date), + 'loop_post_featured_image' => $option('loop_post_featured_image', 'Loop', [ + 'loop_position' => $text(), 'thumbnail_size' => $select(), ]), + 'loop_post_meta_key_manual_custom_field' => $option( + 'loop_post_meta_key_manual_custom_field', + 'Loop Post Custom Fields', + $meta + ), + 'loop_product_title' => $option('loop_product_title', 'Loop Product', $loop), + 'loop_term_name' => $option('loop_term_name', 'Loop Terms', $loop), + 'loop_term_meta_key_manual_custom_field' => $option( + 'loop_term_meta_key_manual_custom_field', + 'Loop Term Custom Fields', + $meta + ), + 'loop_user_name' => $option('loop_user_name', 'Loop Users', $loop), + 'loop_user_meta_key_manual_custom_field' => $option( + 'loop_user_meta_key_manual_custom_field', + 'Loop User Custom Fields', + $meta + ), + 'loop_menu_text' => $option('loop_menu_text', 'Loop Menus', $loop), + 'loop_acf_team|||name' => $option('loop_acf_team|||name', 'Loop ACF team', $loop + ['acf_type' => $text()]), // Odd entries a third-party filter could add. 'broken_fields' => $option('broken_fields', 'Default', ['a' => 'not-an-array', 'b' => ['type' => 3]]), 'no_fields' => ['id' => 'no_fields', 'type' => 'text'], diff --git a/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php b/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php index 0c26f7a..152d0bd 100644 --- a/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages; use InvalidArgumentException; use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules; +use LIT\MCP\Integrations\Divi\Compose\LoopScope; use LIT\MCP\Integrations\Divi\Compose\Placeholders; use LIT\MCP\Integrations\Divi\Compose\VariableRef; use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; @@ -210,6 +211,88 @@ final class DynamicContentMapTest extends TestCase self::assertSame($before, json_encode($after)); } + /** Ruling X2: loop_* options stay, grouped by kind – by group, the three meta options by name. */ + public function testLoopFieldsGroupTheLoopOptionsByKind(): void + { + $map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry()); + + self::assertSame( + [ + 'loop_post_title', 'loop_post_date', 'loop_post_featured_image', + 'loop_post_meta_key_manual_custom_field', 'loop_product_title', + ], + array_keys($map->loopFields(LoopScope::POST)) + ); + self::assertSame( + ['loop_term_name', 'loop_term_meta_key_manual_custom_field'], + array_keys($map->loopFields(LoopScope::TERM)) + ); + self::assertSame( + ['loop_user_name', 'loop_user_meta_key_manual_custom_field'], + array_keys($map->loopFields(LoopScope::USER)) + ); + self::assertSame(['loop_menu_text'], array_keys($map->loopFields(LoopScope::MENU))); + // ACF repeater rows have no kind (X2/X7e); an unknown kind has no options. + self::assertSame([], $map->loopFields('repeater_team')); + self::assertSame([], $map->loopFields('')); + } + + public function testLoopFieldsHaveTheShapeOfTheAllowMap(): void + { + $map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry()); + + $post = $map->loopFields(LoopScope::POST); + + self::assertSame( + [ + 'before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text', + 'date_format' => 'select', 'custom_date_format' => 'text', + ], + $post['loop_post_date'] + ); + self::assertSame(['loop_position' => 'text', 'thumbnail_size' => 'select'], $post['loop_post_featured_image']); + self::assertSame( + ['select', 'text', 'yes_no_button'], + [ + $post['loop_post_meta_key_manual_custom_field']['select_loop_meta_key'], + $post['loop_post_meta_key_manual_custom_field']['loop_meta_key'], + $post['loop_post_meta_key_manual_custom_field']['enable_html'], + ] + ); + } + + /** The custom field groups are translated ("Loop %s Custom Fields", esc_html__) – their name decides. */ + public function testTheMetaOptionsFollowTheirNameNotTheirGroup(): void + { + $registry = DiviFixtures::dynamicContentRegistry(); + $registry['loop_user_meta_key_manual_custom_field']['group'] = 'Schleife: Benutzer-Felder'; + $registry['loop_term_meta_key_manual_custom_field']['group'] = 'Loop'; + $registry['loop_post_meta_key_manual_custom_field']['group'] = 'Loop Users'; + $map = new DynamicContentMap(static fn (int $id): array => $registry); + + self::assertArrayHasKey('loop_user_meta_key_manual_custom_field', $map->loopFields(LoopScope::USER)); + self::assertArrayHasKey('loop_term_meta_key_manual_custom_field', $map->loopFields(LoopScope::TERM)); + self::assertArrayHasKey('loop_post_meta_key_manual_custom_field', $map->loopFields(LoopScope::POST)); + self::assertArrayNotHasKey('loop_post_meta_key_manual_custom_field', $map->loopFields(LoopScope::USER)); + } + + public function testLoopFieldsReadTheRegistryOfThePostAndAreEmptyWithoutIt(): void + { + $seen = []; + $map = new DynamicContentMap(static function (int $postId) use (&$seen): array { + $seen[] = $postId; + + return DiviFixtures::dynamicContentRegistry(); + }); + + $map->loopFields(LoopScope::POST, 42); + $map->loopFields(LoopScope::TERM); + + self::assertSame([42, 0], $seen); + self::assertSame([], (new DynamicContentMap(static fn (): array => []))->loopFields(LoopScope::POST)); + self::assertSame([], (new DynamicContentMap())->loopFields(LoopScope::POST), 'without Divi'); + } + private static function assertRejected(callable $call, string $fragment): void { try {