Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
207 lines
6.1 KiB
PHP
207 lines
6.1 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||
|
||
use InvalidArgumentException;
|
||
use LogicException;
|
||
use stdClass;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* 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
|
||
{
|
||
/** A valid block name: "namespace/block-name" (WordPress' own block name convention). */
|
||
private const NAME_PATTERN = '/^[a-z][a-z0-9_-]*\/[a-z][a-z0-9_-]*$/D';
|
||
|
||
/**
|
||
* @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
|
||
{
|
||
if (preg_match(self::NAME_PATTERN, $name) !== 1) {
|
||
throw new InvalidArgumentException(sprintf('"%s" is not a valid block name.', $name));
|
||
}
|
||
|
||
$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
|
||
{
|
||
if ($this->opaque) {
|
||
throw new LogicException('Cannot set attributes of an opaque block.');
|
||
}
|
||
|
||
$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/');
|
||
}
|
||
|
||
/** The divi/placeholder wrapper the builder puts around a page's sections (transparent for IDs). */
|
||
public function isPlaceholder(): bool
|
||
{
|
||
return $this->name === 'divi/placeholder';
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
|
||
/**
|
||
* A deep copy of this block under another name and attributes: opener and closer are
|
||
* regenerated, children and the text between them are kept, the void flag too. Opaque exactly
|
||
* when the Parser would make it so. GlobalInstance uses it to turn a library root into an
|
||
* instance and back.
|
||
*
|
||
* @param array<string, mixed> $attrs
|
||
*/
|
||
public function copyAs(string $name, array $attrs): self
|
||
{
|
||
if (preg_match(self::NAME_PATTERN, $name) !== 1) {
|
||
throw new InvalidArgumentException(sprintf('"%s" is not a valid block name.', $name));
|
||
}
|
||
|
||
$copy = clone $this;
|
||
$copy->name = $name;
|
||
$copy->attrs = self::deepCloneAttrs($attrs);
|
||
$copy->rawOpen = null;
|
||
$copy->rawClose = null;
|
||
$copy->opaque = $name === 'divi/global-layout' || array_key_exists('globalModule', $attrs);
|
||
|
||
return $copy;
|
||
}
|
||
|
||
public function __clone()
|
||
{
|
||
foreach ($this->parts as $position => $part) {
|
||
if ($part instanceof Block) {
|
||
$this->parts[$position] = clone $part;
|
||
}
|
||
}
|
||
$this->attrs = self::deepCloneAttrs($this->attrs);
|
||
}
|
||
|
||
/**
|
||
* PHP's own array-copy-on-write already deep-copies nested arrays, but stdClass values
|
||
* (Json.php keeps empty/list-like JSON objects as stdClass) are objects – clone() alone
|
||
* would leave the copy's attrs() sharing them with the original.
|
||
*
|
||
* @param array<string, mixed> $attrs
|
||
* @return array<string, mixed>
|
||
*/
|
||
private static function deepCloneAttrs(array $attrs): array
|
||
{
|
||
foreach ($attrs as $key => $value) {
|
||
if ($value instanceof stdClass) {
|
||
$attrs[$key] = (object) self::deepCloneAttrs((array) $value);
|
||
} elseif (is_array($value)) {
|
||
$attrs[$key] = self::deepCloneAttrs($value);
|
||
}
|
||
}
|
||
|
||
return $attrs;
|
||
}
|
||
|
||
protected function touched(): void
|
||
{
|
||
if ($this->void) {
|
||
$this->void = false;
|
||
$this->rawOpen = null;
|
||
}
|
||
}
|
||
}
|