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:
@@ -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.',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user