feat: jedes Divi-Tool und lit/content-create erklärt seine Divi-Fähigkeiten (RequiresDiviFeatures, DeclaresRequirements, FeatureSets); Tabelle Tool → Fähigkeiten als Test

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-18 22:14:39 +02:00
co-authored by Claude Sonnet 5.5
parent e387d8d29e
commit fdbda8012c
39 changed files with 1131 additions and 23 deletions
@@ -10,6 +10,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Outline;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
@@ -36,6 +37,19 @@ defined('ABSPATH') || exit;
*/
final class DiviContentComposer implements ContentComposer
{
/**
* What a new Divi entry needs: Catalog (diviEnabled(), composing), DesignRead (presets, variables), DynamicSources
* (the composition checks references against the new entry's map), PageRead (link targets) and PageWrite (saveNew
* with the Divi flags). No library or Theme Builder scope: the draft is an ordinary entry.
*/
private const FEATURES = [
DiviFeature::PageRead,
DiviFeature::PageWrite,
DiviFeature::Catalog,
DiviFeature::DesignRead,
DiviFeature::DynamicSources,
];
/** @var Closure(): bool */
private Closure $writable;
@@ -60,6 +74,11 @@ final class DiviContentComposer implements ContentComposer
return ($this->writable)();
}
public function requirements(): array
{
return array_map(static fn (DiviFeature $feature): string => $feature->value, self::FEATURES);
}
public function refusal(): ?WP_Error
{
$status = ReleaseGate::status();
@@ -8,6 +8,7 @@ use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use WP_Error;
defined('ABSPATH') || exit;
@@ -60,6 +61,12 @@ final class DiviDeleteAbility extends DiviWriteAbility
return Annotations::destructive(false);
}
/** Like lit/divi-move: TreeEditor::remove() only, no composition of client values. */
protected function baseFeatures(): array
{
return [DiviFeature::PageRead, DiviFeature::PageWrite, DiviFeature::Catalog, DiviFeature::DesignRead];
}
protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
{
/** @var array<string, Block> $nodes */
@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use WP_Error;
defined('ABSPATH') || exit;
@@ -55,6 +56,12 @@ final class DiviDuplicateNodeAbility extends DiviWriteAbility
return false;
}
/** Like lit/divi-move: TreeEditor::duplicate() copies stored bytes, no composition of client values. */
protected function baseFeatures(): array
{
return [DiviFeature::PageRead, DiviFeature::PageWrite, DiviFeature::Catalog, DiviFeature::DesignRead];
}
protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
{
$id = is_string($input['id'] ?? null) ? $input['id'] : '';
@@ -11,6 +11,8 @@ use LIT\MCP\Abilities\JsonMap;
use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
@@ -28,7 +30,7 @@ defined('ABSPATH') || exit;
* left out with its reason (DynamicFields). Hidden fields (security audit M4) are neither offered nor
* named. The field list needs the Divi role option read_dynamic_content_custom_fields (E3, F25).
*/
final class DiviDynamicSourcesAbility implements Ability
final class DiviDynamicSourcesAbility implements Ability, RequiresDiviFeatures
{
/** Candidates the default sample lookup checks for edit_post. */
private const SAMPLE_CANDIDATES = 10;
@@ -130,6 +132,15 @@ final class DiviDynamicSourcesAbility implements Ability
return false;
}
/**
* Divi's dynamic content registry (DynamicSources); listing custom fields asks the role option
* read_dynamic_content_custom_fields (Roles) and omits them without it, so a broken roles check must not pass.
*/
public function diviFeatures(array $input): array
{
return [DiviFeature::DynamicSources, DiviFeature::Roles];
}
public function permission(array $input): bool|WP_Error
{
$type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts');
@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use WP_Error;
defined('ABSPATH') || exit;
@@ -64,6 +65,21 @@ final class DiviInsertAbility extends DiviWriteAbility
return Annotations::write(false);
}
/**
* Loads and saves the entry (PageRead, PageWrite) and composes new nodes: Catalog, DesignRead (presets,
* variables) and DynamicSources (references are checked against the dynamic content map of the entry).
*/
protected function baseFeatures(): array
{
return [
DiviFeature::PageRead,
DiviFeature::PageWrite,
DiviFeature::Catalog,
DiviFeature::DesignRead,
DiviFeature::DynamicSources,
];
}
protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
{
$editor = $composition->editor();
@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use WP_Error;
defined('ABSPATH') || exit;
@@ -52,6 +53,15 @@ final class DiviMoveAbility extends DiviWriteAbility
return Annotations::write(false);
}
/**
* No client value enters the tree (no DynamicSources): load and save (PageRead, PageWrite), StructureRules
* need the catalog, the quality warnings of the result read the design system (DesignRead).
*/
protected function baseFeatures(): array
{
return [DiviFeature::PageRead, DiviFeature::PageWrite, DiviFeature::Catalog, DiviFeature::DesignRead];
}
protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
{
$id = is_string($input['id'] ?? null) ? $input['id'] : '';
@@ -7,6 +7,8 @@ namespace LIT\MCP\Integrations\Divi\Pages;
use Closure;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FeatureSets;
use WP_Error;
defined('ABSPATH') || exit;
@@ -117,6 +119,29 @@ final class DiviNodeCopyAbility extends DiviWriteAbility
return $this->loader->library()?->canInsert();
}
/**
* Like lit/divi-insert (StoredNodeGuard cleans the copied node: Catalog, DesignRead, DynamicSources); the source
* entry is only read, so a library entry or Theme Builder layout as source adds the Divi roles.
*/
protected function baseFeatures(): array
{
return [
DiviFeature::PageRead,
DiviFeature::PageWrite,
DiviFeature::Catalog,
DiviFeature::DesignRead,
DiviFeature::DynamicSources,
];
}
public function diviFeatures(array $input): array
{
return FeatureSets::merge(
parent::diviFeatures($input),
FeatureSets::ofEntry($input['source_post_id'] ?? null, false)
);
}
protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
{
$source = $this->loader->load($input['source_post_id'] ?? null, false);
@@ -14,6 +14,9 @@ use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FeatureSets;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
use WP_Error;
@@ -23,7 +26,7 @@ defined('ABSPATH') || exit;
* lit/divi-node-get: all attributes of one node in the full form (spec §5.4, §5.7) –
* attribute path => breakpoint map – plus the current short-form values.
*/
final class DiviNodeGetAbility implements Ability
final class DiviNodeGetAbility implements Ability, RequiresDiviFeatures
{
public function __construct(private DiviPageLoader $loader, private DesignSystemReader $design)
{
@@ -123,6 +126,18 @@ final class DiviNodeGetAbility implements Ability
return true;
}
/**
* Loads the entry (PageRead) and names preset labels (DesignRead); ShortformMap is pure. A library entry or
* Theme Builder layout as post_id adds the Divi roles.
*/
public function diviFeatures(array $input): array
{
return FeatureSets::merge(
[DiviFeature::PageRead, DiviFeature::DesignRead],
FeatureSets::ofEntry($input['post_id'] ?? null, false)
);
}
public function permission(array $input): bool|WP_Error
{
$page = $this->loader->load($input['post_id'] ?? null, false);
@@ -11,6 +11,9 @@ use LIT\MCP\Abilities\Rev;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Outline;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FeatureSets;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use WP_Error;
defined('ABSPATH') || exit;
@@ -19,7 +22,7 @@ defined('ABSPATH') || exit;
* lit/divi-outline: the page tree with path IDs, types, admin labels, text excerpts and
* preset names plus tree_rev (spec §5.3, §5.7). Every Divi write needs this tree_rev.
*/
final class DiviOutlineAbility implements Ability
final class DiviOutlineAbility implements Ability, RequiresDiviFeatures
{
public function __construct(private DiviPageLoader $loader, private DesignSystemReader $design)
{
@@ -137,6 +140,18 @@ final class DiviOutlineAbility implements Ability
return true;
}
/**
* Loads the entry (PageRead) and names preset labels (DesignRead). Global modules are shown by ID only, never
* resolved. A library entry or Theme Builder layout as post_id adds the Divi roles.
*/
public function diviFeatures(array $input): array
{
return FeatureSets::merge(
[DiviFeature::PageRead, DiviFeature::DesignRead],
FeatureSets::ofEntry($input['post_id'] ?? null, false)
);
}
public function permission(array $input): bool|WP_Error
{
$page = $this->loader->load($input['post_id'] ?? null, false);
@@ -13,6 +13,9 @@ use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\Compose\TextReplacer;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FeatureSets;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Log;
@@ -36,7 +39,7 @@ defined('ABSPATH') || exit;
* others go on. Inserted text is cleaned by the field kind through
* ValueCleaner::textReplacementFor() (S2/S28); URL and code fields are never touched.
*/
final class DiviReplaceTextAbility implements Ability
final class DiviReplaceTextAbility implements Ability, RequiresDiviFeatures
{
private const MAX_TARGETS = 20;
@@ -128,6 +131,34 @@ final class DiviReplaceTextAbility implements Ability
return false;
}
/**
* The dry run (default) reads: PageRead, Catalog and DesignRead; the cleaner builds its reference check from the
* entry's dynamic content map (DynamicSources). A real run (dry_run: false) also saves each target, which may be a
* library entry or Theme Builder layout (PageWrite and what FeatureSets::ofEntry() adds).
*/
public function diviFeatures(array $input): array
{
$features = [
DiviFeature::PageRead,
DiviFeature::Catalog,
DiviFeature::DesignRead,
DiviFeature::DynamicSources,
];
if (($input['dry_run'] ?? true) !== false) {
return $features;
}
$features[] = DiviFeature::PageWrite;
foreach (is_array($input['targets'] ?? null) ? $input['targets'] : [] as $target) {
$features = FeatureSets::merge(
$features,
FeatureSets::ofEntry(is_array($target) ? ($target['post_id'] ?? null) : null, true)
);
}
return $features;
}
public function permission(array $input): bool|WP_Error
{
$type = PostGuard::postType($input['post_type'] ?? 'page', $this->schema, 'edit_posts');
@@ -9,6 +9,7 @@ use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\Compose\ColumnWidths;
use LIT\MCP\Integrations\Divi\Design\PresetAssignment;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use WP_Error;
defined('ABSPATH') || exit;
@@ -101,6 +102,18 @@ final class DiviUpdateAbility extends DiviWriteAbility
return Annotations::write(true);
}
/** Like lit/divi-insert: the Composer checks every changed value (Catalog, DesignRead, DynamicSources). */
protected function baseFeatures(): array
{
return [
DiviFeature::PageRead,
DiviFeature::PageWrite,
DiviFeature::Catalog,
DiviFeature::DesignRead,
DiviFeature::DynamicSources,
];
}
protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
{
$changes = is_array($input['changes'] ?? null) ? array_values($input['changes']) : [];
@@ -6,6 +6,9 @@ namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\FeatureSets;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use WP_Error;
defined('ABSPATH') || exit;
@@ -17,7 +20,7 @@ defined('ABSPATH') || exit;
* validated, all or nothing (ruling S39) – and DiviWriter::commit() stores it (library entries:
* GlobalEdits) and builds the result. The composition is bound to the page (DiviWriter::composition(), ruling S22).
*/
abstract class DiviWriteAbility implements Ability
abstract class DiviWriteAbility implements Ability, RequiresDiviFeatures
{
public function __construct(protected DiviWriter $writer)
{
@@ -31,6 +34,19 @@ abstract class DiviWriteAbility implements Ability
*/
abstract protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error;
/**
* What the tool needs whatever entry it changes; diviFeatures() adds what the entry post_id names (a library
* entry or Theme Builder layout is saved with its own capability, D5).
*
* @return list<DiviFeature>
*/
abstract protected function baseFeatures(): array;
public function diviFeatures(array $input): array
{
return FeatureSets::merge($this->baseFeatures(), FeatureSets::ofEntry($input['post_id'] ?? null, true));
}
public function outputSchema(): ?array
{
return ToolResult::schema([