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
+144
View File
@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\BlockTree;
use stdClass;
/**
* Divi 5 attributes addressed by dotted paths ("title.decoration.font.font"). The value at
* a path is a breakpoint map: {"desktop": {"value": …, "hover": …}, "phone": {"value": …}}.
* flatten() lists every such path – the full form lit-divi-node-get returns and
* lit-divi-update accepts (spec §5.4).
*/
final class AttrMap
{
public const BREAKPOINTS = ['desktop', 'tablet', 'phone', 'tabletWide', 'phoneWide', 'widescreen', 'ultraWide'];
/** Top-level keys that are no setting groups: versions, presets, global references. */
public const META_KEYS = [
'builderVersion', 'modulePreset', 'groupPreset', 'globalModule', 'globalParent', 'localAttrs',
];
/**
* @param array<string, mixed> $attrs
* @return array<string, mixed> Path => breakpoint map (or the raw value of an odd leaf).
*/
public static function flatten(array $attrs): array
{
$flat = [];
foreach ($attrs as $key => $value) {
if (! in_array($key, self::META_KEYS, true)) {
self::collect((string) $key, $value, $flat);
}
}
ksort($flat);
return $flat;
}
public static function isBreakpointMap(mixed $value): bool
{
if (! is_array($value) || $value === [] || array_is_list($value)) {
return false;
}
return array_diff(array_map('strval', array_keys($value)), self::BREAKPOINTS) === [];
}
/** @param array<string, mixed> $attrs */
public static function get(array $attrs, string $path): mixed
{
$value = $attrs;
foreach (explode('.', $path) as $segment) {
if (! is_array($value) || ! array_key_exists($segment, $value)) {
return null;
}
$value = $value[$segment];
}
return $value;
}
/**
* Desktop value of a path (state "value"), e.g. the text of "title.innerContent".
*
* @param array<string, mixed> $attrs
*/
public static function desktop(array $attrs, string $path): mixed
{
$map = self::get($attrs, $path);
return is_array($map) && is_array($map['desktop'] ?? null) ? ($map['desktop']['value'] ?? null) : null;
}
/**
* @param array<string, mixed> $attrs
* @return array<string, mixed>
*/
public static function set(array $attrs, string $path, mixed $value): array
{
$segments = explode('.', $path);
$key = (string) array_shift($segments);
if ($segments === []) {
$attrs[$key] = $value;
return $attrs;
}
$child = $attrs[$key] ?? [];
$child = $child instanceof stdClass ? (array) $child : (is_array($child) ? $child : []);
$attrs[$key] = self::set($child, implode('.', $segments), $value);
return $attrs;
}
/**
* Removes the value at $path and every parent that becomes empty.
*
* @param array<string, mixed> $attrs
* @return array<string, mixed>
*/
public static function remove(array $attrs, string $path): array
{
$segments = explode('.', $path);
$key = (string) array_shift($segments);
if (! array_key_exists($key, $attrs)) {
return $attrs;
}
if ($segments === []) {
unset($attrs[$key]);
return $attrs;
}
if (! is_array($attrs[$key])) {
return $attrs;
}
$child = self::remove($attrs[$key], implode('.', $segments));
if ($child === []) {
unset($attrs[$key]);
} else {
$attrs[$key] = $child;
}
return $attrs;
}
/** @param array<string, mixed> $flat */
private static function collect(string $path, mixed $value, array &$flat): void
{
if (self::isBreakpointMap($value) || ! is_array($value) || $value === [] || array_is_list($value)) {
$flat[$path] = $value;
return;
}
foreach ($value as $key => $item) {
self::collect($path . '.' . $key, $item, $flat);
}
}
}
@@ -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);
}
}
}
}
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\BlockTree;
/**
* Short human-readable facts about a node for the outline: admin label, first text,
* preset IDs, column layout. Pure PHP.
*/
final class NodeSummary
{
/** innerContent groups read first for the excerpt (then alphabetical). */
private const PRIORITY = ['title', 'name', 'author', 'content', 'button', 'number'];
/** Keys of object values that carry readable text, in this order. */
private const TEXT_KEYS = ['text', 'title', 'label', 'alt', 'address', 'linkUrl', 'url', 'src'];
public static function label(Block $block): string
{
$label = AttrMap::desktop($block->attrs(), 'module.meta.adminLabel');
return is_string($label) ? $label : '';
}
public static function excerpt(Block $block, int $max = 60): string
{
$paths = array_filter(
array_keys(AttrMap::flatten($block->attrs())),
static fn (string $path): bool => str_ends_with($path, '.innerContent')
);
usort($paths, static function (string $a, string $b): int {
return [self::rank($a), $a] <=> [self::rank($b), $b];
});
foreach ($paths as $path) {
$text = self::textOf(AttrMap::desktop($block->attrs(), $path));
if ($text !== '') {
return self::shorten($text, $max);
}
}
return '';
}
/** @return list<string> */
public static function presetIds(Block $block): array
{
$presets = $block->attrs()['modulePreset'] ?? [];
if (is_string($presets)) {
$presets = [$presets];
}
return is_array($presets)
? array_values(array_filter($presets, static fn (mixed $id): bool => is_string($id) && $id !== ''))
: [];
}
/** Layout facts: section type, column widths of a row (e.g. "8_24+16_24"). */
public static function info(Block $block): string
{
if ($block->name() === 'divi/section') {
$type = AttrMap::desktop($block->attrs(), 'module.advanced.type');
return is_string($type) && $type !== 'regular' ? $type : '';
}
if (in_array($block->name(), ['divi/row', 'divi/row-inner'], true)) {
$structure = AttrMap::desktop($block->attrs(), 'module.advanced.columnStructure');
$fallback = is_string($structure) ? explode(',', $structure) : [];
$widths = [];
foreach ($block->children() as $position => $column) {
$widths[] = self::columnWidth($column) ?? ($fallback[$position] ?? '24_24');
}
return $widths === [] ? '' : 'Spalten ' . implode('+', $widths);
}
return '';
}
/** flexType (Divi 5 flex layout) or legacy column type; null when the column sets neither. */
public static function columnWidth(Block $column): ?string
{
$sizing = AttrMap::desktop($column->attrs(), 'module.decoration.sizing');
if (is_array($sizing) && is_string($sizing['flexType'] ?? null)) {
return $sizing['flexType'];
}
$type = AttrMap::desktop($column->attrs(), 'module.advanced.type');
return is_string($type) ? $type : null;
}
/** Readable text of a value: HTML stripped, dynamic content shown as {name}. */
public static function textOf(mixed $value): string
{
if (is_array($value)) {
foreach (self::TEXT_KEYS as $key) {
$text = self::textOf($value[$key] ?? null);
if ($text !== '') {
return $text;
}
}
return '';
}
if (! is_string($value) && ! is_int($value) && ! is_float($value)) {
return '';
}
$value = (string) $value;
if (preg_match('/^\$variable\((.*)\)\$$/s', $value, $match) === 1) {
$data = json_decode($match[1], true);
$name = is_array($data) && is_string($data['value']['name'] ?? null) ? $data['value']['name'] : 'dynamisch';
return '{' . $name . '}';
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags -- pure PHP (spec §4.4).
$text = html_entity_decode(strip_tags($value), ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim((string) preg_replace('/\s+/u', ' ', $text));
}
private static function rank(string $path): int
{
$index = array_search(explode('.', $path)[0], self::PRIORITY, true);
return $index === false ? count(self::PRIORITY) : (int) $index;
}
private static function shorten(string $text, int $max): string
{
return mb_strlen($text) > $max ? rtrim(mb_substr($text, 0, $max - 1)) . '…' : $text;
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\BlockTree;
/**
* The page tree as rows and as compact text (lit-divi-outline, spec §5.3/§5.7; also the
* "outline" of write results). Preset IDs are shown with their names when known.
*/
final class Outline
{
/** @param array<string, string> $presetNames Preset ID => name. */
public function __construct(private array $presetNames = [])
{
}
/**
* @return list<array{id: string, type: string, label: string, excerpt: string, info: string,
* presets: list<string>, children: int, editable: bool, depth: int}>
*/
public function rows(NodeIndex $index, ?string $rootId = null, ?int $maxDepth = null): array
{
$rows = [];
$baseDepth = null;
foreach ($index->entries() as $entry) {
$id = $entry['id'];
if ($rootId !== null && $id !== $rootId && ! str_starts_with($id, $rootId . '.')) {
continue;
}
$baseDepth ??= $entry['depth'];
$depth = $entry['depth'] - $baseDepth;
if ($maxDepth !== null && $depth > $maxDepth) {
continue;
}
$rows[] = $this->row($entry['block'], $id, $depth);
}
return $rows;
}
/** @param list<array<string, mixed>> $rows Output of rows(). */
public function text(array $rows): string
{
$lines = [];
foreach ($rows as $row) {
$line = str_repeat(' ', (int) $row['depth']) . $row['id'] . ' ' . $row['type'];
$line .= $row['label'] !== '' ? ' "' . $row['label'] . '"' : '';
$line .= $row['info'] !== '' ? ' [' . $row['info'] . ']' : '';
$line .= $row['presets'] !== [] ? ' {Preset: ' . implode(', ', $row['presets']) . '}' : '';
$line .= $row['excerpt'] !== '' ? ' – ' . $row['excerpt'] : '';
$line .= $row['editable'] ? '' : ' (nur lesbar)';
$lines[] = $line;
}
return implode("\n", $lines);
}
/**
* @return array{id: string, type: string, label: string, excerpt: string, info: string,
* presets: list<string>, children: int, editable: bool, depth: int}
*/
private function row(Block $block, string $id, int $depth): array
{
$editable = $block->isDivi() && ! $block->isOpaque();
return [
'id' => $id,
'type' => $block->isDivi() ? $block->shortName() : $block->name(),
'label' => NodeSummary::label($block),
'excerpt' => NodeSummary::excerpt($block),
'info' => $editable ? NodeSummary::info($block) : '',
'presets' => array_map(
fn (string $presetId): string => $this->presetNames[$presetId] ?? $presetId,
NodeSummary::presetIds($block)
),
'children' => $editable ? count($block->children()) : 0,
'editable' => $editable,
'depth' => $depth,
];
}
}