feat: Divi tools edit Theme Builder layouts; visible layouts need confirm

TB layouts load through ThemeBuilderAccess (P12), live layouts (attached +
enabled, Q8) need a dry run and confirm (Q9) and get the shared restore point
of VisibleEdits, extracted from GlobalEdits (Q2: backup when revisions are
off, P9 refusals, P14 naming). divi/post-content only in body layouts (F22),
also for nested stored nodes. SaveScope::ThemeBuilderLayout sets Divi flags
and clears site CSS + TB cache.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 15:45:07 +02:00
co-authored by Claude Opus 5.5
parent 4ff1c1e2a5
commit b1dd35abd8
27 changed files with 1106 additions and 132 deletions
+68 -37
View File
@@ -14,6 +14,7 @@ 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\Library\GlobalEdits;
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;
use LIT\MCP\Integrations\WordPress\PostSaveFollowUp;
use WP_Error;
@@ -25,7 +26,8 @@ defined('ABSPATH') || exit;
* tree – only when its whole input validated (all or nothing) –, serialize, save via DiviSaver,
* then lit_mcp_after_save, change log and the ToolResult with changed nodes, affected outline,
* quality warnings (spec §5.5), revision_id, the new rev and tree_rev. commit() is the entry point
* of the tools: library entries additionally pass GlobalEdits (shape, dry run/confirm, backup).
* of the tools: library entries additionally pass GlobalEdits (shape, dry run/confirm, backup), Theme
* Builder layouts ThemeBuilderEdits (live layouts: dry run/confirm, backup; ruling Q2/Q9).
*/
final class DiviWriter
{
@@ -38,7 +40,8 @@ final class DiviWriter
private ChangeLog $log,
private DesignSystemReader $design,
private DiviComposition $composition,
private ?GlobalEdits $globals = null
private ?GlobalEdits $globals = null,
private ?ThemeBuilderEdits $themeBuilder = null
) {
}
@@ -67,11 +70,12 @@ final class DiviWriter
/**
* The Compose classes for $page: bound to its entry, so placeholders and $variable(…)$
* references are checked against Divi's dynamic content options for it (ruling S22).
* references are checked against Divi's dynamic content options for it (ruling S22); a Theme
* Builder layout also by its area (F22: modules Divi renders only elsewhere are refused).
*/
public function composition(DiviPage $page): DiviComposition
{
return $this->composition->forPost((int) $page->post->ID);
return $this->composition->forPost((int) $page->post->ID, $page->themeBuilderArea());
}
/**
@@ -151,7 +155,7 @@ final class DiviWriter
/**
* save() for the Divi tools (E2): pages as before; a library entry keeps its shape, a global one
* needs confirm (dry run first), gets a backup when revisions are off and clears every page's
* cache; the result then names the affected pages.
* cache; the result then names the affected pages. Theme Builder layouts: commitLayout().
*
* @param array<string, mixed> $input
* @return array<string, mixed>|WP_Error
@@ -159,6 +163,10 @@ final class DiviWriter
*/
public function commit(string $ability, DiviPage $page, DiviChange $change, array $input): array|WP_Error
{
if ($page->isThemeBuilderLayout()) {
return $this->commitLayout($ability, $page, $change, $input);
}
if (! $page->isLibraryItem()) {
return $this->save($ability, $page, $change, $input);
}
@@ -179,48 +187,71 @@ final class DiviWriter
return $prepared;
}
return $this->savePrepared($ability, $page, $change, $input, $prepared, 'des Bibliothekseintrags');
}
/**
* save() for a Theme Builder layout: a layout visitors see needs confirm and gets the restore point
* of a visible change (ThemeBuilderEdits, rulings Q2/Q9); the result names the templates that show it.
*
* @param array<string, mixed> $input
* @return array<string, mixed>|WP_Error
* @throws WriteFailed
*/
private function commitLayout(string $ability, DiviPage $page, DiviChange $change, array $input): array|WP_Error
{
if ($this->themeBuilder === null) {
return Errors::unsupported(
'Theme-Builder-Layouts lassen sich über diese Verbindung nicht ändern.',
'Das Layout im Divi Theme Builder bearbeiten.'
);
}
if (Serializer::document($page->document) === (string) $page->post->post_content) {
return $this->save($ability, $page, $change, $input, SaveScope::ThemeBuilderLayout);
}
$prepared = $this->themeBuilder->prepare($page, $input);
if ($prepared instanceof WP_Error) {
return $prepared;
}
return $this->savePrepared($ability, $page, $change, $input, $prepared, 'des Theme-Builder-Layouts');
}
/**
* save() with the scope GlobalEdits/ThemeBuilderEdits prepared; their details join the result, and a
* failure after their backup names it (ruling P14, VisibleEdits). $entry names the entry in that error.
*
* @param array<string, mixed> $input
* @param array{scope: SaveScope, details: array<string, mixed>} $prepared
* @return array<string, mixed>
* @throws WriteFailed
*/
private function savePrepared(
string $ability,
DiviPage $page,
DiviChange $change,
array $input,
array $prepared,
string $entry
): array {
try {
$saved = $this->save($ability, $page, $change, $input, $prepared['scope']);
} catch (WriteFailed $e) {
throw self::keepingBackup($e, $prepared['details']['backup_id'] ?? null);
throw VisibleEdits::keepingBackup($e, $prepared['details']['backup_id'] ?? null, $entry);
}
return array_merge($saved, $prepared['details']);
}
/**
* Ruling P14: GlobalEdits made a backup (revisions off) before the save failed – the error names
* it, because it stays in the trash and is the only earlier state.
*/
private static function keepingBackup(WriteFailed $e, mixed $backupId): WriteFailed
{
if (! is_int($backupId)) {
return $e;
}
$kept = sprintf(
'Sicherung %d liegt im Papierkorb (vorheriger Stand des Bibliothekseintrags, in WordPress '
. 'wiederherstellbar).',
$backupId
);
if ($e->rolledBack) {
return $e->withDetails(
['backup_id' => $backupId],
$kept . ' Den Aufruf erst nach Prüfung mit lit-divi-outline wiederholen.',
Errors::notSavedKeeping(sprintf('Sicherung %d im Papierkorb', $backupId))
);
}
return $e->withDetails(
['backup_id' => $backupId],
'Den Aufruf nicht einfach wiederholen. ' . $kept . ' Zuerst den Stand mit lit-divi-outline prüfen und '
. 'dem Nutzer Bescheid geben.'
);
}
/** A tool's confirm_required on a global library entry, extended by the affected pages. */
/** A tool's confirm_required on a global library entry or a live Theme Builder layout, extended by who sees it. */
public function notice(DiviPage $page, WP_Error $error): WP_Error
{
if ($page->isThemeBuilderLayout()) {
return $this->themeBuilder?->notice($page, $error) ?? $error;
}
return $page->isLibraryItem() && $this->globals !== null ? $this->globals->notice($page, $error) : $error;
}