From c3f08e2d33a48409a1b22fccafbf9c89315de09e Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 18 Oct 2026 16:41:42 +0200 Subject: [PATCH] feat: Outline und Knoten zeigen Gruppen-Presets mit Namen Co-Authored-By: Claude Haiku 5.5 --- .../Divi/BlockTree/NodeSummary.php | 51 ++++++++++++-- src/Integrations/Divi/BlockTree/Outline.php | 43 +++++++++--- .../Divi/Pages/DiviNodeGetAbility.php | 51 +++++++++++++- .../Divi/Pages/DiviOutlineAbility.php | 12 +++- .../Divi/BlockTree/NodeSummaryTest.php | 67 +++++++++++++++++++ .../Divi/BlockTree/OutlineTest.php | 54 ++++++++++++++- .../Divi/Pages/DiviReadAbilitiesTest.php | 51 ++++++++++++++ 7 files changed, 308 insertions(+), 21 deletions(-) create mode 100644 tests/Unit/Integrations/Divi/BlockTree/NodeSummaryTest.php diff --git a/src/Integrations/Divi/BlockTree/NodeSummary.php b/src/Integrations/Divi/BlockTree/NodeSummary.php index 24da786..e503f6f 100644 --- a/src/Integrations/Divi/BlockTree/NodeSummary.php +++ b/src/Integrations/Divi/BlockTree/NodeSummary.php @@ -84,14 +84,37 @@ final class NodeSummary /** @return list */ public static function presetIds(Block $block): array { - $presets = $block->attrs()['modulePreset'] ?? []; - if (is_string($presets)) { - $presets = [$presets]; + return self::idList($block->attrs()['modulePreset'] ?? []); + } + + /** + * Option group presets of the block (formats §3 "Verweise in Blöcken"): groupPreset. holds + * {presetId: [id, …], groupName: "divi/"}. Empty IDs are dropped; an entry without a valid + * groupName or without any ID is left out. + * + * @return array}> groupId => entry + */ + public static function groupPresets(Block $block): array + { + $entries = $block->attrs()['groupPreset'] ?? []; + if (! is_array($entries)) { + return []; } - return is_array($presets) - ? array_values(array_filter($presets, static fn (mixed $id): bool => is_string($id) && $id !== '')) - : []; + $groups = []; + foreach ($entries as $groupId => $entry) { + if (! is_array($entry)) { + continue; + } + + $name = $entry['groupName'] ?? null; + $ids = self::idList($entry['presetId'] ?? []); + if (is_string($name) && $name !== '' && $ids !== []) { + $groups[(string) $groupId] = ['groupName' => $name, 'presetIds' => $ids]; + } + } + + return $groups; } /** Layout facts: section type, column widths of a row (e.g. "8_24+16_24"). */ @@ -162,6 +185,22 @@ final class NodeSummary return trim((string) preg_replace('/\s+/u', ' ', $text)); } + /** + * Preset IDs as Divi stores them: a list, or a single string (legacy). + * + * @return list + */ + private static function idList(mixed $ids): array + { + if (is_string($ids)) { + $ids = [$ids]; + } + + return is_array($ids) + ? array_values(array_filter($ids, static fn (mixed $id): bool => is_string($id) && $id !== '')) + : []; + } + private static function rank(string $path): int { $index = array_search(explode('.', $path)[0], self::PRIORITY, true); diff --git a/src/Integrations/Divi/BlockTree/Outline.php b/src/Integrations/Divi/BlockTree/Outline.php index 5a35327..bec980d 100644 --- a/src/Integrations/Divi/BlockTree/Outline.php +++ b/src/Integrations/Divi/BlockTree/Outline.php @@ -62,8 +62,8 @@ final class Outline /** * @return list, children: int, editable: bool, depth: int, truncated: bool, - * global_id: ?string}> + * presets: list, group_presets: list, children: int, editable: bool, depth: int, + * truncated: bool, global_id: ?string}> */ public function rows(NodeIndex $index, ?string $rootId = null, ?int $maxDepth = null, ?int $limit = null): array { @@ -121,6 +121,8 @@ final class Outline $line .= ($row['global_id'] ?? null) !== null ? ' [global ' . $row['global_id'] . ']' : ''; $line .= $row['info'] !== '' ? ' [' . $row['info'] . ']' : ''; $line .= $row['presets'] !== [] ? ' {Preset: ' . implode(', ', $row['presets']) . '}' : ''; + $groupPresets = $row['group_presets'] ?? []; + $line .= $groupPresets !== [] ? ' {Gruppen-Presets: ' . implode('; ', $groupPresets) . '}' : ''; $line .= $row['excerpt'] !== '' ? ' – ' . $row['excerpt'] : ''; $line .= $row['editable'] ? '' : ' (nur lesbar)'; $line .= ($row['truncated'] ?? false) ? ' (weitere Kinder abgeschnitten)' : ''; @@ -132,8 +134,8 @@ final class Outline /** * @return array{id: string, type: string, label: string, excerpt: string, info: string, - * presets: list, children: int, editable: bool, depth: int, truncated: bool, - * global_id: ?string} + * presets: list, group_presets: list, children: int, editable: bool, depth: int, + * truncated: bool, global_id: ?string} */ private function row(Block $block, string $id, int $depth): array { @@ -152,10 +154,8 @@ final class Outline '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) - ), + 'presets' => $this->namesOf(NodeSummary::presetIds($block)), + 'group_presets' => $this->groupPresetLines($block), 'children' => $editable ? count($block->children()) : 0, 'editable' => $editable, 'depth' => $depth, @@ -163,4 +163,31 @@ final class Outline 'global_id' => $this->globalIds ? ($global['id'] ?? null) : null, ]; } + + /** + * One entry per option group: ": [, ]", e.g. + * "font-body: Fließtext" (the short name is the key "group_presets" of the write tools takes). + * + * @return list + */ + private function groupPresetLines(Block $block): array + { + $lines = []; + foreach (NodeSummary::groupPresets($block) as $group) { + $name = $group['groupName']; + $short = str_starts_with($name, 'divi/') ? substr($name, 5) : $name; + $lines[] = $short . ': ' . implode(', ', $this->namesOf($group['presetIds'])); + } + + return $lines; + } + + /** + * @param list $presetIds + * @return list The preset names, the ID where the name is unknown. + */ + private function namesOf(array $presetIds): array + { + return array_map(fn (string $presetId): string => $this->presetNames[$presetId] ?? $presetId, $presetIds); + } } diff --git a/src/Integrations/Divi/Pages/DiviNodeGetAbility.php b/src/Integrations/Divi/Pages/DiviNodeGetAbility.php index c598068..719e0ec 100644 --- a/src/Integrations/Divi/Pages/DiviNodeGetAbility.php +++ b/src/Integrations/Divi/Pages/DiviNodeGetAbility.php @@ -10,6 +10,7 @@ use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\JsonMap; use LIT\MCP\Abilities\Rev; use LIT\MCP\Integrations\Divi\BlockTree\AttrMap; +use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; @@ -42,9 +43,9 @@ final class DiviNodeGetAbility implements Ability { return 'Liest einen Knoten der Divi-Seite vollständig: attrs als Attributpfad → Werte je Breakpoint ' . '(Vollform, z. B. "title.decoration.font.font": {"desktop": {"value": {…}}}), die Kurzform-Werte, ' - . 'Presets und die IDs der Kinder. Pfad-IDs aus lit-divi-outline. Werte wie $variable(…)$ (globale ' - . 'Farben, Variablen, dynamische Inhalte) werden unverändert angezeigt, beim Schreiben aber erneut ' - . 'geprüft, nie ungeprüft übernommen.'; + . 'Presets, Gruppen-Presets und die IDs der Kinder. Pfad-IDs aus lit-divi-outline. Werte wie ' + . '$variable(…)$ (globale Farben, Variablen, dynamische Inhalte) werden unverändert angezeigt, beim ' + . 'Schreiben aber erneut geprüft, nie ungeprüft übernommen.'; } public function inputSchema(): array @@ -83,6 +84,27 @@ final class DiviNodeGetAbility implements Ability 'required' => ['id', 'name'], ], ], + 'group_presets' => [ + 'type' => 'array', + 'description' => 'Gruppen-Presets des Moduls: group_id = Attributpfad der Gruppe, group = ' + . 'Kurzname (Schlüssel für group_presets).', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'group_id' => ['type' => 'string'], + 'group' => ['type' => 'string'], + 'presets' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => ['id' => ['type' => 'string'], 'name' => ['type' => 'string']], + 'required' => ['id', 'name'], + ], + ], + ], + 'required' => ['group_id', 'group', 'presets'], + ], + ], 'builder_version' => ['type' => ['string', 'null']], 'children' => ['type' => 'array', 'items' => ['type' => 'string']], 'global' => ['type' => ['object', 'null']], @@ -163,6 +185,7 @@ final class DiviNodeGetAbility implements Ability static fn (string $presetId): array => ['id' => $presetId, 'name' => $names[$presetId] ?? ''], NodeSummary::presetIds($block) ), + 'group_presets' => $this->groupPresets($block, $names), 'builder_version' => is_string($version) ? $version : null, 'children' => $editable ? array_values(array_filter(array_map([$page->index, 'idOf'], $block->children()))) @@ -170,4 +193,26 @@ final class DiviNodeGetAbility implements Ability 'global' => $global, ]; } + + /** + * @param array $names Preset ID => name. + * @return list}> + */ + private function groupPresets(Block $block, array $names): array + { + $groups = []; + foreach (NodeSummary::groupPresets($block) as $groupId => $group) { + $name = $group['groupName']; + $groups[] = [ + 'group_id' => $groupId, + 'group' => str_starts_with($name, 'divi/') ? substr($name, 5) : $name, + 'presets' => array_map( + static fn (string $presetId): array => ['id' => $presetId, 'name' => $names[$presetId] ?? ''], + $group['presetIds'] + ), + ]; + } + + return $groups; + } } diff --git a/src/Integrations/Divi/Pages/DiviOutlineAbility.php b/src/Integrations/Divi/Pages/DiviOutlineAbility.php index f11e340..acb4e51 100644 --- a/src/Integrations/Divi/Pages/DiviOutlineAbility.php +++ b/src/Integrations/Divi/Pages/DiviOutlineAbility.php @@ -38,7 +38,7 @@ final class DiviOutlineAbility implements Ability public function description(): string { return 'Liest den Aufbau einer Divi-Seite: Knoten mit Pfad-ID (s0, s0.r1, s0.r1.c0.m2), Modultyp, ' - . 'Admin-Label, Textauszug und Preset. Liefert tree_rev – Pflicht für jede Divi-Änderung. ' + . 'Admin-Label, Textauszug, Preset und Gruppen-Presets. Liefert tree_rev – Pflicht für jede Divi-Änderung. ' . 'Optional nur ein Teilbaum (id), begrenzte Tiefe (depth) oder begrenzte Breite je Ebene (limit); ' . 'abgeschnittene Kinder zeigt "truncated".'; } @@ -91,6 +91,12 @@ final class DiviOutlineAbility implements Ability 'excerpt' => ['type' => 'string'], 'info' => ['type' => 'string'], 'presets' => ['type' => 'array', 'items' => ['type' => 'string']], + 'group_presets' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + 'description' => 'Gruppen-Presets des Moduls als ": [, ]", ' + . 'z. B. "font-body: Fließtext". Die Gruppe ist der Schlüssel für group_presets.', + ], 'children' => ['type' => 'integer'], 'editable' => ['type' => 'boolean'], 'depth' => ['type' => 'integer'], @@ -104,8 +110,8 @@ final class DiviOutlineAbility implements Ability ], ], 'required' => [ - 'id', 'type', 'label', 'excerpt', 'info', 'presets', 'children', 'editable', 'depth', - 'truncated', 'global_id', + 'id', 'type', 'label', 'excerpt', 'info', 'presets', 'group_presets', 'children', + 'editable', 'depth', 'truncated', 'global_id', ], ], ], diff --git a/tests/Unit/Integrations/Divi/BlockTree/NodeSummaryTest.php b/tests/Unit/Integrations/Divi/BlockTree/NodeSummaryTest.php new file mode 100644 index 0000000..f7b0015 --- /dev/null +++ b/tests/Unit/Integrations/Divi/BlockTree/NodeSummaryTest.php @@ -0,0 +1,67 @@ + [ + 'content.decoration.bodyFont' => ['presetId' => ['ja6o54jtlc'], 'groupName' => 'divi/font-body'], + 'module.decoration.spacing' => ['presetId' => ['apzj7e17ok', 'b2b2b2b2b2'], 'groupName' => 'divi/spacing'], + ]]); + + self::assertSame( + [ + 'content.decoration.bodyFont' => ['groupName' => 'divi/font-body', 'presetIds' => ['ja6o54jtlc']], + 'module.decoration.spacing' => [ + 'groupName' => 'divi/spacing', + 'presetIds' => ['apzj7e17ok', 'b2b2b2b2b2'], + ], + ], + NodeSummary::groupPresets($block) + ); + } + + public function testPresetIdAsStringIsAccepted(): void + { + $block = Block::create('divi/text', ['groupPreset' => [ + 'content.decoration.bodyFont' => ['presetId' => 'ja6o54jtlc', 'groupName' => 'divi/font-body'], + ]]); + + self::assertSame( + ['content.decoration.bodyFont' => ['groupName' => 'divi/font-body', 'presetIds' => ['ja6o54jtlc']]], + NodeSummary::groupPresets($block) + ); + } + + public function testEmptyIdsAreFilteredAndBrokenEntriesLeftOut(): void + { + $block = Block::create('divi/text', ['groupPreset' => [ + 'a.one' => ['presetId' => ['', 'x1', 7, null], 'groupName' => 'divi/font'], + 'a.two' => ['presetId' => [''], 'groupName' => 'divi/font'], + 'a.three' => ['presetId' => ['x2']], + 'a.four' => ['presetId' => ['x3'], 'groupName' => ''], + 'a.five' => ['presetId' => ['x4'], 'groupName' => ['divi/font']], + 'a.six' => 'kein Eintrag', + ]]); + + self::assertSame( + ['a.one' => ['groupName' => 'divi/font', 'presetIds' => ['x1']]], + NodeSummary::groupPresets($block) + ); + } + + public function testBlockWithoutOrWithBrokenGroupPreset(): void + { + self::assertSame([], NodeSummary::groupPresets(Block::create('divi/text'))); + self::assertSame([], NodeSummary::groupPresets(Block::create('divi/text', ['groupPreset' => 'x']))); + self::assertSame([], NodeSummary::groupPresets(Block::create('divi/text', ['groupPreset' => []]))); + } +} diff --git a/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php b/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php index 1d6b4fc..89341eb 100644 --- a/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php +++ b/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree; use LIT\MCP\Integrations\Divi\BlockTree\AttrMap; use LIT\MCP\Integrations\Divi\BlockTree\Block; +use LIT\MCP\Integrations\Divi\BlockTree\Document; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary; use LIT\MCP\Integrations\Divi\BlockTree\Outline; @@ -26,7 +27,8 @@ final class OutlineTest extends TestCase 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, + 'info' => '', 'presets' => ['Hero-Titel'], 'group_presets' => [], 'children' => 0, + 'editable' => true, 'depth' => 3, 'truncated' => false, 'global_id' => null], $rows[5] ); @@ -41,6 +43,56 @@ final class OutlineTest extends TestCase ); } + public function testGroupPresetsAreShownWithNamesInRowAndText(): void + { + $content = DiviFixtures::markup('stage4/page--designsystem.html'); + $outline = new Outline([ + 'vdcl6msnuo' => '[Fixture] Blurb Leistung', + 'ja6o54jtlc' => '[Fixture] Fließtext', + 'apzj7e17ok' => '[Fixture] Abstand Karte', + ]); + + $rows = $outline->rows(NodeIndex::build((new Parser())->parse($content))); + $byId = array_column($rows, null, 'id'); + + self::assertSame([], $byId['s0.r0.c0.m1']['group_presets']); + self::assertSame(['font-body: [Fixture] Fließtext'], $byId['s0.r0.c0.m2']['group_presets']); + self::assertSame(['spacing: [Fixture] Abstand Karte'], $byId['s0.r0.c0.m5']['group_presets']); + self::assertSame(['[Fixture] Blurb Leistung'], $byId['s0.r0.c0.m5']['presets']); + + $lines = explode("\n", $outline->text($rows)); + self::assertStringContainsString( + 's0.r0.c0.m2 text "Text mit Gruppen-Preset" {Gruppen-Presets: font-body: [Fixture] Fließtext} – ', + $lines[5] + ); + self::assertStringContainsString( + 's0.r0.c0.m5 blurb "Blurb mit Modul- und Gruppen-Preset" {Preset: [Fixture] Blurb Leistung} ' + . '{Gruppen-Presets: spacing: [Fixture] Abstand Karte} – Möbel nach Maß', + $lines[8] + ); + self::assertStringNotContainsString('Gruppen-Presets', $lines[3]); + } + + public function testUnknownGroupPresetFallsBackToTheIdAndStacksAreListed(): void + { + $block = Block::create('divi/blurb', ['groupPreset' => [ + 'module.decoration.spacing' => ['presetId' => ['a1', 'b2'], 'groupName' => 'divi/spacing'], + 'title.decoration.font' => ['presetId' => ['c3'], 'groupName' => 'divi/font'], + ]]); + $document = new Document(); + $document->append($block); + $index = NodeIndex::build($document); + $outline = new Outline(['a1' => 'Abstand Karte']); + + $rows = $outline->rows($index); + + self::assertSame(['spacing: Abstand Karte, b2', 'font: c3'], $rows[0]['group_presets']); + self::assertStringContainsString( + '{Gruppen-Presets: spacing: Abstand Karte, b2; font: c3}', + $outline->text($rows) + ); + } + public function testGlobalInstancesShowTheirLibraryIdAndRootType(): void { $content = DiviFixtures::markup('stage3/page--globale-instanzen.html'); diff --git a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php index 612e702..6d7f89b 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages; +use LIT\MCP\Integrations\Divi\Design\DesignSystem; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Library\LibraryAccess; use LIT\MCP\Integrations\Divi\Pages\DiviNodeGetAbility; @@ -25,6 +26,56 @@ final class DiviReadAbilitiesTest extends TestCase return $reader; } + /** The stage 4 preset inventory (tests/Fixtures/divi/stage4/presets.json) as the design system reads it. */ + private function designWithGroupPresets(): DesignSystemReader + { + $stored = json_decode(DiviFixtures::markup('stage4/presets.json'), true)['stored']; + $reader = Mockery::mock(DesignSystemReader::class); + $reader->allows('read')->andReturn(DesignSystem::fromDivi([], [], $stored, [])); + + return $reader; + } + + public function testOutlineShowsGroupPresetsWithNames(): void + { + DiviFixtures::givenPost(DiviFixtures::markup('stage4/page--designsystem.html')); + $ability = new DiviOutlineAbility(new DiviPageLoader(DiviFixtures::schema()), $this->designWithGroupPresets()); + + $result = $ability->execute(['post_id' => 7]); + $byId = array_column($result['nodes'], null, 'id'); + + self::assertSame([], $byId['s0.r0.c0.m1']['group_presets']); + self::assertSame(['[Fixture] Text Hinweis'], $byId['s0.r0.c0.m1']['presets']); + self::assertSame(['font-body: [Fixture] Fließtext'], $byId['s0.r0.c0.m2']['group_presets']); + self::assertSame(['spacing: [Fixture] Abstand Karte'], $byId['s0.r0.c0.m5']['group_presets']); + self::assertStringContainsString( + '{Preset: [Fixture] Blurb Leistung} {Gruppen-Presets: spacing: [Fixture] Abstand Karte}', + $result['outline'] + ); + self::assertContains('group_presets', $ability->outputSchema()['properties']['nodes']['items']['required']); + } + + public function testNodeGetListsGroupPresetsWithGroupAndNames(): void + { + DiviFixtures::givenPost(DiviFixtures::markup('stage4/page--designsystem.html')); + $ability = new DiviNodeGetAbility(new DiviPageLoader(DiviFixtures::schema()), $this->designWithGroupPresets()); + + $blurb = $ability->execute(['post_id' => 7, 'id' => 's0.r0.c0.m5']); + $plain = $ability->execute(['post_id' => 7, 'id' => 's0.r0.c0.m3']); + + self::assertSame( + [[ + 'group_id' => 'module.decoration.spacing', + 'group' => 'spacing', + 'presets' => [['id' => 'apzj7e17ok', 'name' => '[Fixture] Abstand Karte']], + ]], + $blurb['group_presets'] + ); + self::assertSame([['id' => 'vdcl6msnuo', 'name' => '[Fixture] Blurb Leistung']], $blurb['presets']); + self::assertSame([], $plain['group_presets']); + self::assertArrayNotHasKey('groupPreset', $blurb['attrs']); + } + public function testOutlineListsNodesWithTreeRevAndPostRev(): void { $content = DiviFixtures::markup('synthetic-basic.html');