diff --git a/src/Integrations/Divi/Design/AssignRewrite.php b/src/Integrations/Divi/Design/AssignRewrite.php new file mode 100644 index 0000000..ee25ec2 --- /dev/null +++ b/src/Integrations/Divi/Design/AssignRewrite.php @@ -0,0 +1,94 @@ +selects($block)) { + return null; + } + + $attrs = $block->attrs(); + $result = $this->assignment->apply($this->module, $attrs, $this->spec, 'node'); + if ($result['errors'] !== []) { + throw new LogicException('The preset assignment was not validated: ' . implode('; ', $result['errors'])); + } + + $new = $this->clearOverrides ? $this->assignment->clearOverrides($result['attrs']) : $result['attrs']; + + return self::same($attrs, $new) ? null : $new; + } + + public function notes(Block $block, string $nodeId): array + { + if ($this->clearOverrides) { + return []; + } + + return $this->assignment->overrides()->describe($nodeId, $block->attrs(), $this->assignment->names()); + } + + public function concernsInstance(Block $instance): bool + { + return GlobalInstance::effectiveName($instance) === $this->module; + } + + private function selects(Block $block): bool + { + if ($block->name() !== $this->module) { + return false; + } + + $stack = NodeSummary::presetIds($block); + if ($this->onlyWithoutPreset && array_diff($stack, PresetUsageMatcher::DEFAULT_IDS) !== []) { + return false; + } + + return $this->onlyWithPresetId === null || in_array($this->onlyWithPresetId, $stack, true); + } + + /** @param array $before @param array $after */ + private static function same(array $before, array $after): bool + { + try { + return Json::encode($before) === Json::encode($after); + } catch (JsonException) { + return false; + } + } +} diff --git a/src/Integrations/Divi/Design/DetachRewrite.php b/src/Integrations/Divi/Design/DetachRewrite.php new file mode 100644 index 0000000..076bb10 --- /dev/null +++ b/src/Integrations/Divi/Design/DetachRewrite.php @@ -0,0 +1,146 @@ +isDivi()) { + return null; + } + + return $this->scope->kind === PresetScope::MODULE + ? $this->withoutModulePreset($block) + : $this->withoutGroupPreset($block->attrs()); + } + + public function notes(Block $block, string $nodeId): array + { + return []; + } + + public function concernsInstance(Block $instance): bool + { + return self::mentions($instance, $this->id); + } + + /** @return array|null */ + private function withoutModulePreset(Block $block): ?array + { + $attrs = $block->attrs(); + $stack = self::idList($attrs['modulePreset'] ?? null); + if ($block->name() !== $this->scope->name || ! in_array($this->id, $stack, true)) { + return null; + } + + $rest = $this->without($stack); + if ($rest === []) { + unset($attrs['modulePreset']); + } else { + $attrs['modulePreset'] = $rest; + } + + return $attrs; + } + + /** + * @param array $attrs + * @return array|null + */ + private function withoutGroupPreset(array $attrs): ?array + { + $groups = $attrs['groupPreset'] ?? null; + if (! is_array($groups)) { + return null; + } + + $changed = false; + foreach ($groups as $groupId => $entry) { + if (! is_array($entry) || ($entry['groupName'] ?? null) !== $this->scope->name) { + continue; + } + $ids = self::idList($entry['presetId'] ?? null); + if (! in_array($this->id, $ids, true)) { + continue; + } + + $changed = true; + $rest = $this->without($ids); + if ($rest === []) { + unset($groups[$groupId]); + } else { + $groups[$groupId]['presetId'] = $rest; + } + } + + if (! $changed) { + return null; + } + + if ($groups === []) { + unset($attrs['groupPreset']); + } else { + $attrs['groupPreset'] = $groups; + } + + return $attrs; + } + + /** @return list The IDs of a stored stack: a list, the legacy single string, else none. */ + private static function idList(mixed $value): array + { + if (is_string($value)) { + return [$value]; + } + + return is_array($value) ? array_values($value) : []; + } + + /** + * @param array $ids + * @return list + */ + private function without(array $ids): array + { + return array_values(array_filter($ids, fn (mixed $item): bool => $item !== $this->id)); + } + + /** Whether the attributes of $block or of any block below mention $id (fail closed on unencodable values). */ + private static function mentions(Block $block, string $id): bool + { + try { + if (str_contains(Json::encode($block->attrs()), $id)) { + return true; + } + } catch (JsonException) { + return true; + } + + foreach ($block->children() as $child) { + if (self::mentions($child, $id)) { + return true; + } + } + + return false; + } +} diff --git a/src/Integrations/Divi/Design/NodeRewrite.php b/src/Integrations/Divi/Design/NodeRewrite.php new file mode 100644 index 0000000..92aa8c8 --- /dev/null +++ b/src/Integrations/Divi/Design/NodeRewrite.php @@ -0,0 +1,30 @@ + + */ + public function notes(Block $block, string $nodeId): array; + + /** Whether the opaque global instance $instance would matter for this change (then it is reported as skipped). */ + public function concernsInstance(Block $instance): bool; +} diff --git a/src/Integrations/Divi/Design/TreeRewrite.php b/src/Integrations/Divi/Design/TreeRewrite.php new file mode 100644 index 0000000..86f068c --- /dev/null +++ b/src/Integrations/Divi/Design/TreeRewrite.php @@ -0,0 +1,69 @@ +, warnings: list, + * skipped: list} + */ + public function apply(NodeIndex $index): array + { + $changed = []; + $warnings = []; + $skipped = []; + foreach ($index->entries() as $entry) { + $id = $entry['id']; + $block = $entry['block']; + + if ($block->isOpaque()) { + if ($this->rewrite->concernsInstance($block)) { + $skipped[] = ['id' => $id, 'reason' => self::reason($block)]; + } + continue; + } + + $attrs = $this->rewrite->rewrite($block); + if ($attrs === null) { + continue; + } + + $block->setAttrs($attrs); + $changed[] = ['id' => $id, 'module' => $block->name(), 'label' => NodeSummary::label($block)]; + array_push($warnings, ...$this->rewrite->notes($block, $id)); + } + + return ['changed' => $changed, 'warnings' => $warnings, 'skipped' => $skipped]; + } + + private static function reason(Block $block): string + { + $ref = NodeSummary::globalRef($block); + if ($ref !== null) { + return sprintf('globales Modul – im Bibliothekseintrag %s ändern', $ref['id']); + } + + return $block->isGlobal() + ? 'globales Modul ohne Bibliothekseintrag – im Divi-Builder prüfen' + : 'Attribute nicht lesbar – im Divi-Builder prüfen'; + } +} diff --git a/tests/Unit/Integrations/Divi/Design/AssignRewriteTest.php b/tests/Unit/Integrations/Divi/Design/AssignRewriteTest.php new file mode 100644 index 0000000..ac54c7d --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/AssignRewriteTest.php @@ -0,0 +1,209 @@ + 'divi/button', 'preset' => 'aaaaaaaaaa']; + + /** PresetBookTest::stored() with settings in "CTA primär" (aaaaaaaaaa): background colour #111111. */ + private static function stored(): array + { + $stored = PresetBookTest::stored(); + $stored['module']['divi/button']['items']['aaaaaaaaaa']['attrs'] = [ + 'button' => ['decoration' => ['background' => ['desktop' => ['value' => ['color' => '#111111']]]]], + ]; + + return $stored; + } + + private static function assignment(): PresetAssignment + { + return new PresetAssignment( + static fn (): PresetBook => PresetBook::fromDivi(self::stored()), + static fn (string $module): array => $module === 'divi/text' + ? ['content.decoration.bodyFont' => 'divi/font-body'] + : [], + static fn (string $id): bool => preg_match('/^[a-z0-9]{10}$/D', $id) === 1 + ); + } + + /** @return NodeIndex Blocks below s0.r0.c0 are m0, m1, … in the order of $inner. */ + private static function index(string $inner): NodeIndex + { + return NodeIndex::build((new Parser())->parse( + '' . $inner + . '' + )); + } + + private static function node(NodeIndex $index, string $id): Block + { + $block = $index->find('s0.r0.c0.' . $id); + self::assertNotNull($block); + + return $block; + } + + public function testAssignsTheStackToBlocksOfTheModuleOnly(): void + { + $index = self::index(''); + $rewrite = new AssignRewrite('divi/button', self::assignment(), ['preset' => ['aaaaaaaaaa', 'bbbbbbbbbb']]); + + $button = $rewrite->rewrite(self::node($index, 'm0')); + + self::assertSame(['aaaaaaaaaa', 'bbbbbbbbbb'], $button['modulePreset'] ?? null); + self::assertNull($rewrite->rewrite(self::node($index, 'm1')), 'another module'); + } + + public function testOnlyWithoutPresetSkipsBlocksWithAnExplicitPreset(): void + { + $index = self::index( + '' + . '' + . '' + ); + $rewrite = new AssignRewrite('divi/button', self::assignment(), self::BUTTON, onlyWithoutPreset: true); + + self::assertNull($rewrite->rewrite(self::node($index, 'm0'))); + self::assertSame(['aaaaaaaaaa'], $rewrite->rewrite(self::node($index, 'm1'))['modulePreset'] ?? null); + self::assertSame(['aaaaaaaaaa'], $rewrite->rewrite(self::node($index, 'm2'))['modulePreset'] ?? null); + } + + public function testOnlyWithPresetIdReplacesThatPreset(): void + { + $index = self::index( + '' + . '' + . '' + ); + $rewrite = new AssignRewrite('divi/button', self::assignment(), self::BUTTON, onlyWithPresetId: 'bbbbbbbbbb'); + + self::assertSame(['aaaaaaaaaa'], $rewrite->rewrite(self::node($index, 'm0'))['modulePreset'] ?? null); + self::assertNull($rewrite->rewrite(self::node($index, 'm1')), 'another preset'); + self::assertNull($rewrite->rewrite(self::node($index, 'm2')), 'no preset at all'); + } + + public function testBothFiltersMustHold(): void + { + $index = self::index(''); + $rewrite = new AssignRewrite( + 'divi/button', + self::assignment(), + self::BUTTON, + onlyWithoutPreset: true, + onlyWithPresetId: 'bbbbbbbbbb' + ); + + self::assertNull($rewrite->rewrite(self::node($index, 'm0'))); + } + + public function testRunningAgainChangesNothing(): void + { + $index = self::index(''); + $rewrite = new AssignRewrite('divi/button', self::assignment(), self::BUTTON); + + self::assertNull($rewrite->rewrite(self::node($index, 'm0'))); + } + + public function testGroupPresetsAreAssignedThroughTheKeyOfTheGroup(): void + { + $index = self::index(''); + $rewrite = new AssignRewrite( + 'divi/text', + self::assignment(), + ['group_presets' => ['font-body' => 'Lesetext']] + ); + + $attrs = $rewrite->rewrite(self::node($index, 'm0')); + + self::assertSame( + ['presetId' => ['gggggggggg'], 'groupName' => 'divi/font-body'], + $attrs['groupPreset']['content.decoration.bodyFont'] ?? null + ); + self::assertArrayNotHasKey('modulePreset', $attrs); + } + + public function testNotesDescribeWhatTheAssignedPresetCovers(): void + { + $index = self::index( + '' + . '' + ); + $rewrite = new AssignRewrite('divi/button', self::assignment(), self::BUTTON); + + $different = self::node($index, 'm0'); + $different->setAttrs($rewrite->rewrite($different) ?? []); + $notes = $rewrite->notes($different, 's0.r0.c0.m0'); + + self::assertCount(1, $notes); + self::assertStringContainsString('s0.r0.c0.m0: lokale Werte überdecken „CTA primär“', $notes[0]); + self::assertStringContainsString('clear_overrides: true entfernt sie.', $notes[0]); + + $same = self::node($index, 'm1'); + $same->setAttrs($rewrite->rewrite($same) ?? []); + self::assertSame([], $rewrite->notes($same, 's0.r0.c0.m1'), 'the same value covers nothing'); + } + + public function testClearOverridesRemovesTheCoveredValuesAndHasNoNotes(): void + { + $index = self::index( + '' + ); + $rewrite = new AssignRewrite('divi/button', self::assignment(), self::BUTTON, clearOverrides: true); + $block = self::node($index, 'm0'); + + $attrs = $rewrite->rewrite($block); + + self::assertSame(['aaaaaaaaaa'], $attrs['modulePreset'] ?? null); + self::assertArrayNotHasKey('button', $attrs, 'the local value and its empty parents are gone'); + $block->setAttrs($attrs); + self::assertSame([], $rewrite->notes($block, 's0.r0.c0.m0')); + } + + public function testClearOverridesAloneAlsoChangesABlockThatAlreadyHasThePreset(): void + { + $index = self::index( + '' + ); + $rewrite = new AssignRewrite('divi/button', self::assignment(), self::BUTTON, clearOverrides: true); + + $attrs = $rewrite->rewrite(self::node($index, 'm0')); + + self::assertSame(['modulePreset' => ['aaaaaaaaaa']], $attrs); + } + + public function testAnUnvalidatedSpecIsALogicError(): void + { + $index = self::index(''); + $rewrite = new AssignRewrite('divi/button', self::assignment(), ['preset' => 'Gibt es nicht']); + + $this->expectException(LogicException::class); + + $rewrite->rewrite(self::node($index, 'm0')); + } + + public function testGlobalInstancesOfTheModuleConcernTheRewrite(): void + { + $index = self::index( + '' + . '' + ); + $rewrite = new AssignRewrite('divi/button', self::assignment(), self::BUTTON); + + self::assertTrue($rewrite->concernsInstance(self::node($index, 'g0'))); + self::assertFalse($rewrite->concernsInstance(self::node($index, 'g1'))); + } +} diff --git a/tests/Unit/Integrations/Divi/Design/DetachRewriteTest.php b/tests/Unit/Integrations/Divi/Design/DetachRewriteTest.php new file mode 100644 index 0000000..51d9d5e --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/DetachRewriteTest.php @@ -0,0 +1,117 @@ +parse( + '' . $inner + . '' + )); + } + + private static function node(NodeIndex $index, string $id): Block + { + $block = $index->find('s0.r0.c0.' . $id); + self::assertNotNull($block); + + return $block; + } + + public function testRemovesAModulePresetFromTheStack(): void + { + $index = self::index( + '' + . '' + . '' + . '' + . '' + ); + $rewrite = new DetachRewrite(new PresetScope('module', 'divi/button'), 'aaaaaaaaaa'); + + self::assertSame(['modulePreset' => ['bbbbbbbbbb']], $rewrite->rewrite(self::node($index, 'm0'))); + self::assertSame(['x' => 1], $rewrite->rewrite(self::node($index, 'm1')), 'an empty stack leaves no key'); + self::assertNull($rewrite->rewrite(self::node($index, 'm2')), 'another module'); + self::assertSame([], $rewrite->rewrite(self::node($index, 'm3')), 'legacy string form'); + self::assertNull($rewrite->rewrite(self::node($index, 'm4')), 'the ID is not in the stack'); + } + + public function testRemovesAGroupPresetFromEveryEntryOfTheGroup(): void + { + $index = self::index( + '' + . '' + . '' + . '' + ); + $rewrite = new DetachRewrite(new PresetScope('group', 'divi/font-body'), 'gggggggggg'); + + $first = $rewrite->rewrite(self::node($index, 'm0')); + self::assertSame(['hhhhhhhhhh'], $first['groupPreset']['content.decoration.bodyFont']['presetId'] ?? null); + self::assertSame( + ['presetId' => ['gggggggggg'], 'groupName' => 'divi/spacing'], + $first['groupPreset']['module.decoration.spacing'] ?? null, + 'another group keeps its preset' + ); + self::assertSame(['x' => 1], $rewrite->rewrite(self::node($index, 'm1')), 'empty entry and map are removed'); + self::assertNull($rewrite->rewrite(self::node($index, 'm2')), 'the ID sits in another group'); + self::assertNull($rewrite->rewrite(self::node($index, 'x0')), 'not a Divi block'); + } + + public function testAnUntouchedBlockStaysNull(): void + { + $index = self::index(''); + + self::assertNull( + (new DetachRewrite(new PresetScope('module', 'divi/button'), 'aaaaaaaaaa'))->rewrite(self::node($index, 'm0')) + ); + self::assertNull( + (new DetachRewrite(new PresetScope('group', 'divi/font-body'), 'gggggggggg'))->rewrite(self::node($index, 'm1')) + ); + } + + public function testNotesAreEmpty(): void + { + $index = self::index(''); + + self::assertSame( + [], + (new DetachRewrite(new PresetScope('module', 'divi/button'), 'aaaaaaaaaa')) + ->notes(self::node($index, 'm0'), 's0.r0.c0.m0') + ); + } + + public function testInstancesThatMentionTheIdConcernTheRewrite(): void + { + $index = self::index( + '' + . '' + . '' + . '' + ); + $rewrite = new DetachRewrite(new PresetScope('module', 'divi/button'), 'aaaaaaaaaa'); + $other = new DetachRewrite(new PresetScope('module', 'divi/button'), 'bbbbbbbbbb'); + + self::assertTrue($rewrite->concernsInstance(self::node($index, 'g0')), 'localAttrs'); + self::assertTrue($rewrite->concernsInstance(self::node($index, 'g1')), 'snapshot of a child'); + self::assertFalse($other->concernsInstance(self::node($index, 'g0'))); + } +} diff --git a/tests/Unit/Integrations/Divi/Design/TreeRewriteTest.php b/tests/Unit/Integrations/Divi/Design/TreeRewriteTest.php new file mode 100644 index 0000000..bcfabf3 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/TreeRewriteTest.php @@ -0,0 +1,122 @@ +parse( + '' . $inner + . '' + ); + } + + /** Marks every divi/button; refuses opaque blocks, like the real strategies must never be offered one. */ + private static function touchButtons(): NodeRewrite + { + return new class implements NodeRewrite { + public function rewrite(Block $block): ?array + { + if ($block->isOpaque()) { + throw new \LogicException('An opaque block was offered to rewrite().'); + } + + return $block->name() === 'divi/button' ? $block->attrs() + ['touched' => true] : null; + } + + public function notes(Block $block, string $nodeId): array + { + return ['Hinweis ' . $nodeId]; + } + + public function concernsInstance(Block $instance): bool + { + return GlobalInstance::effectiveName($instance) === 'divi/button'; + } + }; + } + + public function testChangesBlocksAndReportsThem(): void + { + $document = self::document( + '' + . '' + ); + + $result = (new TreeRewrite(self::touchButtons()))->apply(NodeIndex::build($document)); + + self::assertSame([ + ['id' => 's0.r0.c0.m0', 'module' => 'divi/button', 'label' => 'Kaufen'], + ['id' => 's0.r0.c0.m2', 'module' => 'divi/button', 'label' => ''], + ], $result['changed']); + self::assertSame(['Hinweis s0.r0.c0.m0', 'Hinweis s0.r0.c0.m2'], $result['warnings']); + self::assertSame([], $result['skipped']); + self::assertSame(2, substr_count(Serializer::document($document), '"touched":true')); + } + + public function testNothingToDoChangesNothing(): void + { + $document = self::document(''); + $before = Serializer::document($document); + + $result = (new TreeRewrite(self::touchButtons()))->apply(NodeIndex::build($document)); + + self::assertSame(['changed' => [], 'warnings' => [], 'skipped' => []], $result); + self::assertSame($before, Serializer::document($document)); + } + + public function testGlobalInstancesAreSkippedWithTheLibraryEntry(): void + { + $document = self::document( + '' + . '' + ); + $before = Serializer::document($document); + + $result = (new TreeRewrite(self::touchButtons()))->apply(NodeIndex::build($document)); + + self::assertSame([], $result['changed']); + self::assertSame( + [['id' => 's0.r0.c0.g0', 'reason' => 'globales Modul – im Bibliothekseintrag 1058 ändern']], + $result['skipped'] + ); + self::assertSame($before, Serializer::document($document), 'instances are never rewritten'); + } + + public function testBlocksWithUnreadableAttributesAreSkippedToo(): void + { + $document = self::document(''); + + $result = (new TreeRewrite(self::touchButtons()))->apply(NodeIndex::build($document)); + + self::assertSame( + [['id' => 's0.r0.c0.m0', 'reason' => 'Attribute nicht lesbar – im Divi-Builder prüfen']], + $result['skipped'] + ); + } + + public function testAGlobalLayoutWithoutLibraryEntryHasItsOwnReason(): void + { + $document = self::document(''); + + $result = (new TreeRewrite(self::touchButtons()))->apply(NodeIndex::build($document)); + + self::assertSame( + [['id' => 's0.r0.c0.g0', 'reason' => 'globales Modul ohne Bibliothekseintrag – im Divi-Builder prüfen']], + $result['skipped'] + ); + } +}