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 +1071 -228 2.6.22 → 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,21 +264,14 @@
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;
256 - }
257 -
258 - // 2. OTTO description
259 - $desc = get_post_meta($post_id, '_metasync_otto_description', true);
260 - if (!empty($desc)) {
261 - return $desc;
262 - }
263 -
264 - return '';
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);
265 274 }
266 275
267 276 /**
268 277 * Return MetaSync's intentional SEO title for the current request.
@@ -280,8 +289,15 @@
280 289 if (!$post_id) {
281 290 return '';
282 291 }
283 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 +
284 300 // 1. Social Media & Open Graph meta box (most specific — a social-only title)
285 301 $og_title = $this->get_customized_og_title($post_id);
286 302 if ($og_title !== '') {
287 303 return $og_title;
@@ -286,32 +302,35 @@
286 302 if ($og_title !== '') {
287 303 return $og_title;
288 304 }
289 305
290 - // 2. SEO sidebar (user-edited)
291 - $title = get_post_meta($post_id, '_metasync_seo_title', true);
292 - if (!empty($title)) {
293 - return $title;
294 - }
295 -
296 - // 3. OTTO title
297 - $title = get_post_meta($post_id, '_metasync_otto_title', true);
298 - if (!empty($title)) {
299 - return $title;
300 - }
301 -
302 - return '';
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);
303 320 }
304 321
305 322 /**
306 323 * The per-post OG meta box title, but only when the user genuinely set it.
307 324 *
308 - * The meta box pre-fills its Title from the post title and PERSISTS that
309 - * default on save, so a non-empty `_metasync_og_title` alone does not prove
310 - * intent — treating it as one would let an auto-filled post title override a
311 - * deliberately-set SEO title on every ordinary edit. A value counts as the
312 - * user's only when it differs from that default, the same comparison
313 - * Otto_html_class::apply_metabox_og_precedence() makes.
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.
314 333 *
315 334 * @param int $post_id
316 335 * @return string The customized OG title, or '' when unset or auto-filled.
317 336 */
@@ -323,8 +342,13 @@
323 342 if (!is_singular()) {
324 343 return '';
325 344 }
326 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 +
327 351 $og_title = (string) get_post_meta($post_id, '_metasync_og_title', true);
328 352 if ($og_title === '') {
329 353 return '';
330 354 }
@@ -360,8 +384,18 @@
360 384 if (!$this->suppresses_third_party_og_title()) {
361 385 return false;
362 386 }
363 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 +
364 398 $post_id = $this->get_current_object_id();
365 399 if (!$post_id) {
366 400 return false;
367 401 }
@@ -452,8 +486,15 @@
452 486 if (!$post_id || !is_singular()) {
453 487 return '';
454 488 }
455 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 +
456 497 // Validate both sources: legacy rows corrupted to the literal "Array"
457 498 // (or stored as arrays) must never be emitted as a canonical.
458 499 $canonical = Metasync_Canonical_Sanitizer::sanitize(
459 500 get_post_meta($post_id, '_metasync_canonical_url', true)
@@ -473,8 +514,9 @@
473 514 public function reset_cache() {
474 515 $this->has_description_cache = null;
475 516 $this->aioseo_has_description_cache = null;
476 517 $this->sync_cache = [];
518 + $this->robots_suppress_cache = [];
477 519 $this->live_suggestions_cache = null;
478 520 }
479 521
480 522 /**
@@ -527,9 +569,15 @@
527 569 'rankmath' => is_plugin_active('seo-by-rank-math/rank-math.php') || is_plugin_active('seo-by-rankmath/rank-math.php'),
528 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'),
529 571 ];
530 572
531 - $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'));
532 580
533 581 if ($has_any_sync) {
534 582 // Synced: primary = first active + synced plugin
535 583 foreach ($priority as $slug) {
@@ -617,10 +665,20 @@
617 665 if ($this->aioseo_has_description_cache === null) {
618 666 $this->aioseo_has_description_cache = !empty($description);
619 667 }
620 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 +
621 680 // Primary plugin check — only the designated plugin outputs.
622 - $post_id = $this->get_current_object_id();
623 681 if ($post_id && $this->has_active_seo_plugin()) {
624 682 if ($this->is_primary_output_plugin($post_id, 'aioseo')) {
625 683 return $description;
626 684 }
@@ -633,9 +691,9 @@
633 691 // from its `wp_aioseo_terms` table, so return the MetaSync value
634 692 // directly so AIOSEO renders it.
635 693 $term = $this->get_current_term();
636 694 if ($term) {
637 - $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
695 + $term_desc = $this->get_metasync_term_description($term);
638 696 if (!empty($term_desc)) {
639 697 return $term_desc;
640 698 }
641 699 }
@@ -659,8 +717,33 @@
659 717 */
660 718 public function filter_aioseo_title($title) {
661 719 // Primary plugin check — only the designated plugin outputs.
662 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 +
663 746 if ($post_id && $this->has_active_seo_plugin()) {
664 747 if ($this->is_primary_output_plugin($post_id, 'aioseo')) {
665 748 return $title;
666 749 }
@@ -668,17 +751,8 @@
668 751 return '';
669 752 }
670 753 }
671 754
672 - // Term archives: return MetaSync term title directly.
673 - $term = $this->get_current_term();
674 - if ($term) {
675 - $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
676 - if (!empty($term_title)) {
677 - return $term_title;
678 - }
679 - }
680 -
681 755 if ($this->should_suppress_third_party_title()) {
682 756 return '';
683 757 }
684 758
@@ -706,26 +780,35 @@
706 780 if ($this->og_output_disabled($post_id)) {
707 781 return $meta;
708 782 }
709 783
710 - // Post synced to AIOSEO — let AIOSEO read from its own storage.
711 - if ($post_id && $this->is_primary_output_plugin($post_id, 'aioseo')) {
712 - return $meta;
713 - }
714 -
715 - // og:title — suppress when OTTO has og:title OR MetaSync has title
716 - 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')) {
717 791 unset($meta['og:title']);
718 792 }
719 793
720 - // og:description — suppress when OTTO has og:description OR MetaSync has description
721 - 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')) {
722 799 unset($meta['og:description']);
723 800 }
724 801
725 802 // og:url, og:type, og:locale, og:site_name — suppress when OTTO has og:title
726 - // (OTTO injects these structural OG tags alongside og:title in its block)
727 - 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')) {
728 811 unset($meta['og:url'], $meta['og:type'], $meta['og:locale'], $meta['og:site_name']);
729 812 }
730 813
731 814 return $meta;
@@ -752,23 +835,29 @@
752 835 if ($this->og_output_disabled($post_id)) {
753 836 return $meta;
754 837 }
755 838
756 - // Post synced to AIOSEO — let AIOSEO read from its own storage.
757 - if ($post_id && $this->is_primary_output_plugin($post_id, 'aioseo')) {
758 - return $meta;
759 - }
760 -
761 - 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')) {
762 845 unset($meta['twitter:title']);
763 846 }
764 847
765 - 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')) {
766 852 unset($meta['twitter:description']);
767 853 }
768 854
769 - // twitter:card — suppress when OTTO has any twitter tag
770 - 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'))) {
771 860 unset($meta['twitter:card']);
772 861 }
773 862
774 863 return $meta;
@@ -774,8 +863,54 @@
774 863 return $meta;
775 864 }
776 865
777 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 + /**
778 913 * Filter AIOSEO robots meta output.
779 914 *
780 915 * When MetaSync has an intentional robots value (admin checkbox or REST API),
781 916 * suppress AIOSEO's robots tag to avoid duplicates. MetaSync's own output in
@@ -781,25 +916,18 @@
781 916 * suppress AIOSEO's robots tag to avoid duplicates. MetaSync's own output in
782 917 * hook_metasync_metatags() will output the MetaSync value instead.
783 918 *
784 919 * AIOSEO passes an array like ['noindex' => 'noindex', 'nofollow' => 'nofollow'].
785 - * 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.
786 924 *
787 925 * @param array $robots AIOSEO's computed robots attributes array.
788 926 * @return array
789 927 */
790 928 public function filter_aioseo_robots($robots) {
791 - $post_id = $this->get_current_object_id();
792 - if (!$post_id) {
793 - return $robots;
794 - }
795 -
796 - // Post synced to AIOSEO — let AIOSEO read from its own storage.
797 - if ($this->is_primary_output_plugin($post_id, 'aioseo')) {
798 - return $robots;
799 - }
800 -
801 - if ($this->metasync_has_robots($post_id)) {
929 + if ($this->should_suppress_third_party_robots('aioseo')) {
802 930 // MetaSync has robots — suppress AIOSEO's tag.
803 931 return [];
804 932 }
805 933
@@ -837,8 +965,27 @@
837 965 * @param int $post_id Post ID.
838 966 * @return bool
839 967 */
840 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 +
841 988 $meta_robots = get_post_meta($post_id, 'meta_robots', true);
842 989 if (!empty($meta_robots)) {
843 990 return true;
844 991 }
@@ -857,8 +1004,16 @@
857 1004 * @param int $post_id Post ID.
858 1005 * @return bool
859 1006 */
860 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 +
861 1016 // Social Media & Open Graph meta box, when genuinely customized. Counted
862 1017 // here so a post whose ONLY title is the OG one still suppresses the
863 1018 // third-party tag — otherwise the plugin keeps rendering its own and the
864 1019 // replacement below never gets the chance to emit.
@@ -875,18 +1030,8 @@
875 1030 if (!empty($otto_title)) {
876 1031 return true;
877 1032 }
878 1033
879 - // Term-level fallback: on taxonomy archives the "object" is a term,
880 - // so read `_metasync_metatitle` from term meta when we're rendering one.
881 - $term = $this->get_current_term();
882 - if ($term) {
883 - $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
884 - if (!empty($term_title)) {
885 - return true;
886 - }
887 - }
888 -
889 1034 return false;
890 1035 }
891 1036
892 1037 /**
@@ -989,8 +1134,15 @@
989 1134 if (!$post_id) {
990 1135 return false;
991 1136 }
992 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 +
993 1145 if ($this->has_active_seo_plugin() && !$this->otto_has_live_suggestions()) {
994 1146 return false;
995 1147 }
996 1148
@@ -1023,8 +1175,19 @@
1023 1175 * @param int $post_id
1024 1176 * @return bool
1025 1177 */
1026 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 +
1027 1190 return $post_id && get_post_meta($post_id, '_metasync_og_enabled', true) === '0';
1028 1191 }
1029 1192
1030 1193 // ------------------------------------------------------------------
@@ -1089,15 +1252,44 @@
1089 1252 */
1090 1253 public function filter_yoast_title($title) {
1091 1254 $post_id = $this->get_current_object_id();
1092 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 +
1093 1281 // When Yoast is the primary output plugin, let it through.
1094 1282 // For synced posts, Yoast reads from its own storage (already has the value).
1095 1283 // For unsynced posts as primary, still check for MetaSync sidebar override.
1096 1284 if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1097 - // Even in passthrough, a sidebar title override takes precedence
1098 - $sidebar_title = get_post_meta($post_id, '_metasync_seo_title', true);
1099 - 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 !== '') {
1100 1292 return $sidebar_title;
1101 1293 }
1102 1294 return $title;
1103 1295 }
@@ -1108,21 +1300,12 @@
1108 1300 return '';
1109 1301 }
1110 1302 }
1111 1303
1112 - // Term-level archives
1113 - $term = $this->get_current_term();
1114 - if ($term) {
1115 - $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
1116 - if (!empty($term_title)) {
1117 - return $term_title;
1118 - }
1119 - }
1120 -
1121 1304 // MetaSync sidebar has an explicit title — return it so Yoast renders it.
1122 1305 if ($post_id) {
1123 - $sidebar_title = get_post_meta($post_id, '_metasync_seo_title', true);
1124 - if (!empty($sidebar_title)) {
1306 + $sidebar_title = $this->metasync_title_value($post_id);
1307 + if ($sidebar_title !== '') {
1125 1308 return $sidebar_title;
1126 1309 }
1127 1310 }
1128 1311
@@ -1136,17 +1319,103 @@
1136 1319
1137 1320 /**
1138 1321 * Filter Yoast SEO description output.
1139 1322 *
1140 - * On term archives: the term-level sync writes MetaSync's description
1141 - * into Yoast's native storage, so Yoast already computes the correct
1142 - * 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.
1143 1326 *
1144 1327 * On singular pages: suppress when OTTO or MetaSync sidebar provides
1145 1328 * the description (MetaSync outputs its own tag).
1146 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 +
1147 1386 public function filter_yoast_description($description) {
1148 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 +
1149 1418 if ($post_id && $this->has_active_seo_plugin()) {
1150 1419 if ($this->is_primary_output_plugin($post_id, 'yoast')) {
1151 1420 return $description;
1152 1421 }
@@ -1154,17 +1423,8 @@
1154 1423 return '';
1155 1424 }
1156 1425 }
1157 1426
1158 - // Term archives: MetaSync syncs to Yoast storage — let Yoast render it.
1159 - $term = $this->get_current_term();
1160 - if ($term) {
1161 - $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
1162 - if (!empty($term_desc)) {
1163 - return $description;
1164 - }
1165 - }
1166 -
1167 1427 if ($this->otto_has_tag('description') || $this->metasync_has_description()) {
1168 1428 return '';
1169 1429 }
1170 1430 return $description;
@@ -1178,14 +1438,24 @@
1178 1438 $post_id = $this->get_current_object_id();
1179 1439 if ($this->og_output_disabled($post_id)) {
1180 1440 return $value;
1181 1441 }
1182 - if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1183 - 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;
1184 1450 }
1185 - 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')) {
1186 1455 return '';
1187 1456 }
1457 +
1188 1458 return $value;
1189 1459 }
1190 1460
1191 1461 /**
@@ -1196,14 +1466,24 @@
1196 1466 $post_id = $this->get_current_object_id();
1197 1467 if ($this->og_output_disabled($post_id)) {
1198 1468 return $value;
1199 1469 }
1200 - if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1201 - 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;
1202 1478 }
1203 - 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')) {
1204 1483 return '';
1205 1484 }
1485 +
1206 1486 return $value;
1207 1487 }
1208 1488
1209 1489 /**
@@ -1232,14 +1512,24 @@
1232 1512 $post_id = $this->get_current_object_id();
1233 1513 if ($this->og_output_disabled($post_id)) {
1234 1514 return $value;
1235 1515 }
1236 - if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1237 - 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;
1238 1524 }
1239 - 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')) {
1240 1529 return '';
1241 1530 }
1531 +
1242 1532 return $value;
1243 1533 }
1244 1534
1245 1535 /**
@@ -1250,14 +1540,24 @@
1250 1540 $post_id = $this->get_current_object_id();
1251 1541 if ($this->og_output_disabled($post_id)) {
1252 1542 return $value;
1253 1543 }
1254 - if ($post_id && $this->is_primary_output_plugin($post_id, 'yoast')) {
1255 - 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;
1256 1552 }
1257 - 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')) {
1258 1557 return '';
1259 1558 }
1559 +
1260 1560 return $value;
1261 1561 }
1262 1562
1263 1563 /**
@@ -1283,14 +1583,22 @@
1283 1583 * Suppress when OTTO has structured data for the current page.
1284 1584 * Also strip BreadcrumbList entries when MetaSync breadcrumbs are enabled,
1285 1585 * so MetaSync's own BreadcrumbList is the only one on the page.
1286 1586 *
1287 - * @param array|false $data Yoast's JSON-LD data.
1288 - * @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
1289 1597 */
1290 1598 public function filter_yoast_schema($data) {
1291 1599 if ($this->otto_has_schema_for_current_page()) {
1292 - return false;
1600 + return [];
1293 1601 }
1294 1602
1295 1603 if ($this->metasync_breadcrumb_enabled() && is_array($data)) {
1296 1604 $data = $this->strip_breadcrumb_from_graph($data);
@@ -1310,22 +1618,179 @@
1310 1618 private function register_rankmath_filters() {
1311 1619 add_filter('rank_math/frontend/title', [$this, 'filter_rankmath_title'], 999);
1312 1620 add_filter('rank_math/frontend/description', [$this, 'filter_rankmath_description'], 999);
1313 1621
1314 - // OG/Twitter description — per-tag suppression, mirroring the Yoast filters.
1315 - // Without these Rank Math renders its own og:description alongside the one
1316 - // MetaSync emits, leaving two conflicting tags on the page.
1317 - add_filter('rank_math/opengraph/facebook/og_description', [$this, 'filter_rankmath_og_description'], 999);
1318 - add_filter('rank_math/opengraph/twitter/twitter_description', [$this, 'filter_rankmath_twitter_description'], 999);
1319 -
1320 1622 // Suppress RankMath schema/JSON-LD when OTTO has structured data
1321 1623 add_filter('rank_math/json_ld', [$this, 'filter_rankmath_schema'], 999);
1322 1624
1323 1625 // Canonical — override RankMath's canonical with the MetaSync/OTTO value when set
1324 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);
1325 1642 }
1326 1643
1327 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 + /**
1328 1793 * Filter RankMath's canonical URL.
1329 1794 *
1330 1795 * Mirrors filter_yoast_canonical(): return the MetaSync-managed canonical
1331 1796 * (OTTO or the Canonical meta box) when set, else pass RankMath's through.
@@ -1351,8 +1816,33 @@
1351 1816 * @return string
1352 1817 */
1353 1818 public function filter_rankmath_title($title) {
1354 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 +
1355 1845 // If another plugin is the primary output owner, suppress Rank Math.
1356 1846 if ($post_id && $this->has_active_seo_plugin()) {
1357 1847 if ($this->is_primary_output_plugin($post_id, 'rankmath')) {
1358 1848 return $title;
@@ -1362,25 +1852,14 @@
1362 1852 return '';
1363 1853 }
1364 1854 }
1365 1855
1366 - // Term-level archives (category/tag/custom taxonomy): when MetaSync has
1367 - // an explicit `_metasync_metatitle`, return it so Rank Math renders the
1368 - // MetaSync-managed archive title inside <title>.
1369 - $term = $this->get_current_term();
1370 - if ($term) {
1371 - $term_title = get_term_meta($term->term_id, '_metasync_metatitle', true);
1372 - if (!empty($term_title)) {
1373 - return $term_title;
1374 - }
1375 - }
1376 -
1377 1856 $post_id = $this->get_current_object_id();
1378 1857
1379 1858 // MetaSync sidebar has an explicit title — return it so Rank Math renders it.
1380 1859 if ($post_id) {
1381 - $sidebar_title = get_post_meta($post_id, '_metasync_seo_title', true);
1382 - if (!empty($sidebar_title)) {
1860 + $sidebar_title = $this->metasync_title_value($post_id);
1861 + if ($sidebar_title !== '') {
1383 1862 return $sidebar_title;
1384 1863 }
1385 1864 }
1386 1865
@@ -1393,11 +1872,11 @@
1393 1872
1394 1873 /**
1395 1874 * Filter RankMath description output.
1396 1875 *
1397 - * On term archives: the term-level sync writes MetaSync's description
1398 - * into Rank Math's native term meta, so Rank Math already computes the
1399 - * 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.
1400 1879 *
1401 1880 * On singular pages: suppress when OTTO or MetaSync sidebar provides
1402 1881 * the description.
1403 1882 *
@@ -1405,8 +1884,38 @@
1405 1884 * @return string
1406 1885 */
1407 1886 public function filter_rankmath_description($description) {
1408 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 +
1409 1918 if ($post_id && $this->has_active_seo_plugin()) {
1410 1919 if ($this->is_primary_output_plugin($post_id, 'rankmath')) {
1411 1920 return $description;
1412 1921 }
@@ -1414,17 +1923,8 @@
1414 1923 return '';
1415 1924 }
1416 1925 }
1417 1926
1418 - // Term archives: MetaSync syncs to Rank Math storage — let it render.
1419 - $term = $this->get_current_term();
1420 - if ($term) {
1421 - $term_desc = get_term_meta($term->term_id, '_metasync_metadesc', true);
1422 - if (!empty($term_desc)) {
1423 - return $description;
1424 - }
1425 - }
1426 -
1427 1927 if ($this->otto_has_tag('description') || $this->metasync_has_description()) {
1428 1928 return '';
1429 1929 }
1430 1930
@@ -1431,58 +1931,8 @@
1431 1931 return $description;
1432 1932 }
1433 1933
1434 1934 /**
1435 - * Filter Rank Math's og:description / twitter:description output.
1436 - *
1437 - * Mirrors filter_yoast_og_description(): suppress when OTTO has the tag or
1438 - * MetaSync has an intentional description, so exactly one description tag
1439 - * reaches the page. Returning '' removes Rank Math's.
1440 - *
1441 - * @param string $value Rank Math's computed description.
1442 - * @return string
1443 - */
1444 - public function filter_rankmath_og_description($value) {
1445 - return $this->filter_rankmath_social_description($value, 'og:description');
1446 - }
1447 -
1448 - /**
1449 - * Filter Rank Math's twitter:description output.
1450 - *
1451 - * Kept separate from the og:description filter so each consults its OWN
1452 - * OTTO key — twitter:description resolves to _metasync_otto_twitter_description,
1453 - * not the og one. Sharing a callback would suppress Rank Math's tag on the
1454 - * strength of the wrong key, either leaving a duplicate or a blank.
1455 - *
1456 - * @param string $value Rank Math's computed description.
1457 - * @return string
1458 - */
1459 - public function filter_rankmath_twitter_description($value) {
1460 - return $this->filter_rankmath_social_description($value, 'twitter:description');
1461 - }
1462 -
1463 - /**
1464 - * Shared body for the two Rank Math social-description filters.
1465 - *
1466 - * @param string $value Rank Math's computed description.
1467 - * @param string $otto_tag OTTO tag identifier to test for this specific tag.
1468 - * @return string
1469 - */
1470 - private function filter_rankmath_social_description($value, $otto_tag) {
1471 - $post_id = $this->get_current_object_id();
1472 - if ($post_id && $this->og_output_disabled($post_id)) {
1473 - return $value;
1474 - }
1475 - if ($post_id && $this->is_primary_output_plugin($post_id, 'rankmath')) {
1476 - return $value;
1477 - }
1478 - if ($this->otto_has_tag($otto_tag) || $this->metasync_has_description()) {
1479 - return '';
1480 - }
1481 - return $value;
1482 - }
1483 -
1484 - /**
1485 1935 * Filter RankMath schema/JSON-LD output.
1486 1936 * Suppress when OTTO has structured data for the current page.
1487 1937 * Also strip BreadcrumbList entries when MetaSync breadcrumbs are enabled,
1488 1938 * so MetaSync's own BreadcrumbList is the only one on the page.
@@ -1607,10 +2057,32 @@
1607 2057 * @param array $graph The @graph array from a third-party SEO plugin.
1608 2058 * @return array
1609 2059 */
1610 2060 private function strip_breadcrumb_from_graph($graph) {
1611 - // 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.
1612 2083 $removed_ids = [];
2084 + $removed_any = false;
1613 2085
1614 2086 foreach ($graph as $key => $entry) {
1615 2087 if (is_array($entry) && isset($entry['@type']) && $entry['@type'] === 'BreadcrumbList') {
1616 2088 if (!empty($entry['@id'])) {
@@ -1615,34 +2087,43 @@
1615 2087 if (is_array($entry) && isset($entry['@type']) && $entry['@type'] === 'BreadcrumbList') {
1616 2088 if (!empty($entry['@id'])) {
1617 2089 $removed_ids[] = $entry['@id'];
1618 2090 }
2091 + $removed_any = true;
1619 2092 unset($graph[$key]);
1620 2093 }
1621 2094 }
1622 2095
1623 - // Remove dangling breadcrumb references from WebPage-type nodes.
1624 - foreach ($graph as $key => &$entry) {
1625 - if (!is_array($entry) || !isset($entry['@type'])) {
1626 - continue;
1627 - }
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 + }
1628 2112
1629 - $type = $entry['@type'];
1630 - $is_page_type = $type === 'WebPage'
1631 - || (is_array($type) && in_array('WebPage', $type, true));
1632 -
1633 - if ($is_page_type && isset($entry['breadcrumb'])) {
1634 - // Remove if the reference points to a stripped node, or if
1635 - // no BreadcrumbList remains in this graph at all.
1636 - $ref_id = is_array($entry['breadcrumb']) ? ($entry['breadcrumb']['@id'] ?? '') : '';
1637 - if (empty($removed_ids) || empty($ref_id) || in_array($ref_id, $removed_ids, true)) {
1638 - 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 + }
1639 2120 }
1640 2121 }
2122 + unset($entry);
1641 2123 }
1642 - unset($entry);
1643 2124
1644 - return array_values($graph);
2125 + return $has_string_keys ? $graph : array_values($graph);
1645 2126 }
1646 2127
1647 2128 /**
1648 2129 * Determine whether MetaSync's own BreadcrumbList output is enabled.
@@ -1678,8 +2159,14 @@
1678 2159 *
1679 2160 * @return bool
1680 2161 */
1681 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 +
1682 2169 if (!$this->is_otto_active()) {
1683 2170 return false;
1684 2171 }
1685 2172
@@ -1707,8 +2194,26 @@
1707 2194 * page, so this returns 0.
1708 2195 *
1709 2196 * @return int 0 when unknown.
1710 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 +
1711 2216 private function get_current_object_id() {
1712 2217 // Singular pages (posts, pages, CPTs, attachments)
1713 2218 if (is_singular()) {
1714 2219 return (int) get_the_ID();
@@ -1726,8 +2231,346 @@
1726 2231 return (int) $id;
1727 2232 }
1728 2233
1729 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');
1730 2573 }
1731 2574
1732 2575 /**
1733 2576 * Return the WP_Term being rendered on taxonomy archive pages.