feat: read the Divi design system live – lit/divi-design-system

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 12:06:15 +02:00
co-authored by Claude Opus 5.5
parent 02ae76d4f3
commit a54a337e0b
8 changed files with 561 additions and 1 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ des verbundenen WordPress-Benutzers.
Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`, 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-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 Welche Tools direkt sichtbar sind, lässt sich unter Einstellungen → LIT MCP → Tools ändern (außer bei
`lit-guide`, das immer direkt bleibt). `lit-guide`, das immer direkt bleibt).
@@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
/**
* The site's Divi design system as read live from Divi (spec §2 principle 2, §5.7): global
* colors, global variables, theme fonts and sizes, module and group presets. Pure PHP –
* fromDivi() takes the raw arrays Divi returns. Placeholders (spec §5.4) resolve against it.
*/
final class DesignSystem
{
/** Aliases of the five customizer colors. */
public const COLOR_ALIASES = [
'primary' => '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<array{id: string, label: string, value: string}> $colors
* @param list<array{id: string, type: string, label: string, value: string}> $variables
* @param list<array{kind: string, module: string, id: string, name: string, default: bool}> $presets
* @param array{heading: string, body: string} $fonts
* @param array<string, string> $sizes
*/
public function __construct(
private array $colors,
private array $variables,
private array $presets,
private array $fonts,
private array $sizes
) {
}
/**
* @param array<array-key, mixed> $colors GlobalData::get_global_colors()
* @param array<array-key, mixed> $variables GlobalData::get_global_variables()
* @param array<array-key, mixed> $presets GlobalPreset::get_data()
* @param array<array-key, mixed> $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<array{id: string, label: string, value: string}> */
public function colors(): array
{
return $this->colors;
}
/** @return list<array{id: string, type: string, label: string, value: string}> */
public function variables(): array
{
return $this->variables;
}
/** @return list<array{kind: string, module: string, id: string, name: string, default: bool}> */
public function presets(): array
{
return $this->presets;
}
/** @return array{heading: string, body: string} */
public function fonts(): array
{
return $this->fonts;
}
/** @return array<string, string> */
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<string> 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<string, string> 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<array-key, mixed> $data GlobalPreset::get_data():
* {module|group: {name: {default, items: {id: item}}}}
* @return list<array{kind: string, module: string, id: string, name: string, default: bool}>
*/
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;
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use ET\Builder\Packages\GlobalData\GlobalData;
use ET\Builder\Packages\GlobalData\GlobalPreset;
/**
* Reads the design system from Divi's own APIs once per request (spec §2 principle 2 – no
* copy is ever stored). Not final so abilities can be unit-tested with a mock.
*/
class DesignSystemReader
{
private ?DesignSystem $designSystem = null;
public function read(): DesignSystem
{
if ($this->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;
}
}
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use WP_Error;
/**
* lit/divi-design-system: global colors, variables, theme fonts/sizes and presets with the
* placeholder to use for each (spec §5.4, §5.7). Read live from Divi; writing follows in
* Etappe 4.
*/
final class DiviDesignSystemAbility implements Ability
{
public function __construct(private DesignSystemReader $reader)
{
}
public function name(): string
{
return 'lit/divi-design-system';
}
public function label(): string
{
return 'Divi-Designsystem';
}
public function description(): string
{
return 'Liest das Designsystem der Website aus Divi: globale Farben, globale Variablen, Theme-Schriften und '
. '-Größen, Modul- und Gruppen-Presets – jeweils mit dem Platzhalter ({{color:…}}, {{var:…}}, '
. '{{font:…}}, preset), den neue Inhalte statt fester Werte verwenden sollen.';
}
public function inputSchema(): array
{
return ['type' => '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": "<Name>".',
'Feste Farben oder Schriften nur verwenden, wenn der Nutzer es ausdrücklich möchte.',
],
];
}
}
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi; 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\DiviCatalogAbility;
use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\Integration; use LIT\MCP\Integrations\Integration;
@@ -16,10 +18,12 @@ use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
final class DiviIntegration implements Integration final class DiviIntegration implements Integration
{ {
private DiviModules $modules; private DiviModules $modules;
private DesignSystemReader $design;
public function __construct(private ContentSchema $schema) public function __construct(private ContentSchema $schema)
{ {
$this->modules = new DiviModules(); $this->modules = new DiviModules();
$this->design = new DesignSystemReader();
} }
public function id(): string public function id(): string
@@ -36,6 +40,7 @@ final class DiviIntegration implements Integration
{ {
return [ return [
new DiviCatalogAbility($this->modules), new DiviCatalogAbility($this->modules),
new DiviDesignSystemAbility($this->design),
]; ];
} }
} }
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
use PHPUnit\Framework\TestCase;
final class DesignSystemTest extends TestCase
{
/** Shapes as GlobalData / GlobalPreset return them (Divi 5.2.1, verified in DDEV). */
public static function sample(): DesignSystem
{
return DesignSystem::fromDivi(
[
'gcid-primary-color' => ['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']);
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class DiviDesignSystemAbilityTest extends TestCase
{
public function testReturnsDesignSystemWithPlaceholders(): void
{
$reader = Mockery::mock(DesignSystemReader::class);
$reader->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());
}
}
@@ -15,6 +15,7 @@ final class DiviIntegrationTest extends TestCase
/** Grows with every task that adds Divi abilities – keep in the order of abilities(). */ /** Grows with every task that adds Divi abilities – keep in the order of abilities(). */
private const EXPECTED = [ private const EXPECTED = [
'lit/divi-catalog', 'lit/divi-catalog',
'lit/divi-design-system',
]; ];
public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void