feat: lit/tb-create-template – disabled templates, all or nothing

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 16:09:49 +02:00
co-authored by Claude Opus 5.5
parent 54922a9f7e
commit 42b9b384d5
10 changed files with 1165 additions and 46 deletions
@@ -6,8 +6,6 @@ namespace LIT\MCP\Integrations\Divi\Pages;
use Closure;
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;
@@ -70,7 +68,7 @@ final class DiviContentComposer implements ContentComposer
);
}
$layout = $this->layout($this->composition->forPost(0), $sections);
$layout = LayoutBuilder::build($this->composition->forPost(0), $sections, 'divi');
return $layout instanceof WP_Error ? $layout : null;
}
@@ -78,7 +76,7 @@ final class DiviContentComposer implements ContentComposer
public function write(int $postId, string $postType, array $sections): array|WP_Error
{
$composition = $this->composition->forPost($postId);
$layout = $this->layout($composition, $sections);
$layout = LayoutBuilder::build($composition, $sections, 'divi');
if ($layout instanceof WP_Error) {
return $layout;
}
@@ -98,37 +96,4 @@ final class DiviContentComposer implements ContentComposer
'edit_url' => DiviPage::builderUrl($postId),
];
}
/**
* 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 DiviErrors::composed($composed, 'Divi-Inhalt ist ungültig; nichts wurde angelegt.');
}
$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']];
}
}
@@ -0,0 +1,52 @@
<?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 WP_Error;
defined('ABSPATH') || exit;
/**
* Sections in short/full form composed into an empty document (lit/content-create, lit/tb-create-template): the
* blocks come only from Composer (the single choke point, D7) and enter the document only through
* TreeEditor::insert(), which wraps the first section in divi/placeholder (ruling S9). All or nothing.
*/
final class LayoutBuilder
{
/**
* @param list<mixed> $sections
* @param string $field Input path the errors are reported under (e.g. "divi", "body.nodes").
* @return array{document: Document, blocks: list<Block>}|WP_Error
*/
public static function build(DiviComposition $composition, array $sections, string $field): array|WP_Error
{
if ($sections === []) {
return Errors::invalidInput('Kein Divi-Inhalt angegeben; nichts wurde angelegt.', [
$field => 'mindestens ein Abschnitt',
]);
}
$composed = $composition->composer()->compose(array_values($sections), null, $field);
if ($composed['errors'] !== []) {
return DiviErrors::composed($composed, 'Divi-Inhalt ist ungültig; nichts wurde angelegt.');
}
$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.', [
$field => $violation,
]);
}
return ['document' => $document, 'blocks' => $composed['blocks']];
}
}
@@ -0,0 +1,385 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\FollowUp;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\LayoutBuilder;
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
use RuntimeException;
use Throwable;
use WP_Error;
use WP_Post;
defined('ABSPATH') || exit;
/**
* lit/tb-create-template (spec §5.7, E7, answer F1): a Theme Builder template, ALWAYS disabled, with per area a
* new layout (optionally with nodes – Composer/TreeEditor, stored by DiviSaver::saveNew, D7), the default
* template's layout ("global" header/footer), nothing of its own ("default") or the area hidden. Conditions only
* from TemplateConditions. Everything is validated first; Divi then creates layouts and template
* (DiviThemeBuilder), the template is read back and appended to the live list; any failure removes all of it
* (TemplateBuild). Refused while the Theme Builder has an unsaved draft (E7). Never enables anything (F1, Q5):
* Divi stores it with enabled '0' and a template that reads back enabled or as default is rolled back.
*/
final class TbCreateTemplateAbility implements Ability
{
public const SOURCES = ['new', 'global', 'default', 'hidden'];
public const STALE_EDITOR = 'Ist der Divi Theme Builder gerade in einem anderen Fenster geöffnet, dort vor dem '
. 'nächsten Speichern neu laden – sonst entfernt Divi die neue Vorlage wieder aus der Liste (lit/tb-list zeigt '
. 'sie dann unter detached).';
private const AREA_LABELS = ['header' => 'Kopfzeile', 'body' => 'Inhalt', 'footer' => 'Fußzeile'];
public function __construct(
private ThemeBuilderAccess $access,
private TemplateConditions $conditions,
private DiviComposition $composition,
private DiviSaver $saver,
private ChangeLog $log
) {
}
public function name(): string
{
return 'lit/tb-create-template';
}
public function label(): string
{
return 'Theme-Builder-Vorlage anlegen';
}
public function description(): string
{
return 'Legt eine Vorlage im Divi Theme Builder an – immer deaktiviert, also ohne Wirkung auf die Website. Je '
. 'Bereich (header, body, footer) source "new" (neues Layout, optional mit nodes: Abschnitte in Kurz- oder '
. 'Vollform wie bei lit-divi-insert, z. B. {{post:title}} oder {{field:<Feld>}}), "global" (Kopf-/Fußzeile '
. 'der Standardvorlage mitbenutzen), "default" (nichts Eigenes) oder "hidden" (Bereich ausblenden). '
. 'Bedingungen optional (use_on, exclude_from; IDs aus lit/tb-list mit conditions: true). Aktivieren '
. 'nur mit lit/tb-activate.';
}
public function inputSchema(): array
{
$area = [
'type' => 'object',
'properties' => [
'source' => ['type' => 'string', 'enum' => self::SOURCES],
'title' => ['type' => 'string', 'maxLength' => 200, 'description' => 'Titel des neuen Layouts.'],
'nodes' => [
'type' => 'array',
'maxItems' => 20,
'items' => ['type' => 'object'],
'description' => 'Nur mit source "new": Abschnitte wie bei lit-divi-insert.',
],
],
'required' => ['source'],
'additionalProperties' => false,
];
$conditions = ['type' => 'array', 'maxItems' => TemplateConditions::MAX, 'items' => ['type' => 'string']];
return [
'type' => 'object',
'properties' => [
'title' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 200],
'header' => $area,
'body' => $area,
'footer' => $area,
'use_on' => $conditions,
'exclude_from' => $conditions,
],
'required' => ['title'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema([
'template_id' => ['type' => 'integer'],
'template_rev' => ['type' => 'string'],
'enabled' => ['type' => 'boolean'],
'layouts' => ['type' => 'object'],
], ['template_id', 'template_rev', 'enabled']);
}
public function annotations(): array
{
return Annotations::write(false);
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
return $this->access->canCreate() ?? $this->access->draftRefusal() ?? true;
}
/** @throws WriteFailed After the first post exists (everything was rolled back first, if possible). */
public function execute(array $input): mixed
{
$refusal = $this->access->canCreate() ?? $this->access->draftRefusal();
if ($refusal !== null) {
return $refusal;
}
$errors = [];
$title = sanitize_text_field(is_string($input['title'] ?? null) ? $input['title'] : '');
if ($title === '') {
$errors['title'] = 'nicht leer';
}
$plan = $this->plan($input, $title, $errors);
$useOn = $this->conditions->check($input['use_on'] ?? null, 'use_on');
$excludeFrom = $this->conditions->check($input['exclude_from'] ?? null, 'exclude_from');
$errors = array_merge($errors, $useOn['errors'], $excludeFrom['errors']);
foreach ($plan as $area => $spec) {
if ($spec['nodes'] !== []) {
$layout = LayoutBuilder::build($this->composition->forPost(0, $area), $spec['nodes'], $area . '.nodes');
if ($layout instanceof WP_Error) {
$errors = array_merge($errors, self::fields($layout, $area));
}
}
}
if ($errors !== []) {
return Errors::invalidInput('Die Vorlage ist ungültig; nichts wurde angelegt.', $errors);
}
return $this->create($title, $plan, $useOn['ids'], $excludeFrom['ids']);
}
/**
* @param array<string, mixed> $input
* @param array<string, string> $errors
* @return array<string, array{source: string, title: string, nodes: list<mixed>, id: int}>
*/
private function plan(array $input, string $title, array &$errors): array
{
$default = null;
$plan = [];
foreach (Template::AREAS as $area) {
$spec = is_array($input[$area] ?? null) ? $input[$area] : ['source' => 'default'];
$source = $spec['source'] ?? null;
if (! in_array($source, self::SOURCES, true)) {
$errors[$area . '.source'] = 'new, global, default oder hidden';
continue;
}
$nodes = is_array($spec['nodes'] ?? null) ? array_values($spec['nodes']) : [];
if ($nodes !== [] && $source !== 'new') {
$errors[$area . '.nodes'] = 'nur mit source "new"';
}
$id = 0;
if ($source === 'global') {
if ($area === 'body') {
$errors['body.source'] = 'für den Inhalt gibt es kein globales Layout';
continue;
}
$default ??= $this->defaultTemplate();
$id = $this->reusable($area, $default);
if ($id === null) {
$errors[$area . '.source'] = sprintf(
'die Standardvorlage hat keine eigene %s, die sich mitbenutzen lässt',
self::AREA_LABELS[$area]
);
continue;
}
}
$layoutTitle = sanitize_text_field(is_string($spec['title'] ?? null) ? $spec['title'] : '');
$plan[$area] = [
'source' => (string) $source,
'title' => $layoutTitle !== '' ? $layoutTitle : sprintf('%s – %s', $title, self::AREA_LABELS[$area]),
'nodes' => $nodes,
'id' => $id,
];
}
return $plan;
}
private function defaultTemplate(): ?Template
{
foreach ($this->access->store()->liveTemplates() as $template) {
if ($template->default) {
return $template;
}
}
return null;
}
/**
* The default template's layout of $area when it can be reused: a published layout of that area the user may
* edit (ruling Q8 – Divi would silently drop anything else, F17). Missing, trashed and not editable read the
* same (P12). It is referenced by ID, never copied, so no stored content moves (StoredNodeGuard not needed).
*/
private function reusable(string $area, ?Template $default): ?int
{
$id = $default?->layouts[$area]['id'] ?? 0;
$post = $id > 0 ? get_post($id) : null;
if (
! $post instanceof WP_Post
|| $post->post_type !== DiviThemeBuilder::LAYOUT_TYPES[$area]
|| (string) $post->post_status !== 'publish'
|| ! current_user_can('edit_post', $id)
) {
return null;
}
return $id;
}
/**
* @param array<string, array{source: string, title: string, nodes: list<mixed>, id: int}> $plan
* @param list<string> $useOn
* @param list<string> $excludeFrom
* @return array<string, mixed>|WP_Error
* @throws WriteFailed
*/
private function create(string $title, array $plan, array $useOn, array $excludeFrom): array|WP_Error
{
$store = $this->access->store();
$build = new TemplateBuild($store, $this->log, $this->name());
$layouts = [];
$warnings = [];
try {
foreach ($plan as $area => $spec) {
$layouts[$area] = ['id' => $spec['id'], 'enabled' => $spec['source'] !== 'hidden'];
if ($spec['source'] !== 'new') {
continue;
}
$id = $build->layout($area, $spec['title']);
$layouts[$area]['id'] = $id;
if ($spec['nodes'] === []) {
continue;
}
$composition = $this->composition->forPost($id, $area);
$layout = LayoutBuilder::build($composition, $spec['nodes'], $area . '.nodes');
if ($layout instanceof WP_Error) {
return $build->refuse($layout);
}
$warnings = array_merge(
$warnings,
$composition->warnings()->collect(NodeIndex::build($layout['document']), $layout['blocks']),
$this->saver->saveNew(
$id,
Serializer::document($layout['document']),
SaveScope::ThemeBuilderLayout
)
);
}
$themeBuilderId = $build->themeBuilder();
$templateId = $build->template($themeBuilderId, [
'title' => $title,
'layouts' => $layouts,
'use_on' => $useOn,
'exclude_from' => $excludeFrom,
]);
$mismatch = self::mismatch($store->template($templateId, false), $layouts, $useOn, $excludeFrom);
if ($mismatch !== null) {
throw new RuntimeException($mismatch);
}
$build->attach($themeBuilderId, $templateId);
} catch (WriteFailed $e) {
throw $e;
} catch (Throwable $e) {
$build->rollBack($e);
}
$after = [];
FollowUp::run(
static fn () => $store->clearCaches(),
'theme builder cache',
'Der Cache des Theme Builders konnte nicht geleert werden; die Vorlage ist angelegt (deaktiviert).',
$after
);
$newLayouts = [];
foreach ($plan as $area => $spec) {
if ($spec['source'] === 'new') {
$newLayouts[] = $area . ' ' . $layouts[$area]['id'];
}
}
$logId = $this->log->record(
$this->name(),
DiviThemeBuilder::TEMPLATE,
(string) $templateId,
sprintf(
'Theme Builder: Vorlage „%s“ angelegt (deaktiviert; neue Layouts: %s)',
$title,
$newLayouts === [] ? 'keine' : implode(', ', $newLayouts)
)
);
$template = $store->template($templateId, true);
$out = [];
foreach ($layouts as $area => $layout) {
$out[$area] = $layout + ['source' => $plan[$area]['source']];
}
return ToolResult::ok(sprintf(
'Theme-Builder-Vorlage „%s“ angelegt – deaktiviert, ohne Wirkung auf die Website. Aktivieren nur mit '
. 'lit/tb-activate.',
$title
))
->with('template_id', $templateId)
->with('template_rev', $template?->rev() ?? '')
->with('enabled', false)
->with('layouts', $out)
->warnings(array_merge($warnings, $after, [self::STALE_EDITOR]))
->logId($logId)
->urls(null, (string) admin_url('admin.php?page=et_theme_builder'))
->toArray();
}
/**
* Why the stored template is not what was asked for (Divi silently drops layout IDs, F17), else null.
*
* @param array<string, array{id: int, enabled: bool}> $layouts
* @param list<string> $useOn
* @param list<string> $excludeFrom
*/
private static function mismatch(?Template $stored, array $layouts, array $useOn, array $excludeFrom): ?string
{
if ($stored === null) {
return 'template not readable after store';
}
if ($stored->enabled || $stored->default) {
return 'template stored enabled or as default';
}
foreach ($layouts as $area => $layout) {
if ($stored->layouts[$area] !== $layout) {
return sprintf('layout %s stored as %d', $area, $stored->layouts[$area]['id']);
}
}
$same = $stored->useOn === $useOn && $stored->excludeFrom === $excludeFrom;
return $same ? null : 'conditions stored differently';
}
/** @return array<string, string> The refusal's fields, or its message under "<area>.nodes". */
private static function fields(WP_Error $error, string $area): array
{
$fields = Errors::fieldsOf($error);
return $fields !== [] ? $fields : [$area . '.nodes' => Errors::clientMessage($error)];
}
}
@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
use LIT\MCP\Abilities\Rollback;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use RuntimeException;
use Throwable;
use WP_Error;
defined('ABSPATH') || exit;
/**
* The transaction of lit/tb-create-template (all or nothing, like NewDraft for one post): records every post
* created for it (layouts, a live Theme Builder post Divi creates on the way – ruling Q8/B12 –, the template)
* and the assignment to the Theme Builder post; refuse() and rollBack() remove all of it again, the assignment
* first, then the posts newest first (wp_delete_post with force – nothing can use them yet; Divi's own trash
* routine is never called). A cleanup that fails is an honest partial_write naming the IDs (logged).
*/
final class TemplateBuild
{
public const REASON = 'Die Theme-Builder-Vorlage konnte nicht angelegt werden.';
/** @var list<int> */
private array $created = [];
/** @var array{0: int, 1: int}|null Theme Builder post, template. */
private ?array $attached = null;
public function __construct(private DiviThemeBuilder $store, private ChangeLog $log, private string $ability)
{
}
/** @throws RuntimeException */
public function layout(string $area, string $title): int
{
$id = $this->store->insertLayout($area, $title);
$this->created[] = $id;
return $id;
}
/**
* The live Theme Builder post; when the site has none yet, Divi creates it like on its first save and the
* new post belongs to this build (B12).
*
* @throws RuntimeException
*/
public function themeBuilder(): int
{
$id = $this->store->liveId(false);
if ($id > 0) {
return $id;
}
$id = $this->store->liveId(true);
if ($id <= 0) {
throw new RuntimeException('no live Theme Builder post');
}
$this->created[] = $id;
return $id;
}
/**
* @param array{title: string, layouts: array<string, array{id: int, enabled: bool}>, use_on: list<string>,
* exclude_from: list<string>} $template
* @throws RuntimeException
*/
public function template(int $themeBuilderId, array $template): int
{
$id = $this->store->storeTemplate($themeBuilderId, $template);
$this->created[] = $id;
return $id;
}
/** @throws RuntimeException */
public function attach(int $themeBuilderId, int $templateId): void
{
$this->attached = [$themeBuilderId, $templateId];
$this->store->attach($themeBuilderId, $templateId);
}
/** @return list<int> */
public function created(): array
{
return $this->created;
}
/**
* A refusal after posts exist (a layout the new post's own dynamic content map refuses): everything created
* is removed, then the refusal is returned.
*
* @throws WriteFailed When the cleanup fails.
*/
public function refuse(WP_Error $refusal): WP_Error
{
$failures = $this->undo();
if ($failures !== []) {
throw $this->partial(new RuntimeException($failures[0]), '');
}
return $refusal;
}
/** @throws WriteFailed Always. */
public function rollBack(Throwable $cause): never
{
$failures = $this->undo();
if ($failures !== []) {
throw $this->partial($cause, '; rollback: ' . implode('; ', $failures));
}
throw WriteFailed::rolledBack(
self::REASON,
$cause,
'Theme Builder template not created: ' . $cause->getMessage()
);
}
/** @return list<string> */
private function undo(): array
{
$steps = [];
if ($this->attached !== null) {
[$themeBuilderId, $templateId] = $this->attached;
$steps[] = fn () => $this->store->detach($themeBuilderId, $templateId);
}
foreach (array_reverse($this->created) as $id) {
$steps[] = static function () use ($id): void {
if (! wp_delete_post($id, true)) {
throw new RuntimeException(sprintf('post %d not deleted', $id));
}
};
}
return Rollback::attempt(...$steps);
}
private function partial(Throwable $cause, string $detail): WriteFailed
{
$ids = implode(', ', $this->created);
$this->log->recordPartial(
$this->ability,
DiviThemeBuilder::TEMPLATE,
$ids,
sprintf('Theme Builder: Anlegen nicht vollständig zurückgenommen (Beiträge %s)', $ids)
);
return WriteFailed::partial(
self::REASON,
$cause,
'Theme Builder rollback incomplete' . $detail,
state: sprintf('Möglicherweise sind die Beiträge %s noch vorhanden (deaktiviert).', $ids),
hint: sprintf('Im Divi Theme Builder prüfen und die Beiträge %s bei Bedarf löschen.', $ids),
details: ['created_ids' => $this->created]
);
}
}
@@ -124,7 +124,10 @@ class ThemeBuilderAccess
return $post;
}
/** Template $templateId for the Theme Builder tools; any other ID, or a hidden Theme Builder, is not found. */
/**
* Template $templateId for the Theme Builder tools. Any other ID, a hidden Theme Builder and a template the
* user may not edit (canEditTemplate) answer identically: not found (ruling Q18, P12 – like tb-list, Q8/B11).
*/
public function template(mixed $templateId, bool $forWrite): WP_Post|WP_Error
{
$id = PostGuard::id($templateId);
@@ -140,7 +143,7 @@ class ThemeBuilderAccess
! $post instanceof WP_Post
|| $post->post_type !== DiviThemeBuilder::TEMPLATE
|| (string) $post->post_status !== 'publish'
|| ! $this->visible()
|| ! $this->canEditTemplate($id)
) {
return Errors::notFound(
sprintf('Theme-Builder-Vorlage %d gibt es nicht.', $id),
@@ -148,10 +151,6 @@ class ThemeBuilderAccess
);
}
if (! current_user_can('edit_post', $id)) {
return Errors::forbidden(sprintf('Keine Berechtigung, Theme-Builder-Vorlage %d zu bearbeiten.', $id));
}
return $forWrite ? ($this->writableGate() ?? $post) : $post;
}
@@ -17,8 +17,8 @@ defined('ABSPATH') || exit;
* lit/tb-activate, all indirect. Built by DiviIntegration::themeBuilder() so it shares the Theme Builder access
* (rights, Tools-tab gates – its id ThemeBuilderAccess::GROUP is what the gates of the Divi tools ask; once this
* group is registered, its mode and "nur lesen" switch in the Tools tab drive them). Available with the Divi 5
* builder, like the Divi group; missing Divi internals show in lit-site-status. Composition, saver and log are
* for the writing tools of the later tasks.
* builder, like the Divi group; missing Divi internals show in lit-site-status. Composition, saver and log serve
* the writing tools.
*/
final class ThemeBuilderIntegration implements Integration
{
@@ -51,6 +51,7 @@ final class ThemeBuilderIntegration implements Integration
return [
new TbListAbility($this->access, $conditions),
new TbCreateTemplateAbility($this->access, $conditions, $this->composition, $this->saver, $this->log),
];
}
}