Control characters now go on the raw value in AttrWriter::cleanNode and
VariableRef::setting; after the sanitizer only rejection remains ('<' + VT/FF
no longer turns into a tag). VALUE_KEY anchored with D, exact column-width
fractions, nested rows in columns, specialty columns refused, fullwidth by
catalog category, preset ambiguity in DesignSystem::modulePresetId().
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
118 lines
4.5 KiB
PHP
118 lines
4.5 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\Schema\ModuleCatalog;
|
||
|
||
/**
|
||
* 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. 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';
|
||
|
||
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) && 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)';
|
||
}
|
||
}
|