refactor: ColumnWidths holds the column-width rules (H5)

Composer::row uses ColumnWidths::parse; rowWidthsError and its error-state
save/restore are gone. The update width check moves out of
DiviUpdateAbility into ColumnWidths::updateErrors. Behaviour unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 18:21:03 +02:00
co-authored by Claude Opus 5.5
parent 6cf0a565e6
commit 9da236b0e3
5 changed files with 267 additions and 219 deletions
+8 -117
View File
@@ -24,8 +24,9 @@ use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
* 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()).
* 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
{
@@ -36,9 +37,6 @@ final class Composer
/** 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';
@@ -48,12 +46,6 @@ final class Composer
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';
@@ -342,7 +334,11 @@ final class Composer
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');
$parsed = ColumnWidths::parse($spec['columns'] ?? '4_4');
if ($parsed['error'] !== null) {
$this->errors[$path . '.columns'] = $parsed['error'];
}
$widths = $parsed['widths'];
$label = $context === '' ? null : $context . ' – Zeile';
$attrs = $this->common('divi/row', ['builderVersion' => $this->builderVersion], $spec, $path, $label);
$row = Block::create('divi/row', $attrs);
@@ -604,111 +600,6 @@ final class Composer
}
}
/**
* "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