The choke point moves out of AttrWriter into ValueCleaner (catalog, the composition's VariableRef, sanitizer): clean() (was AttrWriter::cleanValue), kindOf(), guardPath() and the private cleanNode/kind/unknownKind/tightens with VALUE_KEY, URL_KEY, URL_SEGMENT, STRICTNESS, BUILDER_ONLY (its docblock now sits on it; STRUCTURE stays in AttrWriter). Code of the pipeline moved unchanged. - ValueCleaner's docblock is the one canonical description: steps 0-8 with their rulings in code order, invariants I1-I3; the step comments in cleanNode carry the same numbers. AttrWriter/ShortformWriter/Composer/TextReplacer docblocks point there and keep only their own rules. - ValueCleaner::textReplacementFor($module) holds the D10 rule that was a closure in DiviReplaceTextAbility::replace (text/html only, clean() by kind). - AttrWriter, ShortformWriter and Composer take the ValueCleaner; DiviComposition builds one per composition (cleaner()). Tests: the eight AttrWriterTest methods that only exercised cleanValue/kindOf moved verbatim to ValueCleanerTest (clean/kindOf, ComposeFixture::cleaner()); mixed tests call the cleaner in place; +1 test for textReplacementFor. Every provider row and per-test assertion count unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
723 lines
27 KiB
PHP
723 lines
27 KiB
PHP
<?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 / 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. 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';
|
||
|
||
private const BACKGROUND = 'module.decoration.background';
|
||
|
||
private const DEFAULT_LEVEL = 'h2';
|
||
|
||
/**
|
||
* The builder's "Column Class" choices (Divi 5.2.1 visual-builder/build/field-library.js):
|
||
* n_24, the special fractions and "none" (no width class). Ruling S43.
|
||
*/
|
||
private const COLUMN_CLASSES = ['1_5', '2_5', '3_5', '1_7', '1_9', '1_10', '1_11', 'none'];
|
||
|
||
/** 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. */
|
||
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');
|
||
$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 */
|
||
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)));
|
||
$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, '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, '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);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* "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;
|
||
}
|
||
|
||
/**
|
||
* Ruling S41: the desktop flexType values of all columns of a row (in order), checked with
|
||
* the rules of "columns" – valid Divi widths that fill the row exactly. Null when valid,
|
||
* else the German problem.
|
||
*
|
||
* @param list<mixed> $flexTypes
|
||
*/
|
||
public function rowWidthsError(array $flexTypes): ?string
|
||
{
|
||
$saved = $this->errors;
|
||
$this->errors = [];
|
||
$valid = $flexTypes !== [] && array_filter(
|
||
$flexTypes,
|
||
static fn (mixed $width): bool => is_string($width) && preg_match('/^\d{1,2}_\d{1,2}$/D', $width) === 1
|
||
) === $flexTypes;
|
||
if ($valid) {
|
||
$this->widths(implode(',', $flexTypes), 'w');
|
||
}
|
||
|
||
$error = $valid ? ($this->errors['w'] ?? null) : 'Spaltenbreiten wie "8_24" erwartet';
|
||
$this->errors = $saved;
|
||
|
||
return $error;
|
||
}
|
||
|
||
/** Ruling S43: whether $width is a column class the Visual Builder offers. */
|
||
public static function isColumnClass(mixed $width): bool
|
||
{
|
||
if (! is_string($width)) {
|
||
return false;
|
||
}
|
||
|
||
return in_array($width, self::COLUMN_CLASSES, true)
|
||
|| (preg_match('/^([1-9]|1\d|2[0-4])_24$/D', $width) === 1);
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
}
|
||
}
|
||
}
|