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
@@ -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.',
],
];
}
}