Every file under src/ exits outside WordPress (defined('ABSPATH') ||
exit; after the use block, so a direct request no longer prints a fatal
error with the server path); divi-convert-fixture.php gets the WP_CLI
guard the other integration scripts have. A unit test keeps both true;
PSR1 side-effects warning excluded for the guard.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
149 lines
4.6 KiB
PHP
149 lines
4.6 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Path IDs of a Document (spec §5.3): s0, s0.r1, s0.r1.c0.m2. One letter per role –
|
||
* s section, r row / row-inner, c column / column-inner, m module (incl. group and child
|
||
* items), g global module or global layout, x non-Divi block – numbered per parent and letter.
|
||
* divi/placeholder wrappers are transparent (their children count as top level). Opaque and
|
||
* non-Divi blocks get an ID but are not descended into. IDs are only valid for the tree_rev
|
||
* they were read with; build a new index after every change.
|
||
*/
|
||
final class NodeIndex
|
||
{
|
||
public const ID_PATTERN = '/^[srcmgx]\d+(\.[srcmgx]\d+)*$/D';
|
||
|
||
/** @var array<string, Block> */
|
||
private array $byId = [];
|
||
|
||
/** @var array<int, string> spl_object_id(block) => id */
|
||
private array $ids = [];
|
||
|
||
/** @var array<int, Container> spl_object_id(block) => container holding it */
|
||
private array $parents = [];
|
||
|
||
/** @var list<array{id: string, block: Block, depth: int}> */
|
||
private array $entries = [];
|
||
|
||
private function __construct(private Container $root)
|
||
{
|
||
}
|
||
|
||
public static function build(Document $document): self
|
||
{
|
||
$root = $document;
|
||
foreach ($document->children() as $block) {
|
||
if ($block->isPlaceholder()) {
|
||
$root = $block;
|
||
break;
|
||
}
|
||
}
|
||
|
||
$index = new self($root);
|
||
$counters = [];
|
||
$index->walk($document, '', 0, $counters);
|
||
|
||
return $index;
|
||
}
|
||
|
||
public static function letter(Block $block): string
|
||
{
|
||
if ($block->isGlobal()) {
|
||
return 'g';
|
||
}
|
||
|
||
return match ($block->name()) {
|
||
'divi/section' => 's',
|
||
'divi/row', 'divi/row-inner' => 'r',
|
||
'divi/column', 'divi/column-inner' => 'c',
|
||
default => $block->isDivi() ? 'm' : 'x',
|
||
};
|
||
}
|
||
|
||
/** Container that receives new top-level sections: the first divi/placeholder, else the document. */
|
||
public function root(): Container
|
||
{
|
||
return $this->root;
|
||
}
|
||
|
||
public function find(string $id): ?Block
|
||
{
|
||
return $this->byId[$id] ?? null;
|
||
}
|
||
|
||
public function idOf(Block $block): ?string
|
||
{
|
||
return $this->ids[spl_object_id($block)] ?? null;
|
||
}
|
||
|
||
public function parentOf(Block $block): ?Container
|
||
{
|
||
return $this->parents[spl_object_id($block)] ?? null;
|
||
}
|
||
|
||
/** ID of the parent node, null for top-level nodes (their container is the root or a placeholder). */
|
||
public function parentId(Block $block): ?string
|
||
{
|
||
$parent = $this->parentOf($block);
|
||
|
||
return $parent instanceof Block ? $this->idOf($parent) : null;
|
||
}
|
||
|
||
/** The parent node of $block; null at the top level (the root or a placeholder holds it). */
|
||
public function parentBlock(Block $block): ?Block
|
||
{
|
||
$parent = $this->parentOf($block);
|
||
|
||
return $parent instanceof Block && ! $parent->isPlaceholder() ? $parent : null;
|
||
}
|
||
|
||
/** Admin label of the section around $block (or $block itself), for automatic labels ("" when none). */
|
||
public function sectionLabel(?Block $block): string
|
||
{
|
||
for ($node = $block; $node !== null; $node = $this->parentBlock($node)) {
|
||
if ($node->name() === 'divi/section') {
|
||
return NodeSummary::label($node);
|
||
}
|
||
}
|
||
|
||
return '';
|
||
}
|
||
|
||
/** @return list<array{id: string, block: Block, depth: int}> In document order. */
|
||
public function entries(): array
|
||
{
|
||
return $this->entries;
|
||
}
|
||
|
||
/** @param array<string, int> $counters */
|
||
private function walk(Container $container, string $prefix, int $depth, array &$counters): void
|
||
{
|
||
foreach ($container->children() as $block) {
|
||
if ($prefix === '' && $block->isPlaceholder()) {
|
||
$this->walk($block, '', $depth, $counters);
|
||
continue;
|
||
}
|
||
|
||
$letter = self::letter($block);
|
||
$number = $counters[$letter] ?? 0;
|
||
$counters[$letter] = $number + 1;
|
||
$id = ($prefix === '' ? '' : $prefix . '.') . $letter . $number;
|
||
|
||
$this->byId[$id] = $block;
|
||
$this->ids[spl_object_id($block)] = $id;
|
||
$this->parents[spl_object_id($block)] = $container;
|
||
$this->entries[] = ['id' => $id, 'block' => $block, 'depth' => $depth];
|
||
|
||
if ($block->isDivi() && ! $block->isOpaque()) {
|
||
$childCounters = [];
|
||
$this->walk($block, $id, $depth + 1, $childCounters);
|
||
}
|
||
}
|
||
}
|
||
}
|