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`); `section_type: "fullwidth"` für Vollbreite-Abschnitte (dann nur `fullwidth-…`-Module in `modules`);
`background_color`. Spezialabschnitte (specialty) nur im Divi-Builder bauen. `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"`, - **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 `"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
einfache Liste). Spalte eine Liste (bei einer Spalte reicht eine einfache Liste).
- **Module** mit Kind-Modulen (Akkordeon, Tabs, Slider, Kontaktformular, Karte, Social-Media-Follow, - **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. 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 - `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 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 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 `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 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). 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 * 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, * ("1_3,2_3" → flexTypes, ruling S34) and the flexType checks on update (rulings S41, S43,
* S43). Every width this class returns is built from digits or taken from a fixed list. * S49). Every width this class returns is built from digits or taken from a fixed list.
*/ */
final class ColumnWidths 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): * 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). */ /** Divi's column default (column/module-default-render-attributes.json). */
private const DEFAULT_WIDTH = '24_24'; 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 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")'; 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 * Column widths on update (rulings S41, S43, S49). Every new flexType – desktop and every
* builder's column classes (isColumnClass()) or null (removed: Divi's default 24_24). The * other breakpoint – must be one of the builder's column classes (isColumnClass()) or null
* row sum is only checked for rows that are exact today – every column has a class other * (removed; desktop then is Divi's default 24_24). The desktop row sum is only checked for
* than "none", the classes fill the row (rowError()), the row does not wrap or use a grid * rows that are exact today – every column has a class other than "none", the classes fill
* on desktop – and when no new width is "none". Such a row must stay exact after all * the row (rowError()), the row does not wrap or use a grid on desktop – and when no new
* changes; other rows are laid out by Divi as the builder allows. * 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. * @param list<array{0: Block, 1: array<string, mixed>, 2: string}> $updates Block, its new attrs, input path.
* @return array<string, string> * @return array<string, string>
@@ -116,14 +122,27 @@ final class ColumnWidths
$new = []; $new = [];
$rows = []; $rows = [];
foreach ($updates as [$block, $attrs, $field]) { 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); $width = self::width($attrs);
if ($block->name() !== 'divi/column' || $width === self::width($block->attrs())) { if ($width === self::width($block->attrs())) {
continue; continue;
} }
if ($width !== null && ! self::isColumnClass($width)) { if ($width !== null && ! self::isColumnClass($width)) {
$errors[$field . '.attrs.' . self::SIZING] = 'Spaltenbreite (flexType) wie im Divi-Builder erwartet: ' $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; continue;
} }
@@ -172,10 +191,34 @@ final class ColumnWidths
return self::rowError($widths) === null; 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; 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']); 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. */ /** Ruling S43: the builder's column classes. */
public function testColumnClassesAreTheBuilderList(): void public function testColumnClassesAreTheBuilderList(): void
{ {
@@ -157,6 +157,38 @@ final class DiviUpdateAbilityTest extends DiviWriteAbilityTestCase
self::assertSame([], $this->saved); 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). */ /** Ruling S43: rows that are no plain exact row are not sum-checked (none, omitted width, wrap). */
public function testUpdateAcceptsWidthChangesInLooseRows(): void public function testUpdateAcceptsWidthChangesInLooseRows(): void
{ {