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,137 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One block of the tree. A parsed block keeps its original opener/closer bytes; they are
|
||||||
|
* dropped only when the block itself changes (setAttrs(), a void block getting children),
|
||||||
|
* so the Serializer regenerates exactly the changed openers and nothing else.
|
||||||
|
*
|
||||||
|
* Opaque blocks (attributes that are no JSON object, divi/global-layout, anything carrying
|
||||||
|
* globalModule) are never regenerated and never descended into by the tools – Divi's own
|
||||||
|
* code warns that parse_blocks() mishandles global modules; Etappe 3 edits them.
|
||||||
|
*/
|
||||||
|
final class Block extends Container
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $attrs
|
||||||
|
*/
|
||||||
|
private function __construct(
|
||||||
|
private string $name,
|
||||||
|
private array $attrs,
|
||||||
|
private ?string $rawOpen,
|
||||||
|
private ?string $rawClose,
|
||||||
|
private bool $void,
|
||||||
|
private bool $opaque
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $attrs */
|
||||||
|
public static function parsed(string $name, array $attrs, string $rawOpen, bool $void, bool $opaque): self
|
||||||
|
{
|
||||||
|
return new self($name, $attrs, $rawOpen, null, $void, $opaque);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A new block (opener and closer are generated; written as a pair like Divi does).
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $attrs
|
||||||
|
* @param list<Block> $children
|
||||||
|
*/
|
||||||
|
public static function create(string $name, array $attrs = [], array $children = []): self
|
||||||
|
{
|
||||||
|
$block = new self($name, $attrs, null, null, false, false);
|
||||||
|
foreach ($children as $child) {
|
||||||
|
$block->parts[] = $child;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Used by the Parser for the closing delimiter. */
|
||||||
|
public function closeWith(string $rawClose): void
|
||||||
|
{
|
||||||
|
$this->rawClose = $rawClose;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full block name, e.g. "divi/section"; blocks without namespace are "core/…". */
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Name without the "divi/" namespace, e.g. "button"; other namespaces stay. */
|
||||||
|
public function shortName(): string
|
||||||
|
{
|
||||||
|
return str_starts_with($this->name, 'divi/') ? substr($this->name, 5) : $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function attrs(): array
|
||||||
|
{
|
||||||
|
return $this->attrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $attrs */
|
||||||
|
public function setAttrs(array $attrs): void
|
||||||
|
{
|
||||||
|
$this->attrs = $attrs;
|
||||||
|
$this->rawOpen = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rawOpen(): ?string
|
||||||
|
{
|
||||||
|
return $this->rawOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rawClose(): ?string
|
||||||
|
{
|
||||||
|
return $this->rawClose;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isVoid(): bool
|
||||||
|
{
|
||||||
|
return $this->void;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isOpaque(): bool
|
||||||
|
{
|
||||||
|
return $this->opaque;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDivi(): bool
|
||||||
|
{
|
||||||
|
return str_starts_with($this->name, 'divi/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A global module reference or global layout (edited only in Etappe 3). */
|
||||||
|
public function isGlobal(): bool
|
||||||
|
{
|
||||||
|
return $this->name === 'divi/global-layout' || array_key_exists('globalModule', $this->attrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deep copy – unchanged copies serialize to exactly the same bytes. */
|
||||||
|
public function copy(): self
|
||||||
|
{
|
||||||
|
return clone $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __clone()
|
||||||
|
{
|
||||||
|
foreach ($this->parts as $position => $part) {
|
||||||
|
if ($part instanceof Block) {
|
||||||
|
$this->parts[$position] = clone $part;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function touched(): void
|
||||||
|
{
|
||||||
|
if ($this->void) {
|
||||||
|
$this->void = false;
|
||||||
|
$this->rawOpen = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordered parts of a document or of a block's inner content: blocks and the raw text
|
||||||
|
* between them (whitespace, freeform HTML, Divi 4 shortcodes). Keeping every byte of that
|
||||||
|
* text is what makes parse → serialize byte-identical (spec §5.2).
|
||||||
|
*/
|
||||||
|
abstract class Container
|
||||||
|
{
|
||||||
|
/** @var list<string|Block> */
|
||||||
|
protected array $parts = [];
|
||||||
|
|
||||||
|
/** @return list<string|Block> */
|
||||||
|
public function parts(): array
|
||||||
|
{
|
||||||
|
return $this->parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<Block> */
|
||||||
|
public function children(): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter($this->parts, static fn (string|Block $part): bool => $part instanceof Block));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Used by the Parser: adds a part without marking the container as changed. */
|
||||||
|
public function append(string|Block $part): void
|
||||||
|
{
|
||||||
|
$this->parts[] = $part;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Child position of $block (0-based among the child blocks), null when it is no child. */
|
||||||
|
public function indexOf(Block $block): ?int
|
||||||
|
{
|
||||||
|
$index = array_search($block, $this->children(), true);
|
||||||
|
|
||||||
|
return $index === false ? null : (int) $index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts $block so it becomes child number $index (0 = first; count or more = after the
|
||||||
|
* last child). Text between existing children stays where it is.
|
||||||
|
*/
|
||||||
|
public function insertChild(int $index, Block $block): void
|
||||||
|
{
|
||||||
|
$children = $this->children();
|
||||||
|
$index = max(0, min($index, count($children)));
|
||||||
|
if ($index < count($children)) {
|
||||||
|
$position = $this->partPosition($children[$index]);
|
||||||
|
} elseif ($children === []) {
|
||||||
|
$position = count($this->parts);
|
||||||
|
} else {
|
||||||
|
$position = $this->partPosition($children[count($children) - 1]) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
array_splice($this->parts, $position, 0, [$block]);
|
||||||
|
$this->touched();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes the child block; the text around it stays. Returns its former child index. */
|
||||||
|
public function removeChild(Block $block): int
|
||||||
|
{
|
||||||
|
$index = $this->indexOf($block);
|
||||||
|
if ($index === null) {
|
||||||
|
throw new InvalidArgumentException('Block is not a child of this container.');
|
||||||
|
}
|
||||||
|
|
||||||
|
array_splice($this->parts, $this->partPosition($block), 1);
|
||||||
|
$this->touched();
|
||||||
|
|
||||||
|
return $index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called after the list of children changed. */
|
||||||
|
protected function touched(): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private function partPosition(Block $block): int
|
||||||
|
{
|
||||||
|
$position = array_search($block, $this->parts, true);
|
||||||
|
if ($position === false) {
|
||||||
|
throw new InvalidArgumentException('Block is not a child of this container.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $position;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The parsed post_content: top-level blocks and the text between them.
|
||||||
|
*/
|
||||||
|
final class Document extends Container
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||||||
|
|
||||||
|
use JsonException;
|
||||||
|
use stdClass;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON of block attributes. PHP arrays cannot tell an empty JSON object from an empty list,
|
||||||
|
* nor an object with the keys "0", "1", … from a list. decodeObject() therefore keeps such
|
||||||
|
* objects as stdClass, so a regenerated opener encodes them as {} again (Divi breaks on
|
||||||
|
* "[]" where it expects "{}" – see its EmptyArrayCorruptionMigration).
|
||||||
|
*/
|
||||||
|
final class Json
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed>|null Null when $json is not a JSON object. */
|
||||||
|
public static function decodeObject(string $json): ?array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$value = json_decode($json, false, 512, JSON_THROW_ON_ERROR);
|
||||||
|
} catch (JsonException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $value instanceof stdClass) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$converted = self::fromJson($value);
|
||||||
|
|
||||||
|
return is_array($converted) ? $converted : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Converts json_decode(…, false) output: objects become arrays unless that would lose their object-ness. */
|
||||||
|
public static function fromJson(mixed $value): mixed
|
||||||
|
{
|
||||||
|
if ($value instanceof stdClass) {
|
||||||
|
$array = [];
|
||||||
|
foreach (get_object_vars($value) as $key => $item) {
|
||||||
|
$array[$key] = self::fromJson($item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $array === [] || array_is_list($array) ? (object) $array : $array;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
return array_map([self::class, 'fromJson'], $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws JsonException */
|
||||||
|
public static function encode(mixed $value): string
|
||||||
|
{
|
||||||
|
// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP (spec §4.4).
|
||||||
|
return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* post_content is no balanced block markup (closer without opener, unclosed block, PCRE
|
||||||
|
* failure). The Divi tools then refuse to read or write the entry – nothing is guessed.
|
||||||
|
*/
|
||||||
|
final class MalformedMarkup extends RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses block markup into a Document without WordPress (spec §4.4, §5.2). Uses the
|
||||||
|
* block-delimiter pattern of WP_Block_Parser, but records the exact bytes of every
|
||||||
|
* delimiter and of all text between them, so Serializer::document() reproduces the input
|
||||||
|
* byte for byte. Unbalanced markup is refused (MalformedMarkup), never repaired.
|
||||||
|
*/
|
||||||
|
final class Parser
|
||||||
|
{
|
||||||
|
/** WP_Block_Parser::next_token() (WordPress 7.0). */
|
||||||
|
private const TOKEN = '/<!--\s+(?P<closer>\/)?wp:(?P<namespace>[a-z][a-z0-9_-]*\/)?(?P<name>[a-z][a-z0-9_-]*)\s+'
|
||||||
|
. '(?P<attrs>{(?:(?:[^}]+|}+(?=})|(?!}\s+\/?-->).)*+)?}\s+)?(?P<void>\/)?-->/s';
|
||||||
|
|
||||||
|
/** @throws MalformedMarkup */
|
||||||
|
public function parse(string $content): Document
|
||||||
|
{
|
||||||
|
$document = new Document();
|
||||||
|
/** @var list<Block> $stack */
|
||||||
|
$stack = [];
|
||||||
|
$offset = 0;
|
||||||
|
$length = strlen($content);
|
||||||
|
|
||||||
|
while ($offset < $length) {
|
||||||
|
$found = preg_match(self::TOKEN, $content, $match, PREG_OFFSET_CAPTURE, $offset);
|
||||||
|
if ($found === false) {
|
||||||
|
throw new MalformedMarkup('Block delimiters could not be scanned (PCRE error).');
|
||||||
|
}
|
||||||
|
if ($found === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$token, $start] = $match[0];
|
||||||
|
$container = $stack === [] ? $document : $stack[count($stack) - 1];
|
||||||
|
if ($start > $offset) {
|
||||||
|
$container->append(substr($content, $offset, $start - $offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = self::name($match);
|
||||||
|
if (self::captured($match, 'closer')) {
|
||||||
|
$open = array_pop($stack);
|
||||||
|
if ($open === null || $open->name() !== $name) {
|
||||||
|
throw new MalformedMarkup(sprintf('Closer of %s at byte %d has no opener.', $name, $start));
|
||||||
|
}
|
||||||
|
$open->closeWith($token);
|
||||||
|
} else {
|
||||||
|
$json = self::captured($match, 'attrs') ? $match['attrs'][0] : '';
|
||||||
|
$attrs = $json === '' ? [] : Json::decodeObject($json);
|
||||||
|
$void = self::captured($match, 'void');
|
||||||
|
$opaque = $attrs === null || $name === 'divi/global-layout' || array_key_exists('globalModule', $attrs);
|
||||||
|
$block = Block::parsed($name, $attrs ?? [], $token, $void, $opaque);
|
||||||
|
$container->append($block);
|
||||||
|
if (! $void) {
|
||||||
|
$stack[] = $block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$offset = $start + strlen($token);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($offset < $length) {
|
||||||
|
($stack === [] ? $document : $stack[count($stack) - 1])->append(substr($content, $offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stack !== []) {
|
||||||
|
throw new MalformedMarkup(sprintf('Block %s is never closed.', $stack[count($stack) - 1]->name()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $document;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int|string, array{0: string, 1: int}> $match */
|
||||||
|
private static function name(array $match): string
|
||||||
|
{
|
||||||
|
$namespace = self::captured($match, 'namespace') ? $match['namespace'][0] : 'core/';
|
||||||
|
|
||||||
|
return $namespace . $match['name'][0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int|string, array{0: string, 1: int}> $match */
|
||||||
|
private static function captured(array $match, string $group): bool
|
||||||
|
{
|
||||||
|
return isset($match[$group]) && $match[$group][1] !== -1 && $match[$group][0] !== '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a Document back into block markup. Unchanged blocks reuse their original
|
||||||
|
* delimiters; changed or new blocks get delimiters generated exactly like WordPress'
|
||||||
|
* get_comment_delimited_block_content() / serialize_block_attributes() do, which also
|
||||||
|
* yields the escaping Divi expects inside $variable(…)$ payloads (" → ").
|
||||||
|
*/
|
||||||
|
final class Serializer
|
||||||
|
{
|
||||||
|
/** serialize_block_attributes() replacements (WordPress 7.0). */
|
||||||
|
private const ESCAPES = [
|
||||||
|
'\\\\' => '\\u005c',
|
||||||
|
'--' => '\\u002d\\u002d',
|
||||||
|
'<' => '\\u003c',
|
||||||
|
'>' => '\\u003e',
|
||||||
|
'&' => '\\u0026',
|
||||||
|
'\\"' => '\\u0022',
|
||||||
|
];
|
||||||
|
|
||||||
|
public static function document(Document $document): string
|
||||||
|
{
|
||||||
|
return self::parts($document->parts());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function block(Block $block): string
|
||||||
|
{
|
||||||
|
$open = $block->rawOpen() ?? self::opener($block);
|
||||||
|
if ($block->isVoid()) {
|
||||||
|
return $open;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $open . self::parts($block->parts()) . ($block->rawClose() ?? self::closer($block));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $attrs */
|
||||||
|
public static function attributes(array $attrs): string
|
||||||
|
{
|
||||||
|
return strtr(Json::encode($attrs), self::ESCAPES);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function opener(Block $block): string
|
||||||
|
{
|
||||||
|
$attrs = $block->attrs() === [] ? '' : self::attributes($block->attrs()) . ' ';
|
||||||
|
|
||||||
|
return '<!-- wp:' . self::serializedName($block->name()) . ' ' . $attrs . ($block->isVoid() ? '/-->' : '-->');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function closer(Block $block): string
|
||||||
|
{
|
||||||
|
return '<!-- /wp:' . self::serializedName($block->name()) . ' -->';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function serializedName(string $name): string
|
||||||
|
{
|
||||||
|
return str_starts_with($name, 'core/') ? substr($name, 5) : $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string|Block> $parts */
|
||||||
|
private static function parts(array $parts): string
|
||||||
|
{
|
||||||
|
$out = '';
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
$out .= $part instanceof Block ? self::block($part) : $part;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<!-- wp:divi/placeholder --><!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Details"}}},"decoration":{"layout":{"desktop":{"value":{"display":"block"}}}}},"builderVersion":"5.0.0-public-alpha.18.2"} --><!-- wp:divi/row {"module":{"advanced":{"columnStructure":{"desktop":{"value":"2_3,1_3"}}},"decoration":{"layout":{"desktop":{"value":{"display":"block"}}}}},"builderVersion":"5.0.0-public-alpha.18.2"} --><!-- wp:divi/column {"module":{"advanced":{"type":{"desktop":{"value":"2_3"}}},"decoration":{"layout":{"desktop":{"value":{"display":"block"}}}}},"builderVersion":"5.0.0-public-alpha.18.2"} --><!-- wp:divi/tabs {"builderVersion":"4.27.4"} --><!-- wp:divi/tab {"title":{"innerContent":{"desktop":{"value":"Ablauf"}}},"builderVersion":"4.27.4","content":{"innerContent":{"desktop":{"value":"\u003cp\u003eErst reden, dann bauen.\u003c/p\u003e"}}}} --><!-- /wp:divi/tab --><!-- wp:divi/tab {"title":{"innerContent":{"desktop":{"value":"Preise"}}},"builderVersion":"4.27.4","content":{"innerContent":{"desktop":{"value":"\u003cp\u003eAb 99 €.\u003c/p\u003e"}}}} --><!-- /wp:divi/tab --><!-- /wp:divi/tabs --><!-- wp:divi/toggle {"title":{"innerContent":{"desktop":{"value":"Garantie"}}},"module":{"advanced":{"open":{"desktop":{"value":"off"}}}},"builderVersion":"4.27.4","content":{"innerContent":{"desktop":{"value":"\u003cp\u003e5 Jahre.\u003c/p\u003e"}}}} --><!-- /wp:divi/toggle --><!-- wp:divi/slider {"builderVersion":"4.27.4","module":{"advanced":{"text":{"text":{"desktop":{"value":{"orientation":"center"}}}}}}} --><!-- wp:divi/slide {"title":{"innerContent":{"desktop":{"value":"Neu"}}},"button":{"innerContent":{"desktop":{"value":{"text":"Mehr","linkUrl":"/neu"}}}},"builderVersion":"4.27.4","content":{"innerContent":{"desktop":{"value":"\u003cp\u003eUnsere neue Halle.\u003c/p\u003e"}}},"module":{"advanced":{"text":{"text":{"desktop":{"value":{"orientation":"center"}}}}}}} --><!-- /wp:divi/slide --><!-- /wp:divi/slider --><!-- wp:divi/gallery {"image":{"advanced":{"galleryIds":{"desktop":{"value":"12,33"}}}},"builderVersion":"5.0.0-public-alpha.18.2","galleryGrid":{"decoration":{"layout":{"desktop":{"value":{"display":"block"}}}}}} --><!-- /wp:divi/gallery --><!-- wp:divi/code {"builderVersion":"4.27.4","content":{"innerContent":{"desktop":{"value":"\u003cdiv class=\u0022hinweis\u0022\u003eCode\u003c/div\u003e"}}}} --><!-- /wp:divi/code --><!-- /wp:divi/column --><!-- wp:divi/column {"module":{"advanced":{"type":{"desktop":{"value":"1_3"}}},"decoration":{"layout":{"desktop":{"value":{"display":"block"}}}}},"builderVersion":"5.0.0-public-alpha.18.2"} --><!-- wp:divi/team-member {"name":{"innerContent":{"desktop":{"value":"Max Meister"}}},"position":{"innerContent":{"desktop":{"value":"Inhaber"}}},"builderVersion":"4.27.4","content":{"innerContent":{"desktop":{"value":"\u003cp\u003eMeister seit 2001.\u003c/p\u003e"}}},"module":{"decoration":{"layout":{"desktop":{"value":{"display":"block"}}}}}} --><!-- /wp:divi/team-member --><!-- wp:divi/number-counter {"title":{"innerContent":{"desktop":{"value":"Projekte"}}},"number":{"innerContent":{"desktop":{"value":"480"}},"advanced":{"enablePercentSign":{"desktop":{"value":"off"}}}},"builderVersion":"4.27.4"} --><!-- /wp:divi/number-counter --><!-- wp:divi/divider {"divider":{"advanced":{"line":{"desktop":{"value":{"show":"on"}}}}},"builderVersion":"4.27.4"} --><!-- /wp:divi/divider --><!-- wp:divi/social-media-follow {"builderVersion":"5.0.0-public-alpha.18.2","module":{"decoration":{"layout":{"desktop":{"value":{"display":"block"}}}}}} --><!-- wp:divi/social-media-follow-network {"socialNetwork":{"innerContent":{"desktop":{"value":{"title":"facebook","link":"https://facebook.com/example","label":"facebook"}}}},"builderVersion":"4.27.4"} --><!-- /wp:divi/social-media-follow-network --><!-- /wp:divi/social-media-follow --><!-- wp:divi/map {"map":{"innerContent":{"desktop":{"value":{"address":"Domkloster 4, 50667 Köln","lat":50.9413,"lng":6.9583}}}},"builderVersion":"4.27.4"} --><!-- wp:divi/map-pin {"title":{"innerContent":{"desktop":{"value":"Büro"}}},"pin":{"innerContent":{"desktop":{"value":{"address":"Domkloster 4, 50667 Köln","lat":50.9413,"lng":6.9583}}}},"builderVersion":"4.27.4"} --><!-- /wp:divi/map-pin --><!-- /wp:divi/map --><!-- wp:divi/video {"video":{"innerContent":{"desktop":{"value":{"src":"https://www.youtube.com/watch?v=dQw4w9WgXcQ"}}}},"builderVersion":"4.27.4"} --><!-- /wp:divi/video --><!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
[et_pb_section fb_built="1" _builder_version="4.27.4"][et_pb_row _builder_version="4.27.4"][et_pb_column type="4_4"][et_pb_text]<p>Divi 4</p>[/et_pb_text][/et_pb_column][/et_pb_row][/et_pb_section]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[et_pb_section fb_built="1" admin_label="Details" _builder_version="4.27.4"][et_pb_row column_structure="2_3,1_3" _builder_version="4.27.4"][et_pb_column type="2_3" _builder_version="4.27.4"][et_pb_tabs _builder_version="4.27.4"][et_pb_tab title="Ablauf" _builder_version="4.27.4"]<p>Erst reden, dann bauen.</p>[/et_pb_tab][et_pb_tab title="Preise" _builder_version="4.27.4"]<p>Ab 99 €.</p>[/et_pb_tab][/et_pb_tabs][et_pb_toggle title="Garantie" open="off" _builder_version="4.27.4"]<p>5 Jahre.</p>[/et_pb_toggle][et_pb_slider _builder_version="4.27.4"][et_pb_slide heading="Neu" button_text="Mehr" button_link="/neu" _builder_version="4.27.4"]<p>Unsere neue Halle.</p>[/et_pb_slide][/et_pb_slider][et_pb_gallery gallery_ids="12,33" _builder_version="4.27.4"][/et_pb_gallery][et_pb_code _builder_version="4.27.4"]<div class="hinweis">Code</div>[/et_pb_code][/et_pb_column][et_pb_column type="1_3" _builder_version="4.27.4"][et_pb_team_member name="Max Meister" position="Inhaber" _builder_version="4.27.4"]<p>Meister seit 2001.</p>[/et_pb_team_member][et_pb_number_counter title="Projekte" number="480" percent_sign="off" _builder_version="4.27.4"][/et_pb_number_counter][et_pb_divider show_divider="on" _builder_version="4.27.4"][/et_pb_divider][et_pb_social_media_follow _builder_version="4.27.4"][et_pb_social_media_follow_network social_network="facebook" url="https://facebook.com/example" _builder_version="4.27.4"]facebook[/et_pb_social_media_follow_network][/et_pb_social_media_follow][et_pb_map address="Domkloster 4, 50667 Köln" address_lat="50.9413" address_lng="6.9583" _builder_version="4.27.4"][et_pb_map_pin title="Büro" pin_address="Domkloster 4, 50667 Köln" pin_address_lat="50.9413" pin_address_lng="6.9583" _builder_version="4.27.4"][/et_pb_map_pin][/et_pb_map][et_pb_video src="https://www.youtube.com/watch?v=dQw4w9WgXcQ" _builder_version="4.27.4"][/et_pb_video][/et_pb_column][/et_pb_row][/et_pb_section]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[et_pb_section fb_built="1" admin_label="Hero" _builder_version="4.27.4" background_color="#0b3d59"][et_pb_row column_structure="1_2,1_2" _builder_version="4.27.4"][et_pb_column type="1_2" _builder_version="4.27.4"][et_pb_heading title="Handwerk mit Herz" title_level="h1" _builder_version="4.27.4"][/et_pb_heading][et_pb_text _builder_version="4.27.4"]<p>Seit 1987 in <strong>Köln</strong> – "fair" & pünktlich.</p>[/et_pb_text][et_pb_button button_text="Termin buchen" button_url="/kontakt" _builder_version="4.27.4"][/et_pb_button][/et_pb_column][et_pb_column type="1_2" _builder_version="4.27.4"][et_pb_image src="https://example.test/wp-content/uploads/2026/09/werkstatt.jpg" alt="Werkstatt" title_text="Werkstatt" _builder_version="4.27.4"][/et_pb_image][/et_pb_column][/et_pb_row][/et_pb_section][et_pb_section fb_built="1" admin_label="Leistungen" _builder_version="4.27.4"][et_pb_row column_structure="1_3,1_3,1_3" _builder_version="4.27.4"][et_pb_column type="1_3" _builder_version="4.27.4"][et_pb_blurb title="Beratung" use_icon="on" font_icon="||divi||400" _builder_version="4.27.4"]<p>Persönlich vor Ort.</p>[/et_pb_blurb][/et_pb_column][et_pb_column type="1_3" _builder_version="4.27.4"][et_pb_blurb title="Planung" image="https://example.test/wp-content/uploads/2026/09/plan.png" alt="Plan" _builder_version="4.27.4"]<p>Mit 3D-Ansicht.</p>[/et_pb_blurb][/et_pb_column][et_pb_column type="1_3" _builder_version="4.27.4"][et_pb_blurb title="Umsetzung" _builder_version="4.27.4"]<p>Termingerecht.</p>[/et_pb_blurb][/et_pb_column][/et_pb_row][et_pb_row _builder_version="4.27.4"][et_pb_column type="4_4" _builder_version="4.27.4"][et_pb_cta title="Bereit?" button_text="Anfragen" button_url="/kontakt" _builder_version="4.27.4"]<p>Wir melden uns binnen 24 Stunden.</p>[/et_pb_cta][et_pb_testimonial author="Anna B." job_title="Bauherrin" company_name="Privat" _builder_version="4.27.4"]<p>Top Arbeit!</p>[/et_pb_testimonial][et_pb_accordion _builder_version="4.27.4"][et_pb_accordion_item title="Was kostet das?" open="on" _builder_version="4.27.4"]<p>Kommt drauf an.</p>[/et_pb_accordion_item][et_pb_accordion_item title="Wie schnell?" open="off" _builder_version="4.27.4"]<p>2 Wochen.</p>[/et_pb_accordion_item][/et_pb_accordion][et_pb_contact_form email="info@example.test" title="Kontakt" _builder_version="4.27.4"][et_pb_contact_field field_id="Name" field_title="Name" _builder_version="4.27.4"][/et_pb_contact_field][et_pb_contact_field field_id="Email" field_title="E-Mail" field_type="email" _builder_version="4.27.4"][/et_pb_contact_field][et_pb_contact_field field_id="Message" field_title="Nachricht" field_type="text" fullwidth_field="on" _builder_version="4.27.4"][/et_pb_contact_field][/et_pb_contact_form][/et_pb_column][/et_pb_row][/et_pb_section]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<!-- wp:divi/placeholder --><!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Hero"}}},"decoration":{"background":{"desktop":{"value":{"color":"$variable({\u0022type\u0022:\u0022color\u0022,\u0022value\u0022:{\u0022name\u0022:\u0022gcid-primary-color\u0022,\u0022settings\u0022:{}}})$"}}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"Hero – Zeile"}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"8_24"}}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/image {"image":{"innerContent":{"desktop":{"value":{"src":"https://kunde.test/wp-content/uploads/2026/09/team.jpg","id":"33","alt":"Unser Team vor dem Büro"}}}},"module":{"meta":{"adminLabel":{"desktop":{"value":"Hero – Bild"}}}},"builderVersion":"5.2.1"} /--><!-- /wp:divi/column --><!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"16_24"}}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/heading {"title":{"innerContent":{"desktop":{"value":"Willkommen bei Müller \u0026 Söhne"}},"decoration":{"font":{"font":{"desktop":{"value":{"headingLevel":"h1","color":"#1a1a1a"}},"phone":{"value":{"size":"28px"}}}}}},"modulePreset":["a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"],"builderVersion":"5.2.1"} --><!-- /wp:divi/heading --><!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eSeit 1987 \u003cstrong\u003ezuverlässig\u003c/strong\u003e – \u003ca href=\u0022/kontakt\u0022\u003ejetzt anfragen\u003c/a\u003e.\u003c/p\u003e\u003c!\u002d\u002d more \u002d\u002d\u003e"}}},"builderVersion":"5.2.1"} --><!-- /wp:divi/text --><!-- wp:divi/button {"button":{"innerContent":{"desktop":{"value":{"text":"Termin buchen","linkUrl":"/kontakt","linkTarget":"off"}}}},"module":{"meta":{"adminLabel":{"desktop":{"value":"Hero – Button"}}}},"builderVersion":"5.2.1"} --><!-- /wp:divi/button --><!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Leistungen"}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/row {"builderVersion":"5.2.1"} --><!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"12_24"}}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/blurb {"title":{"innerContent":{"desktop":{"value":{"text":"Beratung","url":""}}},"decoration":{"font":{"font":{"desktop":{"value":{"headingLevel":"h3"}}}}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003ePersönlich und \u0022ehrlich\u0022.\u003c/p\u003e"}}},"imageIcon":{"innerContent":{"desktop":{"value":{"useIcon":"on","icon":{"unicode":"\u0026#xe08b;","type":"divi","weight":"400"}}}}},"builderVersion":"5.2.1"} --><!-- /wp:divi/blurb --><!-- /wp:divi/column --><!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"12_24"}}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/accordion {"builderVersion":"5.2.1"} --><!-- wp:divi/accordion-item {"title":{"innerContent":{"desktop":{"value":"Was kostet das?"}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eKommt drauf an.\u003c/p\u003e"}}},"module":{"advanced":{"open":{"desktop":{"value":"on"}}}},"builderVersion":"5.2.1"} --><!-- /wp:divi/accordion-item --><!-- wp:divi/accordion-item {"title":{"innerContent":{"desktop":{"value":"Wie schnell?"}}},"content":{"innerContent":{"desktop":{"value":"\u003cp\u003eIn 2 Wochen.\u003c/p\u003e"}}},"builderVersion":"5.2.1"} --><!-- /wp:divi/accordion-item --><!-- /wp:divi/accordion --><!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!-- wp:paragraph -->
|
||||||
|
<p>Einleitung aus dem Block-Editor mit <a href="https://example.test" target="_blank">Link</a>.</p>
|
||||||
|
<!-- /wp:paragraph -->
|
||||||
|
|
||||||
|
<!-- wp:divi/placeholder -->
|
||||||
|
<!-- wp:divi/section {"module":{"advanced":{"type":{"desktop":{"value":"specialty"}}}},"builderVersion":"5.2.1"} -->
|
||||||
|
<!-- wp:divi/column {"module":{"advanced":{"type":{"desktop":{"value":"1_3"}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":"Seitenleiste"}}},"builderVersion":"5.2.1"} /--><!-- /wp:divi/column -->
|
||||||
|
<!-- wp:divi/column {"module":{"advanced":{"type":{"desktop":{"value":"2_3"}},"specialtyColumns":{"desktop":{"value":"2"}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/row-inner {"module":{"advanced":{"columnStructure":{"desktop":{"value":"1_2,1_2"}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/column-inner {"builderVersion":"5.2.1"} --><!-- wp:divi/code {"content":{"innerContent":{"desktop":{"value":"\u003cscript\u003econsole.log(\u0022a\u002d\u002db\u0022)\u003c/script\u003e"}}},"builderVersion":"5.2.1"} /--><!-- /wp:divi/column-inner --><!-- wp:divi/column-inner {"builderVersion":"5.2.1"} --><!-- /wp:divi/column-inner --><!-- /wp:divi/row-inner --><!-- /wp:divi/column -->
|
||||||
|
<!-- /wp:divi/section -->
|
||||||
|
<!-- wp:divi/section {"builderVersion":"5.2.1","module":{"decoration":{"spacing":{"desktop":{"value":{}}},"background":{"desktop":{"value":[]}}}},"localAttrs":{"0":"x","1":"y"}} --><!-- wp:divi/global-layout {"globalModule":"812","builderVersion":"5.2.1"} /--><!-- wp:divi/row {"globalModule":"913","module":{"meta":{"adminLabel":{"desktop":{"value":"Globale Zeile"}}}}} --><!-- wp:divi/column {"builderVersion":"5.2.1"} --><!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":"lokal"}}}} /--><!-- /wp:divi/column --><!-- /wp:divi/row --><!-- wp:divi/global-layout {"globalModule":"914","localAttrs":{"title":"x"}} --><!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":"Kind"}}}} /--><!-- /wp:divi/global-layout --><!-- /wp:divi/section -->
|
||||||
|
<!-- /wp:divi/placeholder -->
|
||||||
|
Freier Text am Ende [et_pb_legacy]
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Generates a Divi 5 fixture from a Divi 4 shortcode file with Divi's own server-side
|
||||||
|
* conversion (the same code the Divi 5 migrator uses) and writes it into tests/Fixtures/divi/.
|
||||||
|
* Usage (inside DDEV, from the WordPress root):
|
||||||
|
* wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-convert-fixture.php <source.shortcode> <target.html>
|
||||||
|
* Paths are relative to the plugin's tests/Fixtures/divi directory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use ET\Builder\Packages\Conversion\Conversion;
|
||||||
|
|
||||||
|
$args = $args ?? [];
|
||||||
|
if (count($args) !== 2) {
|
||||||
|
WP_CLI::error('Usage: divi-convert-fixture.php <source.shortcode> <target.html>');
|
||||||
|
}
|
||||||
|
if (! class_exists(Conversion::class)) {
|
||||||
|
WP_CLI::error('Divi 5 is not active.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$dir = dirname(__DIR__) . '/Fixtures/divi/';
|
||||||
|
$source = file_get_contents($dir . $args[0]);
|
||||||
|
if ($source === false) {
|
||||||
|
WP_CLI::error('Cannot read ' . $dir . $args[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$converted = Conversion::maybeConvertContent(trim($source));
|
||||||
|
if (! str_contains($converted, '<!-- wp:divi/section')) {
|
||||||
|
WP_CLI::error('Conversion produced no Divi 5 sections.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$markup = '<!-- wp:divi/placeholder -->' . $converted . '<!-- /wp:divi/placeholder -->';
|
||||||
|
if (file_put_contents($dir . $args[1], $markup) === false) {
|
||||||
|
WP_CLI::error('Cannot write ' . $dir . $args[1]);
|
||||||
|
}
|
||||||
|
WP_CLI::success(sprintf('%s: %d bytes', $args[1], strlen($markup)));
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports the post_content of a page built in the Divi Visual Builder byte-exact into
|
||||||
|
* tests/Fixtures/divi/builder-<name>.html. Every file there is part of the parse → serialize
|
||||||
|
* round-trip test (tests/Unit/Integrations/Divi/BlockTree/ParserTest.php).
|
||||||
|
* Usage (DDEV, WordPress root):
|
||||||
|
* wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-export-page.php <post_id> <name>
|
||||||
|
* <name>: lowercase letters, digits and dashes, e.g. "startseite-mit-global".
|
||||||
|
* Only use pages without personal data – fixtures are committed.
|
||||||
|
* No `declare(strict_types=1);` – wp eval-file runs the file through eval().
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined('WP_CLI') && WP_CLI || exit;
|
||||||
|
|
||||||
|
$args = $args ?? [];
|
||||||
|
$postId = (int) ($args[0] ?? 0);
|
||||||
|
$name = (string) ($args[1] ?? '');
|
||||||
|
if ($postId <= 0 || preg_match('/^[a-z0-9]+(-[a-z0-9]+)*$/', $name) !== 1) {
|
||||||
|
WP_CLI::error('Usage: divi-export-page.php <post_id> <name>');
|
||||||
|
}
|
||||||
|
|
||||||
|
$post = get_post($postId);
|
||||||
|
if (! $post instanceof WP_Post) {
|
||||||
|
WP_CLI::error('No post ' . $postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = (string) $post->post_content;
|
||||||
|
if (! str_contains($content, '<!-- wp:divi/')) {
|
||||||
|
WP_CLI::error('Post ' . $postId . ' has no Divi 5 content (open and save it in the Divi builder first).');
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = dirname(__DIR__) . '/Fixtures/divi/builder-' . $name . '.html';
|
||||||
|
if (file_put_contents($file, $content) === false) {
|
||||||
|
WP_CLI::error('Cannot write ' . $file);
|
||||||
|
}
|
||||||
|
|
||||||
|
WP_CLI::success(sprintf(
|
||||||
|
'%s: %d bytes, %d blocks, globalModule: %s, tree_rev %s. Now run composer test.',
|
||||||
|
basename($file),
|
||||||
|
strlen($content),
|
||||||
|
substr_count($content, '<!-- wp:'),
|
||||||
|
str_contains($content, 'globalModule') ? 'yes' : 'no',
|
||||||
|
substr(hash('sha256', $content), 0, 12)
|
||||||
|
));
|
||||||
@@ -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