feat: Divi-Fähigkeiten (DiviFeature, Requirement, ConstantRequirement) an Call, Mirror und Manifest; alle acht Adapter getaggt; Tests für Vollständigkeit und die gepinnte Zuordnung

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-18 21:59:43 +02:00
co-authored by Claude Sonnet 5.5
parent e6e0f914ba
commit 392fe7c569
19 changed files with 552 additions and 48 deletions
@@ -8,6 +8,7 @@ use ET\Builder\Packages\GlobalData\GlobalData;
use ET\Builder\Packages\GlobalData\GlobalPreset;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\Manifest;
defined('ABSPATH') || exit;
@@ -34,7 +35,8 @@ class DesignSystemReader implements DiviAdapter
new Call(GlobalData::class . '::get_global_variables', 0),
new Call(GlobalPreset::class . '::get_data', 0),
],
keys: [self::OPTIONS]
keys: [self::OPTIONS],
features: [DiviFeature::DesignRead, DiviFeature::PageRead]
);
}
@@ -10,6 +10,7 @@ use ET\Builder\Packages\GlobalData\GlobalPreset;
use ET\Builder\Packages\ModuleLibrary\ModuleRegistration;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\Mirror;
@@ -44,7 +45,8 @@ class DesignSystemWriter implements DiviAdapter
return new Manifest(
Manifest::DESIGN,
calls: [
new Call(GlobalPreset::class . '::get_data', 0),
// Read by the preset tools as well as by the design system tool.
new Call(GlobalPreset::class . '::get_data', 0, [DiviFeature::DesignRead, DiviFeature::PresetWrite]),
new Call(GlobalPreset::class . '::prepare_data', 1),
new Call(GlobalPreset::class . '::validate_preset_count', 3),
new Call(GlobalPreset::class . '::get_deleted_preset_ids', 3),
@@ -52,8 +54,17 @@ class DesignSystemWriter implements DiviAdapter
new Call(GlobalPreset::class . '::save_data', 1),
new Call(Conversion::class . '::get_preset_attrs_mapping', 1),
new Call(Conversion::class . '::get_preset_attrs', 3),
new Call(GlobalPreset::class . '::get_group_preset_default_attr', 1),
new Call(ModuleRegistration::class . '::get_module_settings', 1),
// groupsOf(): also used by the composer for group presets (insert/update/assign), so Catalog too.
new Call(
GlobalPreset::class . '::get_group_preset_default_attr',
1,
[DiviFeature::Catalog, DiviFeature::PresetWrite]
),
new Call(
ModuleRegistration::class . '::get_module_settings',
1,
[DiviFeature::Catalog, DiviFeature::PresetWrite]
),
new Call(UserRole::class . '::can_current_user_use_visual_builder', 0),
],
mirrors: [
@@ -65,7 +76,8 @@ class DesignSystemWriter implements DiviAdapter
'Speicherablauf der Presets (prepare_data, validate_preset_count, Bereinigung der Divi-4-Option '
. 'beim Löschen, save_data)'
),
]
],
features: [DiviFeature::PresetWrite]
);
}
@@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi\Library;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\Mirror;
@@ -55,7 +56,14 @@ class DiviLibraryStore implements DiviAdapter
{
return new Manifest(
Manifest::LIBRARY,
calls: [new Call(self::GLOBAL_LAYOUT . '::is_global_layout_template', 1)],
calls: [
// isGlobal() serves listing and describing as well as the global edits.
new Call(
self::GLOBAL_LAYOUT . '::is_global_layout_template',
1,
[DiviFeature::LibraryRead, DiviFeature::LibraryWrite]
),
],
postTypes: [self::POST_TYPE],
taxonomies: [...self::TAXONOMIES, 'layout_category', 'layout_tag'],
keys: [self::MODULE_TYPE, self::EXCLUDED_OPTIONS, self::BUILT_FOR, self::TEMPLATE_TYPE],
@@ -68,7 +76,8 @@ class DiviLibraryStore implements DiviAdapter
'Terme und Meta neuer Bibliothekseinträge (scope, layout_type, module_width, _et_pb_module_type, '
. '_et_pb_excluded_global_options, _et_pb_built_for_post_type, _et_pb_template_type)'
),
]
],
features: [DiviFeature::LibraryWrite]
);
}
+6 -1
View File
@@ -8,6 +8,7 @@ use Closure;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use WP_Error;
@@ -33,7 +34,11 @@ class DiviRoles implements DiviAdapter
public static function manifest(): Manifest
{
return new Manifest(Manifest::LIBRARY, calls: [new Call('et_pb_is_allowed', 1)]);
return new Manifest(
Manifest::LIBRARY,
calls: [new Call('et_pb_is_allowed', 1)],
features: [DiviFeature::Roles]
);
}
/** @param (Closure(string): bool)|null $check Test seam; null = Divi's et_pb_is_allowed(). */
+9 -2
View File
@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\Mirror;
@@ -36,7 +37,12 @@ final class DiviPages implements DiviAdapter
return new Manifest(
Manifest::PAGES,
calls: [
new Call('ET_Core_PageResource::remove_static_resources', 3),
// Also called by DiviThemeBuilder::clearSiteCaches() – a break locks both features.
new Call(
'ET_Core_PageResource::remove_static_resources',
3,
[DiviFeature::PageWrite, DiviFeature::ThemeBuilderWrite]
),
new Call('et_builder_get_public_post_types', 0),
],
keys: ['_et_pb_use_divi_5', '_et_pb_use_builder', '_et_pb_show_page_creation', 'et_fb'],
@@ -48,7 +54,8 @@ final class DiviPages implements DiviAdapter
'SyncToServerController',
'Builder-Kennzeichen einer Seite (_et_pb_use_divi_5, _et_pb_use_builder, _et_pb_show_page_creation)'
),
]
],
features: [DiviFeature::PageWrite]
);
}
@@ -9,6 +9,7 @@ use LIT\MCP\Integrations\Divi\Compose\Placeholders;
use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Log;
use Throwable;
@@ -46,7 +47,11 @@ class DynamicContentMap implements DiviAdapter
public static function manifest(): Manifest
{
return new Manifest(Manifest::CATALOG, calls: [new Call(self::REGISTRY . '::get_options', 2)]);
return new Manifest(
Manifest::CATALOG,
calls: [new Call(self::REGISTRY . '::get_options', 2)],
features: [DiviFeature::DynamicSources]
);
}
/** @return array<string, array<string, string>>|null Null = use DynamicContentRules' default map. */
+15 -4
View File
@@ -10,11 +10,17 @@ defined('ABSPATH') || exit;
* One Divi function or static method an adapter calls (spec §4.1), with the number of arguments the plugin passes.
* $args null: only its existence matters (a function Divi itself needs, e.g. et_() inside store_template).
*/
final class Call
final class Call implements Requirement
{
/** @param string $symbol "function_name" or "Fully\Qualified\Class::method". */
public function __construct(public readonly string $symbol, public readonly ?int $args = null)
{
/**
* @param string $symbol "function_name" or "Fully\Qualified\Class::method".
* @param list<DiviFeature> $features [] = the default of the manifest.
*/
public function __construct(
public readonly string $symbol,
public readonly ?int $args = null,
private readonly array $features = []
) {
}
public function className(): ?string
@@ -27,6 +33,11 @@ final class Call
return str_contains($this->symbol, '::') ? explode('::', $this->symbol, 2)[1] : $this->symbol;
}
public function features(): array
{
return $this->features;
}
public function label(): string
{
$class = $this->className();
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/** A Divi constant an adapter reads (spec §4.1); it must be defined. */
final class ConstantRequirement implements Requirement
{
/** @param list<DiviFeature> $features [] = the default of the manifest. */
public function __construct(public readonly string $name, private readonly array $features = [])
{
}
public function label(): string
{
return 'Konstante ' . $this->name;
}
public function features(): array
{
return $this->features;
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
// The installed PHPCompatibility sniffs predate enums (PHP 8.1) and read their methods as plain functions.
// phpcs:disable PHPCompatibility.Variables.ForbiddenThisUseContexts.OutsideObjectContext
/**
* What the plugin can do with Divi, as far as a Divi update may break it separately: every manifest entry (call,
* mirror, constant) belongs to one or more features, and the release gate decides per feature.
*/
enum DiviFeature: string
{
case PageRead = 'page-read';
case PageWrite = 'page-write';
case Catalog = 'catalog';
case DynamicSources = 'dynamic-sources';
case DesignRead = 'design-read';
case PresetWrite = 'preset-write';
case LibraryRead = 'library-read';
case LibraryWrite = 'library-write';
case Roles = 'roles';
case ThemeBuilderRead = 'tb-read';
case ThemeBuilderWrite = 'tb-write';
/** Whether the feature changes data (the four *Write cases). */
public function isWrite(): bool
{
return match ($this) {
self::PageWrite, self::PresetWrite, self::LibraryWrite, self::ThemeBuilderWrite => true,
default => false,
};
}
/** Name for messages, the status tool and the admin tab (German). */
public function label(): string
{
return match ($this) {
self::PageRead => 'Seiten lesen',
self::PageWrite => 'Seiten schreiben',
self::Catalog => 'Modulkatalog',
self::DynamicSources => 'Dynamische Inhalte',
self::DesignRead => 'Designsystem lesen',
self::PresetWrite => 'Presets schreiben',
self::LibraryRead => 'Bibliothek lesen',
self::LibraryWrite => 'Bibliothek schreiben',
self::Roles => 'Divi-Rollen',
self::ThemeBuilderRead => 'Theme Builder lesen',
self::ThemeBuilderWrite => 'Theme Builder schreiben',
};
}
}
+42 -12
View File
@@ -18,27 +18,56 @@ final class Manifest
public const DESIGN = 'Designsystem';
public const CATALOG = 'Katalog';
/** @var list<ConstantRequirement> */
public readonly array $constants;
/**
* @param string $area One of the constants above (the five areas of spec §3).
* @param list<Call> $calls Divi functions and static methods the adapter calls.
* @param list<string> $constants Divi constants it reads.
* @param list<string> $postTypes Divi post types it needs registered.
* @param list<string> $taxonomies Divi taxonomies it needs registered.
* @param list<string> $blockTypes Divi block types it needs (registered or in Divi's module metadata).
* @param list<string> $keys Other Divi names it uses as strings: meta keys, options, internal post
* types that need no registration check, query arguments.
* @param list<Mirror> $mirrors Divi functions whose storage logic it repeats.
* @param string $area One of the constants above (the five areas of spec §3).
* @param list<Call> $calls Divi functions and static methods the adapter calls.
* @param list<string|ConstantRequirement> $constants Divi constants it reads; a plain name stands for a
* ConstantRequirement without features of its own.
* @param list<string> $postTypes Divi post types it needs registered.
* @param list<string> $taxonomies Divi taxonomies it needs registered.
* @param list<string> $blockTypes Divi block types it needs (registered or in Divi's
* module metadata).
* @param list<string> $keys Other Divi names it uses as strings: meta keys, options,
* internal post types that need no registration check,
* query arguments.
* @param list<Mirror> $mirrors Divi functions whose storage logic it repeats.
* @param list<DiviFeature> $features The features of every call, mirror and constant that names
* none itself (featuresOf()).
*/
public function __construct(
public readonly string $area,
public readonly array $calls = [],
public readonly array $constants = [],
array $constants = [],
public readonly array $postTypes = [],
public readonly array $taxonomies = [],
public readonly array $blockTypes = [],
public readonly array $keys = [],
public readonly array $mirrors = []
public readonly array $mirrors = [],
public readonly array $features = []
) {
$this->constants = array_map(
static fn (string|ConstantRequirement $constant): ConstantRequirement => is_string($constant)
? new ConstantRequirement($constant)
: $constant,
array_values($constants)
);
}
/** @return list<Requirement> Calls, constants and mirrors – everything the release gate checks as code. */
public function requirements(): array
{
return [...$this->calls, ...$this->constants, ...$this->mirrors];
}
/** @return list<DiviFeature> The features $requirement names itself, else this manifest's default. */
public function featuresOf(Requirement $requirement): array
{
$own = $requirement->features();
return $own !== [] ? $own : $this->features;
}
/**
@@ -51,11 +80,12 @@ final class Manifest
foreach ($this->calls as $call) {
$names[] = $call->className() ?? $call->member();
}
$constants = array_map(static fn (ConstantRequirement $constant): string => $constant->name, $this->constants);
$mirrored = array_map(static fn (Mirror $mirror): string => $mirror->function, $this->mirrors);
return array_values(array_unique(array_merge(
$names,
$this->constants,
$constants,
$this->postTypes,
$this->taxonomies,
$this->keys,
@@ -57,7 +57,8 @@ class ManifestCheck
$checks[$manifest->area . ': ' . $call->label()] = static fn (): bool => self::accepts($call);
}
foreach ($manifest->constants as $constant) {
$checks[$manifest->area . ': Konstante ' . $constant] = static fn (): bool => defined($constant);
$checks[$manifest->area . ': ' . $constant->label()] =
static fn (): bool => defined($constant->name);
}
}
+16 -8
View File
@@ -10,25 +10,33 @@ defined('ABSPATH') || exit;
* A Divi function whose storage logic an adapter repeats instead of calling it (spec §4.1 "mirrors"). Its
* fingerprint (SourceFingerprint) must match the one in fingerprints.json, else Divi changed what we copy.
*/
final class Mirror
final class Mirror implements Requirement
{
/**
* @param string $id Key in fingerprints.json and name of tests/Fixtures/divi/reference/<id>.txt.
* @param string $file Divi file, relative to the Divi theme directory.
* @param string $function Function or method name.
* @param string|null $class Class of a method – the short name is enough (the file fixes where it
* lives, and a short name is no Divi name for DiviBoundaryTest); null for a function.
* @param string $what What the plugin repeats (German, for reports).
* @param string $id Key in fingerprints.json and name of tests/Fixtures/divi/reference/<id>.txt.
* @param string $file Divi file, relative to the Divi theme directory.
* @param string $function Function or method name.
* @param string|null $class Class of a method – the short name is enough (the file fixes where it
* lives, and a short name is no Divi name for DiviBoundaryTest); null for a
* function.
* @param string $what What the plugin repeats (German, for reports).
* @param list<DiviFeature> $features [] = the default of the manifest.
*/
public function __construct(
public readonly string $id,
public readonly string $file,
public readonly string $function,
public readonly ?string $class,
public readonly string $what
public readonly string $what,
private readonly array $features = []
) {
}
public function features(): array
{
return $this->features;
}
public function label(): string
{
$class = $this->class === null ? '' : SourceFingerprint::shortName($this->class) . '::';
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
defined('ABSPATH') || exit;
/** One thing an adapter needs from Divi (a call, a mirrored function or a constant) and the features it serves. */
interface Requirement
{
/** Name for reports, e.g. "GlobalPreset::get_data()" (without the manifest area). */
public function label(): string;
/**
* The features this requirement serves itself; [] = the default of the manifest that lists it
* (Manifest::featuresOf() resolves that).
*
* @return list<DiviFeature>
*/
public function features(): array;
}
+3 -1
View File
@@ -9,6 +9,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
@@ -46,7 +47,8 @@ class DiviModules implements DiviAdapter
],
constants: ['ET_BUILDER_PRODUCT_VERSION'],
blockTypes: [GlobalInstance::BLOCK],
keys: ['et_pb_canvas', '_et_pb_speculation']
keys: ['et_pb_canvas', '_et_pb_speculation'],
features: [DiviFeature::Catalog]
);
}
@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\Mirror;
@@ -84,16 +85,31 @@ class DiviThemeBuilder implements DiviAdapter
return new Manifest(
Manifest::THEME_BUILDER,
calls: [
new Call('et_theme_builder_get_theme_builder_post_id', 2),
// liveId(): reads the live post, or creates it like Divi's first save.
new Call(
'et_theme_builder_get_theme_builder_post_id',
2,
[DiviFeature::ThemeBuilderRead, DiviFeature::ThemeBuilderWrite]
),
new Call('et_theme_builder_get_theme_builder_template_ids', 2),
new Call('et_theme_builder_get_template', 1),
new Call('et_theme_builder_insert_layout', 1),
new Call('et_theme_builder_store_template', 3),
new Call('et_theme_builder_insert_layout', 1, [DiviFeature::ThemeBuilderWrite]),
new Call('et_theme_builder_store_template', 3, [DiviFeature::ThemeBuilderWrite]),
new Call('et_theme_builder_get_flat_template_settings_options', 0),
new Call('et_theme_builder_clear_wp_cache', 1),
// clearSiteCaches() also runs after every page save with a site-wide scope (DiviSaver).
new Call(
'et_theme_builder_clear_wp_cache',
1,
[DiviFeature::ThemeBuilderWrite, DiviFeature::PageWrite]
),
// store_template needs Divi's et_() helper (E15).
new Call('et_'),
new Call('ET_Core_PageResource::remove_static_resources', 3),
new Call('et_', null, [DiviFeature::ThemeBuilderWrite]),
// Also called by DiviPages::clearPageCss() – a break locks both features.
new Call(
'ET_Core_PageResource::remove_static_resources',
3,
[DiviFeature::ThemeBuilderWrite, DiviFeature::PageWrite]
),
],
postTypes: self::postTypes(),
keys: [
@@ -106,23 +122,27 @@ class DiviThemeBuilder implements DiviAdapter
'et_theme_builder_api_save',
null,
'Vorlagenliste des Live-Theme-Builders (_et_template mit Sicherung et_tb_templates_backup_) und '
. '„ungenutzt“-Markierung'
. '„ungenutzt“-Markierung',
[DiviFeature::ThemeBuilderWrite]
),
new Mirror(
'tb-store-template',
$dir . 'theme-builder.php',
'et_theme_builder_store_template',
null,
'Bedingungen (_et_use_on, _et_exclude_from) und Aktiv-Schalter (_et_enabled) einer Vorlage'
'Bedingungen (_et_use_on, _et_exclude_from) und Aktiv-Schalter (_et_enabled) einer Vorlage',
[DiviFeature::ThemeBuilderWrite]
),
new Mirror(
'tb-load-setting-options',
$dir . 'theme-builder.php',
'et_theme_builder_load_template_setting_options',
null,
'Zerlegen der Bedingungs-IDs (TemplateConditions)'
'Zerlegen der Bedingungs-IDs (TemplateConditions)',
[DiviFeature::ThemeBuilderRead, DiviFeature::ThemeBuilderWrite]
),
]
],
features: [DiviFeature::ThemeBuilderRead]
);
}