diff --git a/src/Integrations/Divi/Design/DesignSystemWriter.php b/src/Integrations/Divi/Design/DesignSystemWriter.php index d909a8b..80d7026 100644 --- a/src/Integrations/Divi/Design/DesignSystemWriter.php +++ b/src/Integrations/Divi/Design/DesignSystemWriter.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi\Design; use ET\Builder\Framework\UserRole\UserRole; use ET\Builder\Packages\Conversion\Conversion; use ET\Builder\Packages\GlobalData\GlobalPreset; +use ET\Builder\Packages\ModuleLibrary\ModuleRegistration; use LIT\MCP\Integrations\Divi\Release\Call; use LIT\MCP\Integrations\Divi\Release\DiviAdapter; use LIT\MCP\Integrations\Divi\Release\DiviLocked; @@ -51,6 +52,8 @@ class DesignSystemWriter implements DiviAdapter new Call(GlobalPreset::class . '::save_data', 1), new Call(Conversion::class . '::get_preset_attrs_mapping', 1), new Call(Conversion::class . '::get_preset_attrs', 3), + new Call(GlobalPreset::class . '::get_group_preset_default_attr', 1), + new Call(ModuleRegistration::class . '::get_module_settings', 1), new Call(UserRole::class . '::can_current_user_use_visual_builder', 0), ], mirrors: [ @@ -113,6 +116,33 @@ class DesignSystemWriter implements DiviAdapter return is_array($saved) ? $saved : []; } + /** + * The option groups of $moduleName that take group presets: attribute path (groupId) => group name, e.g. + * "module.decoration.spacing" => "divi/spacing" (formats §3). [] without Divi or for an unknown module. + * + * @return array + */ + public function groupsOf(string $moduleName): array + { + if (! class_exists(ModuleRegistration::class)) { + return []; + } + + $settings = ModuleRegistration::get_module_settings($moduleName); + if ($settings === null) { + return []; + } + + $groups = []; + foreach (GlobalPreset::get_group_preset_default_attr($settings) as $groupId => $group) { + if (is_array($group) && is_string($group['groupName'] ?? null)) { + $groups[(string) $groupId] = $group['groupName']; + } + } + + return $groups; + } + /** * The parts of $attrs a preset of $moduleName carries (Divi's preset attribute mapping): all, style * (styleAttrs) and render (renderAttrs: html + script). diff --git a/src/Integrations/Divi/Design/PresetComposer.php b/src/Integrations/Divi/Design/PresetComposer.php new file mode 100644 index 0000000..c5952fa --- /dev/null +++ b/src/Integrations/Divi/Design/PresetComposer.php @@ -0,0 +1,324 @@ +): array{all: array, style: array, + * render: array} $presetAttrs DesignSystemWriter::presetAttrs() + * @param Closure(string): array $groupsOf DesignSystemWriter::groupsOf() + * @param Closure(): int $now Milliseconds, like the builder's Date.now(). + */ + public function __construct( + private Composer $composer, + private StoredNodeGuard $guard, + private Closure $presetAttrs, + private Closure $groupsOf, + private string $version, + private Closure $now + ) { + } + + /** + * @param array|null $existing The preset to change, null for a new one. + * @param array $input One item of lit/divi-presets-set. + * @return array{item: array, errors: array, invalid_paths: list} + */ + public function compose( + PresetBook $book, + PresetScope $scope, + ?array $existing, + array $input, + string $field, + ?Block $source + ): array { + $errors = []; + $module = $this->moduleOf($scope, $existing, $input, $source); + if ($module === null) { + $errors[$field . '.source_module'] = 'Modul des Gruppen-Presets fehlt (source_module oder from_node)'; + + return ['item' => [], 'errors' => $errors, 'invalid_paths' => []]; + } + + $groupId = null; + if ($scope->kind === PresetScope::GROUP) { + $groupId = $this->groupIdOf($scope, $module, $existing, $input, $field, $errors); + if ($groupId === null) { + return ['item' => [], 'errors' => $errors, 'invalid_paths' => []]; + } + } + + $attrs = is_array($existing['attrs'] ?? null) ? $existing['attrs'] : []; + if ($source !== null) { + $problems = $this->guard->problems($source); + if ($problems !== []) { + $errors[$field . '.from_node'] = implode('; ', $problems); + } + $taken = ($this->presetAttrs)($module, $source->attrs())['all']; + $attrs = array_replace_recursive($attrs, $groupId === null ? $taken : self::below($taken, $groupId)); + } + + $change = array_intersect_key($input, ['set' => true, 'attrs' => true]); + if ($change !== []) { + $updated = $this->composer->update(Block::create($module, $attrs), $change, $field); + $attrs = $updated['attrs']; + $errors += $updated['errors']; + if ($updated['invalid_paths'] !== []) { + return ['item' => [], 'errors' => $errors, 'invalid_paths' => $updated['invalid_paths']]; + } + } + + $split = ($this->presetAttrs)($module, $attrs); + $allowed = AttrMap::flatten($split['all']); + foreach (array_keys(AttrMap::flatten($attrs)) as $path) { + if (! array_key_exists($path, $allowed)) { + $errors[$field . '.attrs.' . $path] = self::CONTENT; + } elseif ($groupId !== null && ! self::inGroup($path, $groupId)) { + $errors[$field . '.attrs.' . $path] = 'gehört nicht zur Gruppe ' . $groupId; + } + } + + $nested = $this->groupPresets($book, $scope, $module, $existing, $input, $field, $errors); + $name = $this->nameOf($existing, $input, $field, $errors); + + return [ + 'item' => $this->item($scope, $existing, $name, $module, $groupId, $attrs, $split, $nested), + 'errors' => $errors, + 'invalid_paths' => [], + ]; + } + + /** + * @param array|null $existing + * @param array $input + */ + private function moduleOf(PresetScope $scope, ?array $existing, array $input, ?Block $source): ?string + { + if ($scope->kind === PresetScope::MODULE) { + return $scope->name; + } + if (is_string($existing['moduleName'] ?? null)) { + return $existing['moduleName']; + } + if ($source !== null) { + return $source->name(); + } + $short = $input['source_module'] ?? null; + if (! is_string($short) || preg_match('/^[a-z0-9][a-z0-9-]*(\/[a-z0-9][a-z0-9-]*)?$/D', $short) !== 1) { + return null; + } + + return str_contains($short, '/') ? $short : 'divi/' . $short; + } + + /** + * @param array|null $existing + * @param array $input + * @param array $errors + */ + private function groupIdOf( + PresetScope $scope, + string $module, + ?array $existing, + array $input, + string $field, + array &$errors + ): ?string { + if (is_string($existing['groupId'] ?? null)) { + return $existing['groupId']; + } + + $paths = array_keys(array_filter( + ($this->groupsOf)($module), + static fn (string $group): bool => $group === $scope->name + )); + $wanted = $input['group_id'] ?? null; + if (is_string($wanted) && in_array($wanted, $paths, true)) { + return $wanted; + } + if (count($paths) === 1 && $wanted === null) { + return (string) $paths[0]; + } + + $errors[$field . '.group_id'] = $paths === [] + ? sprintf('%s hat keine Gruppe %s', $module, $scope->name) + : sprintf('mehrere Gruppen %s in %s – group_id angeben: %s', $scope->name, $module, implode(', ', $paths)); + + return null; + } + + /** + * @param array|null $existing + * @param array $input + * @param array $errors + * @return array, groupName: string}> + */ + private function groupPresets( + PresetBook $book, + PresetScope $scope, + string $module, + ?array $existing, + array $input, + string $field, + array &$errors + ): array { + $nested = is_array($existing['groupPresets'] ?? null) ? $existing['groupPresets'] : []; + $wanted = $input['group_presets'] ?? null; + if ($wanted === null) { + return $nested; + } + if ($scope->kind !== PresetScope::MODULE || ! is_array($wanted)) { + $errors[$field . '.group_presets'] = 'nur bei Modul-Presets, als Objekt Gruppe → Preset'; + + return $nested; + } + + $groups = ($this->groupsOf)($module); + foreach ($wanted as $group => $preset) { + $path = $field . '.group_presets.' . $group; + $groupId = self::findGroup($groups, (string) $group); + if ($groupId === null) { + $errors[$path] = sprintf('keine Gruppe "%s" in %s', $group, $module); + continue; + } + if ($preset === null) { + unset($nested[$groupId]); + continue; + } + try { + $found = is_string($preset) + ? $book->find(new PresetScope(PresetScope::GROUP, $groups[$groupId]), $preset) + : null; + } catch (InvalidArgumentException $e) { + $errors[$path] = $e->getMessage(); + continue; + } + if ($found === null) { + $errors[$path] = sprintf('unbekanntes Gruppen-Preset "%s" (%s)', (string) $preset, $groups[$groupId]); + continue; + } + $nested[$groupId] = ['presetId' => [(string) $found['id']], 'groupName' => $groups[$groupId]]; + } + + return $nested; + } + + /** @param array $groups groupId => groupName */ + private static function findGroup(array $groups, string $wanted): ?string + { + foreach ($groups as $groupId => $name) { + if ($wanted === $groupId || $wanted === $name || 'divi/' . $wanted === $name) { + return (string) $groupId; + } + } + + return null; + } + + /** + * @param array|null $existing + * @param array $input + * @param array $errors + */ + private function nameOf(?array $existing, array $input, string $field, array &$errors): string + { + foreach (['rename', 'name'] as $key) { + if (is_string($input[$key] ?? null) && trim($input[$key]) !== '') { + return trim($input[$key]); + } + } + if (is_string($existing['name'] ?? null)) { + return $existing['name']; + } + $errors[$field . '.name'] = 'Name des neuen Presets fehlt'; + + return ''; + } + + /** + * @param array|null $existing + * @param array $attrs + * @param array{all: array, style: array, render: array} $split + * @param array $nested + * @return array + */ + private function item( + PresetScope $scope, + ?array $existing, + string $name, + string $module, + ?string $groupId, + array $attrs, + array $split, + array $nested + ): array { + $now = ($this->now)(); + $item = ['type' => $scope->kind, 'id' => (string) ($existing['id'] ?? PresetBook::newId()), 'name' => $name]; + if ($groupId !== null) { + $item += ['groupName' => $scope->name, 'groupId' => $groupId]; + } + $item += [ + 'moduleName' => $module, + 'version' => $this->version, + 'created' => (int) ($existing['created'] ?? $now), + 'updated' => $now, + 'attrs' => $attrs, + ]; + if ($split['style'] !== []) { + $item['styleAttrs'] = $split['style']; + } + if ($split['render'] !== []) { + $item['renderAttrs'] = $split['render']; + } + if ($nested !== []) { + $item['groupPresets'] = $nested; + } + foreach (['priority', 'order'] as $key) { + if (isset($existing[$key])) { + $item[$key] = $existing[$key]; + } + } + + return $item; + } + + private static function inGroup(string $path, string $groupId): bool + { + return $path === $groupId || str_starts_with($path, $groupId . '.'); + } + + /** + * The part of $attrs below the dotted path $groupId. + * + * @param array $attrs + * @return array + */ + private static function below(array $attrs, string $groupId): array + { + $value = AttrMap::get($attrs, $groupId); + + return $value === null ? [] : AttrMap::set([], $groupId, $value); + } +} diff --git a/tests/Unit/Integrations/Divi/Design/DesignSystemWriterTest.php b/tests/Unit/Integrations/Divi/Design/DesignSystemWriterTest.php index 28fdd96..326efcd 100644 --- a/tests/Unit/Integrations/Divi/Design/DesignSystemWriterTest.php +++ b/tests/Unit/Integrations/Divi/Design/DesignSystemWriterTest.php @@ -27,6 +27,7 @@ final class DesignSystemWriterTest extends TestCase 'GlobalPreset::get_data()', 'GlobalPreset::prepare_data()', 'GlobalPreset::validate_preset_count()', 'GlobalPreset::get_deleted_preset_ids()', 'GlobalPreset::remove_preset_from_legacy_option()', 'GlobalPreset::save_data()', 'Conversion::get_preset_attrs_mapping()', 'Conversion::get_preset_attrs()', + 'GlobalPreset::get_group_preset_default_attr()', 'ModuleRegistration::get_module_settings()', 'UserRole::can_current_user_use_visual_builder()', ], array_map(static fn (Call $call): string => $call->label(), $manifest->calls) @@ -48,4 +49,9 @@ final class DesignSystemWriterTest extends TestCase { self::assertFalse((new DesignSystemWriter())->canUseVisualBuilder()); } + + public function testWithoutDiviAModuleHasNoGroups(): void + { + self::assertSame([], (new DesignSystemWriter())->groupsOf('divi/button')); + } } diff --git a/tests/Unit/Integrations/Divi/Design/PresetComposerTest.php b/tests/Unit/Integrations/Divi/Design/PresetComposerTest.php new file mode 100644 index 0000000..795d378 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/PresetComposerTest.php @@ -0,0 +1,198 @@ + $value) { + if (is_array($value)) { + $kept = array_diff_key($value, ['innerContent' => true]); + if ($kept !== []) { + $all[$key] = $kept; + } + } + } + + return ['all' => $all, 'style' => $all, 'render' => []]; + }; + } + + private static function composer(bool $unfiltered = true): PresetComposer + { + return new PresetComposer( + ComposeFixture::composer($unfiltered), + new StoredNodeGuard( + ComposeFixture::cleaner($unfiltered), + ComposeFixture::references(), + ComposeFixture::sanitizer($unfiltered) + ), + self::presetAttrs(), + static fn (string $module): array => [ + 'module.decoration.spacing' => 'divi/spacing', + 'button.decoration.font' => 'divi/font', + ], + '5.15.1', + static fn (): int => self::NOW + ); + } + + private static function book(): PresetBook + { + return PresetBook::fromDivi(PresetBookTest::stored()); + } + + public function testNewModulePresetFromAttrs(): void + { + $result = self::composer()->compose(self::book(), new PresetScope('module', 'divi/button'), null, [ + 'name' => 'CTA sekundär', + 'attrs' => ['button.decoration.background' => ['desktop' => ['color' => '#123456']]], + ], 'items[0]', null); + + self::assertSame([], $result['errors']); + $item = $result['item']; + self::assertMatchesRegularExpression('/^[0-9a-z]{10}$/', $item['id']); + self::assertSame(['module', 'CTA sekundär', 'divi/button', '5.15.1'], [$item['type'], $item['name'], + $item['moduleName'], $item['version']]); + self::assertSame([self::NOW, self::NOW], [$item['created'], $item['updated']]); + self::assertSame('#123456', $item['attrs']['button']['decoration']['background']['desktop']['value']['color']); + self::assertSame($item['attrs'], $item['styleAttrs']); + self::assertArrayNotHasKey('renderAttrs', $item); + } + + public function testContentPathsAreRefused(): void + { + $result = self::composer()->compose(self::book(), new PresetScope('module', 'divi/button'), null, [ + 'name' => 'Mit Text', + 'set' => ['text' => 'Jetzt buchen'], + ], 'items[0]', null); + + self::assertArrayHasKey('items[0].attrs.button.innerContent', $result['errors']); + self::assertStringContainsString('kein Preset-Attribut', $result['errors']['items[0].attrs.button.innerContent']); + } + + public function testChangingMergesKeepsIdCreatedAndOrder(): void + { + $existing = self::book()->items(new PresetScope('module', 'divi/button'))['aaaaaaaaaa']; + $existing['created'] = 5; + $existing['order'] = 3; + $existing['attrs'] = ['button' => ['decoration' => ['border' => ['desktop' => ['value' => ['radius' => '4px']]]]]]; + + $result = self::composer()->compose(self::book(), new PresetScope('module', 'divi/button'), $existing, [ + 'rename' => 'CTA Primär', + 'attrs' => ['button.decoration.background' => ['desktop' => ['color' => '#000000']]], + ], 'items[0]', null); + + self::assertSame([], $result['errors']); + self::assertSame(['aaaaaaaaaa', 'CTA Primär', 5, self::NOW, 3], [$result['item']['id'], $result['item']['name'], + $result['item']['created'], $result['item']['updated'], $result['item']['order']]); + self::assertSame('4px', $result['item']['attrs']['button']['decoration']['border']['desktop']['value']['radius']); + } + + public function testFromNodeTakesTheDesignButNotTheContent(): void + { + $node = Block::create('divi/button', [ + 'button' => [ + 'innerContent' => ['desktop' => ['value' => ['text' => 'Hallo']]], + 'decoration' => ['background' => ['desktop' => ['value' => ['color' => '#abcdef']]]], + ], + ]); + + $result = self::composer()->compose(self::book(), new PresetScope('module', 'divi/button'), null, [ + 'name' => 'Aus Seite', + ], 'items[0]', $node); + + self::assertSame([], $result['errors']); + self::assertSame('#abcdef', $result['item']['attrs']['button']['decoration']['background']['desktop']['value']['color']); + self::assertArrayNotHasKey('innerContent', $result['item']['attrs']['button']); + } + + public function testEditorCannotCarryBuilderOnlyValuesFromANode(): void + { + $node = Block::create('divi/button', [ + 'css' => ['desktop' => ['value' => ['mainElement' => 'color:red']]], + 'button' => ['decoration' => ['background' => ['desktop' => ['value' => ['color' => '#000000']]]]], + ]); + + $result = self::composer(false)->compose(self::book(), new PresetScope('module', 'divi/button'), null, [ + 'name' => 'Mit CSS', + ], 'items[0]', $node); + + self::assertArrayHasKey('items[0].from_node', $result['errors']); + } + + public function testGroupPresetOnlyTakesItsGroup(): void + { + $result = self::composer()->compose(self::book(), new PresetScope('group', 'divi/spacing'), null, [ + 'name' => 'Abstand groß', + 'source_module' => 'button', + 'attrs' => [ + 'module.decoration.spacing' => ['desktop' => ['padding' => ['top' => '40px']]], + 'button.decoration.background' => ['desktop' => ['color' => '#000000']], + ], + ], 'items[0]', null); + + self::assertSame( + 'gehört nicht zur Gruppe module.decoration.spacing', + $result['errors']['items[0].attrs.button.decoration.background'] + ); + self::assertSame(['group', 'divi/spacing', 'module.decoration.spacing', 'divi/button'], [ + $result['item']['type'], $result['item']['groupName'], $result['item']['groupId'], + $result['item']['moduleName'], + ]); + } + + public function testNestedGroupPresetsByShortName(): void + { + $book = self::book()->with(new PresetScope('group', 'divi/font'), [ + 'type' => 'group', 'id' => 'ffffffffff', 'name' => 'Button-Schrift', 'groupName' => 'divi/font', + 'groupId' => 'button.decoration.font', 'moduleName' => 'divi/button', 'attrs' => [], + ]); + + $result = self::composer()->compose($book, new PresetScope('module', 'divi/button'), null, [ + 'name' => 'Mit Schrift', + 'group_presets' => ['font' => 'Button-Schrift'], + ], 'items[0]', null); + + self::assertSame([], $result['errors']); + self::assertSame( + ['button.decoration.font' => ['presetId' => ['ffffffffff'], 'groupName' => 'divi/font']], + $result['item']['groupPresets'] + ); + } + + public function testUnknownGroupAndPresetAreErrors(): void + { + $result = self::composer()->compose(self::book(), new PresetScope('module', 'divi/button'), null, [ + 'name' => 'X', + 'group_presets' => ['sizing' => 'Gibt es nicht', 'font' => 'Fehlt'], + ], 'items[0]', null); + + self::assertStringContainsString('keine Gruppe', $result['errors']['items[0].group_presets.sizing']); + self::assertStringContainsString('unbekanntes Gruppen-Preset', $result['errors']['items[0].group_presets.font']); + } + + public function testNewPresetNeedsAName(): void + { + $result = self::composer()->compose(self::book(), new PresetScope('module', 'divi/button'), null, [], + 'items[0]', null); + + self::assertSame('Name des neuen Presets fehlt', $result['errors']['items[0].name']); + } +}