) so the PHPUnit suite can force an integration on * WITHOUT installing the host plugin, then stub the host API in the fixture. SEO is * detected per plugin: yoast (defined WPSEO_VERSION), rankmath (class RankMath), * aioseo (function aioseo). Each per-plugin set registers on its own predicate, so a * site running two SEO plugins genuinely contributes both surfaces (no first-match-wins * exclusivity). The shared SEO permission gates and the recursive JSON-LD sanitizer also * live here so the per-plugin ability files can reuse them. * * @package AgentAbilitiesForMCP */ declare( strict_types=1 ); defined( 'ABSPATH' ) || exit; /** * Whether a given integration's host plugin is active (so its abilities should * register / discover). * * @param string $slug One of 'yoast' | 'rankmath' | 'aioseo' | 'acf' | 'woocommerce'. * @return bool */ function aafm_integration_active( string $slug ): bool { switch ( $slug ) { case 'yoast': $active = aafm_yoast_active(); break; case 'rankmath': $active = aafm_rankmath_active(); break; case 'aioseo': $active = aafm_aioseo_active(); break; case 'acf': $active = aafm_acf_active(); break; case 'woocommerce': $active = aafm_woocommerce_active(); break; default: return false; } /** * Filters whether an integration is active. Used by the test suite to force-enable an * integration without installing the host plugin. Production passes the real detection * through unchanged. * * @param bool $active Detected active state. */ return (bool) apply_filters( 'aafm_integration_active_' . $slug, $active ); } /** * Whether Yoast SEO is active, behind a filterable seam. * * Real detection is defined('WPSEO_VERSION'). Wrapped in its own filter so the PHPUnit suite can * pin it deterministically: the suite stubs Yoast by defining WPSEO_VERSION process-wide, and a * defined constant can never be undefined, so the host-inactive registry test cannot flip detection * back off by removing the aafm_integration_active_yoast force filter alone. Driving this seam to * false through aafm_yoast_active lets that test force detection OFF deterministically. Production * passes the real detection through unchanged. * * @return bool */ function aafm_yoast_active(): bool { $active = defined( 'WPSEO_VERSION' ); /** * Filters whether Yoast SEO is reported active. * * @param bool $active Detected active state. */ return (bool) apply_filters( 'aafm_yoast_active', $active ); } /** * Whether Rank Math is active, behind a filterable seam (see aafm_yoast_active for the rationale; * Rank Math is stubbed with a process-wide RankMath marker class). * * @return bool */ function aafm_rankmath_active(): bool { $active = class_exists( 'RankMath' ); /** * Filters whether Rank Math is reported active. * * @param bool $active Detected active state. */ return (bool) apply_filters( 'aafm_rankmath_active', $active ); } /** * Whether All in One SEO is active, behind a filterable seam (see aafm_yoast_active for the * rationale; AIOSEO is stubbed with a process-wide aioseo() marker function). * * @return bool */ function aafm_aioseo_active(): bool { $active = function_exists( 'aioseo' ); /** * Filters whether AIOSEO is reported active. * * @param bool $active Detected active state. */ return (bool) apply_filters( 'aafm_aioseo_active', $active ); } /** * Per-object permission shared by every per-plugin SEO read/write: the caller may edit THIS post * (SEO meta is post content). Relocated here from the deleted unified seo.php so the yoast / rankmath * / aioseo ability files reuse one gate. * * @param array $input Validated input. * @return bool */ function aafm_perm_seo_post_object( array $input ): bool { $id = absint( $input['post_id'] ?? 0 ); $post = $id > 0 ? get_post( $id ) : null; // Delegate to the shared content-edit gate (not a bare edit_post): it enforces the operator's // post-type exposure allowlist AND the map_meta_cap===true fail-open guard, so SEO meta on a // non-exposed or non-mapped type is refused exactly as the core content writes are. return $post instanceof WP_Post && aafm_can_edit_post_object( $post ); } /** * Object-independent floor for the per-plugin *-get-head abilities: the caller can author posts at * all. The per-object edit_post($id) refinement runs inside execute. This floor lets discovery * (empty input) advertise the tool to a capable user, mirroring the documented FSE/floor pattern. * * @return bool */ function aafm_perm_seo_get_head_floor(): bool { return current_user_can( 'edit_posts' ); } /** * Recursively sanitize a JSON-LD schema array. Relocated here from the deleted unified seo.php so the * Rank Math schema writer can reuse it; integrations.php loads before the ability files, so the * function exists at registration time. * * At every depth: arrays recurse; a value under a url-ish key (url / image / logo / sameAs / @id, * case-insensitive) is run through esc_url_raw so a javascript: scheme is dropped; every other scalar * leaf is run through sanitize_text_field, which strips