Files
WP-LIT-MCP/src/Integrations/Divi/Compose/Composer.php
T
janniclas.loosenandClaude Opus 5.5 62ce99e558 refactor: ValueCleaner is the named choke point (H1)
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
2026-09-26 18:11:28 +02:00

723 lines
27 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 / 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);
}
}
}