diff --git a/src/Guides/InstructionsBuilder.php b/src/Guides/InstructionsBuilder.php index 0594612..3e3784a 100644 --- a/src/Guides/InstructionsBuilder.php +++ b/src/Guides/InstructionsBuilder.php @@ -13,8 +13,12 @@ use LIT\MCP\Integrations\IntegrationRegistry; */ final class InstructionsBuilder { - public function __construct(private IntegrationRegistry $registry) + /** @var (callable(): list)|null */ + private $postTypeNames; + + public function __construct(private IntegrationRegistry $registry, ?callable $postTypeNames = null) { + $this->postTypeNames = $postTypeNames; } public function build(): string @@ -24,17 +28,27 @@ final class InstructionsBuilder $this->registry->available() )); - return implode("\n", [ + $lines = [ 'LIT MCP pflegt diese WordPress-Website (Inhalte, Medien, Divi 5, Pods).', 'Aktive Integrationen: ' . $active . '.', + ]; + + if ($this->postTypeNames !== null) { + $lines[] = 'Inhaltstypen: ' . implode(', ', ($this->postTypeNames)()) + . ' (Felder und Taxonomien: lit-content-types).'; + } + + return implode("\n", array_merge($lines, [ 'Regeln:', '- Vor dem ersten Arbeitsschritt lit-guide (ohne Topic) aufrufen; Details je Thema über lit-guide ' . 'mit topic.', '- Änderungen erzeugen native Divi-Module; Variablen und Presets vor festen Werten; Code-Modul nur, ' . 'wenn kein natives Modul passt.', - '- Den Status (Entwurf/veröffentlicht) nie ändern, außer der Nutzer verlangt es ausdrücklich.', - '- Schreib-Tools verlangen den aktuellen rev-Wert aus dem letzten Lesevorgang.', + '- Den Status (Entwurf/veröffentlicht) nie ändern, außer der Nutzer verlangt es ausdrücklich; ' + . 'neue Inhalte entstehen als Entwurf.', + '- Beim Bearbeiten immer den rev-Wert aus dem letzten Lesevorgang mitgeben (Pflicht).', + '- Löschen und Ersetzen erst nach Rückfrage beim Nutzer (confirm: true).', '- Fehler enthalten einen hint – ihm folgen, statt blind zu wiederholen.', - ]); + ])); } } diff --git a/src/Integrations/WordPress/Content/ContentTypesAbility.php b/src/Integrations/WordPress/Content/ContentTypesAbility.php new file mode 100644 index 0000000..d39c542 --- /dev/null +++ b/src/Integrations/WordPress/Content/ContentTypesAbility.php @@ -0,0 +1,94 @@ + 'object', + 'properties' => [ + 'post_type' => [ + 'type' => 'string', + 'description' => 'Optional: nur diesen Inhaltstyp, dafür mit allen Feldern.', + ], + ], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'post_types' => ['type' => 'array', 'items' => ['type' => 'object']], + ], + 'required' => ['post_types'], + ]; + } + + public function annotations(): array + { + return ['readonly' => true, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return true; + } + + public function permission(array $input): bool|WP_Error + { + return current_user_can('edit_posts') + ? true + : Errors::forbidden('Keine Berechtigung, Inhalte zu bearbeiten.'); + } + + public function execute(array $input): mixed + { + $postType = isset($input['post_type']) && is_string($input['post_type']) ? $input['post_type'] : ''; + if ($postType === '') { + return ['post_types' => $this->schema->describePostTypes()]; + } + + $description = $this->schema->describe($postType, true); + if ($description === null) { + return Errors::notFound( + sprintf('Inhaltstyp "%s" ist nicht verfügbar.', $postType), + 'Verfügbare Inhaltstypen: ' . implode(', ', $this->schema->postTypeNames()) . '.' + ); + } + + return ['post_types' => [$description]]; + } +} diff --git a/src/Integrations/WordPress/WordPressIntegration.php b/src/Integrations/WordPress/WordPressIntegration.php new file mode 100644 index 0000000..4afdd58 --- /dev/null +++ b/src/Integrations/WordPress/WordPressIntegration.php @@ -0,0 +1,38 @@ +schema), + ]; + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 371df4d..96b9aaf 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -11,6 +11,11 @@ use LIT\MCP\ChangeLog\ChangeLogInstaller; use LIT\MCP\Guides\InstructionsBuilder; use LIT\MCP\Integrations\Core\CoreIntegration; use LIT\MCP\Integrations\IntegrationRegistry; +use LIT\MCP\Integrations\Pods\PodsFieldSource; +use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; +use LIT\MCP\Integrations\WordPress\Schema\RegisteredMetaSource; +use LIT\MCP\Integrations\WordPress\Schema\SampledMetaSource; +use LIT\MCP\Integrations\WordPress\WordPressIntegration; use LIT\MCP\Server\RequestContext; use LIT\MCP\Server\ServerRegistrar; @@ -40,12 +45,17 @@ final class Plugin (new ChangeLogInstaller($changeLog))->maybeInstall(); (new ChangeLogCleanup($changeLog, new Limits()))->register(); + $schema = self::contentSchema(); $registry = new IntegrationRegistry( - new CoreIntegration(self::pluginDir() . '/docs/guides') + new CoreIntegration(self::pluginDir() . '/docs/guides'), + new WordPressIntegration($schema, $changeLog) ); (new AbilityRegistrar($registry))->register(); - (new ServerRegistrar($registry, new InstructionsBuilder($registry)))->register(); + (new ServerRegistrar( + $registry, + new InstructionsBuilder($registry, static fn (): array => $schema->postTypeNames()) + ))->register(); \WPMedia\MCP\OAuth\Bootstrap::instance(); } @@ -71,4 +81,14 @@ final class Plugin ); }); } + + private static function contentSchema(): ContentSchema + { + $sources = [new RegisteredMetaSource(), new SampledMetaSource()]; + if (PodsFieldSource::isAvailable()) { + array_unshift($sources, new PodsFieldSource()); + } + + return new ContentSchema($sources); + } } diff --git a/tests/Unit/Guides/InstructionsBuilderTest.php b/tests/Unit/Guides/InstructionsBuilderTest.php index 587ce69..51394d4 100644 --- a/tests/Unit/Guides/InstructionsBuilderTest.php +++ b/tests/Unit/Guides/InstructionsBuilderTest.php @@ -25,4 +25,17 @@ final class InstructionsBuilderTest extends TestCase self::assertStringContainsString('Status', $text); self::assertLessThan(2000, strlen($text), 'Instructions must stay short – they are loaded in every conversation.'); } + + public function testListsPostTypesWhenAProviderIsGiven(): void + { + $core = Mockery::mock(Integration::class); + $core->allows('id')->andReturn('core'); + $core->allows('isAvailable')->andReturn(true); + + $text = (new InstructionsBuilder(new IntegrationRegistry($core), static fn (): array => ['page', 'post']))->build(); + + self::assertStringContainsString('Inhaltstypen: page, post', $text); + self::assertStringContainsString('lit-content-types', $text); + self::assertStringContainsString('confirm: true', $text); + } } diff --git a/tests/Unit/Integrations/WordPress/Content/ContentTypesAbilityTest.php b/tests/Unit/Integrations/WordPress/Content/ContentTypesAbilityTest.php new file mode 100644 index 0000000..9e28264 --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Content/ContentTypesAbilityTest.php @@ -0,0 +1,66 @@ +expects('describePostTypes')->andReturn([['name' => 'page'], ['name' => 'post']]); + + $result = (new ContentTypesAbility($schema))->execute([]); + + self::assertSame(['post_types' => [['name' => 'page'], ['name' => 'post']]], $result); + } + + public function testSingleTypeIncludesFields(): void + { + $schema = Mockery::mock(ContentSchema::class); + $schema->expects('describe')->with('lit_person', true)->andReturn(['name' => 'lit_person', 'fields' => []]); + + $result = (new ContentTypesAbility($schema))->execute(['post_type' => 'lit_person']); + + self::assertSame([['name' => 'lit_person', 'fields' => []]], $result['post_types']); + } + + public function testUnknownTypeIsNotFoundWithTheAvailableTypes(): void + { + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('describe')->andReturn(null); + $schema->allows('postTypeNames')->andReturn(['page', 'post']); + + $result = (new ContentTypesAbility($schema))->execute(['post_type' => 'nope']); + + self::assertInstanceOf(WP_Error::class, $result); + self::assertSame('lit_not_found', $result->get_error_code()); + self::assertStringContainsString('page, post', $result->get_error_message()); + } + + public function testPermissionRequiresEditPosts(): void + { + Functions\when('current_user_can')->justReturn(false); + $ability = new ContentTypesAbility(Mockery::mock(ContentSchema::class)); + + self::assertSame('lit_forbidden', $ability->permission([])->get_error_code()); + } + + public function testMetadata(): void + { + $ability = new ContentTypesAbility(Mockery::mock(ContentSchema::class)); + + self::assertSame('lit/content-types', $ability->name()); + self::assertTrue($ability->isDirect()); + self::assertSame(['readonly' => true, 'destructive' => false, 'idempotent' => true], $ability->annotations()); + self::assertFalse($ability->inputSchema()['additionalProperties']); + } +} diff --git a/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php b/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php new file mode 100644 index 0000000..82a812a --- /dev/null +++ b/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php @@ -0,0 +1,44 @@ +id()); + self::assertTrue($integration->isAvailable()); + self::assertSame( + self::EXPECTED, + array_map(static fn (Ability $ability): string => $ability->name(), $integration->abilities()) + ); + } + + public function testEveryAbilityHasAnObjectInputSchemaThatRejectsUnknownKeys(): void + { + $integration = new WordPressIntegration(Mockery::mock(ContentSchema::class), Mockery::mock(ChangeLog::class)); + + foreach ($integration->abilities() as $ability) { + $schema = $ability->inputSchema(); + self::assertSame('object', $schema['type'], $ability->name()); + self::assertFalse($schema['additionalProperties'], $ability->name()); + self::assertMatchesRegularExpression('#^lit/[a-z]+(-[a-z]+)+$#', $ability->name()); + } + } +}