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:
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class NodeIndexTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testAssignsPathIdsPerParentAndLetter(): void
|
||||||
|
{
|
||||||
|
$index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-basic.html')));
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
[
|
||||||
|
's0', 's0.r0', 's0.r0.c0', 's0.r0.c0.m0', 's0.r0.c1', 's0.r0.c1.m0', 's0.r0.c1.m1', 's0.r0.c1.m2',
|
||||||
|
's1', 's1.r0', 's1.r0.c0', 's1.r0.c0.m0', 's1.r0.c1', 's1.r0.c1.m0', 's1.r0.c1.m0.m0',
|
||||||
|
's1.r0.c1.m0.m1',
|
||||||
|
],
|
||||||
|
array_column($index->entries(), 'id')
|
||||||
|
);
|
||||||
|
self::assertSame('divi/button', $index->find('s0.r0.c1.m2')?->name());
|
||||||
|
self::assertSame('s0.r0.c1', $index->parentId($index->find('s0.r0.c1.m2')));
|
||||||
|
self::assertNull($index->parentId($index->find('s1')));
|
||||||
|
self::assertSame('divi/placeholder', $index->root() instanceof \LIT\MCP\Integrations\Divi\BlockTree\Block
|
||||||
|
? $index->root()->name() : '');
|
||||||
|
self::assertSame($index->root(), $index->parentOf($index->find('s0')));
|
||||||
|
self::assertNull($index->find('s9'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGlobalAndForeignBlocksAreLeavesWithOwnLetters(): void
|
||||||
|
{
|
||||||
|
$index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-mixed.html')));
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
['x0', 's0', 's0.c0', 's0.c0.m0', 's0.c1', 's0.c1.r0', 's0.c1.r0.c0', 's0.c1.r0.c0.m0', 's0.c1.r0.c1',
|
||||||
|
's1', 's1.g0', 's1.g1', 's1.g2'],
|
||||||
|
array_column($index->entries(), 'id')
|
||||||
|
);
|
||||||
|
self::assertSame(1, preg_match(NodeIndex::ID_PATTERN, 's1.g2'));
|
||||||
|
self::assertSame(0, preg_match(NodeIndex::ID_PATTERN, 's1..g2'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDocumentWithoutPlaceholderIsItsOwnRoot(): void
|
||||||
|
{
|
||||||
|
$document = (new Parser())->parse('<!-- wp:divi/section /-->');
|
||||||
|
$index = NodeIndex::build($document);
|
||||||
|
|
||||||
|
self::assertSame($document, $index->root());
|
||||||
|
self::assertSame('s0', $index->entries()[0]['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fixture(string $name): string
|
||||||
|
{
|
||||||
|
return (string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/' . $name);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Outline;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use stdClass;
|
||||||
|
|
||||||
|
final class OutlineTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testRowsAndTextOfAPage(): void
|
||||||
|
{
|
||||||
|
$index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-basic.html')));
|
||||||
|
$outline = new Outline(['a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' => 'Hero-Titel']);
|
||||||
|
|
||||||
|
$rows = $outline->rows($index);
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
['id' => 's0.r0.c1.m0', 'type' => 'heading', 'label' => '', 'excerpt' => 'Willkommen bei Müller & Söhne',
|
||||||
|
'info' => '', 'presets' => ['Hero-Titel'], 'children' => 0, 'editable' => true, 'depth' => 3],
|
||||||
|
$rows[5]
|
||||||
|
);
|
||||||
|
self::assertSame('Spalten 8_24+16_24', $rows[1]['info']);
|
||||||
|
self::assertSame('Seit 1987 zuverlässig – jetzt anfragen.', $rows[6]['excerpt']);
|
||||||
|
self::assertSame(
|
||||||
|
's0 section "Hero"' . "\n"
|
||||||
|
. ' s0.r0 row "Hero – Zeile" [Spalten 8_24+16_24]' . "\n"
|
||||||
|
. ' s0.r0.c0 column' . "\n"
|
||||||
|
. ' s0.r0.c0.m0 image "Hero – Bild" – Unser Team vor dem Büro',
|
||||||
|
implode("\n", array_slice(explode("\n", $outline->text($rows)), 0, 4))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSubtreeAndDepthLimit(): void
|
||||||
|
{
|
||||||
|
$index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-basic.html')));
|
||||||
|
$outline = new Outline();
|
||||||
|
|
||||||
|
$rows = $outline->rows($index, 's1.r0.c1', 1);
|
||||||
|
|
||||||
|
self::assertSame(['s1.r0.c1', 's1.r0.c1.m0'], array_column($rows, 'id'));
|
||||||
|
self::assertSame([0, 1], array_column($rows, 'depth'));
|
||||||
|
self::assertSame(2, $rows[1]['children']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReadOnlyNodesAreMarked(): void
|
||||||
|
{
|
||||||
|
$index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-mixed.html')));
|
||||||
|
$text = (new Outline())->text((new Outline())->rows($index));
|
||||||
|
|
||||||
|
self::assertStringContainsString('x0 core/paragraph (nur lesbar)', $text);
|
||||||
|
self::assertStringContainsString('s1.g1 row "Globale Zeile" (nur lesbar)', $text);
|
||||||
|
self::assertStringContainsString('s0 section [specialty]', $text);
|
||||||
|
self::assertStringContainsString('s0.c1.r0 row-inner [Spalten 1_2+1_2]', $text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSummaryHelpers(): void
|
||||||
|
{
|
||||||
|
self::assertSame('{post_title}', NodeSummary::textOf(
|
||||||
|
'$variable({"type":"content","value":{"name":"post_title","settings":{}}})$'
|
||||||
|
));
|
||||||
|
self::assertSame('Mehr', NodeSummary::textOf(['linkUrl' => '/x', 'text' => 'Mehr']));
|
||||||
|
self::assertSame('', NodeSummary::textOf(new stdClass()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAttrMapFlattenGetSetRemove(): void
|
||||||
|
{
|
||||||
|
$attrs = [
|
||||||
|
'builderVersion' => '5.2.1',
|
||||||
|
'title' => ['innerContent' => ['desktop' => ['value' => 'Hallo']], 'decoration' => ['font' => [
|
||||||
|
'font' => ['desktop' => ['value' => ['headingLevel' => 'h2']], 'phone' => ['value' => ['size' => '20px']]],
|
||||||
|
]]],
|
||||||
|
'module' => ['decoration' => ['spacing' => ['desktop' => ['value' => new stdClass()]]]],
|
||||||
|
];
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
['module.decoration.spacing', 'title.decoration.font.font', 'title.innerContent'],
|
||||||
|
array_keys(AttrMap::flatten($attrs))
|
||||||
|
);
|
||||||
|
self::assertSame('Hallo', AttrMap::desktop($attrs, 'title.innerContent'));
|
||||||
|
self::assertTrue(AttrMap::isBreakpointMap(['desktop' => [], 'phone' => []]));
|
||||||
|
self::assertFalse(AttrMap::isBreakpointMap(['desktop' => [], 'font' => []]));
|
||||||
|
|
||||||
|
$changed = AttrMap::set($attrs, 'module.decoration.spacing.desktop.value.padding', '10px');
|
||||||
|
self::assertSame('10px', AttrMap::get($changed, 'module.decoration.spacing.desktop.value.padding'));
|
||||||
|
|
||||||
|
$removed = AttrMap::remove($attrs, 'title.decoration.font.font');
|
||||||
|
self::assertArrayNotHasKey('decoration', $removed['title']);
|
||||||
|
self::assertSame($attrs, AttrMap::remove($attrs, 'nope.path'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fixture(string $name): string
|
||||||
|
{
|
||||||
|
return (string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/' . $name);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user