feat: design-system filters and global module ids in node-get/outline (S19)

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-27 00:44:58 +02:00
co-authored by Claude Opus 5.5
parent 858aa6a182
commit a3a807051f
8 changed files with 187 additions and 19 deletions
@@ -25,7 +25,10 @@ final class NodeSummary
public static function label(Block $block): string
{
$label = AttrMap::desktop($block->attrs(), 'module.meta.adminLabel');
$attrs = $block->name() === 'divi/global-layout' && is_array($block->attrs()['localAttrs'] ?? null)
? $block->attrs()['localAttrs']
: $block->attrs();
$label = AttrMap::desktop($attrs, 'module.meta.adminLabel');
if (! is_string($label) || $label === '') {
return '';
}
@@ -55,6 +58,29 @@ final class NodeSummary
return '';
}
/**
* Library entry of a global module: a divi/global-layout instance (its root type in blockName)
* or a block that carries globalModule itself (Divi 4 conversions). Null for every other block.
*
* @return array{id: string, block_name: string}|null
*/
public static function globalRef(Block $block): ?array
{
if (! $block->isGlobal()) {
return null;
}
$attrs = $block->attrs();
$id = $attrs['globalModule'] ?? null;
if (! is_string($id) && ! is_int($id)) {
return null;
}
$name = $block->name() === 'divi/global-layout' ? ($attrs['blockName'] ?? '') : $block->name();
return ['id' => (string) $id, 'block_name' => is_string($name) ? $name : ''];
}
/** @return list<string> */
public static function presetIds(Block $block): array
{
+14 -3
View File
@@ -59,7 +59,8 @@ final class Outline
/**
* @return list<array{id: string, type: string, label: string, excerpt: string, info: string,
* presets: list<string>, children: int, editable: bool, depth: int, truncated: bool}>
* presets: list<string>, 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
{
@@ -114,6 +115,7 @@ final class Outline
foreach ($rows as $row) {
$line = str_repeat(' ', (int) $row['depth']) . $row['id'] . ' ' . $row['type'];
$line .= $row['label'] !== '' ? ' "' . $row['label'] . '"' : '';
$line .= ($row['global_id'] ?? null) !== null ? ' [global ' . $row['global_id'] . ']' : '';
$line .= $row['info'] !== '' ? ' [' . $row['info'] . ']' : '';
$line .= $row['presets'] !== [] ? ' {Preset: ' . implode(', ', $row['presets']) . '}' : '';
$line .= $row['excerpt'] !== '' ? ' – ' . $row['excerpt'] : '';
@@ -127,15 +129,23 @@ final class Outline
/**
* @return array{id: string, type: string, label: string, excerpt: string, info: string,
* presets: list<string>, children: int, editable: bool, depth: int, truncated: bool}
* presets: list<string>, children: int, editable: bool, depth: int, truncated: bool,
* global_id: ?string}
*/
private function row(Block $block, string $id, int $depth): array
{
$editable = $block->isDivi() && ! $block->isOpaque();
$global = NodeSummary::globalRef($block);
$type = $block->isDivi() ? $block->shortName() : $block->name();
if ($global !== null && $global['block_name'] !== '') {
$type = str_starts_with($global['block_name'], 'divi/')
? substr($global['block_name'], 5)
: $global['block_name'];
}
return [
'id' => $id,
'type' => $block->isDivi() ? $block->shortName() : $block->name(),
'type' => $type,
'label' => NodeSummary::label($block),
'excerpt' => NodeSummary::excerpt($block),
'info' => $editable ? NodeSummary::info($block) : '',
@@ -147,6 +157,7 @@ final class Outline
'editable' => $editable,
'depth' => $depth,
'truncated' => false,
'global_id' => $global['id'] ?? null,
];
}
}
@@ -18,6 +18,11 @@ defined('ABSPATH') || exit;
*/
final class DiviDesignSystemAbility implements Ability
{
/** Presets per answer (ruling S19); narrow with "module". */
public const PRESET_LIMIT = 200;
private const PARTS = ['colors', 'variables', 'presets'];
public function __construct(private DesignSystemReader $reader)
{
}
@@ -36,12 +41,28 @@ final class DiviDesignSystemAbility implements Ability
{
return 'Liest das Designsystem der Website aus Divi: globale Farben, globale Variablen, Theme-Schriften und '
. '-Größen, Modul- und Gruppen-Presets – jeweils mit dem Platzhalter ({{color:…}}, {{var:…}}, '
. '{{font:…}}, preset), den neue Inhalte statt fester Werte verwenden sollen.';
. '{{font:…}}, preset), den neue Inhalte statt fester Werte verwenden sollen. '
. 'Filter: only (colors/variables/presets) und module (Presets eines Moduls).';
}
public function inputSchema(): array
{
return ['type' => 'object', 'properties' => [], 'additionalProperties' => false];
return [
'type' => 'object',
'properties' => [
'only' => [
'type' => 'array',
'items' => ['type' => 'string', 'enum' => self::PARTS],
'description' => 'Nur diese Teile liefern (Standard: alle). Schriften und Größen sind immer '
. 'dabei.',
],
'module' => [
'type' => 'string',
'description' => 'Nur Presets dieses Moduls, z. B. "button" oder "divi/button".',
],
],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
@@ -110,26 +131,52 @@ final class DiviDesignSystemAbility implements Ability
public function execute(array $input): mixed
{
$design = $this->reader->read();
$only = is_array($input['only'] ?? null) && $input['only'] !== []
? array_values(array_intersect(self::PARTS, $input['only']))
: self::PARTS;
$notes = [
'Schriften: {{font:heading}} bzw. {{font:body}}; Presets in der Kurzform über "preset": "<Name>".',
'Feste Farben oder Schriften nur verwenden, wenn der Nutzer es ausdrücklich möchte.',
];
$skipped = array_values(array_diff(self::PARTS, $only));
if ($skipped !== []) {
$notes[] = 'Nicht angefordert: ' . implode(', ', $skipped) . '.';
}
$presets = $design->presets();
$module = is_string($input['module'] ?? null) ? trim($input['module']) : '';
if ($module !== '') {
$name = str_contains($module, '/') ? $module : 'divi/' . $module;
$presets = array_values(array_filter(
$presets,
static fn (array $preset): bool => $preset['module'] === $name
));
}
if (count($presets) > self::PRESET_LIMIT) {
$notes[] = sprintf(
'%d von %d Presets gezeigt; mit module auf ein Modul eingrenzen.',
self::PRESET_LIMIT,
count($presets)
);
$presets = array_slice($presets, 0, self::PRESET_LIMIT);
}
return [
'colors' => array_map(
'colors' => in_array('colors', $only, true) ? array_map(
static fn (array $color): array => $color + [
'placeholder' => DesignSystem::colorPlaceholder($color['id'], $color['label']),
],
$design->colors()
),
'variables' => array_map(
) : [],
'variables' => in_array('variables', $only, true) ? array_map(
static fn (array $variable): array => $variable
+ ['placeholder' => '{{var:' . $variable['label'] . '}}'],
$design->variables()
),
) : [],
'fonts' => $design->fonts(),
'sizes' => $design->sizes(),
'presets' => $design->presets(),
'notes' => [
'Schriften: {{font:heading}} bzw. {{font:body}}; Presets in der Kurzform über "preset": "<Name>".',
'Feste Farben oder Schriften nur verwenden, wenn der Nutzer es ausdrücklich möchte.',
],
'presets' => in_array('presets', $only, true) ? $presets : [],
'notes' => $notes,
];
}
}
@@ -85,6 +85,7 @@ final class DiviNodeGetAbility implements Ability
],
'builder_version' => ['type' => ['string', 'null']],
'children' => ['type' => 'array', 'items' => ['type' => 'string']],
'global' => ['type' => ['object', 'null']],
],
'required' => ['post_id', 'tree_rev', 'rev', 'id', 'type', 'editable', 'attrs', 'shortform', 'children'],
];
@@ -128,6 +129,16 @@ final class DiviNodeGetAbility implements Ability
$editable = $block->isDivi() && ! $block->isOpaque();
$version = $block->attrs()['builderVersion'] ?? null;
$global = NodeSummary::globalRef($block);
if ($global !== null) {
$global['hint'] = sprintf(
'Globales Modul: Inhalt im Bibliothekseintrag %s bearbeiten (lit-divi-outline mit post_id %s); '
. 'jede Änderung dort wirkt auf alle Seiten mit diesem Modul.',
$global['id'],
$global['id']
);
}
return [
'post_id' => (int) $page->post->ID,
'tree_rev' => $page->treeRev(),
@@ -147,6 +158,7 @@ final class DiviNodeGetAbility implements Ability
'children' => $editable
? array_values(array_filter(array_map([$page->index, 'idOf'], $block->children())))
: [],
'global' => $global,
];
}
}
@@ -98,10 +98,14 @@ final class DiviOutlineAbility implements Ability
'type' => 'boolean',
'description' => 'Weitere Kinder dieses Knotens wurden wegen "limit" abgeschnitten.',
],
'global_id' => [
'type' => ['string', 'null'],
'description' => 'Bibliotheks-ID eines globalen Moduls.',
],
],
'required' => [
'id', 'type', 'label', 'excerpt', 'info', 'presets', 'children', 'editable', 'depth',
'truncated',
'truncated', 'global_id',
],
],
],
@@ -187,7 +191,8 @@ final class DiviOutlineAbility implements Ability
if (in_array(false, array_column($rows, 'editable'), true)) {
$hints[] = 'Knoten mit "nur lesbar" (globale Module, fremde Blöcke) lassen sich nur als Ganzes '
. 'verschieben, duplizieren oder löschen; ihren Inhalt im Divi-Builder bearbeiten.';
. 'verschieben, duplizieren oder löschen. Globale Module [global <ID>] im Bibliothekseintrag '
. 'bearbeiten: lit-divi-outline mit post_id <ID> – die Änderung wirkt auf alle Seiten.';
}
return $hints;
@@ -10,6 +10,7 @@ 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 LIT\MCP\Tests\Support\DiviFixtures;
use PHPUnit\Framework\TestCase;
use stdClass;
@@ -26,7 +27,7 @@ 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,
'truncated' => false],
'truncated' => false, 'global_id' => null],
$rows[5]
);
self::assertSame('Spalten 8_24+16_24', $rows[1]['info']);
@@ -40,6 +41,22 @@ final class OutlineTest extends TestCase
);
}
public function testGlobalInstancesShowTheirLibraryIdAndRootType(): void
{
$content = DiviFixtures::markup('stage3/page--globale-instanzen.html');
$outline = new Outline();
$rows = $outline->rows(NodeIndex::build((new Parser())->parse($content)));
$byId = array_column($rows, null, 'id');
self::assertSame('1058', $byId['g0']['global_id']);
self::assertSame('section', $byId['g0']['type']);
self::assertFalse($byId['g0']['editable']);
self::assertSame('1059', $byId['s0.r0.c0.g0']['global_id']);
self::assertSame('text', $byId['s0.r0.c0.g0']['type']);
self::assertNull($byId['s0']['global_id']);
self::assertStringContainsString('g0 section "Global Abschnitt" [global 1058]', $outline->text($rows));
}
public function testSubtreeAndDepthLimit(): void
{
$index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-basic.html')));
@@ -104,7 +121,7 @@ final class OutlineTest extends TestCase
$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('s1.g1 row "Globale Zeile" [global 913] (nur lesbar)', $text);
self::assertStringContainsString('s0 section [specialty]', $text);
self::assertStringContainsString('s0.c1.r0 row-inner [Spalten 1_2+1_2]', $text);
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility;
use LIT\MCP\Tests\Support\DiviFixtures;
@@ -41,4 +42,38 @@ final class DiviDesignSystemAbilityTest extends TestCase
self::assertToolError($result, 'forbidden');
}
public function testFiltersPartsAndPresetsOfOneModule(): void
{
Functions\when('current_user_can')->justReturn(true);
$reader = Mockery::mock(DesignSystemReader::class);
$reader->allows('read')->andReturn(DiviFixtures::designSystem());
$ability = new DiviDesignSystemAbility($reader);
$result = $ability->execute(['only' => ['presets'], 'module' => 'button']);
self::assertSame([], $result['colors']);
self::assertSame([], $result['variables']);
self::assertSame(['p-1', 'p-2'], array_column($result['presets'], 'id'));
self::assertSame(['divi/button'], array_values(array_unique(array_column($result['presets'], 'module'))));
self::assertNotEmpty($result['fonts']['heading']);
self::assertContains('Nicht angefordert: colors, variables.', $result['notes']);
}
public function testCapsThePresetList(): void
{
Functions\when('current_user_can')->justReturn(true);
$items = [];
for ($i = 0; $i < 250; ++$i) {
$items['p' . $i] = ['id' => 'p' . $i, 'name' => 'Preset ' . $i, 'moduleName' => 'divi/text'];
}
$design = DesignSystem::fromDivi([], [], ['module' => ['divi/text' => ['default' => '', 'items' => $items]]], []);
$reader = Mockery::mock(DesignSystemReader::class);
$reader->allows('read')->andReturn($design);
$result = (new DiviDesignSystemAbility($reader))->execute([]);
self::assertCount(DiviDesignSystemAbility::PRESET_LIMIT, $result['presets']);
self::assertContains('200 von 250 Presets gezeigt; mit module auf ein Modul eingrenzen.', $result['notes']);
}
}
@@ -123,4 +123,19 @@ final class DiviReadAbilitiesTest extends TestCase
self::assertToolError($ability->execute(['post_id' => 7, 'id' => 'hero']), 'validation_failed');
self::assertToolError($ability->execute(['post_id' => 7, 'id' => 's5.r0']), 'not_found');
}
public function testNodeGetNamesTheLibraryEntryOfAGlobalModule(): void
{
DiviFixtures::givenPost(DiviFixtures::markup('stage3/page--globale-instanzen.html'));
$ability = new DiviNodeGetAbility(new DiviPageLoader(DiviFixtures::schema()), $this->design());
$global = $ability->execute(['post_id' => 7, 'id' => 'g0']);
$plain = $ability->execute(['post_id' => 7, 'id' => 's0']);
self::assertSame('1058', $global['global']['id']);
self::assertSame('divi/section', $global['global']['block_name']);
self::assertStringContainsString('post_id 1058', $global['global']['hint']);
self::assertFalse($global['editable']);
self::assertNull($plain['global']);
}
}