feat: lit/divi-colors-set löst {{color:…}} mit relative gegen den geplanten Bestand auf, lehnt Zyklen ab und warnt vor Verwendungen in Presets und relativen Farben

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 14:07:54 +02:00
co-authored by Claude Sonnet 5.5
parent 337b87b572
commit 2fd03f3900
2 changed files with 259 additions and 11 deletions
@@ -9,7 +9,9 @@ use InvalidArgumentException;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Library\UsageReport;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use WP_Error;
@@ -20,8 +22,10 @@ defined('ABSPATH') || exit;
* lit/divi-colors-set (spec §7.1, rulings V3–V5, V8): creates and changes global colors through the change pipeline
* (ColorArea). An item names an existing color in id (ID, label, alias primary … or option name accent_color …) or
* creates one from label and color. Colors are never deleted, only deactivated; the five main colors only change
* their value. The dry run lists the posts that reference a changed, deactivated or activated color
* (ReferenceUsageQuery).
* their value, never to a reference. {{color:…}} with relative is resolved against the planned inventory – also
* colors of the same call, in item order – and stored as Divi reference (VariableRef); cycles and chains deeper than
* 10 are refused. The dry run lists the posts that reference a changed, deactivated or activated color
* (ReferenceUsageQuery) and warns about presets and relative colors built on it.
*/
final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
{
@@ -39,6 +43,17 @@ final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
private const NEW_ENTRY = ['color' => '', 'folder' => '', 'label' => '', 'lastUpdated' => '', 'status' => 'active',
'usedInPosts' => []];
private const PLACEHOLDER = '/^\{\{\s*color\s*:\s*([^{}]+?)\s*\}\}$/D';
/** Exception code of value(): the problem belongs to the item's relative, not to its color. */
private const RELATIVE = 1;
/** At most this many names per warning. */
private const LISTED = 10;
private const IN_USE = 'Die unter affected genannten Inhalte verweisen auf geänderte oder deaktivierte Farben; '
. 'deaktivierte Farben bleiben dort als Verweis stehen.';
/** @param (Closure(int): string)|null $random Random characters for new IDs (ColorBook::newId(); tests). */
public function __construct(
private DesignSystemWriter $writer,
@@ -172,6 +187,13 @@ final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
}
$book = $this->apply($book, $item, $field, $errors);
}
$cycle = $errors === [] ? $book->cycle() : null;
if ($cycle !== null) {
$errors['items'] = sprintf(
'Relative Farben bauen im Kreis aufeinander auf oder sind tiefer als 10 Stufen verschachtelt: %s',
implode(' → ', array_map($book->label(...), $cycle))
);
}
if ($errors !== []) {
return Errors::invalidInput('Ungültige Farb-Angaben; nichts wurde gespeichert.', $errors);
}
@@ -184,12 +206,14 @@ final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
}
}
$touched = array_values(array_unique($touched));
$report = $touched === [] ? null : $this->usages->reportFor(new ReferenceUsageQuery($touched));
return new DesignPlan(
$book->data(),
$diff,
ColorDiff::summary($diff),
$touched === [] ? null : $this->usages->reportFor(new ReferenceUsageQuery($touched))
$report,
$this->warnings($book, $diff, $report)
);
}
@@ -225,12 +249,15 @@ final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
'label' => self::cleanLabel($book, $item['label'], $id),
'folder' => self::folder($item['folder']),
'status' => self::status($item['status']),
default => self::value($item),
default => self::value($book, $item, $main),
};
} catch (InvalidArgumentException $e) {
$errors[$field . '.' . $key] = $e->getMessage();
$errors[$field . '.' . ($e->getCode() === self::RELATIVE ? 'relative' : $key)] = $e->getMessage();
}
}
if (array_key_exists('relative', $item) && ! array_key_exists('color', $item)) {
$errors[$field . '.relative'] = 'nur zusammen mit color als Platzhalter {{color:…}}';
}
if (count($errors) > $known || ($id !== null && $entry === $book->entry($id))) {
return $book;
}
@@ -313,22 +340,52 @@ final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
}
/**
* The stored value of $item's color (ruling V5): a checked fixed value, or {{color:…}} with relative resolved
* against $book (the planned inventory) into a Divi reference. A main color only takes a fixed value – Divi keeps
* it in et_divi, where a reference would break the Customizer.
*
* @param array<array-key, mixed> $item
* @throws InvalidArgumentException
* @throws InvalidArgumentException Code RELATIVE when the problem is in relative.
*/
private static function value(array $item): string
private static function value(ColorBook $book, array $item, bool $main): string
{
$color = $item['color'];
if (! is_string($color)) {
throw new InvalidArgumentException('Farbwert als Text erwartet');
}
$color = trim($color);
$problem = ColorValue::check($color);
if ($problem !== null) {
throw new InvalidArgumentException($problem);
if (preg_match(self::PLACEHOLDER, $color, $match) !== 1) {
if (array_key_exists('relative', $item)) {
throw new InvalidArgumentException('nur zusammen mit einem Platzhalter {{color:…}}', self::RELATIVE);
}
$problem = ColorValue::check($color);
if ($problem !== null) {
throw new InvalidArgumentException($problem);
}
return $color;
}
return $color;
if ($main) {
throw new InvalidArgumentException('Hauptfarben brauchen einen festen Farbwert, keinen Platzhalter');
}
$base = $book->find($match[1]);
if ($base === null) {
throw new InvalidArgumentException(sprintf('unbekannte Farbe „%s“ im Platzhalter', $match[1]));
}
$relative = $item['relative'] ?? null;
if (! is_array($relative)) {
throw new InvalidArgumentException(
'Pflicht mit einem Platzhalter: {hue, saturation, lightness, opacity}',
self::RELATIVE
);
}
$settings = ColorValue::relative($relative);
if (is_string($settings)) {
throw new InvalidArgumentException($settings, self::RELATIVE);
}
return VariableRef::serialize('color', $base, $settings);
}
private function newId(ColorBook $book): string
@@ -339,4 +396,57 @@ final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
return $id;
}
/**
* Ruling V8: presets and relative colors built on a changed, deactivated or activated color are warnings, never
* a block.
*
* @param list<array<string, mixed>> $diff
* @return list<string>
*/
private function warnings(ColorBook $book, array $diff, ?UsageReport $report): array
{
$presets = null;
$warnings = [];
$deactivates = false;
foreach ($diff as $row) {
if ($row['action'] === 'added') {
continue;
}
$deactivated = $row['action'] === 'deactivated';
$deactivates = $deactivates || $deactivated;
$presets ??= PresetBook::fromDivi($this->writer->presets());
$users = array_column($presets->referencing((string) $row['id']), 'name');
if ($users !== []) {
$warnings[] = sprintf(
$deactivated
? 'Farbe „%s“ wird deaktiviert, aber noch von Presets verwendet: %s.'
: 'Die Änderung an Farbe „%s“ wirkt auch auf die Presets %s.',
$row['name'],
self::names($users)
);
}
$derived = array_map($book->label(...), $book->referencing((string) $row['id']));
if ($derived !== []) {
$warnings[] = sprintf(
$deactivated
? 'Farbe „%1$s“ wird deaktiviert, aber die relativen Farben %2$s bauen auf ihr auf.'
: 'Die relativen Farben %2$s bauen auf Farbe „%1$s“ auf und ändern sich mit.',
$row['name'],
self::names($derived)
);
}
}
if ($deactivates && $report !== null && $report->exists()) {
$warnings[] = self::IN_USE;
}
return $warnings;
}
/** @param list<string> $names */
private static function names(array $names): string
{
return implode(', ', array_slice($names, 0, self::LISTED)) . (count($names) > self::LISTED ? ', …' : '');
}
}