diff --git a/src/Integrations/Divi/Design/ColorBook.php b/src/Integrations/Divi/Design/ColorBook.php new file mode 100644 index 0000000..a3c5238 --- /dev/null +++ b/src/Integrations/Divi/Design/ColorBook.php @@ -0,0 +1,265 @@ + {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}> $data */ + private function __construct(private array $data) + { + } + + /** @param array|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}> */ + 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}|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 $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 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|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}> + */ + 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 $entry + * @return array{color: string, folder: string, label: string, lastUpdated: string, status: string, + * usedInPosts: list} + */ + 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; + } +} diff --git a/tests/Unit/Integrations/Divi/Design/ColorBookTest.php b/tests/Unit/Integrations/Divi/Design/ColorBookTest.php new file mode 100644 index 0000000..d7844fd --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/ColorBookTest.php @@ -0,0 +1,244 @@ +> The shape of GlobalData::get_global_colors() (formats §1). */ + public static function stored(string $customizerStamp = '2026-10-19T08:00:00.000Z'): array + { + $entry = static fn ( + string $color, + string $label, + string $folder = '', + string $stamp = '2026-09-27T22:21:39.000Z', + string $status = 'active' + ): array => [ + 'color' => $color, 'folder' => $folder, 'label' => $label, 'lastUpdated' => $stamp, + 'status' => $status, 'usedInPosts' => [], + ]; + + return [ + 'gcid-primary-color' => $entry('#2ea3f2', 'Primärfarbe', 'customizer', $customizerStamp), + 'gcid-secondary-color' => $entry('#2ea3f2', 'Sekundärfarbe', 'customizer', $customizerStamp), + 'gcid-q9swzy97df' => $entry('#5b3a29', 'Holz dunkel'), + 'gcid-989stx15zc' => $entry(self::ref('gcid-q9swzy97df', ['lightness' => 30]), 'Holz hell'), + 'gcid-aaaaaaaaaa' => $entry('#111111', 'Doppelt'), + 'gcid-bbbbbbbbbb' => $entry('#222222', 'doppelt', '', '2026-09-27T22:21:39.000Z', 'inactive'), + ]; + } + + /** @param array $settings */ + public static function ref(string $id, array $settings = []): string + { + return VariableRef::serialize('color', $id, $settings); + } + + public function testMissingKeysAreFilledAndOnlyTheSixKeysStay(): void + { + $book = ColorBook::fromDivi([ + 'gcid-x1' => ['color' => '#fff', 'extra' => 'dropped'], + 'gcid-x2' => (object) ['color' => '#000', 'label' => 'Schwarz', 'usedInPosts' => [4 => 12]], + 'gcid-x3' => 'not an entry', + ]); + + self::assertSame(['gcid-x1', 'gcid-x2'], array_keys($book->data())); + self::assertSame([ + 'color' => '#fff', 'folder' => '', 'label' => '', 'lastUpdated' => '', 'status' => 'active', + 'usedInPosts' => [], + ], $book->entry('gcid-x1')); + self::assertSame([12], $book->entry('gcid-x2')['usedInPosts']); + self::assertSame('Schwarz', $book->label('gcid-x2')); + self::assertSame('gcid-x1', $book->label('gcid-x1'), 'a color without a label shows its ID'); + self::assertNull($book->entry('gcid-nope')); + } + + public function testObjectInputIsAcceptedLikeAnArray(): void + { + $raw = new stdClass(); + $raw->{'gcid-x1'} = (object) ['color' => '#fff', 'status' => 'inactive']; + + self::assertSame(ColorBook::fromDivi(['gcid-x1' => ['color' => '#fff', 'status' => 'inactive']])->data(), + ColorBook::fromDivi($raw)->data()); + } + + public function testCustomizerLastUpdatedIsNormalizedSoRevStaysStable(): void + { + $now = ColorBook::fromDivi(self::stored('2026-10-19T08:00:00.000Z')); + $later = ColorBook::fromDivi(self::stored('2026-10-19T09:30:00.000Z')); + + self::assertSame('', $now->entry('gcid-primary-color')['lastUpdated']); + self::assertSame('2026-09-27T22:21:39.000Z', $now->entry('gcid-q9swzy97df')['lastUpdated']); + self::assertSame($now->rev(), $later->rev()); + self::assertSame($now->data(), $later->data()); + self::assertSame([], $later->changedSince($now)); + } + + public function testRevIgnoresTheOrderOfEntriesAndChangesWithTheContent(): void + { + $stored = self::stored(); + $book = ColorBook::fromDivi($stored); + $reversed = ColorBook::fromDivi(array_reverse($stored, true)); + + self::assertMatchesRegularExpression('/^[0-9a-f]{12}$/', $book->rev()); + self::assertSame($book->rev(), $reversed->rev()); + self::assertSame($book->rev(), ColorBook::fromDivi($stored)->rev()); + self::assertNotSame($book->rev(), $book->with('gcid-q9swzy97df', ['color' => '#000000'])->rev()); + } + + public function testRealDiviFixtureAndTheSnapshotOfItAreTheSame(): void + { + $json = (string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/stage4/global-colors.json'); + $fixture = json_decode($json, true); + $read = ColorBook::fromDivi($fixture['get_global_colors']); + $snapshot = ColorBook::fromDivi($read->data()); + + self::assertCount(9, $read->data()); + self::assertSame($read->rev(), $snapshot->rev()); + self::assertTrue($read->isCustomizer('gcid-link-color')); + self::assertFalse($read->isCustomizer('gcid-q9swzy97df')); + self::assertSame(['gcid-989stx15zc'], $read->referencing('gcid-q9swzy97df')); + self::assertNull($read->cycle()); + } + + public function testIsCustomizerByIdOrFolder(): void + { + $book = ColorBook::fromDivi([ + 'gcid-body-color' => ['color' => '#666'], + 'gcid-legacy' => ['color' => '#666', 'folder' => 'customizer'], + 'gcid-x1' => ['color' => '#666'], + ]); + + self::assertTrue($book->isCustomizer('gcid-body-color')); + self::assertTrue($book->isCustomizer('gcid-legacy')); + self::assertFalse($book->isCustomizer('gcid-x1')); + self::assertTrue($book->isCustomizer('gcid-heading-color'), 'a customizer ID counts even if it is missing'); + self::assertSame('', $book->entry('gcid-legacy')['lastUpdated']); + } + + public function testFindByIdAliasOptionNameAndLabel(): void + { + $book = ColorBook::fromDivi(self::stored()); + + self::assertSame('gcid-q9swzy97df', $book->find('gcid-q9swzy97df')); + self::assertSame('gcid-primary-color', $book->find('primary')); + self::assertSame('gcid-primary-color', $book->find('PRIMARY')); + self::assertSame('gcid-secondary-color', $book->find('secondary_accent_color')); + self::assertSame('gcid-q9swzy97df', $book->find(' holz DUNKEL ')); + self::assertSame('gcid-primary-color', $book->find('Primärfarbe')); + self::assertNull($book->find('heading'), 'an alias whose color does not exist finds nothing'); + self::assertNull($book->find('Fehlt')); + self::assertNull($book->find('')); + } + + public function testAmbiguousLabelListsTheIds(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Name der globalen Farbe "Doppelt" ist mehrdeutig; bitte die ID angeben: gcid-aaaaaaaaaa, gcid-bbbbbbbbbb.' + ); + + ColorBook::fromDivi(self::stored())->find('Doppelt'); + } + + public function testWithAddsOrReplacesAndLeavesTheOriginalAlone(): void + { + $book = ColorBook::fromDivi(self::stored()); + $added = $book->with('gcid-new0000001', ['color' => '#abc', 'label' => 'Neu']); + $replaced = $book->with('gcid-q9swzy97df', ['color' => '#000000', 'label' => 'Holz', 'status' => 'inactive']); + + self::assertCount(6, $book->data()); + self::assertCount(7, $added->data()); + self::assertSame('active', $added->entry('gcid-new0000001')['status']); + self::assertSame('inactive', $replaced->entry('gcid-q9swzy97df')['status']); + self::assertSame('#5b3a29', $book->entry('gcid-q9swzy97df')['color']); + self::assertSame('', $book->with('gcid-primary-color', ['color' => '#fff', 'lastUpdated' => 'x']) + ->entry('gcid-primary-color')['lastUpdated']); + } + + public function testReferencingFindsRelativeColorsIncludingSelfReferences(): void + { + $book = ColorBook::fromDivi(self::stored()) + ->with('gcid-self000001', ['color' => self::ref('gcid-self000001')]) + ->with('gcid-text000001', ['color' => 'Hinweis: gcid-q9swzy97df']); + + self::assertSame(['gcid-989stx15zc'], $book->referencing('gcid-q9swzy97df')); + self::assertSame(['gcid-self000001'], $book->referencing('gcid-self000001')); + self::assertSame([], $book->referencing('gcid-989stx15zc')); + } + + public function testCycleFindsLoopsAndSelfReferences(): void + { + $loop = ColorBook::fromDivi([ + 'gcid-a' => ['color' => self::ref('gcid-b')], + 'gcid-b' => ['color' => self::ref('gcid-c', ['hue' => 10])], + 'gcid-c' => ['color' => self::ref('gcid-b')], + ]); + $self = ColorBook::fromDivi(['gcid-a' => ['color' => self::ref('gcid-a')]]); + + self::assertSame(['gcid-a', 'gcid-b', 'gcid-c', 'gcid-b'], $loop->cycle()); + self::assertSame(['gcid-a', 'gcid-a'], $self->cycle()); + self::assertNull(ColorBook::fromDivi(self::stored())->cycle()); + } + + public function testCycleStopsAtMoreThanTenReferences(): void + { + $chain = static function (int $references): ColorBook { + $raw = []; + for ($i = 0; $i < $references; ++$i) { + $raw['gcid-c' . $i] = ['color' => self::ref('gcid-c' . ($i + 1))]; + } + $raw['gcid-c' . $references] = ['color' => '#123456']; + + return ColorBook::fromDivi($raw); + }; + + self::assertNull($chain(10)->cycle(), 'ten references still resolve in Divi'); + $tooDeep = $chain(11)->cycle(); + self::assertCount(12, $tooDeep); + self::assertSame(['gcid-c0', 'gcid-c1'], array_slice($tooDeep, 0, 2)); + } + + public function testReferenceToAnUnknownColorIsNoCycle(): void + { + self::assertNull(ColorBook::fromDivi(['gcid-a' => ['color' => self::ref('gcid-missing')]])->cycle()); + } + + public function testChangedSinceListsNewAndChangedEntriesOnly(): void + { + $before = ColorBook::fromDivi(self::stored()); + $after = $before + ->with('gcid-q9swzy97df', ['color' => '#000000', 'label' => 'Holz dunkel', 'lastUpdated' => 'x']) + ->with('gcid-new0000001', ['color' => '#abc', 'label' => 'Neu']) + ->with('gcid-aaaaaaaaaa', $before->entry('gcid-aaaaaaaaaa') ?? []); + + self::assertSame(['gcid-q9swzy97df', 'gcid-new0000001'], array_keys($after->changedSince($before))); + self::assertSame([], $before->changedSince($before)); + self::assertSame(['gcid-primary-color'], array_keys( + $before->with('gcid-primary-color', ['color' => '#ff0000', 'folder' => 'customizer'])->changedSince($before) + )); + } + + public function testNewIdHasTheBuilderFormat(): void + { + self::assertMatchesRegularExpression('/^gcid-[0-9a-z]{10}$/', ColorBook::newId()); + self::assertNotSame(ColorBook::newId(), ColorBook::newId()); + self::assertSame( + 'gcid-abcdefghij', + ColorBook::newId(static fn (int $length): string => substr('abcdefghijkl', 0, $length)) + ); + } + + public function testNowHasDiviTimestampFormat(): void + { + self::assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/', ColorBook::now()); + } +}