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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user