Files
WP-LIT-MCP/src/Integrations/Divi/Compose/Composer.php
T
janniclas.loosenandClaude Opus 5.5 8688a02402 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
2026-09-26 14:26:18 +02:00

546 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Compose;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
/**
* Builds new Divi blocks from node descriptions (spec §5.4): short form, full form ("attrs")
* or both; sections with rows ("columns": "1_3,2_3") and modules; parent modules with
* "items"; presets by name; automatic admin labels "<Abschnitt> – <Modul>" (spec §5.5).
* Every error is collected with its input path; blocks are only usable when there are none.
* Pure PHP.
*
* Divi saves bypass kses (D7): every client string reaches the attributes only through
* ShortformWriter / AttrWriter::cleanValue() (the single choke point) – short-form fields,
* full-form attrs, admin labels, background_color (ShortformWriter::color(), ruling S13) and
* preset ids. A value that fails there is an error, never stored raw (S13). What Composer
* writes itself is not client text: the builder version (checked in the constructor), block
* names (catalog + StructureRules + Block::create), "fullwidth" and column flexTypes built
* from digits. Design-system names that match several entries are errors listing the ids
* (ruling S13a: colors/variables in Placeholders, presets in DesignSystem::modulePresetId()).
*/
final class Composer
{
private const COMMON = ['type', 'label', 'preset', 'attrs'];
private const SECTION = ['section_type', 'background_color', 'rows', 'modules'];
private const ROW = ['columns', 'modules'];
/** Keys of one lit-divi-update change ("id" names the node, read by the ability). */
private const UPDATE = ['id', 'set', 'attrs', 'preset', 'label'];
/** Column fractions Divi's flex layout knows besides multiples of 1/24. */
private const SPECIAL_WIDTHS = ['1_5', '2_5', '3_5', '4_5', '1_7', '1_9', '1_10', '1_11'];
/** ET_BUILDER_PRODUCT_VERSION, e.g. "5.2.1" – written into every new block. */
private const VERSION = '/^[0-9][0-9A-Za-z.+-]*$/D';
private const LABEL_PATH = 'module.meta.adminLabel';
/** @var array<string, string> */
private array $errors = [];
/** @var list<string> */
private array $invalidPaths = [];
/** @throws InvalidArgumentException When $builderVersion is no version number. */
public function __construct(
private ModuleCatalog $catalog,
private StructureRules $rules,
private ShortformWriter $shortform,
private AttrWriter $attrWriter,
private DesignSystem $design,
private string $builderVersion
) {
if (preg_match(self::VERSION, $builderVersion) !== 1) {
throw new InvalidArgumentException(sprintf('"%s" is not a Divi builder version.', $builderVersion));
}
}
/**
* @param array<array-key, mixed> $specs Node descriptions.
* @param Block|null $parent Block the nodes go into (null = top level of the page).
* @param string $field Input path of $specs for error keys, e.g. "nodes".
* @param string $context Admin label of the surrounding section ("" if none).
* @return array{blocks: list<Block>, errors: array<string, string>, invalid_paths: list<string>}
*/
public function compose(array $specs, ?Block $parent, string $field, string $context = ''): array
{
$this->errors = [];
$this->invalidPaths = [];
$blocks = [];
foreach (array_values($specs) as $position => $spec) {
$block = $this->node($spec, $parent, sprintf('%s[%d]', $field, $position), $context);
if ($block !== null) {
$blocks[] = $block;
}
}
return ['blocks' => $blocks, 'errors' => $this->errors, 'invalid_paths' => $this->invalidPaths];
}
/**
* Applies short-form fields ("set"), full-form attrs, preset and label to an existing block
* (lit-divi-update); errors like compose(). The block itself is not changed – the caller
* sets the returned attrs. "preset": null and "label": null remove them.
*
* @param array<array-key, mixed> $change id, set, attrs, preset, label
* @return array{attrs: array<string, mixed>, errors: array<string, string>, invalid_paths: list<string>}
*/
public function update(Block $block, array $change, string $field): array
{
$this->errors = [];
$this->invalidPaths = [];
$attrs = $block->attrs();
if (! $block->isDivi() || $block->isOpaque()) {
$this->errors[$field . '.id'] = 'nur lesbar (globales Modul oder fremder Block)';
return ['attrs' => $attrs, 'errors' => $this->errors, 'invalid_paths' => []];
}
$this->unknownKeys($change, self::UPDATE, $field);
if (array_key_exists('set', $change)) {
if (! is_array($change['set']) || $change['set'] === [] || array_is_list($change['set'])) {
$this->errors[$field . '.set'] = 'Objekt mit Kurzform-Feldern erwartet';
} else {
$result = $this->shortform->apply($block->shortName(), $attrs, $change['set'], $field . '.set');
$attrs = $result['attrs'];
$this->errors += $result['errors'];
}
}
$attrs = $this->presetAndAttrs($block->name(), $attrs, $change, $field);
if (array_key_exists('label', $change)) {
$attrs = $change['label'] === null || $change['label'] === ''
? AttrMap::remove($attrs, self::LABEL_PATH)
: $this->label($block->name(), $attrs, $change['label'], $field . '.label');
}
return ['attrs' => $attrs, 'errors' => $this->errors, 'invalid_paths' => $this->invalidPaths];
}
/** Admin label for a new module: "<Abschnitt> – <Modultitel>" or the title alone. */
public function autoLabel(string $name, string $context): string
{
$title = $this->catalog->title($name);
return $context === '' ? $title : $context . ' – ' . $title;
}
private function node(mixed $spec, ?Block $parent, string $path, string $context): ?Block
{
if (! is_array($spec) || array_is_list($spec)) {
$this->errors[$path] = 'Objekt mit "type" erwartet';
return null;
}
$type = $this->typeOf($spec, $parent);
if ($type === null) {
$this->errors[$path . '.type'] = 'type fehlt';
return null;
}
$violation = $this->rules->violation($parent, $type);
if ($violation !== null) {
$this->errors[$path . '.type'] = $violation;
return null;
}
return match ($type) {
'divi/section' => $this->section($spec, $path),
'divi/row' => $this->row($spec, $path, $context),
default => $this->module($type, $spec, $path, $context),
};
}
/** @param array<array-key, mixed> $spec */
private function typeOf(array $spec, ?Block $parent): ?string
{
if (is_string($spec['type'] ?? null) && trim($spec['type']) !== '') {
return ModuleCatalog::normalize($spec['type']);
}
if (array_key_exists('type', $spec)) {
return null;
}
$children = $parent === null ? [] : $this->catalog->childrenOf($parent->name());
return count($children) === 1 ? $children[0] : null;
}
/** @param array<array-key, mixed> $spec */
private function section(array $spec, string $path): Block
{
$this->unknownKeys($spec, array_merge(self::COMMON, self::SECTION), $path);
$type = $spec['section_type'] ?? 'regular';
$attrs = ['builderVersion' => $this->builderVersion];
if ($type === 'fullwidth') {
$attrs = AttrMap::set($attrs, 'module.advanced.type', ['desktop' => ['value' => 'fullwidth']]);
} elseif ($type !== 'regular') {
$this->errors[$path . '.section_type'] = 'regular oder fullwidth erwartet (specialty nur im Divi-Builder)';
}
if (array_key_exists('background_color', $spec)) {
$attrs = $this->backgroundColor($attrs, $spec['background_color'], $path . '.background_color');
}
$attrs = $this->common('divi/section', $attrs, $spec, $path, null);
$label = AttrMap::desktop($attrs, self::LABEL_PATH);
$context = is_string($label) ? $label : '';
$section = Block::create('divi/section', $attrs);
$hasRows = array_key_exists('rows', $spec);
$hasModules = array_key_exists('modules', $spec);
if ($type === 'fullwidth') {
if ($hasRows) {
$this->errors[$path . '.rows'] = 'ein Vollbreite-Abschnitt (section_type fullwidth) hat keine Zeilen; '
. 'fullwidth-Module unter "modules" angeben';
}
$this->children($section, $spec['modules'] ?? [], $path . '.modules', $context);
} elseif ($hasRows && $hasModules) {
$this->errors[$path] = 'entweder rows oder modules angeben';
} elseif ($hasModules) {
$this->children($section, [['type' => 'row', 'modules' => $spec['modules']]], $path . '.modules', $context);
} else {
$this->children($section, $spec['rows'] ?? [], $path . '.rows', $context);
}
return $section;
}
/** @param array<array-key, mixed> $spec */
private function row(array $spec, string $path, string $context): Block
{
$this->unknownKeys($spec, array_merge(self::COMMON, self::ROW), $path);
$widths = $this->widths($spec['columns'] ?? '4_4', $path . '.columns');
$label = $context === '' ? null : $context . ' – Zeile';
$attrs = $this->common('divi/row', ['builderVersion' => $this->builderVersion], $spec, $path, $label);
$row = Block::create('divi/row', $attrs);
$modules = $spec['modules'] ?? [];
if (! is_array($modules) || ! array_is_list($modules)) {
$this->errors[$path . '.modules'] = 'Liste erwartet (je Spalte eine Liste von Modulen)';
$modules = [];
}
// One column: a flat list of modules is accepted.
$first = $modules[0] ?? null;
if (count($widths) === 1 && is_array($first) && ! array_is_list($first)) {
$modules = [$modules];
}
if (count($modules) > count($widths)) {
$this->errors[$path . '.modules'] = sprintf(
'%d Spalten, aber %d Modul-Listen',
count($widths),
count($modules)
);
}
foreach ($widths as $position => $width) {
$column = Block::create('divi/column', AttrMap::set(
['builderVersion' => $this->builderVersion],
'module.decoration.sizing',
['desktop' => ['value' => ['flexType' => $width]]]
));
$row->insertChild($position, $column);
$this->children($column, $modules[$position] ?? [], sprintf('%s.modules[%d]', $path, $position), $context);
}
return $row;
}
/** @param array<array-key, mixed> $spec */
private function module(string $name, array $spec, string $path, string $context): Block
{
$short = ModuleCatalog::shortName($name);
$fields = array_keys(ShortformMap::fields($short));
$hasItems = $this->catalog->childrenOf($name) !== [];
$allowed = array_merge(
self::COMMON,
$fields,
$hasItems ? ['items'] : [],
$name === 'divi/group' ? ['modules'] : []
);
$this->unknownKeys($spec, $allowed, $path);
// "type" and "label" of a node are its module type and admin label; the short-form fields
// of the same name (contact-field type, social network label) go through lit-divi-update "set".
$values = array_intersect_key($spec, array_flip(array_diff($fields, self::COMMON)));
if ($name === 'divi/heading' && ! array_key_exists('level', $values)) {
$values['level'] = 'h2';
}
$attrs = ['builderVersion' => $this->builderVersion];
if ($values !== []) {
$result = $this->shortform->apply($short, $attrs, $values, $path);
$attrs = $result['attrs'];
$this->errors += $result['errors'];
}
$block = Block::create($name, $this->common($name, $attrs, $spec, $path, $this->autoLabel($name, $context)));
if ($hasItems) {
$this->children($block, $spec['items'] ?? [], $path . '.items', $context);
} elseif ($name === 'divi/group') {
$this->children($block, $spec['modules'] ?? [], $path . '.modules', $context);
}
return $block;
}
/**
* Preset, full-form attrs and admin label of a new block. The label is $spec's "label"
* when given ("" = none), else $autoLabel.
*
* @param array<string, mixed> $attrs
* @param array<array-key, mixed> $spec
* @return array<string, mixed>
*/
private function common(string $name, array $attrs, array $spec, string $path, ?string $autoLabel): array
{
$attrs = $this->presetAndAttrs($name, $attrs, $spec, $path);
$label = array_key_exists('label', $spec) && $spec['label'] !== null ? $spec['label'] : $autoLabel;
if ($label === null || $label === '') {
return $attrs;
}
return $this->label($name, $attrs, $label, $path . '.label');
}
/**
* @param array<string, mixed> $attrs
* @param array<array-key, mixed> $spec
* @return array<string, mixed>
*/
private function presetAndAttrs(string $name, array $attrs, array $spec, string $path): array
{
if (array_key_exists('preset', $spec)) {
$attrs = $this->preset($name, $attrs, $spec['preset'], $path . '.preset');
}
if (! array_key_exists('attrs', $spec)) {
return $attrs;
}
if (! is_array($spec['attrs']) || array_is_list($spec['attrs'])) {
$this->errors[$path . '.attrs'] = 'Objekt "Attributpfad": {Breakpoint: Wert} erwartet';
return $attrs;
}
$result = $this->attrWriter->apply($name, $attrs, $spec['attrs'], $path . '.attrs');
$this->errors += $result['errors'];
array_push($this->invalidPaths, ...$result['invalid_paths']);
return $result['attrs'];
}
/**
* The admin label, cleaned as text at the choke point (ruling S13): a label that fails there,
* or that still carries a reference, is an error and nothing is written.
*
* @param array<string, mixed> $attrs
* @return array<string, mixed>
*/
private function label(string $name, array $attrs, mixed $label, string $path): array
{
if (! is_string($label)) {
$this->errors[$path] = 'Text erwartet';
return $attrs;
}
try {
$clean = $this->attrWriter->cleanValue($name, self::LABEL_PATH, $label, null, 'text');
} catch (InvalidArgumentException $e) {
$this->errors[$path] = $e->getMessage();
return $attrs;
}
if (! is_string($clean) || VariableRef::contains($clean)) {
$this->errors[$path] = 'Beschriftung: nur Text, keine Divi-Verweise oder Platzhalter';
return $attrs;
}
return $clean === '' ? $attrs : AttrMap::set($attrs, self::LABEL_PATH, ['desktop' => ['value' => $clean]]);
}
/**
* @param array<string, mixed> $attrs
* @return array<string, mixed>
*/
private function preset(string $name, array $attrs, mixed $preset, string $path): array
{
if ($preset === null || $preset === '') {
unset($attrs['modulePreset']);
return $attrs;
}
try {
$id = is_string($preset) ? $this->design->modulePresetId($name, $preset) : null;
} catch (InvalidArgumentException $e) {
// Ruling S13a: a name several presets share (DesignSystem lists their ids).
$this->errors[$path] = $e->getMessage();
return $attrs;
}
if ($id === null) {
$names = $this->design->modulePresetNames($name);
$this->errors[$path] = sprintf(
'unbekanntes Preset; vorhanden für %s: %s',
$name,
$names === [] ? '(keine)' : implode(', ', $names)
);
return $attrs;
}
// Design-system data, but still through the choke point: an id text() would change is refused.
try {
$clean = $this->attrWriter->cleanValue($name, 'modulePreset', $id, null, 'text');
} catch (InvalidArgumentException $e) {
$clean = null;
}
if ($clean !== $id) {
$this->errors[$path] = sprintf('Preset-ID "%s" ist ungültig', $id);
return $attrs;
}
$attrs['modulePreset'] = [$id];
return $attrs;
}
/**
* Ruling S13: the short-form color check (ShortformWriter::color()), never text().
*
* @param array<string, mixed> $attrs
* @return array<string, mixed>
*/
private function backgroundColor(array $attrs, mixed $color, string $path): array
{
try {
$clean = $this->shortform->color('divi/section', 'module.decoration.background', $color, 'color');
} catch (InvalidArgumentException $e) {
$this->errors[$path] = $e->getMessage();
return $attrs;
}
return AttrMap::set($attrs, 'module.decoration.background', ['desktop' => ['value' => ['color' => $clean]]]);
}
private function children(Block $parent, mixed $specs, string $path, string $context): void
{
if (! is_array($specs) || ($specs !== [] && ! array_is_list($specs))) {
$this->errors[$path] = 'Liste erwartet';
return;
}
foreach ($specs as $position => $spec) {
$child = $this->node($spec, $parent, sprintf('%s[%d]', $path, $position), $context);
if ($child !== null) {
$parent->insertChild(PHP_INT_MAX, $child);
}
}
}
/**
* "1_3,2_3" → flexTypes ["8_24", "16_24"]; the fractions must add up to exactly one row
* (exact integer arithmetic over the common denominator, ruling S34 – no float tolerance).
*
* @return list<string>
*/
private function widths(mixed $columns, string $path): array
{
$parts = is_string($columns) ? array_map('trim', explode(',', $columns)) : [];
$widths = [];
$fractions = [];
foreach ($parts as $part) {
$valid = preg_match('/^(\d{1,2})_(\d{1,2})$/D', $part, $match) === 1
&& (int) $match[1] >= 1
&& (int) $match[1] <= (int) $match[2];
if (! $valid) {
$this->errors[$path] = 'Spaltenaufteilung wie "1_2,1_2" oder "1_3,2_3" erwartet';
return ['24_24'];
}
[$numerator, $denominator] = [(int) $match[1], (int) $match[2]];
if (24 % $denominator === 0) {
$widths[] = ($numerator * 24 / $denominator) . '_24';
} elseif (in_array($part, self::SPECIAL_WIDTHS, true)) {
$widths[] = $part;
} else {
$this->errors[$path] = sprintf('Spaltenbreite %s gibt es in Divi nicht', $part);
return ['24_24'];
}
$fractions[] = [$numerator, $denominator];
}
if ($widths === [] || ! self::sumsToOne($fractions)) {
$this->errors[$path] = 'die Spaltenbreiten müssen zusammen eine volle Zeile ergeben (z. B. "1_3,2_3")';
return ['24_24'];
}
return $widths;
}
/**
* Whether the fractions add up to exactly 1: numerators scaled to the least common
* denominator (denominators are divisors of 24 or 5, 7, 9, 10, 11 – at most 27720).
*
* @param list<array{int, int}> $fractions [numerator, denominator]
*/
private static function sumsToOne(array $fractions): bool
{
$common = 1;
foreach ($fractions as [, $denominator]) {
$common = intdiv($common * $denominator, self::gcd($common, $denominator));
}
$sum = 0;
foreach ($fractions as [$numerator, $denominator]) {
$sum += $numerator * intdiv($common, $denominator);
}
return $sum === $common;
}
private static function gcd(int $a, int $b): int
{
return $b === 0 ? $a : self::gcd($b, $a % $b);
}
/**
* @param array<array-key, mixed> $spec
* @param list<string> $allowed
*/
private function unknownKeys(array $spec, array $allowed, string $path): void
{
foreach (array_diff(array_map('strval', array_keys($spec)), $allowed) as $key) {
$this->errors[$path . '.' . $key] = 'unbekanntes Feld; erlaubt: ' . implode(', ', $allowed);
}
}
}