diff --git a/docs/superpowers/notes/2026-09-26-etappe-2a-abnahme.md b/docs/superpowers/notes/2026-09-26-etappe-2a-abnahme.md index 6049b32..7c7d684 100644 --- a/docs/superpowers/notes/2026-09-26-etappe-2a-abnahme.md +++ b/docs/superpowers/notes/2026-09-26-etappe-2a-abnahme.md @@ -54,8 +54,9 @@ Staging) verbinden und prüfen, dass die Antworten mit dem tatsächlichen Aufbau Überschriften/Texte/Links/Bilder liefern, die dem Frontend entsprechen. - [ ] Bei einer der zuvor im Visual Builder gebauten Seiten (Schritt 1): stichprobenartig prüfen, dass globales Modul, Preset, Dynamic Content und Spezialabschnitt im Outline/Node-Get sinnvoll - wiedergegeben werden (globales Modul und Spezialabschnitt ggf. als „nur lesbar“ markiert – das ist - in dieser Etappe so vorgesehen, siehe `divi-structure`-Guide). + wiedergegeben werden. Nur das globale Modul ist dabei „nur lesbar“ (siehe `divi-structure`-Guide); + ein Spezialabschnitt (specialty section) ist ein normaler, bearbeitbarer `divi/section`-Block – + Outline zeigt ihn lediglich mit dem Zusatz `[specialty]`, nicht als „nur lesbar“. ## Ergebnis diff --git a/src/Integrations/Divi/BlockTree/NodeSummary.php b/src/Integrations/Divi/BlockTree/NodeSummary.php index 8de562f..95da6d8 100644 --- a/src/Integrations/Divi/BlockTree/NodeSummary.php +++ b/src/Integrations/Divi/BlockTree/NodeSummary.php @@ -18,11 +18,19 @@ final class NodeSummary /** Keys of object values that carry readable text, in this order. */ private const TEXT_KEYS = ['text', 'title', 'label', 'alt', 'address', 'linkUrl', 'url', 'src']; + /** The admin label is a one-line title in the layers panel, never an unbounded copy. */ + private const LABEL_LIMIT = 80; + public static function label(Block $block): string { $label = AttrMap::desktop($block->attrs(), 'module.meta.adminLabel'); + if (! is_string($label) || $label === '') { + return ''; + } - return is_string($label) ? $label : ''; + $normalized = trim((string) preg_replace('/\s+/u', ' ', $label)); + + return Text::shorten($normalized, self::LABEL_LIMIT); } public static function excerpt(Block $block, int $max = 60): string diff --git a/src/Integrations/Divi/Pages/DiviNodeGetAbility.php b/src/Integrations/Divi/Pages/DiviNodeGetAbility.php index be8400a..20c3c9d 100644 --- a/src/Integrations/Divi/Pages/DiviNodeGetAbility.php +++ b/src/Integrations/Divi/Pages/DiviNodeGetAbility.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi\Pages; use LIT\MCP\Abilities\Ability; 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\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary; @@ -38,7 +39,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.'; + . '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 @@ -61,6 +64,7 @@ final class DiviNodeGetAbility implements Ability 'properties' => [ 'post_id' => ['type' => 'integer'], 'tree_rev' => ['type' => 'string'], + 'rev' => ['type' => 'string'], 'id' => ['type' => 'string'], 'parent' => ['type' => ['string', 'null']], 'type' => ['type' => 'string'], @@ -79,7 +83,7 @@ final class DiviNodeGetAbility implements Ability 'builder_version' => ['type' => ['string', 'null']], 'children' => ['type' => 'array', 'items' => ['type' => 'string']], ], - 'required' => ['post_id', 'tree_rev', 'id', 'type', 'editable', 'attrs', 'shortform', 'children'], + 'required' => ['post_id', 'tree_rev', 'rev', 'id', 'type', 'editable', 'attrs', 'shortform', 'children'], ]; } @@ -124,9 +128,10 @@ final class DiviNodeGetAbility implements Ability return [ 'post_id' => (int) $page->post->ID, 'tree_rev' => $page->treeRev(), + 'rev' => Rev::ofPost($page->post), 'id' => $id, 'parent' => $page->index->parentId($block), - 'type' => $block->name(), + 'type' => $block->isDivi() ? $block->shortName() : $block->name(), 'label' => NodeSummary::label($block), 'editable' => $editable, 'attrs' => JsonMap::of(AttrMap::flatten($block->attrs())), diff --git a/src/Integrations/Divi/Pages/DiviOutlineAbility.php b/src/Integrations/Divi/Pages/DiviOutlineAbility.php index 2587483..6407979 100644 --- a/src/Integrations/Divi/Pages/DiviOutlineAbility.php +++ b/src/Integrations/Divi/Pages/DiviOutlineAbility.php @@ -5,7 +5,9 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\Pages; use LIT\MCP\Abilities\Ability; +use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Rev; +use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\Outline; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use WP_Error; @@ -82,7 +84,9 @@ final class DiviOutlineAbility implements Ability 'editable' => ['type' => 'boolean'], 'depth' => ['type' => 'integer'], ], - 'required' => ['id', 'type', 'label', 'excerpt', 'editable', 'depth'], + 'required' => [ + 'id', 'type', 'label', 'excerpt', 'info', 'presets', 'children', 'editable', 'depth', + ], ], ], 'edit_url' => ['type' => 'string'], @@ -117,6 +121,9 @@ final class DiviOutlineAbility implements Ability } $rootId = isset($input['id']) && is_string($input['id']) && $input['id'] !== '' ? $input['id'] : null; + if ($rootId !== null && preg_match(NodeIndex::ID_PATTERN, $rootId) !== 1) { + return Errors::invalidInput('id ist keine Pfad-ID.', ['id' => 'z. B. "s0.r1.c0.m2"']); + } if ($rootId !== null && $page->index->find($rootId) === null) { return DiviPageLoader::unknownNode($rootId, $page->post); } diff --git a/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php b/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php index b66abc4..15b5090 100644 --- a/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php +++ b/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php @@ -5,6 +5,7 @@ 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\Block; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary; use LIT\MCP\Integrations\Divi\BlockTree\Outline; @@ -69,6 +70,24 @@ final class OutlineTest extends TestCase self::assertSame('', NodeSummary::textOf(new stdClass())); } + public function testLabelIsWhitespaceNormalizedAndBounded(): void + { + $block = self::blockWithAdminLabel("Hero \n Titel\twith space"); + self::assertSame('Hero Titel with space', NodeSummary::label($block)); + + $long = str_repeat('a', 200); + $shortened = NodeSummary::label(self::blockWithAdminLabel($long)); + self::assertLessThan(200, mb_strlen($shortened)); + self::assertStringEndsWith('…', $shortened); + } + + private static function blockWithAdminLabel(string $label): Block + { + return Block::create('divi/text', [ + 'module' => ['meta' => ['adminLabel' => ['desktop' => ['value' => $label]]]], + ]); + } + public function testAttrMapFlattenGetSetRemove(): void { $attrs = [ diff --git a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php index 76b52a5..6592f16 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php @@ -55,6 +55,17 @@ final class DiviReadAbilitiesTest extends TestCase self::assertStringContainsString('tree_rev', $missing->get_error_message()); } + public function testOutlineValidatesTheSubtreeId(): void + { + DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html')); + $ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design()); + + self::assertSame( + 'lit_validation_failed', + $ability->execute(['post_id' => 7, 'id' => 'hero'])->get_error_code() + ); + } + public function testOutlineHintsForEmptyAndReadOnlyContent(): void { DiviPageLoaderTest::givenPost(''); @@ -73,8 +84,9 @@ final class DiviReadAbilitiesTest extends TestCase $heading = $ability->execute(['post_id' => 7, 'id' => 's0.r0.c1.m0']); - self::assertSame('divi/heading', $heading['type']); + self::assertSame('heading', $heading['type']); self::assertSame('s0.r0.c1', $heading['parent']); + self::assertSame(12, strlen($heading['rev'])); self::assertSame( ['desktop' => ['value' => ['headingLevel' => 'h1', 'color' => '#1a1a1a']], 'phone' => ['value' => ['size' => '28px']]], $heading['attrs']['title.decoration.font.font']