fix: builder column classes only – no 4_5, all breakpoints checked (S49)

New rows no longer accept 4_5 (the builder does not offer it). On update,
the flexType of every non-desktop breakpoint (tablet, phone, …) is
format-checked against the builder list like desktop. Guide updated.

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:34:45 +02:00
co-authored by Claude Opus 5.5
parent e04a5304f7
commit eda592548e
4 changed files with 103 additions and 18 deletions
+5 -3
View File
@@ -25,8 +25,8 @@ Neue Divi-Inhalte beschreibt man als JSON-Knoten. `lit-divi-insert` (in `nodes`)
`section_type: "fullwidth"` für Vollbreite-Abschnitte (dann nur `fullwidth-…`-Module in `modules`);
`background_color`. Spezialabschnitte (specialty) nur im Divi-Builder bauen.
- **Zeile** (`row`): `columns` = Breiten als Brüche, die zusammen 1 ergeben (`"4_4"`, `"1_2,1_2"`,
`"1_3,2_3"`, `"1_4,1_4,1_2"`, auch Fünftel); `modules` = je Spalte eine Liste (bei einer Spalte reicht eine
einfache Liste).
`"1_3,2_3"`, `"1_4,1_4,1_2"`, auch `1_5`, `2_5`, `3_5` wie im Divi-Builder – `4_5` nicht); `modules` = je
Spalte eine Liste (bei einer Spalte reicht eine einfache Liste).
- **Module** mit Kind-Modulen (Akkordeon, Tabs, Slider, Kontaktformular, Karte, Social-Media-Follow,
Icon-Liste) bekommen ihre Einträge in `items`; der Eintragstyp ergibt sich von selbst.
- `label` fehlt → Admin-Label „<Abschnitt> – <Modul>“, z. B. „Hero – Button“. Abschnitten deshalb ein Label
@@ -90,7 +90,9 @@ Fehler: `validation_failed` bzw. `invalid_attr_path` mit `fields` (Eingabepfad
Die Breite einer Spalte steht in `module.decoration.sizing` (Desktop: `flexType`, z. B. `8_24` = 1/3). Wer
diesen Desktop-Wert über `attrs` ersetzt, ersetzt das ganze Objekt: `flexType` immer mitschicken, sonst fällt
die Breite weg. In einer Zeile mit festen Breiten alle Spalten zusammen ändern (Summe 1). Die Angabe
die Breite weg. Erlaubt sind je Breakpoint (Desktop, Tablet, Telefon, …) nur die Spaltenklassen des
Divi-Builders: `1_24` … `24_24`, `1_5`, `2_5`, `3_5`, `1_7`, `1_9`, `1_10`, `1_11` oder `none`. In einer Zeile
mit festen Breiten alle Spalten zusammen ändern (Summe 1). Die Angabe
`flexColumnStructure` der Zeile (Spaltenvorlage im Divi-Builder) passt das Werkzeug nicht an; nach einer
Breitenänderung zeigt der Builder dort evtl. noch die alte Vorlage; gespeichert sind die neuen Breiten (bei
Bedarf die Vorlage im Builder neu wählen).
+58 -15
View File
@@ -10,13 +10,16 @@ use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
/**
* Column widths of Divi rows (pure, static): the "columns" input of new rows
* ("1_3,2_3" → flexTypes, ruling S34) and the desktop flexType checks on update (rulings S41,
* S43). Every width this class returns is built from digits or taken from a fixed list.
* ("1_3,2_3" → flexTypes, ruling S34) and the flexType checks on update (rulings S41, S43,
* S49). Every width this class returns is built from digits or taken from a fixed list.
*/
final class ColumnWidths
{
/** 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'];
/**
* Column fractions of "columns" besides multiples of 1/24: the builder's special column
* classes (ruling S49 – no 4_5, the builder does not offer it).
*/
private const SPECIAL_WIDTHS = ['1_5', '2_5', '3_5', '1_7', '1_9', '1_10', '1_11'];
/**
* The builder's "Column Class" choices (Divi 5.2.1 visual-builder/build/field-library.js):
@@ -29,6 +32,8 @@ final class ColumnWidths
/** Divi's column default (column/module-default-render-attributes.json). */
private const DEFAULT_WIDTH = '24_24';
private const CLASS_LIST = '1_24 … 24_24, 1_5, 2_5, 3_5, 1_7, 1_9, 1_10, 1_11 oder none';
private const FORMAT = 'Spaltenaufteilung wie "1_2,1_2" oder "1_3,2_3" erwartet';
private const FULL_ROW = 'die Spaltenbreiten müssen zusammen eine volle Zeile ergeben (z. B. "1_3,2_3")';
@@ -100,12 +105,13 @@ final class ColumnWidths
}
/**
* Column widths on update (rulings S41, S43). Every new desktop flexType must be one of the
* builder's column classes (isColumnClass()) or null (removed: Divi's default 24_24). The
* row sum is only checked for rows that are exact today – every column has a class other
* than "none", the classes fill the row (rowError()), the row does not wrap or use a grid
* on desktop – and when no new width is "none". Such a row must stay exact after all
* changes; other rows are laid out by Divi as the builder allows.
* Column widths on update (rulings S41, S43, S49). Every new flexType – desktop and every
* other breakpoint – must be one of the builder's column classes (isColumnClass()) or null
* (removed; desktop then is Divi's default 24_24). The desktop row sum is only checked for
* rows that are exact today – every column has a class other than "none", the classes fill
* the row (rowError()), the row does not wrap or use a grid on desktop – and when no new
* width is "none". Such a row must stay exact after all changes; other rows are laid out by
* Divi as the builder allows.
*
* @param list<array{0: Block, 1: array<string, mixed>, 2: string}> $updates Block, its new attrs, input path.
* @return array<string, string>
@@ -116,14 +122,27 @@ final class ColumnWidths
$new = [];
$rows = [];
foreach ($updates as [$block, $attrs, $field]) {
if ($block->name() !== 'divi/column') {
continue;
}
$breakpoint = self::invalidBreakpoint($block->attrs(), $attrs);
if ($breakpoint !== null) {
$errors[$field . '.attrs.' . self::SIZING] = sprintf(
'Spaltenbreite (flexType) für %s wie im Divi-Builder erwartet: %s',
$breakpoint,
self::CLASS_LIST
);
}
$width = self::width($attrs);
if ($block->name() !== 'divi/column' || $width === self::width($block->attrs())) {
if ($width === self::width($block->attrs())) {
continue;
}
if ($width !== null && ! self::isColumnClass($width)) {
$errors[$field . '.attrs.' . self::SIZING] = 'Spaltenbreite (flexType) wie im Divi-Builder erwartet: '
. '1_24 … 24_24, 1_5, 2_5, 3_5, 1_7, 1_9, 1_10, 1_11 oder none';
. self::CLASS_LIST;
continue;
}
@@ -172,10 +191,34 @@ final class ColumnWidths
return self::rowError($widths) === null;
}
/** @param array<string, mixed> $attrs */
private static function width(array $attrs): mixed
/**
* The first non-desktop breakpoint whose flexType changed to something that is no column
* class (ruling S49), or null.
*
* @param array<string, mixed> $before
* @param array<string, mixed> $after
*/
private static function invalidBreakpoint(array $before, array $after): ?string
{
$sizing = AttrMap::desktop($attrs, self::SIZING);
foreach (AttrMap::BREAKPOINTS as $breakpoint) {
if ($breakpoint === 'desktop') {
continue;
}
$width = self::width($after, $breakpoint);
if ($width !== null && $width !== self::width($before, $breakpoint) && ! self::isColumnClass($width)) {
return $breakpoint;
}
}
return null;
}
/** @param array<string, mixed> $attrs */
private static function width(array $attrs, string $breakpoint = 'desktop'): mixed
{
$map = AttrMap::get($attrs, self::SIZING);
$sizing = is_array($map) && is_array($map[$breakpoint] ?? null) ? ($map[$breakpoint]['value'] ?? null) : null;
return is_array($sizing) ? ($sizing['flexType'] ?? null) : null;
}
@@ -29,6 +29,14 @@ final class ColumnWidthsTest extends TestCase
self::assertStringContainsString('volle Zeile', (string) ColumnWidths::parse('1_2,1_3')['error']);
}
/** Ruling S49: new rows use only widths the builder offers – 4_5 is no builder column class. */
public function testFourFifthsIsNoInsertWidth(): void
{
self::assertSame(['widths' => ['24_24'], 'error' => 'Spaltenbreite 4_5 gibt es in Divi nicht'], ColumnWidths::parse('1_5,4_5'));
self::assertNotNull(ColumnWidths::rowError(['1_5', '4_5']));
self::assertSame(['widths' => ['1_5', '2_5', '2_5'], 'error' => null], ColumnWidths::parse('1_5,2_5,2_5'));
}
/** Ruling S43: the builder's column classes. */
public function testColumnClassesAreTheBuilderList(): void
{
@@ -157,6 +157,38 @@ final class DiviUpdateAbilityTest extends DiviWriteAbilityTestCase
self::assertSame([], $this->saved);
}
/** Ruling S49: tablet/phone (every non-desktop breakpoint) flexTypes are format-checked like desktop. */
public function testUpdateChecksTheColumnClassFormatOnOtherBreakpoints(): void
{
foreach (['tablet', 'phone', 'phoneWide'] as $breakpoint) {
foreach (['4_5', '13_25', 'auto'] as $width) {
$result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [[
'id' => 's0.r0.c0',
'attrs' => ['module.decoration.sizing' => [
'desktop' => ['flexType' => '8_24'],
$breakpoint => ['flexType' => $width],
]],
]]]);
self::assertInstanceOf(WP_Error::class, $result, $breakpoint . ' ' . $width);
$message = self::clientError($result)['fields']['changes[0].attrs.module.decoration.sizing'];
self::assertStringContainsString($breakpoint, $message);
self::assertStringContainsString('none', $message);
}
}
self::assertSame([], $this->saved);
$valid = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [[
'id' => 's0.r0.c0',
'attrs' => ['module.decoration.sizing' => [
'desktop' => ['flexType' => '8_24'],
'tablet' => ['flexType' => '24_24'],
'phone' => ['flexType' => 'none'],
]],
]]]);
self::assertTrue($valid['ok'], (string) json_encode($valid));
self::assertStringContainsString('"tablet":{"value":{"flexType":"24_24"}},"phone":{"value":{"flexType":"none"}}', $this->saved[0]);
}
/** Ruling S43: rows that are no plain exact row are not sum-checked (none, omitted width, wrap). */
public function testUpdateAcceptsWidthChangesInLooseRows(): void
{