feat: global instance in the builder's format, deglobalize, library content rules

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 00:53:38 +02:00
co-authored by Claude Opus 5.5
parent ce1ad7c6c3
commit 2700fd615a
11 changed files with 587 additions and 4 deletions
+24
View File
@@ -141,6 +141,30 @@ final class Block extends Container
return clone $this; 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<string, mixed> $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() public function __clone()
{ {
foreach ($this->parts as $position => $part) { foreach ($this->parts as $position => $part) {
+30 -1
View File
@@ -93,12 +93,41 @@ abstract class Container
return $index; 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. */ /** Called after the list of children changed. */
protected function touched(): void 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 private function guardMutable(): void
{ {
if ($this instanceof Block && $this->isOpaque()) { if ($this instanceof Block && $this->isOpaque()) {
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\BlockTree;
use InvalidArgumentException;
defined('ABSPATH') || exit;
/**
* Global modules the way Divi 5 stores them (F6, F10, fixtures stage3): an instance on a page is a
* divi/global-layout block whose content Divi takes from the library entry at render time (F7).
* Instances stay opaque for the page tools; edits go to the library entry. Pure PHP.
*/
final class GlobalInstance
{
public const BLOCK = 'divi/global-layout';
/** Block name the node counts as for structure rules: an instance counts as its root type. */
public static function effectiveName(Block $block): string
{
if ($block->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": "<id>" 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);
}
}
}
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\BlockTree;
defined('ABSPATH') || exit;
/**
* post_content of Divi Library entries (F4, fixtures stage3): the nodes sit in a divi/placeholder
* frame; a section/row/module entry has exactly one root, a layout entry sections. Pure PHP.
*/
final class LibraryContent
{
public const WRAPPER = 'divi/placeholder';
/**
* Parses library content for copying and unifies its line breaks (Container::unifyLineBreaks()).
* Never used for the content a tool edits in place – that keeps its bytes.
*
* @throws MalformedMarkup
*/
public static function load(Parser $parser, string $content): Document
{
$document = $parser->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 '<!-- wp:' . self::WRAPPER . ' -->' . Serializer::block($node) . '<!-- /wp:' . self::WRAPPER . ' -->';
}
/** @return list<Block> 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<Block> $before
* @param list<Block> $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;
}
}
+5 -3
View File
@@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi\Compose;
use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\Container; use LIT\MCP\Integrations\Divi\BlockTree\Container;
use LIT\MCP\Integrations\Divi\BlockTree\Document; 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\NodeIndex;
defined('ABSPATH') || exit; 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, * 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 * 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 final class TreeEditor
{ {
@@ -79,7 +81,7 @@ final class TreeEditor
public function insert(array $position, array $blocks): ?string public function insert(array $position, array $blocks): ?string
{ {
foreach ($blocks as $block) { foreach ($blocks as $block) {
$violation = $this->rules->violation($position['parent'], $block->name()); $violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($block));
if ($violation !== null) { if ($violation !== null) {
return $violation; 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) { if ($violation !== null) {
return $violation; return $violation;
} }
+1
View File
@@ -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. 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“). 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. 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 ## Wirkung auf die Live-Site
@@ -7,6 +7,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree;
use InvalidArgumentException; use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LogicException; use LogicException;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use stdClass; use stdClass;
@@ -74,4 +75,31 @@ final class BlockTest extends TestCase
self::assertEquals($block->attrs()['localAttrs'], $copy->attrs()['localAttrs']); self::assertEquals($block->attrs()['localAttrs'], $copy->attrs()['localAttrs']);
self::assertNotSame($block->attrs()['nested']['obj'], $copy->attrs()['nested']['obj']); self::assertNotSame($block->attrs()['nested']['obj'], $copy->attrs()['nested']['obj']);
} }
public function testCopyAsRegeneratesTheDelimitersAndKeepsChildrenAndText(): void
{
$document = (new Parser())->parse("<!-- wp:divi/row {\"a\":1} -->\n<!-- wp:divi/column /-->\n<!-- /wp:divi/row -->");
$row = $document->children()[0];
$copy = $row->copyAs('divi/global-layout', ['globalModule' => '5', 'blockName' => 'divi/row']);
self::assertSame(
"<!-- wp:divi/global-layout {\"globalModule\":\"5\",\"blockName\":\"divi/row\"} -->\n<!-- wp:divi/column /-->\n"
. '<!-- /wp:divi/global-layout -->',
Serializer::block($copy)
);
self::assertTrue($copy->isOpaque());
self::assertSame(
"<!-- wp:divi/row {\"a\":1} -->\n<!-- wp:divi/column /-->\n<!-- /wp:divi/row -->",
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', []);
}
} }
@@ -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\Block;
use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LogicException; use LogicException;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -51,4 +52,52 @@ final class ContainerTest extends TestCase
$this->expectException(LogicException::class); $this->expectException(LogicException::class);
$void->append('text'); $void->append('text');
} }
public function testReplaceChildKeepsTheTextAround(): void
{
$document = (new Parser())->parse("A<!-- wp:divi/text /-->B");
$new = Block::create('divi/button');
$document->replaceChild($document->children()[0], $new);
self::assertSame('A<!-- wp:divi/button --><!-- /wp:divi/button -->B', Serializer::document($document));
}
public function testUnifyLineBreaksOnlyTouchesWhitespaceBetweenBlocks(): void
{
$document = (new Parser())->parse(
"<!-- wp:divi/section -->\r\n<!-- wp:divi/row -->\r\n<!-- /wp:divi/row -->\r\n<!-- /wp:divi/section -->x\r\ny"
);
$document->unifyLineBreaks();
self::assertSame(
"<!-- wp:divi/section -->\n<!-- wp:divi/row -->\n<!-- /wp:divi/row -->\n<!-- /wp:divi/section -->x\r\ny",
Serializer::document($document)
);
}
/** Global instances stay opaque: their bytes are Divi's, even the whitespace inside. */
public function testUnifyLineBreaksLeavesOpaqueBlocksAlone(): void
{
$markup = "<!-- wp:divi/global-layout {\"globalModule\":\"5\"} -->\r\n<!-- wp:divi/text /-->\r\n"
. "<!-- /wp:divi/global-layout -->\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(
'<!-- wp:divi/global-layout {"globalModule":"5"} --><!-- wp:divi/text /--><!-- /wp:divi/global-layout -->'
);
$instance = $document->children()[0];
$this->expectException(LogicException::class);
$instance->replaceChild($instance->children()[0], Block::create('divi/button'));
}
} }
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Tests\Support\DiviFixtures;
use PHPUnit\Framework\TestCase;
final class GlobalInstanceTest extends TestCase
{
private static function libraryRoot(string $file): Block
{
return LibraryContent::roots(LibraryContent::load(new Parser(), DiviFixtures::markup('stage3/' . $file)))[0];
}
private static function pageIndex(): NodeIndex
{
return NodeIndex::build((new Parser())->parse(DiviFixtures::markup('stage3/page--globale-instanzen.html')));
}
/** @return array<string, array{string, int, string}> */
public static function instances(): array
{
return [
'global section' => ['library-global--globaler-abschnitt.html', 1058, '<!-- /wp:divi/global-layout -->'],
'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, '<!-- wp:divi/global-layout {"globalModule":"' . $id . '"');
$expected = substr($page, $start, (int) strpos($page, $end, $start) + strlen($end) - $start);
$instance = GlobalInstance::of(self::libraryRoot($file), $id);
self::assertSame($expected, Serializer::block($instance));
self::assertTrue($instance->isOpaque());
self::assertTrue($instance->isGlobal());
}
public function testOfLeavesTheLibraryRootUnchanged(): void
{
$root = self::libraryRoot('library-global--globaler-abschnitt.html');
$before = Serializer::block($root);
GlobalInstance::of($root, 1058);
self::assertSame($before, Serializer::block($root));
}
public function testEffectiveNameOfAnInstanceIsItsRootType(): void
{
$index = self::pageIndex();
self::assertSame('divi/section', GlobalInstance::effectiveName($index->find('g0')));
self::assertSame('divi/text', GlobalInstance::effectiveName($index->find('s0.r0.c0.g0')));
self::assertSame('divi/section', GlobalInstance::effectiveName($index->find('s0')));
}
public function testDeglobalizeRestoresTheLibraryRoot(): void
{
$root = self::libraryRoot('library-global--globaler-abschnitt.html');
$plain = GlobalInstance::deglobalize(self::pageIndex()->find('g0')->copy());
self::assertSame('divi/section', $plain->name());
self::assertFalse($plain->isOpaque());
self::assertEquals($root->attrs(), $plain->attrs());
self::assertSame(
array_map([Serializer::class, 'block'], $root->children()),
array_map([Serializer::class, 'block'], $plain->children())
);
self::assertStringNotContainsString('globalParent', Serializer::block($plain));
}
public function testDeglobalizeReachesNestedInstances(): void
{
$section = GlobalInstance::deglobalize(self::pageIndex()->find('s0')->copy());
$markup = Serializer::block($section);
self::assertStringNotContainsString('global-layout', $markup);
self::assertStringContainsString('<!-- wp:divi/text {"builderVersion":"5.2.1","content":', $markup);
}
/** Stored content is not trusted: an instance with an unusable blockName stays an opaque instance. */
public function testDeglobalizeKeepsAnInstanceWithAnInvalidBlockName(): void
{
$markup = '<!-- wp:divi/global-layout {"globalModule":"5","blockName":"Divi Text"} /-->';
$instance = (new Parser())->parse($markup)->children()[0];
$result = GlobalInstance::deglobalize($instance);
self::assertSame($instance, $result);
self::assertSame($markup, Serializer::block($result));
}
}
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Tests\Support\DiviFixtures;
use PHPUnit\Framework\TestCase;
final class LibraryContentTest extends TestCase
{
/** @return array<string, array{string, string}> */
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('<!-- wp:divi/text /-->'))
)
);
}
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)
);
}
}
@@ -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\Block;
use LIT\MCP\Integrations\Divi\BlockTree\Document; 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\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer; 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::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])); 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)])
);
}
} }