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;
}
}
}