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

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