bool */ private $sync_cache = []; /** * Cached result for whether OTTO has live transient-cached suggestions * for the current request URL. * * @var bool|null */ private $live_suggestions_cache = null; /** * Get singleton instance. * * @return self */ public static function get_instance() { if (self::$instance === null) { self::$instance = new self(); } return self::$instance; } /** * Private constructor — use get_instance(). */ private function __construct() { // Only hook on the frontend if (is_admin()) { return; } add_action('wp', [$this, 'register_filters'], 0); } /** * Register filters after the query is parsed (so is_singular() etc. work). */ public function register_filters() { if ($this->is_aioseo_active()) { $this->register_aioseo_filters(); } // Ensure is_plugin_active() is available if (!function_exists('is_plugin_active')) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } if (is_plugin_active('wordpress-seo/wp-seo.php') || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')) { $this->register_yoast_filters(); } if (is_plugin_active('seo-by-rank-math/rank-math.php') || is_plugin_active('seo-by-rankmath/rank-math.php')) { $this->register_rankmath_filters(); } } // ------------------------------------------------------------------ // Third-party SEO plugin detection // ------------------------------------------------------------------ /** * Check whether AIOSEO (free or pro) is active. * * @return bool */ public function is_aioseo_active() { // Ensure is_plugin_active() is available on the frontend if (!function_exists('is_plugin_active')) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } return is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') || is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php'); } /** * Check whether any supported third-party SEO plugin is active. * * @return bool */ public function has_active_seo_plugin() { // is_plugin_active() availability ensured by is_aioseo_active() call return $this->is_aioseo_active() || is_plugin_active('wordpress-seo/wp-seo.php') || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php') || is_plugin_active('seo-by-rank-math/rank-math.php') || is_plugin_active('seo-by-rankmath/rank-math.php'); } /** * Whether an active third-party SEO plugin actually holds a * non-empty meta description for this post in its OWN storage. * * The native-first output guards previously stood down whenever a * sync timestamp (_metasync_plugin_sync_ts) existed, assuming the plugin * would render the description. But a stale or partial sync leaves the * plugin's field empty — MetaSync suppresses its own tag, the plugin has * nothing to emit, and the description is dropped entirely. Callers use this * to only defer when the plugin can genuinely output a description. * * @param int $post_id Post ID. * @return bool True if the active/primary SEO plugin has a description. */ public function active_plugin_has_description($post_id) { $post_id = (int) $post_id; if ($post_id <= 0) { return false; } $this->ensure_plugin_api(); // Yoast (free or premium) if (is_plugin_active('wordpress-seo/wp-seo.php') || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')) { if (!empty(get_post_meta($post_id, '_yoast_wpseo_metadesc', true))) { return true; } } // Rank Math if (is_plugin_active('seo-by-rank-math/rank-math.php') || is_plugin_active('seo-by-rankmath/rank-math.php')) { if (!empty(get_post_meta($post_id, 'rank_math_description', true))) { return true; } } // AIOSEO stores its description in a custom table, not post meta. if ($this->is_aioseo_active()) { global $wpdb; // Defensive: $wpdb is always present on a booted frontend, but never // assume — a method call on a null/!object $wpdb would be fatal. if (isset($wpdb) && is_object($wpdb)) { $table = $wpdb->prefix . 'aioseo_posts'; $desc = $wpdb->get_var($wpdb->prepare("SELECT description FROM {$table} WHERE post_id = %d", $post_id)); if (!empty($desc)) { return true; } } } return false; } // ------------------------------------------------------------------ // MetaSync description resolution // ------------------------------------------------------------------ /** * Determine whether MetaSync holds an intentional meta description * for the current request. * * Only considers explicitly set values: * 1. SEO sidebar custom value (_metasync_seo_desc) * 2. OTTO persisted description (_metasync_otto_description) * * Auto-generated excerpts (legacy `meta_description` key) are NOT * counted — they should not suppress a third-party plugin. * * @return bool */ public function metasync_has_description() { if ($this->has_description_cache !== null) { return $this->has_description_cache; } if (!empty($this->get_metasync_description())) { $this->has_description_cache = true; return true; } // Term-level: on taxonomy archives MetaSync may have term meta // (`_metasync_metadesc`) set via MCP, OTTO, or the importer. $term = $this->get_current_term(); if ($term) { $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true); if (!empty($term_desc)) { $this->has_description_cache = true; return true; } } $this->has_description_cache = false; return false; } /** * Return MetaSync's intentional meta description for the current request. * * Only returns values that were explicitly set (sidebar or OTTO), NOT * auto-generated excerpts from the legacy `meta_description` key. * This ensures we only suppress third-party plugins when MetaSync has * a deliberate SEO value. * * @return string */ public function get_metasync_description() { $post_id = $this->get_current_object_id(); if (!$post_id) { return ''; } // 1. SEO sidebar (highest priority — user-edited) $desc = get_post_meta($post_id, '_metasync_seo_desc', true); if (!empty($desc)) { return $desc; } // 2. OTTO description $desc = get_post_meta($post_id, '_metasync_otto_description', true); if (!empty($desc)) { return $desc; } return ''; } /** * Resolve the MetaSync-managed canonical URL for a post, if any. * * Priority: OTTO persisted canonical (_metasync_canonical_url) → Canonical meta * box value (meta_canonical). Restricted to singular views so term/archive * queried-object ids are never misread as post ids. Mirrors the fallback in * Metasync_Seo_Output::get_canonical_url() so both the no-SEO-plugin path and * the third-party-plugin path honor the same value. * * @param int $post_id Current object id. * @return string Escaped canonical URL, or '' when none is set. */ private function get_metasync_canonical($post_id) { if (!$post_id || !is_singular()) { return ''; } // Validate both sources: legacy rows corrupted to the literal "Array" // (or stored as arrays) must never be emitted as a canonical. $canonical = Metasync_Canonical_Sanitizer::sanitize( get_post_meta($post_id, '_metasync_canonical_url', true) ); if ($canonical === '') { $canonical = Metasync_Canonical_Sanitizer::sanitize( get_post_meta($post_id, 'meta_canonical', true) ); } return $canonical !== '' ? esc_url($canonical) : ''; } /** * Reset the cached description flag (useful when the queried object changes). */ public function reset_cache() { $this->has_description_cache = null; $this->aioseo_has_description_cache = null; $this->sync_cache = []; $this->live_suggestions_cache = null; } /** * Check whether a post has been synced to third-party plugins via. * * When native-first sync is active, each plugin reads MetaSync values from * its own storage — no filter suppression needed. This method returns true * when _metasync_plugin_sync_ts exists and contains a timestamp for the * given plugin slug. * * @param int $post_id Post ID. * @param string $plugin_slug Plugin slug: 'yoast', 'rankmath', or 'aioseo'. * @return bool True if the post has been synced to this plugin. */ private function is_post_synced($post_id, $plugin_slug) { if ($post_id <= 0) { return false; } if (!isset($this->sync_cache[$post_id])) { $ts_raw = get_post_meta($post_id, '_metasync_plugin_sync_ts', true); $this->sync_cache[$post_id] = !empty($ts_raw) ? json_decode($ts_raw, true) : []; if (!is_array($this->sync_cache[$post_id])) { $this->sync_cache[$post_id] = []; } } return !empty($this->sync_cache[$post_id][$plugin_slug]); } /** * For synced posts with multiple SEO plugins active, determine if a * specific plugin is the designated output owner. * * Only the first active plugin in priority order (Yoast > Rank Math > AIOSEO) * is allowed to output — the others are suppressed to prevent duplicate tags. * * @param int $post_id Post ID. * @param string $plugin_slug Plugin slug to check. * @return bool True if this plugin should output its tags. */ private function is_primary_output_plugin($post_id, $plugin_slug) { // For synced posts: only the primary synced plugin passes through. // For unsynced posts with multiple plugins: only the highest-priority // active plugin outputs to prevent duplicate tags. $this->ensure_plugin_api(); $priority = ['yoast', 'rankmath', 'aioseo']; $active_check = [ 'yoast' => is_plugin_active('wordpress-seo/wp-seo.php') || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php'), 'rankmath' => is_plugin_active('seo-by-rank-math/rank-math.php') || is_plugin_active('seo-by-rankmath/rank-math.php'), 'aioseo' => is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') || is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php'), ]; $has_any_sync = $post_id > 0 && ($this->is_post_synced($post_id, 'yoast') || $this->is_post_synced($post_id, 'rankmath') || $this->is_post_synced($post_id, 'aioseo')); if ($has_any_sync) { // Synced: primary = first active + synced plugin foreach ($priority as $slug) { if ($active_check[$slug] && $this->is_post_synced($post_id, $slug)) { return $slug === $plugin_slug; } } return false; } // Unsynced / multiple plugins active: pick the first active plugin // as the sole outputter to prevent duplicate tags. $active_count = count(array_filter($active_check)); if ($active_count > 1) { foreach ($priority as $slug) { if ($active_check[$slug]) { return $slug === $plugin_slug; } } } // Single plugin active or no plugins — don't interfere return false; } /** * Ensure is_plugin_active() is loaded on the frontend. */ private function ensure_plugin_api() { if (!function_exists('is_plugin_active')) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } } // ------------------------------------------------------------------ // AIOSEO integration // ------------------------------------------------------------------ /** * Register AIOSEO-specific filters to suppress its output * when MetaSync/OTTO already provides the same tags. */ private function register_aioseo_filters() { // Suppress AIOSEO meta description add_filter('aioseo_description', [$this, 'filter_aioseo_description'], 999); // Suppress AIOSEO title add_filter('aioseo_title', [$this, 'filter_aioseo_title'], 999); // Suppress AIOSEO OG/Twitter tags that OTTO already provides add_filter('aioseo_facebook_tags', [$this, 'filter_aioseo_facebook_tags'], 999); add_filter('aioseo_twitter_tags', [$this, 'filter_aioseo_twitter_tags'], 999); // Suppress AIOSEO robots when MetaSync has an intentional robots value add_filter('aioseo_robots_meta', [$this, 'filter_aioseo_robots'], 999); // Suppress AIOSEO schema/JSON-LD when OTTO has structured data add_filter('aioseo_schema_output', [$this, 'filter_aioseo_schema'], 999); // Canonical — override AIOSEO's canonical with the MetaSync/OTTO value when set add_filter('aioseo_canonical_url', [$this, 'filter_aioseo_canonical'], 999); } /** * Filter AIOSEO's canonical URL. * * Mirrors filter_yoast_canonical(): return the MetaSync-managed canonical * (OTTO or the Canonical meta box) when set, else pass AIOSEO's through. */ public function filter_aioseo_canonical($canonical) { $custom = $this->get_metasync_canonical($this->get_current_object_id()); return $custom !== '' ? $custom : $canonical; } /** * Filter AIOSEO description output. * Returns empty string when MetaSync has a description, letting MetaSync output it. * * @param string $description AIOSEO's computed description. * @return string */ public function filter_aioseo_description($description) { // Cache whether AIOSEO actually has a description (before we suppress it). // This is used later by should_output_legacy_description(). if ($this->aioseo_has_description_cache === null) { $this->aioseo_has_description_cache = !empty($description); } // Primary plugin check — only the designated plugin outputs. $post_id = $this->get_current_object_id(); if ($post_id && $this->has_active_seo_plugin()) { if ($this->is_primary_output_plugin($post_id, 'aioseo')) { return $description; } if ($this->is_primary_output_plugin($post_id, 'yoast') || $this->is_primary_output_plugin($post_id, 'rankmath')) { return ''; } } // Term archives: AIOSEO free doesn't read per-term custom descriptions // from its `wp_aioseo_terms` table, so return the MetaSync value // directly so AIOSEO renders it. $term = $this->get_current_term(); if ($term) { $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true); if (!empty($term_desc)) { return $term_desc; } } // Suppress when: OTTO active + has description, OR MetaSync sidebar has description if ($this->otto_has_tag('description') || $this->metasync_has_description()) { return ''; } return $description; } /** * Filter AIOSEO title output. * * On term archives: AIOSEO free doesn't read custom per-term titles from * its `wp_aioseo_terms` table, so we replace AIOSEO's template-based title * with the MetaSync term title directly. * * @param string $title AIOSEO's computed title. * @return string */ public function filter_aioseo_title($title) { // Primary plugin check — only the designated plugin outputs. $post_id = $this->get_current_object_id(); if ($post_id && $this->has_active_seo_plugin()) { if ($this->is_primary_output_plugin($post_id, 'aioseo')) { return $title; } if ($this->is_primary_output_plugin($post_id, 'yoast') || $this->is_primary_output_plugin($post_id, 'rankmath')) { return ''; } } // Term archives: return MetaSync term title directly. $term = $this->get_current_term(); if ($term) { $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true); if (!empty($term_title)) { return $term_title; } } if ($this->should_suppress_third_party_title()) { return ''; } return $title; } /** * Filter AIOSEO Facebook/OG tags. * * Per-tag suppression: only remove a tag when OTTO is active AND has * a persisted value for that specific tag, OR when MetaSync sidebar * provides the equivalent value. * * @param array $meta AIOSEO's OG meta array. * @return array */ public function filter_aioseo_facebook_tags($meta) { if (!is_array($meta)) { return $meta; } $post_id = $this->get_current_object_id(); // Post synced to AIOSEO — let AIOSEO read from its own storage. if ($post_id && $this->is_primary_output_plugin($post_id, 'aioseo')) { return $meta; } // og:title — suppress when OTTO has og:title OR MetaSync has title if ($this->otto_has_tag('og:title') || ($post_id && $this->metasync_has_title($post_id))) { unset($meta['og:title']); } // og:description — suppress when OTTO has og:description OR MetaSync has description if ($this->otto_has_tag('og:description') || $this->metasync_has_description()) { unset($meta['og:description']); } // og:url, og:type, og:locale, og:site_name — suppress when OTTO has og:title // (OTTO injects these structural OG tags alongside og:title in its block) if ($this->otto_has_tag('og:title')) { unset($meta['og:url'], $meta['og:type'], $meta['og:locale'], $meta['og:site_name']); } return $meta; } /** * Filter AIOSEO Twitter tags. * * Per-tag suppression: only remove a tag when OTTO is active AND has * a persisted value for that specific tag, OR when MetaSync sidebar * provides the equivalent value. * * @param array $meta AIOSEO's Twitter meta array. * @return array */ public function filter_aioseo_twitter_tags($meta) { if (!is_array($meta)) { return $meta; } $post_id = $this->get_current_object_id(); // Post synced to AIOSEO — let AIOSEO read from its own storage. if ($post_id && $this->is_primary_output_plugin($post_id, 'aioseo')) { return $meta; } if ($this->otto_has_tag('twitter:title') || ($post_id && $this->metasync_has_title($post_id))) { unset($meta['twitter:title']); } if ($this->otto_has_tag('twitter:description') || $this->metasync_has_description()) { unset($meta['twitter:description']); } // twitter:card — suppress when OTTO has any twitter tag if ($this->otto_has_tag('twitter:title') || $this->otto_has_tag('twitter:description')) { unset($meta['twitter:card']); } return $meta; } /** * Filter AIOSEO robots meta output. * * When MetaSync has an intentional robots value (admin checkbox or REST API), * suppress AIOSEO's robots tag to avoid duplicates. MetaSync's own output in * hook_metasync_metatags() will output the MetaSync value instead. * * AIOSEO passes an array like ['noindex' => 'noindex', 'nofollow' => 'nofollow']. * Returning an empty array suppresses AIOSEO's robots tag entirely. * * @param array $robots AIOSEO's computed robots attributes array. * @return array */ public function filter_aioseo_robots($robots) { $post_id = $this->get_current_object_id(); if (!$post_id) { return $robots; } // Post synced to AIOSEO — let AIOSEO read from its own storage. if ($this->is_primary_output_plugin($post_id, 'aioseo')) { return $robots; } if ($this->metasync_has_robots($post_id)) { // MetaSync has robots — suppress AIOSEO's tag. return []; } return $robots; } /** * Filter AIOSEO schema/JSON-LD output. * Suppress when OTTO has structured data for the current page. * Also strip BreadcrumbList entries when MetaSync breadcrumbs are enabled, * so MetaSync's own BreadcrumbList is the only one on the page. * * @param array $output AIOSEO's @graph array. * @return array */ public function filter_aioseo_schema($output) { if ($this->otto_has_schema_for_current_page()) { return []; } if ($this->metasync_breadcrumb_enabled() && is_array($output)) { $output = $this->strip_breadcrumb_from_graph($output); } return $output; } /** * Check whether MetaSync holds an intentional robots directive for a post. * * Checks both storage formats: * - meta_robots (string from REST API) * - metasync_common_robots (array from admin checkbox) * * @param int $post_id Post ID. * @return bool */ public function metasync_has_robots($post_id) { $meta_robots = get_post_meta($post_id, 'meta_robots', true); if (!empty($meta_robots)) { return true; } $common_robots = get_post_meta($post_id, 'metasync_common_robots', true); if (is_array($common_robots) && !empty(array_filter($common_robots))) { return true; } return false; } /** * Check whether MetaSync/OTTO has a title for a given post. * * @param int $post_id Post ID. * @return bool */ private function metasync_has_title($post_id) { $seo_title = get_post_meta($post_id, '_metasync_seo_title', true); if (!empty($seo_title)) { return true; } $otto_title = get_post_meta($post_id, '_metasync_otto_title', true); if (!empty($otto_title)) { return true; } // Term-level fallback: on taxonomy archives the "object" is a term, // so read `_metasync_metatitle` from term meta when we're rendering one. $term = $this->get_current_term(); if ($term) { $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true); if (!empty($term_title)) { return true; } } return false; } /** * Determine whether a third-party SEO plugin's title should be suppressed. * * Suppress when either condition is met: * 1. OTTO is active AND has a persisted title for this page * 2. MetaSync sidebar has an explicit title for this page * * @return bool True if the third-party title should be suppressed. */ private function should_suppress_third_party_title() { // Condition 1: OTTO active + has title for this page if ($this->otto_has_tag('title')) { return true; } // Condition 2: MetaSync sidebar has explicit title $post_id = $this->get_current_object_id(); if ($post_id) { return $this->metasync_has_title($post_id); } return false; } /** * Check whether the OTTO pixel is active. * * @return bool */ private function is_otto_active() { if (class_exists('Metasync_Otto_Config')) { return Metasync_Otto_Config::is_otto_enabled(); } return false; } /** * Check whether the OTTO transient cache has live suggestions for the * current request URL. * * Passive get_transient() lookup only — no OTTO API call. Mirrors the * cache-key format used by Metasync_Otto_Transient_Cache and the URL * construction from Otto_pixel_class::get_route(). * * @return bool */ public function otto_has_live_suggestions() { if ($this->live_suggestions_cache !== null) { return $this->live_suggestions_cache; } if (!$this->is_otto_active()) { $this->live_suggestions_cache = false; return false; } if (empty($_SERVER['HTTP_HOST']) || empty($_SERVER['REQUEST_URI'])) { $this->live_suggestions_cache = false; return false; } $scheme = is_ssl() ? 'https' : 'http'; $host = $_SERVER['HTTP_HOST']; $request_uri = strtok($_SERVER['REQUEST_URI'], '?') ?: $_SERVER['REQUEST_URI']; $url = $scheme . '://' . $host . $request_uri; $hash = md5(rtrim(strtolower($url), '/')); $site_id = is_multisite() ? get_current_blog_id() : 0; $cached = get_transient('otto_suggestions_' . $site_id . '_' . $hash); $this->live_suggestions_cache = ($cached !== false && !empty($cached)); return $this->live_suggestions_cache; } /** * Check whether OTTO has a persisted value for a specific meta tag. * * Two conditions must be true to suppress a third-party tag: * 1. OTTO is active (globally enabled) * 2. OTTO has a value for this specific tag on the current page * * For OG/Twitter tags where OTTO's pixel injects dynamically (the * specific _metasync_otto_og_* key may be empty), the buffer-level * dedup in Otto_html_class::deduplicate_og_twitter_tags() handles * removal after all sources have output. This method only does the * direct per-tag check. * * @param string $tag Tag identifier (e.g. 'title', 'og:title', 'twitter:description'). * @return bool True when OTTO is active AND has a persisted value for this tag. */ private function otto_has_tag($tag) { if (!$this->is_otto_active()) { return false; } $post_id = $this->get_current_object_id(); if (!$post_id) { return false; } if ($this->has_active_seo_plugin() && !$this->otto_has_live_suggestions()) { return false; } $meta_key_map = [ 'title' => '_metasync_otto_title', 'description' => '_metasync_otto_description', 'og:title' => '_metasync_otto_og_title', 'og:description' => '_metasync_otto_og_description', 'twitter:title' => '_metasync_otto_twitter_title', 'twitter:description' => '_metasync_otto_twitter_description', ]; if (!isset($meta_key_map[$tag])) { return false; } return !empty(get_post_meta($post_id, $meta_key_map[$tag], true)); } // ------------------------------------------------------------------ // Yoast SEO integration // ------------------------------------------------------------------ /** * Register Yoast SEO-specific filters to suppress its title, * description, and OG/Twitter output when MetaSync/OTTO provides them. */ private function register_yoast_filters() { add_filter('wpseo_title', [$this, 'filter_yoast_title'], 999); add_filter('wpseo_metadesc', [$this, 'filter_yoast_description'], 999); // OG tags — per-tag suppression add_filter('wpseo_opengraph_title', [$this, 'filter_yoast_og_title'], 999); add_filter('wpseo_opengraph_desc', [$this, 'filter_yoast_og_description'], 999); add_filter('wpseo_opengraph_url', [$this, 'filter_yoast_og_structural'], 999); add_filter('wpseo_opengraph_type', [$this, 'filter_yoast_og_structural'], 999); add_filter('wpseo_opengraph_site_name', [$this, 'filter_yoast_og_structural'], 999); add_filter('wpseo_og_locale', [$this, 'filter_yoast_og_structural'], 999); add_filter('wpseo_opengraph_image', [$this, 'filter_yoast_og_structural'], 999); // Twitter tags — per-tag suppression add_filter('wpseo_twitter_title', [$this, 'filter_yoast_twitter_title'], 999); add_filter('wpseo_twitter_description', [$this, 'filter_yoast_twitter_description'], 999); add_filter('wpseo_twitter_image', [$this, 'filter_yoast_twitter_structural'], 999); add_filter('wpseo_twitter_card_type', [$this, 'filter_yoast_twitter_structural'], 999); // Suppress Yoast schema/JSON-LD when OTTO has structured data add_filter('wpseo_schema_graph', [$this, 'filter_yoast_schema'], 999); // Canonical — override Yoast's canonical with the MetaSync/OTTO value when set add_filter('wpseo_canonical', [$this, 'filter_yoast_canonical'], 999); } /** * Filter Yoast's canonical URL. * * When a MetaSync-managed canonical exists (OTTO or the Canonical meta box), * return it so Yoast emits our value instead of its own — avoiding a duplicate * while still honoring the per-post override. * Otherwise let Yoast's canonical through unchanged. */ public function filter_yoast_canonical($canonical) { $custom = $this->get_metasync_canonical($this->get_current_object_id()); return $custom !== '' ? $custom : $canonical; } /** * Filter Yoast SEO title output. * * When the MetaSync sidebar has an explicit SEO title, return that title so * Yoast's Title_Presenter renders it inside the