feat: Divi BlockTree – byte-exact parser/serializer with round-trip fixtures
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
<?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\MalformedMarkup;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use stdClass;
|
||||
|
||||
final class ParserTest extends TestCase
|
||||
{
|
||||
/** @return array<string, array{string}> Every fixture page, also with Windows line endings. */
|
||||
public static function fixtures(): array
|
||||
{
|
||||
$cases = [];
|
||||
foreach (glob(dirname(__DIR__, 4) . '/Fixtures/divi/*.html') ?: [] as $file) {
|
||||
$content = (string) file_get_contents($file);
|
||||
$cases[basename($file)] = [$content];
|
||||
$cases[basename($file) . ' (CRLF)'] = [str_replace("\n", "\r\n", $content)];
|
||||
}
|
||||
|
||||
return $cases;
|
||||
}
|
||||
|
||||
/** @dataProvider fixtures */
|
||||
public function testParseThenSerializeIsByteIdentical(string $content): void
|
||||
{
|
||||
self::assertSame($content, Serializer::document((new Parser())->parse($content)));
|
||||
}
|
||||
|
||||
public function testFixturesArePresent(): void
|
||||
{
|
||||
$names = array_map('basename', glob(dirname(__DIR__, 4) . '/Fixtures/divi/*.html') ?: []);
|
||||
|
||||
self::assertContains('synthetic-basic.html', $names);
|
||||
self::assertContains('synthetic-mixed.html', $names);
|
||||
self::assertContains('converted-landing.html', $names);
|
||||
self::assertContains('converted-features.html', $names);
|
||||
}
|
||||
|
||||
public function testBuildsTheTree(): void
|
||||
{
|
||||
$document = (new Parser())->parse($this->fixture('synthetic-basic.html'));
|
||||
|
||||
$placeholder = $document->children()[0];
|
||||
self::assertSame('divi/placeholder', $placeholder->name());
|
||||
$sections = $placeholder->children();
|
||||
self::assertCount(2, $sections);
|
||||
self::assertSame('divi/section', $sections[0]->name());
|
||||
self::assertFalse($sections[0]->isOpaque());
|
||||
self::assertSame(
|
||||
'$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$',
|
||||
$sections[0]->attrs()['module']['decoration']['background']['desktop']['value']['color']
|
||||
);
|
||||
$columns = $sections[0]->children()[0]->children();
|
||||
self::assertSame(['divi/column', 'divi/column'], array_map(static fn (Block $b): string => $b->name(), $columns));
|
||||
self::assertTrue($columns[0]->children()[0]->isVoid());
|
||||
self::assertSame('image', $columns[0]->children()[0]->shortName());
|
||||
self::assertSame(
|
||||
'Willkommen bei Müller & Söhne',
|
||||
$columns[1]->children()[0]->attrs()['title']['innerContent']['desktop']['value']
|
||||
);
|
||||
}
|
||||
|
||||
public function testKeepsTextBetweenBlocksAndNamesCoreBlocks(): void
|
||||
{
|
||||
$document = (new Parser())->parse("<!-- wp:paragraph -->\n<p>x</p>\n<!-- /wp:paragraph -->\n\ntext");
|
||||
|
||||
self::assertSame('core/paragraph', $document->children()[0]->name());
|
||||
self::assertSame(["\n<p>x</p>\n"], $document->children()[0]->parts());
|
||||
self::assertSame("\n\ntext", $document->parts()[1]);
|
||||
}
|
||||
|
||||
public function testMarksGlobalAndUndecodableBlocksOpaque(): void
|
||||
{
|
||||
$document = (new Parser())->parse($this->fixture('synthetic-mixed.html'));
|
||||
$placeholder = $document->children()[1];
|
||||
$second = $placeholder->children()[1];
|
||||
[$globalLayout, $globalRow, $globalWithChild] = $second->children();
|
||||
|
||||
self::assertTrue($globalLayout->isOpaque());
|
||||
self::assertTrue($globalLayout->isGlobal());
|
||||
self::assertTrue($globalRow->isOpaque());
|
||||
self::assertTrue($globalRow->isGlobal());
|
||||
self::assertTrue($globalWithChild->isOpaque());
|
||||
self::assertFalse($second->isOpaque());
|
||||
|
||||
$broken = (new Parser())->parse('<!-- wp:divi/text {"a":} /-->');
|
||||
self::assertTrue($broken->children()[0]->isOpaque());
|
||||
self::assertSame([], $broken->children()[0]->attrs());
|
||||
}
|
||||
|
||||
public function testKeepsEmptyAndListLikeObjectsAsObjects(): void
|
||||
{
|
||||
$document = (new Parser())->parse($this->fixture('synthetic-mixed.html'));
|
||||
$attrs = $document->children()[1]->children()[1]->attrs();
|
||||
|
||||
self::assertInstanceOf(stdClass::class, $attrs['module']['decoration']['spacing']['desktop']['value']);
|
||||
self::assertSame([], $attrs['module']['decoration']['background']['desktop']['value']);
|
||||
self::assertInstanceOf(stdClass::class, $attrs['localAttrs']);
|
||||
}
|
||||
|
||||
public function testDivi4ShortcodesAreOneTextPart(): void
|
||||
{
|
||||
$content = $this->fixture('divi4-shortcodes.html');
|
||||
$document = (new Parser())->parse($content);
|
||||
|
||||
self::assertSame([], $document->children());
|
||||
self::assertSame([$content], $document->parts());
|
||||
}
|
||||
|
||||
/** @return array<string, array{string}> */
|
||||
public static function malformed(): array
|
||||
{
|
||||
return [
|
||||
'closer without opener' => ['<!-- /wp:divi/section -->'],
|
||||
'unclosed block' => ['<!-- wp:divi/section --><!-- wp:divi/row --><!-- /wp:divi/row -->'],
|
||||
'crossed blocks' => ['<!-- wp:divi/row --><!-- wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/column -->'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @dataProvider malformed */
|
||||
public function testRefusesUnbalancedMarkup(string $content): void
|
||||
{
|
||||
$this->expectException(MalformedMarkup::class);
|
||||
|
||||
(new Parser())->parse($content);
|
||||
}
|
||||
|
||||
private function fixture(string $name): string
|
||||
{
|
||||
return (string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/' . $name);
|
||||
}
|
||||
}
|
||||
@@ -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\Document;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use stdClass;
|
||||
|
||||
final class SerializerTest extends TestCase
|
||||
{
|
||||
public function testEscapesAttributesLikeWordPress(): void
|
||||
{
|
||||
$variable = '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$';
|
||||
|
||||
self::assertSame(
|
||||
'{"c":"$variable({\u0022type\u0022:\u0022color\u0022,\u0022value\u0022:{\u0022name\u0022:'
|
||||
. '\u0022gcid-primary-color\u0022,\u0022settings\u0022:{}}})$"}',
|
||||
Serializer::attributes(['c' => $variable])
|
||||
);
|
||||
self::assertSame(
|
||||
'{"t":"\u003cp\u003eA \u0026 B\u003c/p\u003e \u002d\u002d \u005c/ ä","e":{},"l":[]}',
|
||||
Serializer::attributes(['t' => '<p>A & B</p> -- \\/ ä', 'e' => new stdClass(), 'l' => []])
|
||||
);
|
||||
}
|
||||
|
||||
public function testRegeneratesOnlyTheChangedOpener(): void
|
||||
{
|
||||
$content = '<!-- wp:divi/section {"b":1} --><!-- wp:divi/text {"x":"y"} /--><!-- /wp:divi/section -->';
|
||||
$document = (new Parser())->parse($content);
|
||||
$text = $document->children()[0]->children()[0];
|
||||
|
||||
$text->setAttrs(['x' => 'z']);
|
||||
|
||||
self::assertSame(
|
||||
'<!-- wp:divi/section {"b":1} --><!-- wp:divi/text {"x":"z"} /--><!-- /wp:divi/section -->',
|
||||
Serializer::document($document)
|
||||
);
|
||||
}
|
||||
|
||||
public function testNewBlocksAreWrittenAsPairs(): void
|
||||
{
|
||||
$block = Block::create('divi/row', ['builderVersion' => '5.2.1'], [Block::create('divi/column')]);
|
||||
|
||||
self::assertSame(
|
||||
'<!-- wp:divi/row {"builderVersion":"5.2.1"} --><!-- wp:divi/column --><!-- /wp:divi/column -->'
|
||||
. '<!-- /wp:divi/row -->',
|
||||
Serializer::block($block)
|
||||
);
|
||||
self::assertSame('<!-- wp:paragraph --><!-- /wp:paragraph -->', Serializer::block(Block::create('core/paragraph')));
|
||||
}
|
||||
|
||||
public function testVoidBlockGettingAChildBecomesAPair(): void
|
||||
{
|
||||
$document = (new Parser())->parse('<!-- wp:divi/column {"a":1} /-->');
|
||||
$column = $document->children()[0];
|
||||
|
||||
$column->insertChild(0, Block::create('divi/text'));
|
||||
|
||||
self::assertSame(
|
||||
'<!-- wp:divi/column {"a":1} --><!-- wp:divi/text --><!-- /wp:divi/text --><!-- /wp:divi/column -->',
|
||||
Serializer::document($document)
|
||||
);
|
||||
}
|
||||
|
||||
public function testInsertAndRemoveKeepSurroundingText(): void
|
||||
{
|
||||
$document = (new Parser())->parse("A<!-- wp:divi/text /-->\nB<!-- wp:divi/code /-->C");
|
||||
[$text, $code] = $document->children();
|
||||
$new = Block::create('divi/button');
|
||||
|
||||
$document->insertChild(1, $new);
|
||||
self::assertSame(
|
||||
"A<!-- wp:divi/text /-->\nB<!-- wp:divi/button --><!-- /wp:divi/button --><!-- wp:divi/code /-->C",
|
||||
Serializer::document($document)
|
||||
);
|
||||
self::assertSame(2, $document->removeChild($code));
|
||||
self::assertSame(0, $document->indexOf($text));
|
||||
$document->insertChild(99, $code);
|
||||
self::assertSame(2, $document->indexOf($code));
|
||||
|
||||
$empty = new Document();
|
||||
$empty->append("\n");
|
||||
$empty->insertChild(0, Block::create('divi/section'));
|
||||
self::assertSame("\n<!-- wp:divi/section --><!-- /wp:divi/section -->", Serializer::document($empty));
|
||||
}
|
||||
|
||||
public function testCopyIsDeepAndSerializesIdentically(): void
|
||||
{
|
||||
$content = '<!-- wp:divi/row {"a":1} --><!-- wp:divi/column /--><!-- /wp:divi/row -->';
|
||||
$row = (new Parser())->parse($content)->children()[0];
|
||||
$copy = $row->copy();
|
||||
|
||||
$copy->children()[0]->setAttrs(['b' => 2]);
|
||||
|
||||
self::assertSame($content, Serializer::block($row));
|
||||
self::assertSame('<!-- wp:divi/row {"a":1} --><!-- wp:divi/column {"b":2} /--><!-- /wp:divi/row -->', Serializer::block($copy));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user