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
@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Divi\Compose;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
defined('ABSPATH') || exit;
@@ -18,7 +19,8 @@ defined('ABSPATH') || exit;
* rules limit the depth); parent module → its child modules (catalog). Columns only come with
* their row; specialty sections, specialty columns (module.advanced.specialtyColumns or
* row-inner children, ColumnModule.php:306-324) and read-only nodes (opaque, non-Divi) are not
* rebuilt. Pure PHP.
* rebuilt. F22: in Theme Builder layouts only modules Divi renders there (appearance.themeBuilderLayouts,
* e.g. divi/post-content only in the body). Pure PHP.
*/
final class StructureRules
{
@@ -26,7 +28,8 @@ final class StructureRules
private const ROWS = ['divi/row', 'divi/row-inner'];
private const FULLWIDTH = 'fullwidth-module';
public function __construct(private ModuleCatalog $catalog)
/** @param ?string $themeBuilderArea "header", "body" or "footer" when the entry is a Theme Builder layout. */
public function __construct(private ModuleCatalog $catalog, private ?string $themeBuilderArea = null)
{
}
@@ -37,6 +40,11 @@ final class StructureRules
return sprintf('unbekanntes Modul "%s" (siehe lit-divi-catalog)', $child);
}
$area = $this->areaViolation($child);
if ($area !== null) {
return $area;
}
if ($parent === null) {
return $child === 'divi/section' ? null : 'auf oberster Ebene sind nur Abschnitte (section) erlaubt';
}
@@ -116,4 +124,41 @@ final class StructureRules
return $child === 'divi/row' ? null : 'ein Abschnitt enthält Zeilen (row)';
}
/**
* F22 for stored nodes that arrive with their children (copy, library entry, duplicate): the first module
* below $block that Divi does not render in this Theme Builder area; null for pages.
*/
public function nestedAreaViolation(Block $block): ?string
{
if ($this->themeBuilderArea === null) {
return null;
}
foreach ($block->children() as $child) {
$violation = $this->areaViolation(GlobalInstance::effectiveName($child))
?? $this->nestedAreaViolation($child);
if ($violation !== null) {
return $violation;
}
}
return null;
}
/** F22: in a Theme Builder layout, a module Divi renders only in other areas (Divi's own render rule). */
private function areaViolation(string $child): ?string
{
$areas = $this->catalog->themeBuilderAreas($child);
if ($areas === null || $this->themeBuilderArea === null || in_array($this->themeBuilderArea, $areas, true)) {
return null;
}
return sprintf(
'%s zeigt Divi im Bereich „%s“ nicht (nur %s)',
$child,
$this->themeBuilderArea,
implode(', ', $areas)
);
}
}
+2 -1
View File
@@ -81,7 +81,8 @@ final class TreeEditor
public function insert(array $position, array $blocks): ?string
{
foreach ($blocks as $block) {
$violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($block));
$violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($block))
?? $this->rules->nestedAreaViolation($block);
if ($violation !== null) {
return $violation;
}