Files
WP-LIT-MCP/src/Integrations/Divi/Compose/Composer.php
T
janniclas.loosenandClaude Opus 5.5 b24eb6c3e9 fix(security): no direct HTTP access to plugin PHP files (audit L2, cheap part)
Every file under src/ exits outside WordPress (defined('ABSPATH') ||
exit; after the use block, so a direct request no longer prints a fatal
error with the server path); divi-convert-fixture.php gets the WP_CLI
guard the other integration scripts have. A unit test keeps both true;
PSR1 side-effects warning excluded for the guard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-26 19:32:12 +02:00

655 lines
25 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;
defined('ABSPATH') || exit;
/**
* 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 / ValueCleaner::clean() (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 (ColumnWidths). 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'];
/** 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';
private const BACKGROUND = 'module.decoration.background';
private const DEFAULT_LEVEL = 'h2';
/** Advice of the S40 overlap error for new nodes. */
private const ONE_FORM = 'für diesen Pfad nur Kurzform oder attrs angeben';
/** @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 ValueCleaner $cleaner,
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'];
}
}
$set = $change['set'] ?? null;
$short = is_array($set) ? $this->shortPaths($block->shortName(), $set, 'set.') : [];
if (array_key_exists('label', $change)) {
// Ruling S41: the admin label is a short-form value too.
$short['label'] = self::LABEL_PATH;
}
$this->overlaps(
$short,
$change['attrs'] ?? null,
$field,
'bitte als zwei Änderungen senden'
);
$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];
}
/**
* Rulings S38/S40: one change or new node may not reach the same attribute path through the
* short form and "attrs" – composite values are replaced whole per breakpoint, so combining
* them would silently lose sub-keys. Paths overlap when equal or when one lies below the other.
*
* @param array<string, string> $short Input name of the short-form value ("set.text", "text") => its path.
* @param mixed $full The "attrs" input.
* @param string $advice How to resolve it, for the error message.
*/
private function overlaps(array $short, mixed $full, string $field, string $advice): void
{
if ($short === [] || ! is_array($full)) {
return;
}
foreach (array_keys($full) as $path) {
$path = (string) $path;
foreach ($short as $name => $shortPath) {
if (AttrMap::overlaps($path, $shortPath)) {
$this->errors[$field . '.attrs.' . $path] = sprintf(
'derselbe Attributpfad wird auch über %s geändert; %s',
$name,
$advice
);
break;
}
}
}
}
/** @param array<array-key, mixed> $full "attrs" input. */
private function touches(array $full, string $path): bool
{
foreach (array_keys($full) as $given) {
if (AttrMap::overlaps((string) $given, $path)) {
return true;
}
}
return false;
}
/**
* Ruling S41: the default level of a new heading stays when attrs replaced the desktop font
* value it lives in (attrs replace a breakpoint value whole) – unless attrs name a level.
*
* @param array<string, mixed> $attrs
* @return array<string, mixed>
*/
private function defaultLevel(string $short, array $attrs): array
{
$field = ShortformMap::fields($short)['level'] ?? null;
if ($field === null || $field['sub'] === null) {
return $attrs;
}
$value = AttrMap::desktop($attrs, (string) $field['path']);
if ($value !== null && ! is_array($value)) {
return $attrs;
}
if (is_array($value) && array_key_exists((string) $field['sub'], $value)) {
return $attrs;
}
$current = AttrMap::get($attrs, (string) $field['path'] . '.desktop.value');
$merged = array_merge([(string) $field['sub'] => self::DEFAULT_LEVEL], is_array($current) ? $current : []);
return AttrMap::set($attrs, (string) $field['path'] . '.desktop.value', $merged);
}
/**
* Paths of the short-form fields a client gave, keyed by their input name ($prefix . field).
*
* @param array<array-key, mixed> $values Field => value.
* @return array<string, string>
*/
private function shortPaths(string $type, array $values, string $prefix): array
{
$paths = [];
foreach ($this->shortform->paths($type, array_map('strval', array_keys($values))) as $name => $path) {
$paths[$prefix . $name] = $path;
}
return $paths;
}
/** Admin label for a new module: "<Abschnitt> – <Modultitel>" or the title alone. */
private 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'] = $this->typeFieldCollision($spec, $parent) ?? $violation;
return null;
}
return match ($type) {
'divi/section' => $this->section($spec, $path),
'divi/row' => $this->row($spec, $path, $context, $parent !== null && $parent->name() !== 'divi/section'),
default => $this->module($type, $spec, $path, $context),
};
}
/**
* Trial feedback: an item whose only possible type has a short-form field named "type" (the
* contact field's field type) – a `type` that is no allowed child was almost certainly meant as
* that field. Explains the collision instead of "unbekanntes Modul divi/<value>".
*
* @param array<array-key, mixed> $spec
*/
private function typeFieldCollision(array $spec, ?Block $parent): ?string
{
$children = $parent === null ? [] : $this->catalog->childrenOf($parent->name());
if (count($children) !== 1 || ! is_string($spec['type'] ?? null)) {
return null;
}
$child = ModuleCatalog::shortName($children[0]);
$field = ShortformMap::fields($child)['type'] ?? null;
if ($field === null) {
return null;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP (spec §4.4).
$value = json_encode(
$spec['type'],
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE
);
return sprintf(
'`type` ist hier der Knotentyp (%1$s, darf fehlen); das Feldtyp-Feld über attrs setzen: '
. '{"attrs": {"%2$s": {"desktop": %3$s}}} – oder type weglassen und danach mit lit-divi-update '
. '{"set": {"type": %3$s}} setzen.',
$child,
(string) $field['path'],
(string) $value
);
}
/** @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');
$this->overlaps(
['background_color' => self::BACKGROUND],
$spec['attrs'] ?? null,
$path,
self::ONE_FORM
);
}
$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
* @param bool $nested In a column (Divi 5 rows nest): own auto label (ledger T3).
*/
private function row(array $spec, string $path, string $context, bool $nested): Block
{
$this->unknownKeys($spec, array_merge(self::COMMON, self::ROW), $path);
$parsed = ColumnWidths::parse($spec['columns'] ?? '4_4');
if ($parsed['error'] !== null) {
$this->errors[$path . '.columns'] = $parsed['error'];
}
$widths = $parsed['widths'];
$label = $context === '' ? null : $context . ($nested ? ' – Zeile (innen)' : ' – 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)));
$this->overlaps($this->shortPaths($short, $values, ''), $spec['attrs'] ?? null, $path, self::ONE_FORM);
$defaultLevel = $name === 'divi/heading' && ! array_key_exists('level', $values);
if ($defaultLevel) {
$values['level'] = self::DEFAULT_LEVEL;
}
$attrs = ['builderVersion' => $this->builderVersion];
if ($values !== []) {
$result = $this->shortform->apply($short, $attrs, $values, $path);
$attrs = $result['attrs'];
$this->errors += $result['errors'];
}
$attrs = $this->common($name, $attrs, $spec, $path, $this->autoLabel($name, $context));
if ($defaultLevel) {
$attrs = $this->defaultLevel($short, $attrs);
}
$block = Block::create($name, $attrs);
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
{
// Ruling S41: "label" and attrs on the admin label is an error; attrs alone replace the auto label.
$full = $spec['attrs'] ?? null;
if (array_key_exists('label', $spec) && $spec['label'] !== null) {
$this->overlaps(['label' => self::LABEL_PATH], $full, $path, self::ONE_FORM);
} elseif (is_array($full) && $this->touches($full, self::LABEL_PATH)) {
$autoLabel = null;
}
$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');
// An unknown path is the more useful message on its key (it decides invalid_attr_path).
foreach ($result['invalid_paths'] as $invalid) {
$key = $path . '.attrs.' . $invalid;
if (isset($result['errors'][$key])) {
$this->errors[$key] = $result['errors'][$key];
}
}
$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->cleaner->clean($name, self::LABEL_PATH, $label, null, ValueKind::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->cleaner->clean($name, 'modulePreset', $id, null, ValueKind::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', self::BACKGROUND, $color, 'color');
} catch (InvalidArgumentException $e) {
$this->errors[$path] = $e->getMessage();
return $attrs;
}
return AttrMap::set($attrs, self::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);
}
}
}
/**
* @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);
}
}
}