diff --git a/docs/guides/divi-attrs.md b/docs/guides/divi-attrs.md index f07f680..7669eba 100644 --- a/docs/guides/divi-attrs.md +++ b/docs/guides/divi-attrs.md @@ -38,8 +38,8 @@ zusammengeführt): z. B. bei `module.decoration.sizing` einer Spalte `flexType` Unbekannte Pfade → Fehler `invalid_attr_path` mit Vorschlägen unter `fields`. Texte werden bereinigt (HTML nur, wo Divi Rich-Text erwartet; Links ohne `javascript:`). `$variable`-Verweise werden geparst, -geprüft und vom Plugin neu erzeugt (siehe `divi-placeholders`). Eigenes HTML, CSS und HTML-Attribute -(`…decoration.attributes`, `…advanced.html`, `…css…`) nur mit dem Recht `unfiltered_html`, sonst „nur im +geprüft und vom Plugin neu erzeugt (siehe `divi-placeholders`). Eigenes HTML, CSS, HTML-Attribute und +Interaktionen (`…decoration.attributes`, `…decoration.interactions`, `…advanced.html`, `…css…`) nur mit dem Recht `unfiltered_html`, sonst „nur im Divi-Builder“. Unberührte Teile der Seite bleiben byte-identisch; Ausnahme: Divi selbst normalisiert Seiten mit eigenen HTML-/Attribut-Feldern beim Speichern – nur wenn die Seite noch nicht in Divis eigener gespeicherter Form vorliegt (z. B. importierter oder direkt in die Datenbank geschriebener Inhalt); über den Divi-Builder oder LIT MCP gespeicherte Seiten betrifft das praktisch nie (Warnung in der Antwort, neue `tree_rev` gilt). diff --git a/docs/guides/divi-shortform.md b/docs/guides/divi-shortform.md index 1c9fb1b..694e5e2 100644 --- a/docs/guides/divi-shortform.md +++ b/docs/guides/divi-shortform.md @@ -106,8 +106,8 @@ Bedarf die Vorlage im Builder neu wählen). Alle Werte werden vor dem Speichern bereinigt: Texte ohne HTML, Rich-Text ohne `unfiltered_html` nur mit dem HTML, das WordPress in Beiträgen erlaubt, Links ohne `javascript:`. -`$variable`-Verweise werden geparst, geprüft und vom Plugin neu erzeugt. Eigenes HTML, CSS und HTML-Attribute eines Moduls (`…decoration.attributes`, `…advanced.html`, -`…css…`) ändert nur, wer das Recht `unfiltered_html` hat – sonst „nur im Divi-Builder“. Unberührte Teile der +`$variable`-Verweise werden geparst, geprüft und vom Plugin neu erzeugt. Eigenes HTML, CSS, HTML-Attribute und Interaktionen eines Moduls (`…decoration.attributes`, +`…decoration.interactions`, `…advanced.html`, `…css…`) ändert nur, wer das Recht `unfiltered_html` hat – sonst „nur im Divi-Builder“. Unberührte Teile der Seite bleiben byte-identisch; Ausnahme: Divi selbst normalisiert Seiten mit eigenen HTML-/Attribut-Feldern beim Speichern – nur wenn die Seite noch nicht in Divis eigener gespeicherter Form vorliegt (z. B. importierter oder direkt in die Datenbank geschriebener Inhalt); über den Divi-Builder oder LIT MCP gespeicherte Seiten betrifft das praktisch nie (dann Warnung, dass der Inhalt beim Speichern verändert wurde – mit der neuen `tree_rev` weiterarbeiten). diff --git a/src/Integrations/Divi/Compose/ValueCleaner.php b/src/Integrations/Divi/Compose/ValueCleaner.php index 142ef8a..0d74b72 100644 --- a/src/Integrations/Divi/Compose/ValueCleaner.php +++ b/src/Integrations/Divi/Compose/ValueCleaner.php @@ -54,9 +54,12 @@ final class ValueCleaner /** * Raw HTML/CSS fields, and the ones Divi re-serializes whole pages for (rulings S7/S26), by * path shape at any depth: module.advanced.html(.htmlBefore), module.decoration.attributes, - * any free-form custom CSS path of any module (css, css.*, title.css.*, css.advanced.customCss). + * any free-form custom CSS path of any module (css, css.*, title.css.*, css.advanced.customCss), + * and interactions (ruling P16: *.decoration.interactions – Divi's frontend script applies the + * "Attribute" effect with setAttribute, e.g. an onclick handler, and sets cookies/presets). */ - private const BUILDER_ONLY = '/(^|\.)(decoration\.attributes|advanced\.html|advanced\.customCss|css)(\.|$)/'; + private const BUILDER_ONLY + = '/(^|\.)(decoration\.attributes|decoration\.interactions|advanced\.html|advanced\.customCss|css)(\.|$)/'; public function __construct( private ModuleCatalog $catalog, @@ -134,7 +137,7 @@ final class ValueCleaner { if (preg_match(self::BUILDER_ONLY, $path) === 1 && ! $this->sanitizer->allowsUnfilteredHtml()) { throw new InvalidArgumentException(sprintf( - '"%s" (eigenes HTML, CSS oder HTML-Attribute) ist nur im Divi-Builder änderbar ' + '"%s" (eigenes HTML, CSS, HTML-Attribute oder Interaktionen) ist nur im Divi-Builder änderbar ' . '(oder mit dem Recht unfiltered_html).', $path )); diff --git a/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php index 579236f..6553d2b 100644 --- a/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php +++ b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php @@ -376,4 +376,33 @@ final class AttrWriterTest extends TestCase $heading = ComposeFixture::attrWriter()->apply('divi/heading', [], ['module.meta.adminLabel' => ['desktop' => 'x']], 'attrs'); self::assertSame([], $heading['errors'], 'only section type and specialty columns are structure'); } + + /** Ruling P16: interactions (setAttribute on the frontend) only with unfiltered_html – set and removal. */ + public function testInteractionsNeedUnfilteredHtml(): void + { + $interaction = ['interactions' => [[ + 'trigger' => 'click', + 'effect' => 'addAttribute', + 'attributeName' => 'data-probe', + 'attributeValue' => '1', + ]]]; + $changes = ['module.decoration.interactions' => ['desktop' => $interaction]]; + + $editor = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, $changes, 'attrs'); + self::assertSame(self::TEXT, $editor['attrs']); + self::assertStringContainsString('nur im Divi-Builder', $editor['errors']['attrs.module.decoration.interactions']); + + $removal = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, ['module.decoration.interactions' => null], 'attrs'); + self::assertStringContainsString('nur im Divi-Builder', $removal['errors']['attrs.module.decoration.interactions']); + + $admin = ComposeFixture::attrWriter(true)->apply('divi/text', self::TEXT, $changes, 'attrs'); + self::assertSame([], $admin['errors']); + self::assertSame( + 'data-probe', + $admin['attrs']['module']['decoration']['interactions']['desktop']['value']['interactions'][0]['attributeName'] + ); + $adminRemoval = ComposeFixture::attrWriter(true)->apply('divi/text', $admin['attrs'], ['module.decoration.interactions' => null], 'attrs'); + self::assertSame([], $adminRemoval['errors']); + self::assertArrayNotHasKey('interactions', $adminRemoval['attrs']['module']['decoration'] ?? []); + } } diff --git a/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php b/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php index 158a868..f9e074b 100644 --- a/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php +++ b/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php @@ -10,6 +10,7 @@ use LIT\MCP\Integrations\Divi\Compose\ShortformWriter; use LIT\MCP\Integrations\Divi\Compose\ValueCleaner; use LIT\MCP\Integrations\Divi\Compose\VariableRef; use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; +use LIT\MCP\Integrations\Divi\Schema\ShortformMap; use LIT\MCP\Tests\Support\ComposeFixture; use LIT\MCP\Tests\Support\DiviFixtures; use PHPUnit\Framework\TestCase; @@ -426,4 +427,26 @@ final class ShortformWriterTest extends TestCase return new ShortformWriter(new Placeholders($design, ComposeFixture::links()), ComposeFixture::media(), $cleaner); } + + /** Ruling P16: no curated short field reaches interactions, and a field that did would be gated. */ + public function testInteractionsAreBuilderOnlyInTheShortForm(): void + { + foreach (ShortformMap::types() as $type) { + foreach (ShortformMap::fields($type) as $name => $field) { + self::assertStringNotContainsString('interactions', $field['path'], $type . '.' . $name); + } + } + + $modules = ['raw' => [ + 'effects' => ['path' => 'module.decoration.interactions', 'sub' => 'interactions', 'kind' => 'text', 'description' => 'x'], + ]]; + foreach (['data-probe', null] as $value) { + $result = ComposeFixture::shortform(false, $modules)->apply('raw', self::BASE, ['effects' => $value], 'n'); + self::assertStringContainsString('nur im Divi-Builder', $result['errors']['n.effects']); + self::assertSame(self::BASE, $result['attrs']); + } + + $admin = ComposeFixture::shortform(true, $modules)->apply('raw', [], ['effects' => 'x'], 'n'); + self::assertSame([], $admin['errors']); + } } diff --git a/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php b/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php index 88b12d6..b65a716 100644 --- a/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php +++ b/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php @@ -148,6 +148,17 @@ final class StoredNodeGuardTest extends TestCase ])]]), 'module.advanced.html', ], + 'interactions (P16)' => [ + self::node('divi/text', ['module' => ['decoration' => ['interactions' => self::desktop([ + 'interactions' => [[ + 'trigger' => 'click', + 'effect' => 'addAttribute', + 'attributeName' => 'data-probe', + 'attributeValue' => '1', + ]], + ])]]]), + 'module.decoration.interactions', + ], // Code module (E6). 'code module' => [ self::node('divi/code', ['content' => ['innerContent' => self::desktop(self::PROBE_SCRIPT)]]), diff --git a/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php b/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php index 706e1fd..ab21058 100644 --- a/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php +++ b/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php @@ -198,4 +198,38 @@ final class ValueCleanerTest extends TestCase self::assertNull(($cleaner->textReplacementFor('divi/button'))('button.innerContent', 'linkUrl', 'https://x.test')); self::assertNull(($cleaner->textReplacementFor('divi/code'))('content.innerContent', null, 'x')); } + + /** + * Ruling P16: Divi's interactions ("Attribute" effect → setAttribute on the frontend, Cookie, + * Preset) are builder-only like HTML attributes, at every depth. + */ + public function testInteractionPathsNeedUnfilteredHtml(): void + { + $value = ['interactions' => [[ + 'trigger' => 'click', + 'effect' => 'addAttribute', + 'attributeName' => 'data-probe', + 'attributeValue' => '1', + ]]]; + $paths = [ + 'module.decoration.interactions', + 'module.decoration.interactions.desktop', + 'title.decoration.interactions', + 'decoration.interactions', + ]; + foreach ($paths as $path) { + try { + ComposeFixture::cleaner()->clean('divi/text', $path, $value); + self::fail('Accepted for an editor: ' . $path); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString('nur im Divi-Builder', $e->getMessage()); + } + + self::assertSame($value, ComposeFixture::cleaner(true)->clean('divi/text', $path, $value)); + } + + // Only a whole "interactions" segment below "decoration" counts. + self::assertSame('x', ComposeFixture::cleaner()->clean('divi/text', 'module.decoration.interactionsish', 'x')); + self::assertSame('x', ComposeFixture::cleaner()->clean('divi/text', 'module.advanced.interactions', 'x')); + } }