diff --git a/src/Integrations/Divi/BlockTree/Block.php b/src/Integrations/Divi/BlockTree/Block.php index 89a1872..9266bf4 100644 --- a/src/Integrations/Divi/BlockTree/Block.php +++ b/src/Integrations/Divi/BlockTree/Block.php @@ -141,6 +141,30 @@ final class Block extends Container return clone $this; } + /** + * A deep copy of this block under another name and attributes: opener and closer are + * regenerated, children and the text between them are kept, the void flag too. Opaque exactly + * when the Parser would make it so. GlobalInstance uses it to turn a library root into an + * instance and back. + * + * @param array $attrs + */ + public function copyAs(string $name, array $attrs): self + { + if (preg_match(self::NAME_PATTERN, $name) !== 1) { + throw new InvalidArgumentException(sprintf('"%s" is not a valid block name.', $name)); + } + + $copy = clone $this; + $copy->name = $name; + $copy->attrs = self::deepCloneAttrs($attrs); + $copy->rawOpen = null; + $copy->rawClose = null; + $copy->opaque = $name === 'divi/global-layout' || array_key_exists('globalModule', $attrs); + + return $copy; + } + public function __clone() { foreach ($this->parts as $position => $part) { diff --git a/src/Integrations/Divi/BlockTree/Container.php b/src/Integrations/Divi/BlockTree/Container.php index 05447ed..5f79f14 100644 --- a/src/Integrations/Divi/BlockTree/Container.php +++ b/src/Integrations/Divi/BlockTree/Container.php @@ -93,12 +93,41 @@ abstract class Container return $index; } + /** Puts $new where the child $old is; the text around it stays. */ + public function replaceChild(Block $old, Block $new): void + { + $this->guardMutable(); + $this->parts[$this->partPosition($old)] = $new; + $this->touched(); + } + + /** + * "\r\n" → "\n" in whitespace-only text between blocks, at every depth. Library entries saved + * through Divi's "add to library" form carry CRLF (browsers normalise form fields, fixture + * finding 9); the Visual Builder writes "\n". Text inside blocks' own content stays untouched, + * and so do opaque blocks (global instances keep Divi's bytes). + */ + public function unifyLineBreaks(): void + { + if ($this instanceof Block && $this->isOpaque()) { + return; + } + + foreach ($this->parts as $position => $part) { + if ($part instanceof Block) { + $part->unifyLineBreaks(); + } elseif (trim($part) === '' && str_contains($part, "\r\n")) { + $this->parts[$position] = str_replace("\r\n", "\n", $part); + } + } + } + /** Called after the list of children changed. */ protected function touched(): void { } - /** insertChild()/removeChild() are the tools' structural edits – never on an opaque block. */ + /** insertChild()/removeChild()/replaceChild() are the tools' structural edits – never on an opaque block. */ private function guardMutable(): void { if ($this instanceof Block && $this->isOpaque()) { diff --git a/src/Integrations/Divi/BlockTree/GlobalInstance.php b/src/Integrations/Divi/BlockTree/GlobalInstance.php new file mode 100644 index 0000000..2b56f44 --- /dev/null +++ b/src/Integrations/Divi/BlockTree/GlobalInstance.php @@ -0,0 +1,115 @@ +name() !== self::BLOCK) { + return $block->name(); + } + + $name = $block->attrs()['blockName'] ?? ''; + + return is_string($name) ? $name : ''; + } + + /** + * The instance the Visual Builder writes for library entry $libraryId whose content root is + * $root (serialized-post.js, filter divi.serializedPost.parseFlatModuleObjectIntoBlockArray.nested; + * fixture page--globale-instanzen.html): divi/global-layout with globalModule, blockName and + * localAttrs – the root's attributes, top-level keys sorted like the builder's store, left out + * when empty –, the root's children as snapshot with "globalParent": "" appended to every + * block below. A void root gives a void instance. $root is not changed. + */ + public static function of(Block $root, int $libraryId): Block + { + $id = (string) $libraryId; + $local = $root->attrs(); + ksort($local); + $attrs = ['globalModule' => $id, 'blockName' => $root->name()]; + if ($local !== []) { + $attrs['localAttrs'] = $local; + } + + $instance = $root->copyAs(self::BLOCK, $attrs); + foreach ($instance->children() as $child) { + self::markParent($child, $id); + } + + return $instance; + } + + /** + * Divi's PortabilityPost::maybe_deglobalize_nested_global_modules() on a tree (Divi 5.2.1 + * PortabilityPost.php:3518, used by DiviLibraryController::load for global entries): every + * divi/global-layout at or below $block becomes its blockName with localAttrs as attributes, + * and globalParent disappears from every block. Other opaque blocks stay, and so does an instance + * without a usable blockName. Changes $block in place (pass a copy) and returns the result – a + * new block when $block itself was an instance. + */ + public static function deglobalize(Block $block): Block + { + if ($block->name() === self::BLOCK) { + $name = self::effectiveName($block); + if ($name === '') { + return $block; + } + $local = $block->attrs()['localAttrs'] ?? []; + try { + $block = $block->copyAs($name, is_array($local) ? $local : []); + } catch (InvalidArgumentException) { + return $block; // Stored blockName is no block name: the instance stays opaque. + } + } + + if ($block->isOpaque()) { + return $block; + } + + if (array_key_exists('globalParent', $block->attrs())) { + $attrs = $block->attrs(); + unset($attrs['globalParent']); + $block->setAttrs($attrs); + } + + foreach ($block->children() as $child) { + $plain = self::deglobalize($child); + if ($plain !== $child) { + $block->replaceChild($child, $plain); + } + } + + return $block; + } + + private static function markParent(Block $block, string $id): void + { + if ($block->isOpaque()) { + return; + } + + $attrs = $block->attrs(); + unset($attrs['globalParent']); + $attrs['globalParent'] = $id; + $block->setAttrs($attrs); + foreach ($block->children() as $child) { + self::markParent($child, $id); + } + } +} diff --git a/src/Integrations/Divi/BlockTree/LibraryContent.php b/src/Integrations/Divi/BlockTree/LibraryContent.php new file mode 100644 index 0000000..6235a40 --- /dev/null +++ b/src/Integrations/Divi/BlockTree/LibraryContent.php @@ -0,0 +1,107 @@ +parse($content); + $document->unifyLineBreaks(); + + return $document; + } + + /** post_content of a library entry holding $node, framed like the builder's "add to library". */ + public static function wrap(Block $node): string + { + return '' . Serializer::block($node) . ''; + } + + /** @return list Top-level nodes: the children of the first divi/placeholder, else of the document. */ + public static function roots(Document $document): array + { + foreach ($document->children() as $block) { + if ($block->isPlaceholder()) { + return $block->children(); + } + } + + return $document->children(); + } + + /** Whether $block is or contains a global module. */ + public static function containsGlobal(Block $block): bool + { + if ($block->isGlobal()) { + return true; + } + + foreach ($block->children() as $child) { + if (self::containsGlobal($child)) { + return true; + } + } + + return false; + } + + /** + * Why the roots $after may not replace $before, or null. Instances on pages expect their + * entry's root type (blockName), so a section/row/module entry keeps exactly one root of the + * same type; a layout entry (or one with several roots) holds sections only. A global entry + * contains no global modules (F10: Divi forbids nesting). + * + * @param list $before + * @param list $after + */ + public static function shapeViolation(array $before, array $after, ?string $layoutType, bool $global): ?string + { + if ($after === []) { + return 'Der Bibliothekseintrag darf nicht leer werden.'; + } + + if ($layoutType === 'layout' || count($before) !== 1) { + foreach ($after as $root) { + if (GlobalInstance::effectiveName($root) !== 'divi/section') { + return 'Ein Layout-Eintrag der Bibliothek enthält nur Abschnitte.'; + } + } + } else { + $expected = GlobalInstance::effectiveName($before[0]); + if (count($after) !== 1 || GlobalInstance::effectiveName($after[0]) !== $expected) { + return sprintf( + 'Der Bibliothekseintrag muss genau ein %s bleiben (Instanzen auf den Seiten erwarten diesen Typ).', + $expected + ); + } + } + + if ($global) { + foreach ($after as $root) { + if (self::containsGlobal($root)) { + return 'Ein globaler Bibliothekseintrag darf keine globalen Module enthalten (Divi erlaubt keine ' + . 'Verschachtelung).'; + } + } + } + + return null; + } +} diff --git a/src/Integrations/Divi/Compose/TreeEditor.php b/src/Integrations/Divi/Compose/TreeEditor.php index 4da8a96..dc8bf68 100644 --- a/src/Integrations/Divi/Compose/TreeEditor.php +++ b/src/Integrations/Divi/Compose/TreeEditor.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi\Compose; use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\Container; use LIT\MCP\Integrations\Divi\BlockTree\Document; +use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; defined('ABSPATH') || exit; @@ -14,7 +15,8 @@ defined('ABSPATH') || exit; /** * Structural changes on a parsed page (spec §5.7): where to insert (parent / before / after, * optional index), move, remove, duplicate – always checked against StructureRules. Works on - * the tree only; saving is DiviWriter's job. Pure PHP. + * the tree only; saving is DiviWriter's job. Global instances count as their root type + * (blockName, GlobalInstance::effectiveName()). Pure PHP. */ final class TreeEditor { @@ -79,7 +81,7 @@ final class TreeEditor public function insert(array $position, array $blocks): ?string { foreach ($blocks as $block) { - $violation = $this->rules->violation($position['parent'], $block->name()); + $violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($block)); if ($violation !== null) { return $violation; } @@ -104,7 +106,7 @@ final class TreeEditor } } - $violation = $this->rules->violation($position['parent'], $node->name()); + $violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($node)); if ($violation !== null) { return $violation; } diff --git a/tests/Fixtures/divi/stage3/README.md b/tests/Fixtures/divi/stage3/README.md index 7a40732..e81f8f1 100644 --- a/tests/Fixtures/divi/stage3/README.md +++ b/tests/Fixtures/divi/stage3/README.md @@ -79,6 +79,7 @@ Im Body-Layout: Überschrift = `post_title`, Text = `post_meta_key` mit manuelle 6. **TB-Layouts haben keinen `layout_type`-Term**, obwohl `et_theme_builder_insert_layout` ihn setzen will (`theme-builder.php:1663`). In `wp_term_relationships` steht für 1061–1063 nichts; vermutlich ist die Taxonomie im admin-ajax-Aufruf nicht registriert (Registrierung über `ET_Builder_Library`, `builder/feature/Library.php:397`) – die Ursache ist nicht weiter geprüft. Nicht auf Terme bei TB-Layouts verlassen. 7. **TB-Bedingungen** stehen als mehrfaches Meta `_et_use_on` / `_et_exclude_from` mit IDs wie `singular:post_type:page:id:1053`. Die Vorlagenzuordnung ist `_et_template` (mehrfach) am `et_theme_builder`-Post; Vorlagen-Titel der Standardvorlage ist übersetzt („Standard-Website-Template“). 8. TB-Layouts heißen nach dem Anlegen alle „Theme Builder Layout“ mit Slug `theme-builder-layout` (in jedem Post-Typ gleich); erst der Titel im Builder benennt sie um. `_et_pb_post_settings_title` spiegelt den Titel. +9. **Zeilenumbrüche in Bibliothekseinträgen:** Einträge aus dem Dialog „Zur Bibliothek hinzufügen“ haben zwischen den Blöcken `\r\n` (1054–1056, 1058; der Browser normalisiert Formularfelder beim Senden an `divi-library/create-item`), Seiten und TB-Layouts `\n`. Der Builder schreibt globale Instanzen mit `\n`. `LibraryContent::load()` vereinheitlicht deshalb Zeilenumbrüche im Leerraum zwischen Blöcken, bevor Inhalte aus der Bibliothek übernommen werden; damit entstehen Instanzen byte-genau wie in `page--globale-instanzen.html`. ## Wirkung auf die Live-Site diff --git a/tests/Unit/Integrations/Divi/BlockTree/BlockTest.php b/tests/Unit/Integrations/Divi/BlockTree/BlockTest.php index ed2fffc..0c1605e 100644 --- a/tests/Unit/Integrations/Divi/BlockTree/BlockTest.php +++ b/tests/Unit/Integrations/Divi/BlockTree/BlockTest.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree; use InvalidArgumentException; use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\Parser; +use LIT\MCP\Integrations\Divi\BlockTree\Serializer; use LogicException; use PHPUnit\Framework\TestCase; use stdClass; @@ -74,4 +75,31 @@ final class BlockTest extends TestCase self::assertEquals($block->attrs()['localAttrs'], $copy->attrs()['localAttrs']); self::assertNotSame($block->attrs()['nested']['obj'], $copy->attrs()['nested']['obj']); } + + public function testCopyAsRegeneratesTheDelimitersAndKeepsChildrenAndText(): void + { + $document = (new Parser())->parse("\n\n"); + $row = $document->children()[0]; + + $copy = $row->copyAs('divi/global-layout', ['globalModule' => '5', 'blockName' => 'divi/row']); + + self::assertSame( + "\n\n" + . '', + Serializer::block($copy) + ); + self::assertTrue($copy->isOpaque()); + self::assertSame( + "\n\n", + Serializer::block($row) + ); + self::assertFalse($row->copyAs('divi/row', [])->isOpaque()); + } + + public function testCopyAsRefusesInvalidNames(): void + { + $this->expectException(InvalidArgumentException::class); + + Block::create('divi/text')->copyAs('Divi Text', []); + } } diff --git a/tests/Unit/Integrations/Divi/BlockTree/ContainerTest.php b/tests/Unit/Integrations/Divi/BlockTree/ContainerTest.php index 8fd85d9..13aecc8 100644 --- a/tests/Unit/Integrations/Divi/BlockTree/ContainerTest.php +++ b/tests/Unit/Integrations/Divi/BlockTree/ContainerTest.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree; use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\Parser; +use LIT\MCP\Integrations\Divi\BlockTree\Serializer; use LogicException; use PHPUnit\Framework\TestCase; @@ -51,4 +52,52 @@ final class ContainerTest extends TestCase $this->expectException(LogicException::class); $void->append('text'); } + + public function testReplaceChildKeepsTheTextAround(): void + { + $document = (new Parser())->parse("AB"); + $new = Block::create('divi/button'); + + $document->replaceChild($document->children()[0], $new); + + self::assertSame('AB', Serializer::document($document)); + } + + public function testUnifyLineBreaksOnlyTouchesWhitespaceBetweenBlocks(): void + { + $document = (new Parser())->parse( + "\r\n\r\n\r\nx\r\ny" + ); + + $document->unifyLineBreaks(); + + self::assertSame( + "\n\n\nx\r\ny", + Serializer::document($document) + ); + } + + /** Global instances stay opaque: their bytes are Divi's, even the whitespace inside. */ + public function testUnifyLineBreaksLeavesOpaqueBlocksAlone(): void + { + $markup = "\r\n\r\n" + . "\r\n"; + $document = (new Parser())->parse($markup); + + $document->unifyLineBreaks(); + + self::assertSame(substr($markup, 0, -2) . "\n", Serializer::document($document)); + } + + public function testReplaceChildRefusesOnAnOpaqueBlock(): void + { + $document = (new Parser())->parse( + '' + ); + $instance = $document->children()[0]; + + $this->expectException(LogicException::class); + + $instance->replaceChild($instance->children()[0], Block::create('divi/button')); + } } diff --git a/tests/Unit/Integrations/Divi/BlockTree/GlobalInstanceTest.php b/tests/Unit/Integrations/Divi/BlockTree/GlobalInstanceTest.php new file mode 100644 index 0000000..5c4676e --- /dev/null +++ b/tests/Unit/Integrations/Divi/BlockTree/GlobalInstanceTest.php @@ -0,0 +1,106 @@ +parse(DiviFixtures::markup('stage3/page--globale-instanzen.html'))); + } + + /** @return array */ + public static function instances(): array + { + return [ + 'global section' => ['library-global--globaler-abschnitt.html', 1058, ''], + 'global module (void)' => ['library-global--globales-modul.html', 1059, '/-->'], + ]; + } + + /** @dataProvider instances */ + public function testInstanceMatchesTheVisualBuilderByteForByte(string $file, int $id, string $end): void + { + $page = DiviFixtures::markup('stage3/page--globale-instanzen.html'); + $start = (int) strpos($page, ''; + $instance = (new Parser())->parse($markup)->children()[0]; + + $result = GlobalInstance::deglobalize($instance); + + self::assertSame($instance, $result); + self::assertSame($markup, Serializer::block($result)); + } +} diff --git a/tests/Unit/Integrations/Divi/BlockTree/LibraryContentTest.php b/tests/Unit/Integrations/Divi/BlockTree/LibraryContentTest.php new file mode 100644 index 0000000..ccb8996 --- /dev/null +++ b/tests/Unit/Integrations/Divi/BlockTree/LibraryContentTest.php @@ -0,0 +1,103 @@ + */ + public static function savedNodes(): array + { + return [ + 'section' => ['s1', 'library--library-abschnitt.html'], + 'row' => ['s1.r0', 'library--library-zeile.html'], + 'module' => ['s1.r0.c0.m0', 'library--library-modul.html'], + ]; + } + + /** + * The builder's "add to library" wraps the node in divi/placeholder (F4); the fixtures only + * differ by the CRLF of the form upload (finding 9). + * + * @dataProvider savedNodes + */ + public function testWrapMatchesTheBuilderEntry(string $id, string $file): void + { + $index = NodeIndex::build((new Parser())->parse(DiviFixtures::markup('stage3/page--globale-instanzen.html'))); + + self::assertSame( + str_replace("\r\n", "\n", DiviFixtures::markup('stage3/' . $file)), + LibraryContent::wrap($index->find($id)) + ); + } + + public function testLoadUnifiesLineBreaksAndRootsSkipThePlaceholder(): void + { + $document = LibraryContent::load(new Parser(), DiviFixtures::markup('stage3/library--layout-ganze-seite.html')); + + self::assertStringNotContainsString("\r", Serializer::document($document)); + self::assertSame( + ['divi/section', 'divi/section', 'divi/section'], + array_map(static fn (Block $block): string => $block->name(), LibraryContent::roots($document)) + ); + self::assertSame( + ['divi/text'], + array_map( + static fn (Block $block): string => $block->name(), + LibraryContent::roots((new Parser())->parse('')) + ) + ); + } + + public function testContainsGlobal(): void + { + $index = NodeIndex::build((new Parser())->parse(DiviFixtures::markup('stage3/page--globale-instanzen.html'))); + + self::assertTrue(LibraryContent::containsGlobal($index->find('s0'))); + self::assertFalse(LibraryContent::containsGlobal($index->find('s1'))); + } + + public function testShapeViolations(): void + { + $section = Block::create('divi/section'); + $row = Block::create('divi/row'); + $instance = GlobalInstance::of(Block::create('divi/text'), 9); + $withInstance = Block::create('divi/section', [], [Block::create('divi/row', [], [ + Block::create('divi/column', [], [$instance]), + ])]); + + self::assertNull(LibraryContent::shapeViolation([$section], [$section], 'section', false)); + self::assertStringContainsString( + 'nicht leer', + (string) LibraryContent::shapeViolation([$section], [], 'section', false) + ); + self::assertStringContainsString( + 'genau ein divi/section', + (string) LibraryContent::shapeViolation([$section], [$section, $section], 'section', false) + ); + self::assertStringContainsString( + 'genau ein divi/section', + (string) LibraryContent::shapeViolation([$section], [$row], null, false) + ); + self::assertNull(LibraryContent::shapeViolation([$section, $section], [$section], 'layout', false)); + self::assertStringContainsString( + 'nur Abschnitte', + (string) LibraryContent::shapeViolation([$section, $section], [$section, $row], 'layout', false) + ); + self::assertNull(LibraryContent::shapeViolation([$section], [$withInstance], 'section', false)); + self::assertStringContainsString( + 'keine globalen Module', + (string) LibraryContent::shapeViolation([$section], [$withInstance], 'section', true) + ); + } +} diff --git a/tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php b/tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php index c0da09e..ba38032 100644 --- a/tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php +++ b/tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose; use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\Document; +use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\BlockTree\Serializer; @@ -201,4 +202,22 @@ final class TreeEditorTest extends TestCase self::assertStringContainsString('Spezialabschnitte', (string) $editor->insert($editor->position($index, ['parent' => 's0']), [Block::create('divi/row')])); self::assertNull($editor->insert($editor->position($index, ['parent' => 's0.c0']), [$text])); } + + /** A global instance counts as its root type: a section instance at the top, a module instance in a column. */ + public function testGlobalInstancesFollowTheRulesOfTheirRootType(): void + { + $document = $this->document('stage3/page--globale-instanzen.html'); + $index = NodeIndex::build($document); + $editor = new TreeEditor(ComposeFixture::rules()); + + self::assertNull($editor->move($index, $index->find('g0'), $editor->position($index, ['after' => 's1']))); + + $index = NodeIndex::build($document); + $module = GlobalInstance::of(Block::create('divi/text'), 1059); + self::assertNull($editor->insert($editor->position($index, ['parent' => 's1.r0.c0']), [$module])); + self::assertSame( + 'auf oberster Ebene sind nur Abschnitte (section) erlaubt', + $editor->insert($editor->position($index, []), [GlobalInstance::of(Block::create('divi/text'), 1059)]) + ); + } }