diff --git a/src/Integrations/Divi/Compose/LoopScope.php b/src/Integrations/Divi/Compose/LoopScope.php new file mode 100644 index 0000000..2784638 --- /dev/null +++ b/src/Integrations/Divi/Compose/LoopScope.php @@ -0,0 +1,116 @@ +, the aliases "terms"/"users", "" – is a loop the tools do + * not support: queryType() names it, but it has no kind and is not active(), so every loop_* option is + * refused inside it (fail closed). ACF repeater rows would need the setting acf_type, which the content + * rules refuse (ruling X7e); Divi's content renderer knows no aliases (LoopUtils.php:3130-3148). + * + * kindOfOption() is the kind an option NAME belongs to: Divi takes the meta type of + * loop__meta_key_* from the name and reads the meta of the loop object's ID, whatever the + * object is (DynamicContentLoopOptions.php:145-158) – a post meta option in a term loop reads the post + * with the term's ID. fits() is that security check (Global Constraints: "Sicherheitsgrenze Typabgleich"). + */ +final class LoopScope +{ + public const POST = 'post'; + public const TERM = 'term'; + public const USER = 'user'; + public const MENU = 'menu'; + + /** Divi queryType => kind. */ + private const KINDS = [ + 'post_types' => self::POST, + 'current_page' => self::POST, + 'post_taxonomies' => self::TERM, + 'user_roles' => self::USER, + 'menus' => self::MENU, + ]; + + private const LABELS = [ + self::POST => 'Beiträge', + self::TERM => 'Terme', + self::USER => 'Benutzer', + self::MENU => 'Menü', + ]; + + /** Name prefix => kind of a loop option; every other loop_* name is a post option (loop_post_*, loop_product_*). */ + private const OPTION_PREFIXES = [ + 'loop_term_' => self::TERM, + 'loop_user_' => self::USER, + 'loop_menu_' => self::MENU, + 'loop_acf_' => null, + ]; + + private function __construct(private ?string $queryType) + { + } + + public static function none(): self + { + return new self(null); + } + + public static function ofQueryType(string $queryType): self + { + return new self($queryType); + } + + /** Whether there is a loop whose loop_* options the tools allow. */ + public function active(): bool + { + return $this->kind() !== null; + } + + /** POST, TERM, USER or MENU; null without a loop or in an unsupported one. */ + public function kind(): ?string + { + return $this->queryType === null ? null : (self::KINDS[$this->queryType] ?? null); + } + + /** Divi's queryType of the loop; null without a loop. */ + public function queryType(): ?string + { + return $this->queryType; + } + + /** German name of the kind for messages; the raw queryType of an unsupported loop; "" without a loop. */ + public function label(): string + { + $kind = $this->kind(); + + return $kind === null ? (string) $this->queryType : self::LABELS[$kind]; + } + + /** Whether the loop_* option $option belongs to this loop's kind. */ + public function fits(string $option): bool + { + return $this->active() && self::kindOfOption($option) === $this->kind(); + } + + /** Kind of a loop_* option by its name; null for other names and ACF repeater options. */ + public static function kindOfOption(string $option): ?string + { + if (! str_starts_with($option, 'loop_')) { + return null; + } + + foreach (self::OPTION_PREFIXES as $prefix => $kind) { + if (str_starts_with($option, $prefix)) { + return $kind; + } + } + + return self::POST; + } +} diff --git a/tests/Unit/Integrations/Divi/Compose/LoopScopeTest.php b/tests/Unit/Integrations/Divi/Compose/LoopScopeTest.php new file mode 100644 index 0000000..645a1d2 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Compose/LoopScopeTest.php @@ -0,0 +1,106 @@ + */ + public static function queryTypes(): array + { + return [ + 'post types' => ['post_types', LoopScope::POST, 'Beiträge'], + 'current page (archive main query)' => ['current_page', LoopScope::POST, 'Beiträge'], + 'taxonomies' => ['post_taxonomies', LoopScope::TERM, 'Terme'], + 'user roles' => ['user_roles', LoopScope::USER, 'Benutzer'], + 'menus' => ['menus', LoopScope::MENU, 'Menü'], + ]; + } + + /** @dataProvider queryTypes */ + public function testTheFiveQueryTypesOfTheBuilderHaveAKind(string $queryType, string $kind, string $label): void + { + $scope = LoopScope::ofQueryType($queryType); + + self::assertTrue($scope->active()); + self::assertSame($kind, $scope->kind()); + self::assertSame($queryType, $scope->queryType()); + self::assertSame($label, $scope->label()); + } + + public function testNoneIsNoLoop(): void + { + $scope = LoopScope::none(); + + self::assertFalse($scope->active()); + self::assertNull($scope->kind()); + self::assertNull($scope->queryType()); + self::assertSame('', $scope->label()); + } + + /** + * Ruling X2: repeater_ rows need the acf_type setting the rules refuse (X7e); Divi's + * renderer knows no "terms"/"users" aliases for the content (LoopUtils.php:3130-3148), and sanitize_key() + * turns "" or an array into "" – a loop the tools do not support: no kind, every loop_* refused. + */ + public function testOtherQueryTypesAreALoopWithoutKind(): void + { + foreach (['repeater_team', 'terms', 'users', '', 'Post_Types', 'post_types '] as $queryType) { + $scope = LoopScope::ofQueryType($queryType); + + self::assertFalse($scope->active(), $queryType); + self::assertNull($scope->kind(), $queryType); + self::assertSame($queryType, $scope->queryType(), $queryType); + self::assertSame($queryType, $scope->label(), $queryType); + } + } + + /** @return array */ + public static function optionKinds(): array + { + return [ + 'post title' => ['loop_post_title', LoopScope::POST], + 'post terms' => ['loop_post_terms', LoopScope::POST], + 'post meta' => ['loop_post_meta_key_manual_custom_field', LoopScope::POST], + 'product (post loop of products)' => ['loop_product_price_current', LoopScope::POST], + 'term name (key, not the id "loop_name")' => ['loop_term_name', LoopScope::TERM], + 'term meta' => ['loop_term_meta_key_manual_custom_field', LoopScope::TERM], + 'user avatar' => ['loop_user_avatar', LoopScope::USER], + 'user meta' => ['loop_user_meta_key_manual_custom_field', LoopScope::USER], + 'menu link' => ['loop_menu_link', LoopScope::MENU], + 'acf repeater row' => ['loop_acf_team|||name', null], + 'no loop option' => ['post_title', null], + 'prefix without underscore' => ['loopy_title', null], + ]; + } + + /** + * The kind an option NAME belongs to – Divi takes the meta type of loop__meta_key_* from the + * name too (DynamicContentOptionLoopPostMetaKey::get_meta_type_from_name, :334-345). + * + * @dataProvider optionKinds + */ + public function testTheOptionNameGivesItsKind(string $option, ?string $kind): void + { + self::assertSame($kind, LoopScope::kindOfOption($option)); + } + + public function testFitsOnlyAnOptionOfTheSameKind(): void + { + $terms = LoopScope::ofQueryType('post_taxonomies'); + + self::assertTrue($terms->fits('loop_term_name')); + self::assertTrue($terms->fits('loop_term_meta_key_manual_custom_field')); + self::assertFalse($terms->fits('loop_post_meta_key_manual_custom_field')); + self::assertFalse($terms->fits('loop_acf_team|||name')); + self::assertFalse($terms->fits('post_title')); + self::assertFalse(LoopScope::none()->fits('loop_post_title')); + self::assertFalse(LoopScope::ofQueryType('repeater_team')->fits('loop_post_title')); + self::assertTrue(LoopScope::ofQueryType('current_page')->fits('loop_post_title')); + } +}