diff --git a/docs/guides/divi-shortform.md b/docs/guides/divi-shortform.md index 8ca822a..d20d59a 100644 --- a/docs/guides/divi-shortform.md +++ b/docs/guides/divi-shortform.md @@ -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 „ – “, 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). diff --git a/src/Integrations/Divi/Compose/ColumnWidths.php b/src/Integrations/Divi/Compose/ColumnWidths.php index 173afa9..87af38a 100644 --- a/src/Integrations/Divi/Compose/ColumnWidths.php +++ b/src/Integrations/Divi/Compose/ColumnWidths.php @@ -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, 2: string}> $updates Block, its new attrs, input path. * @return array @@ -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 $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 $before + * @param array $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 $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; } diff --git a/tests/Unit/Integrations/Divi/Compose/ColumnWidthsTest.php b/tests/Unit/Integrations/Divi/Compose/ColumnWidthsTest.php index 8cb4e22..ea02505 100644 --- a/tests/Unit/Integrations/Divi/Compose/ColumnWidthsTest.php +++ b/tests/Unit/Integrations/Divi/Compose/ColumnWidthsTest.php @@ -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 { diff --git a/tests/Unit/Integrations/Divi/Pages/DiviUpdateAbilityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviUpdateAbilityTest.php index 3ddb687..b3b24ee 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviUpdateAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviUpdateAbilityTest.php @@ -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 {