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
@@ -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
* 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
* 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):
* 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];
}
/**
* 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
* finally, right when $write returns or throws.