feat: ColorBook als reiner Farbbestand mit Auflösung nach ID/Alias/Label, Verweisen, Zyklusprüfung und Änderungsmenge

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 13:57:23 +02:00
co-authored by Claude Sonnet 5.5
parent fb46078d42
commit 2428801bc9
2 changed files with 509 additions and 0 deletions
+265
View File
@@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use Closure;
use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
defined('ABSPATH') || exit;
/**
* The global colors in Divi's format (GlobalData::get_global_colors(), formats §1): id => {color, folder, label,
* lastUpdated, status, usedInPosts}. Takes the shape of get_global_colors() and of a snapshot alike (the same
* entries), fills missing keys and keeps only these six. The five customizer colors carry lastUpdated "" – Divi
* creates it fresh on every read, so design_rev and snapshots stay stable (ruling V3). Immutable – with() returns a
* new book. rev() is the design_rev of the area "colors". Pure PHP.
*/
final class ColorBook
{
/** Customizer color ID => option name in et_divi (GlobalData::$customizer_colors). */
public const CUSTOMIZER_IDS = [
'gcid-primary-color' => 'accent_color',
'gcid-secondary-color' => 'secondary_accent_color',
'gcid-heading-color' => 'header_color',
'gcid-body-color' => 'font_color',
'gcid-link-color' => 'link_color',
];
/** A color that refers to another one resolves at most this many references deep (Divi: depth 10). */
private const MAX_DEPTH = 10;
private const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
/** @param array<string, array{color: string, folder: string, label: string, lastUpdated: string,
* status: string, usedInPosts: list<mixed>}> $data */
private function __construct(private array $data)
{
}
/** @param array<array-key, mixed>|object $raw get_global_colors() or a snapshot – entries may be objects. */
public static function fromDivi(array|object $raw): self
{
$data = [];
foreach (self::plain($raw) as $id => $entry) {
if (is_array($entry)) {
$data[(string) $id] = self::normalize((string) $id, $entry);
}
}
return new self($data);
}
/** @return array<string, array{color: string, folder: string, label: string, lastUpdated: string,
* status: string, usedInPosts: list<mixed>}> */
public function data(): array
{
return $this->data;
}
/** Hash of the canonical JSON (keys sorted recursively), so the order of the entries does not matter. */
public function rev(): string
{
// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP (spec §4.4).
$json = (string) json_encode(self::sorted($this->data), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return Rev::of('colors' . "\0" . $json);
}
/** @return array{color: string, folder: string, label: string, lastUpdated: string, status: string,
* usedInPosts: list<mixed>}|null */
public function entry(string $id): ?array
{
return $this->data[$id] ?? null;
}
/** The label of $id, else $id itself. */
public function label(string $id): string
{
$label = $this->data[$id]['label'] ?? '';
return $label === '' ? $id : $label;
}
public function isCustomizer(string $id): bool
{
return isset(self::CUSTOMIZER_IDS[$id]) || ($this->data[$id]['folder'] ?? '') === 'customizer';
}
/**
* The ID of the color $needle names: an ID, else an alias (primary, secondary, heading, body, link), else the
* option name of a customizer color (accent_color …), else the one color of that label (case-insensitive).
*
* @throws InvalidArgumentException When several colors share the label (ruling S13a).
*/
public function find(string $needle): ?string
{
if (isset($this->data[$needle])) {
return $needle;
}
$key = mb_strtolower(trim($needle));
$alias = DesignSystem::COLOR_ALIASES[$key] ?? array_search($key, self::CUSTOMIZER_IDS, true);
if (is_string($alias) && isset($this->data[$alias])) {
return $alias;
}
$ids = [];
foreach ($this->data as $id => $entry) {
if ($key !== '' && mb_strtolower(trim($entry['label'])) === $key) {
$ids[] = $id;
}
}
if (count($ids) > 1) {
throw new InvalidArgumentException(sprintf(
'Name der globalen Farbe "%s" ist mehrdeutig; bitte die ID angeben: %s.',
trim($needle),
implode(', ', $ids)
));
}
return $ids[0] ?? null;
}
/** @param array<string, mixed> $entry Added or replaced; missing keys are filled like fromDivi() does. */
public function with(string $id, array $entry): self
{
$data = $this->data;
$data[$id] = self::normalize($id, $entry);
return new self($data);
}
/** @return list<string> IDs of the colors whose value is a reference to $id (relative colors). */
public function referencing(string $id): array
{
return array_keys(array_filter(
$this->data,
static fn (array $entry): bool => self::targetOf($entry['color']) === $id
));
}
/**
* The first chain of references that loops or is deeper than Divi resolves (more than 10 references), as the
* IDs from its start to its end (a loop ends with the ID it began to repeat); null when there is none.
*
* @return list<string>|null
*/
public function cycle(): ?array
{
foreach (array_keys($this->data) as $start) {
$chain = [$start];
$id = $start;
while (isset($this->data[$id]) && ($next = self::targetOf($this->data[$id]['color'])) !== null) {
$chain[] = $next;
if (in_array($next, array_slice($chain, 0, -1), true) || count($chain) - 1 > self::MAX_DEPTH) {
return $chain;
}
$id = $next;
}
}
return null;
}
/**
* New and changed entries compared to $before – what the normal write path hands to Divi (ruling V4). The
* customizer colors' lastUpdated is "" in both books and never counts.
*
* @return array<string, array{color: string, folder: string, label: string, lastUpdated: string,
* status: string, usedInPosts: list<mixed>}>
*/
public function changedSince(self $before): array
{
return array_filter(
$this->data,
static fn (array $entry, string $id): bool => $before->entry($id) !== $entry,
ARRAY_FILTER_USE_BOTH
);
}
/** A new color ID like the Visual Builder's: "gcid-" and 10 characters of [0-9a-z] (formats §1). */
public static function newId(?Closure $random = null): string
{
$random ??= static function (int $length): string {
$id = '';
for ($i = 0; $i < $length; ++$i) {
$id .= self::ALPHABET[random_int(0, 35)];
}
return $id;
};
return 'gcid-' . $random(10);
}
/** lastUpdated of a color written now: Y-m-d\TH:i:s.v\Z in UTC (formats §1). */
public static function now(): string
{
return (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s.v\Z');
}
/**
* @param array<array-key, mixed> $entry
* @return array{color: string, folder: string, label: string, lastUpdated: string, status: string,
* usedInPosts: list<mixed>}
*/
private static function normalize(string $id, array $entry): array
{
$text = static fn (string $key, string $default): string => is_scalar($entry[$key] ?? null)
? (string) $entry[$key]
: $default;
$folder = $text('folder', '');
return [
'color' => $text('color', ''),
'folder' => $folder,
'label' => $text('label', ''),
'lastUpdated' => isset(self::CUSTOMIZER_IDS[$id]) || $folder === 'customizer'
? ''
: $text('lastUpdated', ''),
'status' => $text('status', 'active'),
'usedInPosts' => is_array($entry['usedInPosts'] ?? null) ? array_values($entry['usedInPosts']) : [],
];
}
/** The color ID $color refers to when it is exactly one $variable(…)$ color reference, else null. */
private static function targetOf(string $color): ?string
{
if (VariableRef::typeOf($color) !== 'color') {
return null;
}
// typeOf() proved a whole reference: strip marker and end like Divi does, the JSON object is in between.
$payload = json_decode(substr($color, strlen(VariableRef::MARKER), -strlen(VariableRef::END)), true);
$name = is_array($payload) ? ($payload['value']['name'] ?? null) : null;
return is_string($name) ? $name : null;
}
private static function sorted(mixed $value): mixed
{
if (! is_array($value)) {
return $value;
}
$value = array_map([self::class, 'sorted'], $value);
if (! array_is_list($value)) {
ksort($value);
}
return $value;
}
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;
}
}