PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.25
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.25
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / includes / class-metasync-seo-conflict-handler.php

class-metasync-seo-conflict-handler.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.25, at includes/class-metasync-seo-conflict-handler.php

2,521 lines 98.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Centralized SEO Plugin Conflict Handler
4 *
5 * Prevents duplicate meta descriptions when MetaSync coexists with
6 * third-party SEO plugins (AIOSEO, Yoast, RankMath, etc.).
7 *
8 * Strategy:
9 * - When MetaSync (OTTO or sidebar) has a value → suppress the third-party plugin.
10 * - When MetaSync has NO value → let the third-party plugin output its own.
11 * - When NEITHER has a value → let MetaSync's legacy auto-generated description through.
12 *
13 * @package MetaSync
14 * @subpackage MetaSync/includes
15 * @since 2.8.23
16 */
17
18 if (!defined('ABSPATH')) {
19 exit;
20 }
21
22 class Metasync_SEO_Conflict_Handler {
23
24 /**
25 * Singleton instance.
26 *
27 * @var self|null
28 */
29 private static $instance = null;
30
31 /**
32 * Cached result for whether MetaSync has a description for the current page.
33 *
34 * @var bool|null
35 */
36 private $has_description_cache = null;
37
38 /**
39 * Resolved MetaSync title per post id, for this request only.
40 *
41 * @var array<int,string>
42 */
43 private $title_value_cache = [];
44
45 /**
46 * Cached result for whether AIOSEO provides a description for the current page.
47 *
48 * @var bool|null
49 */
50 private $aioseo_has_description_cache = null;
51
52 /**
53 * Cached per-plugin robots-suppression decisions for the current
54 * request (the Rank Math main + advanced robots filters both invoke
55 * the check during wp_head).
56 *
57 * @var array<string, bool>
58 */
59 private $robots_suppress_cache = [];
60
61 /**
62 * Cached result: whether the current post has been synced via.
63 *
64 * @var array Keyed by post_id => bool
65 */
66 private $sync_cache = [];
67
68 /**
69 * Cached result for whether OTTO has live transient-cached suggestions
70 * for the current request URL.
71 *
72 * @var bool|null
73 */
74 private $live_suggestions_cache = null;
75
76 /**
77 * Get singleton instance.
78 *
79 * @return self
80 */
81 public static function get_instance() {
82 if (self::$instance === null) {
83 self::$instance = new self();
84 }
85 return self::$instance;
86 }
87
88 /**
89 * Private constructor — use get_instance().
90 */
91 private function __construct() {
92 // Only hook on the frontend
93 if (is_admin()) {
94 return;
95 }
96
97 add_action('wp', [$this, 'register_filters'], 0);
98 }
99
100 /**
101 * Register filters after the query is parsed (so is_singular() etc. work).
102 */
103 public function register_filters() {
104 if ($this->is_aioseo_active()) {
105 $this->register_aioseo_filters();
106 }
107
108 // Ensure is_plugin_active() is available
109 if (!function_exists('is_plugin_active')) {
110 require_once ABSPATH . 'wp-admin/includes/plugin.php';
111 }
112
113 if (is_plugin_active('wordpress-seo/wp-seo.php') ||
114 is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')) {
115 $this->register_yoast_filters();
116 }
117
118 if (is_plugin_active('seo-by-rank-math/rank-math.php') || is_plugin_active('seo-by-rankmath/rank-math.php')) {
119 $this->register_rankmath_filters();
120 }
121 }
122
123 // ------------------------------------------------------------------
124 // Third-party SEO plugin detection
125 // ------------------------------------------------------------------
126
127 /**
128 * Check whether AIOSEO (free or pro) is active.
129 *
130 * @return bool
131 */
132 public function is_aioseo_active() {
133 // Ensure is_plugin_active() is available on the frontend
134 if (!function_exists('is_plugin_active')) {
135 require_once ABSPATH . 'wp-admin/includes/plugin.php';
136 }
137
138 return is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php')
139 || is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php');
140 }
141
142 /**
143 * Check whether any supported third-party SEO plugin is active.
144 *
145 * @return bool
146 */
147 public function has_active_seo_plugin() {
148 // is_plugin_active() availability ensured by is_aioseo_active() call
149 return $this->is_aioseo_active()
150 || is_plugin_active('wordpress-seo/wp-seo.php')
151 || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')
152 || is_plugin_active('seo-by-rank-math/rank-math.php')
153 || is_plugin_active('seo-by-rankmath/rank-math.php');
154 }
155
156 /**
157 * Whether an active third-party SEO plugin actually holds a
158 * non-empty meta description for this post in its OWN storage.
159 *
160 * The native-first output guards previously stood down whenever a
161 * sync timestamp (_metasync_plugin_sync_ts) existed, assuming the plugin
162 * would render the description. But a stale or partial sync leaves the
163 * plugin's field empty — MetaSync suppresses its own tag, the plugin has
164 * nothing to emit, and the description is dropped entirely. Callers use this
165 * to only defer when the plugin can genuinely output a description.
166 *
167 * @param int $post_id Post ID.
168 * @return bool True if the active/primary SEO plugin has a description.
169 */
170 public function active_plugin_has_description($post_id) {
171 $post_id = (int) $post_id;
172 if ($post_id <= 0) {
173 return false;
174 }
175
176 $this->ensure_plugin_api();
177
178 // Yoast (free or premium)
179 if (is_plugin_active('wordpress-seo/wp-seo.php') || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')) {
180 if (!empty(get_post_meta($post_id, '_yoast_wpseo_metadesc', true))) {
181 return true;
182 }
183 }
184
185 // Rank Math
186 if (is_plugin_active('seo-by-rank-math/rank-math.php') || is_plugin_active('seo-by-rankmath/rank-math.php')) {
187 if (!empty(get_post_meta($post_id, 'rank_math_description', true))) {
188 return true;
189 }
190 }
191
192 // AIOSEO stores its description in a custom table, not post meta.
193 if ($this->is_aioseo_active()) {
194 global $wpdb;
195 // Defensive: $wpdb is always present on a booted frontend, but never
196 // assume — a method call on a null/!object $wpdb would be fatal.
197 if (isset($wpdb) && is_object($wpdb)) {
198 $table = $wpdb->prefix . 'aioseo_posts';
199 $desc = $wpdb->get_var($wpdb->prepare("SELECT description FROM {$table} WHERE post_id = %d", $post_id));
200 if (!empty($desc)) {
201 return true;
202 }
203 }
204 }
205
206 return false;
207 }
208
209 // ------------------------------------------------------------------
210 // MetaSync description resolution
211 // ------------------------------------------------------------------
212
213 /**
214 * Determine whether MetaSync holds an intentional meta description
215 * for the current request.
216 *
217 * Only considers explicitly set values:
218 * 1. SEO sidebar custom value (_metasync_seo_desc)
219 * 2. OTTO persisted description (_metasync_otto_description)
220 *
221 * Auto-generated excerpts (legacy `meta_description` key) are NOT
222 * counted — they should not suppress a third-party plugin.
223 *
224 * @return bool
225 */
226 public function metasync_has_description() {
227 if ($this->has_description_cache !== null) {
228 return $this->has_description_cache;
229 }
230
231 if (!empty($this->get_metasync_description())) {
232 $this->has_description_cache = true;
233 return true;
234 }
235
236 // Term-level: on taxonomy archives MetaSync may have term meta
237 // (`_metasync_metadesc`) set via MCP, OTTO, or the importer.
238 $term = $this->get_current_term();
239 if ($term) {
240 $term_desc = $this->get_metasync_term_description($term);
241 if (!empty($term_desc)) {
242 $this->has_description_cache = true;
243 return true;
244 }
245 }
246
247 $this->has_description_cache = false;
248 return false;
249 }
250
251 /**
252 * Return MetaSync's intentional meta description for the current request.
253 *
254 * Only returns values that were explicitly set (sidebar or OTTO), NOT
255 * auto-generated excerpts from the legacy `meta_description` key.
256 * This ensures we only suppress third-party plugins when MetaSync has
257 * a deliberate SEO value.
258 *
259 * @return string
260 */
261 public function get_metasync_description() {
262 $post_id = $this->get_current_object_id();
263
264 if (!$post_id) {
265 return '';
266 }
267
268 // The full chain, from Metasync_Seo_Precedence. It has to be the same
269 // chain the description filters hand to a third-party plugin: if this
270 // reported a narrower set, MetaSync's own emitter would think it held
271 // nothing, emit anyway, and the page would carry two identical
272 // description tags.
273 return $this->metasync_description_value($post_id);
274 }
275
276 /**
277 * Return MetaSync's intentional SEO title for the current request.
278 *
279 * Mirrors get_metasync_description(): only values that were explicitly set
280 * (sidebar or OTTO), never an auto-generated fallback such as the post title.
281 * This is the value the og:title / twitter:title suppression keys off, so the
282 * sidebar emitter can render the same title back as a replacement.
283 *
284 * @return string
285 */
286 public function get_metasync_title() {
287 $post_id = $this->get_current_object_id();
288
289 if (!$post_id) {
290 return '';
291 }
292
293 // Post-scoped values only. On an archive $post_id is a term id and every
294 // read below would answer for an unrelated post. get_metasync_description()
295 // gates the same way through metasync_description_value().
296 if (!$this->current_object_is_post()) {
297 return '';
298 }
299
300 // 1. Social Media & Open Graph meta box (most specific — a social-only title)
301 $og_title = $this->get_customized_og_title($post_id);
302 if ($og_title !== '') {
303 return $og_title;
304 }
305
306 // 2. SEO sidebar (user-edited)
307 $title = get_post_meta($post_id, '_metasync_seo_title', true);
308 if (!empty($title)) {
309 return $title;
310 }
311
312 // 3. OTTO title
313 $title = get_post_meta($post_id, '_metasync_otto_title', true);
314 if (!empty($title)) {
315 return $title;
316 }
317
318 return '';
319 }
320
321 /**
322 * The per-post OG meta box title, but only when the user genuinely set it.
323 *
324 * The meta box pre-fills its Title from the post title and PERSISTS that
325 * default on save, so a non-empty `_metasync_og_title` alone does not prove
326 * intent — treating it as one would let an auto-filled post title override a
327 * deliberately-set SEO title on every ordinary edit. A value counts as the
328 * user's only when it differs from that default, the same comparison
329 * Otto_html_class::apply_metabox_og_precedence() makes.
330 *
331 * @param int $post_id
332 * @return string The customized OG title, or '' when unset or auto-filled.
333 */
334 private function get_customized_og_title($post_id) {
335 // Per-post OG meta box values only exist on singular views. On archives
336 // get_current_object_id() returns a TERM id, and reading post meta with it
337 // would consult an unrelated post — suppressing the third-party tag on a
338 // page where the singular-only replacement emitter never runs.
339 if (!is_singular()) {
340 return '';
341 }
342
343 $og_title = (string) get_post_meta($post_id, '_metasync_og_title', true);
344 if ($og_title === '') {
345 return '';
346 }
347
348 $post = get_post($post_id);
349 $default = ($post instanceof WP_Post) ? (string) $post->post_title : '';
350
351 return $og_title === $default ? '' : $og_title;
352 }
353
354 /**
355 * Whether this request suppressed a third-party plugin's og:title /
356 * twitter:title and therefore owes a replacement tag.
357 *
358 * Only true in the narrow case where the suppression leaves a gap:
359 * - Yoast or AIOSEO is active. Those are the only plugins whose og:title
360 * we filter; Rank Math / SEOPress / TSF render their own untouched, and
361 * on a MetaSync-only site Metasync_OpenGraph::output_opengraph_tags()
362 * still emits og:title itself — emitting here would duplicate it.
363 * - That plugin is not the primary output owner for the post (a synced
364 * post renders the plugin's own tags, so we must not double up).
365 * - The per-post OG toggle is not explicitly off (toggle off suppresses
366 * nothing, so nothing is owed).
367 * - MetaSync has an intentional title — the value that triggered the
368 * suppression in filter_yoast_og_title() / filter_aioseo_facebook_tags().
369 *
370 * Pages where OTTO owns og:title are deliberately excluded: OTTO injects its
371 * own tag through the output buffer, so there is no gap to fill.
372 *
373 * @return bool
374 */
375 public function og_title_needs_replacement() {
376 if (!$this->suppresses_third_party_og_title()) {
377 return false;
378 }
379
380 // Nothing to replace once the social filters are writing MetaSync's own
381 // og:title into the plugin's tag — emitting here too would duplicate it
382 // rather than fill a gap. This is only reached for a post that holds a
383 // page title and no OG-specific one.
384 $post_id_for_og = $this->get_current_object_id();
385 if ($post_id_for_og
386 && $this->metasync_social_value(Metasync_Seo_Precedence::FIELD_OG_TITLE, $post_id_for_og) !== '') {
387 return false;
388 }
389
390 $post_id = $this->get_current_object_id();
391 if (!$post_id) {
392 return false;
393 }
394
395 if ($this->og_output_disabled($post_id)) {
396 return false;
397 }
398
399 foreach (['yoast', 'rankmath', 'aioseo'] as $slug) {
400 if ($this->is_primary_output_plugin($post_id, $slug)) {
401 return false;
402 }
403 }
404
405 return $this->metasync_has_title($post_id);
406 }
407
408 /**
409 * Whether an active third-party plugin is one whose og:title we filter
410 * (Yoast or AIOSEO). Rank Math, SEOPress and The SEO Framework emit their
411 * own og:title untouched, so they never leave a gap to fill.
412 *
413 * @return bool
414 */
415 private function suppresses_third_party_og_title() {
416 $this->ensure_plugin_api();
417
418 return is_plugin_active('wordpress-seo/wp-seo.php')
419 || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')
420 || $this->is_aioseo_active();
421 }
422
423 /**
424 * Whether an active third-party SEO plugin renders the og:description /
425 * twitter:description for this request, so MetaSync must not add its own.
426 *
427 * Mirrors the condition the suppression filters use: when that plugin is the
428 * primary output owner, filter_yoast_og_description() (and the AIOSEO/Rank
429 * Math equivalents) pass its tag through untouched, so a second tag from the
430 * sidebar emitter would duplicate it.
431 *
432 * Deliberately does NOT ask whether the plugin's OG description field is
433 * populated. Yoast falls back to its meta description, and then to the
434 * excerpt, so it emits an og:description either way — an "is the field set"
435 * test would miss those fallbacks and let the duplicate through.
436 *
437 * @return bool
438 */
439 public function third_party_owns_og_description() {
440 if (!$this->has_active_seo_plugin()) {
441 return false;
442 }
443
444 // Per-post ownership only means anything on singular views. On archives
445 // get_current_object_id() returns a TERM id, and the sync timestamp would
446 // be read from an unrelated post of the same numeric id.
447 if (!is_singular()) {
448 return false;
449 }
450
451 $post_id = $this->get_current_object_id();
452 if (!$post_id) {
453 return false;
454 }
455
456 foreach (['yoast', 'rankmath', 'aioseo'] as $slug) {
457 if ($this->is_primary_output_plugin($post_id, $slug)) {
458 return true;
459 }
460 }
461
462 return false;
463 }
464
465 /**
466 * Resolve the MetaSync-managed canonical URL for a post, if any.
467 *
468 * Priority: OTTO persisted canonical (_metasync_canonical_url) → Canonical meta
469 * box value (meta_canonical). Restricted to singular views so term/archive
470 * queried-object ids are never misread as post ids. Mirrors the fallback in
471 * Metasync_Seo_Output::get_canonical_url() so both the no-SEO-plugin path and
472 * the third-party-plugin path honor the same value.
473 *
474 * @param int $post_id Current object id.
475 * @return string Escaped canonical URL, or '' when none is set.
476 */
477 private function get_metasync_canonical($post_id) {
478 if (!$post_id || !is_singular()) {
479 return '';
480 }
481
482 // Canonical feature switched off. Returning empty makes every canonical
483 // filter fall back to the third-party plugin's own value rather than
484 // overriding it with a MetaSync canonical the user has disabled.
485 if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::CANONICAL)) {
486 return '';
487 }
488
489 // Validate both sources: legacy rows corrupted to the literal "Array"
490 // (or stored as arrays) must never be emitted as a canonical.
491 $canonical = Metasync_Canonical_Sanitizer::sanitize(
492 get_post_meta($post_id, '_metasync_canonical_url', true)
493 );
494 if ($canonical === '') {
495 $canonical = Metasync_Canonical_Sanitizer::sanitize(
496 get_post_meta($post_id, 'meta_canonical', true)
497 );
498 }
499
500 return $canonical !== '' ? esc_url($canonical) : '';
501 }
502
503 /**
504 * Reset the cached description flag (useful when the queried object changes).
505 */
506 public function reset_cache() {
507 $this->has_description_cache = null;
508 $this->aioseo_has_description_cache = null;
509 $this->sync_cache = [];
510 $this->robots_suppress_cache = [];
511 $this->live_suggestions_cache = null;
512 }
513
514 /**
515 * Check whether a post has been synced to third-party plugins via.
516 *
517 * When native-first sync is active, each plugin reads MetaSync values from
518 * its own storage — no filter suppression needed. This method returns true
519 * when _metasync_plugin_sync_ts exists and contains a timestamp for the
520 * given plugin slug.
521 *
522 * @param int $post_id Post ID.
523 * @param string $plugin_slug Plugin slug: 'yoast', 'rankmath', or 'aioseo'.
524 * @return bool True if the post has been synced to this plugin.
525 */
526 private function is_post_synced($post_id, $plugin_slug) {
527 if ($post_id <= 0) {
528 return false;
529 }
530
531 if (!isset($this->sync_cache[$post_id])) {
532 $ts_raw = get_post_meta($post_id, '_metasync_plugin_sync_ts', true);
533 $this->sync_cache[$post_id] = !empty($ts_raw) ? json_decode($ts_raw, true) : [];
534 if (!is_array($this->sync_cache[$post_id])) {
535 $this->sync_cache[$post_id] = [];
536 }
537 }
538
539 return !empty($this->sync_cache[$post_id][$plugin_slug]);
540 }
541
542 /**
543 * For synced posts with multiple SEO plugins active, determine if a
544 * specific plugin is the designated output owner.
545 *
546 * Only the first active plugin in priority order (Yoast > Rank Math > AIOSEO)
547 * is allowed to output — the others are suppressed to prevent duplicate tags.
548 *
549 * @param int $post_id Post ID.
550 * @param string $plugin_slug Plugin slug to check.
551 * @return bool True if this plugin should output its tags.
552 */
553 private function is_primary_output_plugin($post_id, $plugin_slug) {
554 // For synced posts: only the primary synced plugin passes through.
555 // For unsynced posts with multiple plugins: only the highest-priority
556 // active plugin outputs to prevent duplicate tags.
557 $this->ensure_plugin_api();
558 $priority = ['yoast', 'rankmath', 'aioseo'];
559 $active_check = [
560 'yoast' => is_plugin_active('wordpress-seo/wp-seo.php') || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php'),
561 'rankmath' => is_plugin_active('seo-by-rank-math/rank-math.php') || is_plugin_active('seo-by-rankmath/rank-math.php'),
562 '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'),
563 ];
564
565 // The sync stamp is post meta. On an archive $post_id is a term id, so
566 // consulting it would adopt an unrelated post's sync state and hand this
567 // archive's output to the wrong plugin. Archives fall through to the
568 // unsynced branch below, which is what they are.
569 $has_any_sync = $post_id > 0
570 && $this->current_object_is_post()
571 && ($this->is_post_synced($post_id, 'yoast') || $this->is_post_synced($post_id, 'rankmath') || $this->is_post_synced($post_id, 'aioseo'));
572
573 if ($has_any_sync) {
574 // Synced: primary = first active + synced plugin
575 foreach ($priority as $slug) {
576 if ($active_check[$slug] && $this->is_post_synced($post_id, $slug)) {
577 return $slug === $plugin_slug;
578 }
579 }
580 return false;
581 }
582
583 // Unsynced / multiple plugins active: pick the first active plugin
584 // as the sole outputter to prevent duplicate tags.
585 $active_count = count(array_filter($active_check));
586 if ($active_count > 1) {
587 foreach ($priority as $slug) {
588 if ($active_check[$slug]) {
589 return $slug === $plugin_slug;
590 }
591 }
592 }
593
594 // Single plugin active or no plugins — don't interfere
595 return false;
596 }
597
598 /**
599 * Ensure is_plugin_active() is loaded on the frontend.
600 */
601 private function ensure_plugin_api() {
602 if (!function_exists('is_plugin_active')) {
603 require_once ABSPATH . 'wp-admin/includes/plugin.php';
604 }
605 }
606
607 // ------------------------------------------------------------------
608 // AIOSEO integration
609 // ------------------------------------------------------------------
610
611 /**
612 * Register AIOSEO-specific filters to suppress its output
613 * when MetaSync/OTTO already provides the same tags.
614 */
615 private function register_aioseo_filters() {
616 // Suppress AIOSEO meta description
617 add_filter('aioseo_description', [$this, 'filter_aioseo_description'], 999);
618
619 // Suppress AIOSEO title
620 add_filter('aioseo_title', [$this, 'filter_aioseo_title'], 999);
621
622 // Suppress AIOSEO OG/Twitter tags that OTTO already provides
623 add_filter('aioseo_facebook_tags', [$this, 'filter_aioseo_facebook_tags'], 999);
624 add_filter('aioseo_twitter_tags', [$this, 'filter_aioseo_twitter_tags'], 999);
625
626 // Suppress AIOSEO robots when MetaSync has an intentional robots value
627 add_filter('aioseo_robots_meta', [$this, 'filter_aioseo_robots'], 999);
628
629 // Suppress AIOSEO schema/JSON-LD when OTTO has structured data
630 add_filter('aioseo_schema_output', [$this, 'filter_aioseo_schema'], 999);
631
632 // Canonical — override AIOSEO's canonical with the MetaSync/OTTO value when set
633 add_filter('aioseo_canonical_url', [$this, 'filter_aioseo_canonical'], 999);
634 }
635
636 /**
637 * Filter AIOSEO's canonical URL.
638 *
639 * Mirrors filter_yoast_canonical(): return the MetaSync-managed canonical
640 * (OTTO or the Canonical meta box) when set, else pass AIOSEO's through.
641 */
642 public function filter_aioseo_canonical($canonical) {
643 $custom = $this->get_metasync_canonical($this->get_current_object_id());
644 return $custom !== '' ? $custom : $canonical;
645 }
646
647 /**
648 * Filter AIOSEO description output.
649 * Returns empty string when MetaSync has a description, letting MetaSync output it.
650 *
651 * @param string $description AIOSEO's computed description.
652 * @return string
653 */
654 public function filter_aioseo_description($description) {
655 // Cache whether AIOSEO actually has a description (before we suppress it).
656 // This is used later by should_output_legacy_description().
657 if ($this->aioseo_has_description_cache === null) {
658 $this->aioseo_has_description_cache = !empty($description);
659 }
660
661 $post_id = $this->get_current_object_id();
662
663 // If we hold a description, we write it. The sync stamp records that a
664 // sync happened; it does not decide who renders. The mirrored copy lives
665 // in the plugin's own storage and can be edited or overwritten there —
666 // deferring to it hands our tag over with nothing saying so.
667 $ours = $this->metasync_description_value($post_id);
668 if ($ours !== '') {
669 return $ours;
670 }
671
672 // Primary plugin check — only the designated plugin outputs.
673 if ($post_id && $this->has_active_seo_plugin()) {
674 if ($this->is_primary_output_plugin($post_id, 'aioseo')) {
675 return $description;
676 }
677 if ($this->is_primary_output_plugin($post_id, 'yoast') || $this->is_primary_output_plugin($post_id, 'rankmath')) {
678 return '';
679 }
680 }
681
682 // Term archives: AIOSEO free doesn't read per-term custom descriptions
683 // from its `wp_aioseo_terms` table, so return the MetaSync value
684 // directly so AIOSEO renders it.
685 $term = $this->get_current_term();
686 if ($term) {
687 $term_desc = $this->get_metasync_term_description($term);
688 if (!empty($term_desc)) {
689 return $term_desc;
690 }
691 }
692
693 // Suppress when: OTTO active + has description, OR MetaSync sidebar has description
694 if ($this->otto_has_tag('description') || $this->metasync_has_description()) {
695 return '';
696 }
697 return $description;
698 }
699
700 /**
701 * Filter AIOSEO title output.
702 *
703 * On term archives: AIOSEO free doesn't read custom per-term titles from
704 * its `wp_aioseo_terms` table, so we replace AIOSEO's template-based title
705 * with the MetaSync term title directly.
706 *
707 * @param string $title AIOSEO's computed title.
708 * @return string
709 */
710 public function filter_aioseo_title($title) {
711 // Primary plugin check — only the designated plugin outputs.
712 $post_id = $this->get_current_object_id();
713
714 // If we hold a title, we write it — the same rule the description path
715 // follows. The sync stamp records that a sync happened; it does not decide
716 // who renders. The mirrored copy lives in the plugin's own storage and can
717 // be edited there, and deferring to it hands our tag over silently.
718 //
719 // A value, never '': on classic themes the plugin owns the sole <title>
720 // renderer and OTTO's buffer replaces the tag afterwards, so blanking it
721 // would leave nothing for OTTO to replace.
722 $ours = $this->metasync_title_value($post_id);
723 if ($ours !== '') {
724 return $ours;
725 }
726
727 // Taxonomy archives resolve against term meta, not post meta. Resolve
728 // the term before plugin ownership checks so a multi-plugin archive is
729 // not suppressed by an unrelated primary-plugin decision.
730 $term = $this->get_current_term();
731 if ($term) {
732 $term_title = $this->get_metasync_term_title($term);
733 if (!empty($term_title)) {
734 return $term_title;
735 }
736 }
737
738 if ($post_id && $this->has_active_seo_plugin()) {
739 if ($this->is_primary_output_plugin($post_id, 'aioseo')) {
740 return $title;
741 }
742 if ($this->is_primary_output_plugin($post_id, 'yoast') || $this->is_primary_output_plugin($post_id, 'rankmath')) {
743 return '';
744 }
745 }
746
747 if ($this->should_suppress_third_party_title()) {
748 return '';
749 }
750
751 return $title;
752 }
753
754 /**
755 * Filter AIOSEO Facebook/OG tags.
756 *
757 * Per-tag suppression: only remove a tag when OTTO is active AND has
758 * a persisted value for that specific tag, OR when MetaSync sidebar
759 * provides the equivalent value.
760 *
761 * @param array $meta AIOSEO's OG meta array.
762 * @return array
763 */
764 public function filter_aioseo_facebook_tags($meta) {
765 if (!is_array($meta)) {
766 return $meta;
767 }
768
769 $post_id = $this->get_current_object_id();
770
771 // Per-post OG toggle off — MetaSync emits no OG, so leave AIOSEO's tags intact.
772 if ($this->og_output_disabled($post_id)) {
773 return $meta;
774 }
775
776 // og:title — ours when we hold one, otherwise drop it only if OTTO is
777 // injecting its own through the buffer. The sync stamp no longer decides
778 // this: AIOSEO's own copy can be edited there and would silently win.
779 $ours = $this->metasync_social_value(Metasync_Seo_Precedence::FIELD_OG_TITLE, $post_id);
780 if ($ours !== '') {
781 $meta['og:title'] = $ours;
782 } elseif ($this->otto_has_tag('og:title')) {
783 unset($meta['og:title']);
784 }
785
786 // og:description — same rule.
787 $ours = $this->metasync_social_value(Metasync_Seo_Precedence::FIELD_OG_DESCRIPTION, $post_id);
788 if ($ours !== '') {
789 $meta['og:description'] = $ours;
790 } elseif ($this->otto_has_tag('og:description')) {
791 unset($meta['og:description']);
792 }
793
794 // og:url, og:type, og:locale, og:site_name — suppress when OTTO has og:title
795 // (OTTO injects these structural OG tags alongside og:title in its block).
796 //
797 // Still skipped for a post synced to AIOSEO. Only the two tags above stopped
798 // deferring to the sync stamp; these structural tags carry no MetaSync value
799 // to put in their place, so changing when they are dropped is not this
800 // change's business.
801 if (!($post_id && $this->is_primary_output_plugin($post_id, 'aioseo'))
802 && $this->otto_has_tag('og:title')) {
803 unset($meta['og:url'], $meta['og:type'], $meta['og:locale'], $meta['og:site_name']);
804 }
805
806 return $meta;
807 }
808
809 /**
810 * Filter AIOSEO Twitter tags.
811 *
812 * Per-tag suppression: only remove a tag when OTTO is active AND has
813 * a persisted value for that specific tag, OR when MetaSync sidebar
814 * provides the equivalent value.
815 *
816 * @param array $meta AIOSEO's Twitter meta array.
817 * @return array
818 */
819 public function filter_aioseo_twitter_tags($meta) {
820 if (!is_array($meta)) {
821 return $meta;
822 }
823
824 $post_id = $this->get_current_object_id();
825
826 // Per-post OG toggle off — MetaSync emits no Twitter tags, so leave AIOSEO's intact.
827 if ($this->og_output_disabled($post_id)) {
828 return $meta;
829 }
830
831 // See filter_aioseo_facebook_tags() for why the sync stamp no longer
832 // decides who renders these.
833 $ours = $this->metasync_social_value(Metasync_Seo_Precedence::FIELD_TWITTER_TITLE, $post_id);
834 if ($ours !== '') {
835 $meta['twitter:title'] = $ours;
836 } elseif ($this->otto_has_tag('twitter:title')) {
837 unset($meta['twitter:title']);
838 }
839
840 $ours = $this->metasync_social_value(Metasync_Seo_Precedence::FIELD_TWITTER_DESCRIPTION, $post_id);
841 if ($ours !== '') {
842 $meta['twitter:description'] = $ours;
843 } elseif ($this->otto_has_tag('twitter:description')) {
844 unset($meta['twitter:description']);
845 }
846
847 // twitter:card — suppress when OTTO has any twitter tag. See the
848 // structural block in filter_aioseo_facebook_tags() for why this one
849 // still defers to the sync stamp.
850 if (!($post_id && $this->is_primary_output_plugin($post_id, 'aioseo'))
851 && ($this->otto_has_tag('twitter:title') || $this->otto_has_tag('twitter:description'))) {
852 unset($meta['twitter:card']);
853 }
854
855 return $meta;
856 }
857
858 /**
859 * Shared suppression decision for a third-party plugin's robots meta tag.
860 *
861 * Both the AIOSEO and Rank Math robots filters consult this so their
862 * behaviour stays identical by construction: MetaSync holds an
863 * intentional robots value for the current post, the post is not
864 * synced to that plugin as its primary output owner, and the current
865 * view is one where MetaSync actually emits its own robots tag.
866 *
867 * The singular guard matters on taxonomy archives: there
868 * get_current_object_id() returns a TERM id, and reading post meta
869 * with it would consult an unrelated post — suppressing the
870 * third-party tag on a page where MetaSync's replacement emitter
871 * never runs (hook_metasync_metatags() returns early off-singular),
872 * leaving the archive with no robots meta at all.
873 *
874 * @param string $plugin_slug Plugin slug ('aioseo' or 'rankmath').
875 * @return bool True when the third-party robots tag should be removed.
876 */
877 private function should_suppress_third_party_robots($plugin_slug) {
878 if (!isset($this->robots_suppress_cache[$plugin_slug])) {
879 $this->robots_suppress_cache[$plugin_slug] = $this->compute_should_suppress_third_party_robots($plugin_slug);
880 }
881 return $this->robots_suppress_cache[$plugin_slug];
882 }
883
884 /**
885 * Uncached suppression decision; see should_suppress_third_party_robots().
886 *
887 * @param string $plugin_slug Plugin slug ('aioseo' or 'rankmath').
888 * @return bool True when the third-party robots tag should be removed.
889 */
890 private function compute_should_suppress_third_party_robots($plugin_slug) {
891 $post_id = $this->get_current_object_id();
892 if (!$post_id || !is_singular()) {
893 return false;
894 }
895
896 // Post synced to this plugin — let it read from its own storage.
897 if ($this->is_primary_output_plugin($post_id, $plugin_slug)) {
898 return false;
899 }
900
901 return $this->metasync_has_robots($post_id);
902 }
903
904 /**
905 * Filter AIOSEO robots meta output.
906 *
907 * When MetaSync has an intentional robots value (admin checkbox or REST API),
908 * suppress AIOSEO's robots tag to avoid duplicates. MetaSync's own output in
909 * hook_metasync_metatags() will output the MetaSync value instead.
910 *
911 * AIOSEO passes an array like ['noindex' => 'noindex', 'nofollow' => 'nofollow'].
912 * Returning an empty array suppresses AIOSEO's robots tag entirely. Unlike
913 * the Rank Math path this keeps no noindex floor on non-public sites —
914 * AIOSEO's array shape for that case is not yet verified, so the floor is
915 * deliberately Rank Math-only until it can be confirmed against AIOSEO.
916 *
917 * @param array $robots AIOSEO's computed robots attributes array.
918 * @return array
919 */
920 public function filter_aioseo_robots($robots) {
921 if ($this->should_suppress_third_party_robots('aioseo')) {
922 // MetaSync has robots — suppress AIOSEO's tag.
923 return [];
924 }
925
926 return $robots;
927 }
928
929 /**
930 * Filter AIOSEO schema/JSON-LD output.
931 * Suppress when OTTO has structured data for the current page.
932 * Also strip BreadcrumbList entries when MetaSync breadcrumbs are enabled,
933 * so MetaSync's own BreadcrumbList is the only one on the page.
934 *
935 * @param array $output AIOSEO's @graph array.
936 * @return array
937 */
938 public function filter_aioseo_schema($output) {
939 if ($this->otto_has_schema_for_current_page()) {
940 return [];
941 }
942
943 if ($this->metasync_breadcrumb_enabled() && is_array($output)) {
944 $output = $this->strip_breadcrumb_from_graph($output);
945 }
946
947 return $output;
948 }
949
950 /**
951 * Check whether MetaSync holds an intentional robots directive for a post.
952 *
953 * Checks both storage formats:
954 * - meta_robots (string from REST API)
955 * - metasync_common_robots (array from admin checkbox)
956 *
957 * @param int $post_id Post ID.
958 * @return bool
959 */
960 public function metasync_has_robots($post_id) {
961 // Both robots features switched off: MetaSync has no robots value to
962 // offer, so it must not claim ownership of the tag. Reporting false here
963 // also stops the third-party robots suppression filters, leaving the
964 // other plugin's directives in place instead of blanking them.
965 if (Metasync_Feature_Flags::robots_fully_disabled()) {
966 return false;
967 }
968
969 // Both storage formats below hold common-half directives (noindex,
970 // nofollow, noarchive, nosnippet, noimageindex). With that half switched
971 // off the emitter drops every one of them, so claiming the tag here
972 // would suppress the third party's robots tag and then emit nothing —
973 // leaving a page that asked to be noindex fully indexable. The advanced
974 // half being on does not help: it owns only the max-* directives, which
975 // neither of these keys carries.
976 if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::COMMON_ROBOTS)) {
977 return false;
978 }
979
980 $meta_robots = get_post_meta($post_id, 'meta_robots', true);
981 if (!empty($meta_robots)) {
982 return true;
983 }
984
985 $common_robots = get_post_meta($post_id, 'metasync_common_robots', true);
986 if (is_array($common_robots) && !empty(array_filter($common_robots))) {
987 return true;
988 }
989
990 return false;
991 }
992
993 /**
994 * Check whether MetaSync/OTTO has a title for a given post.
995 *
996 * @param int $post_id Post ID.
997 * @return bool
998 */
999 private function metasync_has_title($post_id) {
1000 // Term archives answer from term meta, and answer FIRST: $post_id is the
1001 // term's id here, so the post-meta reads below would consult whatever
1002 // post shares that number and report its title as this archive's.
1003 $term = $this->get_current_term();
1004 if ($term) {
1005 return !empty($this->get_metasync_term_title($term));
1006 }
1007
1008 // Social Media & Open Graph meta box, when genuinely customized. Counted
1009 // here so a post whose ONLY title is the OG one still suppresses the
1010 // third-party tag — otherwise the plugin keeps rendering its own and the
1011 // replacement below never gets the chance to emit.
1012 if ($this->get_customized_og_title($post_id) !== '') {
1013 return true;
1014 }
1015
1016 $seo_title = get_post_meta($post_id, '_metasync_seo_title', true);
1017 if (!empty($seo_title)) {
1018 return true;
1019 }
1020
1021 $otto_title = get_post_meta($post_id, '_metasync_otto_title', true);
1022 if (!empty($otto_title)) {
1023 return true;
1024 }
1025
1026 return false;
1027 }
1028
1029 /**
1030 * Determine whether a third-party SEO plugin's title should be suppressed.
1031 *
1032 * Suppress when either condition is met:
1033 * 1. OTTO is active AND has a persisted title for this page
1034 * 2. MetaSync sidebar has an explicit title for this page
1035 *
1036 * @return bool True if the third-party title should be suppressed.
1037 */
1038 private function should_suppress_third_party_title() {
1039 // Condition 1: OTTO active + has title for this page
1040 if ($this->otto_has_tag('title')) {
1041 return true;
1042 }
1043
1044 // Condition 2: MetaSync sidebar has explicit title
1045 $post_id = $this->get_current_object_id();
1046 if ($post_id) {
1047 return $this->metasync_has_title($post_id);
1048 }
1049
1050 return false;
1051 }
1052
1053 /**
1054 * Check whether the OTTO pixel is active.
1055 *
1056 * @return bool
1057 */
1058 private function is_otto_active() {
1059 if (class_exists('Metasync_Otto_Config')) {
1060 return Metasync_Otto_Config::is_otto_enabled();
1061 }
1062
1063 return false;
1064 }
1065
1066 /**
1067 * Check whether the OTTO transient cache has live suggestions for the
1068 * current request URL.
1069 *
1070 * Passive get_transient() lookup only — no OTTO API call. Mirrors the
1071 * cache-key format used by Metasync_Otto_Transient_Cache and the URL
1072 * construction from Otto_pixel_class::get_route().
1073 *
1074 * @return bool
1075 */
1076 public function otto_has_live_suggestions() {
1077 if ($this->live_suggestions_cache !== null) {
1078 return $this->live_suggestions_cache;
1079 }
1080
1081 if (!$this->is_otto_active()) {
1082 $this->live_suggestions_cache = false;
1083 return false;
1084 }
1085
1086 if (empty($_SERVER['HTTP_HOST']) || empty($_SERVER['REQUEST_URI'])) {
1087 $this->live_suggestions_cache = false;
1088 return false;
1089 }
1090
1091 $scheme = is_ssl() ? 'https' : 'http';
1092 $host = $_SERVER['HTTP_HOST'];
1093 $request_uri = strtok($_SERVER['REQUEST_URI'], '?') ?: $_SERVER['REQUEST_URI'];
1094 $url = $scheme . '://' . $host . $request_uri;
1095
1096 $hash = md5(rtrim(strtolower($url), '/'));
1097 $site_id = is_multisite() ? get_current_blog_id() : 0;
1098 $cached = get_transient('otto_suggestions_' . $site_id . '_' . $hash);
1099
1100 $this->live_suggestions_cache = ($cached !== false && !empty($cached));
1101 return $this->live_suggestions_cache;
1102 }
1103
1104 /**
1105 * Check whether OTTO has a persisted value for a specific meta tag.
1106 *
1107 * Two conditions must be true to suppress a third-party tag:
1108 * 1. OTTO is active (globally enabled)
1109 * 2. OTTO has a value for this specific tag on the current page
1110 *
1111 * For OG/Twitter tags where OTTO's pixel injects dynamically (the
1112 * specific _metasync_otto_og_* key may be empty), the buffer-level
1113 * dedup in Otto_html_class::deduplicate_og_twitter_tags() handles
1114 * removal after all sources have output. This method only does the
1115 * direct per-tag check.
1116 *
1117 * @param string $tag Tag identifier (e.g. 'title', 'og:title', 'twitter:description').
1118 * @return bool True when OTTO is active AND has a persisted value for this tag.
1119 */
1120 private function otto_has_tag($tag) {
1121 if (!$this->is_otto_active()) {
1122 return false;
1123 }
1124
1125 $post_id = $this->get_current_object_id();
1126 if (!$post_id) {
1127 return false;
1128 }
1129
1130 // OTTO stores nothing in term meta — on an archive it arrives through the
1131 // output-buffer pass instead. Without this the post-meta read below would
1132 // report an unrelated post's OTTO tag as this archive's.
1133 if (!$this->current_object_is_post()) {
1134 return false;
1135 }
1136
1137 if ($this->has_active_seo_plugin() && !$this->otto_has_live_suggestions()) {
1138 return false;
1139 }
1140
1141 $meta_key_map = [
1142 'title' => '_metasync_otto_title',
1143 'description' => '_metasync_otto_description',
1144 'og:title' => '_metasync_otto_og_title',
1145 'og:description' => '_metasync_otto_og_description',
1146 'twitter:title' => '_metasync_otto_twitter_title',
1147 'twitter:description' => '_metasync_otto_twitter_description',
1148 ];
1149
1150 if (!isset($meta_key_map[$tag])) {
1151 return false;
1152 }
1153
1154 return !empty(get_post_meta($post_id, $meta_key_map[$tag], true));
1155 }
1156
1157 /**
1158 * Whether the per-post "Enable Open Graph & Social Media Tags" toggle is
1159 * explicitly turned off for the given object.
1160 *
1161 * Mirrors the guard in Metasync_OpenGraph::will_emit()/output_opengraph_tags():
1162 * only an explicit '0' opt-out disables MetaSync's OG/Twitter output; an
1163 * unset/empty value counts as enabled. When disabled, MetaSync emits no
1164 * OG/Twitter tags of its own, so it must NOT strip a third-party SEO
1165 * plugin's OG/Twitter tags either — otherwise the page is left with none.
1166 *
1167 * @param int $post_id
1168 * @return bool
1169 */
1170 private function og_output_disabled($post_id) {
1171 // Feature switched off site-wide. Checked before the per-post opt-out so
1172 // it applies even when no post is in scope: MetaSync emits no social tags,
1173 // so it must leave the third-party plugin's alone.
1174 if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) {
1175 return true;
1176 }
1177
1178 return $post_id && get_post_meta($post_id, '_metasync_og_enabled', true) === '0';
1179 }
1180
1181 // ------------------------------------------------------------------
1182 // Yoast SEO integration
1183 // ------------------------------------------------------------------
1184
1185 /**
1186 * Register Yoast SEO-specific filters to suppress its title,
1187 * description, and OG/Twitter output when MetaSync/OTTO provides them.
1188 */
1189 private function register_yoast_filters() {
1190 add_filter('wpseo_title', [$this, 'filter_yoast_title'], 999);
1191 add_filter('wpseo_metadesc', [$this, 'filter_yoast_description'], 999);
1192
1193 // OG tags — per-tag suppression
1194 add_filter('wpseo_opengraph_title', [$this, 'filter_yoast_og_title'], 999);
1195 add_filter('wpseo_opengraph_desc', [$this, 'filter_yoast_og_description'], 999);
1196 add_filter('wpseo_opengraph_url', [$this, 'filter_yoast_og_structural'], 999);
1197 add_filter('wpseo_opengraph_type', [$this, 'filter_yoast_og_structural'], 999);
1198 add_filter('wpseo_opengraph_site_name', [$this, 'filter_yoast_og_structural'], 999);
1199 add_filter('wpseo_og_locale', [$this, 'filter_yoast_og_structural'], 999);
1200 add_filter('wpseo_opengraph_image', [$this, 'filter_yoast_og_structural'], 999);
1201
1202 // Twitter tags — per-tag suppression
1203 add_filter('wpseo_twitter_title', [$this, 'filter_yoast_twitter_title'], 999);
1204 add_filter('wpseo_twitter_description', [$this, 'filter_yoast_twitter_description'], 999);
1205 add_filter('wpseo_twitter_image', [$this, 'filter_yoast_twitter_structural'], 999);
1206 add_filter('wpseo_twitter_card_type', [$this, 'filter_yoast_twitter_structural'], 999);
1207
1208 // Suppress Yoast schema/JSON-LD when OTTO has structured data
1209 add_filter('wpseo_schema_graph', [$this, 'filter_yoast_schema'], 999);
1210
1211 // Canonical — override Yoast's canonical with the MetaSync/OTTO value when set
1212 add_filter('wpseo_canonical', [$this, 'filter_yoast_canonical'], 999);
1213 }
1214
1215 /**
1216 * Filter Yoast's canonical URL.
1217 *
1218 * When a MetaSync-managed canonical exists (OTTO or the Canonical meta box),
1219 * return it so Yoast emits our value instead of its own — avoiding a duplicate
1220 * <link rel="canonical"> while still honoring the per-post override.
1221 * Otherwise let Yoast's canonical through unchanged.
1222 */
1223 public function filter_yoast_canonical($canonical) {
1224 $custom = $this->get_metasync_canonical($this->get_current_object_id());
1225 return $custom !== '' ? $custom : $canonical;
1226 }
1227
1228 /**
1229 * Filter Yoast SEO title output.
1230 *
1231 * When the MetaSync sidebar has an explicit SEO title, return that title so
1232 * Yoast's Title_Presenter renders it inside the <title> tag it controls.
1233 * Returning '' would cause Title_Presenter to emit NO <title> tag at all,
1234 * because Yoast has already removed WordPress's native _wp_render_title_tag
1235 * action and is the sole renderer of the title element.
1236 *
1237 * When only OTTO has a title (no sidebar override), we let Yoast output its
1238 * own title normally — OTTO's buffer post-processing replaces it in the final
1239 * HTML. Returning '' here would again leave the page with no <title> tag.
1240 */
1241 public function filter_yoast_title($title) {
1242 $post_id = $this->get_current_object_id();
1243
1244 // If we hold a title, we write it — the same rule the description path
1245 // follows. The sync stamp records that a sync happened; it does not decide
1246 // who renders. The mirrored copy lives in the plugin's own storage and can
1247 // be edited there, and deferring to it hands our tag over silently.
1248 //
1249 // A value, never '': on classic themes the plugin owns the sole <title>
1250 // renderer and OTTO's buffer replaces the tag afterwards, so blanking it
1251 // would leave nothing for OTTO to replace.
1252 $ours = $this->metasync_title_value($post_id);
1253 if ($ours !== '') {
1254 return $ours;
1255 }
1256
1257
1258 // Taxonomy archives resolve against term meta, not post meta. Resolve
1259 // the term before plugin ownership checks so a multi-plugin archive is
1260 // not suppressed by an unrelated primary-plugin decision.
1261 $term = $this->get_current_term();
1262 if ($term) {
1263 $term_title = $this->get_metasync_term_title($term);
1264 if (!empty($term_title)) {
1265 return $term_title;
1266 }
1267 }
1268
1269 // When Yoast is the primary output plugin, let it through.
1270 // For synced posts, Yoast reads from its own storage (already has the value).
1271 // For unsynced posts as primary, still check for MetaSync sidebar override.
1272 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1273 // Even in passthrough, a MetaSync title takes precedence. Resolved
1274 // through metasync_title_value() rather than read straight off
1275 // `_metasync_seo_title`: that skipped the rest of the chain, and on
1276 // an archive it read post meta with a term id, answering with
1277 // whatever post happened to share that number.
1278 $sidebar_title = $this->metasync_title_value($post_id);
1279 if ($sidebar_title !== '') {
1280 return $sidebar_title;
1281 }
1282 return $title;
1283 }
1284
1285 // Another plugin is primary — suppress Yoast.
1286 if ($post_id && $this->has_active_seo_plugin()) {
1287 if ($this->is_primary_output_plugin($post_id, 'rankmath') || $this->is_primary_output_plugin($post_id, 'aioseo')) {
1288 return '';
1289 }
1290 }
1291
1292 // MetaSync sidebar has an explicit title — return it so Yoast renders it.
1293 if ($post_id) {
1294 $sidebar_title = $this->metasync_title_value($post_id);
1295 if ($sidebar_title !== '') {
1296 return $sidebar_title;
1297 }
1298 }
1299
1300 // Case 2: OTTO has a persisted title — do NOT suppress Yoast here.
1301 // OTTO's output-buffer post-processing (Otto_html_class) replaces the
1302 // <title> tag in the final HTML after WordPress renders. Returning '' would
1303 // remove the <title> tag entirely before OTTO can inject its replacement.
1304
1305 return $title;
1306 }
1307
1308 /**
1309 * Filter Yoast SEO description output.
1310 *
1311 * On term archives: return MetaSync's resolved term value directly. The
1312 * native plugin field may be empty even when the precedence chain has a
1313 * value, and MetaSync's own archive emitter does not provide a fallback.
1314 *
1315 * On singular pages: suppress when OTTO or MetaSync sidebar provides
1316 * the description (MetaSync outputs its own tag).
1317 */
1318 /**
1319 * Replace a mirrored OTTO description that is dead data for this post.
1320 *
1321 * MetaSync syncs its resolved description into the active plugin's own
1322 * storage, so that plugin renders it. When the per-post "Disable OTTO"
1323 * toggle is set, OTTO stands down everywhere else — but the copy already
1324 * written to the third-party plugin keeps rendering, and OTTO reads as
1325 * "disabled but still leaking SEO".
1326 *
1327 * Only acts when the incoming value is byte-identical to OTTO's stored
1328 * description: that proves the sync wrote it, so a description the customer
1329 * typed into Rank Math or Yoast themselves is never touched. Falls through
1330 * the same precedence the rest of the plugin uses — customer-typed, then
1331 * imported — and suppresses if neither exists.
1332 *
1333 * @param string $description Description the third-party plugin is about to emit.
1334 * @param int $post_id Post ID.
1335 * @return string|null Replacement, or null to leave the value alone.
1336 */
1337 private function replace_dead_otto_description($description, $post_id) {
1338 if (!$post_id || !class_exists('Metasync_Otto_Frontend_Toolbar')) {
1339 return null;
1340 }
1341
1342 // The per-post OTTO toggle and OTTO's stored description are post-scoped.
1343 // On an archive $post_id is a term id, and the byte-comparison below would
1344 // match an unrelated post's OTTO description and return that post's value
1345 // as this archive's.
1346 if (!$this->current_object_is_post()) {
1347 return null;
1348 }
1349
1350 if (!Metasync_Otto_Frontend_Toolbar::is_otto_disabled($post_id)) {
1351 return null;
1352 }
1353
1354 $otto_desc = get_post_meta($post_id, '_metasync_otto_description', true);
1355 if (empty($otto_desc) || (string) $description !== (string) $otto_desc) {
1356 return null;
1357 }
1358
1359 // Falls through the same chain as everywhere else, with the OTTO tiers
1360 // dropped because OTTO has stood down for this post. Suppresses when
1361 // nothing is left.
1362 if (!self::precedence_available()) {
1363 return '';
1364 }
1365
1366 return Metasync_Seo_Precedence::value(
1367 $post_id,
1368 Metasync_Seo_Precedence::FIELD_DESCRIPTION,
1369 Metasync_Seo_Precedence::TYPE_POST,
1370 array('include_otto' => false)
1371 );
1372 }
1373
1374 public function filter_yoast_description($description) {
1375 $post_id = $this->get_current_object_id();
1376
1377 // A synced OTTO description is dead data once OTTO is switched off
1378 // for this post; serving it would leak SEO OTTO has stood down from.
1379 $replacement = $this->replace_dead_otto_description($description, $post_id);
1380 if ($replacement !== null) {
1381 return $replacement;
1382 }
1383
1384 // If we hold a description, we write it. The sync stamp records that a
1385 // sync happened; it does not decide who renders. The mirrored copy lives
1386 // in the plugin's own storage and can be edited or overwritten there —
1387 // deferring to it hands our tag over with nothing saying so.
1388 $ours = $this->metasync_description_value($post_id);
1389 if ($ours !== '') {
1390 return $ours;
1391 }
1392
1393 // On taxonomy archives the queried-object id is a term id, not a post
1394 // id. Yoast may have no native term description to return (the value
1395 // can exist only in MetaSync's precedence tiers), so hand it the
1396 // resolved term value directly. MetaSync's own wp_head emitter exits
1397 // early for archives and cannot provide a fallback tag.
1398 $term = $this->get_current_term();
1399 if ($term) {
1400 $term_desc = $this->get_metasync_term_description($term);
1401 if (!empty($term_desc)) {
1402 return $term_desc;
1403 }
1404 }
1405
1406 if ($post_id && $this->has_active_seo_plugin()) {
1407 if ($this->is_primary_output_plugin($post_id, 'yoast')) {
1408 return $description;
1409 }
1410 if ($this->is_primary_output_plugin($post_id, 'rankmath') || $this->is_primary_output_plugin($post_id, 'aioseo')) {
1411 return '';
1412 }
1413 }
1414
1415 if ($this->otto_has_tag('description') || $this->metasync_has_description()) {
1416 return '';
1417 }
1418 return $description;
1419 }
1420
1421 /**
1422 * Filter Yoast og:title output.
1423 * Suppress when: OTTO active + has og:title, OR MetaSync has title.
1424 */
1425 public function filter_yoast_og_title($value) {
1426 $post_id = $this->get_current_object_id();
1427 if ($this->og_output_disabled($post_id)) {
1428 return $value;
1429 }
1430
1431 // If we hold a value, we write it. The sync stamp records that a sync
1432 // happened; it does not decide who renders. The mirrored copy in the
1433 // plugin's own storage can be edited or overwritten there, and deferring
1434 // to it hands our tag over without anything saying so.
1435 $ours = $this->metasync_social_value(Metasync_Seo_Precedence::FIELD_OG_TITLE, $post_id);
1436 if ($ours !== '') {
1437 return $ours;
1438 }
1439
1440 // OTTO injects some social tags through the output buffer without ever
1441 // persisting them, so its own tag check still has to run.
1442 if ($this->otto_has_tag('og:title')) {
1443 return '';
1444 }
1445
1446 return $value;
1447 }
1448
1449 /**
1450 * Filter Yoast og:description output.
1451 * Suppress when: OTTO active + has og:description, OR MetaSync has description.
1452 */
1453 public function filter_yoast_og_description($value) {
1454 $post_id = $this->get_current_object_id();
1455 if ($this->og_output_disabled($post_id)) {
1456 return $value;
1457 }
1458
1459 // If we hold a value, we write it. The sync stamp records that a sync
1460 // happened; it does not decide who renders. The mirrored copy in the
1461 // plugin's own storage can be edited or overwritten there, and deferring
1462 // to it hands our tag over without anything saying so.
1463 $ours = $this->metasync_social_value(Metasync_Seo_Precedence::FIELD_OG_DESCRIPTION, $post_id);
1464 if ($ours !== '') {
1465 return $ours;
1466 }
1467
1468 // OTTO injects some social tags through the output buffer without ever
1469 // persisting them, so its own tag check still has to run.
1470 if ($this->otto_has_tag('og:description')) {
1471 return '';
1472 }
1473
1474 return $value;
1475 }
1476
1477 /**
1478 * Filter Yoast OG structural tags (og:url, og:type, og:locale, og:site_name, og:image).
1479 * Suppress when: OTTO active + has og:title (OTTO provides these alongside og:title).
1480 */
1481 public function filter_yoast_og_structural($value) {
1482 $post_id = $this->get_current_object_id();
1483 if ($this->og_output_disabled($post_id)) {
1484 return $value;
1485 }
1486 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1487 return $value;
1488 }
1489 if ($this->otto_has_tag('og:title')) {
1490 return '';
1491 }
1492 return $value;
1493 }
1494
1495 /**
1496 * Filter Yoast twitter:title output.
1497 * Suppress when: OTTO active + has twitter:title, OR MetaSync has title.
1498 */
1499 public function filter_yoast_twitter_title($value) {
1500 $post_id = $this->get_current_object_id();
1501 if ($this->og_output_disabled($post_id)) {
1502 return $value;
1503 }
1504
1505 // If we hold a value, we write it. The sync stamp records that a sync
1506 // happened; it does not decide who renders. The mirrored copy in the
1507 // plugin's own storage can be edited or overwritten there, and deferring
1508 // to it hands our tag over without anything saying so.
1509 $ours = $this->metasync_social_value(Metasync_Seo_Precedence::FIELD_TWITTER_TITLE, $post_id);
1510 if ($ours !== '') {
1511 return $ours;
1512 }
1513
1514 // OTTO injects some social tags through the output buffer without ever
1515 // persisting them, so its own tag check still has to run.
1516 if ($this->otto_has_tag('twitter:title')) {
1517 return '';
1518 }
1519
1520 return $value;
1521 }
1522
1523 /**
1524 * Filter Yoast twitter:description output.
1525 * Suppress when: OTTO active + has twitter:description, OR MetaSync has description.
1526 */
1527 public function filter_yoast_twitter_description($value) {
1528 $post_id = $this->get_current_object_id();
1529 if ($this->og_output_disabled($post_id)) {
1530 return $value;
1531 }
1532
1533 // If we hold a value, we write it. The sync stamp records that a sync
1534 // happened; it does not decide who renders. The mirrored copy in the
1535 // plugin's own storage can be edited or overwritten there, and deferring
1536 // to it hands our tag over without anything saying so.
1537 $ours = $this->metasync_social_value(Metasync_Seo_Precedence::FIELD_TWITTER_DESCRIPTION, $post_id);
1538 if ($ours !== '') {
1539 return $ours;
1540 }
1541
1542 // OTTO injects some social tags through the output buffer without ever
1543 // persisting them, so its own tag check still has to run.
1544 if ($this->otto_has_tag('twitter:description')) {
1545 return '';
1546 }
1547
1548 return $value;
1549 }
1550
1551 /**
1552 * Filter Yoast Twitter structural tags (twitter:image, twitter:card).
1553 * Suppress when: OTTO active + has any twitter tag.
1554 */
1555 public function filter_yoast_twitter_structural($value) {
1556 $post_id = $this->get_current_object_id();
1557 if ($this->og_output_disabled($post_id)) {
1558 return $value;
1559 }
1560 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1561 return $value;
1562 }
1563 if ($this->otto_has_tag('twitter:title') || $this->otto_has_tag('twitter:description')) {
1564 return '';
1565 }
1566 return $value;
1567 }
1568
1569 /**
1570 * Filter Yoast SEO schema/JSON-LD output.
1571 * Suppress when OTTO has structured data for the current page.
1572 * Also strip BreadcrumbList entries when MetaSync breadcrumbs are enabled,
1573 * so MetaSync's own BreadcrumbList is the only one on the page.
1574 *
1575 * @param array|false $data Yoast's JSON-LD data.
1576 * @return array|false
1577 */
1578 public function filter_yoast_schema($data) {
1579 if ($this->otto_has_schema_for_current_page()) {
1580 return false;
1581 }
1582
1583 if ($this->metasync_breadcrumb_enabled() && is_array($data)) {
1584 $data = $this->strip_breadcrumb_from_graph($data);
1585 }
1586
1587 return $data;
1588 }
1589
1590 // ------------------------------------------------------------------
1591 // RankMath integration
1592 // ------------------------------------------------------------------
1593
1594 /**
1595 * Register RankMath-specific filters to suppress its title and
1596 * description output when MetaSync/OTTO already provides them.
1597 */
1598 private function register_rankmath_filters() {
1599 add_filter('rank_math/frontend/title', [$this, 'filter_rankmath_title'], 999);
1600 add_filter('rank_math/frontend/description', [$this, 'filter_rankmath_description'], 999);
1601
1602 // Suppress RankMath schema/JSON-LD when OTTO has structured data
1603 add_filter('rank_math/json_ld', [$this, 'filter_rankmath_schema'], 999);
1604
1605 // Canonical — override RankMath's canonical with the MetaSync/OTTO value when set
1606 add_filter('rank_math/frontend/canonical', [$this, 'filter_rankmath_canonical'], 999);
1607
1608 // Social — Rank Math renders these from its own storage, so without these
1609 // filters MetaSync has no way to reach og:/twitter: on a Rank Math page.
1610 add_filter('rank_math/opengraph/facebook/og_title', [$this, 'filter_rankmath_og_title'], 999);
1611 add_filter('rank_math/opengraph/facebook/og_description', [$this, 'filter_rankmath_og_description'], 999);
1612 add_filter('rank_math/opengraph/twitter/twitter_title', [$this, 'filter_rankmath_twitter_title'], 999);
1613 add_filter('rank_math/opengraph/twitter/twitter_description', [$this, 'filter_rankmath_twitter_description'], 999);
1614
1615 // Robots — suppress Rank Math's tag when MetaSync holds an intentional
1616 // robots value for the post, mirroring the AIOSEO path. The advanced
1617 // directives (max-snippet, max-video-preview, max-image-preview) run
1618 // through a separate filter and must be suppressed alongside the main
1619 // tag, or they survive as an orphaned meta tag.
1620 add_filter('rank_math/frontend/robots', [$this, 'filter_rankmath_robots'], 999);
1621 add_filter('rank_math/frontend/advanced_robots', [$this, 'filter_rankmath_advanced_robots'], 999);
1622 }
1623
1624 /**
1625 * Shared body for the Rank Math social filters.
1626 *
1627 * Same rule as the Yoast and AIOSEO paths: when MetaSync holds a value for
1628 * this specific tag we write it, and when it holds nothing we leave Rank
1629 * Math's own tag alone. Each tag consults its OWN chain — twitter:description
1630 * resolves to _metasync_twitter_description, not the og one — because sharing
1631 * a chain would decide one tag on the strength of another's value and leave
1632 * either a duplicate or a blank.
1633 *
1634 * @param string $value Rank Math's computed value.
1635 * @param string $field A Metasync_Seo_Precedence social field constant.
1636 * @param string $otto_tag Matching OTTO tag name.
1637 * @return string
1638 */
1639 private function filter_rankmath_social($value, $field, $otto_tag) {
1640 $post_id = $this->get_current_object_id();
1641
1642 if ($this->og_output_disabled($post_id)) {
1643 return $value;
1644 }
1645
1646 $ours = $this->metasync_social_value($field, $post_id);
1647 if ($ours !== '') {
1648 return $ours;
1649 }
1650
1651 // OTTO injects some social tags through the output buffer without ever
1652 // persisting them, so its own tag check still has to run.
1653 if ($this->otto_has_tag($otto_tag)) {
1654 return '';
1655 }
1656
1657 return $value;
1658 }
1659
1660 /**
1661 * Filter Rank Math's og:title output.
1662 *
1663 * @param string $value Rank Math's computed og:title.
1664 * @return string
1665 */
1666 public function filter_rankmath_og_title($value) {
1667 return $this->filter_rankmath_social($value, Metasync_Seo_Precedence::FIELD_OG_TITLE, 'og:title');
1668 }
1669
1670 /**
1671 * Filter Rank Math's og:description output.
1672 *
1673 * @param string $value Rank Math's computed og:description.
1674 * @return string
1675 */
1676 public function filter_rankmath_og_description($value) {
1677 return $this->filter_rankmath_social($value, Metasync_Seo_Precedence::FIELD_OG_DESCRIPTION, 'og:description');
1678 }
1679
1680 /**
1681 * Filter Rank Math's twitter:title output.
1682 *
1683 * @param string $value Rank Math's computed twitter:title.
1684 * @return string
1685 */
1686 public function filter_rankmath_twitter_title($value) {
1687 return $this->filter_rankmath_social($value, Metasync_Seo_Precedence::FIELD_TWITTER_TITLE, 'twitter:title');
1688 }
1689
1690 /**
1691 * Filter Rank Math's twitter:description output.
1692 *
1693 * @param string $value Rank Math's computed twitter:description.
1694 * @return string
1695 */
1696 public function filter_rankmath_twitter_description($value) {
1697 return $this->filter_rankmath_social($value, Metasync_Seo_Precedence::FIELD_TWITTER_DESCRIPTION, 'twitter:description');
1698 }
1699
1700 /**
1701 * Filter Rank Math robots meta output.
1702 *
1703 * When MetaSync holds an intentional robots value (admin checkbox or REST
1704 * API), suppress Rank Math's robots tag to avoid duplicates. MetaSync's
1705 * own output in hook_metasync_metatags() will output the MetaSync value
1706 * instead. The suppression decision is shared with the AIOSEO path so the
1707 * two plugins behave identically.
1708 *
1709 * Registered against rank_math/frontend/robots (the main directives);
1710 * the max-* directives are handled separately in
1711 * filter_rankmath_advanced_robots(). Returning an empty array suppresses
1712 * the tag entirely, except when Rank Math forces a noindex (see
1713 * site_forces_noindex()), in which case a bare noindex/nofollow floor
1714 * survives so the page stays deindexed even though MetaSync replaces
1715 * every other directive.
1716 *
1717 * @param array $robots Rank Math's computed robots attributes array.
1718 * @return array
1719 */
1720 public function filter_rankmath_robots($robots) {
1721 if ($this->should_suppress_third_party_robots('rankmath')) {
1722 // MetaSync has robots — suppress Rank Math's tag. Keep only the
1723 // noindex Rank Math forces on non-public sites and replytocom
1724 // URLs alive: MetaSync's replacement value carries no such
1725 // directive, so a full suppression would leave those pages
1726 // indexable even though the site asked not to be indexed.
1727 if ($this->site_forces_noindex()) {
1728 return ['index' => 'noindex', 'follow' => 'nofollow'];
1729 }
1730 return [];
1731 }
1732
1733 return $robots;
1734 }
1735
1736 /**
1737 * Filter Rank Math's advanced (max-*) robots directives.
1738 *
1739 * Shares the suppression decision with the main robots filter; Rank Math
1740 * merges the advanced payload into the same <meta name="robots"> tag, so
1741 * when MetaSync replaces the tag the max-* directives must vanish with
1742 * it. No noindex floor here — the main filter already carries it, and a
1743 * second copy would just collide in Rank Math's array merge.
1744 *
1745 * @param array $robots Rank Math's advanced robots attributes array.
1746 * @return array
1747 */
1748 public function filter_rankmath_advanced_robots($robots) {
1749 if ($this->should_suppress_third_party_robots('rankmath')) {
1750 return [];
1751 }
1752
1753 return $robots;
1754 }
1755
1756 /**
1757 * Whether the current request carries a noindex that Rank Math forces
1758 * independently of any per-post setting — a non-public site
1759 * (blog_public = 0, "Discourage search engines") or a ?replytocom=
1760 * comment-reply URL. Mirrors Rank Math's respect_settings_for_robots().
1761 *
1762 * @return bool
1763 */
1764 private function site_forces_noindex() {
1765 if (0 === absint(get_option('blog_public')) || isset($_GET['replytocom'])) {
1766 return true;
1767 }
1768
1769 return false;
1770 }
1771
1772 /**
1773 * Filter RankMath's canonical URL.
1774 *
1775 * Mirrors filter_yoast_canonical(): return the MetaSync-managed canonical
1776 * (OTTO or the Canonical meta box) when set, else pass RankMath's through.
1777 */
1778 public function filter_rankmath_canonical($canonical) {
1779 $custom = $this->get_metasync_canonical($this->get_current_object_id());
1780 return $custom !== '' ? $custom : $canonical;
1781 }
1782
1783 /**
1784 * Filter RankMath title output.
1785 *
1786 * On taxonomy archive pages: when MetaSync has an explicit `_metasync_metatitle`
1787 * term meta value, return it so Rank Math renders the MetaSync-managed archive
1788 * title inside <title>. This mirrors the Yoast term-level title override in
1789 * filter_yoast_title().
1790 *
1791 * On singular pages: return empty string when MetaSync/OTTO has a title (Rank
1792 * Math controls the sole <title> renderer on classic themes, so OTTO's buffer
1793 * post-processing will replace it — returning '' would leave no <title> at all).
1794 *
1795 * @param string $title RankMath's computed title.
1796 * @return string
1797 */
1798 public function filter_rankmath_title($title) {
1799 $post_id = $this->get_current_object_id();
1800
1801 // If we hold a title, we write it — the same rule the description path
1802 // follows. The sync stamp records that a sync happened; it does not decide
1803 // who renders. The mirrored copy lives in the plugin's own storage and can
1804 // be edited there, and deferring to it hands our tag over silently.
1805 //
1806 // A value, never '': on classic themes the plugin owns the sole <title>
1807 // renderer and OTTO's buffer replaces the tag afterwards, so blanking it
1808 // would leave nothing for OTTO to replace.
1809 $ours = $this->metasync_title_value($post_id);
1810 if ($ours !== '') {
1811 return $ours;
1812 }
1813
1814 // Taxonomy archives resolve against term meta, not post meta. Resolve
1815 // the term before plugin ownership checks so a multi-plugin archive is
1816 // not suppressed by an unrelated primary-plugin decision.
1817 $term = $this->get_current_term();
1818 if ($term) {
1819 $term_title = $this->get_metasync_term_title($term);
1820 if (!empty($term_title)) {
1821 return $term_title;
1822 }
1823 }
1824
1825 // If another plugin is the primary output owner, suppress Rank Math.
1826 if ($post_id && $this->has_active_seo_plugin()) {
1827 if ($this->is_primary_output_plugin($post_id, 'rankmath')) {
1828 return $title;
1829 }
1830 // Another plugin is primary — suppress this one
1831 if ($this->is_primary_output_plugin($post_id, 'yoast') || $this->is_primary_output_plugin($post_id, 'aioseo')) {
1832 return '';
1833 }
1834 }
1835
1836 $post_id = $this->get_current_object_id();
1837
1838 // MetaSync sidebar has an explicit title — return it so Rank Math renders it.
1839 if ($post_id) {
1840 $sidebar_title = $this->metasync_title_value($post_id);
1841 if ($sidebar_title !== '') {
1842 return $sidebar_title;
1843 }
1844 }
1845
1846 // OTTO has a persisted title — do NOT suppress Rank Math here.
1847 // OTTO's output-buffer post-processing replaces the <title> tag in the
1848 // final HTML. Returning '' would remove the tag before OTTO can inject.
1849
1850 return $title;
1851 }
1852
1853 /**
1854 * Filter RankMath description output.
1855 *
1856 * On term archives: return MetaSync's resolved term value directly. Rank
1857 * Math may compute an empty native value even when the precedence chain has
1858 * a value, and MetaSync's own archive emitter does not provide a fallback.
1859 *
1860 * On singular pages: suppress when OTTO or MetaSync sidebar provides
1861 * the description.
1862 *
1863 * @param string $description RankMath's computed description.
1864 * @return string
1865 */
1866 public function filter_rankmath_description($description) {
1867 $post_id = $this->get_current_object_id();
1868
1869 // A synced OTTO description is dead data once OTTO is switched off
1870 // for this post; serving it would leak SEO OTTO has stood down from.
1871 $replacement = $this->replace_dead_otto_description($description, $post_id);
1872 if ($replacement !== null) {
1873 return $replacement;
1874 }
1875
1876 // If we hold a description, we write it. The sync stamp records that a
1877 // sync happened; it does not decide who renders. The mirrored copy lives
1878 // in the plugin's own storage and can be edited or overwritten there —
1879 // deferring to it hands our tag over with nothing saying so.
1880 $ours = $this->metasync_description_value($post_id);
1881 if ($ours !== '') {
1882 return $ours;
1883 }
1884
1885 // On taxonomy archives the queried-object id is a term id, not a post
1886 // id. Rank Math may have no native term description to return (the
1887 // value can exist only in MetaSync's precedence tiers), so hand it the
1888 // resolved term value directly. MetaSync's own wp_head emitter exits
1889 // early for archives and cannot provide a fallback tag.
1890 $term = $this->get_current_term();
1891 if ($term) {
1892 $term_desc = $this->get_metasync_term_description($term);
1893 if (!empty($term_desc)) {
1894 return $term_desc;
1895 }
1896 }
1897
1898 if ($post_id && $this->has_active_seo_plugin()) {
1899 if ($this->is_primary_output_plugin($post_id, 'rankmath')) {
1900 return $description;
1901 }
1902 if ($this->is_primary_output_plugin($post_id, 'yoast') || $this->is_primary_output_plugin($post_id, 'aioseo')) {
1903 return '';
1904 }
1905 }
1906
1907 if ($this->otto_has_tag('description') || $this->metasync_has_description()) {
1908 return '';
1909 }
1910
1911 return $description;
1912 }
1913
1914 /**
1915 * Filter RankMath schema/JSON-LD output.
1916 * Suppress when OTTO has structured data for the current page.
1917 * Also strip BreadcrumbList entries when MetaSync breadcrumbs are enabled,
1918 * so MetaSync's own BreadcrumbList is the only one on the page.
1919 *
1920 * @param array $data RankMath's JSON-LD data array.
1921 * @return array
1922 */
1923 public function filter_rankmath_schema($data) {
1924 if ($this->otto_has_schema_for_current_page()) {
1925 return [];
1926 }
1927
1928 if ($this->metasync_breadcrumb_enabled() && is_array($data)) {
1929 $data = $this->strip_breadcrumb_from_graph($data);
1930 }
1931
1932 return $data;
1933 }
1934
1935 // ------------------------------------------------------------------
1936 // MetaSync output gating
1937 // ------------------------------------------------------------------
1938
1939 /**
1940 * Whether the legacy `hook_metasync_metatags()` should output a description tag.
1941 *
1942 * Decision matrix (when a third-party SEO plugin is active):
1943 * MetaSync has value → true (MetaSync outputs, AIOSEO suppressed via filter)
1944 * AIOSEO has value → false (let AIOSEO handle it)
1945 * Neither has value → true (fallback: legacy auto-generated description)
1946 *
1947 * When no third-party SEO plugin is active → always true.
1948 *
1949 * @return bool True if the legacy output should include a description tag.
1950 */
1951 public function should_output_legacy_description() {
1952 // Post synced to any active plugin — that plugin now owns the
1953 // description output from its native storage. Suppress MetaSync's own tag.
1954 $post_id = $this->get_current_object_id();
1955 if ($post_id && $this->has_active_seo_plugin()) {
1956 // If synced to any plugin, a primary output plugin exists — suppress MetaSync's own tag.
1957 if ($this->is_post_synced($post_id, 'yoast') || $this->is_post_synced($post_id, 'rankmath') || $this->is_post_synced($post_id, 'aioseo')) {
1958 return false;
1959 }
1960 }
1961
1962 // OTTO active + has description → suppress legacy auto-generated description.
1963 if ($this->otto_has_tag('description')) {
1964 return false;
1965 }
1966
1967 if (!$this->has_active_seo_plugin()) {
1968 return true;
1969 }
1970
1971 // MetaSync has an intentional value — always output it
1972 if ($this->metasync_has_description()) {
1973 return true;
1974 }
1975
1976 // Check if AIOSEO actually provides a description for this page.
1977 // If it does, suppress our legacy output to avoid duplicates.
1978 // If it doesn't, let our legacy auto-generated description through
1979 // so the page isn't left with zero descriptions.
1980 if ($this->is_aioseo_active() && $this->aioseo_provides_description()) {
1981 return false;
1982 }
1983
1984 // For Yoast/RankMath: they always auto-generate a description,
1985 // so suppress our legacy output when they're active.
1986 if (is_plugin_active('wordpress-seo/wp-seo.php')) {
1987 return false;
1988 }
1989 if (is_plugin_active('seo-by-rank-math/rank-math.php') ||
1990 is_plugin_active('seo-by-rankmath/rank-math.php')) {
1991 return false;
1992 }
1993
1994 // No third-party plugin will provide a description — output ours
1995 return true;
1996 }
1997
1998 /**
1999 * Check whether AIOSEO will actually output a description for the current page.
2000 *
2001 * Uses the cached value captured in filter_aioseo_description() if available.
2002 * Falls back to calling AIOSEO's API directly if the filter hasn't fired yet.
2003 *
2004 * @return bool
2005 */
2006 private function aioseo_provides_description() {
2007 // Use cached value if available (set when our filter fires)
2008 if ($this->aioseo_has_description_cache !== null) {
2009 return $this->aioseo_has_description_cache;
2010 }
2011
2012 // Filter hasn't fired yet — query AIOSEO directly
2013 if (function_exists('aioseo') && isset(aioseo()->meta->description)) {
2014 $desc = aioseo()->meta->description->getDescription();
2015 $this->aioseo_has_description_cache = !empty($desc);
2016 return $this->aioseo_has_description_cache;
2017 }
2018
2019 // Can't determine — assume AIOSEO has one to avoid duplicates
2020 $this->aioseo_has_description_cache = true;
2021 return true;
2022 }
2023
2024 // ------------------------------------------------------------------
2025 // Helpers
2026 // ------------------------------------------------------------------
2027
2028 /**
2029 * Strip BreadcrumbList nodes from a JSON-LD @graph array and remove
2030 * dangling breadcrumb references from WebPage-type nodes.
2031 *
2032 * Previously we only removed the BreadcrumbList entry but left
2033 * the WebPage's `breadcrumb: { @id: "...#breadcrumb" }` property intact.
2034 * Google follows that dangling @id, finds no matching node, and reports
2035 * "Missing field itemListElement".
2036 *
2037 * @param array $graph The @graph array from a third-party SEO plugin.
2038 * @return array
2039 */
2040 private function strip_breadcrumb_from_graph($graph) {
2041 // Rank Math's graph is keyed by entity name (e.g. 'WebPage', 'ProfilePage')
2042 // and later filters/consumers look entries up by those keys (see
2043 // RankMath\Schema\Frontend::remove_person_entity(), hooked on
2044 // rank_math/schema/validated_data, which reads $data['ProfilePage']).
2045 // AIOSEO/Yoast pass a plain numeric list. Re-index with array_values()
2046 // unless the graph carries string keys — a string-keyed graph is Rank
2047 // Math's shape and must be preserved untouched. This is deliberately
2048 // broader than array_is_list(): a numeric-keyed AIOSEO/Yoast graph
2049 // that a third-party filter left with a gap (e.g. an earlier unset())
2050 // still needs forcing back into a gap-free list, or json_encode()
2051 // would serialize it as a JSON object instead of an array.
2052 $has_string_keys = false;
2053 foreach (array_keys($graph) as $key) {
2054 if (is_string($key)) {
2055 $has_string_keys = true;
2056 break;
2057 }
2058 }
2059
2060 // Collect @ids of BreadcrumbList nodes being removed. Track removal
2061 // separately from the id list: a BreadcrumbList may carry no @id, in
2062 // which case it is removed but contributes nothing to $removed_ids.
2063 $removed_ids = [];
2064 $removed_any = false;
2065
2066 foreach ($graph as $key => $entry) {
2067 if (is_array($entry) && isset($entry['@type']) && $entry['@type'] === 'BreadcrumbList') {
2068 if (!empty($entry['@id'])) {
2069 $removed_ids[] = $entry['@id'];
2070 }
2071 $removed_any = true;
2072 unset($graph[$key]);
2073 }
2074 }
2075
2076 // Remove dangling breadcrumb references from any node that carries one.
2077 // Gating this on @type === 'WebPage' missed every WebPage subtype the
2078 // SEO plugins actually emit — Rank Math uses 'CollectionPage' for
2079 // archives and 'ItemPage' for products — so the BreadcrumbList was
2080 // removed while the reference to it survived. The subtype set is
2081 // open-ended, so match on the reference itself rather than the type.
2082 //
2083 // Only run when this method actually removed a BreadcrumbList. If it
2084 // removed nothing, no reference in this graph was invalidated here, and
2085 // stripping one would break a relationship that is still intact — the
2086 // list may simply live in another JSON-LD block on the page.
2087 if ($removed_any) {
2088 foreach ($graph as $key => &$entry) {
2089 if (!is_array($entry)) {
2090 continue;
2091 }
2092
2093 if (isset($entry['breadcrumb'])) {
2094 // Remove if the reference points to a stripped node, or if
2095 // the removed list carried no @id to match against.
2096 $ref_id = is_array($entry['breadcrumb']) ? ($entry['breadcrumb']['@id'] ?? '') : '';
2097 if (empty($removed_ids) || empty($ref_id) || in_array($ref_id, $removed_ids, true)) {
2098 unset($entry['breadcrumb']);
2099 }
2100 }
2101 }
2102 unset($entry);
2103 }
2104
2105 return $has_string_keys ? $graph : array_values($graph);
2106 }
2107
2108 /**
2109 * Determine whether MetaSync's own BreadcrumbList output is enabled.
2110 *
2111 * Mirrors the gate logic in Metasync_Breadcrumbs_Schema::output_breadcrumb_schema():
2112 * enabled by default, disabled only when the `enabled` setting is explicitly falsy.
2113 * Used by the Yoast / RankMath / AIOSEO schema filters so we only strip their
2114 * BreadcrumbList entries when MetaSync will emit one itself.
2115 *
2116 * @return bool
2117 */
2118 private function metasync_breadcrumb_enabled() {
2119 $settings = Metasync::get_option('breadcrumbs', array());
2120 if (!is_array($settings)) {
2121 return true;
2122 }
2123
2124 if (array_key_exists('enabled', $settings) && empty($settings['enabled'])) {
2125 return false;
2126 }
2127
2128 // When schema output is explicitly disabled, don't strip
2129 // third-party breadcrumbs — MetaSync won't emit its own.
2130 if (!empty($settings['disable_schema'])) {
2131 return false;
2132 }
2133
2134 return true;
2135 }
2136
2137 /**
2138 * Check whether OTTO has structured data (schema/JSON-LD) for the current page.
2139 *
2140 * @return bool
2141 */
2142 private function otto_has_schema_for_current_page() {
2143 // Schema feature switched off: MetaSync contributes no JSON-LD, so the
2144 // third-party plugin's graph must be left intact rather than emptied.
2145 if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SCHEMA)) {
2146 return false;
2147 }
2148
2149 if (!$this->is_otto_active()) {
2150 return false;
2151 }
2152
2153 $post_id = $this->get_current_object_id();
2154 if (!$post_id) {
2155 return false;
2156 }
2157
2158 if ($this->has_active_seo_plugin() && !$this->otto_has_live_suggestions()) {
2159 return false;
2160 }
2161
2162 return !empty(get_post_meta($post_id, '_metasync_otto_structured_data', true));
2163 }
2164
2165 /**
2166 * Get the current queried object ID.
2167 *
2168 * Uses get_queried_object_id() as the universal fallback so every
2169 * public page type (singular, front page, static blog page, CPT
2170 * archives, WooCommerce shop, etc.) is covered without enumerating
2171 * each one individually.
2172 *
2173 * For blog-style homepages (show_on_front=posts) there is no backing
2174 * page, so this returns 0.
2175 *
2176 * @return int 0 when unknown.
2177 */
2178 /**
2179 * Whether get_current_object_id() is currently answering with a post id.
2180 *
2181 * On a taxonomy archive the queried object is a term, so that method returns
2182 * a TERM id. Reading post meta with it answers with whatever post happens to
2183 * share that number — a collision that is ordinary rather than exotic on any
2184 * site with a few dozen terms. Every post-meta read keyed on
2185 * get_current_object_id() has to gate on this.
2186 *
2187 * A term is the only case: on the static posts page and the front page the
2188 * queried object is a real page, and its id is a genuine post id.
2189 *
2190 * @return bool
2191 */
2192 private function current_object_is_post() {
2193 return !$this->get_current_term();
2194 }
2195
2196 private function get_current_object_id() {
2197 // Singular pages (posts, pages, CPTs, attachments)
2198 if (is_singular()) {
2199 return (int) get_the_ID();
2200 }
2201
2202 // WooCommerce shop page (virtual archive backed by a real page)
2203 if (function_exists('is_shop') && is_shop()) {
2204 return function_exists('wc_get_page_id') ? (int) wc_get_page_id('shop') : 0;
2205 }
2206
2207 // Universal fallback: static front page, static posts page,
2208 // or any other page type WordPress assigns a queried object to.
2209 $id = get_queried_object_id();
2210 if ($id > 0) {
2211 return (int) $id;
2212 }
2213
2214 return 0;
2215 }
2216
2217 /**
2218 * MetaSync's title for a taxonomy archive, highest tier first.
2219 *
2220 * The order comes from Metasync_Seo_Precedence: on a term
2221 * `_metasync_metatitle` means "someone set this deliberately" — the MCP
2222 * taxonomy tool is its only writer — and an import sits one tier below on
2223 * `_metasync_imported_seo_title`, so a bulk import can no longer take the
2224 * slot a deliberate value occupies.
2225 *
2226 * Terms carry no OTTO meta at all, which is why that chain is shorter than
2227 * the post one and why the same key can mean something different on a post
2228 * without ambiguity. OTTO reaches an archive through the output-buffer pass,
2229 * which runs after these filters and still overrides what we return.
2230 *
2231 * @param \WP_Term $term Term being rendered.
2232 * @return string Title, or '' when MetaSync has none.
2233 */
2234 private function get_metasync_term_title($term) {
2235 if (empty($term->term_id) || !self::precedence_available()) {
2236 return '';
2237 }
2238
2239 return Metasync_Seo_Precedence::value(
2240 $term->term_id,
2241 Metasync_Seo_Precedence::FIELD_TITLE,
2242 Metasync_Seo_Precedence::TYPE_TERM
2243 );
2244 }
2245
2246 /**
2247 * MetaSync's description for a taxonomy archive. See get_metasync_term_title().
2248 *
2249 * @param \WP_Term $term Term being rendered.
2250 * @return string Description, or '' when MetaSync has none.
2251 */
2252 private function get_metasync_term_description($term) {
2253 if (empty($term->term_id) || !self::precedence_available()) {
2254 return '';
2255 }
2256
2257 return Metasync_Seo_Precedence::value(
2258 $term->term_id,
2259 Metasync_Seo_Precedence::FIELD_DESCRIPTION,
2260 Metasync_Seo_Precedence::TYPE_TERM
2261 );
2262 }
2263
2264 /**
2265 * The social value MetaSync holds for a tag, if any.
2266 *
2267 * Social fields carry their own values rather than deriving from the page
2268 * title and description, so they are asked about on their own chain: a post
2269 * can have an OG title and no SEO title, and the page-title chain would
2270 * answer "we hold nothing" for a value we do hold. That mismatch is what let
2271 * a third-party plugin's own OG tag outrank ours.
2272 *
2273 * @param string $field A Metasync_Seo_Precedence social field constant.
2274 * @param int $post_id Post being rendered.
2275 * @return string '' when MetaSync holds nothing for this tag.
2276 */
2277 private function metasync_social_value($field, $post_id) {
2278 if (!$post_id || !self::precedence_available()) {
2279 return '';
2280 }
2281
2282 // Social values are per-post. On an archive the queried-object id is a
2283 // term id, and reading post meta with it would answer with whatever post
2284 // happens to share that number — another page's og:title.
2285 if (!is_singular()) {
2286 return '';
2287 }
2288
2289 // The OG meta box pre-fills every field from the post and PERSISTS those
2290 // defaults on save, so a non-empty value does not prove the customer chose
2291 // it. Only a value that differs from the default counts as theirs; an
2292 // auto-filled one must not outrank OTTO. Same comparison
2293 // Otto_html_class::apply_metabox_og_precedence() makes — without it the two
2294 // disagree, and a page can lose the tag entirely because each side expects
2295 // the other to supply it.
2296 $customized = $this->customized_metabox_value($post_id, $field);
2297 if ($customized !== '') {
2298 return $customized;
2299 }
2300
2301 // Auto-filled or unset: skip MetaSync's own tier and take the rest of the
2302 // chain — OTTO, then whatever an import brought in.
2303 $value = Metasync_Seo_Precedence::value(
2304 $post_id,
2305 $field,
2306 Metasync_Seo_Precedence::TYPE_POST,
2307 array('include_metasync' => false)
2308 );
2309 if ($value !== '') {
2310 return $value;
2311 }
2312
2313 // A social description with no OG-specific value of its own falls back to
2314 // the page description, which is what MetaSync's own emitter has always
2315 // done. Without this the plugin renders its own og:description while the
2316 // sidebar emits ours, and the page carries both.
2317 if ($field === Metasync_Seo_Precedence::FIELD_OG_DESCRIPTION
2318 || $field === Metasync_Seo_Precedence::FIELD_TWITTER_DESCRIPTION) {
2319 return $this->metasync_description_value($post_id);
2320 }
2321
2322 // Social titles fall back to the page title for the same reason, and it
2323 // is the fallback Metasync_OpenGraph already uses. It also keeps the
2324 // WP-609 replacement path from firing: with the plugin rendering our
2325 // title there is no suppressed tag left to replace.
2326 if ($field === Metasync_Seo_Precedence::FIELD_OG_TITLE
2327 || $field === Metasync_Seo_Precedence::FIELD_TWITTER_TITLE) {
2328 return Metasync_Seo_Precedence::value($post_id, Metasync_Seo_Precedence::FIELD_TITLE);
2329 }
2330
2331 return '';
2332 }
2333
2334 /**
2335 * A social meta box value, but only when the customer genuinely set it.
2336 *
2337 * The box pre-fills Title from the post title, Description from the excerpt
2338 * and Image from the featured image, and persists whatever is in those fields
2339 * on save. So "non-empty" is not intent — a field is the customer's only when
2340 * it differs from the default it was pre-filled with.
2341 *
2342 * Compared per field rather than letting the Twitter fields inherit the OG
2343 * verdict, so a customized og:title cannot silently promote an auto-filled
2344 * twitter:title.
2345 *
2346 * @param int $post_id
2347 * @param string $field A Metasync_Seo_Precedence social field constant.
2348 * @return string '' when unset, auto-filled, or not determinable.
2349 */
2350 private function customized_metabox_value($post_id, $field) {
2351 $map = array(
2352 Metasync_Seo_Precedence::FIELD_OG_TITLE => array('_metasync_og_title', 'title'),
2353 Metasync_Seo_Precedence::FIELD_OG_DESCRIPTION => array('_metasync_og_description', 'description'),
2354 Metasync_Seo_Precedence::FIELD_OG_IMAGE => array('_metasync_og_image', 'image'),
2355 Metasync_Seo_Precedence::FIELD_TWITTER_TITLE => array('_metasync_twitter_title', 'title'),
2356 Metasync_Seo_Precedence::FIELD_TWITTER_DESCRIPTION => array('_metasync_twitter_description', 'description'),
2357 Metasync_Seo_Precedence::FIELD_TWITTER_IMAGE => array('_metasync_twitter_image', 'image'),
2358 );
2359
2360 if (!isset($map[$field])) {
2361 return '';
2362 }
2363
2364 list($meta_key, $default_key) = $map[$field];
2365
2366 $stored = (string) get_post_meta($post_id, $meta_key, true);
2367 if ($stored === '') {
2368 return '';
2369 }
2370
2371 // A stored "Auto Draft" placeholder was never something the customer typed —
2372 // it is the meta box's own pre-fill from a still-untitled post, persisted
2373 // verbatim. Comparing only against the post's *current* title (below) misses
2374 // this once the post is later given a real title: the placeholder no longer
2375 // matches the default, so it reads as a deliberate customization and gets
2376 // handed to a third-party SEO plugin as-is (WP-640). Collapse it here too,
2377 // the same way the render-time resolver already does for MetaSync's own tag.
2378 if (class_exists('Metasync_OpenGraph')
2379 && in_array($meta_key, Metasync_OpenGraph::AUTO_DRAFT_PRONE_KEYS, true)
2380 && Metasync_OpenGraph::is_auto_draft_title($stored)
2381 ) {
2382 return '';
2383 }
2384
2385 // The title default is the post title, which we can read without help. The
2386 // description and image defaults come from the emitter's own resolver so
2387 // they match it exactly.
2388 if ($default_key === 'title') {
2389 $post = get_post($post_id);
2390 $default = ($post instanceof WP_Post) ? (string) $post->post_title : '';
2391
2392 return $stored === $default ? '' : $stored;
2393 }
2394
2395 if (!class_exists('Metasync_OpenGraph') || !Metasync_OpenGraph::get_instance()) {
2396 // Cannot prove it was auto-filled, so do not demote a value the
2397 // customer may well have typed. Erring the other way would silently
2398 // drop real values whenever the emitter is unavailable.
2399 return $stored;
2400 }
2401
2402 $defaults = Metasync_OpenGraph::get_instance()->get_default_og_values($post_id);
2403 $default = (string) $defaults[$default_key];
2404
2405 return $stored === $default ? '' : $stored;
2406 }
2407
2408 /**
2409 * The page title MetaSync holds for a post, if any.
2410 *
2411 * The full chain — what the customer typed, then OTTO, then a value brought
2412 * in from another SEO plugin — so a third-party plugin can never render its
2413 * own title over one of ours. OTTO's tiers drop out on their own when the
2414 * per-post toggle is off.
2415 *
2416 * Cached for the request: three plugins' title filters can each ask, and on
2417 * a page where nothing is set that is otherwise four meta reads apiece.
2418 *
2419 * @param int $post_id Post being rendered.
2420 * @return string '' when MetaSync holds nothing.
2421 */
2422 private function metasync_title_value($post_id) {
2423 if (!$post_id || !self::precedence_available()) {
2424 return '';
2425 }
2426
2427 if ($this->get_current_term()) {
2428 return '';
2429 }
2430
2431 if (array_key_exists($post_id, $this->title_value_cache)) {
2432 return $this->title_value_cache[$post_id];
2433 }
2434
2435 $this->title_value_cache[$post_id] = Metasync_Seo_Precedence::value(
2436 $post_id,
2437 Metasync_Seo_Precedence::FIELD_TITLE
2438 );
2439
2440 return $this->title_value_cache[$post_id];
2441 }
2442
2443 /**
2444 * The meta description MetaSync holds for a post, if any.
2445 *
2446 * The full chain — what the customer typed, then OTTO, then a value brought
2447 * in from another SEO plugin — so a third-party plugin can never render its
2448 * own description over one of ours. OTTO's tiers drop out on their own when
2449 * the per-post toggle is off, so a stood-down suggestion is not served.
2450 *
2451 * @param int $post_id Post being rendered.
2452 * @return string '' when MetaSync holds nothing.
2453 */
2454 private function metasync_description_value($post_id) {
2455 if (!$post_id || !self::precedence_available()) {
2456 return '';
2457 }
2458
2459 // On a taxonomy archive the queried-object id is a term id; reading post
2460 // meta with it would answer with whatever post shares that number. The
2461 // term branch in each filter handles archives.
2462 if ($this->get_current_term()) {
2463 return '';
2464 }
2465
2466 return Metasync_Seo_Precedence::value(
2467 $post_id,
2468 Metasync_Seo_Precedence::FIELD_DESCRIPTION
2469 );
2470 }
2471
2472 /**
2473 * Make sure the precedence resolver is loaded before calling it.
2474 *
2475 * These filters run on wp_head, so an unresolvable class here takes the
2476 * front end down rather than degrading. A partial update can leave newer
2477 * PHP files beside an older committed autoload classmap that has never
2478 * heard of this class, so fall back to requiring the file directly and
2479 * report honestly if it is genuinely absent. Same reasoning as the post-meta
2480 * sync bridges in class-metasync.php.
2481 *
2482 * @return bool
2483 */
2484 private static function precedence_available() {
2485 if (class_exists('Metasync_Seo_Precedence')) {
2486 return true;
2487 }
2488
2489 $file = plugin_dir_path(__FILE__) . 'class-metasync-seo-precedence.php';
2490 if (is_readable($file)) {
2491 require_once $file;
2492 }
2493
2494 return class_exists('Metasync_Seo_Precedence');
2495 }
2496
2497 /**
2498 * Return the WP_Term being rendered on taxonomy archive pages.
2499 *
2500 * Only returns a term when the current query is a category, tag, or
2501 * custom taxonomy archive — i.e. when MetaSync term meta could be
2502 * driving the rendered output. Returns null in every other context
2503 * (singular, blog home, search, 404, etc.) so callers don't have to
2504 * double-check the page type.
2505 *
2506 * @return \WP_Term|null
2507 */
2508 private function get_current_term() {
2509 if (!(is_category() || is_tag() || is_tax())) {
2510 return null;
2511 }
2512
2513 $queried = get_queried_object();
2514 if ($queried instanceof \WP_Term) {
2515 return $queried;
2516 }
2517
2518 return null;
2519 }
2520 }
2521