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:
Jan-Niclas Loosen
2026-10-19 20:39:29 +02:00
co-authored by Claude Sonnet 5.5
parent f5790def97
commit 39dfb0f490
2 changed files with 222 additions and 0 deletions
+116
View File
@@ -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;
}
}