feat: PresetScope und PresetBook – Preset-Bestand im Divi-Speicherformat

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-16 17:27:27 +02:00
co-authored by Claude Haiku 5.5
parent 36af846b3e
commit 578ccba118
4 changed files with 471 additions and 0 deletions
+236
View File
@@ -0,0 +1,236 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use InvalidArgumentException;
use LIT\MCP\Abilities\Rev;
defined('ABSPATH') || exit;
/**
* The preset inventory in Divi's stored format (GlobalPreset::get_data(), formats §3):
* {module|group: {<name>: {default: id|"", items: {id: item}}}}. Immutable – with()/without()/withDefault() return a
* new book. rev() is the design_rev of the area "presets" (spec §2). toRest() gives the REST/sync format
* ({module|group: list<{default, items: list}>}) that GlobalPreset::prepare_data() takes. Pure PHP.
*/
final class PresetBook
{
private const KINDS = [PresetScope::MODULE, PresetScope::GROUP];
private const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
/** @param array{module: array<string, array{default: string, items: array<string, array<string, mixed>>}>,
* group: array<string, array{default: string, items: array<string, array<string, mixed>>}>} $data */
private function __construct(private array $data)
{
}
/** @param array<array-key, mixed>|object $raw GlobalPreset::get_data() – groups may be stdClass. */
public static function fromDivi(array|object $raw): self
{
$raw = self::plain($raw);
$data = [PresetScope::MODULE => [], PresetScope::GROUP => []];
foreach (self::KINDS as $kind) {
foreach (is_array($raw[$kind] ?? null) ? $raw[$kind] : [] as $name => $record) {
if (! is_array($record)) {
continue;
}
$items = [];
foreach (is_array($record['items'] ?? null) ? $record['items'] : [] as $id => $item) {
if (is_array($item)) {
$items[(string) $id] = $item;
}
}
$data[$kind][(string) $name] = [
'default' => is_string($record['default'] ?? null) ? $record['default'] : '',
'items' => $items,
];
}
}
return new self($data);
}
/** @return array<string, mixed> */
public function data(): array
{
return $this->data;
}
public function rev(): string
{
// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP (spec §4.4).
return Rev::of('presets' . "\0" . (string) json_encode($this->data, JSON_UNESCAPED_UNICODE));
}
public function count(): int
{
$count = 0;
foreach ($this->data as $records) {
foreach ($records as $record) {
$count += count($record['items']);
}
}
return $count;
}
/** @return array<string, array<string, mixed>> id => item */
public function items(PresetScope $scope): array
{
return $this->data[$scope->kind][$scope->name]['items'] ?? [];
}
public function defaultId(PresetScope $scope): string
{
return $this->data[$scope->kind][$scope->name]['default'] ?? '';
}
/**
* The preset with ID $idOrName, else the one preset named $idOrName (case-insensitive).
*
* @return array<string, mixed>|null
* @throws InvalidArgumentException When several presets of $scope share the name (ruling S13a).
*/
public function find(PresetScope $scope, string $idOrName): ?array
{
$items = $this->items($scope);
if (isset($items[$idOrName])) {
return $items[$idOrName];
}
$ids = $this->idsNamed($scope, $idOrName);
if (count($ids) > 1) {
throw new InvalidArgumentException(sprintf(
'Preset-Name "%s" ist mehrdeutig; bitte die ID angeben: %s.',
trim($idOrName),
implode(', ', $ids)
));
}
return $ids === [] ? null : $items[$ids[0]];
}
/** @return list<string> IDs of the presets of $scope named $name (case-insensitive, trimmed). */
public function idsNamed(PresetScope $scope, string $name): array
{
$key = mb_strtolower(trim($name));
$ids = [];
foreach ($this->items($scope) as $id => $item) {
if (is_string($item['name'] ?? null) && mb_strtolower(trim($item['name'])) === $key) {
$ids[] = (string) $id;
}
}
return $ids;
}
public function scopeOf(string $id): ?PresetScope
{
foreach ($this->data as $kind => $records) {
foreach ($records as $name => $record) {
if (isset($record['items'][$id])) {
return new PresetScope($kind, $name);
}
}
}
return null;
}
/** @param array<string, mixed> $item Needs a string "id"; added or replaced. */
public function with(PresetScope $scope, array $item): self
{
$data = $this->data;
$data[$scope->kind][$scope->name] ??= ['default' => '', 'items' => []];
$data[$scope->kind][$scope->name]['items'][(string) $item['id']] = $item;
return new self($data);
}
public function withDefault(PresetScope $scope, string $id): self
{
$data = $this->data;
$data[$scope->kind][$scope->name] ??= ['default' => '', 'items' => []];
$data[$scope->kind][$scope->name]['default'] = $id;
return new self($data);
}
/** Removes the preset; a default that pointed to it becomes "" (spec §6.4). */
public function without(PresetScope $scope, string $id): self
{
$data = $this->data;
unset($data[$scope->kind][$scope->name]['items'][$id]);
if (($data[$scope->kind][$scope->name]['default'] ?? '') === $id) {
$data[$scope->kind][$scope->name]['default'] = '';
}
return new self($data);
}
/**
* Presets that nest group preset $groupPresetId in their groupPresets (formats §3).
*
* @return list<array{scope: PresetScope, id: string, name: string}>
*/
public function nestedUsers(string $groupPresetId): array
{
$users = [];
foreach ($this->data as $kind => $records) {
foreach ($records as $name => $record) {
foreach ($record['items'] as $id => $item) {
foreach (is_array($item['groupPresets'] ?? null) ? $item['groupPresets'] : [] as $nested) {
$ids = is_array($nested) ? (array) ($nested['presetId'] ?? []) : [];
if (in_array($groupPresetId, $ids, true)) {
$users[] = [
'scope' => new PresetScope($kind, $name),
'id' => (string) $id,
'name' => is_string($item['name'] ?? null) ? $item['name'] : (string) $id,
];
break;
}
}
}
}
}
return $users;
}
/** @return array{module: list<array{default: string, items: list<array<string, mixed>>}>,
* group: list<array{default: string, items: list<array<string, mixed>>}>} */
public function toRest(): array
{
$rest = [PresetScope::MODULE => [], PresetScope::GROUP => []];
foreach ($this->data as $kind => $records) {
foreach ($records as $record) {
$rest[$kind][] = ['default' => $record['default'], 'items' => array_values($record['items'])];
}
}
return $rest;
}
/** A new preset ID like the Visual Builder's generateNanoUuid(): 10 characters of [0-9a-z] (formats §3). */
public static function newId(): string
{
$id = '';
for ($i = 0; $i < 10; ++$i) {
$id .= self::ALPHABET[random_int(0, 35)];
}
return $id;
}
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;
}
}
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use InvalidArgumentException;
defined('ABSPATH') || exit;
/**
* Where a preset lives (spec §6.1): a module preset ("module" + divi/<module>) or an option group preset
* ("group" + divi/<group>). Input takes the short ("button", "font-body") or the full name. Pure PHP.
*/
final class PresetScope
{
public const MODULE = 'module';
public const GROUP = 'group';
private const NAME = '/^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/D';
public function __construct(public readonly string $kind, public readonly string $name)
{
}
/**
* @param array<array-key, mixed> $item An input entry with exactly one of "module" or "group".
* @throws InvalidArgumentException "<field>: <problem>" for the input path.
*/
public static function fromInput(array $item, string $field): self
{
$kinds = array_values(array_filter(
[self::MODULE, self::GROUP],
static fn (string $kind): bool => array_key_exists($kind, $item)
));
if (count($kinds) !== 1) {
throw new InvalidArgumentException($field . ': genau eines von module oder group angeben');
}
$kind = $kinds[0];
$value = $item[$kind];
$name = is_string($value) ? (str_contains($value, '/') ? $value : 'divi/' . $value) : '';
if (preg_match(self::NAME, $name) !== 1) {
throw new InvalidArgumentException(sprintf(
'%s.%s: Name wie "button" bzw. "divi/button" (Gruppe: "font-body") erwartet',
$field,
$kind
));
}
return new self($kind, $name);
}
public function label(): string
{
return ($this->kind === self::MODULE ? 'Modul-Preset ' : 'Gruppen-Preset ') . $this->name;
}
public function equals(self $other): bool
{
return $this->kind === $other->kind && $this->name === $other->name;
}
}
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Design\PresetBook;
use LIT\MCP\Integrations\Divi\Design\PresetScope;
use PHPUnit\Framework\TestCase;
use stdClass;
final class PresetBookTest extends TestCase
{
/** @return array<string, mixed> Stored format like GlobalPreset::get_data(). */
public static function stored(): array
{
return [
'module' => [
'divi/button' => ['default' => '', 'items' => [
'aaaaaaaaaa' => ['type' => 'module', 'id' => 'aaaaaaaaaa', 'name' => 'CTA primär',
'moduleName' => 'divi/button', 'attrs' => ['button' => ['x' => 1]]],
'bbbbbbbbbb' => ['type' => 'module', 'id' => 'bbbbbbbbbb', 'name' => 'Doppelt',
'moduleName' => 'divi/button', 'attrs' => []],
'cccccccccc' => ['type' => 'module', 'id' => 'cccccccccc', 'name' => 'doppelt',
'moduleName' => 'divi/button', 'attrs' => []],
]],
'divi/text' => ['default' => 'dddddddddd', 'items' => [
'dddddddddd' => ['type' => 'module', 'id' => 'dddddddddd', 'name' => 'Fließtext',
'moduleName' => 'divi/text', 'attrs' => [],
'groupPresets' => ['content.decoration.bodyFont' => [
'presetId' => ['gggggggggg'], 'groupName' => 'divi/font-body']]],
]],
],
'group' => [
'divi/font-body' => ['default' => '', 'items' => [
'gggggggggg' => ['type' => 'group', 'id' => 'gggggggggg', 'name' => 'Lesetext',
'groupName' => 'divi/font-body', 'groupId' => 'content.decoration.bodyFont',
'moduleName' => 'divi/text', 'attrs' => []],
]],
],
];
}
public function testFindsByIdOrUniqueName(): void
{
$book = PresetBook::fromDivi(self::stored());
$button = new PresetScope('module', 'divi/button');
self::assertSame('aaaaaaaaaa', $book->find($button, 'aaaaaaaaaa')['id']);
self::assertSame('aaaaaaaaaa', $book->find($button, 'cta PRIMÄR')['id']);
self::assertNull($book->find($button, 'Fehlt'));
self::assertNull($book->find(new PresetScope('module', 'divi/text'), 'aaaaaaaaaa'));
self::assertSame(['bbbbbbbbbb', 'cccccccccc'], $book->idsNamed($button, 'DOPPELT'));
}
public function testAmbiguousNameListsTheIds(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage(
'Preset-Name "Doppelt" ist mehrdeutig; bitte die ID angeben: bbbbbbbbbb, cccccccccc.'
);
PresetBook::fromDivi(self::stored())->find(new PresetScope('module', 'divi/button'), 'Doppelt');
}
public function testStdClassGroupsBecomeArraysAndCountCoversBothKinds(): void
{
$raw = self::stored();
$raw['group'] = (object) ['divi/font-body' => (object) $raw['group']['divi/font-body']];
$book = PresetBook::fromDivi($raw);
self::assertSame(5, $book->count());
self::assertIsArray($book->data()['group']['divi/font-body']['items']);
self::assertSame('group', $book->scopeOf('gggggggggg')?->kind);
self::assertNull($book->scopeOf('zzzzzzzzzz'));
self::assertSame(0, PresetBook::fromDivi(new stdClass())->count());
}
public function testWithWithoutAndDefaultAreImmutable(): void
{
$book = PresetBook::fromDivi(self::stored());
$button = new PresetScope('module', 'divi/button');
$new = [
'type' => 'module', 'id' => 'eeeeeeeeee', 'name' => 'Neu', 'moduleName' => 'divi/button', 'attrs' => [],
];
$added = $book->with($button, $new)->withDefault($button, 'eeeeeeeeee');
$removed = $added->without($button, 'eeeeeeeeee');
self::assertSame(5, $book->count());
self::assertSame(6, $added->count());
self::assertSame('eeeeeeeeee', $added->defaultId($button));
self::assertSame('', $removed->defaultId($button), 'removing the default clears it');
self::assertNotSame($book->rev(), $added->rev());
self::assertSame($book->rev(), PresetBook::fromDivi(self::stored())->rev());
self::assertSame('', $book->defaultId(new PresetScope('group', 'divi/spacing')));
}
public function testNestedUsersOfAGroupPreset(): void
{
$users = PresetBook::fromDivi(self::stored())->nestedUsers('gggggggggg');
self::assertCount(1, $users);
self::assertSame(['dddddddddd', 'Fließtext'], [$users[0]['id'], $users[0]['name']]);
self::assertSame('divi/text', $users[0]['scope']->name);
}
public function testRestFormatListsItemsPerName(): void
{
$rest = PresetBook::fromDivi(self::stored())->toRest();
self::assertSame('dddddddddd', $rest['module'][1]['default']);
self::assertSame(['aaaaaaaaaa', 'bbbbbbbbbb', 'cccccccccc'], array_column($rest['module'][0]['items'], 'id'));
self::assertSame('gggggggggg', $rest['group'][0]['items'][0]['id']);
self::assertTrue(array_is_list($rest['module']));
}
public function testNewIdsLookLikeTheBuildersIds(): void
{
self::assertMatchesRegularExpression('/^[0-9a-z]{10}$/', PresetBook::newId());
self::assertNotSame(PresetBook::newId(), PresetBook::newId());
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Design\PresetScope;
use PHPUnit\Framework\TestCase;
final class PresetScopeTest extends TestCase
{
public function testShortAndFullNames(): void
{
self::assertSame('divi/button', PresetScope::fromInput(['module' => 'button'], 'items[0]')->name);
self::assertSame('module', PresetScope::fromInput(['module' => 'divi/button'], 'items[0]')->kind);
$group = PresetScope::fromInput(['group' => 'font-body'], 'items[0]');
self::assertSame(['group', 'divi/font-body'], [$group->kind, $group->name]);
self::assertSame('Gruppen-Preset divi/font-body', $group->label());
self::assertTrue($group->equals(new PresetScope('group', 'divi/font-body')));
}
/** @return array<string, array{array<string, mixed>, string}> */
public static function invalid(): array
{
return [
'nothing' => [[], 'items[0]'],
'both' => [['module' => 'button', 'group' => 'font'], 'items[0]'],
'not a name' => [['module' => 'Button 1'], 'items[0].module'],
'not a string' => [['group' => 5], 'items[0].group'],
'two slashes' => [['module' => 'divi/a/b'], 'items[0].module'],
];
}
/**
* @dataProvider invalid
* @param array<string, mixed> $item
*/
public function testInvalidInputNamesTheField(array $item, string $field): void
{
try {
PresetScope::fromInput($item, 'items[0]');
self::fail('expected an exception');
} catch (InvalidArgumentException $e) {
self::assertStringStartsWith($field . ':', $e->getMessage());
}
}
}