diff --git a/src/Integrations/Divi/Compose/Composer.php b/src/Integrations/Divi/Compose/Composer.php index b222250..020952a 100644 --- a/src/Integrations/Divi/Compose/Composer.php +++ b/src/Integrations/Divi/Compose/Composer.php @@ -7,7 +7,7 @@ namespace LIT\MCP\Integrations\Divi\Compose; use InvalidArgumentException; use LIT\MCP\Integrations\Divi\BlockTree\AttrMap; use LIT\MCP\Integrations\Divi\BlockTree\Block; -use LIT\MCP\Integrations\Divi\Design\DesignSystem; +use LIT\MCP\Integrations\Divi\Design\PresetAssignment; use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog; use LIT\MCP\Integrations\Divi\Schema\ShortformMap; @@ -16,7 +16,8 @@ defined('ABSPATH') || exit; /** * Builds new Divi blocks from node descriptions (spec §5.4): short form, full form ("attrs") * or both; sections with rows ("columns": "1_3,2_3") and modules; parent modules with - * "items"; presets by name; automatic admin labels " – " (spec §5.5). + * "items"; preset stacks and group presets by name, clear_overrides; automatic admin labels + * " – " (spec §5.5). * Every error is collected with its input path; blocks are only usable when there are none. * Pure PHP. * @@ -28,16 +29,19 @@ defined('ABSPATH') || exit; * names (catalog + StructureRules + Block::create), "fullwidth" and column flexTypes built * from digits (ColumnWidths). Design-system names that match several entries are errors * listing the ids (ruling S13a: colors/variables in Placeholders, presets in - * DesignSystem::modulePresetId()). + * PresetAssignment). */ final class Composer { - private const COMMON = ['type', 'label', 'preset', 'attrs']; + private const COMMON = ['type', 'label', 'preset', 'group_presets', 'clear_overrides', 'attrs']; private const SECTION = ['section_type', 'background_color', 'rows', 'modules']; private const ROW = ['columns', 'modules']; /** Keys of one lit-divi-update change ("id" names the node, read by the ability). */ - private const UPDATE = ['id', 'set', 'attrs', 'preset', 'label']; + private const UPDATE = ['id', 'set', 'attrs', 'preset', 'group_presets', 'clear_overrides', 'label']; + + /** The keys of a node that PresetAssignment::apply() reads (rulings R2, R3). */ + private const PRESET_KEYS = ['preset', 'group_presets']; /** ET_BUILDER_PRODUCT_VERSION, e.g. "5.2.1" – written into every new block. */ private const VERSION = '/^[0-9][0-9A-Za-z.+-]*$/D'; @@ -64,7 +68,7 @@ final class Composer private ShortformWriter $shortform, private AttrWriter $attrWriter, private ValueCleaner $cleaner, - private DesignSystem $design, + private PresetAssignment $presets, private string $builderVersion ) { if (preg_match(self::VERSION, $builderVersion) !== 1) { @@ -97,9 +101,11 @@ final class Composer /** * Applies short-form fields ("set"), full-form attrs, preset and label to an existing block * (lit-divi-update); errors like compose(). The block itself is not changed – the caller - * sets the returned attrs. "preset": null and "label": null remove them. + * sets the returned attrs. "preset": null and "label": null remove them; "preset" may be a stack (list), + * "group_presets" assigns group presets (null removes one), "clear_overrides": true removes the local values + * the assigned presets also set – after set and attrs (ruling R7). * - * @param array $change id, set, attrs, preset, label + * @param array $change id, set, attrs, preset, group_presets, clear_overrides, label * @return array{attrs: array, errors: array, invalid_paths: list} */ public function update(Block $block, array $change, string $field): array @@ -449,7 +455,8 @@ final class Composer $attrs = $this->common($name, $attrs, $spec, $path, $this->autoLabel($name, $context)); if ($defaultLevel) { - $attrs = $this->defaultLevel($short, $attrs); + // The automatic level is no client value: a preset's headingLevel wins when overrides are cleared. + $attrs = $this->clearedOverrides($this->defaultLevel($short, $attrs), $spec, $path); } $block = Block::create($name, $attrs); @@ -496,24 +503,30 @@ final class Composer */ private function presetAndAttrs(string $name, array $attrs, array $spec, string $path): array { - if (array_key_exists('preset', $spec)) { - $attrs = $this->preset($name, $attrs, $spec['preset'], $path . '.preset'); + $attrs = $this->presets($name, $attrs, $spec, $path); + if (array_key_exists('attrs', $spec)) { + $attrs = $this->fullForm($name, $attrs, $spec['attrs'], $path . '.attrs'); } - if (! array_key_exists('attrs', $spec)) { - return $attrs; - } + return $this->clearedOverrides($attrs, $spec, $path); + } - if (! is_array($spec['attrs']) || array_is_list($spec['attrs'])) { - $this->errors[$path . '.attrs'] = 'Objekt "Attributpfad": {Breakpoint: Wert} erwartet'; + /** + * @param array $attrs + * @return array + */ + private function fullForm(string $name, array $attrs, mixed $full, string $path): array + { + if (! is_array($full) || array_is_list($full)) { + $this->errors[$path] = 'Objekt "Attributpfad": {Breakpoint: Wert} erwartet'; return $attrs; } - $result = $this->attrWriter->apply($name, $attrs, $spec['attrs'], $path . '.attrs'); + $result = $this->attrWriter->apply($name, $attrs, $full, $path); // An unknown path is the more useful message on its key (it decides invalid_attr_path). foreach ($result['invalid_paths'] as $invalid) { - $key = $path . '.attrs.' . $invalid; + $key = $path . '.' . $invalid; if (isset($result['errors'][$key])) { $this->errors[$key] = $result['errors'][$key]; } @@ -557,53 +570,56 @@ final class Composer } /** - * @param array $attrs + * "preset" (name or id, a stack of them, null/"" = remove) and "group_presets" – through PresetAssignment, the + * one place presets reach a block (D7, rulings R2, R3). Its error keys already carry $path. PresetAssignment + * keeps the valid group keys when another one fails, so with errors the attrs stay as they were. + * + * @param array $attrs + * @param array $spec * @return array */ - private function preset(string $name, array $attrs, mixed $preset, string $path): array + private function presets(string $name, array $attrs, array $spec, string $path): array { - if ($preset === null || $preset === '') { - unset($attrs['modulePreset']); + $given = array_intersect_key($spec, array_flip(self::PRESET_KEYS)); + if ($given === []) { + return $attrs; + } + + if (($given['preset'] ?? null) === '') { + $given['preset'] = null; + } + + $result = $this->presets->apply($name, $attrs, $given, $path); + if ($result['errors'] !== []) { + $this->errors += $result['errors']; return $attrs; } - try { - $id = is_string($preset) ? $this->design->modulePresetId($name, $preset) : null; - } catch (InvalidArgumentException $e) { - // Ruling S13a: a name several presets share (DesignSystem lists their ids). - $this->errors[$path] = $e->getMessage(); + return $result['attrs']; + } + + /** + * Ruling R7: "clear_overrides": true removes the local values that sit on a leaf an explicitly assigned preset + * also sets; it runs after set and attrs, so it also takes what this very call wrote. Not a boolean is an error. + * + * @param array $attrs + * @param array $spec + * @return array + */ + private function clearedOverrides(array $attrs, array $spec, string $path): array + { + if (! array_key_exists('clear_overrides', $spec)) { + return $attrs; + } + + if (! is_bool($spec['clear_overrides'])) { + $this->errors[$path . '.clear_overrides'] = 'true oder false erwartet'; return $attrs; } - if ($id === null) { - $names = $this->design->modulePresetNames($name); - $this->errors[$path] = sprintf( - 'unbekanntes Preset; vorhanden für %s: %s', - $name, - $names === [] ? '(keine)' : implode(', ', $names) - ); - - return $attrs; - } - - // Design-system data, but still through the choke point: an id text() would change is refused. - try { - $clean = $this->cleaner->clean($name, 'modulePreset', $id, null, ValueKind::Text); - } catch (InvalidArgumentException $e) { - $clean = null; - } - - if ($clean !== $id) { - $this->errors[$path] = sprintf('Preset-ID "%s" ist ungültig', $id); - - return $attrs; - } - - $attrs['modulePreset'] = [$id]; - - return $attrs; + return $spec['clear_overrides'] ? $this->presets->clearOverrides($attrs) : $attrs; } /** diff --git a/src/Integrations/Divi/Compose/ValueCleaner.php b/src/Integrations/Divi/Compose/ValueCleaner.php index 531d3f1..f4f6829 100644 --- a/src/Integrations/Divi/Compose/ValueCleaner.php +++ b/src/Integrations/Divi/Compose/ValueCleaner.php @@ -63,6 +63,9 @@ final class ValueCleaner private const BUILDER_ONLY = '/(^|\.)(decoration\.attributes|decoration\.interactions|advanced\.html' . '|advanced\.customCss|css|lottie\.innerContent)(\.|$)/'; + /** Module the catalog routes design-system ids through; the kind is fixed to text, so any module will do. */ + private const ID_MODULE = 'divi/text'; + public function __construct( private ModuleCatalog $catalog, private VariableRef $references, @@ -92,6 +95,24 @@ final class ValueCleaner return $this->cleanNode($module, $path, $value, $subKey, $subKey, $kind); } + /** + * Whether $id passes clean() unchanged as plain text – the check for ids that come from the design system + * (preset ids, ruling S13): an id the sanitizers would change or refuse is never stored, and an id is never + * a Divi reference. + */ + public function keepsId(string $id): bool + { + if (VariableRef::contains($id)) { + return false; + } + + try { + return $this->clean(self::ID_MODULE, 'modulePreset', $id, null, ValueKind::Text) === $id; + } catch (InvalidArgumentException) { + return false; + } + } + /** * The kind clean() routes a string of $path (sub-field $subKey) to. lit/divi-replace-text * uses it to leave URL and code fields alone (D10). diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 6947644..99b4a66 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -137,7 +137,7 @@ final class DiviIntegration implements Integration { return new DiviContentComposer( $this->schema, - new DiviComposition($this->modules, $this->design), + $this->composition(), new DiviSaver(), $this->design, $writable @@ -225,13 +225,22 @@ final class DiviIntegration implements Integration { return new ThemeBuilderIntegration( $this->themeBuilderAccess, - new DiviComposition($this->modules, $this->design), + $this->composition(), new DiviSaver(), $this->log, new DynamicFields($this->schema) ); } + /** + * The one wiring of the composition for every tool that composes (spec D7): presets and Divi's option groups come + * from the design system writer, so a stack or group preset in any write tool resolves against the live book. + */ + private function composition(): DiviComposition + { + return new DiviComposition($this->modules, $this->design, presetSource: $this->designWriter); + } + private function writer(DiviPageLoader $loader): DiviWriter { return new DiviWriter( @@ -239,7 +248,7 @@ final class DiviIntegration implements Integration new DiviSaver(), $this->log, $this->design, - new DiviComposition($this->modules, $this->design), + $this->composition(), new GlobalEdits($this->access, $this->globalUsages()), new ThemeBuilderEdits($this->themeBuilderAccess, fields: new DynamicFields($this->schema)), new SimilarSections($this->access) @@ -253,7 +262,7 @@ final class DiviIntegration implements Integration private function presetComposer(): PresetComposer { - $composition = new DiviComposition($this->modules, $this->design); + $composition = $this->composition(); return new PresetComposer( $composition->composer(), diff --git a/src/Integrations/Divi/Pages/DiviComposition.php b/src/Integrations/Divi/Pages/DiviComposition.php index 1d960f6..93b91dd 100644 --- a/src/Integrations/Divi/Pages/DiviComposition.php +++ b/src/Integrations/Divi/Pages/DiviComposition.php @@ -18,6 +18,9 @@ use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer; use LIT\MCP\Integrations\Divi\Compose\VariableRef; use LIT\MCP\Integrations\Divi\Compose\Warnings; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; +use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter; +use LIT\MCP\Integrations\Divi\Design\PresetAssignment; +use LIT\MCP\Integrations\Divi\Design\PresetBook; use LIT\MCP\Integrations\Divi\Schema\DiviModules; defined('ABSPATH') || exit; @@ -29,6 +32,11 @@ defined('ABSPATH') || exit; * and ONE ValueCleaner instance are shared by AttrWriter, ShortformWriter and Composer, and the * same AttrWriter serves Composer and the abilities. * + * Presets (D7) reach blocks through ONE PresetAssignment per composition: its book is Divi's stored preset + * inventory (DesignSystemWriter::presets()), read lazily and at most once – only when a write names a preset or a + * warning looks at a block that has one – and Divi's option groups come from DesignSystemWriter::groupsOf(). + * Without a preset source (null, unit tests) there are no presets. + * * forPost() binds the composition to the entry being written (DiviWriter::composition() does it * for the abilities); unbound, VariableRef's default map applies. Not final so abilities can be * tested with a mock. @@ -42,6 +50,7 @@ class DiviComposition private ?ValueCleaner $cleaner = null; private ?AttrWriter $attrWriter = null; private ?Composer $composer = null; + private ?PresetAssignment $assignment = null; public function __construct( private DiviModules $modules, @@ -49,7 +58,8 @@ class DiviComposition private ValueSanitizer $sanitizer = new WpValueSanitizer(), private MediaResolver $media = new WpMediaResolver(), private DynamicContentMap $dynamicContent = new DynamicContentMap(), - private LinkTargets $links = new WpLinkTargets() + private LinkTargets $links = new WpLinkTargets(), + private ?DesignSystemWriter $presetSource = null ) { } @@ -64,6 +74,7 @@ class DiviComposition $bound->cleaner = null; $bound->attrWriter = null; $bound->composer = null; + $bound->assignment = null; return $bound; } @@ -102,6 +113,21 @@ class DiviComposition return $this->attrWriter ??= new AttrWriter($this->modules->catalog(), $this->placeholders(), $this->cleaner()); } + /** + * The one PresetAssignment of this composition (D7, rulings R2, R3): the book comes lazily from Divi's stored + * presets, ids must pass the choke point unchanged. + * + * @internal Public for tests; code uses composer() / warnings(). + */ + public function presets(): PresetAssignment + { + return $this->assignment ??= new PresetAssignment( + fn (): PresetBook => PresetBook::fromDivi($this->presetSource?->presets() ?? []), + fn (string $module): array => $this->presetSource?->groupsOf($module) ?? [], + $this->cleaner()->keepsId(...) + ); + } + public function composer(): Composer { return $this->composer ??= new Composer( @@ -110,7 +136,7 @@ class DiviComposition new ShortformWriter($this->placeholders(), $this->media, $this->cleaner()), $this->attrWriter(), $this->cleaner(), - $this->design->read(), + $this->presets(), $this->modules->builderVersion() ); } diff --git a/src/Integrations/Divi/Pages/DiviInsertAbility.php b/src/Integrations/Divi/Pages/DiviInsertAbility.php index 26b8787..3aa0250 100644 --- a/src/Integrations/Divi/Pages/DiviInsertAbility.php +++ b/src/Integrations/Divi/Pages/DiviInsertAbility.php @@ -34,7 +34,9 @@ final class DiviInsertAbility extends DiviWriteAbility return 'Fügt Divi-Knoten ein: Module, Zeilen oder ganze Abschnitte in Kurzform (z. B. {"type": "button", ' . '"text": "Termin buchen", "url": "/kontakt", "preset": "CTA primär"}) und/oder Vollform ("attrs"). ' . 'Position über parent, before oder after (Pfad-IDs aus lit-divi-outline), sonst ans Seitenende. ' - . 'Platzhalter wie {{color:primary}} statt fester Werte. tree_rev ist Pflicht; alles oder nichts. ' + . 'Platzhalter wie {{color:primary}} statt fester Werte. Presets: preset = Name/ID oder Liste (Stapel; ' + . 'das letzte gewinnt bei gleicher Priorität), group_presets = {Gruppe: Preset}, clear_overrides: true ' + . 'entfernt lokale Werte, die ein Preset überdecken. tree_rev ist Pflicht; alles oder nichts. ' . 'Format: lit-guide topic "divi-shortform".'; } diff --git a/src/Integrations/Divi/Pages/DiviUpdateAbility.php b/src/Integrations/Divi/Pages/DiviUpdateAbility.php index 5d968b1..059040d 100644 --- a/src/Integrations/Divi/Pages/DiviUpdateAbility.php +++ b/src/Integrations/Divi/Pages/DiviUpdateAbility.php @@ -35,9 +35,12 @@ final class DiviUpdateAbility extends DiviWriteAbility public function description(): string { return 'Ändert vorhandene Divi-Knoten: changes = Liste von {id, set: {Kurzform-Felder}, attrs: {Attributpfad: ' - . '{Breakpoint: Wert}}, preset, label}. null entfernt einen Wert. Nicht genannte Einstellungen bleiben ' - . 'unverändert. Jeder Knoten nur einmal pro Aufruf; denselben Pfad nicht zugleich über set und attrs. ' - . 'tree_rev aus lit-divi-outline ist Pflicht; alles oder nichts.'; + . '{Breakpoint: Wert}}, preset, group_presets, clear_overrides, label}. null entfernt einen Wert. ' + . 'preset = Name/ID oder Liste (Stapel, bis 10; bei gleicher Priorität gewinnt das letzte Preset im ' + . 'Stapel); group_presets = {Gruppe: Preset}, z. B. {"font-body": "Fließtext"}. Überdecken lokale Werte ' + . 'ein Preset, meldet die Antwort das; clear_overrides: true entfernt sie (nach set/attrs). Nicht ' + . 'genannte Einstellungen bleiben unverändert. Jeder Knoten nur einmal pro Aufruf; denselben Pfad nicht ' + . 'zugleich über set und attrs. tree_rev aus lit-divi-outline ist Pflicht; alles oder nichts.'; } public function inputSchema(): array @@ -58,7 +61,22 @@ final class DiviUpdateAbility extends DiviWriteAbility 'type' => 'object', 'description' => 'Vollform: Attributpfad → Breakpoint-Werte.', ], - 'preset' => ['type' => ['string', 'null'], 'description' => 'Preset-Name; null entfernt.'], + 'preset' => self::presetValue( + 'Preset-Name oder -ID, Liste (Stapel, 1–10) oder null zum Entfernen. Bei gleicher ' + . 'Priorität gewinnt das letzte Preset im Stapel.' + ), + 'group_presets' => [ + 'type' => 'object', + 'description' => 'Gruppen-Presets: {Gruppe: Preset-Name | Liste | null}. Gruppe = ' + . 'Kurzname (font-body), voller Name (divi/font-body) oder Attributpfad; null ' + . 'entfernt den Eintrag der Gruppe.', + 'additionalProperties' => self::presetValue(), + ], + 'clear_overrides' => [ + 'type' => 'boolean', + 'description' => 'true entfernt lokale Werte, die Werte der zugewiesenen Presets ' + . 'überdecken (nach set und attrs).', + ], 'label' => [ 'type' => ['string', 'null'], 'description' => 'Admin-Label im Divi-Builder; null entfernt.', @@ -154,4 +172,21 @@ final class DiviUpdateAbility extends DiviWriteAbility return $change; } + + /** + * Schema of one preset value: a name or id, a stack of 1–10 of them, or null. + * + * @return array + */ + private static function presetValue(string $description = ''): array + { + $schema = [ + 'type' => ['string', 'array', 'null'], + 'items' => ['type' => 'string'], + 'minItems' => 1, + 'maxItems' => 10, + ]; + + return $description === '' ? $schema : $schema + ['description' => $description]; + } } diff --git a/src/Integrations/WordPress/Content/ContentCreateAbility.php b/src/Integrations/WordPress/Content/ContentCreateAbility.php index 8693f12..da1477c 100644 --- a/src/Integrations/WordPress/Content/ContentCreateAbility.php +++ b/src/Integrations/WordPress/Content/ContentCreateAbility.php @@ -61,7 +61,8 @@ final class ContentCreateAbility implements Ability return 'Legt einen neuen Eintrag an – immer als Entwurf (veröffentlichen nur über lit-post-publish). ' . 'Kerndaten, Begriffe, Felder (gegen das Schema aus lit-content-types geprüft) und Beitragsbild ' . 'in einem Schritt; bei einem Fehler wird nichts angelegt. Divi-Seiten direkt mit Inhalt: divi = Liste ' - . 'von Abschnitten in Kurzform (lit-guide topic "divi-shortform"), statt content; das Ergebnis enthält ' + . 'von Abschnitten in Kurzform (lit-guide topic "divi-shortform"; Module können preset – auch als Liste –, ' + . 'group_presets und clear_overrides tragen), statt content; das Ergebnis enthält ' . 'dann tree_rev für die lit-divi-* Werkzeuge.'; } diff --git a/tests/Support/ComposeFixture.php b/tests/Support/ComposeFixture.php index d14a261..7e1ae3a 100644 --- a/tests/Support/ComposeFixture.php +++ b/tests/Support/ComposeFixture.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Support; +use Closure; use LIT\MCP\Integrations\Divi\Compose\AttrWriter; use LIT\MCP\Integrations\Divi\Compose\Composer; use LIT\MCP\Integrations\Divi\Compose\LinkTargets; @@ -15,6 +16,8 @@ use LIT\MCP\Integrations\Divi\Compose\ValueCleaner; use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer; use LIT\MCP\Integrations\Divi\Compose\VariableRef; use LIT\MCP\Integrations\Divi\Design\DesignSystem; +use LIT\MCP\Integrations\Divi\Design\PresetAssignment; +use LIT\MCP\Integrations\Divi\Design\PresetBook; /** * Compose classes wired with the real Divi 5.2.1 sample catalog, the sample design system and @@ -193,10 +196,17 @@ final class ComposeFixture /** * Builder version 5.2.1. $design replaces the sample design system (one Placeholders, one * VariableRef and one ValueCleaner shared by AttrWriter, ShortformWriter and Composer, like the - * live wiring – ruling S31). + * live wiring – ruling S31). The presets come from $book (default: the presets of $design, see + * bookOf()); $groupsOf names the option groups per module (default: none). + * + * @param (Closure(string): array)|null $groupsOf */ - public static function composer(bool $unfiltered = false, ?DesignSystem $design = null): Composer - { + public static function composer( + bool $unfiltered = false, + ?DesignSystem $design = null, + ?PresetBook $book = null, + ?Closure $groupsOf = null + ): Composer { $design ??= DiviFixtures::designSystem(); $placeholders = new Placeholders($design); $cleaner = new ValueCleaner(DiviFixtures::catalog(), new VariableRef($design), self::sanitizer($unfiltered)); @@ -208,8 +218,52 @@ final class ComposeFixture new ShortformWriter($placeholders, self::media(), $cleaner), $attrWriter, $cleaner, - $design, + self::presetAssignment($book, $groupsOf, $design, $cleaner), '5.2.1' ); } + + /** + * The presets of $design in Divi's stored format (ids, names, defaults; no attrs) as a PresetBook – so the tests + * that hand a DesignSystem to composer() keep their presets. + */ + public static function bookOf(DesignSystem $design): PresetBook + { + $raw = ['module' => [], 'group' => []]; + foreach ($design->presets() as $preset) { + $raw[$preset['kind']][$preset['module']] ??= ['default' => '', 'items' => []]; + $raw[$preset['kind']][$preset['module']]['items'][$preset['id']] = [ + 'id' => $preset['id'], + 'name' => $preset['name'], + $preset['kind'] === 'module' ? 'moduleName' : 'groupName' => $preset['module'], + ]; + if ($preset['default']) { + $raw[$preset['kind']][$preset['module']]['default'] = $preset['id']; + } + } + + return PresetBook::fromDivi($raw); + } + + /** + * PresetAssignment as the live wiring builds it: the book is $book (default: bookOf($design), else of the sample + * design system), the id check is the cleaner's keepsId(). + * + * @param (Closure(string): array)|null $groupsOf + */ + public static function presetAssignment( + ?PresetBook $book = null, + ?Closure $groupsOf = null, + ?DesignSystem $design = null, + ?ValueCleaner $cleaner = null + ): PresetAssignment { + $book ??= self::bookOf($design ?? DiviFixtures::designSystem()); + $cleaner ??= self::cleaner(); + + return new PresetAssignment( + static fn (): PresetBook => $book, + $groupsOf ?? static fn (string $module): array => [], + $cleaner->keepsId(...) + ); + } } diff --git a/tests/Unit/Integrations/Divi/Compose/ComposerTest.php b/tests/Unit/Integrations/Divi/Compose/ComposerTest.php index bddf7ab..f01c798 100644 --- a/tests/Unit/Integrations/Divi/Compose/ComposerTest.php +++ b/tests/Unit/Integrations/Divi/Compose/ComposerTest.php @@ -5,12 +5,14 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose; use InvalidArgumentException; +use LIT\MCP\Integrations\Divi\BlockTree\AttrMap; use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary; use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\BlockTree\Serializer; use LIT\MCP\Integrations\Divi\Compose\Composer; use LIT\MCP\Integrations\Divi\Design\DesignSystem; +use LIT\MCP\Integrations\Divi\Design\PresetBook; use LIT\MCP\Tests\Support\ComposeFixture; use LIT\MCP\Tests\Support\DiviFixtures; use PHPUnit\Framework\TestCase; @@ -424,6 +426,165 @@ final class ComposerTest extends TestCase self::assertArrayNotHasKey('modulePreset', $result['attrs']); } + /** Button presets p-1/p-2 as in the sample design system; p-2 carries a font color, g-1 is a font group preset. */ + private static function coveringBook(): PresetBook + { + return PresetBook::fromDivi([ + 'module' => ['divi/button' => ['default' => 'p-1', 'items' => [ + 'p-1' => ['id' => 'p-1', 'name' => 'Standard', 'moduleName' => 'divi/button'], + 'p-2' => [ + 'id' => 'p-2', + 'name' => 'CTA primär', + 'moduleName' => 'divi/button', + 'attrs' => ['button' => ['decoration' => ['font' => ['font' => [ + 'desktop' => ['value' => ['color' => '#ffffff']], + ]]]]], + ], + ]]], + 'group' => ['divi/font' => ['default' => '', 'items' => [ + 'g-1' => ['id' => 'g-1', 'name' => 'Fließtext groß', 'groupName' => 'divi/font'], + ]]], + ]); + } + + /** @return array The option groups the tests give every button. */ + private static function buttonGroups(string $module): array + { + return $module === 'divi/button' ? ['button.decoration.font.font' => 'divi/font'] : []; + } + + public function testPresetStackIsStoredInTheGivenOrder(): void + { + $composer = ComposeFixture::composer(); + + $result = $composer->compose( + [['type' => 'button', 'text' => 'Los', 'preset' => ['CTA primär', 'p-1']]], + Block::create('divi/column'), + 'nodes' + ); + self::assertSame([], $result['errors']); + self::assertSame(['p-2', 'p-1'], $result['blocks'][0]->attrs()['modulePreset']); + + $update = $composer->update(Block::create('divi/button'), ['preset' => ['Standard', 'CTA primär']], 'changes[0]'); + self::assertSame([], $update['errors']); + self::assertSame(['p-1', 'p-2'], $update['attrs']['modulePreset']); + } + + public function testPresetStackErrorsKeepTheirInputPaths(): void + { + $composer = ComposeFixture::composer(); + $button = Block::create('divi/button'); + + $unknown = $composer->update($button, ['preset' => ['Standard', 'Gibt es nicht']], 'changes[0]'); + self::assertSame( + 'unbekanntes Preset; vorhanden für divi/button: Standard, CTA primär', + $unknown['errors']['changes[0].preset.1'] + ); + self::assertArrayNotHasKey('modulePreset', $unknown['attrs']); + + foreach ([[], ['Standard', 'p-1']] as $invalid) { + $result = $composer->update($button, ['preset' => $invalid], 'changes[0]'); + self::assertNotSame([], preg_grep('/^changes\[0\]\.preset/', array_keys($result['errors']))); + self::assertArrayNotHasKey('modulePreset', $result['attrs']); + } + } + + public function testGroupPresetsAreAssignedByShortNameAndRemovedWithNull(): void + { + $composer = ComposeFixture::composer(false, null, null, self::buttonGroups(...)); + $groups = ['button.decoration.font.font' => ['groupName' => 'divi/font', 'presetIds' => ['g-1']]]; + + $update = $composer->update(Block::create('divi/button'), ['group_presets' => ['font' => 'Fließtext groß']], 'changes[0]'); + self::assertSame([], $update['errors']); + self::assertSame($groups, NodeSummary::groupPresets(Block::create('divi/button', $update['attrs']))); + + $created = $composer->compose( + [['type' => 'button', 'text' => 'Los', 'group_presets' => ['divi/font' => 'g-1']]], + Block::create('divi/column'), + 'nodes' + ); + self::assertSame([], $created['errors']); + self::assertSame($groups, NodeSummary::groupPresets($created['blocks'][0])); + + $removed = $composer->update(Block::create('divi/button', $update['attrs']), ['group_presets' => ['font' => null]], 'changes[0]'); + self::assertSame([], $removed['errors']); + self::assertSame([], NodeSummary::groupPresets(Block::create('divi/button', $removed['attrs']))); + self::assertArrayNotHasKey('groupPreset', $removed['attrs']); + + $unknown = $composer->update(Block::create('divi/button'), ['group_presets' => ['farbe' => 'x']], 'changes[0]'); + self::assertArrayHasKey('changes[0].group_presets.farbe', $unknown['errors']); + } + + /** Ruling R7: clear_overrides runs after set/attrs of the same node and removes only leaves a preset also sets. */ + public function testClearOverridesRunsAfterSetAndAttrsOnANewNode(): void + { + $composer = ComposeFixture::composer(false, null, self::coveringBook()); + $font = ['desktop' => ['color' => '#abcdef', 'family' => 'Open Sans']]; + + $kept = $composer->compose( + [['type' => 'button', 'text' => 'Los', 'preset' => 'CTA primär', 'attrs' => ['button.decoration.font.font' => $font]]], + Block::create('divi/column'), + 'nodes' + ); + self::assertSame([], $kept['errors']); + self::assertSame( + ['color' => '#abcdef', 'family' => 'Open Sans'], + AttrMap::desktop($kept['blocks'][0]->attrs(), 'button.decoration.font.font') + ); + + $cleared = $composer->compose( + [[ + 'type' => 'button', + 'text' => 'Los', + 'preset' => 'CTA primär', + 'clear_overrides' => true, + 'attrs' => ['button.decoration.font.font' => $font], + ]], + Block::create('divi/column'), + 'nodes' + ); + self::assertSame([], $cleared['errors']); + $attrs = $cleared['blocks'][0]->attrs(); + self::assertSame(['p-2'], $attrs['modulePreset']); + self::assertSame(['family' => 'Open Sans'], AttrMap::desktop($attrs, 'button.decoration.font.font')); + self::assertSame('Los', AttrMap::desktop($attrs, 'button.innerContent')['text']); + } + + public function testClearOverridesOnAnUpdateKeepsEverythingNoPresetCovers(): void + { + $composer = ComposeFixture::composer(false, null, self::coveringBook()); + $attrs = ['modulePreset' => ['p-2'], 'button' => ['decoration' => ['font' => ['font' => [ + 'desktop' => ['value' => ['color' => '#000000']], + ]]]]]; + + $result = $composer->update(Block::create('divi/button', $attrs), ['clear_overrides' => true], 'changes[0]'); + self::assertSame([], $result['errors']); + self::assertSame(['modulePreset' => ['p-2']], $result['attrs']); + + $plain = Block::create('divi/button', ['button' => ['innerContent' => ['desktop' => ['value' => ['text' => 'Alt']]]]]); + $unchanged = $composer->update($plain, ['clear_overrides' => true], 'changes[0]'); + self::assertSame([], $unchanged['errors']); + self::assertSame($plain->attrs(), $unchanged['attrs']); + + $off = $composer->update(Block::create('divi/button', $attrs), ['clear_overrides' => false], 'changes[0]'); + self::assertSame($attrs, $off['attrs']); + } + + public function testClearOverridesMustBeABoolean(): void + { + $composer = ComposeFixture::composer(); + + $update = $composer->update(Block::create('divi/button'), ['clear_overrides' => 'ja'], 'changes[0]'); + self::assertSame('true oder false erwartet', $update['errors']['changes[0].clear_overrides']); + + $compose = $composer->compose( + [['type' => 'section', 'clear_overrides' => null, 'modules' => []]], + null, + 'nodes' + ); + self::assertSame('true oder false erwartet', $compose['errors']['nodes[0].clear_overrides']); + } + public function testBuilderVersionMustBeAVersion(): void { $this->expectException(InvalidArgumentException::class); @@ -434,7 +595,7 @@ final class ComposerTest extends TestCase ComposeFixture::shortform(), ComposeFixture::attrWriter(), ComposeFixture::cleaner(), - DiviFixtures::designSystem(), + ComposeFixture::presetAssignment(), '5.2.1"},"x":{"' ); } diff --git a/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php b/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php index 7543687..0b12b19 100644 --- a/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php +++ b/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php @@ -253,4 +253,17 @@ final class ValueCleanerTest extends TestCase self::assertSame('x', ComposeFixture::cleaner()->clean('divi/lottie', 'lottie.advanced.width', 'x')); self::assertSame('x', ComposeFixture::cleaner()->clean('divi/lottie', 'lottie.innerContentish', 'x')); } + + /** Design-system ids (preset ids) are no client text, but they pass the choke point unchanged (ruling S13). */ + public function testKeepsIdAcceptsPlainIdsAndRefusesEverythingTheCleanerChanges(): void + { + $cleaner = ComposeFixture::cleaner(); + + self::assertTrue($cleaner->keepsId('3gbmxlurey')); + self::assertTrue($cleaner->keepsId('a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d')); + self::assertFalse($cleaner->keepsId('p'), 'tags would be stripped'); + self::assertFalse($cleaner->keepsId('p%41x'), 'octets would be stripped'); + self::assertFalse($cleaner->keepsId("p\x01"), 'control characters would be stripped'); + self::assertFalse($cleaner->keepsId(ComposeFixture::REFERENCE_ALT), 'an id is never a $variable reference'); + } } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php b/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php index d54b5b8..6aebcc0 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php @@ -8,10 +8,12 @@ use LIT\MCP\Integrations\Divi\BlockTree\AttrMap; use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\Document; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; +use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary; use LIT\MCP\Integrations\Divi\Compose\StoredNodeGuard; use LIT\MCP\Integrations\Divi\Compose\TreeEditor; use LIT\MCP\Integrations\Divi\Compose\VariableRef; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; +use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter; use LIT\MCP\Integrations\Divi\Pages\DiviComposition; use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; use LIT\MCP\Integrations\Divi\Schema\DiviModules; @@ -26,7 +28,7 @@ final class DiviCompositionTest extends TestCase /** @var list */ private array $registryCalls = []; - private function composition(): DiviComposition + private function composition(?DesignSystemWriter $presets = null): DiviComposition { $modules = Mockery::mock(DiviModules::class); $modules->allows('catalog')->andReturn(DiviFixtures::catalog()); @@ -45,10 +47,33 @@ final class DiviCompositionTest extends TestCase ComposeFixture::sanitizer(), ComposeFixture::media(), $registry, - ComposeFixture::links() + ComposeFixture::links(), + presetSource: $presets ); } + /** The preset source of the live wiring: Divi's stored presets (raw format) and the option groups per module. */ + private function presetSource(int $reads = 1): DesignSystemWriter + { + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->expects('presets')->times($reads)->andReturn([ + 'module' => ['divi/button' => ['default' => 'p-1', 'items' => [ + 'p-1' => ['id' => 'p-1', 'name' => 'Standard', 'moduleName' => 'divi/button'], + 'p-2' => ['id' => 'p-2', 'name' => 'CTA primär', 'moduleName' => 'divi/button'], + ]]], + 'group' => ['divi/font' => ['default' => '', 'items' => [ + 'g-1' => ['id' => 'g-1', 'name' => 'Fließtext groß', 'groupName' => 'divi/font'], + ]]], + ]); + $writer->allows('groupsOf')->andReturnUsing( + static fn (string $module): array => $module === 'divi/button' + ? ['button.decoration.font.font' => 'divi/font'] + : [] + ); + + return $writer; + } + private static function siteTitle(): string { return VariableRef::serialize('content', 'site_title', ['before' => '', 'after' => '']); @@ -156,4 +181,61 @@ final class DiviCompositionTest extends TestCase DiviWriterFixture::composition(DiviWriterFixture::designReader(), true)->storedGuard()->problems($code) ); } + + public function testPresetStackAndGroupsComeFromTheWritersBookAndAreReadOnce(): void + { + $composer = $this->composition($this->presetSource())->forPost(7)->composer(); + + $result = $composer->compose([ + ['type' => 'button', 'text' => 'Eins', 'preset' => ['CTA primär', 'Standard'], 'group_presets' => ['font' => 'g-1']], + ['type' => 'button', 'text' => 'Zwei', 'preset' => 'p-2'], + ], Block::create('divi/column'), 'nodes'); + + self::assertSame([], $result['errors']); + self::assertSame(['p-2', 'p-1'], $result['blocks'][0]->attrs()['modulePreset']); + self::assertSame(['p-2'], $result['blocks'][1]->attrs()['modulePreset']); + self::assertSame( + ['button.decoration.font.font' => ['groupName' => 'divi/font', 'presetIds' => ['g-1']]], + NodeSummary::groupPresets($result['blocks'][0]) + ); + } + + public function testThePresetBookIsNotReadWithoutAPresetInTheInput(): void + { + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->shouldNotReceive('presets'); + $writer->shouldNotReceive('groupsOf'); + + $result = $this->composition($writer)->forPost(7)->composer()->compose( + [['type' => 'button', 'text' => 'Los']], + Block::create('divi/column'), + 'nodes' + ); + + self::assertSame([], $result['errors']); + } + + public function testWithoutAPresetSourceNoPresetExists(): void + { + $result = $this->composition()->forPost(7)->composer()->compose( + [['type' => 'button', 'text' => 'Los', 'preset' => 'Standard']], + Block::create('divi/column'), + 'nodes' + ); + + self::assertSame( + 'unbekanntes Preset; vorhanden für divi/button: (keine)', + $result['errors']['nodes[0].preset'] + ); + } + + public function testOnePresetAssignmentPerBoundComposition(): void + { + $base = $this->composition($this->presetSource(0)); + $seven = $base->forPost(7); + + self::assertSame($seven->presets(), $seven->presets()); + self::assertNotSame($seven->presets(), $base->forPost(8)->presets()); + self::assertNotSame($base->presets(), $seven->presets()); + } } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviUpdateAbilityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviUpdateAbilityTest.php index 9741ad1..119ae6d 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviUpdateAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviUpdateAbilityTest.php @@ -261,5 +261,51 @@ final class DiviUpdateAbilityTest extends DiviWriteAbilityTestCase self::assertStringNotContainsString('1058', (string) json_encode($hidden)); self::assertStringContainsString('[global 1059]', (string) $shown['outline']); } -} + public function testUpdateOffersPresetStacksGroupPresetsAndClearOverrides(): void + { + $change = $this->update()->inputSchema()['properties']['changes']['items']['properties']; + + self::assertSame(['string', 'array', 'null'], $change['preset']['type']); + self::assertSame(['type' => 'string'], $change['preset']['items']); + self::assertSame([1, 10], [$change['preset']['minItems'], $change['preset']['maxItems']]); + self::assertSame('object', $change['group_presets']['type']); + self::assertSame(['string', 'array', 'null'], $change['group_presets']['additionalProperties']['type']); + self::assertSame([1, 10], [ + $change['group_presets']['additionalProperties']['minItems'], + $change['group_presets']['additionalProperties']['maxItems'], + ]); + self::assertSame('boolean', $change['clear_overrides']['type']); + + $description = $this->update()->description(); + foreach (['group_presets', 'clear_overrides', 'das letzte Preset'] as $text) { + self::assertStringContainsString($text, $description); + } + self::assertStringContainsString('group_presets', $this->insert()->description()); + } + + public function testClearOverridesMustBeABoolean(): void + { + $result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [ + ['id' => 's0.r0.c1.m2', 'clear_overrides' => 'ja'], + ]]); + + self::assertToolError($result, 'validation_failed'); + self::assertSame( + 'true oder false erwartet', + self::clientError($result)['fields']['changes[0].clear_overrides'] + ); + self::assertSame([], $this->saved); + } + + /** A pure clear_overrides that finds no covering value changes nothing and saves nothing. */ + public function testClearOverridesWithoutAHitIsNoChange(): void + { + $result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [ + ['id' => 's0.r0.c1.m2', 'clear_overrides' => true], + ]]); + + self::assertStringStartsWith('Keine Änderung', $result['summary']); + self::assertSame([], $this->saved); + } +} diff --git a/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php b/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php index 80bb101..5609016 100644 --- a/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php @@ -263,4 +263,13 @@ final class ContentCreateAbilityTest extends TestCase Functions\when('current_user_can')->justReturn(false); self::assertToolError($ability->permission(['post_type' => 'page']), 'forbidden'); } + + public function testDescriptionNamesPresetStacksAndGroupPresets(): void + { + $ability = new ContentCreateAbility($this->schema(), Mockery::mock(PostWriter::class), FakeChangeLog::forbidden()); + $description = $ability->description(); + + self::assertStringContainsString('group_presets', $description); + self::assertStringContainsString('clear_overrides', $description); + } }