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:
co-authored by
Claude Sonnet 5.5
parent
fafa140546
commit
be6ca5bee0
@@ -7,7 +7,7 @@ namespace LIT\MCP\Integrations\Divi\Compose;
|
|||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
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\ModuleCatalog;
|
||||||
use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
|
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")
|
* 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
|
* 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.
|
* Every error is collected with its input path; blocks are only usable when there are none.
|
||||||
* Pure PHP.
|
* Pure PHP.
|
||||||
*
|
*
|
||||||
@@ -28,16 +29,19 @@ defined('ABSPATH') || exit;
|
|||||||
* names (catalog + StructureRules + Block::create), "fullwidth" and column flexTypes built
|
* names (catalog + StructureRules + Block::create), "fullwidth" and column flexTypes built
|
||||||
* from digits (ColumnWidths). Design-system names that match several entries are errors
|
* from digits (ColumnWidths). Design-system names that match several entries are errors
|
||||||
* listing the ids (ruling S13a: colors/variables in Placeholders, presets in
|
* listing the ids (ruling S13a: colors/variables in Placeholders, presets in
|
||||||
* DesignSystem::modulePresetId()).
|
* PresetAssignment).
|
||||||
*/
|
*/
|
||||||
final class Composer
|
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 SECTION = ['section_type', 'background_color', 'rows', 'modules'];
|
||||||
private const ROW = ['columns', 'modules'];
|
private const ROW = ['columns', 'modules'];
|
||||||
|
|
||||||
/** Keys of one lit-divi-update change ("id" names the node, read by the ability). */
|
/** 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. */
|
/** ET_BUILDER_PRODUCT_VERSION, e.g. "5.2.1" – written into every new block. */
|
||||||
private const VERSION = '/^[0-9][0-9A-Za-z.+-]*$/D';
|
private const VERSION = '/^[0-9][0-9A-Za-z.+-]*$/D';
|
||||||
@@ -64,7 +68,7 @@ final class Composer
|
|||||||
private ShortformWriter $shortform,
|
private ShortformWriter $shortform,
|
||||||
private AttrWriter $attrWriter,
|
private AttrWriter $attrWriter,
|
||||||
private ValueCleaner $cleaner,
|
private ValueCleaner $cleaner,
|
||||||
private DesignSystem $design,
|
private PresetAssignment $presets,
|
||||||
private string $builderVersion
|
private string $builderVersion
|
||||||
) {
|
) {
|
||||||
if (preg_match(self::VERSION, $builderVersion) !== 1) {
|
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
|
* 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
|
* (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>}
|
* @return array{attrs: array<string, mixed>, errors: array<string, string>, invalid_paths: list<string>}
|
||||||
*/
|
*/
|
||||||
public function update(Block $block, array $change, string $field): array
|
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));
|
$attrs = $this->common($name, $attrs, $spec, $path, $this->autoLabel($name, $context));
|
||||||
if ($defaultLevel) {
|
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);
|
$block = Block::create($name, $attrs);
|
||||||
@@ -496,24 +503,30 @@ final class Composer
|
|||||||
*/
|
*/
|
||||||
private function presetAndAttrs(string $name, array $attrs, array $spec, string $path): array
|
private function presetAndAttrs(string $name, array $attrs, array $spec, string $path): array
|
||||||
{
|
{
|
||||||
if (array_key_exists('preset', $spec)) {
|
$attrs = $this->presets($name, $attrs, $spec, $path);
|
||||||
$attrs = $this->preset($name, $attrs, $spec['preset'], $path . '.preset');
|
if (array_key_exists('attrs', $spec)) {
|
||||||
|
$attrs = $this->fullForm($name, $attrs, $spec['attrs'], $path . '.attrs');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! array_key_exists('attrs', $spec)) {
|
return $this->clearedOverrides($attrs, $spec, $path);
|
||||||
return $attrs;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
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).
|
// An unknown path is the more useful message on its key (it decides invalid_attr_path).
|
||||||
foreach ($result['invalid_paths'] as $invalid) {
|
foreach ($result['invalid_paths'] as $invalid) {
|
||||||
$key = $path . '.attrs.' . $invalid;
|
$key = $path . '.' . $invalid;
|
||||||
if (isset($result['errors'][$key])) {
|
if (isset($result['errors'][$key])) {
|
||||||
$this->errors[$key] = $result['errors'][$key];
|
$this->errors[$key] = $result['errors'][$key];
|
||||||
}
|
}
|
||||||
@@ -557,53 +570,56 @@ final class Composer
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* "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<string, mixed> $attrs
|
||||||
|
* @param array<array-key, mixed> $spec
|
||||||
* @return array<string, mixed>
|
* @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 === '') {
|
$given = array_intersect_key($spec, array_flip(self::PRESET_KEYS));
|
||||||
unset($attrs['modulePreset']);
|
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;
|
return $attrs;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return $result['attrs'];
|
||||||
$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();
|
* 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;
|
return $attrs;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($id === null) {
|
return $spec['clear_overrides'] ? $this->presets->clearOverrides($attrs) : $attrs;
|
||||||
$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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ final class ValueCleaner
|
|||||||
private const BUILDER_ONLY = '/(^|\.)(decoration\.attributes|decoration\.interactions|advanced\.html'
|
private const BUILDER_ONLY = '/(^|\.)(decoration\.attributes|decoration\.interactions|advanced\.html'
|
||||||
. '|advanced\.customCss|css|lottie\.innerContent)(\.|$)/';
|
. '|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(
|
public function __construct(
|
||||||
private ModuleCatalog $catalog,
|
private ModuleCatalog $catalog,
|
||||||
private VariableRef $references,
|
private VariableRef $references,
|
||||||
@@ -92,6 +95,24 @@ final class ValueCleaner
|
|||||||
return $this->cleanNode($module, $path, $value, $subKey, $subKey, $kind);
|
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
|
* 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).
|
* uses it to leave URL and code fields alone (D10).
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ final class DiviIntegration implements Integration
|
|||||||
{
|
{
|
||||||
return new DiviContentComposer(
|
return new DiviContentComposer(
|
||||||
$this->schema,
|
$this->schema,
|
||||||
new DiviComposition($this->modules, $this->design),
|
$this->composition(),
|
||||||
new DiviSaver(),
|
new DiviSaver(),
|
||||||
$this->design,
|
$this->design,
|
||||||
$writable
|
$writable
|
||||||
@@ -225,13 +225,22 @@ final class DiviIntegration implements Integration
|
|||||||
{
|
{
|
||||||
return new ThemeBuilderIntegration(
|
return new ThemeBuilderIntegration(
|
||||||
$this->themeBuilderAccess,
|
$this->themeBuilderAccess,
|
||||||
new DiviComposition($this->modules, $this->design),
|
$this->composition(),
|
||||||
new DiviSaver(),
|
new DiviSaver(),
|
||||||
$this->log,
|
$this->log,
|
||||||
new DynamicFields($this->schema)
|
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
|
private function writer(DiviPageLoader $loader): DiviWriter
|
||||||
{
|
{
|
||||||
return new DiviWriter(
|
return new DiviWriter(
|
||||||
@@ -239,7 +248,7 @@ final class DiviIntegration implements Integration
|
|||||||
new DiviSaver(),
|
new DiviSaver(),
|
||||||
$this->log,
|
$this->log,
|
||||||
$this->design,
|
$this->design,
|
||||||
new DiviComposition($this->modules, $this->design),
|
$this->composition(),
|
||||||
new GlobalEdits($this->access, $this->globalUsages()),
|
new GlobalEdits($this->access, $this->globalUsages()),
|
||||||
new ThemeBuilderEdits($this->themeBuilderAccess, fields: new DynamicFields($this->schema)),
|
new ThemeBuilderEdits($this->themeBuilderAccess, fields: new DynamicFields($this->schema)),
|
||||||
new SimilarSections($this->access)
|
new SimilarSections($this->access)
|
||||||
@@ -253,7 +262,7 @@ final class DiviIntegration implements Integration
|
|||||||
|
|
||||||
private function presetComposer(): PresetComposer
|
private function presetComposer(): PresetComposer
|
||||||
{
|
{
|
||||||
$composition = new DiviComposition($this->modules, $this->design);
|
$composition = $this->composition();
|
||||||
|
|
||||||
return new PresetComposer(
|
return new PresetComposer(
|
||||||
$composition->composer(),
|
$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\VariableRef;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\Warnings;
|
use LIT\MCP\Integrations\Divi\Compose\Warnings;
|
||||||
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
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;
|
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
@@ -29,6 +32,11 @@ defined('ABSPATH') || exit;
|
|||||||
* and ONE ValueCleaner instance are shared by AttrWriter, ShortformWriter and Composer, and the
|
* and ONE ValueCleaner instance are shared by AttrWriter, ShortformWriter and Composer, and the
|
||||||
* same AttrWriter serves Composer and the abilities.
|
* 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
|
* 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
|
* for the abilities); unbound, VariableRef's default map applies. Not final so abilities can be
|
||||||
* tested with a mock.
|
* tested with a mock.
|
||||||
@@ -42,6 +50,7 @@ class DiviComposition
|
|||||||
private ?ValueCleaner $cleaner = null;
|
private ?ValueCleaner $cleaner = null;
|
||||||
private ?AttrWriter $attrWriter = null;
|
private ?AttrWriter $attrWriter = null;
|
||||||
private ?Composer $composer = null;
|
private ?Composer $composer = null;
|
||||||
|
private ?PresetAssignment $assignment = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private DiviModules $modules,
|
private DiviModules $modules,
|
||||||
@@ -49,7 +58,8 @@ class DiviComposition
|
|||||||
private ValueSanitizer $sanitizer = new WpValueSanitizer(),
|
private ValueSanitizer $sanitizer = new WpValueSanitizer(),
|
||||||
private MediaResolver $media = new WpMediaResolver(),
|
private MediaResolver $media = new WpMediaResolver(),
|
||||||
private DynamicContentMap $dynamicContent = new DynamicContentMap(),
|
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->cleaner = null;
|
||||||
$bound->attrWriter = null;
|
$bound->attrWriter = null;
|
||||||
$bound->composer = null;
|
$bound->composer = null;
|
||||||
|
$bound->assignment = null;
|
||||||
|
|
||||||
return $bound;
|
return $bound;
|
||||||
}
|
}
|
||||||
@@ -102,6 +113,21 @@ class DiviComposition
|
|||||||
return $this->attrWriter ??= new AttrWriter($this->modules->catalog(), $this->placeholders(), $this->cleaner());
|
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
|
public function composer(): Composer
|
||||||
{
|
{
|
||||||
return $this->composer ??= new Composer(
|
return $this->composer ??= new Composer(
|
||||||
@@ -110,7 +136,7 @@ class DiviComposition
|
|||||||
new ShortformWriter($this->placeholders(), $this->media, $this->cleaner()),
|
new ShortformWriter($this->placeholders(), $this->media, $this->cleaner()),
|
||||||
$this->attrWriter(),
|
$this->attrWriter(),
|
||||||
$this->cleaner(),
|
$this->cleaner(),
|
||||||
$this->design->read(),
|
$this->presets(),
|
||||||
$this->modules->builderVersion()
|
$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", '
|
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"). '
|
. '"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. '
|
. '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".';
|
. 'Format: lit-guide topic "divi-shortform".';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,9 +35,12 @@ final class DiviUpdateAbility extends DiviWriteAbility
|
|||||||
public function description(): string
|
public function description(): string
|
||||||
{
|
{
|
||||||
return 'Ändert vorhandene Divi-Knoten: changes = Liste von {id, set: {Kurzform-Felder}, attrs: {Attributpfad: '
|
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 '
|
. '{Breakpoint: Wert}}, preset, group_presets, clear_overrides, label}. null entfernt einen Wert. '
|
||||||
. 'unverändert. Jeder Knoten nur einmal pro Aufruf; denselben Pfad nicht zugleich über set und attrs. '
|
. 'preset = Name/ID oder Liste (Stapel, bis 10; bei gleicher Priorität gewinnt das letzte Preset im '
|
||||||
. 'tree_rev aus lit-divi-outline ist Pflicht; alles oder nichts.';
|
. '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
|
public function inputSchema(): array
|
||||||
@@ -58,7 +61,22 @@ final class DiviUpdateAbility extends DiviWriteAbility
|
|||||||
'type' => 'object',
|
'type' => 'object',
|
||||||
'description' => 'Vollform: Attributpfad → Breakpoint-Werte.',
|
'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' => [
|
'label' => [
|
||||||
'type' => ['string', 'null'],
|
'type' => ['string', 'null'],
|
||||||
'description' => 'Admin-Label im Divi-Builder; null entfernt.',
|
'description' => 'Admin-Label im Divi-Builder; null entfernt.',
|
||||||
@@ -154,4 +172,21 @@ final class DiviUpdateAbility extends DiviWriteAbility
|
|||||||
|
|
||||||
return $change;
|
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];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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). '
|
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 '
|
. '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 '
|
. '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.';
|
. 'dann tree_rev für die lit-divi-* Werkzeuge.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace LIT\MCP\Tests\Support;
|
namespace LIT\MCP\Tests\Support;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\AttrWriter;
|
use LIT\MCP\Integrations\Divi\Compose\AttrWriter;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\Composer;
|
use LIT\MCP\Integrations\Divi\Compose\Composer;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\LinkTargets;
|
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\ValueSanitizer;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||||||
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
|
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
|
* 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
|
* 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
|
* 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<string, string>)|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();
|
$design ??= DiviFixtures::designSystem();
|
||||||
$placeholders = new Placeholders($design);
|
$placeholders = new Placeholders($design);
|
||||||
$cleaner = new ValueCleaner(DiviFixtures::catalog(), new VariableRef($design), self::sanitizer($unfiltered));
|
$cleaner = new ValueCleaner(DiviFixtures::catalog(), new VariableRef($design), self::sanitizer($unfiltered));
|
||||||
@@ -208,8 +218,52 @@ final class ComposeFixture
|
|||||||
new ShortformWriter($placeholders, self::media(), $cleaner),
|
new ShortformWriter($placeholders, self::media(), $cleaner),
|
||||||
$attrWriter,
|
$attrWriter,
|
||||||
$cleaner,
|
$cleaner,
|
||||||
$design,
|
self::presetAssignment($book, $groupsOf, $design, $cleaner),
|
||||||
'5.2.1'
|
'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<string, string>)|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(...)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ declare(strict_types=1);
|
|||||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||||||
|
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\Composer;
|
use LIT\MCP\Integrations\Divi\Compose\Composer;
|
||||||
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
|
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\ComposeFixture;
|
||||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
@@ -424,6 +426,165 @@ final class ComposerTest extends TestCase
|
|||||||
self::assertArrayNotHasKey('modulePreset', $result['attrs']);
|
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<string, string> 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
|
public function testBuilderVersionMustBeAVersion(): void
|
||||||
{
|
{
|
||||||
$this->expectException(InvalidArgumentException::class);
|
$this->expectException(InvalidArgumentException::class);
|
||||||
@@ -434,7 +595,7 @@ final class ComposerTest extends TestCase
|
|||||||
ComposeFixture::shortform(),
|
ComposeFixture::shortform(),
|
||||||
ComposeFixture::attrWriter(),
|
ComposeFixture::attrWriter(),
|
||||||
ComposeFixture::cleaner(),
|
ComposeFixture::cleaner(),
|
||||||
DiviFixtures::designSystem(),
|
ComposeFixture::presetAssignment(),
|
||||||
'5.2.1"},"x":{"'
|
'5.2.1"},"x":{"'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.advanced.width', 'x'));
|
||||||
self::assertSame('x', ComposeFixture::cleaner()->clean('divi/lottie', 'lottie.innerContentish', '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('<b>p</b>'), '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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
|||||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\Document;
|
use LIT\MCP\Integrations\Divi\BlockTree\Document;
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
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\StoredNodeGuard;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\TreeEditor;
|
use LIT\MCP\Integrations\Divi\Compose\TreeEditor;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||||||
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
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\DiviComposition;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||||||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||||
@@ -26,7 +28,7 @@ final class DiviCompositionTest extends TestCase
|
|||||||
/** @var list<int> */
|
/** @var list<int> */
|
||||||
private array $registryCalls = [];
|
private array $registryCalls = [];
|
||||||
|
|
||||||
private function composition(): DiviComposition
|
private function composition(?DesignSystemWriter $presets = null): DiviComposition
|
||||||
{
|
{
|
||||||
$modules = Mockery::mock(DiviModules::class);
|
$modules = Mockery::mock(DiviModules::class);
|
||||||
$modules->allows('catalog')->andReturn(DiviFixtures::catalog());
|
$modules->allows('catalog')->andReturn(DiviFixtures::catalog());
|
||||||
@@ -45,10 +47,33 @@ final class DiviCompositionTest extends TestCase
|
|||||||
ComposeFixture::sanitizer(),
|
ComposeFixture::sanitizer(),
|
||||||
ComposeFixture::media(),
|
ComposeFixture::media(),
|
||||||
$registry,
|
$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
|
private static function siteTitle(): string
|
||||||
{
|
{
|
||||||
return VariableRef::serialize('content', 'site_title', ['before' => '', 'after' => '']);
|
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)
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -261,5 +261,51 @@ final class DiviUpdateAbilityTest extends DiviWriteAbilityTestCase
|
|||||||
self::assertStringNotContainsString('1058', (string) json_encode($hidden));
|
self::assertStringNotContainsString('1058', (string) json_encode($hidden));
|
||||||
self::assertStringContainsString('[global 1059]', (string) $shown['outline']);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -263,4 +263,13 @@ final class ContentCreateAbilityTest extends TestCase
|
|||||||
Functions\when('current_user_can')->justReturn(false);
|
Functions\when('current_user_can')->justReturn(false);
|
||||||
self::assertToolError($ability->permission(['post_type' => 'page']), 'forbidden');
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user