feat: Presets als Stapel, Gruppen-Presets und clear_overrides im Composer

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-18 16:48:58 +02:00
co-authored by Claude Sonnet 5.5
parent fafa140546
commit be6ca5bee0
13 changed files with 549 additions and 74 deletions
+70 -54
View File
@@ -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 "<Abschnitt> – <Modul>" (spec §5.5).
* "items"; preset stacks and group presets by name, clear_overrides; automatic admin labels
* "<Abschnitt> – <Modul>" (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<array-key, mixed> $change id, set, attrs, preset, label
* @param array<array-key, mixed> $change id, set, attrs, preset, group_presets, clear_overrides, label
* @return array{attrs: array<string, mixed>, errors: array<string, string>, invalid_paths: list<string>}
*/
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<string, mixed> $attrs
* @return array<string, mixed>
*/
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<string, mixed> $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<string, mixed> $attrs
* @param array<array-key, mixed> $spec
* @return array<string, mixed>
*/
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<string, mixed> $attrs
* @param array<array-key, mixed> $spec
* @return array<string, mixed>
*/
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;
}
/**
@@ -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).
+13 -4
View File
@@ -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(),
@@ -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()
);
}
@@ -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".';
}
@@ -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<string, mixed>
*/
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];
}
}