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:
2026-09-26 11:26:14 +02:00
co-authored by Claude Opus 5.5
parent c9b31a61da
commit 8222240ed6
18 changed files with 819 additions and 0 deletions
+137
View File
@@ -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
{
}
+61
View File
@@ -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;
}
}