fix: Task 3 review round 1 - strip control chars before the sanitizer (S32a), S34 minors

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
This commit is contained in:
2026-09-26 14:26:18 +02:00
co-authored by Claude Opus 5.5
parent 4b72c2bb42
commit 8688a02402
13 changed files with 278 additions and 104 deletions
@@ -10,15 +10,19 @@ 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.
* 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 = 'divi/fullwidth-';
private const FULLWIDTH = 'fullwidth-module';
public function __construct(private ModuleCatalog $catalog)
{
@@ -49,8 +53,15 @@ final class StructureRules
. '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') {
return $this->isPlainModule($child) || $child === '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);
}
@@ -65,10 +76,27 @@ final class StructureRules
: sprintf('%s erlaubt nur %s als Kind', $parentName, implode(', ', $children));
}
/** A module that goes into a column: category "module", not a fullwidth module. */
/** 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' && ! str_starts_with($name, self::FULLWIDTH);
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
@@ -79,7 +107,7 @@ final class StructureRules
}
if ($type === 'fullwidth') {
return str_starts_with($child, self::FULLWIDTH)
return $this->catalog->category($child) === self::FULLWIDTH
? null
: 'in einen Vollbreite-Abschnitt passen nur fullwidth-Module';
}