feat: content schema with registered and sampled meta fields

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-25 22:04:04 +02:00
co-authored by Claude Opus 5.5
parent 261972751f
commit b3f4b7ba65
9 changed files with 600 additions and 0 deletions
@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Schema;
use LogicException;
/**
* Detects the content model of this installation (spec §6.1): post types with a
* UI, their taxonomies and their fields merged from all field sources.
* Not final so abilities can be unit-tested with a mock.
*/
class ContentSchema
{
/** Post types handled elsewhere (media tools, Divi tools, WordPress internals). */
public const EXCLUDED = [
'attachment', 'nav_menu_item', 'revision', 'wp_block', 'wp_template', 'wp_template_part',
'wp_navigation', 'wp_global_styles', 'wp_font_family', 'wp_font_face', 'et_pb_layout',
'et_theme_builder', 'et_template', 'et_header_layout', 'et_body_layout', 'et_footer_layout',
'et_pb_canvas', '_et_pb_speculation',
];
private const COUNTED_STATUSES = ['publish', 'draft', 'pending', 'private', 'future'];
/** @var callable(string): bool */
private $diviEnabled;
/** @var array<string, array<string, FieldDefinition>> */
private array $fieldCache = [];
/**
* @param list<FieldSource> $sources Highest priority first.
*/
public function __construct(private array $sources, ?callable $diviEnabled = null)
{
$this->diviEnabled = $diviEnabled ?? static function (string $postType): bool {
return function_exists('et_builder_enabled_for_post_type')
&& (bool) et_builder_enabled_for_post_type($postType);
};
}
/** @return list<string> */
public function postTypeNames(): array
{
$excluded = apply_filters('lit_mcp_excluded_post_types', self::EXCLUDED);
$excluded = is_array($excluded) ? $excluded : self::EXCLUDED;
$names = array_values(array_diff(
array_map('strval', array_keys(get_post_types(['show_ui' => true], 'names'))),
$excluded
));
sort($names);
return $names;
}
public function isManaged(string $postType): bool
{
return in_array($postType, $this->postTypeNames(), true);
}
/** @return list<array<string, mixed>> */
public function describePostTypes(): array
{
$types = [];
foreach ($this->postTypeNames() as $name) {
$description = $this->describe($name, false);
if ($description !== null) {
$types[] = $description;
}
}
return $types;
}
/** @return array<string, mixed>|null Null when the post type is not managed. */
public function describe(string $postType, bool $withFields): ?array
{
$object = $this->isManaged($postType) ? get_post_type_object($postType) : null;
if ($object === null) {
return null;
}
$description = [
'name' => $postType,
'label' => (string) $object->labels->name,
'singular_label' => (string) $object->labels->singular_name,
'hierarchical' => (bool) $object->hierarchical,
'supports' => array_map('strval', array_keys(get_all_post_type_supports($postType))),
'divi' => $this->diviEnabled($postType),
'count' => $this->count($postType),
'taxonomies' => $this->taxonomies($postType),
];
if ($withFields) {
$description['fields'] = array_values(array_map(
static fn (FieldDefinition $field): array => $field->toArray(),
$this->fields($postType)
));
}
return $description;
}
/** @return array<string, FieldDefinition> */
public function fields(string $postType): array
{
if (! isset($this->fieldCache[$postType])) {
$merged = [];
foreach ($this->sources as $source) {
foreach ($source->fieldsFor($postType) as $field) {
$merged[$field->key] ??= $field;
}
}
ksort($merged);
$this->fieldCache[$postType] = $merged;
}
return $this->fieldCache[$postType];
}
public function sourceOf(FieldDefinition $field): FieldSource
{
foreach ($this->sources as $source) {
if ($source->id() === $field->source) {
return $source;
}
}
throw new LogicException(sprintf('No field source "%s" registered.', $field->source));
}
/** @return list<array{name: string, label: string, hierarchical: bool}> */
public function taxonomies(string $postType): array
{
$result = [];
foreach (get_object_taxonomies($postType, 'objects') as $taxonomy) {
if (! $taxonomy->show_ui) {
continue;
}
$result[] = [
'name' => (string) $taxonomy->name,
'label' => (string) $taxonomy->labels->name,
'hierarchical' => (bool) $taxonomy->hierarchical,
];
}
return $result;
}
public function diviEnabled(string $postType): bool
{
return ($this->diviEnabled)($postType);
}
private function count(string $postType): int
{
$counts = (array) wp_count_posts($postType);
$total = 0;
foreach (self::COUNTED_STATUSES as $status) {
$total += (int) ($counts[$status] ?? 0);
}
return $total;
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Schema;
/**
* Description of one field of a post type, independent of where it comes from.
*/
final class FieldDefinition
{
public const WRITABLE = 'writable';
public const READONLY = 'readonly';
public const UNKNOWN = 'unknown';
/**
* @param list<array{value: string, label: string}>|null $choices
* @param array{object_type: string, name: ?string}|null $related
*/
public function __construct(
public readonly string $key,
public readonly string $label,
public readonly string $type,
public readonly string $source,
public readonly string $status,
public readonly bool $required = false,
public readonly bool $multiple = false,
public readonly ?array $choices = null,
public readonly ?array $related = null,
public readonly string $description = ''
) {
}
/** @return array<string, mixed> */
public function toArray(): array
{
return [
'key' => $this->key,
'label' => $this->label,
'type' => $this->type,
'source' => $this->source,
'status' => $this->status,
'required' => $this->required,
'multiple' => $this->multiple,
'choices' => $this->choices,
'related' => $this->related,
'description' => $this->description,
];
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Schema;
use RuntimeException;
/**
* A provider of field definitions for post types that can also read and write
* the values of its own fields (Pods via the Pods API, everything else via meta).
*/
interface FieldSource
{
public function id(): string;
/** @return list<FieldDefinition> */
public function fieldsFor(string $postType): array;
public function read(int $postId, FieldDefinition $field): mixed;
/**
* @param array<string, mixed> $values Validated values; null clears the field.
* @param array<string, FieldDefinition> $definitions All definitions of the post type, by key.
* @throws RuntimeException When the value could not be stored.
*/
public function write(int $postId, string $postType, array $values, array $definitions): void;
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Schema;
/**
* Shared read/write via the post meta API.
*/
abstract class MetaFieldSource implements FieldSource
{
public function read(int $postId, FieldDefinition $field): mixed
{
return get_post_meta($postId, $field->key, ! $field->multiple);
}
public function write(int $postId, string $postType, array $values, array $definitions): void
{
foreach ($values as $key => $value) {
$key = (string) $key;
if ($value === null) {
delete_post_meta($postId, $key);
continue;
}
$field = $definitions[$key] ?? null;
if ($field !== null && $field->multiple && is_array($value) && array_is_list($value)) {
delete_post_meta($postId, $key);
foreach ($value as $item) {
add_post_meta($postId, $key, wp_slash($item));
}
continue;
}
update_post_meta($postId, $key, wp_slash($value));
}
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Schema;
/**
* Fields registered with register_meta()/register_post_meta().
*/
final class RegisteredMetaSource extends MetaFieldSource
{
private const TYPES = ['string', 'boolean', 'integer', 'number', 'array', 'object'];
public function id(): string
{
return 'meta';
}
public function fieldsFor(string $postType): array
{
$keys = array_merge(get_registered_meta_keys('post', ''), get_registered_meta_keys('post', $postType));
$fields = [];
foreach ($keys as $key => $args) {
$key = (string) $key;
$args = is_array($args) ? $args : [];
$type = (string) ($args['type'] ?? 'string');
$readonly = is_protected_meta($key, 'post') && empty($args['show_in_rest']);
$fields[] = new FieldDefinition(
$key,
$key,
in_array($type, self::TYPES, true) ? $type : 'unknown',
'meta',
$readonly ? FieldDefinition::READONLY : FieldDefinition::WRITABLE,
false,
! (bool) ($args['single'] ?? false),
null,
null,
(string) ($args['description'] ?? '')
);
}
return $fields;
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Schema;
/**
* Meta keys actually used by the most recent posts of a type. Keys starting with
* "_" are internal and only shown when another source knows them.
*/
final class SampledMetaSource extends MetaFieldSource
{
private const SAMPLE_POSTS = 50;
private const MAX_KEYS = 200;
public function __construct(private ?\wpdb $db = null)
{
}
public function id(): string
{
return 'sample';
}
public function fieldsFor(string $postType): array
{
$wpdb = $this->db ?? $GLOBALS['wpdb'];
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only core table names interpolated.
$keys = $wpdb->get_col($wpdb->prepare(
"SELECT DISTINCT pm.meta_key FROM {$wpdb->postmeta} pm
INNER JOIN (
SELECT ID FROM {$wpdb->posts}
WHERE post_type = %s AND post_status NOT IN ('auto-draft', 'trash')
ORDER BY ID DESC LIMIT %d
) recent ON recent.ID = pm.post_id
ORDER BY pm.meta_key LIMIT %d",
$postType,
self::SAMPLE_POSTS,
self::MAX_KEYS
));
$fields = [];
foreach ((array) $keys as $key) {
$key = (string) $key;
if ($key === '' || str_starts_with($key, '_')) {
continue;
}
$fields[] = new FieldDefinition($key, $key, 'unknown', 'sample', FieldDefinition::UNKNOWN);
}
return $fields;
}
}