feat: Divi composer – short/full form to blocks with structure rules, presets and labels

Labels, background_color and preset ids go through the choke point (S13);
ambiguous preset names fail with their ids (S13a); fullwidth + rows is an
error. Carry-ins: control characters stripped before the marker check (S32),
kind override may only tighten, reference alt texts dropped, color check via
VariableRef::typeOf (S33).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 14:15:33 +02:00
co-authored by Claude Opus 5.5
parent f86cd5b34a
commit 4b72c2bb42
11 changed files with 1314 additions and 17 deletions
@@ -0,0 +1,89 @@
<?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\Schema\ModuleCatalog;
/**
* Which node may sit where (spec §5.4 "Struktur"): page → sections; regular section → rows;
* fullwidth section → fullwidth modules; column/group → modules; parent module → its child
* modules (catalog). Columns only come with their row; specialty sections and read-only
* nodes (opaque, non-Divi) are not rebuilt. Pure PHP.
*/
final class StructureRules
{
private const COLUMNS = ['divi/column', 'divi/column-inner'];
private const ROWS = ['divi/row', 'divi/row-inner'];
private const FULLWIDTH = 'divi/fullwidth-';
public function __construct(private ModuleCatalog $catalog)
{
}
/** 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);
}
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) || $parentName === 'divi/group') {
return $this->isPlainModule($child) || $child === 'divi/group'
? 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: category "module", not a fullwidth module. */
public function isPlainModule(string $name): bool
{
return $this->catalog->category($name) === 'module' && ! str_starts_with($name, self::FULLWIDTH);
}
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 str_starts_with($child, self::FULLWIDTH)
? null
: 'in einen Vollbreite-Abschnitt passen nur fullwidth-Module';
}
return $child === 'divi/row' ? null : 'ein Abschnitt enthält Zeilen (row)';
}
}