From 617ad2888d74081d20a66f41425bc3b94bfbad27 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Mon, 19 Oct 2026 20:40:02 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20LoopContext=20=E2=80=93=20Schleifen-Kon?= =?UTF-8?q?text=20aus=20dem=20Blockbaum,=20innerste=20gewinnt=20(X1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/Integrations/Divi/Compose/LoopContext.php | 91 +++++++++ .../Divi/Compose/LoopContextTest.php | 185 ++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 src/Integrations/Divi/Compose/LoopContext.php create mode 100644 tests/Unit/Integrations/Divi/Compose/LoopContextTest.php diff --git a/src/Integrations/Divi/Compose/LoopContext.php b/src/Integrations/Divi/Compose/LoopContext.php new file mode 100644 index 0000000..3473116 --- /dev/null +++ b/src/Integrations/Divi/Compose/LoopContext.php @@ -0,0 +1,91 @@ + $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 $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> $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 $attrs + * @return array + */ + private static function settings(array $attrs): array + { + $value = AttrMap::desktop($attrs, self::PATH); + + return is_array($value) ? $value : []; + } +} diff --git a/tests/Unit/Integrations/Divi/Compose/LoopContextTest.php b/tests/Unit/Integrations/Divi/Compose/LoopContextTest.php new file mode 100644 index 0000000..3199882 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Compose/LoopContextTest.php @@ -0,0 +1,185 @@ + + */ + private static function attrs(mixed $loop): array + { + return ['module' => ['advanced' => ['loop' => $loop]], 'builderVersion' => '5.15.1']; + } + + /** + * @param array $value desktop.value of the loop + * @return array + */ + 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 = ''; + + return NodeIndex::build((new Parser())->parse( + '' + . '' + // s0.r0.c0: a post loop, a term loop group inside it, a disabled loop group inside that. + . '' . $text + . '' . $text + . '' . $text . '' + . '' + . '' + // s0.r0.c1: no loop; a global module instance claiming a loop (opaque: not read). + . '' . $text + . '' + . '' + . '' + // s1: the section itself is a user loop. + . '' + . '' . $text . '' + . '' + . '' + )); + } + + 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()); + } +}