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:
@@ -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
|
||||
|
||||
|
||||
@@ -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("<!-- 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\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("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\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)])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user