feat: LoopContext – Schleifen-Kontext aus dem Blockbaum, innerste gewinnt (X1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
39dfb0f490
commit
617ad2888d
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The loop context of a node from the block tree (ruling X1), pure PHP. A block runs a loop exactly when
|
||||||
|
* attrs.module.advanced.loop.desktop.value.enable is the string "on" – Divi's render gate
|
||||||
|
* (LoopUtils::_is_block_loop_enabled, LoopUtils.php:4720-4724) and the Visual Builder read only that;
|
||||||
|
* tablet, phone, hover/sticky values and true/1 do not count. A missing queryType is post_types
|
||||||
|
* (LoopUtils.php:730-732). The nearest enabled ancestor-or-self wins whatever its kind: Divi resolves
|
||||||
|
* nested loops innermost first (LoopUtils.php:4576-4605).
|
||||||
|
*
|
||||||
|
* Limits (rulings X1, X7): loop settings that come only from a preset (LoopPresetAttrsMap) are not seen,
|
||||||
|
* and opaque blocks (global modules, foreign blocks) are not read – both leave the node without a loop,
|
||||||
|
* so loop_* options are refused there (fail closed).
|
||||||
|
*/
|
||||||
|
final class LoopContext
|
||||||
|
{
|
||||||
|
private const PATH = 'module.advanced.loop';
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $attrs Attributes of a block */
|
||||||
|
public static function enabled(array $attrs): bool
|
||||||
|
{
|
||||||
|
return (self::settings($attrs)['enable'] ?? null) === 'on';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The loop of exactly this block; null when it runs none.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $attrs Attributes of a block
|
||||||
|
*/
|
||||||
|
public static function ownScope(array $attrs): ?LoopScope
|
||||||
|
{
|
||||||
|
if (! self::enabled($attrs)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$queryType = self::settings($attrs)['queryType'] ?? 'post_types';
|
||||||
|
|
||||||
|
// Divi's sanitize_key() makes a non-string "" – a loop without a kind the tools support.
|
||||||
|
return LoopScope::ofQueryType(is_string($queryType) ? $queryType : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $attrChain Attributes from the root down to the node (inclusive)
|
||||||
|
*/
|
||||||
|
public static function fromChain(array $attrChain): LoopScope
|
||||||
|
{
|
||||||
|
foreach (array_reverse($attrChain) as $attrs) {
|
||||||
|
$scope = self::ownScope($attrs);
|
||||||
|
if ($scope !== null) {
|
||||||
|
return $scope;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return LoopScope::none();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The loop of the node $nodeId (path ID) or its nearest ancestor; none() for an unknown ID. */
|
||||||
|
public static function of(NodeIndex $index, string $nodeId): LoopScope
|
||||||
|
{
|
||||||
|
for ($node = $index->find($nodeId); $node !== null; $node = $index->parentBlock($node)) {
|
||||||
|
$scope = $node->isOpaque() ? null : self::ownScope($node->attrs());
|
||||||
|
if ($scope !== null) {
|
||||||
|
return $scope;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return LoopScope::none();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* desktop.value of module.advanced.loop; [] when it is no object.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $attrs
|
||||||
|
* @return array<array-key, mixed>
|
||||||
|
*/
|
||||||
|
private static function settings(array $attrs): array
|
||||||
|
{
|
||||||
|
$value = AttrMap::desktop($attrs, self::PATH);
|
||||||
|
|
||||||
|
return is_array($value) ? $value : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\LoopContext;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\LoopScope;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/** Ruling X1: the nearest ancestor-or-self with desktop.value.enable === "on" decides (LoopUtils.php:4720). */
|
||||||
|
final class LoopContextTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Block attributes with module.advanced.loop.
|
||||||
|
*
|
||||||
|
* @param mixed $loop The value of module.advanced.loop
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function attrs(mixed $loop): array
|
||||||
|
{
|
||||||
|
return ['module' => ['advanced' => ['loop' => $loop]], 'builderVersion' => '5.15.1'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $value desktop.value of the loop
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function loop(array $value): array
|
||||||
|
{
|
||||||
|
return self::attrs(['desktop' => ['value' => $value]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOnlyTheStringOnAtDesktopValueEnablesALoop(): void
|
||||||
|
{
|
||||||
|
self::assertTrue(LoopContext::enabled(self::loop(['enable' => 'on'])));
|
||||||
|
|
||||||
|
$off = [
|
||||||
|
'off' => self::loop(['enable' => 'off']),
|
||||||
|
'true' => self::loop(['enable' => true]),
|
||||||
|
'int 1' => self::loop(['enable' => 1]),
|
||||||
|
'"1"' => self::loop(['enable' => '1']),
|
||||||
|
'"true"' => self::loop(['enable' => 'true']),
|
||||||
|
'"ON"' => self::loop(['enable' => 'ON']),
|
||||||
|
'missing enable' => self::loop(['queryType' => 'post_types']),
|
||||||
|
'tablet only' => self::attrs(['tablet' => ['value' => ['enable' => 'on']]]),
|
||||||
|
'phone only' => self::attrs(['phone' => ['value' => ['enable' => 'on']]]),
|
||||||
|
'hover only' => self::attrs(['desktop' => ['hover' => ['enable' => 'on']]]),
|
||||||
|
'sticky only' => self::attrs(['desktop' => ['sticky' => ['enable' => 'on']]]),
|
||||||
|
'no loop' => ['builderVersion' => '5.15.1'],
|
||||||
|
'loop is a string' => self::attrs('on'),
|
||||||
|
'desktop is a string' => self::attrs(['desktop' => 'on']),
|
||||||
|
'value is a string' => self::attrs(['desktop' => ['value' => 'on']]),
|
||||||
|
'value is a list' => self::attrs(['desktop' => ['value' => ['on']]]),
|
||||||
|
'empty JSON object' => self::attrs(new \stdClass()),
|
||||||
|
'advanced is a string' => ['module' => ['advanced' => 'loop']],
|
||||||
|
];
|
||||||
|
foreach ($off as $case => $attrs) {
|
||||||
|
self::assertFalse(LoopContext::enabled($attrs), $case);
|
||||||
|
self::assertNull(LoopContext::ownScope($attrs), $case);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOwnScopeReadsTheQueryTypeWithPostTypesAsDefault(): void
|
||||||
|
{
|
||||||
|
self::assertSame('post_types', LoopContext::ownScope(self::loop(['enable' => 'on']))?->queryType());
|
||||||
|
self::assertSame(LoopScope::POST, LoopContext::ownScope(self::loop(['enable' => 'on']))?->kind());
|
||||||
|
self::assertSame(
|
||||||
|
'post_types',
|
||||||
|
LoopContext::ownScope(self::loop(['enable' => 'on', 'queryType' => null]))?->queryType()
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
LoopScope::TERM,
|
||||||
|
LoopContext::ownScope(self::loop(['enable' => 'on', 'queryType' => 'post_taxonomies']))?->kind()
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
LoopScope::USER,
|
||||||
|
LoopContext::ownScope(self::loop(['enable' => 'on', 'queryType' => 'user_roles']))?->kind()
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
LoopScope::MENU,
|
||||||
|
LoopContext::ownScope(self::loop(['enable' => 'on', 'queryType' => 'menus']))?->kind()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnUnknownOrMalformedQueryTypeIsALoopWithoutKind(): void
|
||||||
|
{
|
||||||
|
$repeater = LoopContext::ownScope(self::loop(['enable' => 'on', 'queryType' => 'repeater_team']));
|
||||||
|
self::assertNotNull($repeater);
|
||||||
|
self::assertFalse($repeater->active());
|
||||||
|
self::assertSame('repeater_team', $repeater->queryType());
|
||||||
|
|
||||||
|
// sanitize_key() turns a non-string into "" – no post_types default (LoopUtils.php:730-732 isset()).
|
||||||
|
$array = LoopContext::ownScope(self::loop(['enable' => 'on', 'queryType' => ['post_types']]));
|
||||||
|
self::assertNotNull($array);
|
||||||
|
self::assertFalse($array->active());
|
||||||
|
self::assertSame('', $array->queryType());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheInnermostEnabledLoopOfTheChainWins(): void
|
||||||
|
{
|
||||||
|
$posts = self::loop(['enable' => 'on', 'queryType' => 'post_types']);
|
||||||
|
$terms = self::loop(['enable' => 'on', 'queryType' => 'post_taxonomies']);
|
||||||
|
$plain = ['builderVersion' => '5.15.1'];
|
||||||
|
$disabled = self::loop(['enable' => 'off', 'queryType' => 'user_roles']);
|
||||||
|
|
||||||
|
self::assertSame(LoopScope::TERM, LoopContext::fromChain([$plain, $posts, $terms, $plain])->kind());
|
||||||
|
self::assertSame(LoopScope::POST, LoopContext::fromChain([$terms, $posts])->kind(), 'post loop inside terms');
|
||||||
|
self::assertSame(LoopScope::POST, LoopContext::fromChain([$posts, $disabled, $plain])->kind());
|
||||||
|
self::assertSame(LoopScope::POST, LoopContext::fromChain([$posts])->kind(), 'the node itself');
|
||||||
|
self::assertNull(LoopContext::fromChain([$plain, $disabled])->queryType());
|
||||||
|
self::assertNull(LoopContext::fromChain([])->queryType());
|
||||||
|
// An unsupported inner loop still hides the outer one: Divi resolves the inner loop first.
|
||||||
|
$repeater = self::loop(['enable' => 'on', 'queryType' => 'repeater_team']);
|
||||||
|
$inner = LoopContext::fromChain([$posts, $repeater, $plain]);
|
||||||
|
self::assertFalse($inner->active());
|
||||||
|
self::assertSame('repeater_team', $inner->queryType());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function page(): NodeIndex
|
||||||
|
{
|
||||||
|
$loop = static fn (string $queryType): string => '{"module":{"advanced":{"loop":{"desktop":{"value":'
|
||||||
|
. '{"enable":"on","loopId":"loop-lit4etest1","queryType":"' . $queryType . '"}}}}},'
|
||||||
|
. '"builderVersion":"5.15.1"}';
|
||||||
|
$off = '{"module":{"advanced":{"loop":{"desktop":{"value":{"enable":"off","queryType":"menus"}},'
|
||||||
|
. '"tablet":{"value":{"enable":"on"}}}}},"builderVersion":"5.15.1"}';
|
||||||
|
$text = '<!-- wp:divi/text {"builderVersion":"5.15.1"} /-->';
|
||||||
|
|
||||||
|
return NodeIndex::build((new Parser())->parse(
|
||||||
|
'<!-- wp:divi/placeholder -->'
|
||||||
|
. '<!-- wp:divi/section {"builderVersion":"5.15.1"} --><!-- wp:divi/row {"builderVersion":"5.15.1"} -->'
|
||||||
|
// s0.r0.c0: a post loop, a term loop group inside it, a disabled loop group inside that.
|
||||||
|
. '<!-- wp:divi/column ' . $loop('post_types') . ' -->' . $text
|
||||||
|
. '<!-- wp:divi/group ' . $loop('post_taxonomies') . ' -->' . $text
|
||||||
|
. '<!-- wp:divi/group ' . $off . ' -->' . $text . '<!-- /wp:divi/group -->'
|
||||||
|
. '<!-- /wp:divi/group -->'
|
||||||
|
. '<!-- /wp:divi/column -->'
|
||||||
|
// s0.r0.c1: no loop; a global module instance claiming a loop (opaque: not read).
|
||||||
|
. '<!-- wp:divi/column {"builderVersion":"5.15.1"} -->' . $text
|
||||||
|
. '<!-- wp:divi/text {"globalModule":"812","module":{"advanced":{"loop":{"desktop":{"value":'
|
||||||
|
. '{"enable":"on"}}}}},"builderVersion":"5.15.1"} /-->'
|
||||||
|
. '<!-- /wp:divi/column -->'
|
||||||
|
. '<!-- /wp:divi/row --><!-- /wp:divi/section -->'
|
||||||
|
// s1: the section itself is a user loop.
|
||||||
|
. '<!-- wp:divi/section ' . $loop('user_roles') . ' --><!-- wp:divi/row {"builderVersion":"5.15.1"} -->'
|
||||||
|
. '<!-- wp:divi/column {"builderVersion":"5.15.1"} -->' . $text . '<!-- /wp:divi/column -->'
|
||||||
|
. '<!-- /wp:divi/row --><!-- /wp:divi/section -->'
|
||||||
|
. '<!-- /wp:divi/placeholder -->'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheTreeWalksAncestorsOrSelfByPathId(): void
|
||||||
|
{
|
||||||
|
$index = self::page();
|
||||||
|
|
||||||
|
// The ids the cases below rely on.
|
||||||
|
self::assertSame('divi/group', $index->find('s0.r0.c0.m1.m1')?->name());
|
||||||
|
self::assertTrue($index->find('s0.r0.c1.g0')?->isOpaque());
|
||||||
|
|
||||||
|
$kinds = [
|
||||||
|
's0' => null,
|
||||||
|
's0.r0' => null,
|
||||||
|
's0.r0.c0' => LoopScope::POST,
|
||||||
|
's0.r0.c0.m0' => LoopScope::POST,
|
||||||
|
's0.r0.c0.m1' => LoopScope::TERM,
|
||||||
|
's0.r0.c0.m1.m0' => LoopScope::TERM,
|
||||||
|
's0.r0.c0.m1.m1' => LoopScope::TERM,
|
||||||
|
's0.r0.c0.m1.m1.m0' => LoopScope::TERM,
|
||||||
|
's0.r0.c1' => null,
|
||||||
|
's0.r0.c1.m0' => null,
|
||||||
|
's0.r0.c1.g0' => null,
|
||||||
|
's1' => LoopScope::USER,
|
||||||
|
's1.r0.c0.m0' => LoopScope::USER,
|
||||||
|
'nope' => null,
|
||||||
|
's9.r0' => null,
|
||||||
|
];
|
||||||
|
foreach ($kinds as $id => $kind) {
|
||||||
|
self::assertSame($kind, LoopContext::of($index, $id)->kind(), $id);
|
||||||
|
}
|
||||||
|
self::assertSame('post_taxonomies', LoopContext::of($index, 's0.r0.c0.m1.m1.m0')->queryType());
|
||||||
|
self::assertNull(LoopContext::of($index, 's0.r0.c1.g0')->queryType());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user