PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
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
← All changes | includes/class-metasync-seo-conflict-handler.php +1228 -149 2.6.20 → 2.7.1 View file →
@@ -35,8 +35,15 @@
35 35 */
36 36 private $has_description_cache = null;
37 37
38 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 + /**
39 46 * Cached result for whether AIOSEO provides a description for the current page.
40 47 *
41 48 * @var bool|null
42 49 */
@@ -42,8 +49,17 @@
42 49 */
43 50 private $aioseo_has_description_cache = null;
44 51
45 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 + /**
46 62 * Cached result: whether the current post has been synced via.
47 63 *
48 64 * @var array Keyed by post_id => bool
49 65 */
@@ -179,9 +195,9 @@
179 195 // Defensive: $wpdb is always present on a booted frontend, but never
180 196 // assume — a method call on a null/!object $wpdb would be fatal.
181 197 if (isset($wpdb) && is_object($wpdb)) {
182 198 $table = $wpdb->prefix . 'aioseo_posts';
183 - $desc = $wpdb->get_var($wpdb->prepare("SELECT description FROM {$table} WHERE post_id = %d", $post_id));
199 + $desc = $wpdb->get_var($wpdb->prepare("SELECT description FROM {$wpdb->prefix}aioseo_posts WHERE post_id = %d", $post_id)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- reads third-party plugin storage — AIOSEO keeps descriptions in its own table, no WordPress API
184 200 if (!empty($desc)) {
185 201 return true;
186 202 }
187 203 }
@@ -220,9 +236,9 @@
220 236 // Term-level: on taxonomy archives MetaSync may have term meta
221 237 // (`_metasync_metadesc`) set via MCP, OTTO, or the importer.
222 238 $term = $this->get_current_term();
223 239 if ($term) {
224 - $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
240 + $term_desc = $this->get_metasync_term_description($term);
225 241 if (!empty($term_desc)) {
226 242 $this->has_description_cache = true;
227 243 return true;
228 244 }
@@ -248,24 +264,214 @@
248 264 if (!$post_id) {
249 265 return '';
250 266 }
251 267
252 - // 1. SEO sidebar (highest priority — user-edited)
253 - $desc = get_post_meta($post_id, '_metasync_seo_desc', true);
254 - if (!empty($desc)) {
255 - return $desc;
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 '';
256 291 }
257 292
258 - // 2. OTTO description
259 - $desc = get_post_meta($post_id, '_metasync_otto_description', true);
260 - if (!empty($desc)) {
261 - return $desc;
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 '';
262 298 }
263 299
264 - return '';
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);
265 320 }
266 321
267 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 + /**
268 474 * Resolve the MetaSync-managed canonical URL for a post, if any.
269 475 *
270 476 * Priority: OTTO persisted canonical (_metasync_canonical_url) → Canonical meta
271 477 * box value (meta_canonical). Restricted to singular views so term/archive
@@ -280,8 +486,15 @@
280 486 if (!$post_id || !is_singular()) {
281 487 return '';
282 488 }
283 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 +
284 497 // Validate both sources: legacy rows corrupted to the literal "Array"
285 498 // (or stored as arrays) must never be emitted as a canonical.
286 499 $canonical = Metasync_Canonical_Sanitizer::sanitize(
287 500 get_post_meta($post_id, '_metasync_canonical_url', true)
@@ -301,8 +514,9 @@
301 514 public function reset_cache() {
302 515 $this->has_description_cache = null;
303 516 $this->aioseo_has_description_cache = null;
304 517 $this->sync_cache = [];
518 + $this->robots_suppress_cache = [];
305 519 $this->live_suggestions_cache = null;
306 520 }
307 521
308 522 /**
@@ -355,9 +569,15 @@
355 569 'rankmath' => is_plugin_active('seo-by-rank-math/rank-math.php') || is_plugin_active('seo-by-rankmath/rank-math.php'),
356 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'),
357 571 ];
358 572
359 - $has_any_sync = $post_id > 0 && ($this->is_post_synced($post_id, 'yoast') || $this->is_post_synced($post_id, 'rankmath') || $this->is_post_synced($post_id, 'aioseo'));
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'));
360 580
361 581 if ($has_any_sync) {
362 582 // Synced: primary = first active + synced plugin
363 583 foreach ($priority as $slug) {
@@ -445,10 +665,20 @@
445 665 if ($this->aioseo_has_description_cache === null) {
446 666 $this->aioseo_has_description_cache = !empty($description);
447 667 }
448 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 +
449 680 // Primary plugin check — only the designated plugin outputs.
450 - $post_id = $this->get_current_object_id();
451 681 if ($post_id && $this->has_active_seo_plugin()) {
452 682 if ($this->is_primary_output_plugin($post_id, 'aioseo')) {
453 683 return $description;
454 684 }
@@ -461,9 +691,9 @@
461 691 // from its `wp_aioseo_terms` table, so return the MetaSync value
462 692 // directly so AIOSEO renders it.
463 693 $term = $this->get_current_term();
464 694 if ($term) {
465 - $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
695 + $term_desc = $this->get_metasync_term_description($term);
466 696 if (!empty($term_desc)) {
467 697 return $term_desc;
468 698 }
469 699 }
@@ -487,8 +717,33 @@
487 717 */
488 718 public function filter_aioseo_title($title) {
489 719 // Primary plugin check — only the designated plugin outputs.
490 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 +
491 746 if ($post_id && $this->has_active_seo_plugin()) {
492 747 if ($this->is_primary_output_plugin($post_id, 'aioseo')) {
493 748 return $title;
494 749 }
@@ -496,17 +751,8 @@
496 751 return '';
497 752 }
498 753 }
499 754
500 - // Term archives: return MetaSync term title directly.
501 - $term = $this->get_current_term();
502 - if ($term) {
503 - $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
504 - if (!empty($term_title)) {
505 - return $term_title;
506 - }
507 - }
508 -
509 755 if ($this->should_suppress_third_party_title()) {
510 756 return '';
511 757 }
512 758
@@ -534,26 +780,35 @@
534 780 if ($this->og_output_disabled($post_id)) {
535 781 return $meta;
536 782 }
537 783
538 - // Post synced to AIOSEO — let AIOSEO read from its own storage.
539 - if ($post_id && $this->is_primary_output_plugin($post_id, 'aioseo')) {
540 - return $meta;
541 - }
542 -
543 - // og:title — suppress when OTTO has og:title OR MetaSync has title
544 - if ($this->otto_has_tag('og:title') || ($post_id && $this->metasync_has_title($post_id))) {
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')) {
545 791 unset($meta['og:title']);
546 792 }
547 793
548 - // og:description — suppress when OTTO has og:description OR MetaSync has description
549 - if ($this->otto_has_tag('og:description') || $this->metasync_has_description()) {
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')) {
550 799 unset($meta['og:description']);
551 800 }
552 801
553 802 // og:url, og:type, og:locale, og:site_name — suppress when OTTO has og:title
554 - // (OTTO injects these structural OG tags alongside og:title in its block)
555 - if ($this->otto_has_tag('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')) {
556 811 unset($meta['og:url'], $meta['og:type'], $meta['og:locale'], $meta['og:site_name']);
557 812 }
558 813
559 814 return $meta;
@@ -580,23 +835,29 @@
580 835 if ($this->og_output_disabled($post_id)) {
581 836 return $meta;
582 837 }
583 838
584 - // Post synced to AIOSEO — let AIOSEO read from its own storage.
585 - if ($post_id && $this->is_primary_output_plugin($post_id, 'aioseo')) {
586 - return $meta;
587 - }
588 -
589 - if ($this->otto_has_tag('twitter:title') || ($post_id && $this->metasync_has_title($post_id))) {
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')) {
590 845 unset($meta['twitter:title']);
591 846 }
592 847
593 - if ($this->otto_has_tag('twitter:description') || $this->metasync_has_description()) {
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')) {
594 852 unset($meta['twitter:description']);
595 853 }
596 854
597 - // twitter:card — suppress when OTTO has any twitter tag
598 - if ($this->otto_has_tag('twitter:title') || $this->otto_has_tag('twitter:description')) {
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'))) {
599 860 unset($meta['twitter:card']);
600 861 }
601 862
602 863 return $meta;
@@ -602,8 +863,54 @@
602 863 return $meta;
603 864 }
604 865
605 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 + /**
606 913 * Filter AIOSEO robots meta output.
607 914 *
608 915 * When MetaSync has an intentional robots value (admin checkbox or REST API),
609 916 * suppress AIOSEO's robots tag to avoid duplicates. MetaSync's own output in
@@ -609,25 +916,18 @@
609 916 * suppress AIOSEO's robots tag to avoid duplicates. MetaSync's own output in
610 917 * hook_metasync_metatags() will output the MetaSync value instead.
611 918 *
612 919 * AIOSEO passes an array like ['noindex' => 'noindex', 'nofollow' => 'nofollow'].
613 - * Returning an empty array suppresses AIOSEO's robots tag entirely.
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.
614 924 *
615 925 * @param array $robots AIOSEO's computed robots attributes array.
616 926 * @return array
617 927 */
618 928 public function filter_aioseo_robots($robots) {
619 - $post_id = $this->get_current_object_id();
620 - if (!$post_id) {
621 - return $robots;
622 - }
623 -
624 - // Post synced to AIOSEO — let AIOSEO read from its own storage.
625 - if ($this->is_primary_output_plugin($post_id, 'aioseo')) {
626 - return $robots;
627 - }
628 -
629 - if ($this->metasync_has_robots($post_id)) {
929 + if ($this->should_suppress_third_party_robots('aioseo')) {
630 930 // MetaSync has robots — suppress AIOSEO's tag.
631 931 return [];
632 932 }
633 933
@@ -665,8 +965,27 @@
665 965 * @param int $post_id Post ID.
666 966 * @return bool
667 967 */
668 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 +
669 988 $meta_robots = get_post_meta($post_id, 'meta_robots', true);
670 989 if (!empty($meta_robots)) {
671 990 return true;
672 991 }
@@ -685,8 +1004,24 @@
685 1004 * @param int $post_id Post ID.
686 1005 * @return bool
687 1006 */
688 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 +
689 1024 $seo_title = get_post_meta($post_id, '_metasync_seo_title', true);
690 1025 if (!empty($seo_title)) {
691 1026 return true;
692 1027 }
@@ -695,18 +1030,8 @@
695 1030 if (!empty($otto_title)) {
696 1031 return true;
697 1032 }
698 1033
699 - // Term-level fallback: on taxonomy archives the "object" is a term,
700 - // so read `_metasync_metatitle` from term meta when we're rendering one.
701 - $term = $this->get_current_term();
702 - if ($term) {
703 - $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
704 - if (!empty($term_title)) {
705 - return true;
706 - }
707 - }
708 -
709 1034 return false;
710 1035 }
711 1036
712 1037 /**
@@ -809,8 +1134,15 @@
809 1134 if (!$post_id) {
810 1135 return false;
811 1136 }
812 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 +
813 1145 if ($this->has_active_seo_plugin() && !$this->otto_has_live_suggestions()) {
814 1146 return false;
815 1147 }
816 1148
@@ -843,8 +1175,19 @@
843 1175 * @param int $post_id
844 1176 * @return bool
845 1177 */
846 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 +
847 1190 return $post_id && get_post_meta($post_id, '_metasync_og_enabled', true) === '0';
848 1191 }
849 1192
850 1193 // ------------------------------------------------------------------
@@ -909,15 +1252,44 @@
909 1252 */
910 1253 public function filter_yoast_title($title) {
911 1254 $post_id = $this->get_current_object_id();
912 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 +
913 1281 // When Yoast is the primary output plugin, let it through.
914 1282 // For synced posts, Yoast reads from its own storage (already has the value).
915 1283 // For unsynced posts as primary, still check for MetaSync sidebar override.
916 1284 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
917 - // Even in passthrough, a sidebar title override takes precedence
918 - $sidebar_title = get_post_meta($post_id, '_metasync_seo_title', true);
919 - if (!empty($sidebar_title)) {
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 !== '') {
920 1292 return $sidebar_title;
921 1293 }
922 1294 return $title;
923 1295 }
@@ -928,21 +1300,12 @@
928 1300 return '';
929 1301 }
930 1302 }
931 1303
932 - // Term-level archives
933 - $term = $this->get_current_term();
934 - if ($term) {
935 - $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
936 - if (!empty($term_title)) {
937 - return $term_title;
938 - }
939 - }
940 -
941 1304 // MetaSync sidebar has an explicit title — return it so Yoast renders it.
942 1305 if ($post_id) {
943 - $sidebar_title = get_post_meta($post_id, '_metasync_seo_title', true);
944 - if (!empty($sidebar_title)) {
1306 + $sidebar_title = $this->metasync_title_value($post_id);
1307 + if ($sidebar_title !== '') {
945 1308 return $sidebar_title;
946 1309 }
947 1310 }
948 1311
@@ -956,17 +1319,103 @@
956 1319
957 1320 /**
958 1321 * Filter Yoast SEO description output.
959 1322 *
960 - * On term archives: the term-level sync writes MetaSync's description
961 - * into Yoast's native storage, so Yoast already computes the correct
962 - * value — let it through.
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.
963 1326 *
964 1327 * On singular pages: suppress when OTTO or MetaSync sidebar provides
965 1328 * the description (MetaSync outputs its own tag).
966 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 +
967 1386 public function filter_yoast_description($description) {
968 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 +
969 1418 if ($post_id && $this->has_active_seo_plugin()) {
970 1419 if ($this->is_primary_output_plugin($post_id, 'yoast')) {
971 1420 return $description;
972 1421 }
@@ -974,17 +1423,8 @@
974 1423 return '';
975 1424 }
976 1425 }
977 1426
978 - // Term archives: MetaSync syncs to Yoast storage — let Yoast render it.
979 - $term = $this->get_current_term();
980 - if ($term) {
981 - $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
982 - if (!empty($term_desc)) {
983 - return $description;
984 - }
985 - }
986 -
987 1427 if ($this->otto_has_tag('description') || $this->metasync_has_description()) {
988 1428 return '';
989 1429 }
990 1430 return $description;
@@ -998,14 +1438,24 @@
998 1438 $post_id = $this->get_current_object_id();
999 1439 if ($this->og_output_disabled($post_id)) {
1000 1440 return $value;
1001 1441 }
1002 - if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1003 - return $value;
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;
1004 1450 }
1005 - if ($this->otto_has_tag('og:title') || ($post_id && $this->metasync_has_title($post_id))) {
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')) {
1006 1455 return '';
1007 1456 }
1457 +
1008 1458 return $value;
1009 1459 }
1010 1460
1011 1461 /**
@@ -1016,14 +1466,24 @@
1016 1466 $post_id = $this->get_current_object_id();
1017 1467 if ($this->og_output_disabled($post_id)) {
1018 1468 return $value;
1019 1469 }
1020 - if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1021 - return $value;
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;
1022 1478 }
1023 - if ($this->otto_has_tag('og:description') || $this->metasync_has_description()) {
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')) {
1024 1483 return '';
1025 1484 }
1485 +
1026 1486 return $value;
1027 1487 }
1028 1488
1029 1489 /**
@@ -1052,14 +1512,24 @@
1052 1512 $post_id = $this->get_current_object_id();
1053 1513 if ($this->og_output_disabled($post_id)) {
1054 1514 return $value;
1055 1515 }
1056 - if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1057 - return $value;
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;
1058 1524 }
1059 - if ($this->otto_has_tag('twitter:title') || ($post_id && $this->metasync_has_title($post_id))) {
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')) {
1060 1529 return '';
1061 1530 }
1531 +
1062 1532 return $value;
1063 1533 }
1064 1534
1065 1535 /**
@@ -1070,14 +1540,24 @@
1070 1540 $post_id = $this->get_current_object_id();
1071 1541 if ($this->og_output_disabled($post_id)) {
1072 1542 return $value;
1073 1543 }
1074 - if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1075 - return $value;
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;
1076 1552 }
1077 - if ($this->otto_has_tag('twitter:description') || $this->metasync_has_description()) {
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')) {
1078 1557 return '';
1079 1558 }
1559 +
1080 1560 return $value;
1081 1561 }
1082 1562
1083 1563 /**
@@ -1103,14 +1583,22 @@
1103 1583 * Suppress when OTTO has structured data for the current page.
1104 1584 * Also strip BreadcrumbList entries when MetaSync breadcrumbs are enabled,
1105 1585 * so MetaSync's own BreadcrumbList is the only one on the page.
1106 1586 *
1107 - * @param array|false $data Yoast's JSON-LD data.
1108 - * @return array|false
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
1109 1597 */
1110 1598 public function filter_yoast_schema($data) {
1111 1599 if ($this->otto_has_schema_for_current_page()) {
1112 - return false;
1600 + return [];
1113 1601 }
1114 1602
1115 1603 if ($this->metasync_breadcrumb_enabled() && is_array($data)) {
1116 1604 $data = $this->strip_breadcrumb_from_graph($data);
@@ -1135,11 +1623,174 @@
1135 1623 add_filter('rank_math/json_ld', [$this, 'filter_rankmath_schema'], 999);
1136 1624
1137 1625 // Canonical — override RankMath's canonical with the MetaSync/OTTO value when set
1138 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);
1139 1642 }
1140 1643
1141 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'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end flag, only forces noindex
1786 + return true;
1787 + }
1788 +
1789 + return false;
1790 + }
1791 +
1792 + /**
1142 1793 * Filter RankMath's canonical URL.
1143 1794 *
1144 1795 * Mirrors filter_yoast_canonical(): return the MetaSync-managed canonical
1145 1796 * (OTTO or the Canonical meta box) when set, else pass RankMath's through.
@@ -1165,8 +1816,33 @@
1165 1816 * @return string
1166 1817 */
1167 1818 public function filter_rankmath_title($title) {
1168 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 +
1169 1845 // If another plugin is the primary output owner, suppress Rank Math.
1170 1846 if ($post_id && $this->has_active_seo_plugin()) {
1171 1847 if ($this->is_primary_output_plugin($post_id, 'rankmath')) {
1172 1848 return $title;
@@ -1176,25 +1852,14 @@
1176 1852 return '';
1177 1853 }
1178 1854 }
1179 1855
1180 - // Term-level archives (category/tag/custom taxonomy): when MetaSync has
1181 - // an explicit `_metasync_metatitle`, return it so Rank Math renders the
1182 - // MetaSync-managed archive title inside <title>.
1183 - $term = $this->get_current_term();
1184 - if ($term) {
1185 - $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
1186 - if (!empty($term_title)) {
1187 - return $term_title;
1188 - }
1189 - }
1190 -
1191 1856 $post_id = $this->get_current_object_id();
1192 1857
1193 1858 // MetaSync sidebar has an explicit title — return it so Rank Math renders it.
1194 1859 if ($post_id) {
1195 - $sidebar_title = get_post_meta($post_id, '_metasync_seo_title', true);
1196 - if (!empty($sidebar_title)) {
1860 + $sidebar_title = $this->metasync_title_value($post_id);
1861 + if ($sidebar_title !== '') {
1197 1862 return $sidebar_title;
1198 1863 }
1199 1864 }
1200 1865
@@ -1207,11 +1872,11 @@
1207 1872
1208 1873 /**
1209 1874 * Filter RankMath description output.
1210 1875 *
1211 - * On term archives: the term-level sync writes MetaSync's description
1212 - * into Rank Math's native term meta, so Rank Math already computes the
1213 - * correct value — let it through.
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.
1214 1879 *
1215 1880 * On singular pages: suppress when OTTO or MetaSync sidebar provides
1216 1881 * the description.
1217 1882 *
@@ -1219,8 +1884,38 @@
1219 1884 * @return string
1220 1885 */
1221 1886 public function filter_rankmath_description($description) {
1222 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 +
1223 1918 if ($post_id && $this->has_active_seo_plugin()) {
1224 1919 if ($this->is_primary_output_plugin($post_id, 'rankmath')) {
1225 1920 return $description;
1226 1921 }
@@ -1228,17 +1923,8 @@
1228 1923 return '';
1229 1924 }
1230 1925 }
1231 1926
1232 - // Term archives: MetaSync syncs to Rank Math storage — let it render.
1233 - $term = $this->get_current_term();
1234 - if ($term) {
1235 - $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
1236 - if (!empty($term_desc)) {
1237 - return $description;
1238 - }
1239 - }
1240 -
1241 1927 if ($this->otto_has_tag('description') || $this->metasync_has_description()) {
1242 1928 return '';
1243 1929 }
1244 1930
@@ -1371,10 +2057,32 @@
1371 2057 * @param array $graph The @graph array from a third-party SEO plugin.
1372 2058 * @return array
1373 2059 */
1374 2060 private function strip_breadcrumb_from_graph($graph) {
1375 - // Collect @ids of BreadcrumbList nodes being removed.
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.
1376 2083 $removed_ids = [];
2084 + $removed_any = false;
1377 2085
1378 2086 foreach ($graph as $key => $entry) {
1379 2087 if (is_array($entry) && isset($entry['@type']) && $entry['@type'] === 'BreadcrumbList') {
1380 2088 if (!empty($entry['@id'])) {
@@ -1379,34 +2087,43 @@
1379 2087 if (is_array($entry) && isset($entry['@type']) && $entry['@type'] === 'BreadcrumbList') {
1380 2088 if (!empty($entry['@id'])) {
1381 2089 $removed_ids[] = $entry['@id'];
1382 2090 }
2091 + $removed_any = true;
1383 2092 unset($graph[$key]);
1384 2093 }
1385 2094 }
1386 2095
1387 - // Remove dangling breadcrumb references from WebPage-type nodes.
1388 - foreach ($graph as $key => &$entry) {
1389 - if (!is_array($entry) || !isset($entry['@type'])) {
1390 - continue;
1391 - }
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 + }
1392 2112
1393 - $type = $entry['@type'];
1394 - $is_page_type = $type === 'WebPage'
1395 - || (is_array($type) && in_array('WebPage', $type, true));
1396 -
1397 - if ($is_page_type && isset($entry['breadcrumb'])) {
1398 - // Remove if the reference points to a stripped node, or if
1399 - // no BreadcrumbList remains in this graph at all.
1400 - $ref_id = is_array($entry['breadcrumb']) ? ($entry['breadcrumb']['@id'] ?? '') : '';
1401 - if (empty($removed_ids) || empty($ref_id) || in_array($ref_id, $removed_ids, true)) {
1402 - unset($entry['breadcrumb']);
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 + }
1403 2120 }
1404 2121 }
2122 + unset($entry);
1405 2123 }
1406 - unset($entry);
1407 2124
1408 - return array_values($graph);
2125 + return $has_string_keys ? $graph : array_values($graph);
1409 2126 }
1410 2127
1411 2128 /**
1412 2129 * Determine whether MetaSync's own BreadcrumbList output is enabled.
@@ -1442,8 +2159,14 @@
1442 2159 *
1443 2160 * @return bool
1444 2161 */
1445 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 +
1446 2169 if (!$this->is_otto_active()) {
1447 2170 return false;
1448 2171 }
1449 2172
@@ -1471,8 +2194,26 @@
1471 2194 * page, so this returns 0.
1472 2195 *
1473 2196 * @return int 0 when unknown.
1474 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 +
1475 2216 private function get_current_object_id() {
1476 2217 // Singular pages (posts, pages, CPTs, attachments)
1477 2218 if (is_singular()) {
1478 2219 return (int) get_the_ID();
@@ -1490,8 +2231,346 @@
1490 2231 return (int) $id;
1491 2232 }
1492 2233
1493 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');
1494 2573 }
1495 2574
1496 2575 /**
1497 2576 * Return the WP_Term being rendered on taxonomy archive pages.