feat: lit/divi-theme-options-update ändert Theme-Optionen über DesignChange (Prüfung, Divi-Sanitizer, Schriftvorschläge)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 17:51:50 +02:00
co-authored by Claude Sonnet 5.5
parent 122548db12
commit 879ca752e7
4 changed files with 657 additions and 0 deletions
@@ -0,0 +1,246 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use WP_Error;
defined('ABSPATH') || exit;
/**
* lit/divi-theme-options-update (spec §8.1, rulings W2–W5, W7): sets or resets the whitelisted Divi theme options
* through the change pipeline (ThemeOptionsArea). set maps keys to values; null resets a key to Divi's default
* (et_delete_option), "none" and false are refused. Every value passes ThemeOptionSpec::check() (type, range, choice),
* fonts must be known to Divi exactly (FontCheck, with suggestions), and Divi's own sanitizer must keep the value –
* false or a changed value is an error, since Divi would store false or round silently; the value is stored with the
* sanitizer's type. A tablet/phone size equal to the size it inherits stays unset. All problems are collected with
* the path set.<key>. Theme options have no page reference: the dry run warns about the whole website instead.
*/
final class DiviThemeOptionsUpdateAbility implements Ability, RequiresDiviFeatures
{
private const RESET_HINT = 'zum Zurücksetzen den Wert null angeben';
private const BUILDER = 'Geänderte Schriften wirken auch im Divi-Builder (Standardschrift der Module).';
public function __construct(
private DesignSystemWriter $writer,
private ThemeOptionsAdapter $options,
private FontCatalog $fonts,
private DesignChange $change
) {
}
public function name(): string
{
return 'lit/divi-theme-options-update';
}
public function label(): string
{
return 'Divi-Theme-Optionen ändern';
}
public function description(): string
{
return 'Ändert Divis Theme-Optionen für Schriften, Schriftgrößen und Layout. set: {Schlüssel: Wert}; null '
. 'setzt auf den Divi-Standard zurück. Schlüssel, aktuelle Werte und Bereiche liefert '
. 'lit/divi-theme-options-get; Schriften mit dem exakten Namen einer Divi-Schrift (unbekannte Namen '
. 'bekommen Vorschläge). Die Hauptfarben ändert lit/divi-colors-set. Wirkt auf die gesamte Website: zuerst '
. 'Probelauf (diff, Warnungen, design_rev), nach Rückfrage beim Nutzer confirm: true mit design_rev. '
. 'Vorher wird ein Snapshot angelegt (lit/divi-design-restore).';
}
public function inputSchema(): array
{
$keys = [];
foreach (ThemeOptionSpec::KEYS as $key) {
$keys[$key] = self::keySchema($key);
}
return [
'type' => 'object',
'properties' => [
'set' => [
'type' => 'object',
'minProperties' => 1,
'properties' => $keys,
'additionalProperties' => false,
'description' => 'Theme-Option => neuer Wert; null = Divi-Standard.',
],
] + DesignChange::inputProperties(),
'required' => ['set'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return DesignChange::outputSchema();
}
public function annotations(): array
{
return Annotations::destructive(false);
}
public function isDirect(): bool
{
return false;
}
/** Reads et_divi and the Customizer table (ThemeOptionRead); sanitizer, font list and writing: ThemeOptionWrite. */
public function diviFeatures(array $input): array
{
return [DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite];
}
public function permission(array $input): bool|WP_Error
{
return DesignRights::themeOptions($this->writer, $this->options) ?? true;
}
public function execute(array $input): mixed
{
$refusal = DesignRights::themeOptions($this->writer, $this->options);
if ($refusal !== null) {
return $refusal;
}
$set = $input['set'] ?? null;
if (! is_array($set) || $set === [] || array_is_list($set)) {
return Errors::invalidInput(
'set fehlt oder ist leer; nichts wurde gespeichert.',
['set' => 'Objekt {Theme-Option: Wert}, Schlüssel aus lit/divi-theme-options-get']
);
}
return $this->change->run(
$this->name(),
new ThemeOptionsArea($this->options),
$input,
fn (array $raw): DesignPlan|WP_Error => $this->plan(ThemeOptionBook::fromDivi($raw), $set)
);
}
/** @param array<array-key, mixed> $set */
private function plan(ThemeOptionBook $before, array $set): DesignPlan|WP_Error
{
$errors = [];
ItemKeys::refuseUnknown($set, ThemeOptionSpec::KEYS, 'set', $errors, self::RESET_HINT);
$book = $before;
// In the fixed key order, so a tablet size is compared with the desktop size of this call.
foreach (ThemeOptionSpec::KEYS as $key) {
if (! array_key_exists($key, $set)) {
continue;
}
if ($set[$key] === null) {
$book = $book->without($key);
continue;
}
$value = $this->value($key, $set[$key], $errors);
if ($value !== null && ! self::inherited($book, $key, $value)) {
$book = $book->with($key, $value);
}
}
if ($errors !== []) {
return Errors::invalidInput('Ungültige Theme-Optionen; nichts wurde gespeichert.', $errors);
}
$diff = ThemeOptionDiff::between($before, $book);
return new DesignPlan($book->data(), $diff, ThemeOptionDiff::summary($diff), null, self::warnings($diff));
}
/**
* The value Divi stores for $key, or null after noting the problem in $errors.
*
* @param array<string, string> $errors
*/
private function value(string $key, mixed $value, array &$errors): int|float|string|null
{
$field = 'set.' . $key;
if ($value === false || $value === 'none') {
$errors[$field] = 'für den Divi-Standard null angeben, nicht false oder "none"';
return null;
}
$problem = ThemeOptionSpec::check($key, $value);
if ($problem === null && ThemeOptionSpec::definition($key)['type'] === 'font') {
// Before Divi's sanitizer: it would only answer false, without suggestions.
$problem = FontCheck::problem($this->fonts, (string) $value);
}
if ($problem !== null) {
$errors[$field] = $problem;
return null;
}
$normalized = ThemeOptionSpec::normalize($key, $value);
$sanitized = $this->options->sanitize($key, $normalized);
if (! is_int($sanitized) && ! is_float($sanitized) && ! is_string($sanitized)) {
$errors[$field] = 'Divi lehnt den Wert ab';
return null;
}
if (! ThemeOptionsArea::same($sanitized, $normalized)) {
$errors[$field] = sprintf('Divi würde den Wert verändert speichern (%s)', $sanitized);
return null;
}
return $sanitized;
}
/** Ruling W3: an unset tablet/phone size given the size it inherits stays unset – it keeps following that size. */
private static function inherited(ThemeOptionBook $book, string $key, int|float|string $value): bool
{
return isset(ThemeOptionSpec::definition($key)['inherits']) && ! $book->has($key)
&& ThemeOptionsArea::same(ThemeOptionSpec::effective($key, $book->data()), $value);
}
/**
* Ruling W7: no affected pages, but the reach of the change.
*
* @param list<array<string, mixed>> $diff
* @return list<string>
*/
private static function warnings(array $diff): array
{
if ($diff === []) {
return [];
}
foreach ($diff as $row) {
if (ThemeOptionSpec::definition((string) $row['key'])['type'] === 'font') {
return [ThemeOptionsArea::SITE_WIDE, self::BUILDER];
}
}
return [ThemeOptionsArea::SITE_WIDE];
}
/** @return array<string, mixed> The schema of one key: its types, the range of numbers, a description. */
private static function keySchema(string $key): array
{
$definition = ThemeOptionSpec::definition($key);
$schema = ['type' => match ($definition['type']) {
'int' => ['integer', 'string', 'null'],
'float' => ['number', 'string', 'null'],
'weight' => ['string', 'integer', 'null'],
default => ['string', 'null'],
}];
if (isset($definition['min'], $definition['max'])) {
$schema['minimum'] = $definition['min'];
$schema['maximum'] = $definition['max'];
}
$schema['description'] = $definition['label'] . (isset($definition['choices'])
? ': ' . implode(', ', $definition['choices'])
: '');
return $schema;
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
defined('ABSPATH') || exit;
/**
* Ruling W5: a font name is valid when Divi knows it exactly (FontCatalog); otherwise the message names up to five
* similar fonts (FontSuggest) or the upload tool. Shared by the theme options and the font variables (W12).
*/
final class FontCheck
{
/** @return string|null Null when $name is a font Divi knows, else a German message. */
public static function problem(FontCatalog $fonts, string $name): ?string
{
if ($fonts->has($name)) {
return null;
}
$similar = FontSuggest::similar($name, $fonts->names());
return sprintf('unbekannte Schrift „%s“', $name) . ($similar === []
? '; eigene Schriften lädt lit/divi-fonts-upload hoch'
: '; ähnlich: ' . implode(', ', $similar));
}
}