feat: lit/content-create with Divi short form

New input divi (1-30 sections, short form like lit-divi-insert) builds a Divi 5
layout in one divi/placeholder. The whole description is checked before the
insert (Divi's map for "no entry yet"); the draft is inserted with its normal
WordPress fields only, then DiviContentComposer composes again bound to the new
entry and stores it via DiviSaver::saveNew (same kses window as save(), strict
DiviFlags). A refusal or failure deletes the draft. Result adds tree_rev and the
outline.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-26 16:46:50 +02:00
co-authored by Claude Opus 5.5
parent 3efcfaabec
commit 5965e32acb
12 changed files with 861 additions and 23 deletions
+14
View File
@@ -8,6 +8,7 @@ use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility; use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviComposition; use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility; use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviDuplicateNodeAbility; use LIT\MCP\Integrations\Divi\Pages\DiviDuplicateNodeAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility; use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility;
@@ -22,12 +23,14 @@ use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility; use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility;
use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\Integration; use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
/** /**
* Divi 5 pages (spec §5, Etappe 2). Only available when the Divi 5 builder is loaded * Divi 5 pages (spec §5, Etappe 2). Only available when the Divi 5 builder is loaded
* (spec §2 principle 4); Divi 4 is never edited (spec §3.4). The write abilities share one * (spec §2 principle 4); Divi 4 is never edited (spec §3.4). The write abilities share one
* DiviWriter; each binds the composition to its entry via DiviWriter::composition(). * DiviWriter; each binds the composition to its entry via DiviWriter::composition().
* contentComposer() gives lit/content-create the Divi short form (spec §6.2).
*/ */
final class DiviIntegration implements Integration final class DiviIntegration implements Integration
{ {
@@ -50,6 +53,17 @@ final class DiviIntegration implements Integration
return DiviModules::isAvailable(); return DiviModules::isAvailable();
} }
/** Divi short form for lit/content-create; available() reports whether Divi 5 is loaded. */
public function contentComposer(): ContentComposer
{
return new DiviContentComposer(
$this->schema,
new DiviComposition($this->modules, $this->design),
new DiviSaver(),
$this->design
);
}
public function abilities(): array public function abilities(): array
{ {
$loader = new DiviPageLoader($this->schema); $loader = new DiviPageLoader($this->schema);
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\Document;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Outline;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
/**
* Divi short form for lit/content-create (spec §6.2): the sections become a Divi 5 layout in
* exactly one divi/placeholder – inserted by TreeEditor like the first section of an empty page
* (ruling S9) – stored through DiviSaver's kses window, with the builder flags (DiviFlags, S11).
*
* Allow map (ruling S22) for an entry that does not exist yet: check() runs before the insert
* with the composition bound to post ID 0 – Divi's own registry for "no entry" (get_options(0)
* treats it like a post; live the same options as for a page, only the taxonomy choices of
* post_categories/post_tags differ). write() composes again, bound to the new entry – the
* layout that is stored always passed that entry's live map, like every other Divi write
* (DiviWriter::composition). Should the entry's map refuse something (an option Divi offers
* only for other types), write() returns the errors and lit/content-create deletes the draft.
*
* Every client string goes through Composer (AttrWriter / ShortformWriter) – the single choke
* point that makes the kses bypass safe (spec D7).
*/
final class DiviContentComposer implements ContentComposer
{
public function __construct(
private ContentSchema $schema,
private DiviComposition $composition,
private DiviSaver $saver,
private DesignSystemReader $design
) {
}
public function available(): bool
{
return DiviModules::isAvailable();
}
public function check(string $postType, array $sections): ?WP_Error
{
if (! $this->schema->diviEnabled($postType)) {
return Errors::invalidInput(
'Für diesen Inhaltstyp ist der Divi-Builder nicht aktiviert; nichts wurde angelegt.',
['divi' => sprintf('Divi-Builder für "%s" nicht aktiv', $postType)]
);
}
$layout = $this->layout($this->composition->forPost(0), $sections);
return $layout instanceof WP_Error ? $layout : null;
}
public function write(int $postId, string $postType, array $sections): array|WP_Error
{
$composition = $this->composition->forPost($postId);
$layout = $this->layout($composition, $sections);
if ($layout instanceof WP_Error) {
return $layout;
}
$index = NodeIndex::build($layout['document']);
$warnings = $composition->warnings()->collect($index, $layout['blocks']);
$outline = new Outline($this->design->read()->presetNames());
$text = $outline->text($outline->rows($index));
if ($outline->wasTruncated()) {
$warnings[] = DiviWriter::OUTLINE_TRUNCATED;
}
$saved = $this->saver->saveNew($postId, Serializer::document($layout['document']));
return ['warnings' => array_merge($warnings, $saved), 'outline' => $text];
}
/**
* The whole description composed and inserted into an empty document – or every error.
*
* @param list<mixed> $sections
* @return array{document: Document, blocks: list<Block>}|WP_Error
*/
private function layout(DiviComposition $composition, array $sections): array|WP_Error
{
if ($sections === []) {
return Errors::invalidInput('Kein Divi-Inhalt angegeben; nichts wurde angelegt.', [
'divi' => 'mindestens ein Abschnitt',
]);
}
$composed = $composition->composer()->compose(array_values($sections), null, 'divi');
if ($composed['errors'] !== []) {
return $composed['invalid_paths'] !== []
? Errors::invalidAttrPath($composed['errors'])
: Errors::invalidInput('Divi-Inhalt ist ungültig; nichts wurde angelegt.', $composed['errors']);
}
$document = new Document();
$violation = $composition->editor()->insert(
['container' => $document, 'parent' => null, 'index' => PHP_INT_MAX],
$composed['blocks']
);
if ($violation !== null) {
return Errors::invalidInput('Divi-Inhalt passt nicht auf die Seite; nichts wurde angelegt.', [
'divi' => $violation,
]);
}
return ['document' => $document, 'blocks' => $composed['blocks']];
}
}
+30 -1
View File
@@ -36,7 +36,8 @@ use WP_Post;
* transition_post_status, wp_after_insert_post and third-party listeners. No MCP input reaches * transition_post_status, wp_after_insert_post and third-party listeners. No MCP input reaches
* those hooks except the content itself (already cleaned); anything a third-party hook writes * those hooks except the content itself (already cleaned); anything a third-party hook writes
* there is saved without kses, as it would be for an administrator. The rollback write in * there is saved without kses, as it would be for an administrator. The rollback write in
* restore() uses the same window with the previous stored content. * restore() uses the same window with the previous stored content; saveNew() (the first layout
* of a new draft, lit/content-create) uses it for its single wp_update_post().
* *
* Divi's own save filters are NOT kses and are never removed (ruling S7, defense in depth): * Divi's own save filters are NOT kses and are never removed (ruling S7, defense in depth):
* Security::initialize() (Divi 5.2.1 Security.php:74-81) registers on wp_insert_post_data * Security::initialize() (Divi 5.2.1 Security.php:74-81) registers on wp_insert_post_data
@@ -116,6 +117,34 @@ class DiviSaver
return ['revision_id' => $revisionId, 'warnings' => $warnings]; return ['revision_id' => $revisionId, 'warnings' => $warnings];
} }
/**
* The first content of a brand-new draft (lit/content-create with divi, which inserted the
* draft with its normal WordPress fields – title and excerpt went through kses as usual).
* Same kses window as save(), holding only this wp_update_post() and its hooks; no restore
* point (the draft has no earlier state) and no static CSS to clear. The Divi flags are part
* of the write here: without them the new entry is no Divi layout. Every failure throws –
* the caller deletes the draft (all or nothing).
*
* @return list<string> Warnings (CHANGED_ON_SAVE).
* @throws RuntimeException
*/
public function saveNew(int $postId, string $content): array
{
$result = $this->withoutKses(
static fn (): int|WP_Error => wp_update_post(wp_slash(['ID' => $postId, 'post_content' => $content]), true)
);
if ($result instanceof WP_Error || $result === 0) {
$message = $result instanceof WP_Error ? $result->get_error_message() : 'returned 0';
throw new RuntimeException(sprintf('wp_update_post(%d) refused the Divi layout: %s', $postId, $message));
}
DiviFlags::set($postId);
$stored = get_post($postId);
return $stored instanceof WP_Post && (string) $stored->post_content !== $content ? [self::CHANGED_ON_SAVE] : [];
}
/** /**
* Runs $write with kses' filters off for users without unfiltered_html (D7); restored in * Runs $write with kses' filters off for users without unfiltered_html (D7); restored in
* finally, right when $write returns or throws. * finally, right when $write returns or throws.
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Content;
use Throwable;
use WP_Error;
/**
* Builds the content of a new entry for lit/content-create from a structured description
* (spec §6.2: "optional Divi-Inhalt in Kurzform"). Implemented by the Divi integration, so the
* WordPress integration does not depend on Divi.
*
* Two steps, because the entry does not exist before the insert: check() validates the whole
* description before anything is created (all or nothing); write() stores the content into the
* brand-new draft, which lit/content-create inserted with its normal WordPress fields only.
*/
interface ContentComposer
{
/** Whether composing is possible on this site right now (e.g. Divi 5 active). */
public function available(): bool;
/**
* Validates the description for a new entry of $postType. Nothing is written.
*
* @param list<mixed> $sections
* @return WP_Error|null Every problem of the description; null = valid.
*/
public function check(string $postType, array $sections): ?WP_Error;
/**
* Composes the description for the new draft $postId and stores it. A WP_Error means the
* description is invalid for this entry (nothing was written) – like a throw, it makes
* lit/content-create delete the draft again.
*
* @param list<mixed> $sections
* @return array{warnings: list<string>, outline: ?string}|WP_Error
* @throws Throwable When storing failed.
*/
public function write(int $postId, string $postType, array $sections): array|WP_Error;
}
@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\WordPress\Content;
use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\ToolResult; use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\ChangeLog\ChangeLog;
@@ -22,6 +23,11 @@ use WP_Post;
* afterwards, created terms and the brand-new draft are deleted again and a WriteFailed * afterwards, created terms and the brand-new draft are deleted again and a WriteFailed
* is thrown (AbilityRegistrar → lit_write_failed, or lit_partial_write if that cleanup * is thrown (AbilityRegistrar → lit_write_failed, or lit_partial_write if that cleanup
* failed or the insert itself threw; partial writes are logged with result "partial"). * failed or the insert itself threw; partial writes are logged with result "partial").
*
* Divi short form (spec §6.2): with `divi` the ContentComposer (Divi integration) checks the whole
* description before the insert; the draft is inserted with its normal WordPress fields only
* (title/excerpt keep kses), then the composer stores the layout (DiviSaver's kses window, spec
* D7) – a refusal or failure there deletes the draft again.
*/ */
final class ContentCreateAbility implements Ability final class ContentCreateAbility implements Ability
{ {
@@ -31,7 +37,8 @@ final class ContentCreateAbility implements Ability
public function __construct( public function __construct(
private ContentSchema $schema, private ContentSchema $schema,
private PostWriter $writer, private PostWriter $writer,
private ChangeLog $log private ChangeLog $log,
private ?ContentComposer $divi = null
) { ) {
} }
@@ -49,7 +56,9 @@ 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.'; . '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 '
. 'dann tree_rev für die lit-divi-* Werkzeuge.';
} }
public function inputSchema(): array public function inputSchema(): array
@@ -59,6 +68,14 @@ final class ContentCreateAbility implements Ability
'properties' => array_merge([ 'properties' => array_merge([
'post_type' => ['type' => 'string'], 'post_type' => ['type' => 'string'],
'title' => ['type' => 'string', 'minLength' => 1], 'title' => ['type' => 'string', 'minLength' => 1],
'divi' => [
'type' => 'array',
'minItems' => 1,
'maxItems' => 30,
'items' => ['type' => 'object'],
'description' => 'Divi-Layout in Kurzform: Abschnitte wie bei lit-divi-insert (nicht zusammen mit '
. 'content). Nur wenn Divi 5 aktiv und der Divi-Builder für den Inhaltstyp eingeschaltet ist.',
],
], PostWriter::inputProperties()), ], PostWriter::inputProperties()),
'required' => ['post_type', 'title'], 'required' => ['post_type', 'title'],
'additionalProperties' => false, 'additionalProperties' => false,
@@ -67,7 +84,7 @@ final class ContentCreateAbility implements Ability
public function outputSchema(): ?array public function outputSchema(): ?array
{ {
return ToolResult::schema(); return ToolResult::schema(['tree_rev' => ['type' => 'string']]);
} }
public function annotations(): array public function annotations(): array
@@ -95,9 +112,18 @@ final class ContentCreateAbility implements Ability
} }
$postType = (string) $input['post_type']; $postType = (string) $input['post_type'];
$withDivi = array_key_exists('divi', $input);
if ($withDivi && ($this->divi === null || ! $this->divi->available())) {
return Errors::unsupported(
'Divi-Inhalt ist auf dieser Website nicht möglich (Divi 5 ist nicht aktiv); nichts wurde angelegt.',
'Ohne divi anlegen und den Inhalt über content oder im Editor setzen.'
);
}
$prepared = $this->writer->prepare($postType, $input, true); $prepared = $this->writer->prepare($postType, $input, true);
if ($prepared instanceof WP_Error) { $problem = self::combined($prepared, $withDivi ? $this->checkDivi($postType, $input) : null);
return $prepared; if ($problem !== null) {
return $problem;
} }
$data = array_merge($this->writer->postData($input), [ $data = array_merge($this->writer->postData($input), [
@@ -123,21 +149,89 @@ final class ContentCreateAbility implements Ability
return $this->refuse($postId, $postType, $denied); return $this->refuse($postId, $postType, $denied);
} }
$layout = null;
try {
$layout = $withDivi ? $this->divi?->write($postId, $postType, self::sections($input)) : null;
} catch (Throwable $e) {
$this->discard($postId, $postType, $e);
}
if ($layout instanceof WP_Error) {
return $this->refuse($postId, $postType, $layout);
}
try { try {
$this->writer->writeExtras($postId, $postType, $prepared, $input); $this->writer->writeExtras($postId, $postType, $prepared, $input);
} catch (Throwable $e) { } catch (Throwable $e) {
$this->discard($postId, $postType, $e); $this->discard($postId, $postType, $e);
} }
return $this->result($postId, $postType, $input, $prepared['warnings']); $warnings = array_merge($prepared['warnings'], $layout['warnings'] ?? []);
return $this->result($postId, $postType, $input, $warnings, $layout['outline'] ?? null);
}
/**
* The Divi description, checked completely before anything is created (spec §6.2).
*
* @param array<string, mixed> $input
*/
private function checkDivi(string $postType, array $input): ?WP_Error
{
if (array_key_exists('content', $input)) {
return Errors::invalidInput('content und divi schließen sich aus; nichts wurde angelegt.', [
'content' => 'bei Divi-Inhalt weglassen',
]);
}
return $this->divi?->check($postType, self::sections($input));
}
/**
* @param array<string, mixed> $input
* @return list<mixed>
*/
private static function sections(array $input): array
{
return is_array($input['divi'] ?? null) ? array_values($input['divi']) : [];
}
/**
* One error with the fields of both checks (all or nothing: every problem comes back at once);
* invalid_attr_path wins, so its suggestions stay visible. Null when both passed.
*
* @param array<string, mixed>|WP_Error $prepared
*/
private static function combined(array|WP_Error $prepared, ?WP_Error $divi): ?WP_Error
{
if (! $prepared instanceof WP_Error || $divi === null) {
return $prepared instanceof WP_Error ? $prepared : $divi;
}
$fields = array_merge(self::fields($prepared), self::fields($divi));
if ($fields === []) {
return $prepared;
}
return $divi->get_error_code() === 'lit_invalid_attr_path'
? Errors::invalidAttrPath($fields)
: Errors::invalidInput('Eingaben sind ungültig; nichts wurde angelegt.', $fields);
}
/** @return array<string, string> */
private static function fields(WP_Error $error): array
{
$data = $error->get_error_data();
return is_array($data) && is_array($data['fields'] ?? null) ? $data['fields'] : [];
} }
/** /**
* @param array<string, mixed> $input * @param array<string, mixed> $input
* @param list<string> $warnings * @param list<string> $warnings
* @param string|null $outline Divi outline of the new layout (with divi only).
* @return array<string, mixed> * @return array<string, mixed>
*/ */
private function result(int $postId, string $postType, array $input, array $warnings): array private function result(int $postId, string $postType, array $input, array $warnings, ?string $outline): array
{ {
// The draft is saved – failures from here on are logged and reported as warnings only. // The draft is saved – failures from here on are logged and reported as warnings only.
$change = (new PostChangeResult($this->name(), $this->log, $postId, 'der Entwurf', $warnings)) $change = (new PostChangeResult($this->name(), $this->log, $postId, 'der Entwurf', $warnings))
@@ -146,10 +240,16 @@ final class ContentCreateAbility implements Ability
$title = $post instanceof WP_Post ? (string) $post->post_title : (string) $input['title']; $title = $post instanceof WP_Post ? (string) $post->post_title : (string) $input['title'];
$change->log($postType, sprintf('Entwurf "%s" angelegt', $title)); $change->log($postType, sprintf('Entwurf "%s" angelegt', $title));
return $change->result(sprintf('"%s" als Entwurf angelegt (ID %d).', $title, $postId), $post) $result = $change->result(sprintf('"%s" als Entwurf angelegt (ID %d).', $title, $postId), $post)
->changed((string) $postId, $postType, 'created', $title) ->changed((string) $postId, $postType, 'created', $title)
->urls((string) get_preview_post_link($postId), (string) get_edit_post_link($postId, 'raw')) ->urls((string) get_preview_post_link($postId), (string) get_edit_post_link($postId, 'raw'));
->toArray(); if (array_key_exists('divi', $input)) {
// The Divi tools lock on tree_rev (ruling P16): the next lit-divi-* call can use it right away.
$result->outline($outline)
->with('tree_rev', Rev::of($post instanceof WP_Post ? (string) $post->post_content : ''));
}
return $result->toArray();
} }
/** /**
@@ -193,13 +293,14 @@ final class ContentCreateAbility implements Ability
} }
/** /**
* Meta rights can only be checked once the draft exists (add_post_meta needs its ID): a * Some checks need the draft: meta rights (add_post_meta needs its ID) and the Divi layout
* refused field removes the brand-new draft again and is reported like any invalid input. * against the new entry's own dynamic content options. A refusal removes the brand-new draft
* again and is reported like any invalid input.
* *
* @param array<string, string> $denied fields.<key> => error * @param array<string, string>|WP_Error $denied fields.<key> => error, or the error itself.
* @throws WriteFailed When the draft cannot be deleted. * @throws WriteFailed When the draft cannot be deleted.
*/ */
private function refuse(int $postId, string $postType, array $denied): WP_Error private function refuse(int $postId, string $postType, array|WP_Error $denied): WP_Error
{ {
try { try {
$deleted = (bool) wp_delete_post($postId, true); $deleted = (bool) wp_delete_post($postId, true);
@@ -210,17 +311,21 @@ final class ContentCreateAbility implements Ability
if (! $deleted) { if (! $deleted) {
$summary = sprintf('Entwurf %d: Anlegen nicht zurückgenommen', $postId); $summary = sprintf('Entwurf %d: Anlegen nicht zurückgenommen', $postId);
$this->logPartial($postType, (string) $postId, $summary); $this->logPartial($postType, (string) $postId, $summary);
$fields = $denied instanceof WP_Error ? self::fields($denied) : $denied;
throw new WriteFailed( throw new WriteFailed(
'Keine Berechtigung für ' . implode(', ', array_keys($denied)) . '.', ($denied instanceof WP_Error ? 'Ungültiger Divi-Inhalt: ' : 'Keine Berechtigung für ')
. implode(', ', array_keys($fields)) . '.',
false, false,
$postId, $postId,
null, null,
null, null,
sprintf('Meta rights denied for new post %d; draft could not be deleted', $postId) sprintf('New post %d refused after the insert; draft could not be deleted', $postId)
); );
} }
return Errors::invalidInput('Eingaben sind ungültig; nichts wurde gespeichert.', $denied); return $denied instanceof WP_Error
? $denied
: Errors::invalidInput('Eingaben sind ungültig; nichts wurde gespeichert.', $denied);
} }
private function logPartial(string $postType, string $postId, string $summary): void private function logPartial(string $postType, string $postId, string $summary): void
@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\WordPress;
use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Integration; use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility; use LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility;
use LIT\MCP\Integrations\WordPress\Content\ContentGetAbility; use LIT\MCP\Integrations\WordPress\Content\ContentGetAbility;
use LIT\MCP\Integrations\WordPress\Content\ContentQueryAbility; use LIT\MCP\Integrations\WordPress\Content\ContentQueryAbility;
@@ -45,8 +46,12 @@ use LIT\MCP\Integrations\WordPress\Status\PostUnpublishAbility;
*/ */
final class WordPressIntegration implements Integration final class WordPressIntegration implements Integration
{ {
public function __construct(private ContentSchema $schema, private ChangeLog $log) /** @param ContentComposer|null $divi Divi short form for lit/content-create (DiviIntegration). */
{ public function __construct(
private ContentSchema $schema,
private ChangeLog $log,
private ?ContentComposer $divi = null
) {
} }
public function id(): string public function id(): string
@@ -70,7 +75,7 @@ final class WordPressIntegration implements Integration
new ContentTypesAbility($this->schema), new ContentTypesAbility($this->schema),
new ContentQueryAbility($this->schema, $presenter), new ContentQueryAbility($this->schema, $presenter),
new ContentGetAbility($this->schema, $presenter), new ContentGetAbility($this->schema, $presenter),
new ContentCreateAbility($this->schema, $writer, $this->log), new ContentCreateAbility($this->schema, $writer, $this->log, $this->divi),
new ContentUpdateAbility($this->schema, $writer, $this->log), new ContentUpdateAbility($this->schema, $writer, $this->log),
new PostPublishAbility($this->schema, $this->log), new PostPublishAbility($this->schema, $this->log),
new PostUnpublishAbility($this->schema, $this->log), new PostUnpublishAbility($this->schema, $this->log),
+3 -2
View File
@@ -66,11 +66,12 @@ final class Plugin
(new ChangeLogCleanup($changeLog, $limits))->register(); (new ChangeLogCleanup($changeLog, $limits))->register();
$schema = self::contentSchema(); $schema = self::contentSchema();
$divi = new DiviIntegration($schema, $changeLog);
$registry = new IntegrationRegistry( $registry = new IntegrationRegistry(
new CoreIntegration(self::pluginDir() . '/docs/guides'), new CoreIntegration(self::pluginDir() . '/docs/guides'),
new WordPressIntegration($schema, $changeLog), new WordPressIntegration($schema, $changeLog, $divi->contentComposer()),
new MediaIntegration($schema, $changeLog, $limits), new MediaIntegration($schema, $changeLog, $limits),
new DiviIntegration($schema, $changeLog) $divi
); );
$policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...)); $policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...));
@@ -7,6 +7,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi;
use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Ability;
use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\DiviIntegration; use LIT\MCP\Integrations\Divi\DiviIntegration;
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Tests\Unit\TestCase; use LIT\MCP\Tests\Unit\TestCase;
use Mockery; use Mockery;
@@ -44,4 +45,14 @@ final class DiviIntegrationTest extends TestCase
self::assertNotNull($ability->outputSchema(), $ability->name()); self::assertNotNull($ability->outputSchema(), $ability->name());
} }
} }
public function testGivesContentCreateTheDiviShortForm(): void
{
$integration = new DiviIntegration(Mockery::mock(ContentSchema::class), Mockery::mock(ChangeLog::class));
$composer = $integration->contentComposer();
self::assertInstanceOf(DiviContentComposer::class, $composer);
self::assertFalse($composer->available(), 'no Divi classes in unit tests');
}
} }
@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Tests\Unit\Integrations\Divi\Compose\ComposeFixture;
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use Mockery\MockInterface;
use RuntimeException;
use WP_Error;
final class DiviContentComposerTest extends TestCase
{
/** @var list<int> Post IDs the dynamic content registry was asked for. */
private array $registryCalls = [];
/** @var array<int, string> Content handed to DiviSaver::saveNew() by post ID. */
private array $saved = [];
/** @var DiviSaver&MockInterface */
private DiviSaver $saver;
protected function setUp(): void
{
parent::setUp();
$this->saver = Mockery::mock(DiviSaver::class);
$this->saver->allows('saveNew')->andReturnUsing(function (int $postId, string $content): array {
$this->saved[$postId] = $content;
return [];
});
}
/**
* @param (callable(int): array<string, mixed>)|null $registry Divi's dynamic content options per post ID.
*/
private function composer(bool $diviEnabled = true, ?callable $registry = null): DiviContentComposer
{
$modules = Mockery::mock(DiviModules::class);
$modules->allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog());
$modules->allows('builderVersion')->andReturn('5.2.1');
$design = Mockery::mock(DesignSystemReader::class);
$design->allows('read')->andReturn(DesignSystemTest::sample());
$map = new DynamicContentMap(function (int $postId) use ($registry): array {
$this->registryCalls[] = $postId;
return $registry !== null ? $registry($postId) : DynamicContentMapTest::registry();
});
return new DiviContentComposer(
DiviPageLoaderTest::schema($diviEnabled),
new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media(), $map),
$this->saver,
$design
);
}
/** @return list<array<string, mixed>> */
private static function sections(): array
{
return [
['type' => 'section', 'label' => 'Hero', 'modules' => [['type' => 'heading', 'text' => 'Willkommen']]],
['type' => 'section', 'modules' => [['type' => 'image', 'src' => 'https://kunde.test/a.jpg']]],
];
}
public function testChecksBeforeTheEntryExistsWithDivisMapForNoEntry(): void
{
self::assertNull($this->composer()->check('page', self::sections()));
self::assertSame([0], $this->registryCalls, 'Divi\'s options for "no entry yet" (post ID 0)');
self::assertSame([], $this->saved, 'check writes nothing');
}
public function testWritesAPlaceholderWrappedLayoutBoundToTheNewEntry(): void
{
$written = $this->composer()->write(42, 'page', self::sections());
self::assertIsArray($written);
self::assertSame([42], $this->registryCalls, 'the final layout uses the live map of the new entry');
$content = $this->saved[42];
// Ruling S9: exactly one divi/placeholder around the sections, as TreeEditor does for empty pages.
self::assertStringStartsWith('<!-- wp:divi/placeholder --><!-- wp:divi/section ', $content);
self::assertStringEndsWith('<!-- /wp:divi/section --><!-- /wp:divi/placeholder -->', $content);
self::assertSame(1, substr_count($content, '<!-- wp:divi/placeholder -->'));
$document = (new Parser())->parse($content);
self::assertCount(1, $document->children());
self::assertCount(2, $document->children()[0]->children());
self::assertContains('s1.r0.c0.m0: Bild ohne Alt-Text.', $written['warnings']);
self::assertStringContainsString('s0 section "Hero"', (string) $written['outline']);
}
public function testRefusesTypesWithoutBuilderAndInvalidNodes(): void
{
$disabled = $this->composer(false)->check('post', [['type' => 'section']]);
self::assertInstanceOf(WP_Error::class, $disabled);
self::assertSame('lit_validation_failed', $disabled->get_error_code());
self::assertArrayHasKey('divi', $disabled->get_error_data()['fields']);
$invalid = $this->composer()->check('page', [
['type' => 'section', 'modules' => [['type' => 'button', 'attrs' => ['x.y' => ['desktop' => 1]]]]],
['type' => 'section', 'modules' => [['type' => 'button', 'url' => 'javascript:alert(1)', 'text' => 'x']]],
]);
self::assertInstanceOf(WP_Error::class, $invalid);
self::assertSame('lit_invalid_attr_path', $invalid->get_error_code());
$fields = $invalid->get_error_data()['fields'];
self::assertArrayHasKey('divi[0].modules[0].modules[0][0].attrs.x.y', $fields);
self::assertGreaterThanOrEqual(2, count($fields), 'every error of every section comes back');
self::assertSame([], $this->saved);
}
/** The entry's own map can be narrower than the one for "no entry": then nothing is written. */
public function testWriteRefusesWhatTheNewEntrysMapDoesNotOffer(): void
{
$registry = static function (int $postId): array {
$options = DynamicContentMapTest::registry();
if ($postId !== 0) {
unset($options['site_title']);
}
return $options;
};
$title = VariableRef::serialize('content', 'site_title', ['before' => '', 'after' => '']);
$sections = [['type' => 'section', 'modules' => [['type' => 'heading', 'text' => $title]]]];
self::assertNull($this->composer(true, $registry)->check('page', $sections));
$written = $this->composer(true, $registry)->write(42, 'page', $sections);
self::assertInstanceOf(WP_Error::class, $written);
self::assertSame('lit_validation_failed', $written->get_error_code());
self::assertSame([], $this->saved);
}
public function testAFailingSaveIsThrown(): void
{
$this->saver = Mockery::mock(DiviSaver::class);
$this->saver->allows('saveNew')->andThrow(new RuntimeException('Divi flag missing'));
$this->expectException(RuntimeException::class);
$this->composer()->write(42, 'page', self::sections());
}
}
@@ -212,4 +212,59 @@ final class DiviSaverTest extends TestCase
self::assertStringContainsString('Divi-Kennzeichen', $saved['warnings'][0]); self::assertStringContainsString('Divi-Kennzeichen', $saved['warnings'][0]);
self::assertStringContainsString('divi builder flags failed', (string) file_get_contents($this->log)); self::assertStringContainsString('divi builder flags failed', (string) file_get_contents($this->log));
} }
public function testSaveNewWritesTheFirstContentWithoutKsesAndSetsTheFlagsStrictly(): void
{
Functions\when('current_user_can')->justReturn(false);
Functions\when('has_filter')->justReturn(10);
Functions\expect('wp_update_post')->once()->with(['ID' => 42, 'post_content' => 'NEU'], true)
->andReturnUsing(function (): int {
$this->calls[] = 'update';
return 42;
});
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 42, 'post_content' => 'NEU']));
Functions\expect('wp_save_post_revision')->never();
self::assertSame([], (new DiviSaver())->saveNew(42, 'NEU'));
self::assertSame(
// Same kses window as save(), but no restore point: the new draft has no earlier state.
[
'kses off', 'update', 'kses on',
'meta:_et_pb_use_divi_5=on', 'meta:_et_pb_use_builder=on', 'meta:_et_pb_show_page_creation=off',
],
$this->calls
);
}
public function testSaveNewReportsAContentChangedOnSaveAndKeepsKsesForUnfilteredUsers(): void
{
Functions\when('current_user_can')->justReturn(true);
Functions\when('has_filter')->justReturn(10);
Functions\when('wp_update_post')->justReturn(42);
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 42, 'post_content' => 'ANDERS']));
self::assertSame([DiviSaver::CHANGED_ON_SAVE], (new DiviSaver())->saveNew(42, 'NEU'));
self::assertNotContains('kses off', $this->calls);
}
public function testSaveNewThrowsWhenTheUpdateIsRefusedOrAFlagIsMissing(): void
{
Functions\when('current_user_can')->justReturn(false);
Functions\when('has_filter')->justReturn(10);
Functions\when('wp_update_post')->justReturn(new WP_Error('db', 'kaputt'));
try {
(new DiviSaver())->saveNew(42, 'NEU');
self::fail('RuntimeException expected');
} catch (RuntimeException $e) {
self::assertStringContainsString('kaputt', $e->getMessage());
}
self::assertSame(['kses off', 'kses on'], $this->calls, 'kses comes back, no flags on a failed write');
Functions\when('wp_update_post')->justReturn(42);
Functions\when('get_post_meta')->justReturn('');
$this->expectException(RuntimeException::class);
(new DiviSaver())->saveNew(42, 'NEU');
}
} }
@@ -0,0 +1,283 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Content;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility;
use LIT\MCP\Integrations\WordPress\Content\PostWriter;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Tests\Unit\Integrations\Divi\Compose\ComposeFixture;
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use Mockery\MockInterface;
use RuntimeException;
use WP_Error;
use WP_Post;
final class ContentCreateDiviTest extends TestCase
{
private const MARKUP = '<!-- wp:divi/placeholder --><!-- wp:divi/section --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->';
private const SECTIONS = [['type' => 'section']];
private const INPUT = ['post_type' => 'page', 'title' => 'Start', 'divi' => self::SECTIONS];
private function schema(): ContentSchema
{
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['create_posts' => 'edit_pages']]);
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_current_user_id')->justReturn(3);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->andReturn(true);
$schema->allows('diviEnabled')->andReturn(true);
return $schema;
}
/** @return PostWriter&MockInterface */
private function writer(): PostWriter
{
$writer = Mockery::mock(PostWriter::class);
$writer->allows('prepare')->andReturn(['fields' => [], 'terms' => [], 'warnings' => []]);
$writer->allows('postData')->andReturn(['post_title' => 'Start']);
$writer->allows('deniedMeta')->andReturn([]);
$writer->allows('writeExtras');
$writer->allows('deleteCreatedTerms');
return $writer;
}
/** @return ContentComposer&MockInterface */
private function composer(bool $available = true): ContentComposer
{
$composer = Mockery::mock(ContentComposer::class);
$composer->allows('available')->andReturn($available);
$composer->allows('check')->with('page', self::SECTIONS)->andReturn(null);
return $composer;
}
private function log(): ChangeLog
{
$log = Mockery::mock(ChangeLog::class);
$log->allows('record')->andReturn(9);
$log->allows('recordPartial');
return $log;
}
private function ability(?ContentComposer $composer, ?PostWriter $writer = null): ContentCreateAbility
{
return new ContentCreateAbility($this->schema(), $writer ?? $this->writer(), $this->log(), $composer);
}
private function stubStoredDraft(): void
{
Functions\when('get_post')->justReturn(new WP_Post([
'ID' => 42, 'post_title' => 'Start', 'post_type' => 'page', 'post_status' => 'draft', 'post_content' => self::MARKUP,
]));
Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=42&preview=true');
Functions\when('get_edit_post_link')->justReturn('https://kunde.test/wp-admin/post.php?post=42&action=edit');
}
public function testCreatesADiviDraftWithTreeRevOutlineAndWarnings(): void
{
$composer = $this->composer();
$composer->expects('write')->with(42, 'page', self::SECTIONS)
->andReturn(['warnings' => ['s0: leer (section ohne Inhalt).'], 'outline' => 's0 divi/section']);
// D7: the insert carries only the normal WordPress fields (kses as usual) – never Divi markup;
// the layout is written by the Divi composer through DiviSaver's single kses window.
Functions\expect('wp_insert_post')->once()->with([
'post_title' => 'Start',
'post_type' => 'page',
'post_status' => 'draft',
'post_author' => 3,
], true)->andReturn(42);
$this->stubStoredDraft();
$result = $this->ability($composer)->execute(self::INPUT);
self::assertTrue($result['ok']);
self::assertSame(Rev::of(self::MARKUP), $result['tree_rev']);
self::assertSame(['s0: leer (section ohne Inhalt).'], $result['warnings']);
self::assertSame('s0 divi/section', $result['outline']);
self::assertStringContainsString('Entwurf', $result['summary'], 'drafts stay drafts');
self::assertArrayHasKey('tree_rev', $this->ability(null)->outputSchema()['properties']);
self::assertArrayHasKey('divi', $this->ability(null)->inputSchema()['properties']);
}
public function testWithoutDiviTheComposerIsNotUsed(): void
{
$composer = Mockery::mock(ContentComposer::class);
$composer->expects('available')->never();
Functions\when('wp_insert_post')->justReturn(42);
$this->stubStoredDraft();
$result = $this->ability($composer)->execute(['post_type' => 'page', 'title' => 'Start']);
self::assertArrayNotHasKey('tree_rev', $result);
}
public function testDiviNeedsDiviAndExcludesContent(): void
{
Functions\expect('wp_insert_post')->never();
$inactive = $this->ability($this->composer(false))->execute(self::INPUT);
self::assertSame('lit_unsupported', $inactive->get_error_code());
$none = $this->ability(null)->execute(self::INPUT);
self::assertSame('lit_unsupported', $none->get_error_code());
$both = $this->ability($this->composer())->execute(self::INPUT + ['content' => '<p>x</p>']);
self::assertSame('lit_validation_failed', $both->get_error_code());
self::assertArrayHasKey('content', $both->get_error_data()['fields']);
}
public function testEveryValidationErrorComesBackAndNothingIsCreated(): void
{
Functions\expect('wp_insert_post')->never();
$composer = Mockery::mock(ContentComposer::class);
$composer->allows('available')->andReturn(true);
$composer->allows('check')->andReturn(Errors::invalidAttrPath(['divi[0].attrs.x' => 'unbekannt']));
$writer = Mockery::mock(PostWriter::class);
$writer->allows('prepare')->andReturn(Errors::invalidInput('ungültig', ['fields.role' => 'Pflichtfeld fehlt.']));
$error = $this->ability($composer, $writer)->execute(self::INPUT);
self::assertSame('lit_invalid_attr_path', $error->get_error_code());
self::assertSame(
['fields.role' => 'Pflichtfeld fehlt.', 'divi[0].attrs.x' => 'unbekannt'],
$error->get_error_data()['fields']
);
}
public function testAFailingLayoutWriteDiscardsTheDraft(): void
{
$composer = $this->composer();
$composer->allows('write')->andThrow(new RuntimeException('Divi flag missing'));
Functions\when('wp_insert_post')->justReturn(42);
Functions\expect('wp_delete_post')->once()->with(42, true)->andReturn(new WP_Post(['ID' => 42]));
try {
$this->ability($composer)->execute(self::INPUT);
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
}
}
public function testALayoutTheNewEntryRefusesDeletesTheDraftAndReturnsTheErrors(): void
{
$composer = $this->composer();
$composer->allows('write')->andReturn(Errors::invalidInput('ungültig', ['divi[0].modules[0].text' => 'nicht angeboten']));
Functions\when('wp_insert_post')->justReturn(42);
Functions\expect('wp_delete_post')->once()->with(42, true)->andReturn(new WP_Post(['ID' => 42]));
$error = $this->ability($composer)->execute(self::INPUT);
self::assertInstanceOf(WP_Error::class, $error);
self::assertSame('lit_validation_failed', $error->get_error_code());
self::assertArrayHasKey('divi[0].modules[0].text', $error->get_error_data()['fields']);
}
public function testARefusedLayoutWhoseDraftCannotBeDeletedIsAPartialWrite(): void
{
$composer = $this->composer();
$composer->allows('write')->andReturn(Errors::invalidInput('ungültig', ['divi[0].modules[0].text' => 'nicht angeboten']));
Functions\when('wp_insert_post')->justReturn(42);
Functions\expect('wp_delete_post')->once()->andReturn(false);
try {
$this->ability($composer)->execute(self::INPUT);
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
self::assertFalse($e->rolledBack);
self::assertSame(42, $e->postId);
self::assertStringContainsString('divi[0].modules[0].text', $e->reason);
}
}
/**
* Rulings S40/S41 hold for content-create too – they live in the Composer: the same path via
* short form and attrs is refused (nothing created); an admin label in attrs wins over label.
*/
public function testShortFormAndAttrsRulesApplyThroughTheRealComposer(): void
{
$modules = Mockery::mock(DiviModules::class);
$modules->allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog());
$modules->allows('builderVersion')->andReturn('5.2.1');
$design = Mockery::mock(DesignSystemReader::class);
$design->allows('read')->andReturn(DesignSystemTest::sample());
$saved = [];
$saver = Mockery::mock(DiviSaver::class);
$saver->allows('saveNew')->andReturnUsing(static function (int $postId, string $content) use (&$saved): array {
$saved[] = $content;
return [];
});
$real = new DiviContentComposer(
$this->schema(),
new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media()),
$saver,
$design
);
self::assertFalse($real->available(), 'no Divi classes in unit tests');
// Only available() is replaced (Divi's classes are missing here); check/write are the real ones.
$composer = new class ($real) implements ContentComposer {
public function __construct(private DiviContentComposer $real)
{
}
public function available(): bool
{
return true;
}
public function check(string $postType, array $sections): ?WP_Error
{
return $this->real->check($postType, $sections);
}
public function write(int $postId, string $postType, array $sections): array|WP_Error
{
return $this->real->write($postId, $postType, $sections);
}
};
Functions\when('is_admin')->justReturn(false);
$this->stubStoredDraft();
Functions\expect('wp_insert_post')->never();
$overlap = $this->ability($composer)->execute(['post_type' => 'page', 'title' => 'Start', 'divi' => [[
'type' => 'section',
'label' => 'Kurz',
'attrs' => ['module.meta.adminLabel' => ['desktop' => 'Lang']],
'modules' => [['type' => 'button', 'text' => 'Los', 'attrs' => ['button.innerContent' => ['desktop' => ['text' => 'x']]]]],
]]]);
self::assertSame('lit_validation_failed', $overlap->get_error_code());
$fields = $overlap->get_error_data()['fields'];
self::assertArrayHasKey('divi[0].attrs.module.meta.adminLabel', $fields, 'S41: label and attrs label');
self::assertArrayHasKey('divi[0].modules[0].modules[0][0].attrs.button.innerContent', $fields, 'S40');
self::assertSame([], $saved);
Functions\expect('wp_insert_post')->once()->andReturn(42);
$label = $this->ability($composer)->execute(['post_type' => 'page', 'title' => 'Start', 'divi' => [[
'type' => 'section',
'label' => 'Hero',
'modules' => [['type' => 'heading', 'text' => 'Hallo', 'attrs' => ['module.meta.adminLabel' => ['desktop' => 'Aus attrs']]]],
]]]);
self::assertTrue($label['ok']);
self::assertStringContainsString('"adminLabel":{"desktop":{"value":"Aus attrs"}}', $saved[0], 'S41: no auto label over it');
}
}
@@ -5,7 +5,9 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress; namespace LIT\MCP\Tests\Unit\Integrations\WordPress;
use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Ability;
use Brain\Monkey\Functions;
use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\WordPressIntegration; use LIT\MCP\Integrations\WordPress\WordPressIntegration;
use LIT\MCP\Tests\Unit\TestCase; use LIT\MCP\Tests\Unit\TestCase;
@@ -61,4 +63,23 @@ final class WordPressIntegrationTest extends TestCase
self::assertMatchesRegularExpression('#^lit/[a-z]+(-[a-z]+)+$#', $ability->name()); self::assertMatchesRegularExpression('#^lit/[a-z]+(-[a-z]+)+$#', $ability->name());
} }
} }
public function testContentCreateGetsTheContentComposer(): void
{
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['create_posts' => 'edit_pages']]);
Functions\when('current_user_can')->justReturn(true);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->andReturn(true);
$composer = Mockery::mock(ContentComposer::class);
$composer->expects('available')->andReturn(false);
$integration = new WordPressIntegration($schema, Mockery::mock(ChangeLog::class), $composer);
$create = array_values(array_filter(
$integration->abilities(),
static fn (Ability $ability): bool => $ability->name() === 'lit/content-create'
))[0];
$result = $create->execute(['post_type' => 'page', 'title' => 'Start', 'divi' => [['type' => 'section']]]);
self::assertSame('lit_unsupported', $result->get_error_code());
}
} }