feat: Divi path IDs, attribute paths and outline

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:35:03 +02:00
co-authored by Claude Opus 5.5
parent 8222240ed6
commit 08d70abfc7
6 changed files with 654 additions and 0 deletions
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\BlockTree;
/**
* 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+)*$/';
/** @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->name() === 'divi/placeholder') {
$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;
}
/** @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->name() === 'divi/placeholder') {
$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);
}
}
}
}