Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
166 lines
5.6 KiB
PHP
166 lines
5.6 KiB
PHP
<?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).
|
||
*
|
||
* "terms" and "users" are Divi's aliases for term and user loops (LoopUtils.php:881-898; LoopContext
|
||
* normalises the queryType like sanitize_key first). Any other queryType – repeater_<ACF field>, "" – 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).
|
||
*
|
||
* Nested loops (audit M1): an empty inner loop hands its block unchanged to the outer loop, which then
|
||
* resolves the references with ITS object. So kinds() lists the kinds of all enabled loops of the chain,
|
||
* and a meta key option has to fit every one of them (fits()).
|
||
*
|
||
* 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,
|
||
'terms' => self::TERM,
|
||
'users' => self::USER,
|
||
];
|
||
|
||
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,
|
||
];
|
||
|
||
/** @param list<?string> $outerKinds Kinds of the enclosing enabled loops, innermost first */
|
||
private function __construct(private ?string $queryType, private array $outerKinds = [])
|
||
{
|
||
}
|
||
|
||
public static function none(): self
|
||
{
|
||
return new self(null);
|
||
}
|
||
|
||
public static function ofQueryType(string $queryType): self
|
||
{
|
||
return new self($queryType);
|
||
}
|
||
|
||
/**
|
||
* The same loop inside the loops of $outerKinds (innermost first; null = an unsupported loop).
|
||
*
|
||
* @param list<?string> $outerKinds
|
||
*/
|
||
public function inside(array $outerKinds): self
|
||
{
|
||
return new self($this->queryType, $outerKinds);
|
||
}
|
||
|
||
/**
|
||
* Kinds of all enabled loops of the chain, innermost (own) first; null for an unsupported loop; [] without.
|
||
*
|
||
* @return list<?string>
|
||
*/
|
||
public function kinds(): array
|
||
{
|
||
return $this->queryType === null ? [] : [$this->kind(), ...$this->outerKinds];
|
||
}
|
||
|
||
/**
|
||
* Identity of the context: the loop and the kinds of its whole chain (audit M1). Two scopes with the same key
|
||
* resolve every loop_* reference the same way; no loop is "-|".
|
||
*/
|
||
public function key(): string
|
||
{
|
||
return ($this->queryType ?? '-') . '|' . implode(',', array_map(
|
||
static fn (?string $kind): string => $kind ?? '?',
|
||
$this->kinds()
|
||
));
|
||
}
|
||
|
||
/** 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 – a meta key option (loop_<kind>_meta_key_*)
|
||
* to the kind of every loop of the chain (audit M1), since Divi reads meta by the ID of whatever loop
|
||
* object resolves it.
|
||
*/
|
||
public function fits(string $option): bool
|
||
{
|
||
$kind = self::kindOfOption($option);
|
||
if (! $this->active() || $kind !== $this->kind()) {
|
||
return false;
|
||
}
|
||
|
||
return ! str_contains($option, '_meta_key_') || array_unique($this->kinds()) === [$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;
|
||
}
|
||
}
|