feat: LoopScope – Schleifen-Art aus queryType und Optionsname (X1/X2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
f5790def97
commit
39dfb0f490
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The Divi loop a node sits in (rulings X1/X2) – an immutable value, pure PHP. Its kind comes from Divi's
|
||||
* queryType: post_types and current_page → post, post_taxonomies → term, user_roles → user, menus → menu
|
||||
* (the five values the Visual Builder offers; LoopUtils.php:730-739, QueryTypeController.php:89-96).
|
||||
*
|
||||
* Any other queryType – repeater_<ACF field>, 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_<post|user|term>_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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\Compose\LoopScope;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/** Rulings X1/X2: the loop kind of a node, from Divi's queryType (LoopUtils.php:730-739, VB "Zj"). */
|
||||
final class LoopScopeTest extends TestCase
|
||||
{
|
||||
/** @return array<string, array{string, string, string}> */
|
||||
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_<acf field> 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<string, array{string, string|null}> */
|
||||
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_<type>_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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user