PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
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.7.2, at includes/class-metasync-seo-conflict-handler.php

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