PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.17
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.17
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 2.6.4 2.6.3 All 139 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.17, at includes/class-metasync-seo-conflict-handler.php

1,475 lines 53.4 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 * Cached result for whether AIOSEO provides a description for the current page.
40 *
41 * @var bool|null
42 */
43 private $aioseo_has_description_cache = null;
44
45 /**
46 * Cached result: whether the current post has been synced via.
47 *
48 * @var array Keyed by post_id => bool
49 */
50 private $sync_cache = [];
51
52 /**
53 * Cached result for whether OTTO has live transient-cached suggestions
54 * for the current request URL.
55 *
56 * @var bool|null
57 */
58 private $live_suggestions_cache = null;
59
60 /**
61 * Get singleton instance.
62 *
63 * @return self
64 */
65 public static function get_instance() {
66 if (self::$instance === null) {
67 self::$instance = new self();
68 }
69 return self::$instance;
70 }
71
72 /**
73 * Private constructor — use get_instance().
74 */
75 private function __construct() {
76 // Only hook on the frontend
77 if (is_admin()) {
78 return;
79 }
80
81 add_action('wp', [$this, 'register_filters'], 0);
82 }
83
84 /**
85 * Register filters after the query is parsed (so is_singular() etc. work).
86 */
87 public function register_filters() {
88 if ($this->is_aioseo_active()) {
89 $this->register_aioseo_filters();
90 }
91
92 // Ensure is_plugin_active() is available
93 if (!function_exists('is_plugin_active')) {
94 require_once ABSPATH . 'wp-admin/includes/plugin.php';
95 }
96
97 if (is_plugin_active('wordpress-seo/wp-seo.php') ||
98 is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')) {
99 $this->register_yoast_filters();
100 }
101
102 if (is_plugin_active('seo-by-rank-math/rank-math.php') || is_plugin_active('seo-by-rankmath/rank-math.php')) {
103 $this->register_rankmath_filters();
104 }
105 }
106
107 // ------------------------------------------------------------------
108 // Third-party SEO plugin detection
109 // ------------------------------------------------------------------
110
111 /**
112 * Check whether AIOSEO (free or pro) is active.
113 *
114 * @return bool
115 */
116 public function is_aioseo_active() {
117 // Ensure is_plugin_active() is available on the frontend
118 if (!function_exists('is_plugin_active')) {
119 require_once ABSPATH . 'wp-admin/includes/plugin.php';
120 }
121
122 return is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php')
123 || is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php');
124 }
125
126 /**
127 * Check whether any supported third-party SEO plugin is active.
128 *
129 * @return bool
130 */
131 public function has_active_seo_plugin() {
132 // is_plugin_active() availability ensured by is_aioseo_active() call
133 return $this->is_aioseo_active()
134 || is_plugin_active('wordpress-seo/wp-seo.php')
135 || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')
136 || is_plugin_active('seo-by-rank-math/rank-math.php')
137 || is_plugin_active('seo-by-rankmath/rank-math.php');
138 }
139
140 /**
141 * Whether an active third-party SEO plugin actually holds a
142 * non-empty meta description for this post in its OWN storage.
143 *
144 * The native-first output guards previously stood down whenever a
145 * sync timestamp (_metasync_plugin_sync_ts) existed, assuming the plugin
146 * would render the description. But a stale or partial sync leaves the
147 * plugin's field empty — MetaSync suppresses its own tag, the plugin has
148 * nothing to emit, and the description is dropped entirely. Callers use this
149 * to only defer when the plugin can genuinely output a description.
150 *
151 * @param int $post_id Post ID.
152 * @return bool True if the active/primary SEO plugin has a description.
153 */
154 public function active_plugin_has_description($post_id) {
155 $post_id = (int) $post_id;
156 if ($post_id <= 0) {
157 return false;
158 }
159
160 $this->ensure_plugin_api();
161
162 // Yoast (free or premium)
163 if (is_plugin_active('wordpress-seo/wp-seo.php') || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')) {
164 if (!empty(get_post_meta($post_id, '_yoast_wpseo_metadesc', true))) {
165 return true;
166 }
167 }
168
169 // Rank Math
170 if (is_plugin_active('seo-by-rank-math/rank-math.php') || is_plugin_active('seo-by-rankmath/rank-math.php')) {
171 if (!empty(get_post_meta($post_id, 'rank_math_description', true))) {
172 return true;
173 }
174 }
175
176 // AIOSEO stores its description in a custom table, not post meta.
177 if ($this->is_aioseo_active()) {
178 global $wpdb;
179 // Defensive: $wpdb is always present on a booted frontend, but never
180 // assume — a method call on a null/!object $wpdb would be fatal.
181 if (isset($wpdb) && is_object($wpdb)) {
182 $table = $wpdb->prefix . 'aioseo_posts';
183 $desc = $wpdb->get_var($wpdb->prepare("SELECT description FROM {$table} WHERE post_id = %d", $post_id));
184 if (!empty($desc)) {
185 return true;
186 }
187 }
188 }
189
190 return false;
191 }
192
193 // ------------------------------------------------------------------
194 // MetaSync description resolution
195 // ------------------------------------------------------------------
196
197 /**
198 * Determine whether MetaSync holds an intentional meta description
199 * for the current request.
200 *
201 * Only considers explicitly set values:
202 * 1. SEO sidebar custom value (_metasync_seo_desc)
203 * 2. OTTO persisted description (_metasync_otto_description)
204 *
205 * Auto-generated excerpts (legacy `meta_description` key) are NOT
206 * counted — they should not suppress a third-party plugin.
207 *
208 * @return bool
209 */
210 public function metasync_has_description() {
211 if ($this->has_description_cache !== null) {
212 return $this->has_description_cache;
213 }
214
215 if (!empty($this->get_metasync_description())) {
216 $this->has_description_cache = true;
217 return true;
218 }
219
220 // Term-level: on taxonomy archives MetaSync may have term meta
221 // (`_metasync_metadesc`) set via MCP, OTTO, or the importer.
222 $term = $this->get_current_term();
223 if ($term) {
224 $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
225 if (!empty($term_desc)) {
226 $this->has_description_cache = true;
227 return true;
228 }
229 }
230
231 $this->has_description_cache = false;
232 return false;
233 }
234
235 /**
236 * Return MetaSync's intentional meta description for the current request.
237 *
238 * Only returns values that were explicitly set (sidebar or OTTO), NOT
239 * auto-generated excerpts from the legacy `meta_description` key.
240 * This ensures we only suppress third-party plugins when MetaSync has
241 * a deliberate SEO value.
242 *
243 * @return string
244 */
245 public function get_metasync_description() {
246 $post_id = $this->get_current_object_id();
247
248 if (!$post_id) {
249 return '';
250 }
251
252 // 1. SEO sidebar (highest priority — user-edited)
253 $desc = get_post_meta($post_id, '_metasync_seo_desc', true);
254 if (!empty($desc)) {
255 return $desc;
256 }
257
258 // 2. OTTO description
259 $desc = get_post_meta($post_id, '_metasync_otto_description', true);
260 if (!empty($desc)) {
261 return $desc;
262 }
263
264 return '';
265 }
266
267 /**
268 * Resolve the MetaSync-managed canonical URL for a post, if any.
269 *
270 * Priority: OTTO persisted canonical (_metasync_canonical_url) → Canonical meta
271 * box value (meta_canonical). Restricted to singular views so term/archive
272 * queried-object ids are never misread as post ids. Mirrors the fallback in
273 * Metasync_Seo_Output::get_canonical_url() so both the no-SEO-plugin path and
274 * the third-party-plugin path honor the same value.
275 *
276 * @param int $post_id Current object id.
277 * @return string Escaped canonical URL, or '' when none is set.
278 */
279 private function get_metasync_canonical($post_id) {
280 if (!$post_id || !is_singular()) {
281 return '';
282 }
283
284 // Validate both sources: legacy rows corrupted to the literal "Array"
285 // (or stored as arrays) must never be emitted as a canonical.
286 $canonical = Metasync_Canonical_Sanitizer::sanitize(
287 get_post_meta($post_id, '_metasync_canonical_url', true)
288 );
289 if ($canonical === '') {
290 $canonical = Metasync_Canonical_Sanitizer::sanitize(
291 get_post_meta($post_id, 'meta_canonical', true)
292 );
293 }
294
295 return $canonical !== '' ? esc_url($canonical) : '';
296 }
297
298 /**
299 * Reset the cached description flag (useful when the queried object changes).
300 */
301 public function reset_cache() {
302 $this->has_description_cache = null;
303 $this->aioseo_has_description_cache = null;
304 $this->sync_cache = [];
305 $this->live_suggestions_cache = null;
306 }
307
308 /**
309 * Check whether a post has been synced to third-party plugins via.
310 *
311 * When native-first sync is active, each plugin reads MetaSync values from
312 * its own storage — no filter suppression needed. This method returns true
313 * when _metasync_plugin_sync_ts exists and contains a timestamp for the
314 * given plugin slug.
315 *
316 * @param int $post_id Post ID.
317 * @param string $plugin_slug Plugin slug: 'yoast', 'rankmath', or 'aioseo'.
318 * @return bool True if the post has been synced to this plugin.
319 */
320 private function is_post_synced($post_id, $plugin_slug) {
321 if ($post_id <= 0) {
322 return false;
323 }
324
325 if (!isset($this->sync_cache[$post_id])) {
326 $ts_raw = get_post_meta($post_id, '_metasync_plugin_sync_ts', true);
327 $this->sync_cache[$post_id] = !empty($ts_raw) ? json_decode($ts_raw, true) : [];
328 if (!is_array($this->sync_cache[$post_id])) {
329 $this->sync_cache[$post_id] = [];
330 }
331 }
332
333 return !empty($this->sync_cache[$post_id][$plugin_slug]);
334 }
335
336 /**
337 * For synced posts with multiple SEO plugins active, determine if a
338 * specific plugin is the designated output owner.
339 *
340 * Only the first active plugin in priority order (Yoast > Rank Math > AIOSEO)
341 * is allowed to output — the others are suppressed to prevent duplicate tags.
342 *
343 * @param int $post_id Post ID.
344 * @param string $plugin_slug Plugin slug to check.
345 * @return bool True if this plugin should output its tags.
346 */
347 private function is_primary_output_plugin($post_id, $plugin_slug) {
348 // For synced posts: only the primary synced plugin passes through.
349 // For unsynced posts with multiple plugins: only the highest-priority
350 // active plugin outputs to prevent duplicate tags.
351 $this->ensure_plugin_api();
352 $priority = ['yoast', 'rankmath', 'aioseo'];
353 $active_check = [
354 'yoast' => is_plugin_active('wordpress-seo/wp-seo.php') || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php'),
355 'rankmath' => is_plugin_active('seo-by-rank-math/rank-math.php') || is_plugin_active('seo-by-rankmath/rank-math.php'),
356 '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'),
357 ];
358
359 $has_any_sync = $post_id > 0 && ($this->is_post_synced($post_id, 'yoast') || $this->is_post_synced($post_id, 'rankmath') || $this->is_post_synced($post_id, 'aioseo'));
360
361 if ($has_any_sync) {
362 // Synced: primary = first active + synced plugin
363 foreach ($priority as $slug) {
364 if ($active_check[$slug] && $this->is_post_synced($post_id, $slug)) {
365 return $slug === $plugin_slug;
366 }
367 }
368 return false;
369 }
370
371 // Unsynced / multiple plugins active: pick the first active plugin
372 // as the sole outputter to prevent duplicate tags.
373 $active_count = count(array_filter($active_check));
374 if ($active_count > 1) {
375 foreach ($priority as $slug) {
376 if ($active_check[$slug]) {
377 return $slug === $plugin_slug;
378 }
379 }
380 }
381
382 // Single plugin active or no plugins — don't interfere
383 return false;
384 }
385
386 /**
387 * Ensure is_plugin_active() is loaded on the frontend.
388 */
389 private function ensure_plugin_api() {
390 if (!function_exists('is_plugin_active')) {
391 require_once ABSPATH . 'wp-admin/includes/plugin.php';
392 }
393 }
394
395 // ------------------------------------------------------------------
396 // AIOSEO integration
397 // ------------------------------------------------------------------
398
399 /**
400 * Register AIOSEO-specific filters to suppress its output
401 * when MetaSync/OTTO already provides the same tags.
402 */
403 private function register_aioseo_filters() {
404 // Suppress AIOSEO meta description
405 add_filter('aioseo_description', [$this, 'filter_aioseo_description'], 999);
406
407 // Suppress AIOSEO title
408 add_filter('aioseo_title', [$this, 'filter_aioseo_title'], 999);
409
410 // Suppress AIOSEO OG/Twitter tags that OTTO already provides
411 add_filter('aioseo_facebook_tags', [$this, 'filter_aioseo_facebook_tags'], 999);
412 add_filter('aioseo_twitter_tags', [$this, 'filter_aioseo_twitter_tags'], 999);
413
414 // Suppress AIOSEO robots when MetaSync has an intentional robots value
415 add_filter('aioseo_robots_meta', [$this, 'filter_aioseo_robots'], 999);
416
417 // Suppress AIOSEO schema/JSON-LD when OTTO has structured data
418 add_filter('aioseo_schema_output', [$this, 'filter_aioseo_schema'], 999);
419
420 // Canonical — override AIOSEO's canonical with the MetaSync/OTTO value when set
421 add_filter('aioseo_canonical_url', [$this, 'filter_aioseo_canonical'], 999);
422 }
423
424 /**
425 * Filter AIOSEO's canonical URL.
426 *
427 * Mirrors filter_yoast_canonical(): return the MetaSync-managed canonical
428 * (OTTO or the Canonical meta box) when set, else pass AIOSEO's through.
429 */
430 public function filter_aioseo_canonical($canonical) {
431 $custom = $this->get_metasync_canonical($this->get_current_object_id());
432 return $custom !== '' ? $custom : $canonical;
433 }
434
435 /**
436 * Filter AIOSEO description output.
437 * Returns empty string when MetaSync has a description, letting MetaSync output it.
438 *
439 * @param string $description AIOSEO's computed description.
440 * @return string
441 */
442 public function filter_aioseo_description($description) {
443 // Cache whether AIOSEO actually has a description (before we suppress it).
444 // This is used later by should_output_legacy_description().
445 if ($this->aioseo_has_description_cache === null) {
446 $this->aioseo_has_description_cache = !empty($description);
447 }
448
449 // Primary plugin check — only the designated plugin outputs.
450 $post_id = $this->get_current_object_id();
451 if ($post_id && $this->has_active_seo_plugin()) {
452 if ($this->is_primary_output_plugin($post_id, 'aioseo')) {
453 return $description;
454 }
455 if ($this->is_primary_output_plugin($post_id, 'yoast') || $this->is_primary_output_plugin($post_id, 'rankmath')) {
456 return '';
457 }
458 }
459
460 // Term archives: AIOSEO free doesn't read per-term custom descriptions
461 // from its `wp_aioseo_terms` table, so return the MetaSync value
462 // directly so AIOSEO renders it.
463 $term = $this->get_current_term();
464 if ($term) {
465 $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
466 if (!empty($term_desc)) {
467 return $term_desc;
468 }
469 }
470
471 // Suppress when: OTTO active + has description, OR MetaSync sidebar has description
472 if ($this->otto_has_tag('description') || $this->metasync_has_description()) {
473 return '';
474 }
475 return $description;
476 }
477
478 /**
479 * Filter AIOSEO title output.
480 *
481 * On term archives: AIOSEO free doesn't read custom per-term titles from
482 * its `wp_aioseo_terms` table, so we replace AIOSEO's template-based title
483 * with the MetaSync term title directly.
484 *
485 * @param string $title AIOSEO's computed title.
486 * @return string
487 */
488 public function filter_aioseo_title($title) {
489 // Primary plugin check — only the designated plugin outputs.
490 $post_id = $this->get_current_object_id();
491 if ($post_id && $this->has_active_seo_plugin()) {
492 if ($this->is_primary_output_plugin($post_id, 'aioseo')) {
493 return $title;
494 }
495 if ($this->is_primary_output_plugin($post_id, 'yoast') || $this->is_primary_output_plugin($post_id, 'rankmath')) {
496 return '';
497 }
498 }
499
500 // Term archives: return MetaSync term title directly.
501 $term = $this->get_current_term();
502 if ($term) {
503 $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
504 if (!empty($term_title)) {
505 return $term_title;
506 }
507 }
508
509 if ($this->should_suppress_third_party_title()) {
510 return '';
511 }
512
513 return $title;
514 }
515
516 /**
517 * Filter AIOSEO Facebook/OG tags.
518 *
519 * Per-tag suppression: only remove a tag when OTTO is active AND has
520 * a persisted value for that specific tag, OR when MetaSync sidebar
521 * provides the equivalent value.
522 *
523 * @param array $meta AIOSEO's OG meta array.
524 * @return array
525 */
526 public function filter_aioseo_facebook_tags($meta) {
527 if (!is_array($meta)) {
528 return $meta;
529 }
530
531 $post_id = $this->get_current_object_id();
532
533 // Post synced to AIOSEO — let AIOSEO read from its own storage.
534 if ($post_id && $this->is_primary_output_plugin($post_id, 'aioseo')) {
535 return $meta;
536 }
537
538 // og:title — suppress when OTTO has og:title OR MetaSync has title
539 if ($this->otto_has_tag('og:title') || ($post_id && $this->metasync_has_title($post_id))) {
540 unset($meta['og:title']);
541 }
542
543 // og:description — suppress when OTTO has og:description OR MetaSync has description
544 if ($this->otto_has_tag('og:description') || $this->metasync_has_description()) {
545 unset($meta['og:description']);
546 }
547
548 // og:url, og:type, og:locale, og:site_name — suppress when OTTO has og:title
549 // (OTTO injects these structural OG tags alongside og:title in its block)
550 if ($this->otto_has_tag('og:title')) {
551 unset($meta['og:url'], $meta['og:type'], $meta['og:locale'], $meta['og:site_name']);
552 }
553
554 return $meta;
555 }
556
557 /**
558 * Filter AIOSEO Twitter tags.
559 *
560 * Per-tag suppression: only remove a tag when OTTO is active AND has
561 * a persisted value for that specific tag, OR when MetaSync sidebar
562 * provides the equivalent value.
563 *
564 * @param array $meta AIOSEO's Twitter meta array.
565 * @return array
566 */
567 public function filter_aioseo_twitter_tags($meta) {
568 if (!is_array($meta)) {
569 return $meta;
570 }
571
572 $post_id = $this->get_current_object_id();
573
574 // Post synced to AIOSEO — let AIOSEO read from its own storage.
575 if ($post_id && $this->is_primary_output_plugin($post_id, 'aioseo')) {
576 return $meta;
577 }
578
579 if ($this->otto_has_tag('twitter:title') || ($post_id && $this->metasync_has_title($post_id))) {
580 unset($meta['twitter:title']);
581 }
582
583 if ($this->otto_has_tag('twitter:description') || $this->metasync_has_description()) {
584 unset($meta['twitter:description']);
585 }
586
587 // twitter:card — suppress when OTTO has any twitter tag
588 if ($this->otto_has_tag('twitter:title') || $this->otto_has_tag('twitter:description')) {
589 unset($meta['twitter:card']);
590 }
591
592 return $meta;
593 }
594
595 /**
596 * Filter AIOSEO robots meta output.
597 *
598 * When MetaSync has an intentional robots value (admin checkbox or REST API),
599 * suppress AIOSEO's robots tag to avoid duplicates. MetaSync's own output in
600 * hook_metasync_metatags() will output the MetaSync value instead.
601 *
602 * AIOSEO passes an array like ['noindex' => 'noindex', 'nofollow' => 'nofollow'].
603 * Returning an empty array suppresses AIOSEO's robots tag entirely.
604 *
605 * @param array $robots AIOSEO's computed robots attributes array.
606 * @return array
607 */
608 public function filter_aioseo_robots($robots) {
609 $post_id = $this->get_current_object_id();
610 if (!$post_id) {
611 return $robots;
612 }
613
614 // Post synced to AIOSEO — let AIOSEO read from its own storage.
615 if ($this->is_primary_output_plugin($post_id, 'aioseo')) {
616 return $robots;
617 }
618
619 if ($this->metasync_has_robots($post_id)) {
620 // MetaSync has robots — suppress AIOSEO's tag.
621 return [];
622 }
623
624 return $robots;
625 }
626
627 /**
628 * Filter AIOSEO schema/JSON-LD output.
629 * Suppress when OTTO has structured data for the current page.
630 * Also strip BreadcrumbList entries when MetaSync breadcrumbs are enabled,
631 * so MetaSync's own BreadcrumbList is the only one on the page.
632 *
633 * @param array $output AIOSEO's @graph array.
634 * @return array
635 */
636 public function filter_aioseo_schema($output) {
637 if ($this->otto_has_schema_for_current_page()) {
638 return [];
639 }
640
641 if ($this->metasync_breadcrumb_enabled() && is_array($output)) {
642 $output = $this->strip_breadcrumb_from_graph($output);
643 }
644
645 return $output;
646 }
647
648 /**
649 * Check whether MetaSync holds an intentional robots directive for a post.
650 *
651 * Checks both storage formats:
652 * - meta_robots (string from REST API)
653 * - metasync_common_robots (array from admin checkbox)
654 *
655 * @param int $post_id Post ID.
656 * @return bool
657 */
658 public function metasync_has_robots($post_id) {
659 $meta_robots = get_post_meta($post_id, 'meta_robots', true);
660 if (!empty($meta_robots)) {
661 return true;
662 }
663
664 $common_robots = get_post_meta($post_id, 'metasync_common_robots', true);
665 if (is_array($common_robots) && !empty(array_filter($common_robots))) {
666 return true;
667 }
668
669 return false;
670 }
671
672 /**
673 * Check whether MetaSync/OTTO has a title for a given post.
674 *
675 * @param int $post_id Post ID.
676 * @return bool
677 */
678 private function metasync_has_title($post_id) {
679 $seo_title = get_post_meta($post_id, '_metasync_seo_title', true);
680 if (!empty($seo_title)) {
681 return true;
682 }
683
684 $otto_title = get_post_meta($post_id, '_metasync_otto_title', true);
685 if (!empty($otto_title)) {
686 return true;
687 }
688
689 // Term-level fallback: on taxonomy archives the "object" is a term,
690 // so read `_metasync_metatitle` from term meta when we're rendering one.
691 $term = $this->get_current_term();
692 if ($term) {
693 $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
694 if (!empty($term_title)) {
695 return true;
696 }
697 }
698
699 return false;
700 }
701
702 /**
703 * Determine whether a third-party SEO plugin's title should be suppressed.
704 *
705 * Suppress when either condition is met:
706 * 1. OTTO is active AND has a persisted title for this page
707 * 2. MetaSync sidebar has an explicit title for this page
708 *
709 * @return bool True if the third-party title should be suppressed.
710 */
711 private function should_suppress_third_party_title() {
712 // Condition 1: OTTO active + has title for this page
713 if ($this->otto_has_tag('title')) {
714 return true;
715 }
716
717 // Condition 2: MetaSync sidebar has explicit title
718 $post_id = $this->get_current_object_id();
719 if ($post_id) {
720 return $this->metasync_has_title($post_id);
721 }
722
723 return false;
724 }
725
726 /**
727 * Check whether the OTTO pixel is active.
728 *
729 * @return bool
730 */
731 private function is_otto_active() {
732 if (class_exists('Metasync_Otto_Config')) {
733 return Metasync_Otto_Config::is_otto_enabled();
734 }
735
736 return false;
737 }
738
739 /**
740 * Check whether the OTTO transient cache has live suggestions for the
741 * current request URL.
742 *
743 * Passive get_transient() lookup only — no OTTO API call. Mirrors the
744 * cache-key format used by Metasync_Otto_Transient_Cache and the URL
745 * construction from Otto_pixel_class::get_route().
746 *
747 * @return bool
748 */
749 public function otto_has_live_suggestions() {
750 if ($this->live_suggestions_cache !== null) {
751 return $this->live_suggestions_cache;
752 }
753
754 if (!$this->is_otto_active()) {
755 $this->live_suggestions_cache = false;
756 return false;
757 }
758
759 if (empty($_SERVER['HTTP_HOST']) || empty($_SERVER['REQUEST_URI'])) {
760 $this->live_suggestions_cache = false;
761 return false;
762 }
763
764 $scheme = is_ssl() ? 'https' : 'http';
765 $host = $_SERVER['HTTP_HOST'];
766 $request_uri = strtok($_SERVER['REQUEST_URI'], '?') ?: $_SERVER['REQUEST_URI'];
767 $url = $scheme . '://' . $host . $request_uri;
768
769 $hash = md5(rtrim(strtolower($url), '/'));
770 $site_id = is_multisite() ? get_current_blog_id() : 0;
771 $cached = get_transient('otto_suggestions_' . $site_id . '_' . $hash);
772
773 $this->live_suggestions_cache = ($cached !== false && !empty($cached));
774 return $this->live_suggestions_cache;
775 }
776
777 /**
778 * Check whether OTTO has a persisted value for a specific meta tag.
779 *
780 * Two conditions must be true to suppress a third-party tag:
781 * 1. OTTO is active (globally enabled)
782 * 2. OTTO has a value for this specific tag on the current page
783 *
784 * For OG/Twitter tags where OTTO's pixel injects dynamically (the
785 * specific _metasync_otto_og_* key may be empty), the buffer-level
786 * dedup in Otto_html_class::deduplicate_og_twitter_tags() handles
787 * removal after all sources have output. This method only does the
788 * direct per-tag check.
789 *
790 * @param string $tag Tag identifier (e.g. 'title', 'og:title', 'twitter:description').
791 * @return bool True when OTTO is active AND has a persisted value for this tag.
792 */
793 private function otto_has_tag($tag) {
794 if (!$this->is_otto_active()) {
795 return false;
796 }
797
798 $post_id = $this->get_current_object_id();
799 if (!$post_id) {
800 return false;
801 }
802
803 if ($this->has_active_seo_plugin() && !$this->otto_has_live_suggestions()) {
804 return false;
805 }
806
807 $meta_key_map = [
808 'title' => '_metasync_otto_title',
809 'description' => '_metasync_otto_description',
810 'og:title' => '_metasync_otto_og_title',
811 'og:description' => '_metasync_otto_og_description',
812 'twitter:title' => '_metasync_otto_twitter_title',
813 'twitter:description' => '_metasync_otto_twitter_description',
814 ];
815
816 if (!isset($meta_key_map[$tag])) {
817 return false;
818 }
819
820 return !empty(get_post_meta($post_id, $meta_key_map[$tag], true));
821 }
822
823 // ------------------------------------------------------------------
824 // Yoast SEO integration
825 // ------------------------------------------------------------------
826
827 /**
828 * Register Yoast SEO-specific filters to suppress its title,
829 * description, and OG/Twitter output when MetaSync/OTTO provides them.
830 */
831 private function register_yoast_filters() {
832 add_filter('wpseo_title', [$this, 'filter_yoast_title'], 999);
833 add_filter('wpseo_metadesc', [$this, 'filter_yoast_description'], 999);
834
835 // OG tags — per-tag suppression
836 add_filter('wpseo_opengraph_title', [$this, 'filter_yoast_og_title'], 999);
837 add_filter('wpseo_opengraph_desc', [$this, 'filter_yoast_og_description'], 999);
838 add_filter('wpseo_opengraph_url', [$this, 'filter_yoast_og_structural'], 999);
839 add_filter('wpseo_opengraph_type', [$this, 'filter_yoast_og_structural'], 999);
840 add_filter('wpseo_opengraph_site_name', [$this, 'filter_yoast_og_structural'], 999);
841 add_filter('wpseo_og_locale', [$this, 'filter_yoast_og_structural'], 999);
842 add_filter('wpseo_opengraph_image', [$this, 'filter_yoast_og_structural'], 999);
843
844 // Twitter tags — per-tag suppression
845 add_filter('wpseo_twitter_title', [$this, 'filter_yoast_twitter_title'], 999);
846 add_filter('wpseo_twitter_description', [$this, 'filter_yoast_twitter_description'], 999);
847 add_filter('wpseo_twitter_image', [$this, 'filter_yoast_twitter_structural'], 999);
848 add_filter('wpseo_twitter_card_type', [$this, 'filter_yoast_twitter_structural'], 999);
849
850 // Suppress Yoast schema/JSON-LD when OTTO has structured data
851 add_filter('wpseo_schema_graph', [$this, 'filter_yoast_schema'], 999);
852
853 // Canonical — override Yoast's canonical with the MetaSync/OTTO value when set
854 add_filter('wpseo_canonical', [$this, 'filter_yoast_canonical'], 999);
855 }
856
857 /**
858 * Filter Yoast's canonical URL.
859 *
860 * When a MetaSync-managed canonical exists (OTTO or the Canonical meta box),
861 * return it so Yoast emits our value instead of its own — avoiding a duplicate
862 * <link rel="canonical"> while still honoring the per-post override.
863 * Otherwise let Yoast's canonical through unchanged.
864 */
865 public function filter_yoast_canonical($canonical) {
866 $custom = $this->get_metasync_canonical($this->get_current_object_id());
867 return $custom !== '' ? $custom : $canonical;
868 }
869
870 /**
871 * Filter Yoast SEO title output.
872 *
873 * When the MetaSync sidebar has an explicit SEO title, return that title so
874 * Yoast's Title_Presenter renders it inside the <title> tag it controls.
875 * Returning '' would cause Title_Presenter to emit NO <title> tag at all,
876 * because Yoast has already removed WordPress's native _wp_render_title_tag
877 * action and is the sole renderer of the title element.
878 *
879 * When only OTTO has a title (no sidebar override), we let Yoast output its
880 * own title normally — OTTO's buffer post-processing replaces it in the final
881 * HTML. Returning '' here would again leave the page with no <title> tag.
882 */
883 public function filter_yoast_title($title) {
884 $post_id = $this->get_current_object_id();
885
886 // When Yoast is the primary output plugin, let it through.
887 // For synced posts, Yoast reads from its own storage (already has the value).
888 // For unsynced posts as primary, still check for MetaSync sidebar override.
889 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
890 // Even in passthrough, a sidebar title override takes precedence
891 $sidebar_title = get_post_meta($post_id, '_metasync_seo_title', true);
892 if (!empty($sidebar_title)) {
893 return $sidebar_title;
894 }
895 return $title;
896 }
897
898 // Another plugin is primary — suppress Yoast.
899 if ($post_id && $this->has_active_seo_plugin()) {
900 if ($this->is_primary_output_plugin($post_id, 'rankmath') || $this->is_primary_output_plugin($post_id, 'aioseo')) {
901 return '';
902 }
903 }
904
905 // Term-level archives
906 $term = $this->get_current_term();
907 if ($term) {
908 $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
909 if (!empty($term_title)) {
910 return $term_title;
911 }
912 }
913
914 // MetaSync sidebar has an explicit title — return it so Yoast renders it.
915 if ($post_id) {
916 $sidebar_title = get_post_meta($post_id, '_metasync_seo_title', true);
917 if (!empty($sidebar_title)) {
918 return $sidebar_title;
919 }
920 }
921
922 // Case 2: OTTO has a persisted title — do NOT suppress Yoast here.
923 // OTTO's output-buffer post-processing (Otto_html_class) replaces the
924 // <title> tag in the final HTML after WordPress renders. Returning '' would
925 // remove the <title> tag entirely before OTTO can inject its replacement.
926
927 return $title;
928 }
929
930 /**
931 * Filter Yoast SEO description output.
932 *
933 * On term archives: the term-level sync writes MetaSync's description
934 * into Yoast's native storage, so Yoast already computes the correct
935 * value — let it through.
936 *
937 * On singular pages: suppress when OTTO or MetaSync sidebar provides
938 * the description (MetaSync outputs its own tag).
939 */
940 public function filter_yoast_description($description) {
941 $post_id = $this->get_current_object_id();
942 if ($post_id && $this->has_active_seo_plugin()) {
943 if ($this->is_primary_output_plugin($post_id, 'yoast')) {
944 return $description;
945 }
946 if ($this->is_primary_output_plugin($post_id, 'rankmath') || $this->is_primary_output_plugin($post_id, 'aioseo')) {
947 return '';
948 }
949 }
950
951 // Term archives: MetaSync syncs to Yoast storage — let Yoast render it.
952 $term = $this->get_current_term();
953 if ($term) {
954 $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
955 if (!empty($term_desc)) {
956 return $description;
957 }
958 }
959
960 if ($this->otto_has_tag('description') || $this->metasync_has_description()) {
961 return '';
962 }
963 return $description;
964 }
965
966 /**
967 * Filter Yoast og:title output.
968 * Suppress when: OTTO active + has og:title, OR MetaSync has title.
969 */
970 public function filter_yoast_og_title($value) {
971 $post_id = $this->get_current_object_id();
972 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
973 return $value;
974 }
975 if ($this->otto_has_tag('og:title') || ($post_id && $this->metasync_has_title($post_id))) {
976 return '';
977 }
978 return $value;
979 }
980
981 /**
982 * Filter Yoast og:description output.
983 * Suppress when: OTTO active + has og:description, OR MetaSync has description.
984 */
985 public function filter_yoast_og_description($value) {
986 $post_id = $this->get_current_object_id();
987 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
988 return $value;
989 }
990 if ($this->otto_has_tag('og:description') || $this->metasync_has_description()) {
991 return '';
992 }
993 return $value;
994 }
995
996 /**
997 * Filter Yoast OG structural tags (og:url, og:type, og:locale, og:site_name, og:image).
998 * Suppress when: OTTO active + has og:title (OTTO provides these alongside og:title).
999 */
1000 public function filter_yoast_og_structural($value) {
1001 $post_id = $this->get_current_object_id();
1002 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1003 return $value;
1004 }
1005 if ($this->otto_has_tag('og:title')) {
1006 return '';
1007 }
1008 return $value;
1009 }
1010
1011 /**
1012 * Filter Yoast twitter:title output.
1013 * Suppress when: OTTO active + has twitter:title, OR MetaSync has title.
1014 */
1015 public function filter_yoast_twitter_title($value) {
1016 $post_id = $this->get_current_object_id();
1017 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1018 return $value;
1019 }
1020 if ($this->otto_has_tag('twitter:title') || ($post_id && $this->metasync_has_title($post_id))) {
1021 return '';
1022 }
1023 return $value;
1024 }
1025
1026 /**
1027 * Filter Yoast twitter:description output.
1028 * Suppress when: OTTO active + has twitter:description, OR MetaSync has description.
1029 */
1030 public function filter_yoast_twitter_description($value) {
1031 $post_id = $this->get_current_object_id();
1032 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1033 return $value;
1034 }
1035 if ($this->otto_has_tag('twitter:description') || $this->metasync_has_description()) {
1036 return '';
1037 }
1038 return $value;
1039 }
1040
1041 /**
1042 * Filter Yoast Twitter structural tags (twitter:image, twitter:card).
1043 * Suppress when: OTTO active + has any twitter tag.
1044 */
1045 public function filter_yoast_twitter_structural($value) {
1046 $post_id = $this->get_current_object_id();
1047 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1048 return $value;
1049 }
1050 if ($this->otto_has_tag('twitter:title') || $this->otto_has_tag('twitter:description')) {
1051 return '';
1052 }
1053 return $value;
1054 }
1055
1056 /**
1057 * Filter Yoast SEO schema/JSON-LD output.
1058 * Suppress when OTTO has structured data for the current page.
1059 * Also strip BreadcrumbList entries when MetaSync breadcrumbs are enabled,
1060 * so MetaSync's own BreadcrumbList is the only one on the page.
1061 *
1062 * @param array|false $data Yoast's JSON-LD data.
1063 * @return array|false
1064 */
1065 public function filter_yoast_schema($data) {
1066 if ($this->otto_has_schema_for_current_page()) {
1067 return false;
1068 }
1069
1070 if ($this->metasync_breadcrumb_enabled() && is_array($data)) {
1071 $data = $this->strip_breadcrumb_from_graph($data);
1072 }
1073
1074 return $data;
1075 }
1076
1077 // ------------------------------------------------------------------
1078 // RankMath integration
1079 // ------------------------------------------------------------------
1080
1081 /**
1082 * Register RankMath-specific filters to suppress its title and
1083 * description output when MetaSync/OTTO already provides them.
1084 */
1085 private function register_rankmath_filters() {
1086 add_filter('rank_math/frontend/title', [$this, 'filter_rankmath_title'], 999);
1087 add_filter('rank_math/frontend/description', [$this, 'filter_rankmath_description'], 999);
1088
1089 // Suppress RankMath schema/JSON-LD when OTTO has structured data
1090 add_filter('rank_math/json_ld', [$this, 'filter_rankmath_schema'], 999);
1091
1092 // Canonical — override RankMath's canonical with the MetaSync/OTTO value when set
1093 add_filter('rank_math/frontend/canonical', [$this, 'filter_rankmath_canonical'], 999);
1094 }
1095
1096 /**
1097 * Filter RankMath's canonical URL.
1098 *
1099 * Mirrors filter_yoast_canonical(): return the MetaSync-managed canonical
1100 * (OTTO or the Canonical meta box) when set, else pass RankMath's through.
1101 */
1102 public function filter_rankmath_canonical($canonical) {
1103 $custom = $this->get_metasync_canonical($this->get_current_object_id());
1104 return $custom !== '' ? $custom : $canonical;
1105 }
1106
1107 /**
1108 * Filter RankMath title output.
1109 *
1110 * On taxonomy archive pages: when MetaSync has an explicit `_metasync_metatitle`
1111 * term meta value, return it so Rank Math renders the MetaSync-managed archive
1112 * title inside <title>. This mirrors the Yoast term-level title override in
1113 * filter_yoast_title().
1114 *
1115 * On singular pages: return empty string when MetaSync/OTTO has a title (Rank
1116 * Math controls the sole <title> renderer on classic themes, so OTTO's buffer
1117 * post-processing will replace it — returning '' would leave no <title> at all).
1118 *
1119 * @param string $title RankMath's computed title.
1120 * @return string
1121 */
1122 public function filter_rankmath_title($title) {
1123 $post_id = $this->get_current_object_id();
1124 // If another plugin is the primary output owner, suppress Rank Math.
1125 if ($post_id && $this->has_active_seo_plugin()) {
1126 if ($this->is_primary_output_plugin($post_id, 'rankmath')) {
1127 return $title;
1128 }
1129 // Another plugin is primary — suppress this one
1130 if ($this->is_primary_output_plugin($post_id, 'yoast') || $this->is_primary_output_plugin($post_id, 'aioseo')) {
1131 return '';
1132 }
1133 }
1134
1135 // Term-level archives (category/tag/custom taxonomy): when MetaSync has
1136 // an explicit `_metasync_metatitle`, return it so Rank Math renders the
1137 // MetaSync-managed archive title inside <title>.
1138 $term = $this->get_current_term();
1139 if ($term) {
1140 $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
1141 if (!empty($term_title)) {
1142 return $term_title;
1143 }
1144 }
1145
1146 $post_id = $this->get_current_object_id();
1147
1148 // MetaSync sidebar has an explicit title — return it so Rank Math renders it.
1149 if ($post_id) {
1150 $sidebar_title = get_post_meta($post_id, '_metasync_seo_title', true);
1151 if (!empty($sidebar_title)) {
1152 return $sidebar_title;
1153 }
1154 }
1155
1156 // OTTO has a persisted title — do NOT suppress Rank Math here.
1157 // OTTO's output-buffer post-processing replaces the <title> tag in the
1158 // final HTML. Returning '' would remove the tag before OTTO can inject.
1159
1160 return $title;
1161 }
1162
1163 /**
1164 * Filter RankMath description output.
1165 *
1166 * On term archives: the term-level sync writes MetaSync's description
1167 * into Rank Math's native term meta, so Rank Math already computes the
1168 * correct value — let it through.
1169 *
1170 * On singular pages: suppress when OTTO or MetaSync sidebar provides
1171 * the description.
1172 *
1173 * @param string $description RankMath's computed description.
1174 * @return string
1175 */
1176 public function filter_rankmath_description($description) {
1177 $post_id = $this->get_current_object_id();
1178 if ($post_id && $this->has_active_seo_plugin()) {
1179 if ($this->is_primary_output_plugin($post_id, 'rankmath')) {
1180 return $description;
1181 }
1182 if ($this->is_primary_output_plugin($post_id, 'yoast') || $this->is_primary_output_plugin($post_id, 'aioseo')) {
1183 return '';
1184 }
1185 }
1186
1187 // Term archives: MetaSync syncs to Rank Math storage — let it render.
1188 $term = $this->get_current_term();
1189 if ($term) {
1190 $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
1191 if (!empty($term_desc)) {
1192 return $description;
1193 }
1194 }
1195
1196 if ($this->otto_has_tag('description') || $this->metasync_has_description()) {
1197 return '';
1198 }
1199
1200 return $description;
1201 }
1202
1203 /**
1204 * Filter RankMath schema/JSON-LD output.
1205 * Suppress when OTTO has structured data for the current page.
1206 * Also strip BreadcrumbList entries when MetaSync breadcrumbs are enabled,
1207 * so MetaSync's own BreadcrumbList is the only one on the page.
1208 *
1209 * @param array $data RankMath's JSON-LD data array.
1210 * @return array
1211 */
1212 public function filter_rankmath_schema($data) {
1213 if ($this->otto_has_schema_for_current_page()) {
1214 return [];
1215 }
1216
1217 if ($this->metasync_breadcrumb_enabled() && is_array($data)) {
1218 $data = $this->strip_breadcrumb_from_graph($data);
1219 }
1220
1221 return $data;
1222 }
1223
1224 // ------------------------------------------------------------------
1225 // MetaSync output gating
1226 // ------------------------------------------------------------------
1227
1228 /**
1229 * Whether the legacy `hook_metasync_metatags()` should output a description tag.
1230 *
1231 * Decision matrix (when a third-party SEO plugin is active):
1232 * MetaSync has value → true (MetaSync outputs, AIOSEO suppressed via filter)
1233 * AIOSEO has value → false (let AIOSEO handle it)
1234 * Neither has value → true (fallback: legacy auto-generated description)
1235 *
1236 * When no third-party SEO plugin is active → always true.
1237 *
1238 * @return bool True if the legacy output should include a description tag.
1239 */
1240 public function should_output_legacy_description() {
1241 // Post synced to any active plugin — that plugin now owns the
1242 // description output from its native storage. Suppress MetaSync's own tag.
1243 $post_id = $this->get_current_object_id();
1244 if ($post_id && $this->has_active_seo_plugin()) {
1245 // If synced to any plugin, a primary output plugin exists — suppress MetaSync's own tag.
1246 if ($this->is_post_synced($post_id, 'yoast') || $this->is_post_synced($post_id, 'rankmath') || $this->is_post_synced($post_id, 'aioseo')) {
1247 return false;
1248 }
1249 }
1250
1251 // OTTO active + has description → suppress legacy auto-generated description.
1252 if ($this->otto_has_tag('description')) {
1253 return false;
1254 }
1255
1256 if (!$this->has_active_seo_plugin()) {
1257 return true;
1258 }
1259
1260 // MetaSync has an intentional value — always output it
1261 if ($this->metasync_has_description()) {
1262 return true;
1263 }
1264
1265 // Check if AIOSEO actually provides a description for this page.
1266 // If it does, suppress our legacy output to avoid duplicates.
1267 // If it doesn't, let our legacy auto-generated description through
1268 // so the page isn't left with zero descriptions.
1269 if ($this->is_aioseo_active() && $this->aioseo_provides_description()) {
1270 return false;
1271 }
1272
1273 // For Yoast/RankMath: they always auto-generate a description,
1274 // so suppress our legacy output when they're active.
1275 if (is_plugin_active('wordpress-seo/wp-seo.php')) {
1276 return false;
1277 }
1278 if (is_plugin_active('seo-by-rank-math/rank-math.php') ||
1279 is_plugin_active('seo-by-rankmath/rank-math.php')) {
1280 return false;
1281 }
1282
1283 // No third-party plugin will provide a description — output ours
1284 return true;
1285 }
1286
1287 /**
1288 * Check whether AIOSEO will actually output a description for the current page.
1289 *
1290 * Uses the cached value captured in filter_aioseo_description() if available.
1291 * Falls back to calling AIOSEO's API directly if the filter hasn't fired yet.
1292 *
1293 * @return bool
1294 */
1295 private function aioseo_provides_description() {
1296 // Use cached value if available (set when our filter fires)
1297 if ($this->aioseo_has_description_cache !== null) {
1298 return $this->aioseo_has_description_cache;
1299 }
1300
1301 // Filter hasn't fired yet — query AIOSEO directly
1302 if (function_exists('aioseo') && isset(aioseo()->meta->description)) {
1303 $desc = aioseo()->meta->description->getDescription();
1304 $this->aioseo_has_description_cache = !empty($desc);
1305 return $this->aioseo_has_description_cache;
1306 }
1307
1308 // Can't determine — assume AIOSEO has one to avoid duplicates
1309 $this->aioseo_has_description_cache = true;
1310 return true;
1311 }
1312
1313 // ------------------------------------------------------------------
1314 // Helpers
1315 // ------------------------------------------------------------------
1316
1317 /**
1318 * Strip BreadcrumbList nodes from a JSON-LD @graph array and remove
1319 * dangling breadcrumb references from WebPage-type nodes.
1320 *
1321 * Previously we only removed the BreadcrumbList entry but left
1322 * the WebPage's `breadcrumb: { @id: "...#breadcrumb" }` property intact.
1323 * Google follows that dangling @id, finds no matching node, and reports
1324 * "Missing field itemListElement".
1325 *
1326 * @param array $graph The @graph array from a third-party SEO plugin.
1327 * @return array
1328 */
1329 private function strip_breadcrumb_from_graph($graph) {
1330 // Collect @ids of BreadcrumbList nodes being removed.
1331 $removed_ids = [];
1332
1333 foreach ($graph as $key => $entry) {
1334 if (is_array($entry) && isset($entry['@type']) && $entry['@type'] === 'BreadcrumbList') {
1335 if (!empty($entry['@id'])) {
1336 $removed_ids[] = $entry['@id'];
1337 }
1338 unset($graph[$key]);
1339 }
1340 }
1341
1342 // Remove dangling breadcrumb references from WebPage-type nodes.
1343 foreach ($graph as $key => &$entry) {
1344 if (!is_array($entry) || !isset($entry['@type'])) {
1345 continue;
1346 }
1347
1348 $type = $entry['@type'];
1349 $is_page_type = $type === 'WebPage'
1350 || (is_array($type) && in_array('WebPage', $type, true));
1351
1352 if ($is_page_type && isset($entry['breadcrumb'])) {
1353 // Remove if the reference points to a stripped node, or if
1354 // no BreadcrumbList remains in this graph at all.
1355 $ref_id = is_array($entry['breadcrumb']) ? ($entry['breadcrumb']['@id'] ?? '') : '';
1356 if (empty($removed_ids) || empty($ref_id) || in_array($ref_id, $removed_ids, true)) {
1357 unset($entry['breadcrumb']);
1358 }
1359 }
1360 }
1361 unset($entry);
1362
1363 return array_values($graph);
1364 }
1365
1366 /**
1367 * Determine whether MetaSync's own BreadcrumbList output is enabled.
1368 *
1369 * Mirrors the gate logic in Metasync_Breadcrumbs_Schema::output_breadcrumb_schema():
1370 * enabled by default, disabled only when the `enabled` setting is explicitly falsy.
1371 * Used by the Yoast / RankMath / AIOSEO schema filters so we only strip their
1372 * BreadcrumbList entries when MetaSync will emit one itself.
1373 *
1374 * @return bool
1375 */
1376 private function metasync_breadcrumb_enabled() {
1377 $settings = Metasync::get_option('breadcrumbs', array());
1378 if (!is_array($settings)) {
1379 return true;
1380 }
1381
1382 if (array_key_exists('enabled', $settings) && empty($settings['enabled'])) {
1383 return false;
1384 }
1385
1386 // When schema output is explicitly disabled, don't strip
1387 // third-party breadcrumbs — MetaSync won't emit its own.
1388 if (!empty($settings['disable_schema'])) {
1389 return false;
1390 }
1391
1392 return true;
1393 }
1394
1395 /**
1396 * Check whether OTTO has structured data (schema/JSON-LD) for the current page.
1397 *
1398 * @return bool
1399 */
1400 private function otto_has_schema_for_current_page() {
1401 if (!$this->is_otto_active()) {
1402 return false;
1403 }
1404
1405 $post_id = $this->get_current_object_id();
1406 if (!$post_id) {
1407 return false;
1408 }
1409
1410 if ($this->has_active_seo_plugin() && !$this->otto_has_live_suggestions()) {
1411 return false;
1412 }
1413
1414 return !empty(get_post_meta($post_id, '_metasync_otto_structured_data', true));
1415 }
1416
1417 /**
1418 * Get the current queried object ID.
1419 *
1420 * Uses get_queried_object_id() as the universal fallback so every
1421 * public page type (singular, front page, static blog page, CPT
1422 * archives, WooCommerce shop, etc.) is covered without enumerating
1423 * each one individually.
1424 *
1425 * For blog-style homepages (show_on_front=posts) there is no backing
1426 * page, so this returns 0.
1427 *
1428 * @return int 0 when unknown.
1429 */
1430 private function get_current_object_id() {
1431 // Singular pages (posts, pages, CPTs, attachments)
1432 if (is_singular()) {
1433 return (int) get_the_ID();
1434 }
1435
1436 // WooCommerce shop page (virtual archive backed by a real page)
1437 if (function_exists('is_shop') && is_shop()) {
1438 return function_exists('wc_get_page_id') ? (int) wc_get_page_id('shop') : 0;
1439 }
1440
1441 // Universal fallback: static front page, static posts page,
1442 // or any other page type WordPress assigns a queried object to.
1443 $id = get_queried_object_id();
1444 if ($id > 0) {
1445 return (int) $id;
1446 }
1447
1448 return 0;
1449 }
1450
1451 /**
1452 * Return the WP_Term being rendered on taxonomy archive pages.
1453 *
1454 * Only returns a term when the current query is a category, tag, or
1455 * custom taxonomy archive — i.e. when MetaSync term meta could be
1456 * driving the rendered output. Returns null in every other context
1457 * (singular, blog home, search, 404, etc.) so callers don't have to
1458 * double-check the page type.
1459 *
1460 * @return \WP_Term|null
1461 */
1462 private function get_current_term() {
1463 if (!(is_category() || is_tag() || is_tax())) {
1464 return null;
1465 }
1466
1467 $queried = get_queried_object();
1468 if ($queried instanceof \WP_Term) {
1469 return $queried;
1470 }
1471
1472 return null;
1473 }
1474 }
1475