diff --git a/docs/guides/overview.md b/docs/guides/overview.md index ede5509..a6ba907 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -19,7 +19,7 @@ des verbundenen WordPress-Benutzers. Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`, `lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`, -`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-divi-catalog`. +`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-divi-catalog`, `lit-divi-design-system`. Welche Tools direkt sichtbar sind, lässt sich unter Einstellungen → LIT MCP → Tools ändern (außer bei `lit-guide`, das immer direkt bleibt). diff --git a/src/Integrations/Divi/Design/DesignSystem.php b/src/Integrations/Divi/Design/DesignSystem.php new file mode 100644 index 0000000..ceaa554 --- /dev/null +++ b/src/Integrations/Divi/Design/DesignSystem.php @@ -0,0 +1,249 @@ + 'gcid-primary-color', + 'secondary' => 'gcid-secondary-color', + 'heading' => 'gcid-heading-color', + 'body' => 'gcid-body-color', + 'link' => 'gcid-link-color', + ]; + + /** Theme fonts as Divi stores them among the global font variables. */ + public const FONT_VARIABLES = ['heading' => '--et_global_heading_font', 'body' => '--et_global_body_font']; + + /** Theme option => Divi's default, read from the et_divi option. */ + public const SIZE_OPTIONS = [ + 'body_font_size' => '14', + 'body_header_size' => '30', + 'body_font_height' => '1.7', + 'body_header_height' => '1', + 'content_width' => '1080', + 'gutter_width' => '3', + ]; + + /** + * @param list $colors + * @param list $variables + * @param list $presets + * @param array{heading: string, body: string} $fonts + * @param array $sizes + */ + public function __construct( + private array $colors, + private array $variables, + private array $presets, + private array $fonts, + private array $sizes + ) { + } + + /** + * @param array $colors GlobalData::get_global_colors() + * @param array $variables GlobalData::get_global_variables() + * @param array $presets GlobalPreset::get_data() + * @param array $options get_option('et_divi') + */ + public static function fromDivi(array $colors, array $variables, array $presets, array $options): self + { + // Divi returns some groups as stdClass (e.g. every variable type) – work on plain arrays. + $colors = (array) self::plain($colors); + $variables = (array) self::plain($variables); + $presets = (array) self::plain($presets); + $colorList = []; + foreach ($colors as $id => $color) { + if (is_array($color) && ($color['status'] ?? 'active') === 'active' && is_string($color['color'] ?? null)) { + $colorList[] = [ + 'id' => (string) $id, + 'label' => is_string($color['label'] ?? null) ? $color['label'] : (string) $id, + 'value' => $color['color'], + ]; + } + } + + $variableList = []; + $fonts = ['heading' => '', 'body' => '']; + foreach ($variables as $type => $items) { + foreach (is_array($items) ? $items : [] as $id => $item) { + if (! is_array($item) || ($item['status'] ?? 'active') !== 'active') { + continue; + } + + $value = is_scalar($item['value'] ?? null) ? (string) $item['value'] : ''; + $font = array_search((string) $id, self::FONT_VARIABLES, true); + if ($font !== false) { + $fonts[$font] = $value; + continue; + } + + $variableList[] = [ + 'id' => (string) $id, + 'type' => (string) $type, + 'label' => is_string($item['label'] ?? null) ? $item['label'] : (string) $id, + 'value' => $value, + ]; + } + } + + $sizes = []; + foreach (self::SIZE_OPTIONS as $option => $default) { + $value = $options[$option] ?? null; + $sizes[$option] = is_scalar($value) && (string) $value !== '' ? (string) $value : $default; + } + + return new self($colorList, $variableList, self::presetList($presets), $fonts, $sizes); + } + + /** @return list */ + public function colors(): array + { + return $this->colors; + } + + /** @return list */ + public function variables(): array + { + return $this->variables; + } + + /** @return list */ + public function presets(): array + { + return $this->presets; + } + + /** @return array{heading: string, body: string} */ + public function fonts(): array + { + return $this->fonts; + } + + /** @return array */ + public function sizes(): array + { + return $this->sizes; + } + + /** gcid of an alias (primary …), an ID or a label (case-insensitive); null when unknown. */ + public function colorId(string $name): ?string + { + $key = mb_strtolower(trim($name)); + $alias = self::COLOR_ALIASES[$key] ?? null; + foreach ($this->colors as $color) { + if ($color['id'] === $alias || $color['id'] === trim($name) || mb_strtolower($color['label']) === $key) { + return $color['id']; + } + } + + return null; + } + + /** @return array{id: string, type: string, label: string, value: string}|null By label or ID. */ + public function variable(string $name): ?array + { + $key = mb_strtolower(trim($name)); + foreach ($this->variables as $variable) { + if ($variable['id'] === trim($name) || mb_strtolower($variable['label']) === $key) { + return $variable; + } + } + + return null; + } + + /** Module preset ID by name or ID for a full module name ("divi/button"); null when unknown. */ + public function modulePresetId(string $module, string $name): ?string + { + $key = mb_strtolower(trim($name)); + foreach ($this->presets as $preset) { + if ( + $preset['kind'] === 'module' + && $preset['module'] === $module + && ($preset['id'] === trim($name) || mb_strtolower($preset['name']) === $key) + ) { + return $preset['id']; + } + } + + return null; + } + + /** @return list Names of the module presets of $module. */ + public function modulePresetNames(string $module): array + { + $names = []; + foreach ($this->presets as $preset) { + if ($preset['kind'] === 'module' && $preset['module'] === $module) { + $names[] = $preset['name']; + } + } + + return $names; + } + + /** @return array Preset ID => name (all presets). */ + public function presetNames(): array + { + $names = []; + foreach ($this->presets as $preset) { + $names[$preset['id']] = $preset['name']; + } + + return $names; + } + + /** Placeholder that stands for a color in new content (spec §5.4). */ + public static function colorPlaceholder(string $id, string $label): string + { + $alias = array_search($id, self::COLOR_ALIASES, true); + + return '{{color:' . ($alias !== false ? $alias : $label) . '}}'; + } + + private static function plain(mixed $value): mixed + { + if (is_object($value)) { + $value = get_object_vars($value); + } + + return is_array($value) ? array_map([self::class, 'plain'], $value) : $value; + } + + /** + * @param array $data GlobalPreset::get_data(): + * {module|group: {name: {default, items: {id: item}}}} + * @return list + */ + private static function presetList(array $data): array + { + $list = []; + foreach (['module', 'group'] as $kind) { + foreach (is_array($data[$kind] ?? null) ? $data[$kind] : [] as $module => $record) { + $default = is_array($record) && is_string($record['default'] ?? null) ? $record['default'] : ''; + $items = is_array($record) && is_array($record['items'] ?? null) ? $record['items'] : []; + foreach ($items as $id => $item) { + $list[] = [ + 'kind' => $kind, + 'module' => (string) $module, + 'id' => (string) $id, + 'name' => is_array($item) && is_string($item['name'] ?? null) ? $item['name'] : (string) $id, + 'default' => (string) $id === $default, + ]; + } + } + } + + return $list; + } +} diff --git a/src/Integrations/Divi/Design/DesignSystemReader.php b/src/Integrations/Divi/Design/DesignSystemReader.php new file mode 100644 index 0000000..f23dfa1 --- /dev/null +++ b/src/Integrations/Divi/Design/DesignSystemReader.php @@ -0,0 +1,35 @@ +designSystem === null) { + $colors = GlobalData::get_global_colors(); + $variables = GlobalData::get_global_variables(); + $presets = GlobalPreset::get_data(); + $options = get_option('et_divi', []); + $this->designSystem = DesignSystem::fromDivi( + is_array($colors) ? $colors : [], + is_array($variables) ? $variables : [], + is_array($presets) ? $presets : [], + is_array($options) ? $options : [] + ); + } + + return $this->designSystem; + } +} diff --git a/src/Integrations/Divi/Design/DiviDesignSystemAbility.php b/src/Integrations/Divi/Design/DiviDesignSystemAbility.php new file mode 100644 index 0000000..76c30d5 --- /dev/null +++ b/src/Integrations/Divi/Design/DiviDesignSystemAbility.php @@ -0,0 +1,132 @@ + 'object', 'properties' => [], 'additionalProperties' => false]; + } + + public function outputSchema(): ?array + { + $entry = static fn (array $properties): array => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => $properties, + 'required' => array_keys($properties), + ], + ]; + + return [ + 'type' => 'object', + 'properties' => [ + 'colors' => $entry([ + 'id' => ['type' => 'string'], + 'label' => ['type' => 'string'], + 'value' => ['type' => 'string'], + 'placeholder' => ['type' => 'string'], + ]), + 'variables' => $entry([ + 'id' => ['type' => 'string'], + 'type' => ['type' => 'string'], + 'label' => ['type' => 'string'], + 'value' => ['type' => 'string'], + 'placeholder' => ['type' => 'string'], + ]), + 'fonts' => [ + 'type' => 'object', + 'properties' => ['heading' => ['type' => 'string'], 'body' => ['type' => 'string']], + 'required' => ['heading', 'body'], + ], + 'sizes' => ['type' => 'object'], + 'presets' => $entry([ + 'kind' => ['type' => 'string'], + 'module' => ['type' => 'string'], + 'id' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'default' => ['type' => 'boolean'], + ]), + 'notes' => ['type' => 'array', 'items' => ['type' => 'string']], + ], + 'required' => ['colors', 'variables', 'fonts', 'sizes', 'presets', 'notes'], + ]; + } + + public function annotations(): array + { + return ['readonly' => true, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return true; + } + + public function permission(array $input): bool|WP_Error + { + return current_user_can('edit_posts') + ? true + : Errors::forbidden('Keine Berechtigung, Inhalte zu bearbeiten.'); + } + + public function execute(array $input): mixed + { + $design = $this->reader->read(); + + return [ + 'colors' => array_map( + static fn (array $color): array => $color + [ + 'placeholder' => DesignSystem::colorPlaceholder($color['id'], $color['label']), + ], + $design->colors() + ), + 'variables' => array_map( + static fn (array $variable): array => $variable + + ['placeholder' => '{{var:' . $variable['label'] . '}}'], + $design->variables() + ), + 'fonts' => $design->fonts(), + 'sizes' => $design->sizes(), + 'presets' => $design->presets(), + 'notes' => [ + 'Schriften: {{font:heading}} bzw. {{font:body}}; Presets in der Kurzform über "preset": "".', + 'Feste Farben oder Schriften nur verwenden, wenn der Nutzer es ausdrücklich möchte.', + ], + ]; + } +} diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 30cde14..eb351d2 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi; +use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; +use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility; use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility; use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LIT\MCP\Integrations\Integration; @@ -16,10 +18,12 @@ use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; final class DiviIntegration implements Integration { private DiviModules $modules; + private DesignSystemReader $design; public function __construct(private ContentSchema $schema) { $this->modules = new DiviModules(); + $this->design = new DesignSystemReader(); } public function id(): string @@ -36,6 +40,7 @@ final class DiviIntegration implements Integration { return [ new DiviCatalogAbility($this->modules), + new DiviDesignSystemAbility($this->design), ]; } } diff --git a/tests/Unit/Integrations/Divi/Design/DesignSystemTest.php b/tests/Unit/Integrations/Divi/Design/DesignSystemTest.php new file mode 100644 index 0000000..34f78e3 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/DesignSystemTest.php @@ -0,0 +1,95 @@ + ['color' => '#2ea3f2', 'label' => 'Primärfarbe', 'status' => 'active'], + 'gcid-body-color' => ['color' => '#666666', 'label' => 'Fließtextfarbe', 'status' => 'active'], + 'gcid-a1b2' => ['color' => '#0b3d59', 'label' => 'Nachtblau', 'status' => 'active'], + 'gcid-old' => ['color' => '#ff0000', 'label' => 'Alt', 'status' => 'inactive'], + ], + [ + 'numbers' => ['gvid-r1' => ['id' => 'gvid-r1', 'label' => 'Radius', 'value' => '8px', 'status' => 'active']], + 'strings' => ['gvid-s1' => ['id' => 'gvid-s1', 'label' => 'Telefon', 'value' => '0221 123', 'status' => 'active']], + // Divi returns every variable type as stdClass (verified in DDEV). + 'fonts' => (object) [ + '--et_global_heading_font' => ['id' => '--et_global_heading_font', 'label' => 'Überschrift', 'value' => 'Open Sans', 'status' => 'active'], + '--et_global_body_font' => ['id' => '--et_global_body_font', 'label' => 'Fließtext', 'value' => 'Lato', 'status' => 'active'], + ], + 'links' => new \stdClass(), + ], + [ + 'module' => ['divi/button' => ['default' => 'p-1', 'items' => [ + 'p-1' => ['id' => 'p-1', 'name' => 'Standard', 'moduleName' => 'divi/button'], + 'p-2' => ['id' => 'p-2', 'name' => 'CTA primär', 'moduleName' => 'divi/button'], + ]]], + 'group' => ['divi/font' => ['default' => '', 'items' => [ + 'g-1' => ['id' => 'g-1', 'name' => 'Fließtext groß', 'groupName' => 'divi/font'], + ]]], + ], + ['body_font_size' => '16'] + ); + } + + public function testReadsColorsVariablesFontsSizesAndPresets(): void + { + $design = self::sample(); + + self::assertSame( + ['id' => 'gcid-a1b2', 'label' => 'Nachtblau', 'value' => '#0b3d59'], + $design->colors()[2] + ); + self::assertCount(3, $design->colors()); + self::assertSame(['heading' => 'Open Sans', 'body' => 'Lato'], $design->fonts()); + self::assertSame( + [['id' => 'gvid-r1', 'type' => 'numbers', 'label' => 'Radius', 'value' => '8px'], + ['id' => 'gvid-s1', 'type' => 'strings', 'label' => 'Telefon', 'value' => '0221 123']], + $design->variables() + ); + self::assertSame('16', $design->sizes()['body_font_size']); + self::assertSame('30', $design->sizes()['body_header_size']); + self::assertSame( + ['kind' => 'module', 'module' => 'divi/button', 'id' => 'p-1', 'name' => 'Standard', 'default' => true], + $design->presets()[0] + ); + self::assertSame(['p-1' => 'Standard', 'p-2' => 'CTA primär', 'g-1' => 'Fließtext groß'], $design->presetNames()); + } + + public function testLooksUpByAliasLabelOrId(): void + { + $design = self::sample(); + + self::assertSame('gcid-primary-color', $design->colorId('primary')); + self::assertSame('gcid-a1b2', $design->colorId('nachtblau')); + self::assertSame('gcid-a1b2', $design->colorId('gcid-a1b2')); + self::assertNull($design->colorId('secondary')); + self::assertNull($design->colorId('Alt')); + self::assertSame('gvid-s1', $design->variable('telefon')['id'] ?? null); + self::assertSame('p-2', $design->modulePresetId('divi/button', 'cta PRIMÄR')); + self::assertNull($design->modulePresetId('divi/text', 'Standard')); + self::assertSame(['Standard', 'CTA primär'], $design->modulePresetNames('divi/button')); + self::assertSame('{{color:primary}}', DesignSystem::colorPlaceholder('gcid-primary-color', 'Primärfarbe')); + self::assertSame('{{color:Nachtblau}}', DesignSystem::colorPlaceholder('gcid-a1b2', 'Nachtblau')); + } + + public function testToleratesEmptyOrOddInput(): void + { + $design = DesignSystem::fromDivi([], ['fonts' => 'x'], ['module' => 'y'], []); + + self::assertSame([], $design->colors()); + self::assertSame(['heading' => '', 'body' => ''], $design->fonts()); + self::assertSame([], $design->presets()); + self::assertSame('14', $design->sizes()['body_font_size']); + } +} diff --git a/tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php b/tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php new file mode 100644 index 0000000..a26ed17 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php @@ -0,0 +1,43 @@ +allows('read')->andReturn(DesignSystemTest::sample()); + Functions\when('current_user_can')->justReturn(true); + $ability = new DiviDesignSystemAbility($reader); + + self::assertTrue($ability->permission([])); + $result = $ability->execute([]); + + self::assertSame('{{color:primary}}', $result['colors'][0]['placeholder']); + self::assertSame('{{color:Nachtblau}}', $result['colors'][2]['placeholder']); + self::assertSame('{{var:Telefon}}', $result['variables'][1]['placeholder']); + self::assertSame(['heading' => 'Open Sans', 'body' => 'Lato'], $result['fonts']); + self::assertSame('CTA primär', $result['presets'][1]['name']); + self::assertStringContainsString('{{font:heading}}', $result['notes'][0]); + self::assertTrue($ability->isDirect()); + self::assertSame(['colors', 'variables', 'fonts', 'sizes', 'presets', 'notes'], $ability->outputSchema()['required']); + } + + public function testNeedsEditPosts(): void + { + Functions\when('current_user_can')->justReturn(false); + + $result = (new DiviDesignSystemAbility(Mockery::mock(DesignSystemReader::class)))->permission([]); + + self::assertSame('lit_forbidden', $result->get_error_code()); + } +} diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index e8760ef..04ca224 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -15,6 +15,7 @@ final class DiviIntegrationTest extends TestCase /** Grows with every task that adds Divi abilities – keep in the order of abilities(). */ private const EXPECTED = [ 'lit/divi-catalog', + 'lit/divi-design-system', ]; public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void