From 08d70abfc774d87ab5ac31128133aa7a9798fa89 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 11:34:54 +0200 Subject: [PATCH] feat: Divi path IDs, attribute paths and outline Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Integrations/Divi/BlockTree/AttrMap.php | 144 ++++++++++++++++++ src/Integrations/Divi/BlockTree/NodeIndex.php | 126 +++++++++++++++ .../Divi/BlockTree/NodeSummary.php | 138 +++++++++++++++++ src/Integrations/Divi/BlockTree/Outline.php | 84 ++++++++++ .../Divi/BlockTree/NodeIndexTest.php | 60 ++++++++ .../Divi/BlockTree/OutlineTest.php | 102 +++++++++++++ 6 files changed, 654 insertions(+) create mode 100644 src/Integrations/Divi/BlockTree/AttrMap.php create mode 100644 src/Integrations/Divi/BlockTree/NodeIndex.php create mode 100644 src/Integrations/Divi/BlockTree/NodeSummary.php create mode 100644 src/Integrations/Divi/BlockTree/Outline.php create mode 100644 tests/Unit/Integrations/Divi/BlockTree/NodeIndexTest.php create mode 100644 tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php diff --git a/src/Integrations/Divi/BlockTree/AttrMap.php b/src/Integrations/Divi/BlockTree/AttrMap.php new file mode 100644 index 0000000..aa784f6 --- /dev/null +++ b/src/Integrations/Divi/BlockTree/AttrMap.php @@ -0,0 +1,144 @@ + $attrs + * @return array 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 $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 $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 $attrs + * @return array + */ + 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 $attrs + * @return array + */ + 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 $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); + } + } +} diff --git a/src/Integrations/Divi/BlockTree/NodeIndex.php b/src/Integrations/Divi/BlockTree/NodeIndex.php new file mode 100644 index 0000000..f982cad --- /dev/null +++ b/src/Integrations/Divi/BlockTree/NodeIndex.php @@ -0,0 +1,126 @@ + */ + private array $byId = []; + + /** @var array spl_object_id(block) => id */ + private array $ids = []; + + /** @var array spl_object_id(block) => container holding it */ + private array $parents = []; + + /** @var list */ + 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 In document order. */ + public function entries(): array + { + return $this->entries; + } + + /** @param array $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); + } + } + } +} diff --git a/src/Integrations/Divi/BlockTree/NodeSummary.php b/src/Integrations/Divi/BlockTree/NodeSummary.php new file mode 100644 index 0000000..55b167b --- /dev/null +++ b/src/Integrations/Divi/BlockTree/NodeSummary.php @@ -0,0 +1,138 @@ +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 */ + 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; + } +} diff --git a/src/Integrations/Divi/BlockTree/Outline.php b/src/Integrations/Divi/BlockTree/Outline.php new file mode 100644 index 0000000..332b293 --- /dev/null +++ b/src/Integrations/Divi/BlockTree/Outline.php @@ -0,0 +1,84 @@ + $presetNames Preset ID => name. */ + public function __construct(private array $presetNames = []) + { + } + + /** + * @return list, 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> $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, 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, + ]; + } +} diff --git a/tests/Unit/Integrations/Divi/BlockTree/NodeIndexTest.php b/tests/Unit/Integrations/Divi/BlockTree/NodeIndexTest.php new file mode 100644 index 0000000..4b050bd --- /dev/null +++ b/tests/Unit/Integrations/Divi/BlockTree/NodeIndexTest.php @@ -0,0 +1,60 @@ +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(''); + $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); + } +} diff --git a/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php b/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php new file mode 100644 index 0000000..b66abc4 --- /dev/null +++ b/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php @@ -0,0 +1,102 @@ +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); + } +}