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>
165 lines
6.3 KiB
PHP
165 lines
6.3 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
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;
|
||
|
||
/**
|
||
* Which node may sit where (spec §5.4 "Struktur"): page → sections; regular section → rows;
|
||
* fullwidth section → fullwidth modules (catalog category "fullwidth-module"); column/group →
|
||
* modules; column → a nested row too (Divi 5 row is "nestable": true; neither the server
|
||
* – BlockParserStore::is_nested_module() only adds et_pb_row_nested – nor the builder's drop
|
||
* 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. 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
|
||
{
|
||
private const COLUMNS = ['divi/column', 'divi/column-inner'];
|
||
private const ROWS = ['divi/row', 'divi/row-inner'];
|
||
private const FULLWIDTH = 'fullwidth-module';
|
||
|
||
/** @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)
|
||
{
|
||
}
|
||
|
||
/** Null when $child may be placed into $parent (null = top level of the page), else the German reason. */
|
||
public function violation(?Block $parent, string $child): ?string
|
||
{
|
||
if (! $this->catalog->has($child)) {
|
||
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';
|
||
}
|
||
|
||
if (! $parent->isDivi() || $parent->isOpaque()) {
|
||
return 'der Zielknoten ist nur lesbar (globales Modul oder fremder Block)';
|
||
}
|
||
|
||
$parentName = $parent->name();
|
||
if ($parentName === 'divi/section') {
|
||
return $this->inSection($parent, $child);
|
||
}
|
||
|
||
if (in_array($parentName, self::ROWS, true)) {
|
||
return 'Spalten entstehen mit der Zeile ("columns"); eine neue Zeile mit der gewünschten Aufteilung '
|
||
. 'einfügen';
|
||
}
|
||
|
||
if (in_array($parentName, self::COLUMNS, true) && self::isSpecialtyColumn($parent)) {
|
||
return 'Spezialspalten (specialty) nur im Divi-Builder umbauen; Module in ihre inneren Spalten einfügen';
|
||
}
|
||
|
||
if (in_array($parentName, self::COLUMNS, true) || $parentName === 'divi/group') {
|
||
// A nested row: only straight into a (Divi 5) column – like the builder's drop target.
|
||
$nestedRow = $child === 'divi/row' && $parentName === 'divi/column';
|
||
|
||
return $this->isPlainModule($child) || $child === 'divi/group' || $nestedRow
|
||
? null
|
||
: sprintf('%s passt nicht in eine Spalte', $child);
|
||
}
|
||
|
||
$children = $this->catalog->childrenOf($parentName);
|
||
if ($children === []) {
|
||
return sprintf('%s kann keine Kind-Module enthalten', $parentName);
|
||
}
|
||
|
||
return in_array($child, $children, true)
|
||
? null
|
||
: sprintf('%s erlaubt nur %s als Kind', $parentName, implode(', ', $children));
|
||
}
|
||
|
||
/** A module that goes into a column: catalog category "module" (not "fullwidth-module"). */
|
||
public function isPlainModule(string $name): bool
|
||
{
|
||
return $this->catalog->category($name) === 'module';
|
||
}
|
||
|
||
/** A column of a specialty section: it holds inner rows, not modules. */
|
||
private static function isSpecialtyColumn(Block $column): bool
|
||
{
|
||
$specialty = AttrMap::desktop($column->attrs(), 'module.advanced.specialtyColumns');
|
||
if ($specialty !== null && $specialty !== '') {
|
||
return true;
|
||
}
|
||
|
||
foreach ($column->children() as $child) {
|
||
if ($child->name() === 'divi/row-inner') {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private function inSection(Block $section, string $child): ?string
|
||
{
|
||
$type = AttrMap::desktop($section->attrs(), 'module.advanced.type');
|
||
if ($type === 'specialty') {
|
||
return 'Spezialabschnitte (specialty) nur im Divi-Builder umbauen; Module in ihre Spalten einfügen';
|
||
}
|
||
|
||
if ($type === 'fullwidth') {
|
||
return $this->catalog->category($child) === self::FULLWIDTH
|
||
? null
|
||
: 'in einen Vollbreite-Abschnitt passen nur fullwidth-Module';
|
||
}
|
||
|
||
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)
|
||
);
|
||
}
|
||
}
|